Compare commits

...
Author SHA1 Message Date
l0ng-ai 953b51b8c6 fix(debugger): show JVM values as {Type@id} summary and map entries as key -> value
The Variables view showed the Java debugger's text under its type: args = {String[]} String[0]@9, map = {HashMap} HashMap@10 size=1, and a map entry as 0 = {HashMap$Node} HashMap$Node@31 "a":"b". A JVM object now reads {String[0]@9} [] and {HashMap@10} size = 1, a map entry as "a" -> "b" without its index, and a String without its type. Inline values and the value tooltip use the same text (args: [], map: size = 1). Values from other debuggers keep their text and type.
2026-11-13 19:23:54 +08:00
l0ng-ai 70b953c77a feat(deployment): server groups for uploading to several servers at once (#3149)
Deployment settings can hold server groups (+ › Server Group): a name and a set of the configured servers. Upload to… lists a group whenever one of its servers covers the selection and uploads to each of its servers in turn. Each server reports its result or error in its own notification, and a failure on one server doesn't stop the rest. Removing a server drops it from its groups. Upload to Default Server, download, sync and delete keep working on single servers.
2026-11-13 19:18:33 +08:00
l0ng-ai cb18b88531 test(i18n): allow "Python {short}" to read the same in every locale (#3147)
The conda Python version picker labels its entries "Python 3.12", which every locale writes like English.
2026-11-13 19:12:07 +08:00
Mr0Bread 401d7a4105 fix(database): Tab goes from a result tab to the grid's first cell (#3130)
Tab on a database result tab stepped through every toolbar control (7 to 11 stops) before reaching the grid, and the grid then had no active cell. Now:
- a new WAI-ARIA Toolbar (src/ui/Toolbar.tsx) makes the result, plan, Output and pager toolbars one Tab stop with roving focus on arrows/Home/End;
- Tabs gets an optional focusContent prop, called on plain Tab, so Tab from a result tab focuses the grid (or plan tree / Output log);
- the grid selects its first cell when focused from the keyboard, Tab / Shift+Tab move between cells row by row and leave the grid past either end, and Tab in the cell editor commits and moves on.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:47 +08:00
Mr0Bread 46bd1f6fb8 fix(ui): accent the selected tab only in the part that has focus (#3124)
The editor, tool windows and database results painted their selected tab with the same accent, so it wasn't clear which part had focus. A focus-scope tracker now marks the editor pane, each tool window and the results view that contain the focused element; outside the focused scope the selected tab uses the existing --bg-tab-selected-inactive / --border-tab-selected-inactive tokens. Scopes are opt-in, so tab strips in dialogs and popups stay accented, and focus moving into a popup, menu or dialog keeps the current marks. IntelliJ theme import maps the inactive underlined tab colours to the same tokens.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:26 +08:00
Mr0Bread 85f755591b feat(database): Expand All in the Database tool window header (#3140)
The Database tool window header had Collapse All but no Expand All. Add Expand All (db.expandAll) next to it, also reachable from Find Action. It opens every node whose children are already loaded and never connects to a data source or runs a query, so unconnected sources and tables without loaded columns stay closed.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:14 +08:00
Shohruh Ismoilovandshohruh-genius 06b7513210 fix(vcs): keep Jump to Source current in focused diffs (#3137)
Pressing Cmd+Down or F4 in a focused revision diff did nothing when the working file was found after focus entered the pane: the keyboard dispatcher kept the navigator object registered before that lookup, while the diff replaced it. The diff now keeps the registered navigator and refreshes its handlers in place, so Jump to Source opens the working file at the mapped caret.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-13 19:04:56 +08:00
l0ng-ai 09aa03bb86 fix(debugger): keep the frames | variables split, put the views after the toolbar, badge live sessions (#3144)
The divider between frames and variables is now remembered across debug starts; the Debug tool window's toolbar leads with the session actions, followed by the Threads & Variables / Console / Memory tabs; and the Debug stripe button shows how many debug sessions are live, as Run does.

Refs #3112
2026-11-13 18:58:27 +08:00
l0ng-ai 317988cfa7 fix(python): refresh the interpreter page after recreating an environment, pick a conda env's Python version (#3143)
Creating an environment at the path the project interpreter already pointed to left the Python Interpreter page saying it does not exist: the package list and the validity check reloaded only when the path changed. Both now reload after an environment is created. A new conda environment now asks for a Python version instead of a base interpreter chosen among existing environments, which read as copying one; the note says it starts with only Python.

Refs #3109
2026-11-13 18:58:22 +08:00
l0ng-ai 7db2e98269 fix(project-view): offer Git and Find Usages in workspace members outside the workspace folder (#3142)
The Project view's context menu counted a node as part of the project only inside the window's root, which in a workspace is the workspace folder. Members outside it lost the Git submenu and Find Usages, although their repositories are discovered and their changes listed. Those entries now accept nodes inside the root or any workspace member; Mark Directory as stays limited to the root, whose project structure it edits, and Scratches stay outside.

Fixes #3111
2026-11-13 18:55:42 +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
103 changed files with 3516 additions and 407 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();
+13
View File
@@ -77,6 +77,8 @@ pub enum AutoUpload {
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentSettings {
pub servers: Vec<DeploymentServer>,
/// Server groups: Upload to… on a group uploads to each of its servers.
pub groups: Vec<DeploymentGroup>,
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
pub default_server: String,
pub auto_upload: AutoUpload,
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
fn default() -> Self {
Self {
servers: Vec::new(),
groups: Vec::new(),
default_server: String::new(),
auto_upload: AutoUpload::Never,
delete_remote: false,
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
}
}
/// Named set of deployment servers that files are uploaded to together.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct DeploymentGroup {
pub id: String,
pub name: String,
/// [`DeploymentServer::id`]s, in upload order.
pub servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DeploymentType {
+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() {
+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**.
+3
View File
@@ -204,6 +204,8 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -268,6 +270,7 @@ by hand:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+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 方案分别使用各自默认的编辑器标签页导航快捷键。切换后焦点移到目标结果标签页。结果导航快捷键仅在结果区域中生效,菜单或对话框打开时不会切换结果。可在 **设置 › 快捷键** 中单独修改数据库结果快捷键,不影响编辑器快捷键。
+3
View File
@@ -177,6 +177,8 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -239,6 +241,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+186
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⇧⌘[",
@@ -172,6 +174,8 @@ for (const [theme, label] of [
const surface = target.locator("..");
const other = tab(page, "Result 3");
const otherSurface = other.locator("..");
// The results hold focus, as they do at the end when the menu is opened from a result tab.
await tab(page, "Result 2").focus();
await page.mouse.move(0, 0);
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
const selectedColor = await tab(page, "Result 2")
@@ -225,6 +229,43 @@ for (const [theme, label] of [
});
}
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await seedResults(page);
const token = (name: string) =>
page.evaluate((name) => {
const probe = document.body.appendChild(document.createElement("div"));
probe.style.background = `var(${name})`;
const color = getComputedStyle(probe).backgroundColor;
probe.remove();
return color;
}, name);
const accent = await token("--bg-tab-selected");
const inactive = await token("--bg-tab-selected-inactive");
expect(inactive).not.toBe(accent);
const surfaces = [
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
page.getByRole("tab", { name: "Database", exact: true }),
tab(page, "Result 2"),
].map((tab) => tab.locator(".."));
const colors = () =>
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
await expect.poll(colors).toEqual([inactive, accent, accent]);
// A menu opened from a result tab keeps the results active.
await tab(page, "Result 2").click({ button: "right" });
await expect(menu(page)).toBeVisible();
await expect.poll(colors).toEqual([inactive, accent, accent]);
await page.keyboard.press("Escape");
await expect(menu(page)).toHaveCount(0);
await page.locator(".cm-content").first().click();
await expect.poll(colors).toEqual([accent, inactive, inactive]);
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
});
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
workbench: page,
}) => {
@@ -413,3 +454,148 @@ test("closes clicked results, other results and all results without removing Out
await expect(page.getByRole("treeitem", { name: "Alpha database", exact: true })).toBeVisible();
await expect(page.getByRole("treeitem", { name: "Beta database", exact: true })).toBeVisible();
});
/** Runs `text` in console `path` through the production store, with the backend's answers stubbed. */
async function runConsole(page: Page, path: string, text: string) {
await page.evaluate(
async ({ path, text }) => {
const bindingsPath = "/src/bindings.ts";
const storePath = "/src/features/database/store.ts";
const { commands } = await import(bindingsPath);
const { executeConsole } = await import(storePath);
commands.dbStatements = async (_id: string, sql: string) => {
let from = 0;
const data = sql.split(";").flatMap((part) => {
const start = from + part.length - part.trimStart().length;
from += part.length + 1;
const st = part.trim();
return st ? [{ from: start, to: start + st.length, text: st }] : [];
});
return { status: "ok", data };
};
commands.dbExecute = async (_r: unknown, _id: string, statements: { text: string }[]) => ({
status: "ok",
data: statements.map((statement) => ({
statement,
error: null,
result: {
statement: statement.text,
columns: [{ name: "payload", typeName: "text" }],
rows: [[/'(.*)'/.exec(statement.text)![1]]],
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
})),
});
await executeConsole(path, text, 0, text.length);
},
{ path, text },
);
}
test("console runs replace their unpinned results; pinned results stay until Close All but Pinned", async ({
workbench: page,
}) => {
await seedResults(page);
const strip = page.getByRole("tablist", { name: "Results", exact: true }).getByRole("tab");
await choose(page, "Result 1", "Close All");
// The seeded Output line's id would collide with the ids of the lines the runs log.
await page.evaluate(async () => {
const storePath = "/src/features/database/store.ts";
const { useDatabase } = await import(storePath);
useDatabase.setState({ output: [] });
});
await runConsole(page, "alpha-console.sql", "SELECT 'one'");
const n = Number(/\d+/.exec((await strip.nth(1).textContent())!)![0]);
const result = (run: number, index = 1) => `Result ${n + run}${index > 1 ? `-${index}` : ""}`;
await runConsole(page, "alpha-console.sql", "SELECT 'two'");
await expect(strip).toHaveText(["Output", result(1)]);
await selected(page, result(1), "two");
const pin = page.getByRole("button", { name: "Pin Tab", exact: true });
await pin.click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await runConsole(page, "alpha-console.sql", "SELECT 'three'; SELECT 'four'");
await runConsole(page, "beta-console.sql", "SELECT 'beta'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(2, 2), result(3)]);
await selected(page, result(3), "beta");
await tab(page, result(2)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeEnabled();
await menu(page).getByRole("menuitem", { name: "Pin Tab", exact: true }).click();
await expect(menu(page)).toHaveCount(0);
await runConsole(page, "alpha-console.sql", "SELECT 'five'");
await expect(strip).toHaveText(["Output", result(1), result(2), result(4), result(3)]);
await selected(page, result(4), "five");
await expect(pin).toHaveAttribute("aria-pressed", "false");
await tab(page, result(2)).click();
await expect(pin).toHaveAttribute("aria-pressed", "true");
await choose(page, result(3), "Close All but Pinned");
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await selected(page, result(2), "three");
await tab(page, result(1)).click({ button: "right" });
await expect(menu(page).getByRole("menuitem", { name: "Unpin Tab", exact: true })).toBeVisible();
await expect(menu(page).getByRole("menuitem", { name: "Close All but Pinned", exact: true })).toBeDisabled();
await page.keyboard.press("Escape");
// A pinned tab's close button is its Unpin button; middle-click leaves it open.
const slot = tab(page, result(1)).locator("..");
await expect(slot.getByRole("button", { name: "Close Tab", exact: true })).toHaveCount(0);
await tab(page, result(1)).click({ button: "middle" });
await expect(strip).toHaveText(["Output", result(1), result(2)]);
await slot.getByRole("button", { name: "Unpin Tab", exact: true }).click();
await selected(page, result(2), "three");
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strip).toHaveText(["Output", result(2)]);
});
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
workbench: page,
}) => {
await openFromTree(page, "README.md");
await seedResults(page);
const grid = page.getByRole("grid");
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
await expect(cell).toHaveAttribute("aria-selected", "false");
await tab(page, "Result 2").focus();
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await expect(cell).toHaveAttribute("aria-selected", "true");
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(tab(page, "Result 2")).toBeFocused();
// The toolbar is entered at the control used last.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Tab");
await expect(grid).not.toBeFocused();
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
// The keyboard path to the header's local filter is the Local Filter action.
await grid.focus();
await page.evaluate(async () => {
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("db.localFilter");
});
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(filter).toBeHidden();
await expect(grid).toBeFocused();
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const path = "/dev/acme-api/README.md";
const probe = new Promise((resolve) => {
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
});
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
const keymap = "/src/platform/keymap.ts";
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
const actions = "/src/platform/actions.ts";
await (
await import(/* @vite-ignore */ actions)
).executeAction("diff.compareTexts", {
path,
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
});
});
const pane = page.locator(".cm-merge-b .cm-content");
await pane.click();
await expect(pane).toBeFocused();
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
await expect(jump).toHaveCount(0);
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
await expect(jump).toBeVisible();
await expect(pane).toBeFocused();
await page.keyboard.press("Meta+ArrowDown");
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
await expect(pane).toBeHidden();
});
Binary file not shown.
@@ -8,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),
+3
View File
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -262,6 +264,7 @@ from `type`, then map:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+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())));
+10
View File
@@ -4090,6 +4090,14 @@ export type DeploymentCoverage = {
skipped: TransferError[],
};
/** Named set of deployment servers that files are uploaded to together. */
export type DeploymentGroup = {
id?: string,
name?: string,
/** [`DeploymentServer::id`]s, in upload order. */
servers?: string[],
};
/** Local files to compare with a deployment server (Sync with Deployed). */
export type DeploymentLaunch = {
/** Deployment server id. */
@@ -4133,6 +4141,8 @@ export type DeploymentServer = {
/** Build, Execution, Deployment › Deployment (and its Options page). */
export type DeploymentSettings = {
servers?: DeploymentServer[],
/** Server groups: Upload to… on a group uploads to each of its servers. */
groups?: DeploymentGroup[],
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
defaultServer?: string,
autoUpload?: AutoUpload,
+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 });
+1
View File
@@ -367,6 +367,7 @@ function PaneView({ paneId }: { paneId: string }) {
tabPrefs.placement === "right" && styles.tabsRight,
)}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
onFocus={onFocus}
onBlur={onBlur}
+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",
@@ -107,3 +107,29 @@ describe("routine grouping", () => {
expect(tree.queryByText("functions")).toBeNull();
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
treeExpanded: new Set(),
});
render(<DatabaseView />);
await act(() => executeAction("db.expandAll"));
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
"pg",
"pg/public",
"pg/public/routines",
"pg/public/tables",
]);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
expect(tree.getByText("refresh_data")).toBeInTheDocument();
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
+37 -11
View File
@@ -58,7 +58,7 @@ import {
useDatabase,
type Loaded,
} from "./store";
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -366,6 +366,34 @@ export function DatabaseView() {
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
const expandable = (n: Node) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group";
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
useEffect(() => {
databaseTree.expandAll = () => {
const next = new Set(useDatabase.getState().treeExpanded);
const walk = (nodes: readonly Node[]) => {
for (const n of nodes) {
const kids = expandable(n) ? children(n) : undefined;
if (!kids) continue;
next.add(n.key);
walk(kids);
}
};
walk(roots);
useDatabase.setState({ treeExpanded: next });
};
return () => {
databaseTree.expandAll = null;
};
});
const label = (n: Node): string => {
switch (n.kind) {
case "source":
@@ -509,14 +537,7 @@ export function DatabaseView() {
getKey={(n) => n.key}
getLabel={label}
getChildren={children}
isExpandable={(n) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group"
}
isExpandable={expandable}
loadChildren={load}
getIcon={(n) => icon(n)}
getDetail={detail}
@@ -662,7 +683,12 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Collapse All. */
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
return (
<>
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
);
}
+59 -3
View File
@@ -12,6 +12,7 @@ import {
useRef,
useState,
type ClipboardEvent,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
} from "react";
@@ -392,6 +393,7 @@ export function Grid(props: GridProps) {
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
canSetNull: () => canEdit() && nullOk(norm(sel!)),
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
canFilter: () => !!props.onFilter && !!sel && !editing,
toggle: () => {
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
},
@@ -401,6 +403,12 @@ export function Grid(props: GridProps) {
setDefault: () => {
if (actions.canSetDefault()) setDefaults(norm(sel!));
},
filter: () => {
if (!actions.canFilter()) return;
scrollColumnIntoView(sel!.c1);
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
if (anchor) props.onFilter!(sel!.c1, anchor);
},
};
Object.assign((cellActions.current ??= {} as CellActions), actions);
});
@@ -430,6 +438,14 @@ export function Grid(props: GridProps) {
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
return;
}
if (e.key === "Tab") {
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
if (!next) return;
e.preventDefault();
moveTo(next[0], next[1], false);
return;
}
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
@@ -439,13 +455,32 @@ export function Grid(props: GridProps) {
const m = moves[e.key];
if (!m) return;
e.preventDefault();
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
moveTo(
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
e.shiftKey,
);
};
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
};
const moveTo = (r: number, c: number, extend: boolean) => {
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
const pointerFocus = useRef(false);
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
if (rowCount && columns.length) moveTo(0, 0, false);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
if (e.button !== 0) {
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
@@ -512,6 +547,9 @@ export function Grid(props: GridProps) {
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
);
}
if (props.onFilter) {
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
}
const extra = rowMenu?.(selectedRows()) ?? [];
if (extra.length) items.push({ kind: "separator" }, ...extra);
showContextMenu(e, items);
@@ -546,6 +584,11 @@ export function Grid(props: GridProps) {
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onFocus={onFocus}
onMouseDownCapture={() => {
pointerFocus.current = true;
setTimeout(() => (pointerFocus.current = false));
}}
onPaste={onPaste}
onContextMenu={onContextMenu}
>
@@ -615,6 +658,10 @@ export function Grid(props: GridProps) {
{props.onFilter && (
<button
type="button"
data-column-filter=""
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
// The keyboard opens it with the Local Filter action.
tabIndex={-1}
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
aria-label={`Filter ${c.name}`}
title={tx("Local filter")}
@@ -709,6 +756,13 @@ export function Grid(props: GridProps) {
return;
}
if (e.key === "Enter") commit();
if (e.key === "Tab") {
// Like IDEA's table editor: Tab commits and selects the next cell.
e.preventDefault();
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
commit();
if (next) moveTo(next[0], next[1], false);
}
if (e.key === "Escape") {
skipBlurCommit.current = true;
setEditing(null);
@@ -726,6 +780,7 @@ export function Grid(props: GridProps) {
<div
key={c}
role="gridcell"
aria-selected={selected(r, c)}
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
@@ -751,6 +806,7 @@ export function Grid(props: GridProps) {
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox
tabIndex={-1}
className={styles.cellCheckbox}
aria-label={tx`Toggle ${columns[c]!.name}`}
checked={val === lit.t}
+64 -35
View File
@@ -21,8 +21,10 @@ import {
showContextMenu,
T,
tooltipAttrs,
Toolbar,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
import { ChartView } from "./ChartView";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
@@ -52,6 +54,7 @@ import {
log,
outcomeSummary,
pageable,
pinResult,
rerunResult,
targetLabel,
useDatabase,
@@ -78,10 +81,10 @@ function Output() {
}, [lines.length]);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Output actions">
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</div>
</Toolbar>
<div
ref={ref}
className={styles.output}
@@ -146,12 +149,7 @@ function Pager({ tab }: { tab: ResultTab }) {
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
return (
<div
className={styles.resultPager}
role="toolbar"
aria-label={tx("Result pages")}
title={outcomeSummary(tab.outcome)}
>
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
<IconButton
icon="page-first"
title="First Page"
@@ -196,7 +194,7 @@ function Pager({ tab }: { tab: ResultTab }) {
onChange={(v) => go(0, Number(v))}
disabled={!canPage}
/>
</div>
</Toolbar>
);
}
@@ -431,11 +429,12 @@ 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")} />
<Toolbar className={styles.toolbar} aria-label="Result actions">
<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"
@@ -484,7 +483,7 @@ function Result({ tab }: { tab: ResultTab }) {
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
/>
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
</div>
</Toolbar>
{view === "chart" ? (
<div className={styles.fill}>
<ChartView columns={columns} rows={r.rows} />
@@ -592,14 +591,15 @@ function PlanResult({ tab }: { tab: ResultTab }) {
const selectedKey = selected < 0 ? statement.key : String(selected);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
<span className={styles.spacer} />
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</div>
</Toolbar>
<div className={clsx(styles.fill, styles.planView)}>
<div className={styles.planTree}>
<div className={styles.planHeader}>
@@ -661,11 +661,25 @@ function PlanResult({ tab }: { tab: ResultTab }) {
);
}
/** IDEA's Pin Tab: the console's next runs leave this result alone. */
function PinButton({ tab }: { tab: ResultTab }) {
return (
<IconButton icon="pin" title="Pin Tab" aria-pressed={!!tab.pinned} onClick={() => pinResult(tab.id, !tab.pinned)} />
);
}
export function ResultsView() {
const results = useDatabase((s) => s.results);
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
const root = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(null);
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
const focusContent = () => {
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
target?.focus();
return !!target && document.activeElement === target;
};
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
@@ -693,6 +707,7 @@ export function ResultsView() {
<div
ref={root}
className={styles.view}
data-focus-scope=""
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -706,27 +721,41 @@ 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,
})),
]}
/>
<div className={styles.fill}>
<ToolWindowContentTabs>
<Tabs
className={styles.resultTabs}
variant="compact"
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
focusContent={focusContent}
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
const r = results.find((r) => r.id === Number(id));
if (r) pinResult(r.id, !r.pinned);
}}
onContextMenu={tabMenu}
items={[
{ id: String(OUTPUT_TAB), label: "Output", icon: "db-console", closable: false },
...results.map((r) => ({
id: String(r.id),
label: r.title,
icon: r.plan ? "db-plan" : "database",
closable: true,
pinned: r.pinned,
tooltip: r.outcome.statement.text,
})),
]}
/>
</ToolWindowContentTabs>
<div
ref={panel}
className={styles.fill}
role="tabpanel"
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
</div>
+11
View File
@@ -5,9 +5,12 @@ export interface CellActions {
canToggle(): boolean;
canSetNull(): boolean;
canSetDefault(): boolean;
canFilter(): boolean;
toggle(): void;
setNull(): void;
setDefault(): void;
/** Opens the local filter of the selected cell's column. */
filter(): void;
}
const grids = new WeakMap<Element, CellActions>();
@@ -59,6 +62,14 @@ export function installCellActions(): () => void {
enabled: (c) => handle(c)?.canSetDefault() ?? false,
run: (c) => handle(c)?.setDefault(),
},
{
id: "db.localFilter",
title: "Local Filter",
category: "Database",
keyWhen: "dbCellGridFocus",
enabled: (c) => handle(c)?.canFilter() ?? false,
run: (c) => handle(c)?.filter(),
},
]);
return () => {
dispose();
+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 },
+219 -1
View File
@@ -1,11 +1,12 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { installCellActions } from "./cellActions";
import { getContext } from "@/platform/context";
import { ResultsView } from "./ResultsView";
import { executeConsole, 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 +17,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 +30,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
dbApplyEdits: backend.applyEdits,
dbExecute: backend.execute,
dbStatements: backend.statements,
dbExplain: backend.explain,
},
};
});
@@ -430,6 +433,75 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
});
});
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
const selectedCells = () =>
screen
.getAllByRole("gridcell")
.filter((cell) => cell.getAttribute("aria-selected") === "true")
.map((cell) => cell.textContent);
const rows = [
["1", "Ann"],
["2", "Bob"],
];
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
await screen.findByRole("button", { name: "Add Row" });
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
await user.tab();
expect(screen.getByRole("grid")).toHaveFocus();
expect(selectedCells()).toEqual(["1"]);
await user.tab();
expect(selectedCells()).toEqual(["Ann"]);
await user.tab();
expect(selectedCells()).toEqual(["2"]);
await user.tab({ shift: true });
await user.tab({ shift: true });
expect(selectedCells()).toEqual(["1"]);
await user.tab({ shift: true });
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
document.activeElement as HTMLElement,
);
await user.tab({ shift: true });
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
});
it("leaves the grid after the last cell", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
await user.tab();
expect(screen.getByRole("grid")).not.toHaveFocus();
expect(selectedCells()).toEqual(["Bob"]);
});
it("commits the cell editor with Tab and selects the next cell", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Cara" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
expect(selectedCells()).toEqual(["2"]);
expect(screen.getByRole("grid")).toHaveFocus();
});
it("goes from the Output tab to the log, past its toolbar", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
act(() => screen.getByRole("tab", { name: "Output" }).focus());
await user.tab();
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
});
});
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
@@ -454,3 +526,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 });
}
+10
View File
@@ -44,6 +44,10 @@ import { tx } from "@/platform/i18n";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
const OPEN = "projectOpen";
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
@@ -481,6 +485,12 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.expandAll",
title: "Expand All",
icon: "expand-all",
run: () => databaseTree.expandAll?.(),
}),
tree({
id: "db.collapseAll",
title: "Collapse All",
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
expect(await screen.findByText("Collecting data…")).toBeTruthy();
adapterData.library = "com.example.library.Library@1";
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
const value = await screen.findByText("com.example.library.Library@1");
const value = await screen.findByText("{Library@1}");
expect(screen.queryByText("Collecting data…")).toBeNull();
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
});
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
await screen.findByText("Collecting data…");
adapterData.library = "Library@2";
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
expect(await screen.findByText("Library@2")).toBeTruthy();
expect(await screen.findByText("{Library@2}")).toBeTruthy();
});
it("does nothing for an invalidation while the program runs", async () => {
+23 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { selectionExpression } from "../editor/valueTooltip";
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
import { inlineValuesFor } from "../editor/execution";
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
import type { StackFrame } from "@/platform/ipc";
@@ -150,3 +150,25 @@ describe("selectionExpression", () => {
expect(selectionExpression(multi)).toBeNull();
});
});
describe("shown values", () => {
it.each([
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
["Db@21", "Db", { label: "{Db@21}", text: "" }],
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
['"hello"', "String", { label: null, text: '"hello"' }],
["42", "int", { label: "{int}", text: "42" }],
// Other debuggers' values keep their text and type.
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
["0x1000", null, { label: null, text: "0x1000" }],
])("%s {%s}", (value, type, shown) => {
expect(shownValue(value, type)).toEqual(shown);
});
});
+5 -3
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { activeSession, useExecutionPoint } from "../state";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { tx } from "@/platform/i18n";
import { identifiersIn, inlineValue } from "../values";
import { identifiersIn, inlineValue, shownValue } from "../values";
import { valueHint } from "./valueTooltip";
import styles from "./editor.module.css";
@@ -113,11 +113,13 @@ export function inlineValuesFor(
evaluateName: id,
})
: raw;
const shown = shownValue(variable.value, variable.typeName);
const value = shown.text || shown.label || variable.value;
parts.push({
name: id,
text: `${id}: ${inlineValue(variable.value, 40)}`,
text: `${id}: ${inlineValue(value, 40)}`,
variable,
truncated: variable.value.length > 40,
truncated: value.length > 40,
});
if (parts.length >= MAX_PER_LINE) break;
}
+14 -8
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { workflowSettings } from "@/platform/workflowSettings";
import { navigationModifierHeld } from "@/platform/navigationModifier";
import { activeSession, evaluate } from "../state";
import { inlineValue } from "../values";
import { inlineValue, shownValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup, selectableText } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
@@ -70,13 +70,19 @@ export function valueHint(
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
const shown = shownValue(value.value, value.typeName);
// A JVM object's `{Type@id}` leads its value; other types follow it.
const leading = shown.label?.includes("@") ?? false;
const type = (text: string) => {
const span = document.createElement("span");
span.className = styles.valueType!;
span.textContent = text;
return span;
};
dom.append(name);
if (leading) dom.append(type(`${shown.label} `));
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
return { dom, destroy: () => tree?.unmount() };
}
+6
View File
@@ -48,6 +48,11 @@ import { forgetMemory } from "./memory/memoryState";
const useAnyOverlay = () =>
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
/** The Debug stripe button's badge, like Run's: how many debug sessions (child sessions aside) are live. */
function useLiveSessionCount(): number | null {
return useDebug((s) => Object.values(s.sessions).filter((x) => x.parent === null && isLive(x)).length || null);
}
export function activate(): () => void {
useDebugRunSessions.setState({
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
@@ -71,6 +76,7 @@ export function activate(): () => void {
order: 20,
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
useBadge: useLiveSessionCount,
}),
registerEditorKind(heapEditorKind),
installHeapContext(),
+30 -25
View File
@@ -176,30 +176,7 @@ export function DebugToolWindow() {
return (
<div className={styles.root}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
<span className={styles.sep} aria-hidden />
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
<ToolButton action="debug.rerun" icon="rerun" />
<ToolButton action="debug.stop" icon="stop" tone="danger" />
<span className={styles.sep} aria-hidden />
@@ -233,6 +210,29 @@ export function DebugToolWindow() {
onClick={() => setMuted(!muted)}
/>
<MoreActions />
<span className={styles.sep} aria-hidden />
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
</div>
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
{tab === "variables" ? (
@@ -251,7 +251,12 @@ export function DebugToolWindow() {
function ThreadsAndVariables({ session }: { session: DebugSession }) {
return (
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
<SplitGroup
orientation="horizontal"
id="debug-threads-variables"
storageKey="openidea.debug.threadsVariables"
className={styles.split}
>
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
<FramesPane session={session} />
</SplitPanel>
+12 -7
View File
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
import { methodAt } from "../breakpoints/symbols";
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
import { blurDebugView, focusDebugView } from "./focusedView";
import { classifyValue, oneLine, sortVariables } from "../values";
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
import { valueText } from "../valueText";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./VariablesView.module.css";
@@ -486,20 +486,25 @@ function VarRow({
</span>
);
const kind = node.error ? null : classifyValue(node.value, node.typeName);
const shown = shownValue(node.value, node.typeName);
return (
<span className={styles.row}>
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
{!shown.entry && (
<>
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
</>
)}
{node.error ? (
<span className={styles.error}>{oneLine(node.error)}</span>
) : (
<>
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
{shown.label && <span className={styles.type}>{shown.label}</span>}
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
{oneLine(node.value)}
{oneLine(shown.text)}
</span>
{overflow && (
<button
+32
View File
@@ -69,3 +69,35 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
}
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
export interface ShownValue {
label: string | null;
text: string;
/** A map entry: the row shows `key -> value` alone, without its index. */
entry?: true;
}
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
/**
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
* a type. Values in other forms keep the adapter's text under `{type}`.
*/
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
if (!typeName) return plain;
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
const object = JVM_OBJECT.exec(value);
if (!object) return plain;
const [, head, id, rest = ""] = object;
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
if (simple(head!) !== simple(typeName)) return plain;
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
}
+31 -1
View File
@@ -5,6 +5,7 @@ import type { SyncEntry } from "@/platform/ipc";
const mocks = vi.hoisted(() => ({
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
servers: [] as DeploymentServerValid[],
groups: [] as { id: string; name: string; servers: string[] }[],
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
choose: vi.fn(),
notify: vi.fn(),
@@ -37,7 +38,7 @@ vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/project" } }) },
}));
vi.mock("@/platform/workflowSettings", () => ({
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
}));
vi.mock("@/workbench/contributions", () => ({
notifications: () => ({ notify: mocks.notify }),
@@ -72,6 +73,7 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
beforeEach(() => {
vi.clearAllMocks();
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
mocks.groups = [];
mocks.coverage = [
{
server: "one",
@@ -91,6 +93,34 @@ beforeEach(() => {
useDeployment.setState({ sync: null });
registerDeploymentActions();
});
describe("server groups", () => {
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
mocks.groups = [
{ id: "g", name: "Production", servers: ["one", "two"] },
{ id: "none", name: "Elsewhere", servers: ["two"] },
];
mocks.coverage[1]!.paths = [];
mocks.choose.mockResolvedValue("Production (group)");
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
await run("deployment.uploadTo");
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
await vi.waitFor(() =>
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
),
);
expect(mocks.upload).toHaveBeenCalledTimes(1);
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
});
it("uploads to every covered server of the group in order", async () => {
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
mocks.coverage[1]!.paths = ["/project/b"];
mocks.choose.mockResolvedValue("Production (group)");
await run("deployment.uploadTo");
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
});
});
describe("deployment eligibility", () => {
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
mocks.servers = [mocks.servers[0]!];
+35 -6
View File
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
: "No deployment server covers the selected paths",
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
group: GROUP,
key: "deployment.noCoverage",
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
});
}
/** Keep the chooser visible even when only one server covers the selection. */
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
/** The servers covering `paths`, or null (after saying why) when none does. */
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
const configured = deployment().servers;
if (!configured.length) {
notifyNoServers();
@@ -119,11 +119,36 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
}
return null;
}
return servers;
}
/** Keep the chooser visible even when only one server covers the selection. */
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
const servers = await coveringServers(paths);
if (!servers) return null;
const labels = servers.map(serverLabel);
const picked = await chooseItem({ title, items: labels });
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
}
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
const servers = await coveringServers(paths);
if (!servers) return null;
const { servers: configured, groups } = deployment();
const choices = new Map<string, DeploymentServerValid[]>();
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
for (const group of groups) {
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
if (!members.some((m) => servers.includes(m))) continue;
const name = group.name.trim() || group.id;
const label = tx`${name} (group)`;
if (!choices.has(label)) choices.set(label, members);
}
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
return picked === null ? null : (choices.get(picked) ?? null);
}
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
return resolveServer(deployment(), null) ?? pickServer(title, paths);
}
@@ -160,7 +185,8 @@ export function showReport(
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
group: GROUP,
key: `deployment.${verb}`,
// One per server, so each server of a group reports on its own.
key: `deployment.${verb}.${server.id}`,
});
}
@@ -171,7 +197,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
title: title.replace("{server}", serverLabel(server)),
body: errorText(e),
group: GROUP,
key: "deployment.failure",
key: `deployment.failure.${server.id}`,
});
}
@@ -451,7 +477,10 @@ export function registerDeploymentActions(): () => void {
category: CATEGORY,
when,
keywords: ["deploy", "sftp", "upload", "remote"],
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
run: withTargets(async (paths) => {
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
}),
},
{
id: "deployment.downloadRemoteInto",
+6
View File
@@ -55,6 +55,12 @@ export const UI_KEYS: Record<string, string[]> = {
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
// The selected tab of a part without focus (src/ui/focusScope.ts).
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
"--border-tab-selected-inactive": [
"EditorTabs.inactiveUnderlinedTabBorderColor",
"EditorTabs.inactiveUnderlineColor",
],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
+11
View File
@@ -12,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} />}
@@ -301,6 +301,32 @@ it("offers workspace removal for every member root", () => {
);
});
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
useProject.setState({
project: {
root: "/work/workspaces/demo",
name: "demo",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
},
});
setContext("vcsAvailable", true);
try {
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
// Project structure is the workspace folder's: its roots can't name a folder outside it.
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
// Scratches stay outside.
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
} finally {
clearContext("vcsAvailable");
}
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
+5 -2
View File
@@ -151,6 +151,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
// Workspace members may live outside the workspace folder; they have VCS and usages too.
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
@@ -228,7 +231,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.open"),
run: () => files.forEach((f) => void openFile(f.path)),
},
inProject &&
inWorkspace &&
singleFile && {
id: "findUsages",
label: "Find Usages",
@@ -338,7 +341,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
);
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
@@ -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);
}}
/>
@@ -194,3 +194,13 @@
padding: 16px;
color: var(--fg-subtle);
}
.groupMembers {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 0;
}
.groupTitle {
color: var(--fg-subtle);
padding-bottom: 2px;
}
+121 -12
View File
@@ -13,10 +13,24 @@ import {
deploymentConnectable,
deploymentConnection,
deploymentSecretKey,
type DeploymentGroupValid,
type DeploymentServerValid,
type FtpConnectionValid,
} from "@/platform/remoteSettings";
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
import {
Badge,
Button,
Checkbox,
Dialog,
Icon,
IconButton,
Input,
Select,
showContextMenu,
Spinner,
T,
Tabs,
} from "@/ui";
import { GlobListEditor, Row } from "../components/Form";
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
import { useSetting } from "../components/context";
@@ -25,10 +39,13 @@ import {
fixDefaultServer,
folderWithin,
formatExcludes,
newGroup,
newServer,
parseExcludes,
pruneGroups,
remoteParent,
removeMapping,
toggleGroupServer,
updateMapping,
} from "./deploymentModel";
import { choosePath } from "../languages/api";
@@ -611,44 +628,118 @@ function ExcludedPathsTab({
type ServerTab = "connection" | "mappings" | "excluded";
function GroupPanel({
group,
servers,
update,
}: {
group: DeploymentGroupValid;
servers: readonly DeploymentServerValid[];
update: (patch: Partial<DeploymentGroupValid>) => void;
}) {
const tx = useTx();
return (
<div className={styles.panel}>
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
<div className={styles.groupTitle}>
<T>Servers in group</T>
</div>
{servers.length === 0 && (
<div className={styles.note}>
<T>No servers. Click + to add one.</T>
</div>
)}
{servers.map((s) => (
<Checkbox
key={s.id}
checked={group.servers.includes(s.id)}
onChange={() => update(toggleGroupServer(group, s.id, servers))}
label={<span>{s.name || s.id}</span>}
/>
))}
</div>
<div className={styles.note}>
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
</div>
</div>
);
}
type Selection = { kind: "server" | "group"; id: string } | null;
function DeploymentServersEditor() {
const tx = useTx();
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
const list = servers ?? [];
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
const groupList = groups ?? [];
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
const [tab, setTab] = useState<ServerTab>("connection");
const current = list.find((s) => s.id === selected) ?? null;
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
const commit = (next: DeploymentServerValid[]) => {
setServers(next);
const d = fixDefaultServer(next, defaultServer ?? "");
if (d !== (defaultServer ?? "")) setDefaultServer(d);
const pruned = pruneGroups(groupList, next);
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
};
const update = (patch: Partial<DeploymentServerValid>) => {
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
};
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
};
const add = (base?: DeploymentServerValid) => {
const server = newServer(list, base);
commit([...list, server]);
setSelected(server.id);
setSelected({ kind: "server", id: server.id });
setTab("connection");
};
const addGroup = () => {
const group = newGroup(groupList);
setGroups([...groupList, group]);
setSelected({ kind: "group", id: group.id });
};
const remove = () => {
if (currentGroup) {
const index = groupList.indexOf(currentGroup);
const rest = groupList.filter((g) => g.id !== currentGroup.id);
setGroups(rest);
const next = rest[Math.min(index, rest.length - 1)] ?? null;
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
return;
}
if (!current) return;
const index = list.indexOf(current);
const rest = list.filter((s) => s.id !== current.id);
commit(rest);
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
const next = rest[Math.min(index, rest.length - 1)] ?? null;
setSelected(next ? { kind: "server", id: next.id } : null);
};
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
return (
<div className={styles.layout}>
<div className={workflow.tableBox}>
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
<IconButton
icon="plus"
title="Add"
size="small"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu({ x: r.left, y: r.bottom }, [
{ label: "Server", icon: "web", run: () => add() },
{ label: "Server Group", icon: "folder", run: addGroup },
]);
}}
/>
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
<IconButton
icon="copy"
title="Copy"
@@ -665,18 +756,31 @@ function DeploymentServersEditor() {
/>
</div>
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
{list.length === 0 && (
{list.length === 0 && groupList.length === 0 && (
<div className={styles.empty}>
<T>No servers. Click + to add one.</T>
</div>
)}
{groupList.map((g) => (
<div
key={g.id}
role="option"
aria-selected={isSelected("group", g.id)}
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
>
<Icon name="folder" />
<span className={styles.serverName}>{g.name || g.id}</span>
<Badge>{g.servers.length}</Badge>
</div>
))}
{list.map((s) => (
<div
key={s.id}
role="option"
aria-selected={s.id === selected}
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
onMouseDown={() => setSelected(s.id)}
aria-selected={isSelected("server", s.id)}
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
>
<Icon name="web" />
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
@@ -687,7 +791,11 @@ function DeploymentServersEditor() {
))}
</div>
</div>
{current ? (
{currentGroup ? (
<div className={styles.detail}>
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
</div>
) : current ? (
<div className={styles.detail}>
<Tabs
variant="compact"
@@ -780,6 +888,7 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
"Passive mode",
"Test Connection",
"Use as Default",
"Server Group",
"Local path",
"Deployment path",
"Web path",
@@ -5,14 +5,40 @@ import {
fixDefaultServer,
folderWithin,
formatExcludes,
newGroup,
newServer,
nextServerName,
parseExcludes,
pruneGroups,
remoteParent,
removeMapping,
toggleGroupServer,
updateMapping,
} from "./deploymentModel";
describe("server groups", () => {
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
it("names a new group after the first free number", () => {
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
expect(g.name).toBe("Group 3");
expect(g.servers).toEqual([]);
expect(g.id).toMatch(/^group-/);
});
it("toggles members, keeping the server list's order", () => {
let g = { id: "g", name: "G", servers: [] as string[] };
g = toggleGroupServer(g, "c", servers);
g = toggleGroupServer(g, "a", servers);
expect(g.servers).toEqual(["a", "c"]);
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
});
it("drops removed servers from groups and keeps untouched groups as they are", () => {
const keep = { id: "k", name: "K", servers: ["a"] };
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
expect(pruned[0]).toBe(keep);
expect(pruned[1]!.servers).toEqual(["a"]);
});
});
describe("exclude names", () => {
it("parses a ;-separated list", () => {
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
@@ -2,6 +2,7 @@
import {
DEFAULT_DEPLOYMENT_SERVER,
newRemoteId,
type DeploymentGroupValid,
type DeploymentMappingValid,
type DeploymentServerValid,
} from "@/platform/remoteSettings";
@@ -67,6 +68,37 @@ export function newServer(
};
}
/** A new, empty group named "Group <n>" (the first free number). */
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
const names = new Set(groups.map((g) => g.name));
let n = groups.length + 1;
while (names.has(`Group ${n}`)) n++;
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
}
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
export function toggleGroupServer(
group: DeploymentGroupValid,
server: string,
servers: readonly DeploymentServerValid[],
): DeploymentGroupValid {
const members = new Set(group.servers);
if (members.has(server)) members.delete(server);
else members.add(server);
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
}
/** `groups` without members that are no longer in `servers`. */
export function pruneGroups(
groups: readonly DeploymentGroupValid[],
servers: readonly DeploymentServerValid[],
): DeploymentGroupValid[] {
const ids = new Set(servers.map((s) => s.id));
return groups.map((g) =>
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
);
}
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
@@ -294,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);
+3 -2
View File
@@ -1107,7 +1107,8 @@ export function DiffEditor({
viewer: viewerOf,
});
useEffect(() => {
nav.current = {
// Keep the registered navigator's identity while refreshing its handlers.
Object.assign(nav.current, {
next: () => step(1),
prev: () => step(-1),
jumpToSource,
@@ -1115,7 +1116,7 @@ export function DiffEditor({
file,
showSettings,
viewer: viewerOf,
};
});
});
useEffect(
+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>
);
}
@@ -560,6 +560,41 @@ describe("Jump to Source of a revision diff", () => {
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
});
it.each([{ key: "ArrowDown", metaKey: true }, { key: "F4" }])(
"jumps with $key when the working copy is found after the pane gains focus",
async (stroke) => {
setUp();
let finishProbe!: (result: unknown) => void;
probe.mockReturnValue(new Promise((resolve) => (finishProbe = resolve)));
const keymap = useKeymap.getState();
applyKeymapSettings("intellij-macos", {});
const unregister = registerCompareActions();
const uninstall = installKeyboardDispatcher(window);
const mounted = render(<DiffEditor input={input} />);
try {
const view = await pane(1);
view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 1 } });
view.focus();
expect(view.contentDOM).toBe(document.activeElement);
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
finishProbe({ status: "ok", data: { size: working.length, binary: false, tooLarge: false } });
await screen.findByRole("button", { name: "Jump to Source" });
expect(view.contentDOM).toBe(document.activeElement);
fireEvent.keyDown(view.contentDOM, { ...stroke, code: stroke.key });
await waitFor(() =>
expect(openFile).toHaveBeenCalledExactlyOnceWith("/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }),
);
} finally {
mounted.unmount();
setContext("diffEditorFocus", false);
uninstall();
unregister();
useKeymap.setState(keymap);
}
},
);
it("jumps on ⌘↓ in the IntelliJ macOS keymap only while a diff pane has focus", async () => {
setUp();
const keymap = useKeymap.getState();
+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;
+1
View File
@@ -264,6 +264,7 @@ describe("catalogs", () => {
"my-agent",
"Java",
"Python",
"Python {short}",
"C/C++",
"C#",
"Go",
+1
View File
@@ -404,6 +404,7 @@ export const intellijMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Meta+T"],
+1
View File
@@ -402,6 +402,7 @@ export const intellijWindows: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Ctrl+Shift+T"],
+1
View File
@@ -392,6 +392,7 @@ export const vscodeMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
"find.popup.nextResult": ["Meta+G"],
+29 -1
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",
@@ -988,6 +1011,7 @@
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
"Set DEFAULT": "Set DEFAULT",
"Toggle Boolean Cell": "Toggle Boolean Cell",
"Local Filter": "Local Filter",
"Toggle {name}": "Toggle {name}",
"true": "true",
"false": "false",
@@ -1482,5 +1506,9 @@
"Port": "Port",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
"Keep the token": "Keep the token",
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
"{name} (group)": "{name} (group)",
"Servers in group": "Servers in group",
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
"Server Group": "Server Group"
}
+1 -1
View File
@@ -753,7 +753,7 @@
"Editor › Font": "Editor › Fuente",
"Editor › General": "Editor › General",
"Effect": "Efecto",
"Effect color": "Color del efecto",
"Effect style": "Estilo de efecto",
"Effects": "Efectos",
"Element": "Elemento",
"Empty": "Vacío",
+1 -1
View File
@@ -691,7 +691,7 @@
"Editor › Font": "Éditeur › Police",
"Editor › General": "Éditeur › Général",
"Effect": "Effet",
"Effect color": "Couleur de l'effet",
"Effect style": "Style d'effet",
"Effects": "Effets",
"Element": "Élément",
"Empty": "Vide",
+1 -1
View File
@@ -789,7 +789,7 @@
"Editor › Font": "편집기 › 글꼴",
"Editor › General": "편집기 › 일반",
"Effect": "효과",
"Effect color": "효과 색상",
"Effect style": "효과 스타일",
"Effects": "효과",
"Element": "요소",
"Empty": "비어 있음",
+1 -1
View File
@@ -745,7 +745,7 @@
"Editor › Font": "Edytor › Czcionka",
"Editor › General": "Edytor › Ogólne",
"Effect": "Efekt",
"Effect color": "Kolor efektu",
"Effect style": "Styl efektu",
"Effects": "Efekty",
"Element": "Element",
"Empty": "Pusty",
+1 -1
View File
@@ -745,7 +745,7 @@
"Editor › Font": "Editor › Fonte",
"Editor › General": "Editor › Geral",
"Effect": "Efeito",
"Effect color": "Cor do efeito",
"Effect style": "Estilo do efeito",
"Effects": "Efeitos",
"Element": "Elemento",
"Empty": "Vazio",
+35 -3
View File
@@ -41,6 +41,26 @@
"Cyan": "青色",
"Dark gray": "深灰色",
"Magenta": "品红色",
"Editor inline blame": "编辑器内联 Blame",
"Date time": "日期时间",
"Issue link": "Issue 链接",
"Subject": "主题",
"Changed decoration": "已变更装饰",
"HEAD tags decoration": "HEAD 标签装饰",
"Local branch decoration": "本地分支装饰",
"master/main with remote": "有远程的 master/main",
"master/main without remote": "无远程的 master/main",
"Tracking branch decoration": "跟踪分支装饰",
"Editor inline blame › Author": "编辑器内联 Blame › 作者",
"Editor inline blame › Date time": "编辑器内联 Blame › 日期时间",
"Editor inline blame › Issue link": "编辑器内联 Blame › Issue 链接",
"Editor inline blame › Subject": "编辑器内联 Blame › 主题",
"Project View › Changed decoration": "项目视图 › 已变更装饰",
"Project View › HEAD tags decoration": "项目视图 › HEAD 标签装饰",
"Project View › Local branch decoration": "项目视图 › 本地分支装饰",
"Project View › master/main with remote": "项目视图 › 有远程的 master/main",
"Project View › master/main without remote": "项目视图 › 无远程的 master/main",
"Project View › Tracking branch decoration": "项目视图 › 跟踪分支装饰",
"Dock Pinned": "停靠固定",
"Dock Unpinned": "停靠取消固定",
"Undock": "取消停靠",
@@ -1166,7 +1186,7 @@
"Editor › Font": "编辑器 › 字体",
"Editor › General": "编辑器 › 常规",
"Effect": "效果",
"Effect color": "效果颜色",
"Effect style": "效果样式",
"Effects": "效果",
"Element": "元素",
"Empty": "空",
@@ -4611,6 +4631,8 @@
"Language servers: {text}": "语言服务器:{text}",
"Install {name}": "安装 {name}",
"Restart {name}": "重启 {name}",
"Check for {name} again": "重新检测 {name}",
"{name} is still not installed": "{name} 仍未安装",
"{count} error": "{count} 个错误",
"{count} errors": "{count} 个错误",
"{count} warning": "{count} 个警告",
@@ -4641,6 +4663,7 @@
"{count} rows": "{count} 行",
"Remove data source '{name}'? Its query console file stays on disk.": "是否移除数据源 '{name}'?其查询控制台文件将保留在磁盘上。",
"Result {n}": "结果 {n}",
"Result {n}-{index}": "结果 {n}-{index}",
"{name} has uncommitted changes. Commit or roll them back before switching to auto-commit?": "{name} 有未提交的更改。是否在切换到自动提交前提交或回滚这些更改?",
"Cannot copy the name": "无法复制名称",
"Cannot copy the reference": "无法复制引用",
@@ -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 检测会在那里找到它。",
@@ -6819,6 +6844,8 @@
"Bad regex": "正则表达式无效",
"Filter rows": "筛选行",
"Result pages": "结果分页",
"Result actions": "结果操作",
"Output actions": "输出操作",
"First Page": "第一页",
"Count rows": "统计行数",
"Last Page": "最后一页",
@@ -7349,6 +7376,7 @@
"Sources not found": "未找到源代码",
"Cannot load library sources": "无法加载库源代码",
"Toggle Boolean Cell": "切换布尔单元格",
"Local Filter": "本地筛选",
"Toggle {name}": "切换 {name}",
"true": "真",
"false": "假",
@@ -7879,5 +7907,9 @@
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
"Keep the token": "保留令牌",
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
"{name} (group)": "{name}(分组)",
"Servers in group": "组内服务器",
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
"Server Group": "服务器分组"
}
+16
View File
@@ -43,8 +43,17 @@ export interface DeploymentServerValid {
excludedPaths: string[];
}
/** Servers that Upload to… on the group uploads to, one after another. */
export interface DeploymentGroupValid {
id: string;
name: string;
/** Server ids, in upload order. */
servers: string[];
}
export interface DeploymentSettingsValid {
servers: DeploymentServerValid[];
groups: DeploymentGroupValid[];
defaultServer: string;
autoUpload: AutoUploadValid;
deleteRemote: boolean;
@@ -106,6 +115,7 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
servers: [],
groups: [],
defaultServer: "",
autoUpload: "never",
deleteRemote: false,
@@ -206,8 +216,14 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
mappings: validateMappings(s.mappings),
excludedPaths: strings(s.excludedPaths, []),
}));
const groups = withIds(o.groups, "group", (g) => ({
id: text(g.id, ""),
name: text(g.name, ""),
servers: strings(g.servers, []),
}));
return {
servers,
groups,
defaultServer: text(o.defaultServer, d.defaultServer),
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
+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;
+13 -1
View File
@@ -12,10 +12,21 @@ export interface CheckboxProps {
/** Tri-state display. */
indeterminate?: boolean;
className?: string;
/** `-1` keeps it out of the Tab order, e.g. inside a grid that moves between cells itself. */
tabIndex?: number;
"aria-label"?: string;
}
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
export function Checkbox({
checked,
onChange,
label,
disabled,
indeterminate,
className,
tabIndex,
...aria
}: CheckboxProps) {
const id = useId();
const tx = useGivenTx();
return (
@@ -27,6 +38,7 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl
className={styles.native}
checked={checked}
disabled={disabled}
tabIndex={tabIndex}
aria-checked={indeterminate ? "mixed" : checked}
aria-label={tx(aria["aria-label"])}
onChange={(e) => onChange(e.target.checked)}
+13 -3
View File
@@ -1,9 +1,16 @@
.strip {
--tab-selected-bg: var(--bg-tab-selected);
--tab-selected-border: var(--border-tab-selected);
flex: none;
display: flex;
align-items: stretch;
min-width: 0;
}
/* IDEA: only the part holding focus accents its selected tab (src/ui/focusScope.ts). */
:where([data-focus-scope]:not([data-focus-active])) .strip {
--tab-selected-bg: var(--bg-tab-selected-inactive);
--tab-selected-border: var(--border-tab-selected-inactive);
}
/* Islands tabs: the selected tab is a rounded pill (tab-selected-bg / -border); no underline, no separators. */
.editor {
height: var(--h-header);
@@ -47,15 +54,15 @@
}
.selected,
.selected:hover {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
background: var(--tab-selected-bg);
box-shadow: inset 0 0 0 1px var(--tab-selected-border);
}
.tinted,
.tinted:hover {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
}
.tinted.selected {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--bg-tab-selected));
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--tab-selected-bg));
}
.tabButton {
display: flex;
@@ -105,6 +112,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,
+36 -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)`. */
@@ -127,6 +132,11 @@ export interface TabsProps {
"aria-label"?: string;
/** Tabpanel id prefix for `aria-controls`. */
panelIdPrefix?: string;
/**
* Tab from a tab moves focus into the selected tab's content (IDEA's table, list or tree)
* instead of the next control; returns false when there is nothing to focus.
*/
focusContent?: () => boolean;
/** Enables drag & drop reordering (and moving between strips of the same type). */
dnd?: TabsDnd;
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
@@ -151,6 +161,7 @@ export function Tabs({
trailing,
className,
panelIdPrefix,
focusContent,
dnd,
layout = "scroll",
closePosition = "right",
@@ -176,9 +187,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,9 +321,12 @@ 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;
case "Tab":
if (e.shiftKey || !focusContent?.()) return;
break;
default:
return;
}
@@ -364,14 +378,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 +473,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")} />}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toolbar } from "./Toolbar";
function Bar({ busy = false, extra = false }: { busy?: boolean; extra?: boolean }) {
return (
<>
<button>Before</button>
<Toolbar aria-label="Actions">
<button>Run</button>
<button disabled={busy}>Stop</button>
{extra && <button>Pin</button>}
<button tabIndex={-1}>Hidden</button>
<input aria-label="Filter" />
</Toolbar>
<button>After</button>
</>
);
}
const tabStops = () => [...document.querySelectorAll('[role="toolbar"] [tabindex="0"]')].map((e) => e.textContent);
describe("Toolbar", () => {
it("is one Tab stop that remembers the control used last", async () => {
const user = userEvent.setup();
render(<Bar />);
screen.getByRole("button", { name: "Before" }).focus();
await user.tab();
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
await user.tab({ shift: true });
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
});
it("moves with the arrows, Home and End, wrapping, past controls that opt out", async () => {
const user = userEvent.setup();
render(<Bar />);
act(() => screen.getByRole("button", { name: "Run" }).focus());
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
// A text field keeps its caret keys.
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{End}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{Home}");
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
expect(screen.getByRole("button", { name: "Hidden" })).toHaveAttribute("tabindex", "-1");
});
it("keeps one stop when controls are added or the remembered one is disabled", async () => {
const { rerender } = render(<Bar />);
act(() => screen.getByRole("button", { name: "Stop" }).focus());
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra />);
await waitFor(() => expect(screen.getByRole("button", { name: "Pin" })).toHaveAttribute("tabindex", "-1"));
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra busy />);
await waitFor(() => expect(tabStops()).toEqual(["Run"]));
fireEvent.keyDown(screen.getByRole("button", { name: "Run" }), { key: "ArrowRight" });
expect(screen.getByRole("button", { name: "Pin" })).toHaveFocus();
});
});
+88
View File
@@ -0,0 +1,88 @@
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, type HTMLAttributes } from "react";
import { useGivenTx } from "@/platform/i18n";
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
"aria-label": string;
orientation?: "horizontal" | "vertical";
}
const ITEMS = "button, input, select, textarea, a[href], [tabindex]";
/** Controls whose tabindex a toolbar wrote, as opposed to an owner's own `-1` (kept across remounts). */
const managed = new WeakSet<Element>();
/**
* A toolbar that is one Tab stop (WAI-ARIA toolbar): the arrow keys, Home and End move
* between its controls, and Tab enters at the control used last. IDEA's toolbar buttons
* aren't Tab stops at all; this keeps them reachable from the keyboard.
*
* Roving works on the DOM, so the controls need nothing: a control that sets its own
* `tabIndex={-1}` stays out, and controls added, removed, enabled or disabled later are
* picked up.
*/
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ orientation = "horizontal", "aria-label": ariaLabel, children, ...rest },
ref,
) {
const tx = useGivenTx();
const root = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => root.current!);
useLayoutEffect(() => {
const el = root.current!;
let current: Element | null = null;
const candidates = () =>
[...el.querySelectorAll<HTMLElement>(ITEMS)].filter(
(item) => managed.has(item) || item.getAttribute("tabindex") !== "-1",
);
const items = () => candidates().filter((item) => !(item as HTMLButtonElement).disabled);
const sync = () => {
const all = candidates();
const enabled = all.filter((item) => !(item as HTMLButtonElement).disabled);
if (!current || !enabled.includes(current as HTMLElement)) current = enabled[0] ?? null;
for (const item of all) {
const index = item === current ? "0" : "-1";
if (item.getAttribute("tabindex") !== index) item.setAttribute("tabindex", index);
managed.add(item);
}
};
const onFocusIn = (e: FocusEvent) => {
if (!(e.target instanceof Element) || !items().includes(e.target as HTMLElement)) return;
current = e.target;
sync();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const target = e.target as HTMLElement;
const all = items();
const index = all.indexOf(target);
if (index < 0) return;
const vertical = orientation === "vertical";
// Text fields keep their caret keys.
const text = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
let next: number;
if (e.key === (vertical ? "ArrowDown" : "ArrowRight") && !text) next = index + 1;
else if (e.key === (vertical ? "ArrowUp" : "ArrowLeft") && !text) next = index - 1;
else if (e.key === "Home" && !text) next = 0;
else if (e.key === "End" && !text) next = all.length - 1;
else return;
e.preventDefault();
all[(next + all.length) % all.length]!.focus();
};
sync();
const observer = new MutationObserver(sync);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled"] });
el.addEventListener("focusin", onFocusIn);
el.addEventListener("keydown", onKeyDown);
return () => {
observer.disconnect();
el.removeEventListener("focusin", onFocusIn);
el.removeEventListener("keydown", onKeyDown);
};
}, [orientation]);
return (
<div ref={root} role="toolbar" aria-orientation={orientation} aria-label={tx(ariaLabel)} {...rest}>
{children}
</div>
);
});
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { FOCUS_ACTIVE_ATTR, installFocusScopeTracking } from "./focusScope";
describe("focus scopes", () => {
let dispose = () => {};
afterEach(() => {
dispose();
document.body.innerHTML = "";
});
function setup() {
document.body.innerHTML = `
<section id="editor" data-focus-scope><button id="editorTab">a</button></section>
<section id="services" data-focus-scope>
<button id="tree">tree</button>
<div id="results" data-focus-scope><button id="grid">grid</button></div>
</section>
<div data-popup><button id="menuItem">menu</button></div>`;
dispose = installFocusScopeTracking();
const el = (id: string) => document.getElementById(id)!;
const active = () =>
["editor", "services", "results"].filter((id) => el(id).hasAttribute(FOCUS_ACTIVE_ATTR)).join(",");
return { el, active };
}
it("marks the focused scope with its enclosing scopes and moves the mark with focus", () => {
const { el, active } = setup();
el("editorTab").focus();
expect(active()).toBe("editor");
el("grid").focus();
expect(active()).toBe("services,results");
el("tree").focus();
expect(active()).toBe("services");
});
it("keeps the marks while focus is in a popup or nowhere", () => {
const { el, active } = setup();
el("grid").focus();
el("menuItem").focus();
expect(active()).toBe("services,results");
el("menuItem").blur();
expect(document.activeElement).toBe(document.body);
expect(active()).toBe("services,results");
});
it("marks the scope already holding focus on install and clears the marks on dispose", () => {
document.body.innerHTML = `<section id="editor" data-focus-scope><button id="tab">a</button></section>`;
document.getElementById("tab")!.focus();
dispose = installFocusScopeTracking();
const editor = document.getElementById("editor")!;
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(true);
dispose();
dispose = () => {};
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Focus scopes: containers marked `data-focus-scope` that show whether they hold focus, as
* IDEA's tab strips do (the selected tab is accented only in the part that has focus).
*
* The scopes containing the last element focused inside any scope get `data-focus-active`.
* Focus moving outside every scope (popups, menus, dialogs, the body) keeps the marks, so a
* context menu opened from a tab leaves its strip active.
*/
const FOCUS_SCOPE_ATTR = "data-focus-scope";
export const FOCUS_ACTIVE_ATTR = "data-focus-active";
/** Marks the scopes holding focus. Returns an uninstaller that clears the marks. */
export function installFocusScopeTracking(): () => void {
let active: Element[] = [];
const mark = (target: Element | null) => {
const chain: Element[] = [];
for (let el = target?.closest(`[${FOCUS_SCOPE_ATTR}]`); el; el = el.parentElement?.closest(`[${FOCUS_SCOPE_ATTR}]`))
chain.push(el);
if (!chain.length) return;
for (const el of active) if (!chain.includes(el)) el.removeAttribute(FOCUS_ACTIVE_ATTR);
for (const el of chain) el.setAttribute(FOCUS_ACTIVE_ATTR, "");
active = chain;
};
const onFocusIn = (e: FocusEvent) => mark(e.target instanceof Element ? e.target : null);
document.addEventListener("focusin", onFocusIn);
mark(document.activeElement);
return () => {
document.removeEventListener("focusin", onFocusIn);
for (const el of active) el.removeAttribute(FOCUS_ACTIVE_ATTR);
active = [];
};
}
+1
View File
@@ -8,6 +8,7 @@ export * from "./Input";
export * from "./Checkbox";
export * from "./Select";
export * from "./Tabs";
export * from "./Toolbar";
export {
FILES_DRAG,
REMOTE_FILES_DRAG,
+2
View File
@@ -17,6 +17,7 @@ import { installBrowserGuards } from "./browserGuards";
import { installDistractionFree } from "./distractionFree";
import { installFileDrop } from "./fileDrop";
import { installFocusTracking } from "./focus";
import { installFocusScopeTracking } from "@/ui/focusScope";
import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore";
import {
NOTIFICATIONS_TOOL_WINDOW,
@@ -74,6 +75,7 @@ export function activateWorkbench(): () => void {
installToolWindowLayoutSync(),
installDistractionFree(),
installFocusTracking(setFocusedToolWindow),
installFocusScopeTracking(),
installTaskEvents(),
installNotifyEvents(),
installBrowserGuards(),
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more