Compare commits

...
Author SHA1 Message Date
l0ng-ai 3bdb10c7fe Merge remote-tracking branch 'origin/main' into fix/debug-execution-point-refresh 2026-11-01 19:02:34 +08:00
l0ng-ai 210f9a78ac feat(run): project CMake profiles (#679)
CMake run configurations used fixed arguments (-B build, Debug) and only recognised existing build directories.

Projects can now define CMake profiles under Settings › Build, Execution, Deployment › CMake: build type, generator, CMake arguments, build directory (default cmake-build-<type>), build arguments and environment. Each profile gets its own Configure, Build, CTest and target run configurations, named after the profile when there are several. Configure runs first only while the build directory has no CMakeCache.txt, and a generator change clears the stale cache before configuring. Projects without profiles keep the previous detection. Arguments are stored as lists and edited with the same quoting rules as program arguments. Toolchain selection is not part of this change.

Refs #655
2026-11-01 19:02:00 +08:00
l0ng-ai 5c9b131f78 fix(debugger): keep execution highlights synchronized 2026-11-01 18:59:44 +08:00
l0ng-ai 49f9c57bcc docs: remove the README build section 2026-11-01 18:46:40 +08:00
l0ng-ai 07609ef9c6 chore: v0.1.33 2026-11-01 18:35:25 +08:00
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00
fuqiuluo 041e72106b fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size.

Linux now builds project and settings windows hidden and restores them after the first configure, keeping the normal size apart from maximize, fullscreen and minimize, protecting it during transitions, and snapshotting each window before close instead of querying GTK during exit. The state file format is unchanged; macOS and Windows keep the plugin as before.

Verified under Mutter and Weston: three restarts at 1234×678, three maximized restarts followed by unmaximize, the settings window, several project windows across a full exit, and window groups all restore exactly. macOS restore is unchanged. Not run: GNOME on real hardware, mixed-DPI monitors, X11.

Fixes #594
Fixes #636
2026-11-01 18:05:05 +08:00
l0ng-ai aa2c24c648 fix(agents): make tool window sessions usable in narrow layouts 2026-11-01 18:00:54 +08:00
arookieofcanda-rookie-of-C-language 3a87c697b2 fix(ide-tools): move run_input above the tests module (#677)
clippy::items_after_test_module failed the workspace clippy run because run_input was defined after the first test module in crates/ide-tools/src/process.rs. Move it above; no code changes.

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-01 17:48:38 +08:00
l0ng-ai 87bf89d688 feat(debugger): add paused editor evaluation entry points (#676)
While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused.

Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled.

Fixes #664
2026-11-01 17:19:00 +08:00
78 changed files with 3938 additions and 539 deletions

No files matched your search

Generated
+36 -35
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,7 +4195,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4209,7 +4209,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4294,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,7 +4310,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4365,7 +4365,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"futures",
@@ -4374,6 +4374,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4389,7 +4390,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"futures",
"ide-core",
@@ -4412,7 +4413,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4430,7 +4431,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4443,7 +4444,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"globset",
"ide-core",
@@ -4460,7 +4461,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4483,7 +4484,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"ide-core",
@@ -4502,7 +4503,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"globset",
@@ -4522,7 +4523,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4535,7 +4536,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4554,7 +4555,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4569,7 +4570,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4582,7 +4583,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"ide-core",
@@ -4594,7 +4595,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"flate2",
"hex",
@@ -4615,7 +4616,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4634,7 +4635,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"chardetng",
"dunce",
@@ -4656,7 +4657,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"open",
@@ -4672,7 +4673,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"dunce",
@@ -6143,7 +6144,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"block2 0.6.2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.32"
version = "0.1.33"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-20
View File
@@ -14,8 +14,6 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+6
View File
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
+56
View File
@@ -58,6 +58,43 @@ pub struct Settings {
pub deployment: DeploymentSettings,
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeSettings {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub profiles: Option<Vec<CMakeProfile>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct CMakeProfile {
pub name: String,
pub build_type: String,
pub generator: String,
pub cmake_args: Vec<String>,
/// Empty uses cmake-build-<lowercase build type>.
pub build_dir: String,
pub build_args: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl Default for CMakeProfile {
fn default() -> Self {
Self {
name: "Debug".into(),
build_type: "Debug".into(),
generator: String::new(),
cmake_args: Vec::new(),
build_dir: String::new(),
build_args: Vec::new(),
env: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -154,6 +191,7 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -765,6 +803,24 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
+6 -4
View File
@@ -172,12 +172,14 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers", "projectStructure"]
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
{
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if let Some(bridge) = &project.kotlin_bridge {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
+5 -4
View File
@@ -50,10 +50,11 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let mut all: Vec<RunConfig> = ide_run::detect_excluding(root, &excluded_poms)
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
let mut all: Vec<RunConfig> =
ide_run::detect_with_profiles(root, &excluded_poms, settings.cmake.profiles.as_deref())
.into_iter()
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
+23
View File
@@ -129,6 +129,8 @@ impl Host {
let root = root.clone();
let data_dir = self.data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
@@ -142,6 +144,27 @@ impl Host {
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
cfg,
effective.editor.code_style.languages.get("java"),
root,
&data_dir,
effective.language_servers.get("jdtls"),
)
.err()
.map(|e| e.to_string());
let mut previous = formatter_problem.lock().unwrap();
if problem != *previous {
if let Some(body) = &problem {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Java formatter configuration unavailable".into(),
body: Some(body.clone()),
});
}
*previous = problem;
}
drop(previous);
ide_lsp::jdtls::configure_source_paths(
cfg,
ide_workspace::roots::unmanaged_java_source_paths(
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
+3
View File
@@ -17,6 +17,9 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
mod formatter;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
/// A workspace build of a large multi-module project can take far longer than
+224
View File
@@ -0,0 +1,224 @@
use ide_core::IdeResult;
use ide_core::editor_settings::LanguageCodeStyle;
use ide_core::settings::LanguageServerSettings;
use ide_language::LanguageServerConfig;
use serde_json::json;
use std::path::Path;
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
const XML: &str = include_str!("intellij.xml");
/// Protocol settings only: switching profiles reaches a running jdtls through
/// didChangeConfiguration. Explicit language-server settings are applied later.
pub fn configure_formatter(
cfg: &mut LanguageServerConfig,
style: Option<&LanguageCodeStyle>,
project_root: &Path,
data_dir: &Path,
overrides: Option<&LanguageServerSettings>,
) -> IdeResult<()> {
if cfg.name != "jdtls" {
return Ok(());
}
// jdtls retains omitted preferences. Send empty values when returning to
// Default, while preserving any profile from the base server configuration.
let inherited = crate::handler::all_settings(cfg);
let defaults = json!({
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
});
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
cfg.settings = Some(settings);
let Some(style) = style else { return Ok(()) };
let (path, profile) = match style.formatter.as_deref() {
Some("intellij") => {
let path = data_dir.join("lsp/jdtls/intellij.xml");
std::fs::create_dir_all(path.parent().unwrap())?;
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
ide_vfs::write_atomic(&path, XML.as_bytes())?;
}
(path, PROFILE)
}
Some("custom") => {
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
if value.is_empty() {
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
}
let path = project_root.join(value);
// Fail without poisoning the server launch; code intelligence remains available.
std::fs::File::open(&path).map_err(|error| {
let path = path.display();
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
})?;
if !path.is_file() {
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
}
(path, "")
}
_ => return Ok(()),
};
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(
json!({
"url": crate::uri::path_to_uri_string(&path),
"profile": profile,
}),
overrides,
);
cfg.settings = Some(settings);
Ok(())
}
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
if let Some(overrides) = overrides {
// all_settings merges protocol settings over initializationOptions.
// Mirror explicit formatter keys so generated protocol values cannot
// accidentally mask an advanced initializationOptions override.
for layer in [
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
]
.into_iter()
.flatten()
{
for key in ["url", "profile"] {
if let Some(value) = layer.get(key) {
format[key] = value.clone();
}
}
}
}
format
}
#[cfg(test)]
mod tests {
use super::*;
fn config(name: &str) -> LanguageServerConfig {
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
}
#[test]
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project with 空格");
let data = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
let custom = root.join("style.xml");
std::fs::write(&custom, XML).unwrap();
for name in ["jdtls", "rust-analyzer"] {
for mode in ["default", "intellij", "custom", "unknown"] {
let mut cfg = LanguageServerConfig {
name: name.into(),
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
..config(name)
};
let initial = cfg.clone();
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("style.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
if name != "jdtls" {
assert_eq!(cfg, initial);
continue;
}
let settings = crate::handler::all_settings(&cfg);
if !["intellij", "custom"].contains(&mode) {
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
continue;
}
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
assert_eq!(
settings["java"]["format"]["settings"]["profile"],
if mode == "custom" { "" } else { PROFILE }
);
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
}
}
}
#[test]
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
let dir = tempfile::tempdir().unwrap();
for path in ["", "missing.xml", "."] {
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.into()),
..Default::default()
};
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
assert!(cfg.launch_error.is_none());
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
}
}
#[test]
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.xml");
std::fs::write(&path, XML).unwrap();
let mut cfg = config("jdtls");
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
}))
.unwrap(),
);
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({"url": "existing.xml", "profile": "Existing"})
);
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
crate::uri::path_to_uri_string(&path)
);
}
#[test]
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
for mode in ["default", "intellij", "custom"] {
for layer in ["initialization", "wrapped", "direct"] {
let mut overrides = LanguageServerSettings {
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
"url": "advanced.xml", "profile": "Advanced"
}}}}})),
..Default::default()
};
if layer != "initialization" {
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
}
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({
"url": "advanced.xml",
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
})
);
}
}
}
}
+235
View File
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
A formatter profile does not sort/group imports (Organize Imports is a separate action).
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
-->
<profiles version="23">
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<!-- IndentOptions.TAB_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<!-- IndentOptions.INDENT_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<!-- INDENT_CASE_FROM_SWITCH = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
<!-- INDENT_BREAK_FROM_CASE = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
</profile>
</profiles>
@@ -0,0 +1,25 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
@Deprecated @SuppressWarnings("unused") private String first="first";
private String second="second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter){
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value->value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter){combine(firstParameter, // retain call break
secondParameter, "third");}
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
@@ -0,0 +1,65 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated
@SuppressWarnings("unused")
public class FormatterSample {
@Deprecated
@SuppressWarnings("unused")
private String first = "first";
private String second = "second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated
@SuppressWarnings("unused")
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
if (firstParameterWithAVeryLongName == null) {
return "none";
} else {
try {
return List
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
thirdParameterWithAVeryLongName)
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
.orElse("none");
} catch (RuntimeException problem) {
throw problem;
} finally {
System.out.println("done");
}
}
}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter) {
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value -> value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter) {
combine(firstParameter, // retain call break
secondParameter, "third");
}
public void arrays() {
int[] values = new int[]{1, 2, 3};
int[] empty = new int[]{};
Function<String, String> clean = value -> {
return value.trim();
};
Runnable run = () -> {
System.out.println(values.length);
};
}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
+64
View File
@@ -1017,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() {
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
+47
View File
@@ -62,6 +62,53 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let source = include_str!("fixtures/FormatterSample.input.java");
let expected = include_str!("fixtures/FormatterSample.intellij.java");
let path = root.join("src/demo/FormatterSample.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
mgr.set_config_hook(Box::new({
let root = root.clone();
move |cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
let style = ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
..Default::default()
};
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
}
}));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
retry("jdtls ready", Duration::from_secs(120), || {
let status = last_status(&events, "jdtls");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("jdtls failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let edits =
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
let mut document = ide_text::Document::new(source);
document.apply_edits(&edits.unwrap()).unwrap();
assert_eq!(document.text(), expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_binary_is_reported_with_install_hint() {
let dir = tempfile::tempdir().unwrap();
+9 -1
View File
@@ -16,6 +16,14 @@ pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
}
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
detect_with_profiles(root, excluded_poms, None)
}
pub(crate) fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
@@ -24,7 +32,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, &mut out);
cmake::detect(root, profiles, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+172 -5
View File
@@ -2,6 +2,7 @@
use super::{ConfigExt, config};
use crate::{BeforeLaunchTask, RunConfig};
use ide_core::settings::CMakeProfile;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
@@ -19,10 +20,16 @@ fn before(config: &mut RunConfig, id: &str) {
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
}
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
if !root.join("CMakeLists.txt").is_file() {
return;
}
if let Some(profiles) = profiles {
for (index, profile) in profiles.iter().enumerate() {
detect_profile(root, profile, index, profiles.len() > 1, out);
}
return;
}
let mut builds = Vec::new();
if let Ok(entries) = fs::read_dir(root) {
builds.extend(entries.flatten().filter_map(|e| {
@@ -79,11 +86,69 @@ pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, out);
targets(root, &build, &dir, &prefix, None, out);
}
}
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
fn detect_profile(root: &Path, profile: &CMakeProfile, index: usize, multiple: bool, out: &mut Vec<RunConfig>) {
let dir = if profile.build_dir.trim().is_empty() {
format!("cmake-build-{}", profile.build_type.to_lowercase())
} else {
profile.build_dir.clone()
};
let build = root.join(&dir);
let prefix = format!("cmake:profile:{index}");
let configure_id = format!("{prefix}:configure");
let build_id = format!("{prefix}:build");
let start = out.len();
let mut configure =
config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &["-S", ".", "-B", &dir]);
if !profile.generator.trim().is_empty() {
configure.args.extend(["-G".into(), profile.generator.clone()]);
}
configure.args.push(format!("-DCMAKE_BUILD_TYPE={}", profile.build_type));
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
configure.args.extend_from_slice(&profile.cmake_args);
configure.extras.params.insert("buildDir".into(), dir.clone());
out.push(configure);
let mut all = config(
&build_id,
format!("CMake Build ({dir})"),
"cmake",
"cmake",
&["--build", &dir, "--config", &profile.build_type],
);
all.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut all, &configure_id);
}
out.push(all);
let mut test = config(
format!("{prefix}:test"),
format!("CTest ({dir})"),
"cmake",
"ctest",
&["--test-dir", &dir, "-C", &profile.build_type, "--output-on-failure"],
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, Some(profile), out);
for config in &mut out[start..] {
config.env = profile.env.clone();
if multiple {
config.name = format!("{} [{}]", config.name, profile.name);
}
}
}
fn targets(
root: &Path,
build: &Path,
dir: &str,
prefix: &str,
profile: Option<&CMakeProfile>,
out: &mut Vec<RunConfig>,
) {
let reply = build.join(".cmake/api/v1/reply");
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
entries
@@ -113,6 +178,9 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
for configuration in configurations {
let name = configuration["name"].as_str().unwrap_or("");
if profile.is_some_and(|p| !name.is_empty() && name != p.build_type) {
continue;
}
let Some(targets) = configuration["targets"].as_array() else { continue };
for target in targets {
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
@@ -130,8 +198,17 @@ fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<Run
if !name.is_empty() {
args.extend(["--config", name]);
}
let build_config =
let mut build_config =
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
if let Some(profile) = profile {
if name.is_empty() {
build_config.args.extend(["--config".into(), profile.build_type.clone()]);
}
build_config.args.extend_from_slice(&profile.build_args);
if !build.join("CMakeCache.txt").is_file() {
before(&mut build_config, &format!("{prefix}:configure"));
}
}
out.push(build_config);
let binary = build.join(artifact);
let mut run =
@@ -165,9 +242,99 @@ pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
&& config.args.first().is_some_and(|a| a == "-S")
&& let Some(dir) = config.extras.params.get("buildDir")
{
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
let build = cwd.join(dir);
let generator = config.args.windows(2).find(|a| a[0] == "-G").map(|a| a[1].as_str());
if let Some(generator) = generator
&& let Ok(cache) = fs::read_to_string(build.join("CMakeCache.txt"))
&& let Some(cached) = cache.lines().find_map(|l| l.strip_prefix("CMAKE_GENERATOR:INTERNAL="))
&& cached != generator
{
fs::remove_file(build.join("CMakeCache.txt"))?;
let files = build.join("CMakeFiles");
if files.is_dir() {
fs::remove_dir_all(files)?;
}
}
let query = build.join(".cmake/api/v1/query/client-openidea");
fs::create_dir_all(&query)?;
fs::write(query.join("codemodel-v2"), "")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profiles_select_file_api_configuration_and_apply_target_options() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let reply = root.path().join("output/.cmake/api/v1/reply");
fs::create_dir_all(&reply).unwrap();
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
write(
"index-1.json",
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
);
write(
"model.json",
serde_json::json!({"paths":{"source":root.path()},"configurations":[
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
]}),
);
for name in ["debug", "release"] {
write(
&format!("{name}.json"),
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
);
}
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
build_dir: "output".into(),
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
env: [("FOO".into(), "bar".into())].into(),
..Default::default()
};
let mut configs = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
let run = configs.iter().find(|c| c.kind == "native").unwrap();
assert!(run.command.ends_with("release/app"));
assert_eq!(run.env, profile.env);
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
assert_eq!(
build.args,
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
);
assert_eq!(build.env, profile.env);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
let mut configured = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
for id in ["cmake:profile:0:build", build.id.as_str()] {
let build = configured.iter().find(|c| c.id == id).unwrap();
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
}
}
#[test]
fn generator_change_clears_only_cmake_state_at_launch() {
let root = tempfile::tempdir().unwrap();
let build = root.path().join("cmake-build-debug");
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
fs::write(build.join("keep.txt"), "artifact").unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let mut configs = Vec::new();
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
assert!(build.join("CMakeCache.txt").exists());
prepare(&configs[0], root.path()).unwrap();
assert!(!build.join("CMakeCache.txt").exists());
assert!(!build.join("CMakeFiles").exists());
assert!(build.join("keep.txt").exists());
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
}
}
+9
View File
@@ -201,6 +201,15 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
detect::detect_excluding(root, excluded_poms)
}
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
pub fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
detect::detect_with_profiles(root, excluded_poms, profiles)
}
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
config::load(root)
+69
View File
@@ -652,3 +652,72 @@ fn go_default_build_output_is_ignored_by_version_control() {
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
}
#[test]
fn cmake_profiles_control_commands_names_and_environment() {
use ide_core::settings::CMakeProfile;
let dir = project(&[("CMakeLists.txt", "project(app)")]);
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
generator: generator.into(),
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
build_dir: "output dir".into(),
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
env: [("MY_ENV".into(), "yes".into())].into(),
};
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
let configure = find(&configs, "cmake:profile:0:configure");
let mut expected = vec!["-S", ".", "-B", "output dir"];
if !generator.is_empty() {
expected.extend(["-G", generator]);
}
expected.extend([
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DFEATURE=ON",
"-DMESSAGE=two words",
"-DQUOTE=\"hello world\"",
"",
]);
assert_eq!(configure.args, expected);
assert_eq!(configure.name, "CMake Configure (output dir)");
let build = find(&configs, "cmake:profile:0:build");
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
assert_eq!(build.extras.before_launch[0].target, configure.id);
assert!(configs.iter().all(|c| c.env == profile.env));
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
}
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
}
#[test]
fn cmake_profile_build_configures_only_without_a_cache() {
use ide_core::settings::CMakeProfile;
let root = project(&[("CMakeLists.txt", "project(app)")]);
for dir in ["", "custom output"] {
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
for cached in [false, true] {
if cached {
fs::create_dir_all(&build_dir).unwrap();
// Generator changes are handled by an explicit Configure, not every Build.
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
}
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
let build = find(&configs, "cmake:profile:0:build");
if cached {
assert!(build.extras.before_launch.is_empty());
} else {
assert_eq!(build.extras.before_launch.len(), 1);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
}
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
}
}
}
+83 -83
View File
@@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -465,89 +548,6 @@ mod tests {
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(all(test, unix))]
mod input_tests {
use super::*;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.32",
"version": "0.1.33",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+16 -9
View File
@@ -8,6 +8,8 @@ mod commands;
#[cfg(debug_assertions)]
mod dev;
mod events;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
mod menu;
mod remote_dev;
@@ -68,16 +70,18 @@ pub fn run() {
// Single instance first: a second launch focuses this one and opens its path argument.
// Debug builds have a separate identifier per worktree.
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
#[cfg(target_os = "linux")]
let app = app.plugin(linux_window_state::plugin());
#[cfg(not(target_os = "linux"))]
let app = app.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
// One remembered geometry shared by every project's settings window.
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
.build(),
);
let app = app
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
// One remembered geometry shared by every project's settings window.
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
.build(),
)
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
.plugin(tauri_plugin_updater::Builder::new().build());
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
@@ -152,6 +156,8 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| match event {
#[cfg(target_os = "linux")]
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
tauri::WindowEvent::Focused(true) => {
application_focus::changed(window.app_handle(), true);
if let Some(w) = window.try_state::<WindowsState>() {
@@ -204,6 +210,7 @@ pub fn run() {
});
}
#[cfg(not(target_os = "linux"))]
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
use tauri_plugin_window_state::StateFlags;
StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
+483
View File
@@ -0,0 +1,483 @@
//! Restore GTK geometry after mapping, using the scale of the window's actual output.
mod geometry;
#[cfg(target_os = "linux")]
use geometry::*;
use tauri::utils::config::WindowConfig;
fn build_hidden(config: &mut WindowConfig) -> bool {
std::mem::replace(&mut config.visible, false)
}
#[cfg(target_os = "linux")]
mod runtime {
use super::*;
use parking_lot::Mutex;
use std::collections::HashMap;
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
#[derive(Default)]
enum Phase {
#[default]
Mapping,
Size {
target: Size,
scale: f64,
request: u64,
},
Mode,
Tracking,
}
#[derive(Default)]
struct Pending {
limits: Limits,
configured: bool,
presented: bool,
presentation_request: u64,
saved: Geometry,
normal: Size,
history: ModeHistory,
restore_position: bool,
phase: Phase,
mode_requested: bool,
mode_request: u64,
}
#[derive(Default)]
struct Cache {
saved: HashMap<String, Geometry>,
windows: HashMap<String, Pending>,
next_request: u64,
}
struct Frame {
size: Size,
scale: f64,
mode: Mode,
position: Option<tauri::PhysicalPosition<i32>>,
monitors: Vec<tauri::Monitor>,
work_area: Option<Size>,
visible: bool,
}
fn frame(window: &Window) -> Option<Frame> {
let size = window.inner_size().ok()?;
Some(Frame {
visible: window.is_visible().ok()?,
size: Size(size.width, size.height),
scale: window.scale_factor().ok()?,
mode: Mode {
maximized: window.is_maximized().ok()?,
fullscreen: window.is_fullscreen().ok()?,
minimized: window.is_minimized().ok()?,
},
position: window.outer_position().ok(),
monitors: window.available_monitors().unwrap_or_default(),
work_area: window
.current_monitor()
.ok()
.flatten()
.map(|m| Size(m.work_area().size.width, m.work_area().size.height)),
})
}
fn key(label: &str) -> &str {
if crate::windows::is_settings_window(label) { "settings" } else { label }
}
pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool {
let hint_monitor = app.primary_monitor().ok().flatten();
let hint_scale = hint_monitor.as_ref().map_or(1.0, |m| m.scale_factor());
let hint_area = hint_monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height));
let cache = app.state::<Mutex<Cache>>();
let mut cache = cache.lock();
let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry {
maximized: config.maximized,
fullscreen: config.fullscreen,
..Default::default()
});
cache.next_request += 1;
let presentation_request = cache.next_request;
let pending = Pending {
presentation_request,
limits: Limits {
min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)),
max: (config.max_width, config.max_height),
},
normal: Size(saved.width, saved.height),
saved,
restore_position: true,
..Default::default()
};
if pending.saved.width > 0 && pending.saved.height > 0 {
// This only reduces the first-frame jump; mapped geometry is authoritative.
let size = restore_size(pending.normal, hint_scale, pending.limits.in_work_area(hint_area, hint_scale));
config.width = f64::from(size.0) / hint_scale;
config.height = f64::from(size.1) / hint_scale;
}
cache.windows.insert(config.label.clone(), pending);
// Establish a normal window before asking GTK to maximize or fullscreen it.
config.maximized = false;
config.fullscreen = false;
build_hidden(config)
}
fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) {
let Some(frame) = frame(window) else { return };
if !frame.visible {
return;
}
let cache = window.state::<Mutex<Cache>>();
let mut cache = cache.lock();
cache.next_request += 1;
let request = cache.next_request;
let Some(pending) = cache.windows.get_mut(window.label()) else { return };
if !pending.presented && pending.saved.width == 0 {
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
}
if !pending.configured || !pending.presented {
return;
}
if !allow_restore && !matches!(pending.phase, Phase::Tracking) {
return;
}
let transition = if matches!(pending.phase, Phase::Tracking) {
pending.history.observe(frame.mode, allow_restore)
} else {
NormalTransition::Stable
};
if transition == NormalTransition::WaitForResize {
let normal = pending.normal;
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
(saved.width, saved.height) = (normal.0, normal.1);
saved.maximized = frame.mode.maximized;
saved.fullscreen = frame.mode.fullscreen;
return;
}
if transition == NormalTransition::Restore {
// The compositor may have changed output/DPI while maximized or minimized.
pending.saved.set_normal_size(pending.normal);
pending.phase = Phase::Mapping;
pending.mode_requested = false;
}
let mut fit_settings = false;
let mut position = None;
if matches!(pending.phase, Phase::Mapping) {
if pending.restore_position {
pending.restore_position = false;
let saved = &pending.saved;
let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) };
if frame.monitors.iter().any(|m| {
let p = m.position();
let s = m.size();
i64::from(x) < i64::from(p.x) + i64::from(s.width)
&& i64::from(x) + i64::from(saved.width) > i64::from(p.x)
&& i64::from(y) < i64::from(p.y) + i64::from(s.height)
&& i64::from(y) + i64::from(saved.height) > i64::from(p.y)
}) {
position = Some(tauri::PhysicalPosition::new(x, y));
}
}
if pending.saved.width > 0 && pending.saved.height > 0 {
let target = restore_size(
Size(pending.saved.width, pending.saved.height),
frame.scale,
pending.limits.in_work_area(frame.work_area, frame.scale),
);
pending.phase = Phase::Size { target, scale: frame.scale, request: 0 };
} else {
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
pending.phase = Phase::Mode;
}
}
if let Phase::Size { target, scale, request: sent } = pending.phase {
if frame.size != target && !accept_constrained_size {
if sent != 0 && scale == frame.scale {
return;
}
// Re-evaluate after output scale changes, rather than reusing monitor 0's scale.
let target = restore_size(
Size(pending.saved.width, pending.saved.height),
frame.scale,
pending.limits.in_work_area(frame.work_area, frame.scale),
);
pending.phase = Phase::Size { target, scale: frame.scale, request };
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) {
tracing::warn!("cannot restore Linux window size: {error}");
}
settle_constrained_size(window, request);
return;
}
if scale != frame.scale && !accept_constrained_size {
pending.phase = Phase::Mapping;
drop(cache);
sample(window, true, false);
return;
}
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
pending.phase = Phase::Mode;
}
if matches!(pending.phase, Phase::Mode) {
let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen);
let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized };
let action = mode_action(reached, pending.mode_requested, accept_constrained_size);
if action == ModeAction::Wait {
return;
}
if action == ModeAction::Request {
pending.mode_requested = true;
pending.mode_request = request;
let mut snapshot = pending.saved.clone();
(snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1);
cache.saved.insert(key(window.label()).to_owned(), snapshot);
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if maximize {
let _ = window.maximize();
}
if fullscreen {
let _ = window.set_fullscreen(true);
}
settle_constrained_size(window, request);
return;
}
pending.phase = Phase::Tracking;
fit_settings = crate::windows::is_settings_window(window.label());
}
pending.history = ModeHistory(frame.mode);
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
let normal = pending.normal;
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
(saved.width, saved.height) = (normal.0, normal.1);
saved.maximized = frame.mode.maximized;
saved.fullscreen = frame.mode.fullscreen;
if frame.mode.normal()
&& let Some(pos) = frame.position
{
(saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y);
}
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if fit_settings {
let app = window.app_handle().clone();
let label = window.label().to_owned();
let _ = window.run_on_main_thread(move || {
if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p))
&& let Some(settings) = app.get_webview_window(&label)
&& let Err(error) = crate::windows::place_settings_window(&settings, &parent)
{
tracing::warn!("cannot fit restored settings window: {error}");
}
});
}
}
fn settle_constrained_size(window: &Window, request: u64) {
let window = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let cloned = window.clone();
let _ = window.run_on_main_thread(move || {
let waiting = cloned.state::<Mutex<Cache>>().lock().windows.get(cloned.label()).is_some_and(|p| {
matches!(p.phase, Phase::Size { request: current, .. } if current == request)
|| matches!(p.phase, Phase::Mode) && p.mode_request == request
});
if waiting {
// Window-manager constraints may reject a size without producing an exact match.
sample(&cloned, true, true);
}
});
});
}
pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize<u32>) {
let cache = app.state::<Mutex<Cache>>();
let mut cache = cache.lock();
cache.next_request += 1;
let request = cache.next_request;
if let Some(pending) = cache.windows.get_mut(label) {
pending.normal = Size(size.width, size.height);
pending.saved.set_normal_size(pending.normal);
pending.mode_requested = false;
pending.presented = false;
pending.presentation_request = request;
pending.phase = Phase::Mapping;
}
let saved = cache.saved.entry(key(label).to_owned()).or_default();
saved.set_normal_size(Size(size.width, size.height));
drop(cache);
if let Some(webview) = app.get_webview_window(label) {
request_presentation(webview.as_ref(), request);
}
}
fn request_presentation(webview: &tauri::Webview, request: u64) {
// A configure precedes output-enter. A second animation frame follows the
// first paint, allowing GTK to learn the scale of the assigned output.
let script = r#"
(() => {
const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false };
state.request = __REQUEST_ID__;
state.ready ??= () => {
if (document.hidden || state.scheduled) return;
const request = state.request;
state.scheduled = true;
requestAnimationFrame(() => requestAnimationFrame(() => {
state.scheduled = false;
if (document.hidden) return;
const api = window.__TAURI_INTERNALS__;
const label = api?.metadata?.currentWindow?.label;
if (label) api.invoke('plugin:event|emit', {
event: 'openidea://window-state-presented', payload: { label, request }
});
if (request !== state.request) state.ready();
}));
};
if (!state.listening) {
state.listening = true;
document.addEventListener('visibilitychange', state.ready);
}
state.ready();
})();
"#
.replace("__REQUEST_ID__", &request.to_string());
let _ = webview.eval(script);
}
pub fn save_before_close(app: &AppHandle) {
for window in app.webview_windows().values() {
sample(&window.as_ref().window(), false, false);
}
persist(app);
}
fn persist(app: &AppHandle) {
let write = || -> Result<(), Box<dyn std::error::Error>> {
let dir = app.path().app_config_dir()?;
std::fs::create_dir_all(&dir)?;
let cache = app.state::<Mutex<Cache>>();
let data = serde_json::to_vec_pretty(&cache.lock().saved)?;
let temporary = dir.join(format!("{FILE}.tmp"));
std::fs::write(&temporary, data)?;
std::fs::rename(temporary, dir.join(FILE))?;
Ok(())
};
if let Err(error) = write() {
tracing::warn!("cannot save Linux window state: {error}");
}
}
pub fn plugin() -> TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("linux-window-state")
.setup(|app, _| {
let saved = app
.path()
.app_config_dir()
.ok()
.and_then(|dir| std::fs::read(dir.join(FILE)).ok())
.and_then(|data| serde_json::from_slice(&data).ok())
.unwrap_or_default();
app.manage(Mutex::new(Cache { saved, ..Default::default() }));
let handle = app.clone();
app.listen("openidea://window-state-presented", move |event| {
#[derive(serde::Deserialize)]
struct Presented {
label: String,
request: u64,
}
let Ok(presented) = serde_json::from_str::<Presented>(event.payload()) else { return };
let Some(webview) = handle.get_webview_window(&presented.label) else { return };
let window = webview.as_ref().window();
let cloned = window.clone();
let _ = window.run_on_main_thread(move || {
if !cloned.is_visible().unwrap_or(false) {
return;
}
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
if pending.presentation_request != presented.request {
return;
}
pending.presented = true;
}
sample(&cloned, true, false);
});
});
Ok(())
})
.on_page_load(|webview, payload| {
if payload.event() == tauri::webview::PageLoadEvent::Finished {
let request = webview
.state::<Mutex<Cache>>()
.lock()
.windows
.get(webview.window().label())
.map(|p| p.presentation_request);
if let Some(request) = request {
request_presentation(webview, request);
}
}
})
.on_window_ready(|window| {
let cloned = window.clone();
window.on_window_event(move |event| match event {
WindowEvent::Resized(_) => {
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
pending.configured = true;
}
let request = cloned
.state::<Mutex<Cache>>()
.lock()
.windows
.get(cloned.label())
.filter(|p| !p.presented)
.map(|p| p.presentation_request);
if let Some(request) = request
&& let Some(webview) = cloned.app_handle().get_webview_window(cloned.label())
{
request_presentation(webview.as_ref(), request);
}
sample(&cloned, true, false);
}
WindowEvent::Moved(_) => sample(&cloned, false, false),
WindowEvent::Destroyed => {
cloned.state::<Mutex<Cache>>().lock().windows.remove(cloned.label());
}
_ => {}
});
})
.on_event(|app, event| {
if let tauri::RunEvent::Exit = event {
persist(app);
}
})
.build()
}
}
#[cfg(target_os = "linux")]
pub use runtime::{normal_size_requested, plugin, prepare, save_before_close};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_creation_preserves_geometry() {
let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() };
assert!(build_hidden(&mut config));
assert!(!config.visible);
assert_eq!((config.width, config.height), (1201.0, 801.0));
assert!(!build_hidden(&mut config));
}
}
@@ -0,0 +1,237 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct Size(pub(super) u32, pub(super) u32);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct Mode {
pub(super) maximized: bool,
pub(super) fullscreen: bool,
pub(super) minimized: bool,
}
impl Mode {
pub(super) fn normal(self) -> bool {
!self.maximized && !self.fullscreen && !self.minimized
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct ModeHistory(pub(super) Mode);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum NormalTransition {
Stable,
WaitForResize,
Restore,
}
impl ModeHistory {
pub(super) fn observe(&mut self, current: Mode, can_restore: bool) -> NormalTransition {
let returned = !self.0.normal() && current.normal();
if returned && !can_restore {
return NormalTransition::WaitForResize;
}
self.0 = current;
if returned { NormalTransition::Restore } else { NormalTransition::Stable }
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct Limits {
pub(super) min: (f64, f64),
pub(super) max: (Option<f64>, Option<f64>),
}
impl Limits {
pub(super) fn in_work_area(self, area: Option<Size>, scale: f64) -> Self {
let Some(Size(width, height)) = area.filter(|s| s.0 > 0 && s.1 > 0) else { return self };
Self {
max: (
Some(self.max.0.unwrap_or(f64::INFINITY).min((f64::from(width) / scale).floor())),
Some(self.max.1.unwrap_or(f64::INFINITY).min((f64::from(height) / scale).floor())),
),
..self
}
}
}
pub(super) fn restore_size(saved: Size, scale: f64, limits: Limits) -> Size {
let dimension = |pixels: u32, min: f64, max: Option<f64>| {
let logical = (f64::from(pixels) / scale).max(min).max(1.0);
// gtk_window_resize takes integer logical dimensions.
(logical.min(max.unwrap_or(f64::INFINITY).max(min)).round() * scale).round() as u32
};
Size(dimension(saved.0, limits.min.0, limits.max.0), dimension(saved.1, limits.min.1, limits.max.1))
}
pub(super) fn remember_size(previous: Size, current: Size, restoring: bool, mode: Mode) -> Size {
if restoring || !mode.normal() || current.0 == 0 || current.1 == 0 { previous } else { current }
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ModeAction {
Request,
Wait,
Track,
}
pub(super) fn mode_action(reached: bool, requested: bool, timed_out: bool) -> ModeAction {
if reached {
ModeAction::Track
} else if !requested {
ModeAction::Request
} else if timed_out {
ModeAction::Track
} else {
ModeAction::Wait
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub(super) struct Geometry {
pub(super) width: u32,
pub(super) height: u32,
pub(super) x: i32,
pub(super) y: i32,
pub(super) prev_x: i32,
pub(super) prev_y: i32,
pub(super) maximized: bool,
pub(super) fullscreen: bool,
pub(super) visible: bool,
pub(super) decorated: bool,
}
impl Geometry {
pub(super) fn set_normal_size(&mut self, size: Size) {
(self.width, self.height) = (size.0, size.1);
self.maximized = false;
self.fullscreen = false;
}
}
impl Default for Geometry {
fn default() -> Self {
Self {
width: 0,
height: 0,
x: 0,
y: 0,
prev_x: 0,
prev_y: 0,
maximized: false,
fullscreen: false,
visible: true,
decorated: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constrained_size_does_not_skip_the_first_mode_request() {
assert_eq!(mode_action(false, false, true), ModeAction::Request);
assert_eq!(mode_action(false, false, false), ModeAction::Request);
assert_eq!(mode_action(false, true, false), ModeAction::Wait);
assert_eq!(mode_action(false, true, true), ModeAction::Track);
assert_eq!(mode_action(true, false, false), ModeAction::Track);
}
#[test]
fn physical_sizes_use_the_actual_output_scale() {
for scale in [1.0, 2.0] {
assert_eq!(restore_size(Size(2468, 1356), scale, Limits::default()), Size(2468, 1356));
}
// Saved at scale 1, mapped on scale 2: keep physical pixels, not twice that size.
assert_eq!(restore_size(Size(1234, 678), 2.0, Limits::default()), Size(1234, 678));
}
#[test]
fn gtk_limits_and_integer_logical_dimensions_are_applied_once() {
let limits = Limits { min: (800.0, 500.0), max: (Some(1600.0), Some(1000.0)) };
assert_eq!(restore_size(Size(1201, 801), 1.0, limits), Size(1201, 801));
assert_eq!(restore_size(Size(1201, 801), 2.0, limits), Size(1600, 1000));
assert_eq!(restore_size(Size(2001, 1401), 2.0, limits), Size(2002, 1402));
assert_eq!(restore_size(Size(9000, 6000), 2.0, limits), Size(3200, 2000));
}
#[test]
fn damaged_sizes_are_bounded_in_the_actual_outputs_units() {
let limits = Limits { min: (800.0, 500.0), ..Default::default() };
for scale in [1.0, 2.0] {
let bounded = limits.in_work_area(Some(Size(3840, 2160)), scale);
assert_eq!(restore_size(Size(45068, 30000), scale, bounded), Size(3840, 2160));
assert_eq!(restore_size(Size(2468, 1356), scale, bounded), Size(2468, 1356));
}
}
#[test]
fn restoration_and_non_normal_events_preserve_the_normal_size() {
let normal = Size(1234, 678);
for current in [Size(2468, 1356), Size(2648, 1602), Size(5476, 3204)] {
assert_eq!(remember_size(normal, current, true, Mode::default()), normal);
for mode in [
Mode { maximized: true, ..Default::default() },
Mode { fullscreen: true, ..Default::default() },
Mode { minimized: true, ..Default::default() },
] {
assert_eq!(remember_size(normal, current, false, mode), normal);
}
}
assert_eq!(remember_size(normal, Size(1201, 801), false, Mode::default()), Size(1201, 801));
assert_eq!(remember_size(normal, Size(0, 678), false, Mode::default()), normal);
}
#[test]
fn a_mixed_dpi_maximized_launch_does_not_compound() {
let mut saved = Size(2468, 1356);
for _ in 0..3 {
saved = remember_size(saved, Size(saved.0 * 2, saved.1 * 2), true, Mode::default());
let restored = restore_size(saved, 2.0, Limits::default());
saved = remember_size(saved, restored, false, Mode::default());
saved = remember_size(saved, Size(3840, 2160), false, Mode { maximized: true, ..Default::default() });
assert_eq!(saved, Size(2468, 1356));
}
}
#[test]
fn moved_does_not_consume_the_unmaximize_resize() {
for mode in [
Mode { maximized: true, ..Default::default() },
Mode { fullscreen: true, ..Default::default() },
Mode { minimized: true, ..Default::default() },
] {
let mut history = ModeHistory(mode);
assert_eq!(history.observe(Mode::default(), false), NormalTransition::WaitForResize);
assert_eq!(history.0, mode);
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Restore);
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Stable);
}
}
#[test]
fn an_explicit_normal_frame_overrides_old_window_modes() {
let mut saved = Geometry { maximized: true, fullscreen: true, x: 123, y: 456, ..Default::default() };
saved.set_normal_size(Size(1100, 700));
assert_eq!((saved.width, saved.height), (1100, 700));
assert!(!saved.maximized && !saved.fullscreen);
assert_eq!((saved.x, saved.y), (123, 456));
}
#[test]
fn state_files_remain_compatible_and_keep_closed_windows() {
let saved: std::collections::HashMap<String, Geometry> = serde_json::from_str(
r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#,
)
.unwrap();
let copy: std::collections::HashMap<String, Geometry> =
serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
assert_eq!(copy["settings"].height, 701);
assert!(copy["main"].maximized);
assert_eq!(copy["main"].width, 1234);
}
}
+4 -1
View File
@@ -60,7 +60,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
if !maximized {
to.unmaximize().map_err(err)?;
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
to.set_size(from.inner_size().map_err(err)?).map_err(err)?;
let size = from.inner_size().map_err(err)?;
#[cfg(target_os = "linux")]
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
to.set_size(size).map_err(err)?;
} else {
to.maximize().map_err(err)?;
}
+9 -1
View File
@@ -298,6 +298,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
} else if let Some(a) = &appearance {
config.background_color = Some(frame_color(a, false));
}
#[cfg(target_os = "linux")]
let show_after_build = crate::linux_window_state::prepare(app, &mut config);
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| dev_font_preferences(b).build())
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
@@ -310,6 +312,10 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
{
let _ = window.set_background_color(Some(frame_color(&a, true)));
}
#[cfg(target_os = "linux")]
if show_after_build {
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
}
if visible {
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
if focused {
@@ -765,6 +771,8 @@ pub fn open_settings_window(
config.background_color = Some(color);
}
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
#[cfg(target_os = "linux")]
crate::linux_window_state::prepare(app, &mut config);
let builder = WebviewWindowBuilder::from_config(app, &config);
// AppKit child windows can disappear when dragged to another display's Space.
// The settings label already tracks ownership and parent-close cleanup.
@@ -832,7 +840,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri:
/// may still be on another display with a different scale, so its size is converted with its own
/// scale and its position is given in physical pixels.
#[cfg(not(target_os = "macos"))]
fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
return Ok(());
};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.32",
"version": "0.1.33",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+34
View File
@@ -1639,6 +1639,30 @@ export type ByteRange = {
total: number,
};
export type CMakeProfile = {
name?: string,
buildType?: string,
generator?: string,
cmakeArgs?: string[],
/** Empty uses cmake-build-<lowercase build type>. */
buildDir?: string,
buildArgs?: string[],
env?: { [key in string]: string },
};
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings = CMakeSettings_Serialize | CMakeSettings_Deserialize;
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings_Deserialize = {
profiles?: CMakeProfile[] | null,
};
/** Absent profiles retain automatic discovery for existing projects. */
export type CMakeSettings_Serialize = {
profiles?: CMakeProfile[] | null,
};
export type CallHierarchy = {
items: HierarchyItem[],
incomplete: ReferencesIncomplete | null,
@@ -3903,6 +3927,10 @@ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle_Deserialize = {
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
formatter?: string | null,
/** Eclipse formatter XML; relative paths start at the project root. */
formatterPath?: string | null,
useTab?: boolean | null,
tabSize?: number | null,
indent?: number | null,
@@ -3915,6 +3943,10 @@ export type LanguageCodeStyle_Deserialize = {
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle_Serialize = {
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
formatter?: string | null,
/** Eclipse formatter XML; relative paths start at the project root. */
formatterPath?: string | null,
useTab?: boolean | null,
tabSize?: number | null,
indent?: number | null,
@@ -5627,6 +5659,7 @@ export type Settings_Deserialize = {
deployment?: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
};
export type Settings_Serialize = {
@@ -5682,6 +5715,7 @@ export type Settings_Serialize = {
deployment: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets: RunTarget[],
cmake: CMakeSettings_Serialize,
};
export type Severity = "error" | "warning" | "info" | "hint";
+22 -22
View File
@@ -3,7 +3,6 @@
min-height: 0;
display: flex;
flex-direction: column;
container-type: inline-size;
}
.content {
flex: 1;
@@ -137,7 +136,7 @@
gap: 5px;
}
/* A conversation docked in the tool window, above the task list. */
/* A docked conversation owns the tool window; history opens on demand. */
.docked {
flex: 1 1 0;
min-height: 0;
@@ -168,30 +167,31 @@
flex-direction: column;
}
.tasks {
flex: 2 1 0;
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.history {
display: flex;
flex-direction: column;
width: min(400px, calc(100vw - 24px));
height: min(420px, 65vh);
overflow: hidden;
}
.historyTitle {
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */
.docked + .tasks {
flex: 0 0 20%;
max-height: 160px;
}
/* A wide bottom window has room beside the conversation, preserving message height. */
@container (min-width: 600px) {
.content {
flex-direction: row;
}
.docked + .tasks {
flex: 0 0 220px;
max-width: 30%;
max-height: none;
border-top: 0;
border-left: 1px solid var(--border);
}
.location {
display: flex;
align-items: center;
gap: 2px;
padding: 1px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
+48 -29
View File
@@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions";
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
import { confirmChecked, showError } from "@/workbench/messages";
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
import { AgentsView } from "./AgentsView";
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
import { taskActions, taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
@@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({
unarchiveTask: vi.fn(async () => {}),
deleteTask: vi.fn(async () => {}),
dockTask: vi.fn(),
undockTask: vi.fn(),
setSessionLocation: vi.fn(),
}));
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
@@ -103,6 +103,8 @@ beforeEach(() => {
error: null,
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
docked: null,
homeOpen: false,
sessionLocation: "editor",
});
dispose = registerActions(taskActions);
});
@@ -115,6 +117,8 @@ afterEach(() => {
function setup() {
render(
<>
<AgentsLocationActions />
<AgentsHeaderActions />
<AgentsView />
<ContextMenuHost />
</>,
@@ -158,24 +162,44 @@ describe("Agents selection", () => {
});
describe("Agents docked view", () => {
it("shows the docked conversation above the list and switches it by selecting a task", () => {
it("opens session history on demand and closes it after switching tasks", () => {
useAgents.setState({ docked: "one" });
setup();
expect(screen.getByTestId("docked-session")).toBeTruthy();
expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
select("two");
expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two");
expect(dockTask).toHaveBeenCalledWith("two");
expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two"));
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two");
expect(openTask).toHaveBeenCalledWith("two");
});
it("hides the docked task from the list below", () => {
it("lets keyboard users browse history before confirming a session", () => {
useAgents.setState({ docked: "one" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = screen.getByRole("tree", { name: "Agent tasks" });
fireEvent.keyDown(tree, { key: "ArrowDown" });
expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
fireEvent.keyDown(tree, { key: "Enter" });
expect(openTask).toHaveBeenCalledWith("two");
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
});
it("includes the current session in history and keeps the conversation mounted", () => {
useAgents.setState({ docked: "one" });
setup();
const session = screen.getByTestId("docked-session");
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = within(screen.getByRole("tree", { name: "Agent tasks" }));
expect(tree.queryByText("one")).toBeNull();
expect(tree.getByText("one")).toBeTruthy();
expect(tree.getByText("two")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
expect(screen.getByTestId("docked-session")).toBe(session);
});
it("gives the whole tool window to the conversation when the docked task is the only one", () => {
@@ -185,11 +209,15 @@ describe("Agents docked view", () => {
expect(screen.getByTestId("docked-session")).toBeTruthy();
});
it("undocks from the docked conversation's bar", () => {
useAgents.setState({ docked: "one" });
it("switches placement from the global Agents header", () => {
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
expect(undockTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
expect(setSessionLocation).toHaveBeenCalledWith("editor");
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
});
it("opens tabs as usual without a docked conversation", () => {
@@ -282,21 +310,12 @@ describe("Agents bulk actions", () => {
});
describe("Agents task menu", () => {
it("offers to dock a task into the tool window", async () => {
setup();
context("one");
fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" }));
await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one"));
});
it("offers to move the docked task back to an editor", async () => {
useAgents.setState({ docked: "one" });
setup();
// The docked task is not listed; its menu lives on the docked conversation's header.
it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => {
useAgents.setState({ docked });
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" }));
await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" });
await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
});
});
+153 -106
View File
@@ -7,6 +7,7 @@ import {
EmptyState,
Icon,
IconButton,
Popup,
Spinner,
T,
Tree,
@@ -18,16 +19,29 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type
import { openHome, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
import styles from "./Agents.module.css";
import { basename, useProject } from "@/platform/project";
import { useTx } from "@/platform/i18n";
import { executeAction } from "@/platform/actions";
export function AgentsHeaderActions() {
return <IconButton icon="plus" action="agents.newTask" size="small" />;
}
export function AgentsLocationActions() {
const location = useAgents((s) => s.sessionLocation);
const tx = useTx();
return (
<>
<IconButton icon="plus" action="agents.newTask" size="small" />
</>
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
<IconButton
icon="open-in-tool-window"
action="agents.useToolWindow"
pressed={location === "toolWindow"}
size="small"
/>
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} size="small" />
</div>
);
}
@@ -77,6 +91,10 @@ export function AgentsView() {
const unread = useAgents((s) => s.unread);
const providers = useAgents((s) => s.providers);
const docked = useAgents((s) => s.docked);
const homeOpen = useAgents((s) => s.homeOpen);
const inline = docked !== null || homeOpen;
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => {
const task = sessionTask(maybeEditorService()?.activeInput?.());
@@ -95,11 +113,7 @@ export function AgentsView() {
}),
[],
);
// The docked conversation is already shown above; listing it again is noise.
const roots = useMemo<Node[]>(
() => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })),
[tasks, docked],
);
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
// Dropping a session editor tab anywhere on the tool window docks it.
const viewRef = useRef<HTMLDivElement>(null);
@@ -126,123 +140,156 @@ export function AgentsView() {
);
if (error) return <EmptyState title="Cannot load agent tasks">{error}</EmptyState>;
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
// While a conversation is docked, clicking a task switches it instead of opening a tab.
const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task);
// Opening any session follows the global placement preference.
const showTask = (task: string) => {
setHistoryOpen(false);
void executeAction("agents.openTask", task);
};
const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null;
const dragTask = (e: ReactPointerEvent<HTMLSpanElement>, task: string) =>
startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task });
const taskList = (
<div className={styles.tasks}>
<Tree<Node>
aria-label="Agent tasks"
roots={roots}
getKey={nodeKey}
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
getChildren={(n) =>
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
}
isExpandable={(n) => n.kind === "group"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(nodeKey(node));
else s.add(nodeKey(node));
return s;
})
}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (!inline && sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
<span className={styles.groupLabel}>
<T>{n.group.label}</T>
</span>
<span className={styles.count}>{n.group.tasks.length}</span>
</span>
) : (
<span
className={styles.row}
title={n.task.error ?? n.task.title}
onPointerDown={(e) => dragTask(e, n.task.id)}
onClick={(e) => {
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
}}
>
<TaskStatusIcon status={n.task.status} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{n.task.title}
</span>
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
}
empty={
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
);
return (
<div ref={viewRef} className={clsx(styles.view, tabOver && styles.dropTarget)}>
<div className={styles.content}>
{docked && (
{inline && (
<div className={styles.docked}>
<div className={styles.dockedBar}>
<IconButton
ref={historyRef}
icon="history"
title="Agent sessions"
size="small"
aria-haspopup="dialog"
aria-expanded={historyOpen}
onClick={() => {
setSelected(new Set(docked ? [docked] : []));
setHistoryOpen((open) => !open);
}}
/>
<span
className={styles.dockedTitle}
title={dockedTitle ?? undefined}
onPointerDown={(e) => dragTask(e, docked)}
onPointerDown={(e) => docked && dragTask(e, docked)}
>
{dockedTitle}
{homeOpen ? <T>New Agent Task</T> : dockedTitle}
</span>
<IconButton
icon="editor-only"
action="agents.undockTask"
actionArg={docked}
title="Open in Editor"
size="small"
/>
</div>
<div className={styles.dockedSession}>
<SessionEditor
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
{homeOpen ? (
<HomeEditor docked />
) : (
docked && (
<SessionEditor
docked
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
)
)}
</div>
</div>
)}
{/* Docked with nothing else to list: give the whole window to the conversation. */}
{(roots.length > 0 || !docked) && (
<div className={styles.tasks}>
<Tree<Node>
aria-label="Agent tasks"
roots={roots}
getKey={nodeKey}
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
getChildren={(n) =>
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
}
isExpandable={(n) => n.kind === "group"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(nodeKey(node));
else s.add(nodeKey(node));
return s;
})
}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
<span className={styles.groupLabel}>
<T>{n.group.label}</T>
</span>
<span className={styles.count}>{n.group.tasks.length}</span>
</span>
) : (
<span
className={styles.row}
title={n.task.error ?? n.task.title}
onPointerDown={(e) => dragTask(e, n.task.id)}
>
<TaskStatusIcon status={n.task.status} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{n.task.title}
</span>
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
}
empty={
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
{!inline && taskList}
{inline && (
<Popup
open={historyOpen}
anchor={historyRef}
onClose={() => setHistoryOpen(false)}
aria-label={tx("Agent sessions")}
className={styles.history}
>
<div className={styles.historyTitle}>
<T>Agent sessions</T>
</div>
{taskList}
</Popup>
)}
</div>
</div>
+78
View File
@@ -1,5 +1,7 @@
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
.composer {
container: agent-composer / inline-size;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
@@ -44,9 +46,85 @@
padding: 4px 6px 6px;
cursor: default;
}
.toolbar {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
min-width: 0;
gap: 2px;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
}
.spacer {
flex: 1;
}
.configPicker {
display: flex;
min-width: 0;
}
.configPicker > button {
max-width: 100%;
}
.settingsMenu {
display: none;
}
.settingsPopup {
width: min(320px, calc(100vw - 24px));
padding: 12px;
}
.settingsTitle {
margin-bottom: 12px;
font-weight: 600;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(0, 80px) minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-top: 8px;
}
.settingsLabel {
min-width: 0;
color: var(--fg-muted);
overflow-wrap: anywhere;
}
@container agent-composer (max-width: 600px) {
.bar {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.toolbar {
flex-wrap: nowrap;
}
.toolbar > button,
.toolbar > .configPicker {
flex: 0 1 auto;
min-width: 0;
max-width: calc((100% - 28px) / 2);
}
.configPicker > button {
width: 100%;
}
.inlineSetting {
display: none;
}
.settingsMenu {
display: flex;
flex: none;
margin-left: auto;
}
.actions {
width: 100%;
margin-left: 0;
}
}
.pickerValue {
display: inline-flex;
align-items: baseline;
+31 -24
View File
@@ -403,30 +403,37 @@ export function Composer(props: ComposerProps) {
}}
/>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
{props.toolbar}
<span className={styles.spacer} />
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
{props.actions}
<div className={styles.toolbar}>{props.toolbar}</div>
<div className={styles.actions}>
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton
icon="file-image"
title="Attach Image…"
size="small"
onClick={() => imageInput.current?.click()}
/>
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
<span className={styles.spacer} />
{props.actions}
</div>
</div>
</div>
);
+26 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentConfigOption } from "@/bindings";
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
@@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => {
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
});
it("edits secondary settings from the compact popup and restores focus when dismissed", () => {
const approval: AgentConfigOption = {
id: "approvals",
name: "Approvals",
category: "approval",
description: null,
current: "ask",
via: "option",
values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")],
};
const onChange = vi.fn();
render(<ConfigPickers config={[approval]} values={{ approvals: "bypass" }} onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "Agent settings" });
trigger.focus();
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "Agent settings" });
fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" }));
fireEvent.click(screen.getByRole("option", { name: "Ask first" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask");
expect(dialog).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
+94 -39
View File
@@ -2,9 +2,11 @@
* Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row.
* Modes and approvals use icons; levels are plain text.
*/
import { useRef, useState } from "react";
import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Select } from "@/ui";
import { IconButton, Popup, Select, T } from "@/ui";
import type { SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -43,47 +45,100 @@ export function ConfigPickers({
onChange: (option: string, value: string) => void;
}) {
const tx = useTx();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
const options = mainOptions(config);
const secondary = options.filter((o) => o.category !== "model");
const approval = secondary.find((o) => o.category === "approval" || o.category === "mode");
const summary = secondary
.map((o) => {
const value = values?.[o.id] ?? o.current;
return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`;
})
.join(" · ");
return (
<>
{mainOptions(config).map((o) => {
const isMode = o.category === "mode";
const value = values?.[o.id] ?? o.current ?? null;
const options = configPickerOptions(o);
return (
<Select
key={o.id}
variant="ghost"
aria-label={o.name}
value={value}
placeholder={tx(o.name)}
options={options.map((opt) => ({
...opt,
icon:
o.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: isMode
? modeIcon(opt.value)
: undefined,
}))}
searchable={o.category === "model"}
onChange={(v) => onChange(o.id, v)}
// Effort and other levels say what they are: "Effort Medium".
renderValue={
o.category === "thought_level"
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{o.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
{options.map((o) => (
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} />
</span>
))}
{secondary.length > 0 && (
<div className={styles.settingsMenu}>
<IconButton
ref={anchor}
icon={approval ? modeIcon(values?.[approval.id] ?? approval.current ?? "") : "settings"}
title={`${tx("Agent settings")}: ${summary}`}
aria-label={tx("Agent settings")}
aria-haspopup="dialog"
aria-expanded={open}
size="small"
onClick={() => setOpen((v) => !v)}
/>
);
})}
<Popup
open={open}
anchor={anchor}
onClose={() => setOpen(false)}
aria-label={tx("Agent settings")}
className={styles.settingsPopup}
>
<div className={styles.settingsTitle}>
<T>Agent settings</T>
</div>
{secondary.map((o) => (
<div key={o.id} className={styles.settingsRow}>
<span className={styles.settingsLabel}>{tx(o.name)}</span>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} framed />
</div>
))}
</Popup>
</div>
)}
</>
);
}
function ConfigPicker({
option,
value,
onChange,
framed = false,
}: {
option: AgentConfigOption;
value: string | null;
onChange: (option: string, value: string) => void;
framed?: boolean;
}) {
const tx = useTx();
return (
<Select
variant={framed ? "framed" : "ghost"}
aria-label={option.name}
value={value}
placeholder={tx(option.name)}
options={configPickerOptions(option).map((opt) => ({
...opt,
icon:
option.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: option.category === "mode"
? modeIcon(opt.value)
: undefined,
}))}
searchable={option.category === "model"}
onChange={(v) => onChange(option.id, v)}
renderValue={
option.category === "thought_level" && !framed
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{option.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
/>
);
}
+44
View File
@@ -60,3 +60,47 @@
.warning {
color: var(--danger);
}
.docked {
justify-content: flex-end;
padding: 16px 12px 12px;
}
.docked .inner {
gap: 8px;
}
.docked .where {
order: -1;
flex-wrap: nowrap;
min-width: 0;
gap: 6px;
}
.docked .project,
.docked .projectPicker {
flex: 0 1 auto;
min-width: 0;
max-width: 30%;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docked .branch,
.docked .basePicker {
order: 1;
flex: 1 1 0;
min-width: 0;
padding: 0;
}
.docked .isolation {
order: 2;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
margin-left: auto;
}
.branchName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+31 -17
View File
@@ -1,4 +1,5 @@
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
import clsx from "clsx";
import { useEffect, useState } from "react";
import type { AgentProbe, Branch } from "@/bindings";
import { executeAction } from "@/platform/actions";
@@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
import { basename, useProject } from "@/platform/project";
import { Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft } from "./composerModel";
@@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions";
import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store";
import styles from "./Home.module.css";
export function HomeEditor() {
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
const tx = useTx();
const txTemplate = useTxTemplate();
const draft = useAgents((s) => s.draft);
@@ -86,15 +87,19 @@ export function HomeEditor() {
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
return (
<div className={styles.home}>
<div className={clsx(styles.home, docked && styles.docked)}>
<div className={styles.inner}>
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
{!docked && (
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
)}
<Composer
id={HOME}
autoFocus
placeholder="Describe a task for the agent. @ references a file, / runs a command"
placeholder={
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
}
root={root}
projectRoot={root}
commands={agentCommands}
@@ -131,6 +136,7 @@ export function HomeEditor() {
<Select
variant="ghost"
aria-label={tx("Project")}
className={styles.projectPicker}
value={root}
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
onChange={(path) => {
@@ -139,12 +145,15 @@ export function HomeEditor() {
}}
/>
) : (
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
<span className={styles.project} title={root}>
{members.length ? basename(root) : project?.name}
</span>
)}
{isGit && (
<Select
variant="ghost"
aria-label="Isolation"
className={styles.isolation}
value={isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
@@ -160,23 +169,28 @@ export function HomeEditor() {
)}
{isolation === "local" && current && (
<>
<span className={styles.note}>
<T>on</T>
</span>
<span className={styles.branch}>
{!docked && (
<span className={styles.note}>
<T>on</T>
</span>
)}
<span className={styles.branch} title={current}>
<Icon name="branch" size={14} />
{current}
<span className={styles.branchName}>{current}</span>
</span>
</>
)}
{isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
<T>from</T>
</span>
{!docked && (
<span className={styles.note}>
<T>from</T>
</span>
)}
<Select
variant="ghost"
aria-label="Base branch"
className={styles.basePicker}
value={base}
options={branches.map((b) => ({ value: b.name, label: b.name }))}
onChange={(b) => updateDraft({ base: b === current ? null : b })}
@@ -184,7 +198,7 @@ export function HomeEditor() {
</>
)}
</div>
{probe && (
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
+25 -1
View File
@@ -16,6 +16,20 @@
border-right: 1px solid var(--border);
}
.conversation[hidden] {
display: none;
}
.docked {
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
}
.docked .conversation {
border-right: 0;
}
.docked .changes {
width: auto;
}
/* The conversation hugs its content; spare space stays below the composer
instead of inside the chat. */
.streamArea,
@@ -568,6 +582,7 @@
width: 280px;
}
.changesHeader {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
@@ -577,11 +592,17 @@
border-bottom: 1px solid var(--border);
}
.changesTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--fg-strong);
}
.changesSummary {
flex: none;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 10px;
color: var(--fg-subtle);
white-space: pre;
@@ -608,8 +629,11 @@
gap: 4px;
}
.changeName {
flex: none;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.changeDir {
flex: 1 1 0;
+30 -14
View File
@@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio
import { taskMenu } from "./taskActions";
import styles from "./Session.module.css";
export function SessionEditor({ input }: { input: EditorInput }) {
export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) {
const id = (input.data as { task: string }).task;
return <TaskSession key={id} id={id} />;
return <TaskSession key={id} id={id} docked={docked} />;
}
function TaskSession({ id }: { id: string }) {
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
const task = useAgents((s) => s.tasks.get(id));
// The tool window bar above already shows the title of a docked conversation.
const docked = useAgents((s) => s.docked === id);
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
const project = useProject((s) => s.project);
@@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) {
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
const view = path.at(-1) ?? null;
@@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) {
useEffect(() => {
void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
return markVisible(id);
}, [id]);
useEffect(() => {
if (!docked || !changesOpen) return markVisible(id);
}, [id, docked, changesOpen]);
useEffect(() => {
if (view) void loadEntries(id, view).catch(() => undefined);
}, [id, view]);
@@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) {
}
}}
>
<div className={styles.conversation}>
<div className={styles.conversation} hidden={docked && changesOpen}>
<Header
task={task}
entries={entries ?? null}
@@ -158,7 +158,13 @@ function TaskSession({ id }: { id: string }) {
)}
</div>
{changesOpen && (
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
<ChangesPane
task={task}
changes={changes ?? null}
head={head}
docked={docked}
onHide={() => setChangesOpen(false)}
/>
)}
</div>
);
@@ -270,6 +276,7 @@ function Header({
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
@@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record<string, boolean> {
/**
* Whether the Changes pane is shown for `task`: the user's choice once made, else
* only while the task has changes (so it opens when the agent first edits a file).
* only while the task has changes. Docked sessions stay in the conversation until
* the user explicitly opens changes; navigation there does not change editor preferences.
*/
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
function useChangesOpen(
task: string,
changes: AgentChange[] | null,
docked: boolean,
): [boolean, (open: boolean) => void] {
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
task,
open: loadChangesOpen()[task],
open: docked ? false : loadChangesOpen()[task],
}));
const chosen = state.task === task ? state.open : loadChangesOpen()[task];
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
const set = (open: boolean) => {
setState({ task, open });
if (docked) return;
try {
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
} catch {
@@ -1315,11 +1328,13 @@ function ChangesPane({
task,
changes,
head,
docked,
onHide,
}: {
task: AgentTask;
changes: AgentChange[] | null;
head: string | null;
docked: boolean;
onHide: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
@@ -1328,6 +1343,7 @@ function ChangesPane({
return (
<aside className={styles.changes} aria-label={tx("Changes")}>
<div className={styles.changesHeader}>
{docked && <IconButton icon="arrow-left" title="Back to conversation" size="small" onClick={onHide} />}
<span className={styles.changesTitle}>
<T>Changes</T>
</span>
@@ -1338,7 +1354,7 @@ function ChangesPane({
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
</div>
{(head ?? task.base) && (
<div className={styles.changesSummary}>
+3 -23
View File
@@ -54,6 +54,7 @@ export function activate(): () => void {
anchor: "right",
order: 0.5,
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
const n = useAgents(
@@ -80,11 +81,10 @@ export function activate(): () => void {
icon: () => "toolwindow-agents",
restorable: true,
}),
// A task dragged from the tool window onto the editor opens as a tab (or undocks).
// An explicit editor drop sets the placement for subsequent sessions too.
registerEditorDropHandler({
accepts: (type) => type === TASK_DRAG,
drop: ({ payload }) =>
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
drop: ({ payload }) => void executeAction("agents.openTaskInEditor", payload),
}),
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
registerMenuGroup({
@@ -96,26 +96,6 @@ export function activate(): () => void {
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
: [],
}),
registerMenuGroup({
// Session tabs have no path; "Open in Tool Window" docks them beside the task list.
id: "agents.tab",
menus: ["editorTab"],
order: 50,
items: (_menu, { paths, input }) => {
const task = input?.kind === SESSION_KIND ? (input.data as { task?: string } | undefined)?.task : undefined;
return paths.length || !task
? []
: [
{
kind: "action",
action: "agents.dockTask",
label: "Open in Tool Window",
icon: "open-in-tool-window",
arg: task,
},
];
},
}),
registerActions([
...taskActions,
{
+50 -6
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import type { AgentEntry, AgentTask } from "@/bindings";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor } from "./SessionEditor";
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
@@ -9,7 +9,7 @@ import { disconnect, useAgents } from "./store";
import { taskActions } from "./taskActions";
import styles from "./Session.module.css";
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>(), changes: [] as AgentChange[] }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
@@ -17,7 +17,7 @@ vi.mock("@/platform/ipc", async (original) => ({
status: "ok",
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
}),
agentChanges: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: backend.changes }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
@@ -76,6 +76,8 @@ afterEach(() => {
beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.entries = new Map([
["A", [{ type: "agent", text: "Answer A" }, subagent]],
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
@@ -83,6 +85,8 @@ beforeEach(() => {
]);
useAgents.setState({
loaded: true,
sessionLocation: "editor",
homeOpen: false,
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
entries: new Map(backend.entries),
});
@@ -92,12 +96,13 @@ describe("session state across tasks and hosts", () => {
it("returns to the task conversation when the docked task changes after opening a subagent", async () => {
const dispose = registerActions(taskActions);
try {
useAgents.setState({ docked: "A" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
expect(await screen.findByText("Child answer")).toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Message" })).not.toBeInTheDocument();
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"), { button: 0 });
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"));
expect(await screen.findByText("Answer B")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
@@ -110,6 +115,45 @@ describe("session state across tasks and hosts", () => {
}
});
it("keeps docked changes opt-in, preserves the draft, and returns to the same conversation", async () => {
backend.changes = [
{
path: "/project/very-long-file-name.ts",
relPath: "very-long-file-name.ts",
kind: "modified",
added: 8,
removed: 2,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
act(() => useAgents.setState({ unread: new Set(["A"]) }));
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.has("A")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Back to conversation" }));
expect(screen.getByText("Answer A")).toBeVisible();
expect(stream()).toBe(conversation);
expect(markVisible).toHaveBeenCalledWith("A");
expect(useAgents.getState().unread.has("A")).toBe(false);
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
expect(message).toHaveValue("Draft to keep");
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
});
it.each([false, true])("retains scrolling and follow-output (%s) when moving in both directions", async (stick) => {
// Give jsdom measurable scroll geometry; each host has a different viewport.
let viewport = 400;
@@ -133,7 +177,7 @@ describe("session state across tasks and hosts", () => {
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
view.unmount();
viewport = 150;
useAgents.setState({ docked: "A" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
const dock = render(<AgentsView />);
await screen.findByText("Answer A");
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
+77 -8
View File
@@ -44,6 +44,8 @@ export interface Draft {
/** Composer key of the New Task page; sessions use their task id. */
export const HOME = "home";
export type SessionLocation = "editor" | "toolWindow";
interface AgentsState {
loaded: boolean;
/** Why the tasks could not be loaded. */
@@ -67,11 +69,15 @@ interface AgentsState {
focus: { key: string; seq: number } | null;
/** Task shown inside the Agents tool window instead of an editor tab. */
docked: string | null;
sessionLocation: SessionLocation;
lastTask: string | null;
homeOpen: boolean;
}
const DRAFT_KEY = "openidea.agents.draft.v2";
const HISTORY_KEY = "openidea.agents.history.v1";
const HISTORY_SIZE = 50;
const SESSION_LOCATION_KEY = "openidea.agents.sessionLocation.v1";
function load<T>(key: string, fallback: T): T {
try {
@@ -106,6 +112,9 @@ export const useAgents = create<AgentsState>(() => ({
history: load<string[]>(HISTORY_KEY, []),
focus: null,
docked: null,
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
}));
@@ -210,6 +219,8 @@ export function disconnect() {
entries: new Map(),
changes: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
});
}
@@ -353,24 +364,76 @@ export function useAgentCommands(
}
export function openTask(task: string) {
if (useAgents.getState().sessionLocation === "toolWindow") {
showDockedTask(task);
return;
}
useAgents.setState({ lastTask: task, homeOpen: false });
openTaskInEditor(task);
}
function openTaskInEditor(task: string) {
// The title rides along so a restored tab has it before the tasks load.
const title = useAgents.getState().tasks.get(task)?.title;
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
}
/** Shows the conversation inside the Agents tool window, closing its editor tab. */
export function dockTask(task: string) {
useAgents.setState({ docked: task });
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
/** Moves the docked conversation back to an editor tab. */
function saveSessionLocation(location: SessionLocation) {
useAgents.setState({ sessionLocation: location });
save(SESSION_LOCATION_KEY, location);
if (location === "toolWindow") {
for (const { input } of editorService().openTabs?.() ?? []) {
if (input.kind === SESSION_KIND || input.kind === HOME_KIND) void editorService().closeInput?.(input.key);
}
}
}
/** Conversation placement is an Agents-wide preference, shared by every task. */
export function setSessionLocation(location: SessionLocation) {
const s = useAgents.getState();
if (s.sessionLocation === location) return;
const active = editorService().activeInput?.();
const activeTask = active?.kind === SESSION_KIND ? (active.data as { task: string }).task : null;
const home = s.homeOpen || active?.kind === HOME_KIND;
const task =
s.docked ??
activeTask ??
(s.lastTask && s.tasks.has(s.lastTask) ? s.lastTask : null) ??
[...s.tasks.values()]
.filter((t) => t.status !== "archived")
.sort((a, b) => (b.updatedMs ?? b.createdMs ?? 0) - (a.updatedMs ?? a.createdMs ?? 0))[0]?.id;
saveSessionLocation(location);
useAgents.setState({ docked: null, homeOpen: false });
if (home) {
openHome();
} else if (task) {
openTask(task);
} else if (location === "toolWindow") {
openHome();
}
}
/** Dragging a session into the tool window selects the same placement for other tasks. */
export function dockTask(task: string) {
saveSessionLocation("toolWindow");
showDockedTask(task);
}
export function undockTask() {
const docked = useAgents.getState().docked;
if (!docked) return;
useAgents.setState({ docked: null });
openTask(docked);
setSessionLocation("editor");
}
/** An explicit editor drop also updates where subsequent sessions open. */
export function openTaskInEditorLocation(task: string) {
saveSessionLocation("editor");
useAgents.setState({ docked: null, homeOpen: false });
openTask(task);
}
/** Opens the New Task page; `prompt` pre-fills it. */
@@ -382,6 +445,12 @@ export function openHome(prompt?: string) {
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
if (useAgents.getState().sessionLocation === "toolWindow") {
useAgents.setState({ docked: null, homeOpen: true });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(HOME_KEY);
return;
}
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
+35 -11
View File
@@ -4,7 +4,18 @@ import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
import {
archiveTask,
unarchiveTask,
deleteTask,
dockTask,
forkTask,
openTask,
openTaskInEditorLocation,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
@@ -59,6 +70,29 @@ async function confirmDelete(tasks: AgentTask[]) {
}
export const taskActions: Action[] = [
{
id: "agents.useToolWindow",
title: "Chat in Tool Window",
category: "Agents",
icon: "open-in-tool-window",
run: () => setSessionLocation("toolWindow"),
},
{
id: "agents.useEditor",
title: "Chat in Editor",
category: "Agents",
icon: "editor-only",
run: () => setSessionLocation("editor"),
},
{
id: "agents.openTaskInEditor",
title: "Open Agent Task in Editor",
category: "Agents",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") openTaskInEditorLocation(arg);
},
},
{
id: "agents.openTask",
title: "Open Agent Task",
@@ -131,20 +165,10 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
const multiple = tasks.length > 1;
const archived = tasks.filter((task) => task.status === "archived").length;
const archivable = tasks.filter(canArchive).length;
const docked = useAgents.getState().docked;
return [
...(!multiple
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
docked === task.id
? { kind: "action", action: "agents.undockTask", label: "Open in Editor", icon: "editor-only" }
: {
kind: "action",
action: "agents.dockTask",
label: "Open in Tool Window",
icon: "open-in-tool-window",
arg: task.id,
},
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
...(task.capabilities?.fork && task.status !== "archived"
? ([
+64 -5
View File
@@ -1,15 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
import { dropOnEditor, useContributions } from "@/workbench/contributions";
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import { AgentsView } from "./AgentsView";
import { AgentsStatus } from "./AgentsStatus";
import { activate } from "./index";
import * as actions from "@/platform/actions";
import { TASK_DRAG } from "./model";
import { connect, disconnect, dockTask, markVisible, undockTask, useAgents } from "./store";
import {
connect,
disconnect,
dockTask,
markVisible,
openHome,
openTask,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
active: null as EditorInput | null,
tabs: [] as OpenEditorTab[],
channel: null as { onmessage: (event: AgentEvent) => void } | null,
tasks: [] as AgentTask[],
notify: vi.fn(),
@@ -38,7 +50,12 @@ vi.mock("@/platform/ipc", async (original) => ({
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
editorService: () => ({
openInput: backend.openInput,
closeInput: backend.closeInput,
activeInput: () => backend.active,
openTabs: () => backend.tabs,
}),
toolWindowService: () => ({ show: backend.showToolWindow }),
notifications: () => ({ notify: backend.notify }),
}));
@@ -81,6 +98,10 @@ beforeEach(async () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
disconnect();
backend.active = null;
backend.tabs = [];
useAgents.setState({ sessionLocation: "editor" });
localStorage.clear();
backend.tasks = [task("one"), task("two", "ready")];
await connect();
});
@@ -251,7 +272,7 @@ describe("docking a conversation in the tool window", () => {
const dispose = activate();
const execute = vi.spyOn(actions, "executeAction");
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "two");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
@@ -260,7 +281,7 @@ describe("docking a conversation in the tool window", () => {
backend.openInput.mockClear();
act(() => dockTask("one"));
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "one");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
@@ -269,9 +290,47 @@ describe("docking a conversation in the tool window", () => {
dispose();
});
it("moves the active session and applies the header choice to every subsequent session", () => {
backend.active = { kind: "agent-session", key: "agent:two", data: { task: "two" } };
backend.tabs = ["one", "two"].map((id) => ({
input: { kind: "agent-session", key: `agent:${id}`, data: { task: id } },
title: id,
icon: "toolwindow-agents",
lastActive: 0,
}));
act(() => setSessionLocation("toolWindow"));
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
expect(backend.closeInput).toHaveBeenCalledWith("agent:two");
expect(useAgents.getState().docked).toBe("two");
expect(backend.openInput).not.toHaveBeenCalled();
act(() => openTask("one"));
expect(useAgents.getState().docked).toBe("one");
expect(backend.openInput).not.toHaveBeenCalled();
expect(JSON.parse(localStorage.getItem("openidea.agents.sessionLocation.v1")!)).toBe("toolWindow");
act(() => setSessionLocation("editor"));
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:one" }), { focus: true });
act(() => openTask("two"));
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:two" }), { focus: true });
});
it("opens new tasks in the selected location and moves an unfinished draft with the layout", () => {
act(() => setSessionLocation("toolWindow"));
backend.openInput.mockClear();
act(() => openHome("Keep this draft"));
expect(useAgents.getState().homeOpen).toBe(true);
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).not.toHaveBeenCalled();
act(() => setSessionLocation("editor"));
expect(useAgents.getState().homeOpen).toBe(false);
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "agent-home" }), { focus: true });
expect(useAgents.getState().composers.get("home")?.text).toBe("Keep this draft");
});
it("drops the dock when the project changes", async () => {
act(() => dockTask("one"));
disconnect();
expect(useAgents.getState().docked).toBeNull();
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
});
});
@@ -6,6 +6,7 @@ const ipc = vi.hoisted(() => {
return {
ok,
debugStep: vi.fn(() => ok(null)),
debugResume: vi.fn(() => ok(null)),
debugForceStepOver: vi.fn(() => ok(null)),
debugRunTo: vi.fn(() => ok(null)),
debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)),
@@ -54,7 +55,7 @@ import type { DebugCapabilities } from "@/platform/ipc";
import { registerDebugActions } from "../actions";
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
import { publishContext, useDebug, type DebugSession } from "../state";
import { publishContext, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
import { focusDebugView, blurDebugView } from "../ui/focusedView";
import { useOverlays } from "../ui/popoverStore";
@@ -97,6 +98,7 @@ function paused(p: Partial<DebugSession> = {}) {
};
useDebug.setState({ sessions: { 3: s }, active: 3 });
publishContext();
updateExecutionPoint();
}
function caret(path: string, language: string, doc: string, line: number) {
@@ -154,15 +156,41 @@ describe("debugger actions (IDEA parity)", () => {
expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus");
});
it.each([
["debug.resume", "debugResume"],
["debug.stepOver", "debugStep"],
["debug.stepInto", "debugStep"],
["debug.stepOut", "debugStep"],
["debug.forceStepOver", "debugForceStepOver"],
["debug.forceStepInto", "debugStep"],
["debug.smartStepInto", "debugStep"],
["debug.runToCursor", "debugRunTo"],
["debug.forceRunToCursor", "debugRunTo"],
["debug.resetFrame", "debugRestartFrame"],
] as const)("%s clears the old execution point before its request", async (id, command) => {
paused({ caps: caps({ restartFrame: true }) });
caret("/p/app.py", "python", "a\nb\nc", 2);
const request = ipc[command].mockImplementationOnce(() => {
expect(useExecutionPoint.getState().path).toBeNull();
expect(useDebug.getState().sessions[3]?.status).toBe("running");
return ipc.ok(null);
});
await executeAction(id);
expect(request).toHaveBeenCalledOnce();
});
it("Force Step Over / Force Step Into / Force Run to Cursor", async () => {
paused();
caret("/p/app.py", "python", "a\nb\nc\nd", 2);
await executeAction("debug.forceStepOver");
expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1);
paused();
await executeAction("debug.forceStepInto");
expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into");
paused();
await executeAction("debug.forceRunToCursor");
expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true);
paused();
await executeAction("debug.runToCursor");
expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false);
});
@@ -180,6 +208,7 @@ describe("debugger actions (IDEA parity)", () => {
expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71);
expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]);
paused({ caps: caps({ stepInTargets: true }) });
ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }]));
await executeAction("debug.smartStepInto");
await settle();
@@ -166,6 +166,60 @@ describe("breakpoint gutter clicks", () => {
});
describe("execution point", () => {
it("keeps the execution line synchronized if inline rendering throws", async () => {
useExecutionPoint.setState({ path: null, values: {} });
const errors: unknown[] = [];
const view = new EditorView({
state: EditorState.create({
doc: "x\nx\nx",
extensions: [executionPoint(ctx), EditorView.exceptionSink.of((e) => errors.push(e))],
}),
parent: document.body,
});
views.push(view);
const values: Record<string, string> = Object.defineProperty({}, "x", {
enumerable: true,
get() {
throw new Error("inline rendering failed");
},
});
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values });
await flush();
expect(errors).toHaveLength(1);
useExecutionPoint.setState({ line: 2, values: {} });
await flush();
expect(view.dom.querySelectorAll(".cm-line")[2]!.className).toContain(styles.execLine);
useExecutionPoint.setState({ path: null });
await flush();
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
});
it("keeps refreshing the execution line around Object.prototype names", async () => {
useExecutionPoint.setState({ path: null, values: {} });
const view = makeView("String text = Integer.toString(40);\nint count = 40;\ncount++;\ncount++;");
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { text: "40" } });
await flush();
useExecutionPoint.setState({ line: 3, values: { text: "40", count: "42" } });
await flush();
expect(view.dom.querySelector(`.${styles.execLine}`)?.textContent).toContain("count++");
useExecutionPoint.setState({ path: null, values: {} });
await flush();
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
});
it.each([2, null])("refreshes a cached editor state when remounted at %s", async (line) => {
useExecutionPoint.setState({ path: ctx.path, line: 0, top: true, values: {} });
const view = makeView("first\nsecond\nthird");
const state = view.state;
view.destroy();
views.splice(views.indexOf(view), 1);
useExecutionPoint.setState({ path: line == null ? null : ctx.path, line: line ?? 0, values: {} });
const restored = new EditorView({ state, parent: document.body });
views.push(restored);
await flush();
expect(restored.dom.querySelector(`.${styles.execLine}`)?.textContent ?? null).toBe(line == null ? null : "third");
});
it("highlights the suspended line and shows inline values", async () => {
const view = makeView("x = 1\ny = x + 1\nprint(y)");
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } });
@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StackFrame, Variable } from "@/platform/ipc";
const ipc = vi.hoisted(() => {
const ok = <T>(data: T) => ({ status: "ok" as const, data });
return {
ok,
debugCapabilities: vi.fn(async () => ok(null)),
debugThreads: vi.fn(async () =>
ok([
{ id: 5, name: "http-nio-8179-exec-5" },
{ id: 1, name: "main" },
]),
),
debugStackTrace: vi.fn(async () => ok([] as StackFrame[])),
debugScopes: vi.fn(async () => ok([{ name: "Locals", variablesReference: 10, expensive: false }])),
debugVariables: vi.fn(async () => ok([] as Variable[])),
};
});
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
const { ok: _ok, ...commands } = ipc;
return { ...real, commands: { ...real.commands, ...commands } };
});
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
maybeEditorService: () => ({ openFile: vi.fn(async () => {}) }),
}));
import { adoptSession, handleDebugEvent, selectThread, resumeExecution, useDebug, useExecutionPoint } from "../state";
const path = "/proj/MsMemberUtils.java";
const frame = (line: number, id = 100): StackFrame => ({
id,
name: "queryMsTicket",
path,
line,
column: 0,
presentationHint: null,
});
const stopped = (threadId = 5, allThreadsStopped = false) =>
handleDebugEvent(1, { kind: "stopped", threadId, allThreadsStopped, reason: "step", description: null });
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
beforeEach(() => {
vi.clearAllMocks();
useDebug.setState({ sessions: {}, active: null, stopSeq: 0 });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
adoptSession(1, { adapter: "java-debug" });
ipc.debugStackTrace.mockResolvedValue(ipc.ok([frame(6840)]));
ipc.debugVariables.mockResolvedValue(ipc.ok([]));
});
describe("execution point state", () => {
it.each([false, true])("selects each stopped thread and its fresh top frame (all=%s)", async (all) => {
stopped(5, all);
await settle();
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 });
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
stopped(5, all);
await settle();
expect(useDebug.getState().sessions[1]).toMatchObject({ selectedThread: 5, selectedFrame: 100 });
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(9)]));
stopped(1, all);
await settle();
expect(useDebug.getState().sessions[1]?.selectedThread).toBe(1);
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 9 });
});
it("clears the old point while a new stack or selected thread is loading", async () => {
stopped();
await settle();
const pending = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
ipc.debugStackTrace.mockReturnValueOnce(pending.promise);
stopped();
expect(useExecutionPoint.getState().path).toBeNull();
pending.resolve(ipc.ok([frame(6842)]));
await settle();
const other = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
ipc.debugStackTrace.mockReturnValueOnce(other.promise);
const selecting = selectThread(1, 1);
expect(useExecutionPoint.getState().path).toBeNull();
other.resolve(ipc.ok([frame(9)]));
await selecting;
expect(useExecutionPoint.getState().line).toBe(9);
});
it("ignores a stack response from an earlier stop on the same thread", async () => {
const old = deferred<ReturnType<typeof ipc.ok<StackFrame[]>>>();
ipc.debugStackTrace.mockReturnValueOnce(old.promise);
stopped();
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
stopped();
await settle();
old.resolve(ipc.ok([frame(6840)]));
await settle();
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
expect(useDebug.getState().sessions[1]?.frames[0]?.line).toBe(6842);
});
it("keeps the selected paused thread when only another thread continues", async () => {
stopped();
await settle();
handleDebugEvent(1, { kind: "continued", threadId: 1, allThreadsContinued: false });
expect(useExecutionPoint.getState().path).toBe(path);
expect(useDebug.getState().sessions[1]?.status).toBe("paused");
handleDebugEvent(1, { kind: "continued", threadId: 5, allThreadsContinued: false });
expect(useExecutionPoint.getState().path).toBeNull();
});
it.each(["continued", "terminated"] as const)("clears the execution point on %s", async (kind) => {
stopped();
await settle();
handleDebugEvent(1, kind === "continued" ? { kind, threadId: 5, allThreadsContinued: true } : { kind });
expect(useExecutionPoint.getState()).toMatchObject({ path: null, values: {} });
});
it("retains locals whose names are Object.prototype properties", async () => {
const names = Object.getOwnPropertyNames(Object.prototype);
ipc.debugVariables.mockResolvedValueOnce(
ipc.ok(
names.map((name) => ({ name, value: "known", typeName: null, variablesReference: 0, evaluateName: name })),
),
);
stopped();
await settle();
for (const name of names) expect(Object.hasOwn(useExecutionPoint.getState().values, name), name).toBe(true);
});
it.each(["step", "continue"])("clears suspended data before a %s request resolves", async () => {
stopped();
await settle();
const pending = deferred<void>();
const request = vi.fn(() => {
expect(useExecutionPoint.getState().path).toBeNull();
expect(useDebug.getState().sessions[1]).toMatchObject({ status: "running", frames: [], selectedFrame: null });
return pending.promise;
});
const result = resumeExecution(1, request);
expect(request).toHaveBeenCalledOnce();
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
stopped();
await settle();
pending.resolve();
await result;
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
});
it("restores suspended data if the command fails without a newer event", async () => {
stopped();
await settle();
await expect(
resumeExecution(1, async () => {
throw new Error("rejected");
}),
).rejects.toThrow("rejected");
expect(useDebug.getState().sessions[1]?.status).toBe("paused");
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6840 });
});
it("does not restore an older stop when a failed command has already stopped again", async () => {
stopped();
await settle();
let reject!: (e: Error) => void;
const result = resumeExecution(
1,
() =>
new Promise((_, r) => {
reject = r;
}),
);
const rejected = expect(result).rejects.toThrow("rejected");
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
stopped();
await settle();
reject(new Error("rejected"));
await rejected;
expect(useExecutionPoint.getState()).toMatchObject({ path, line: 6842 });
});
it("discards locals from an earlier stop even when the frame ID is reused", async () => {
const old = deferred<ReturnType<typeof ipc.ok<Variable[]>>>();
ipc.debugVariables.mockReturnValueOnce(old.promise);
stopped();
await settle();
ipc.debugStackTrace.mockResolvedValueOnce(ipc.ok([frame(6842)]));
ipc.debugVariables.mockResolvedValueOnce(
ipc.ok([{ name: "count", value: "42", typeName: null, variablesReference: 0, evaluateName: "count" }]),
);
stopped();
await settle();
old.resolve(ipc.ok([{ name: "count", value: "40", typeName: null, variablesReference: 0, evaluateName: "count" }]));
await settle();
expect(useExecutionPoint.getState().values.count).toBe("42");
});
});
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { executeAction, registerAction } from "@/platform/actions";
import * as hints from "@/editor/lsp/hint";
import { setContext } from "@/platform/context";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { menuGroupItems } from "@/workbench/contributions";
import { navigationExtension } from "@/editor/lsp/navigation";
import { activate } from "../index";
import { adoptSession, publishContext, useDebug } from "../state";
import { quickEvaluateMouse, quickEvaluateMouseDown } from "../editor/quickEvaluate";
import { useOverlays } from "../ui/popoverStore";
const ipc = vi.hoisted(() => ({
debugEvaluateIn: vi.fn(async () => ({
status: "ok",
data: {
name: "result",
value: "42",
typeName: "int",
variablesReference: 0,
evaluateName: null,
},
})),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, ...ipc } };
});
const editor = vi.hoisted(() => ({ current: null as unknown }));
vi.mock("@/editor/api", async (original) => ({
...(await original<typeof import("@/editor/api")>()),
activeEditor: () => editor.current,
}));
let view: EditorView;
let dispose: () => void;
let uninstall: () => void;
const doc = "total + order.items";
function session(adapter = "debugpy", status: "paused" | "running" | "terminated" = "paused") {
adoptSession(7, { adapter });
useDebug.setState((s) => ({ sessions: { 7: { ...s.sessions[7]!, status, selectedFrame: 10 } }, active: 7 }));
publishContext();
}
function click() {
const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true });
view.contentDOM.dispatchEvent(e);
return e;
}
beforeEach(() => {
vi.clearAllMocks();
useDebug.setState({ sessions: {}, active: null });
setContext("textEditorActive", true);
usePlatform.setState({ os: "macos" });
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
dispose = activate();
uninstall = installKeyboardDispatcher(window);
view = new EditorView({
state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }),
parent: document.body,
});
editor.current = { view, ctx: { path: "/p/app.py" } };
vi.spyOn(view, "posAtCoords").mockReturnValue(16);
});
afterEach(() => {
uninstall();
dispose();
view.destroy();
editor.current = null;
document.body.replaceChildren();
useDebug.setState({ sessions: {}, active: null });
publishContext();
useKeymap.setState({ overrides: {} });
vi.restoreAllMocks();
});
describe("paused editor evaluation", () => {
it.each(["java-debug", "js-debug", "debugpy"])("evaluates the clicked member through %s", async (adapter) => {
session(adapter);
view.dispatch({ selection: { anchor: 0, head: 5 } });
expect(click().defaultPrevented).toBe(true);
await vi.waitFor(() => expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("order.items = 42"));
expect(ipc.debugEvaluateIn).toHaveBeenCalledExactlyOnceWith(7, 10, "order.items", "hover");
expect(view.state.selection.main.head).toBe(16);
});
it.each(["intellij-macos", "intellij-windows"])("preserves a clicked selection with %s", async (preset) => {
const showHint = vi.spyOn(hints, "showHint");
useKeymap.setState({ preset });
session();
view.dispatch({ selection: { anchor: 0, head: doc.length } });
click();
await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, doc, "hover"));
expect(view.state.selection.main.from).toBe(0);
expect(view.state.selection.main.to).toBe(doc.length);
await vi.waitFor(() =>
expect(showHint).toHaveBeenCalledWith(view, expect.any(Function), { pos: 16, timeout: 60_000 }),
);
});
it("uses a selected expression spanning several lines", async () => {
session();
const text = "(total +\n order.items)";
view.dispatch({ changes: { from: 0, to: doc.length, insert: text }, selection: { anchor: 0, head: text.length } });
click();
await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, text, "hover"));
});
it("lets a higher priority custom mouse action win the shared gesture", () => {
session();
const run = vi.fn();
const remove = registerAction({ id: "test.mouseOverride", title: "Custom", run });
useKeymap.setState({ overrides: { "test.mouseOverride": ["Alt+Button1"] } });
try {
click();
expect(run).toHaveBeenCalledOnce();
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
} finally {
remove();
}
});
it.each(["none", "running", "terminated"] as const)("leaves Option+click to the editor when %s", async (status) => {
if (status !== "none") session("debugpy", status);
view.dispatch({ selection: { anchor: 0, head: 5 } });
const dispatch = vi.spyOn(view, "dispatch");
const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true });
expect(quickEvaluateMouseDown(e, view)).toBe(false);
expect(e.defaultPrevented).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
});
it("honours removal of the mouse binding", () => {
session();
useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } });
click();
expect(ipc.debugEvaluateIn).not.toHaveBeenCalled();
});
it.each(["none", "running", "paused", "terminated"] as const)(
"shows the contributed menu only while paused: %s",
async (status) => {
if (status !== "none") session("debugpy", status);
const items = menuGroupItems("editor", { paths: ["/p/app.py"], text: doc });
const evaluate = items.find((item) => item.kind === "action" && item.action === "debug.evaluate");
expect(!!evaluate).toBe(status === "paused");
if (evaluate) {
view.dispatch({ selection: { anchor: 16 } });
await executeAction("debug.evaluate");
expect(useOverlays.getState().evaluate?.expression).toBe("order.items");
}
expect(menuGroupItems("editorTab", { paths: ["/p/app.py"] })).toEqual([]);
},
);
});
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
import { useExecutionPoint } from "../state";
import { adoptSession, useDebug, useExecutionPoint } from "../state";
import { useOverlays } from "../ui/popoverStore";
import {
calleeName,
@@ -92,6 +92,8 @@ describe("picking in the editor", () => {
});
function setup() {
useDebug.setState({ sessions: {}, active: null });
adoptSession(3, { status: "paused", selectedThread: 1, stoppedThread: 1 });
const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false };
const view = new EditorView({
state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }),
@@ -52,6 +52,12 @@ describe("value classification", () => {
);
expect(inlineValuesFor("return 1", values)).toBeNull();
});
it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => {
expect(inlineValuesFor(name, { count: "40" })).toBeNull();
expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`);
expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull();
});
});
const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({
+60 -26
View File
@@ -4,6 +4,7 @@ import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, ty
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { activeEditor } from "@/editor/api";
import type { EditorView } from "@codemirror/view";
import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions";
import { classFileBreakpointReason } from "@/platform/classFiles";
import { canPlaceBreakpoint } from "./breakpoints/languages";
@@ -23,6 +24,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch";
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
import {
activeSession,
resumeExecution,
evaluate,
isLive,
rerunSession,
@@ -39,6 +41,7 @@ import {
import { focusedDebugView } from "./ui/focusedView";
import { browseFocusedConsoleHistory } from "./ui/expressionHistory";
import { expressionAt, valueHint } from "./editor/valueTooltip";
import { mouseEvaluateTarget } from "./editor/quickEvaluate";
import { showHint } from "@/editor/lsp/hint";
import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep";
import {
@@ -87,7 +90,10 @@ function breakpointPathAllowed(path: string): boolean {
export function runToLine(path: string, line: number) {
const t = pausedTarget();
if (t && breakpointPathAllowed(path))
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
void report(
"Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))),
);
}
function notSupported(what: string, s: DebugSession) {
@@ -113,15 +119,17 @@ async function smartStepInto() {
if (!t) return;
const frame = selectedFrame(t.s);
if (!frame || !sessionSupports("stepInTargets", t.s)) {
await call(commands.debugStep(t.s.id, t.thread, "into"));
await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into")));
return;
}
const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id)));
if (targets.length <= 1) {
await call(
targets[0]
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
: commands.debugStep(t.s.id, t.thread, "into"),
await resumeExecution(t.s.id, () =>
call(
targets[0]
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
: commands.debugStep(t.s.id, t.thread, "into"),
),
);
return;
}
@@ -157,28 +165,29 @@ function cursor(): { path: string; line: number; selection: string } | null {
}
/** Evaluate's initial text, as in IDEA: the selection, else the expression at the caret. */
function evaluateSeed(): string {
const ed = activeEditor();
if (!ed) return "";
const { state } = ed.view;
function evaluateSeed(view: EditorView | undefined = activeEditor()?.view, pos?: number): string {
if (!view) return "";
const { state } = view;
const sel = state.selection.main;
if (!sel.empty) return state.sliceDoc(sel.from, sel.to);
return expressionAt(ed.view, sel.head)?.text ?? "";
if (!sel.empty && (pos == null || (sel.from <= pos && pos <= sel.to))) return state.sliceDoc(sel.from, sel.to);
return expressionAt(view, pos ?? sel.head)?.text ?? "";
}
async function quickEvaluate() {
const view = activeEditor()?.view;
async function quickEvaluate(arg?: unknown) {
const target = mouseEvaluateTarget(arg);
const view = target?.view ?? activeEditor()?.view;
if (!view) return;
const sel = view.state.selection.main;
const expression = evaluateSeed().trim();
const pos = target?.pos ?? sel.from;
const expression = evaluateSeed(view, target?.pos).trim();
if (!expression) return showHint(view, "Nothing to evaluate at the caret");
const session = activeSession();
try {
const value = await evaluate(expression, "hover", session);
if (!session) return;
showHint(view, () => valueHint(expression, value, session.id), { pos: sel.from, timeout: 60_000 });
showHint(view, () => valueHint(expression, value, session.id), { pos, timeout: 60_000 });
} catch (e) {
showHint(view, e instanceof Error ? e.message : String(e), { pos: sel.from, tone: "error" });
showHint(view, e instanceof Error ? e.message : String(e), { pos, tone: "error" });
}
}
@@ -314,7 +323,11 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind)));
if (t)
void report(
title,
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))),
);
},
});
@@ -397,7 +410,11 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread)));
if (t)
void report(
"Resume failed",
resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))),
);
},
},
{
@@ -429,7 +446,11 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread)));
if (t)
void report(
"Force Step Over failed",
resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))),
);
},
},
{
@@ -442,7 +463,11 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into")));
if (t)
void report(
"Force Step Into failed",
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))),
);
},
},
{
@@ -481,7 +506,10 @@ export function registerDebugActions(): () => void {
notSupported("Reset Frame", t.s);
return;
}
void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id)));
void report(
"Reset Frame failed",
resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))),
);
},
},
{
@@ -528,7 +556,10 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
void report(
"Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))),
);
},
},
{
@@ -542,7 +573,10 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
void report(
"Force Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))),
);
},
},
{
@@ -677,8 +711,8 @@ export function registerDebugActions(): () => void {
id: "debug.quickEvaluate",
title: "Quick Evaluate Expression",
category: "Debug",
when: "debugSessionActive && textEditorActive",
run: () => quickEvaluate(),
when: "debugSessionPaused && textEditorActive",
run: ({ arg }) => quickEvaluate(arg),
},
{
id: "debug.showToolWindow",
+33 -18
View File
@@ -49,6 +49,7 @@ function currentExec(path: string): Exec | null {
export function inlineValuesFor(text: string, values: Record<string, string>): string | null {
const parts: string[] = [];
for (const id of identifiersIn(text)) {
if (!Object.hasOwn(values, id)) continue;
const v = values[id];
if (v === undefined) continue;
parts.push(`${id}: ${inlineValue(v, 40)}`);
@@ -80,33 +81,48 @@ export function executionPoint(ctx: EditorDocContext): Extension {
provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))),
});
// Keep the execution line alive even if an inline-value renderer fails.
const sync = ViewPlugin.fromClass(
class {
private unsub: () => void;
private pending = false;
private destroyed = false;
constructor(readonly view: EditorView) {
this.unsub = useExecutionPoint.subscribe(() => this.refresh());
// Hidden tabs retain EditorState while their views are unmounted.
this.refresh();
}
refresh() {
if (this.pending) return;
this.pending = true;
queueMicrotask(() => {
this.pending = false;
if (this.destroyed) return;
const next = currentExec(path);
const cur = this.view.state.field(execField);
const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next;
// Locals can change without moving the execution point.
this.view.dispatch(moved ? { effects: setExec.of(next) } : {});
});
}
destroy() {
this.destroyed = true;
this.unsub();
}
},
);
const inline = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
private unsub: () => void;
private pending = false;
private destroyed = false;
private unsubSettings: () => void;
constructor(readonly view: EditorView) {
this.decorations = this.build();
// Settings › Debugger › Data Views › Show values inline.
this.unsubSettings = onWorkflowSettingsChange(
(s) => s.debugger.inlineValues,
() => queueMicrotask(() => !this.destroyed && this.view.dispatch({})),
);
this.unsub = useExecutionPoint.subscribe(() => {
if (this.pending) return;
this.pending = true;
queueMicrotask(() => {
this.pending = false;
if (this.destroyed) return;
const next = currentExec(path);
const cur = this.view.state.field(execField);
const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next;
// An empty transaction still rebuilds the inline values (locals arrive after the frame).
this.view.dispatch(moved ? { effects: setExec.of(next) } : {});
});
});
}
update(u: ViewUpdate) {
@@ -136,12 +152,11 @@ export function executionPoint(ctx: EditorDocContext): Extension {
destroy() {
this.destroyed = true;
this.unsub();
this.unsubSettings();
}
},
{ decorations: (v) => v.decorations },
);
return [execField, inline];
return [execField, sync, inline];
}
@@ -0,0 +1,34 @@
import { EditorSelection, Prec, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { dispatchMouseShortcut } from "@/platform/keymap";
export function mouseEvaluateTarget(arg: unknown): { view: EditorView; pos: number } | null {
if (!arg || typeof arg !== "object" || !("view" in arg) || !("pos" in arg)) return null;
return arg.view instanceof EditorView && typeof arg.pos === "number" ? { view: arg.view, pos: arg.pos } : null;
}
/** Preserve a clicked selection and anchor the value hint at the mouse position. */
export function quickEvaluateMouse(): Extension {
return Prec.highest(EditorView.domEventHandlers({ mousedown: quickEvaluateMouseDown }));
}
export function quickEvaluateMouseDown(e: MouseEvent, view: EditorView): boolean {
if (e.button !== 0) return false;
const over = e.target instanceof Element ? e.target : null;
if (over?.closest(".cm-tooltip, .cm-lsp-inlay") || (over && !over.closest(".cm-content"))) return false;
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
if (pos == null) return false;
const handled = dispatchMouseShortcut(
e,
(id) => {
if (id !== "debug.quickEvaluate") return false;
const sel = view.state.selection.main;
if (sel.empty || pos < sel.from || pos > sel.to) view.dispatch({ selection: EditorSelection.cursor(pos) });
view.focus();
return true;
},
{ view, pos },
);
if (handled) e.preventDefault();
return handled;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle
import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc";
import { suspendKeyboardDispatch } from "@/platform/keymap";
import { notifications } from "@/workbench/contributions";
import { useExecutionPoint } from "../state";
import { useExecutionPoint, resumeExecution } from "../state";
import { useOverlays, type InlineStepTarget } from "../ui/popoverStore";
import styles from "./editor.module.css";
@@ -139,7 +139,7 @@ export function chooseStepTarget(id?: number) {
const target = id ?? s.inline?.ranges[s.inline.index]?.id;
cancelSmartStep();
if (target == null) return;
void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) =>
void resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) =>
notifications().notify({
group: "run",
level: "error",
+17 -1
View File
@@ -7,6 +7,7 @@ import { lazyComponent } from "@/ui";
import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { selectionValueTooltip, valueTooltip } from "./editor/valueTooltip";
import { quickEvaluateMouse } from "./editor/quickEvaluate";
import { registerEditorContribution } from "@/editor/api";
import {
registerMenuGroup,
@@ -24,7 +25,16 @@ import { executionPoint } from "./editor/execution";
import { runToCursorInlay } from "./editor/runToCursor";
import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep";
import { onHotCodeReplace } from "./hotswap";
import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state";
import {
activeSession,
handleDebugEvent,
isLive,
loadWatches,
publishContext,
resetSessions,
stopSession,
useDebug,
} from "./state";
import { DebuggerOverlays } from "./ui/Overlays";
import { REMOTE_TYPES } from "./remote";
@@ -53,6 +63,11 @@ export function activate(): () => void {
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
}),
registerOverlay({ id: "debugger", component: DebuggerOverlays }),
registerMenuGroup({
id: "debugger.evaluate",
menus: ["editor"],
items: () => (activeSession()?.status === "paused" ? [{ kind: "action", action: "debug.evaluate" }] : []),
}),
// Stop (⌘F2) offers the running debug sessions beside run processes.
registerMenuGroup({
id: "debugger.stop",
@@ -71,6 +86,7 @@ export function activate(): () => void {
runToCursorInlay(ctx),
valueTooltip(),
selectionValueTooltip(),
quickEvaluateMouse(),
smartStepTargets(ctx),
],
}),
+38 -5
View File
@@ -644,6 +644,7 @@ export function selectSession(id: DebugSessionId) {
export async function selectThread(id: DebugSessionId, threadId: number) {
patch(id, { selectedThread: threadId, frames: [], selectedFrame: null });
updateExecutionPoint();
await loadFrames(id, threadId, true);
}
@@ -652,7 +653,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true)
updateExecutionPoint();
const s = useDebug.getState().sessions[id];
const frame = s?.frames.find((f) => f.id === frameId);
if (reveal && frame?.path) {
if (reveal && useDebug.getState().active === id && frame?.path) {
void maybeEditorService()?.openFile(frame.path, {
selection: { line: frame.line, col: frame.column },
focus: false,
@@ -662,6 +663,7 @@ export function selectFrame(id: DebugSessionId, frameId: number, reveal = true)
}
async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) {
const before = useDebug.getState().sessions[id];
let frames: StackFrame[] = [];
try {
frames = await call(commands.debugStackTrace(id, threadId));
@@ -669,7 +671,7 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean)
console.warn("stackTrace failed", e);
}
const s = useDebug.getState().sessions[id];
if (!s || s.status !== "paused" || s.selectedThread !== threadId) return;
if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return;
patch(id, { frames });
const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0];
if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) {
@@ -707,19 +709,21 @@ async function loadThreads(id: DebugSessionId) {
/** Locals of `frameId` (first non-expensive scope) for inline values. */
async function loadLocals(id: DebugSessionId, frameId: number) {
const values: Record<string, string> = {};
const frames = useDebug.getState().sessions[id]?.frames;
const values: Record<string, string> = Object.create(null);
try {
const scopes = await call(commands.debugScopes(id, frameId));
const scope = scopes.find((sc) => !sc.expensive);
if (scope) {
const vars = await call(commands.debugVariables(id, scope.variablesReference));
for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value;
for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value;
}
} catch {
// No inline values.
}
const s = useDebug.getState().sessions[id];
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return;
if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames)
return;
useExecutionPoint.setState({ values });
}
@@ -729,6 +733,7 @@ export async function showExecutionPoint(id: DebugSessionId) {
if (!s || s.status !== "paused" || s.stoppedThread == null) return;
if (s.selectedThread !== s.stoppedThread) {
patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null });
updateExecutionPoint();
await loadFrames(id, s.stoppedThread, false);
}
const cur = useDebug.getState().sessions[id];
@@ -757,6 +762,32 @@ export function updateExecutionPoint() {
useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} });
}
/** Invalidate suspended data before sending a command that resumes execution. */
export async function resumeExecution(id: DebugSessionId, request: () => Promise<unknown>) {
const before = useDebug.getState().sessions[id];
if (!before || before.status !== "paused") return;
const frames: StackFrame[] = [];
patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null });
updateExecutionPoint();
try {
await request();
} catch (e) {
const current = useDebug.getState().sessions[id];
// A stop/continue/termination received during the request owns the newer state.
if (current?.status === "running" && current.frames === frames) {
patch(id, {
status: before.status,
frames: before.frames,
selectedFrame: before.selectedFrame,
stoppedThread: before.stoppedThread,
});
updateExecutionPoint();
if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame);
}
throw e;
}
}
// --- watches -------------------------------------------------------------------
export function addWatch(expression: string) {
@@ -862,6 +893,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
});
useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 }));
publishContext();
updateExecutionPoint();
void loadThreads(id);
void loadFrames(id, event.threadId, true);
const dbg = workflowSettings().debugger;
@@ -876,6 +908,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
case "continued": {
const cur = useDebug.getState().sessions[id]!;
if (cur.status === "terminated") break;
if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break;
patch(id, {
status: "running",
frames: [],
+4 -2
View File
@@ -13,7 +13,7 @@ import {
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state";
import {
addMethodBreakpoint,
removeAllSpecial,
@@ -978,7 +978,9 @@ function SmartStepChooser() {
const pick = (targetId: number) => {
if (!state) return;
close();
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
void resumeExecution(state.session, () =>
call(commands.debugStepInTarget(state.session, state.thread, targetId)),
).catch((e) =>
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
);
};
+8 -1
View File
@@ -538,7 +538,14 @@ export function activate(): () => void {
}),
// Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options).
onIdeEvent("settingsChanged", (e) => {
if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload();
if (
!e.key ||
e.key === "languages" ||
e.key.startsWith("languages.") ||
e.key === "cmake" ||
e.key.startsWith("cmake.")
)
scheduleReload();
}),
);
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { validateSettings } from "../model/schema";
import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings";
describe("CMake settings", () => {
it("preserves absent and explicitly empty profiles", () => {
expect(validateSettings({}).cmake).toEqual({});
expect(validateSettings({ cmake: { profiles: [] } }).cmake.profiles).toEqual([]);
});
it("preserves argument arrays and drops malformed argument values", () => {
const cmakeArgs = ["-DMESSAGE=two words", '-DQUOTE="hello"', ""];
const buildArgs = ["-j", "8", "--", "LABEL=it's a build"];
const profiles = validateSettings({ cmake: { profiles: [{ cmakeArgs, buildArgs }] } }).cmake.profiles;
expect(profiles?.[0]).toMatchObject({ cmakeArgs, buildArgs });
expect(
validateSettings({ cmake: { profiles: [{ cmakeArgs: ["-DFOO=bar", 42, null] }] } }).cmake.profiles?.[0]
?.cmakeArgs,
).toEqual(["-DFOO=bar"]);
});
it("retains custom types and generators while normalizing malformed fields", () => {
expect(
validateSettings({
cmake: {
profiles: [
{
name: "Custom",
buildType: "Coverage",
generator: "Custom generator",
buildArgs: 5,
env: { KEEP: "yes", DROP: 5 },
},
null,
],
},
}).cmake.profiles,
).toEqual([
{
...DEFAULT_CMAKE_PROFILE,
name: "Custom",
buildType: "Coverage",
generator: "Custom generator",
env: { KEEP: "yes" },
},
]);
});
});
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import { DEFAULT_CMAKE_PROFILE } from "@/platform/cmakeSettings";
import { parseArgs } from "@/platform/commandLine";
import { PageContext } from "../components/context";
import { validateSettings } from "../model/schema";
import { useSettingsDialog } from "../model/staging";
import { CMakeProfiles } from "../pages/cmake";
const savedDialog = useSettingsDialog.getState();
afterEach(() => {
cleanup();
useSettingsDialog.setState(savedDialog, true);
});
it.each([
["CMake arguments", "cmakeArgs"],
["Build arguments", "buildArgs"],
] as const)("round-trips spaces, quotes and empty values in %s", async (label, key) => {
const args = ["two words", 'a "quote"', "it's", "", String.raw`C:\some path\file`];
useSettingsDialog.setState({
base: validateSettings({ cmake: { profiles: [{ ...DEFAULT_CMAKE_PROFILE, [key]: args }] } }),
staged: {},
});
const mount = () =>
render(
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
<CMakeProfiles />
</PageContext.Provider>,
);
const view = mount();
const input = screen.getByLabelText<HTMLInputElement>(label);
expect(parseArgs(input.value)).toEqual(args);
await userEvent.clear(input);
const text = String.raw`-DNAME="two words" "-DQUOTE=it's" ""`;
await userEvent.type(input, text);
expect(input).toHaveValue(text);
fireEvent.blur(input);
const expected = ["-DNAME=two words", "-DQUOTE=it's", ""];
expect(parseArgs(input.value)).toEqual(expected);
expect(useSettingsDialog.getState().staged["project|cmake.profiles"]?.value).toEqual([
{ ...DEFAULT_CMAKE_PROFILE, [key]: expected },
]);
view.unmount();
mount();
expect(parseArgs(screen.getByLabelText<HTMLInputElement>(label).value)).toEqual(expected);
});
it("displays each profile's arguments when switching profiles", () => {
useSettingsDialog.setState({
base: validateSettings({
cmake: {
profiles: [
{ ...DEFAULT_CMAKE_PROFILE, name: "Debug" },
{ ...DEFAULT_CMAKE_PROFILE, name: "Release", cmakeArgs: ["-DMESSAGE=release build"] },
],
},
}),
staged: {},
});
render(
<PageContext.Provider value={{ pageId: "build.cmake", scope: "project" }}>
<CMakeProfiles />
</PageContext.Provider>,
);
fireEvent.change(screen.getByLabelText("CMake arguments"), { target: { value: '"unfinished ' } });
fireEvent.click(screen.getByRole("option", { name: "Release" }));
expect(parseArgs(screen.getByLabelText<HTMLInputElement>("CMake arguments").value)).toEqual([
"-DMESSAGE=release build",
]);
});
@@ -84,6 +84,29 @@ describe("Settings dialog", () => {
expect(settingsSet).toHaveBeenLastCalledWith("global", "editor.encodings.nativeToAscii", "false");
});
it("edits CMake profiles in project settings and persists them on Apply", async () => {
useProject.setState({ project: { root: "/project" } as never });
const dialog = await openDialog("build.cmake");
expect(within(dialog).getByRole("option", { name: "Debug" })).toBeInTheDocument();
fireEvent.click(
within(within(dialog).getByRole("toolbar", { name: "CMake profile actions" })).getByRole("button", {
name: "Add",
}),
);
fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Release" } });
fireEvent.change(within(dialog).getByLabelText("Build type"), { target: { value: "Release" } });
fireEvent.change(within(dialog).getByLabelText("Generator"), { target: { value: "Ninja" } });
fireEvent.change(within(dialog).getByLabelText("Build arguments"), { target: { value: "-j 8" } });
expect(settingsSet).not.toHaveBeenCalled();
await act(async () => fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })));
const write = settingsSet.mock.calls.find((c) => c[1] === "cmake.profiles")!;
expect(write[0]).toBe("project");
expect(JSON.parse(write[2])).toEqual([
expect.objectContaining({ name: "Debug", buildType: "Debug" }),
expect.objectContaining({ name: "Release", buildType: "Release", generator: "Ninja", buildArgs: ["-j", "8"] }),
]);
});
it("finds Gradle JVM and applies project Gradle settings", async () => {
useProject.setState({ project: { root: "/project" } as never });
const dialog = await openDialog("build.gradle");
@@ -87,6 +87,9 @@ describe("code style scheme", () => {
it("copies every Code Style value to the other scheme", () => {
stage("editor.codeStyle", "global", "editor.codeStyle.hardWrapAt", 100);
stage("editor.codeStyle", "global", "editor.codeStyle.formatterTagsEnabled", true);
stage("editor.codeStyle", "global", "editor.codeStyle.languages", {
java: { formatter: "custom", formatterPath: "config/formatter.xml" },
});
copyScheme("editor.codeStyle", "global", "project");
const s = useSettingsDialog.getState();
expect(stagedValue(s, "project", "editor.codeStyle.hardWrapAt")).toBe(100);
@@ -0,0 +1,68 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
import { DEFAULT_SETTINGS } from "../model/schema";
import { openSettings, stagedValue, useSettingsDialog } from "../model/staging";
import { codeStyleLanguagePages } from "../pages/editor/codeStyle";
const mocks = vi.hoisted(() => ({ open: vi.fn() }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/platform/ipc", async (original) => {
const actual = await original<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
languages: async () => [
{ id: "java", name: "Java", indent: { tabSize: 4, insertSpaces: true } },
{ id: "rust", name: "Rust" },
],
},
};
});
beforeEach(() => {
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null });
openSettings("editor.codeStyle.java");
});
afterEach(cleanup);
async function page(language: string) {
const pages = await codeStyleLanguagePages();
const spec = pages.find((p) => p.id === `editor.codeStyle.${language}`)!;
const Page = spec.component!;
render(
<PageContext.Provider value={{ pageId: spec.id, scope: "global" }}>
<Page />
</PageContext.Provider>,
);
}
const value = () => stagedValue(useSettingsDialog.getState(), "global", "editor.codeStyle.languages");
describe("Java formatting profile", () => {
it("stages profile and relative XML paths, browses for XML, and resets language defaults", async () => {
await page("java");
fireEvent.click(screen.getByRole("button", { name: "Formatting profile" }));
fireEvent.click(screen.getByRole("option", { name: "Custom Eclipse formatter XML" }));
fireEvent.change(screen.getByLabelText("Eclipse formatter XML"), { target: { value: "config/style.xml" } });
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "config/style.xml" } });
mocks.open.mockResolvedValue("/project/custom.xml");
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
);
fireEvent.click(screen.getByRole("button", { name: "Formatting profile" }));
fireEvent.click(screen.getByRole("option", { name: "IntelliJ IDEA" }));
expect(screen.queryByLabelText("Eclipse formatter XML")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Reset to Language Defaults" }));
expect(value()).toEqual({});
});
it("keeps the profile controls specific to Java", async () => {
await page("rust");
expect(screen.queryByRole("button", { name: "Formatting profile" })).toBeNull();
});
});
+4
View File
@@ -4,6 +4,7 @@
* so `validateSettings` never throws — anything unknown or malformed falls
* back to the default (mirrors `ide-core::settings` defaults).
*/
import { validateCMake } from "@/platform/cmakeSettings";
import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames";
import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes";
import type { LanguageServerSettings, Settings } from "@/platform/ipc";
@@ -82,6 +83,7 @@ export interface ValidSettings extends WorkflowSettings {
/** System Settings › HTTP Proxy (global). */
proxy: ProxySettingsValid;
languages: LanguagesValid;
cmake: ReturnType<typeof validateCMake>;
projectStructure: ProjectStructureValid;
inspections: InspectionSettingsValid;
/** Editor › Natural Languages › Spelling. */
@@ -150,6 +152,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
notifications: DEFAULT_NOTIFICATIONS,
proxy: DEFAULT_PROXY,
languages: DEFAULT_LANGUAGES,
cmake: {},
projectStructure: DEFAULT_PROJECT_STRUCTURE,
inspections: DEFAULT_INSPECTIONS,
spelling: { ideWords: [], projectWords: [], customDictionaries: [] },
@@ -254,6 +257,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
notifications: validateNotifications(root.notifications),
proxy: validateProxy(root.proxy),
languages: validateLanguages(root.languages),
cmake: validateCMake(root.cmake),
projectStructure: validateProjectStructure(root.projectStructure),
inspections: validateInspections(root.inspections),
spelling: validateSpelling(root.spelling),
@@ -0,0 +1,4 @@
.profile {
width: 100%;
text-align: left;
}
+151
View File
@@ -0,0 +1,151 @@
import { useId, useState } from "react";
import { DEFAULT_CMAKE_PROFILE, type CMakeProfileValid } from "@/platform/cmakeSettings";
import { formatArgs, parseArgs } from "@/platform/commandLine";
import { tx } from "@/platform/i18n";
import { IconButton, Input, T } from "@/ui";
import { Row, Section } from "../components/Form";
import { useSetting } from "../components/context";
import { TableEditor } from "./workflowControls";
import styles from "./Workflow.module.css";
import own from "./CMake.module.css";
function ArgumentsInput({ id, value, onChange }: { id: string; value: string[]; onChange: (args: string[]) => void }) {
const [text, setText] = useState<string>();
return (
<Input
id={id}
className={styles.wide}
value={text ?? formatArgs(value)}
onChange={(e) => {
setText(e.target.value);
onChange(parseArgs(e.target.value));
}}
onBlur={() => setText(undefined)}
/>
);
}
export function CMakeProfiles() {
const [saved, setProfiles] = useSetting<CMakeProfileValid[] | undefined>("cmake.profiles");
const profiles = saved ?? [DEFAULT_CMAKE_PROFILE];
const [selected, setSelected] = useState(0);
const index = Math.min(selected, Math.max(0, profiles.length - 1));
const current = profiles[index];
const id = useId();
const update = (patch: Partial<CMakeProfileValid>) =>
setProfiles(profiles.map((p, i) => (i === index ? { ...p, ...patch } : p)));
const fields = [
["name", "Name"],
["buildType", "Build type"],
["generator", "Generator"],
["cmakeArgs", "CMake arguments"],
["buildDir", "Build directory"],
["buildArgs", "Build arguments"],
] as const;
return (
<div className={styles.toolsLayout}>
<Section title="CMake profiles">
<div className={styles.tableBox}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("CMake profile actions")}>
<IconButton
icon="plus"
title="Add"
size="small"
onClick={() => {
setProfiles([
...profiles,
{ ...DEFAULT_CMAKE_PROFILE, name: tx`CMake profile ${profiles.length + 1}`, env: {} },
]);
setSelected(profiles.length);
}}
/>
<IconButton
icon="minus"
title="Remove"
size="small"
disabled={!current}
onClick={() => {
setProfiles(profiles.filter((_, i) => i !== index));
setSelected(Math.max(0, index - 1));
}}
/>
</div>
<div className={styles.toolList} role="listbox" aria-label={tx("CMake profiles")}>
{profiles.map((p, i) => (
<button
type="button"
key={i}
role="option"
aria-selected={index === i}
className={`${own.profile} ${styles.toolRow} ${index === i ? styles.toolRowSelected : ""}`}
onClick={() => setSelected(i)}
>
{p.name}
</button>
))}
{profiles.length === 0 && <T>No CMake profiles. Click + to add one.</T>}
</div>
</div>
</Section>
{current && (
<Section title="Profile settings">
{fields.map(([key, label]) => (
<Row key={key} label={label} htmlFor={`${id}-${key}`} className={styles.alignedRow}>
{key === "cmakeArgs" || key === "buildArgs" ? (
<ArgumentsInput
key={index}
id={`${id}-${key}`}
value={current[key]}
onChange={(args) => update({ [key]: args })}
/>
) : (
<Input
id={`${id}-${key}`}
className={styles.wide}
value={current[key]}
list={key === "buildType" || key === "generator" ? `${id}-${key}-options` : undefined}
placeholder={
key === "buildDir"
? "cmake-build-" + current.buildType.toLowerCase()
: key === "generator"
? tx("CMake default")
: undefined
}
onChange={(e) => update({ [key]: e.target.value })}
/>
)}
</Row>
))}
<datalist id={`${id}-buildType-options`}>
{["Debug", "Release", "RelWithDebInfo", "MinSizeRel"].map((v) => (
<option key={v} value={v} />
))}
</datalist>
<datalist id={`${id}-generator-options`}>
{["Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022", "Xcode"].map((v) => (
<option key={v} value={v} />
))}
</datalist>
<Row label="Environment variables" stacked>
<TableEditor
rows={Object.entries(current.env).map(([name, value]) => ({ name, value }))}
columns={[
{ key: "name", title: "Name", placeholder: "NAME" },
{ key: "value", title: "Value", placeholder: "value" },
]}
onChange={(rows) => update({ env: Object.fromEntries(rows.map((r) => [r.name ?? "", r.value ?? ""])) })}
empty="No variables"
aria-label="Environment variables"
/>
</Row>
<div className={styles.note}>
<T>
Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool
options.
</T>
</div>
</Section>
)}
</div>
);
}
@@ -12,7 +12,7 @@ import { Button, Checkbox, Input, type SelectOption, T } from "@/ui";
import { commands, isTauri, type LanguageInfo_Serialize as LanguageInfo } from "@/platform/ipc";
import { ENCODING_CHOICES, lineCommentDefaults, type LanguageCodeStyle } from "@/platform/editorSettings";
import { NATIVE_TO_ASCII_ACTION } from "../../nativeToAscii";
import { NumberInput, Row } from "../../components/Form";
import { NumberInput, Row, Select } from "../../components/Form";
import { useSetting } from "../../components/context";
import type { PageSpec } from "../spec";
import { CODE_STYLE_SCOPE, CodeStyleMain, SchemeRow, formatterLabels } from "./codeStyleScheme";
@@ -159,6 +159,7 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
return (
<div className={styles.page}>
<SchemeRow />
{lang.id === "java" && <JavaFormatter style={own} onChange={set} />}
<section aria-label={tx("Tabs and Indents")}>
<div className={styles.previewTitle}>
<T>Tabs and Indents</T>
@@ -238,6 +239,62 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
);
}
const JAVA_FORMATTERS: SelectOption<NonNullable<LanguageCodeStyle["formatter"]>>[] = [
{ value: "default", label: "Default" },
{ value: "intellij", label: "IntelliJ IDEA" },
{ value: "custom", label: "Custom Eclipse formatter XML" },
];
function JavaFormatter({
style,
onChange,
}: {
style: LanguageCodeStyle;
onChange: (patch: Partial<LanguageCodeStyle>) => void;
}) {
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({
multiple: false,
title: tx("Eclipse formatter XML"),
filters: [{ name: "XML", extensions: ["xml"] }],
});
if (typeof path === "string") onChange({ formatterPath: path });
};
return (
<section aria-label={tx("Formatting profile")}>
<Row
label="Formatting profile"
hint="Changes take effect after Apply without restarting the Java language server."
>
<Select
value={style.formatter ?? "default"}
options={JAVA_FORMATTERS}
onChange={(formatter) => onChange({ formatter })}
aria-label="Formatting profile"
/>
</Row>
{style.formatter === "custom" && (
<Row
label="Eclipse formatter XML"
htmlFor={id}
hint="Absolute or project-relative path. To reload an edited XML file, switch to Default, apply, then select Custom again."
>
<Input
id={id}
value={style.formatterPath ?? ""}
onChange={(e) => onChange({ formatterPath: e.target.value })}
/>
<Button size="small" onClick={browse}>
<T>Browse…</T>
</Button>
</Row>
)}
</section>
);
}
const SKIP = new Set(["plaintext", "log", "diff", "ignore", "gitattributes"]);
/** One Code Style page per language (registered once the language list is known). */
@@ -261,6 +318,9 @@ export async function codeStyleLanguagePages(): Promise<PageSpec[]> {
"Hard wrap at",
"Comment Code",
"Line comment at first column",
...(lang.id === "java"
? ["Formatting profile", "IntelliJ IDEA", "Custom Eclipse formatter XML", "Eclipse formatter XML"]
: []),
],
component: () => <LanguageStyle lang={lang} />,
}));
+20
View File
@@ -3,6 +3,7 @@
* Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger
* features (and `ide-run` for encoding / termination timeout).
*/
import { CMakeProfiles } from "./cmake";
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
import type { SelectOption } from "@/ui";
import { GlobListEditor, NumberInput, Row } from "../components/Form";
@@ -90,6 +91,25 @@ function TooltipDelayRow() {
}
export const EXECUTION_PAGES: PageSpec[] = [
{
id: "build.cmake",
title: "CMake",
group: BUILD_GROUP,
order: 357,
projectScope: true,
defaultScope: "project",
keywords: [
"profile",
"generator",
"Ninja",
"Build type",
"CMake arguments",
"Build directory",
"Build arguments",
"Environment variables",
],
component: CMakeProfiles,
},
{
id: "build.gradle",
projectScope: true,
+38
View File
@@ -0,0 +1,38 @@
import type { CMakeProfile } from "@/bindings";
export type CMakeProfileValid = Required<CMakeProfile>;
export const DEFAULT_CMAKE_PROFILE: CMakeProfileValid = {
name: "Debug",
buildType: "Debug",
generator: "",
cmakeArgs: [],
buildDir: "",
buildArgs: [],
env: {},
};
export function validateCMake(raw: unknown): { profiles?: CMakeProfileValid[] } {
const profiles = (raw as { profiles?: unknown } | undefined)?.profiles;
if (!Array.isArray(profiles)) return {};
const string = (value: unknown, fallback = "") => (typeof value === "string" ? value : fallback);
const args = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((arg): arg is string => typeof arg === "string") : [];
return {
profiles: profiles
.filter((p) => p && typeof p === "object")
.map((p) => ({
name: string(p.name, "Debug"),
buildType: string(p.buildType, "Debug"),
generator: string(p.generator),
cmakeArgs: args(p.cmakeArgs),
buildDir: string(p.buildDir),
buildArgs: args(p.buildArgs),
env: Object.fromEntries(
Object.entries(p.env && typeof p.env === "object" ? p.env : {}).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
})),
};
}
+3
View File
@@ -24,6 +24,9 @@ export interface CustomColorScheme {
export type CustomColorSchemes = Record<string, CustomColorScheme>;
export interface LanguageCodeStyle {
/** Java formatter; relative XML paths start at the project root. */
formatter?: "default" | "intellij" | "custom";
formatterPath?: string;
useTab?: boolean;
tabSize?: number;
indent?: number;
+8 -3
View File
@@ -407,15 +407,20 @@ function runIfEnabled(id: string): boolean {
}
/** Run a configured mouse binding; editors prepare the clicked caret and focus first. */
export function dispatchMouseShortcut(e: MouseEvent, prepare?: () => boolean): boolean {
export function dispatchMouseShortcut(e: MouseEvent, prepare?: (id: string) => boolean, arg?: unknown): boolean {
if (suspended > 0) return false;
const stroke = mouseStrokeOf(e);
if (!stroke) return false;
const ids = effectiveBindings()
.filter(([b]) => b === stroke)
.map(([, id]) => id);
if (!ids.length || (prepare && !prepare())) return false;
for (const id of ids.reverse()) if (runIfEnabled(id)) return true;
for (const id of ids.reverse()) {
const action = getAction(id);
if (!action || !isActionKeyEnabled(action)) continue;
if (prepare && !prepare(id)) return false;
void executeAction(id, arg);
return true;
}
return false;
}
+19
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatBinding,
dispatchMouseShortcut,
installKeyboardDispatcher,
isMouseBinding,
isValidBinding,
@@ -13,6 +14,7 @@ import {
useKeymap,
} from "./keymap";
import { registerAction } from "./actions";
import { setContext } from "./context";
const noMods = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
@@ -84,6 +86,23 @@ describe("mouse shortcuts", () => {
return events;
};
it("does not prepare the editor for a disabled mouse action", () => {
const run = vi.fn();
const prepare = vi.fn(() => true);
const remove = registerAction({ id: "test.pausedMouse", title: "Evaluate", when: "debugSessionPaused", run });
useKeymap.setState({ overrides: { "test.pausedMouse": ["Alt+Button1"] } });
setContext("debugSessionPaused", false);
const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true });
try {
expect(dispatchMouseShortcut(e, prepare)).toBe(false);
expect(prepare).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
} finally {
remove();
}
});
it.each([
["the editor", () => editor],
["another component", () => document.getElementById("tree")!],
+1 -1
View File
@@ -69,7 +69,7 @@ describe("IDEA keymap parity", () => {
["navigate.nextHighlightedUsage", ["Ctrl+Alt+Down"], ["Ctrl+Alt+Down"]],
["analyze.inspectFile", ["Alt+Shift+I"], ["Alt+Shift+I"]],
["project.copyElement", ["F5"], ["F5"]],
["debug.quickEvaluate", ["Meta+Alt+F8"], ["Ctrl+Alt+F8"]],
["debug.quickEvaluate", ["Meta+Alt+F8", "Alt+Button1"], ["Ctrl+Alt+F8", "Alt+Button1"]],
["run.chooseDebugConfig", ["Ctrl+Alt+D"], ["Alt+Shift+F9"]],
["editor.nextSplitter", ["Alt+Tab"], ["Alt+Tab"]],
["toolwindow.resizeLeft", ["Ctrl+Alt+Left"], ["Ctrl+Alt+Shift+Left"]],
+1 -1
View File
@@ -284,7 +284,7 @@ export const intellijMacos: Record<string, string[]> = {
"debug.setValue": ["F2"],
"debug.jumpToSource": ["F4", "Meta+Down"],
"debug.evaluate": ["Alt+F8"],
"debug.quickEvaluate": ["Meta+Alt+F8"],
"debug.quickEvaluate": ["Meta+Alt+F8", "Alt+Button1"],
"debug.browseConsoleHistory": ["Meta+Alt+E"],
// VCS
+1 -1
View File
@@ -278,7 +278,7 @@ export const intellijWindows: Record<string, string[]> = {
"debug.setValue": ["F2"],
"debug.jumpToSource": ["F4"],
"debug.evaluate": ["Alt+F8"],
"debug.quickEvaluate": ["Ctrl+Alt+F8"],
"debug.quickEvaluate": ["Ctrl+Alt+F8", "Alt+Button1"],
"debug.browseConsoleHistory": ["Ctrl+Alt+E"],
// VCS
+29 -3
View File
@@ -1364,12 +1364,10 @@
"Open console for tool output": "为工具输出打开控制台",
"Open documents": "打开的文档",
"Open in Browser": "在浏览器中打开",
"Open in Editor": "在编辑器中打开",
"Open in Find Tool Window": "在“查找”工具窗口中打开",
"Open in Find Window": "在查找窗口中打开",
"Open in Opposite Group": "在相对组中打开",
"Open in New Window": "在新窗口中打开",
"Open in Tool Window": "在工具窗口中打开",
"Open in Terminal": "在终端中打开",
"Open in editor": "在编辑器中打开",
"Open moved files in editor": "在编辑器中打开已移动的文件",
@@ -5440,5 +5438,33 @@
"copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内",
"cannot copy overlapping paths": "无法在相互包含的路径之间复制",
"cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制",
"expected a relative comparison entry path": "需要比较条目的相对路径"
"expected a relative comparison entry path": "需要比较条目的相对路径",
"Agent sessions": "智能体会话",
"Back to conversation": "返回会话",
"Conversation location": "会话显示位置",
"Chat in Tool Window": "在工具窗口中聊天",
"Chat in Editor": "在编辑器中聊天",
"Agent settings": "智能体设置",
"Describe a task…": "描述一个任务…",
"Formatting profile": "格式化方案",
"Custom Eclipse formatter XML": "自定义 Eclipse 格式化 XML",
"Eclipse formatter XML": "Eclipse 格式化 XML",
"Changes take effect after Apply without restarting the Java language server.": "应用后即生效,无需重启 Java 语言服务器。",
"Absolute or project-relative path. To reload an edited XML file, switch to Default, apply, then select Custom again.": "使用绝对路径或项目相对路径。修改 XML 文件后,请切换到默认方案并应用,再重新选择自定义方案。",
"Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}",
"Java formatter configuration unavailable": "Java 格式化配置不可用",
"Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。",
"Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}",
"CMake profiles": "CMake 配置",
"CMake profile actions": "CMake 配置操作",
"No CMake profiles. Click + to add one.": "没有 CMake 配置。点击 + 添加。",
"Profile settings": "配置设置",
"Build type": "构建类型",
"Generator": "生成器",
"CMake arguments": "CMake 参数",
"Build directory": "构建目录",
"Build arguments": "构建参数",
"CMake default": "CMake 默认",
"Quote arguments containing spaces. Build arguments go to cmake --build; use -- before native build tool options.": "包含空格的参数请加引号。构建参数传给 cmake --build;原生构建工具参数前请加 --。",
"CMake profile {0}": "CMake 配置 {0}"
}
+2
View File
@@ -80,7 +80,9 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
/** Text that stays as written in every locale: names, code, formats, shortcuts. */
const UNTRANSLATED = new Set<string>([
// Product names
"CMake",
"Prettier",
"IntelliJ IDEA",
// Go environment variables and placeholder values
"CGO",
"GOPROXY",