Compare commits

..
Author SHA1 Message Date
l0ng-ai ac6c6b2047 feat(debugger): add paused editor evaluation entry points 2026-11-01 17:14:10 +08:00
70 changed files with 519 additions and 3683 deletions

No files matched your search

Generated
+35 -36
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"libc",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,7 +4195,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4209,7 +4209,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4254,7 +4254,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4294,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,7 +4310,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"blake3",
"criterion",
@@ -4365,7 +4365,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"futures",
@@ -4374,7 +4374,6 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4390,7 +4389,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"futures",
"ide-core",
@@ -4413,7 +4412,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-language",
@@ -4431,7 +4430,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-run",
@@ -4444,7 +4443,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"globset",
"ide-core",
@@ -4461,7 +4460,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"encoding_rs",
@@ -4484,7 +4483,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"ide-core",
@@ -4503,7 +4502,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"criterion",
"globset",
@@ -4523,7 +4522,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"parking_lot",
@@ -4536,7 +4535,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4555,7 +4554,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"ide-language",
@@ -4570,7 +4569,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"libc",
@@ -4583,7 +4582,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"criterion",
"ide-core",
@@ -4595,7 +4594,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"flate2",
"hex",
@@ -4616,7 +4615,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"dunce",
"encoding_rs",
@@ -4635,7 +4634,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"chardetng",
"dunce",
@@ -4657,7 +4656,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"ide-core",
"open",
@@ -4673,7 +4672,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.33"
version = "0.1.32"
dependencies = [
"blake3",
"dunce",
@@ -6144,7 +6143,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.33"
version = "0.1.32"
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.33"
version = "0.1.32"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
+20
View File
@@ -14,6 +14,8 @@
 · 
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -45,6 +47,24 @@ 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,12 +371,6 @@ 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,43 +58,6 @@ 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)]
@@ -191,7 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -803,24 +765,6 @@ 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();
+4 -6
View File
@@ -172,14 +172,12 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
if ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.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 {
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
{
for project in affected() {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
if let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
+4 -5
View File
@@ -50,11 +50,10 @@ 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_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();
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();
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,8 +129,6 @@ 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())
@@ -144,27 +142,6 @@ 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,7 +7,6 @@ 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,9 +17,6 @@ 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
@@ -1,224 +0,0 @@
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
@@ -1,235 +0,0 @@
<?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>
@@ -1,25 +0,0 @@
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.
}
@@ -1,65 +0,0 @@
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,70 +1017,6 @@ 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,53 +62,6 @@ 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();
+1 -9
View File
@@ -16,14 +16,6 @@ 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);
@@ -32,7 +24,7 @@ pub(crate) fn detect_with_profiles(
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, profiles, &mut out);
cmake::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+5 -172
View File
@@ -2,7 +2,6 @@
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};
@@ -20,16 +19,10 @@ 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, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, 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| {
@@ -86,69 +79,11 @@ pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut V
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, None, out);
targets(root, &build, &dir, &prefix, out);
}
}
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>,
) {
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, 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
@@ -178,9 +113,6 @@ fn targets(
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 };
@@ -198,17 +130,8 @@ fn targets(
if !name.is_empty() {
args.extend(["--config", name]);
}
let mut build_config =
let 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 =
@@ -242,99 +165,9 @@ 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 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");
let query = cwd.join(dir).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,15 +201,6 @@ 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,72 +652,3 @@ 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,89 +348,6 @@ 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::*;
@@ -548,6 +465,89 @@ 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.33",
"version": "0.1.32",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+9 -16
View File
@@ -8,8 +8,6 @@ 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;
@@ -70,18 +68,16 @@ 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.
@@ -156,8 +152,6 @@ 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>() {
@@ -210,7 +204,6 @@ 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
@@ -1,483 +0,0 @@
//! 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));
}
}
@@ -1,237 +0,0 @@
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);
}
}
+1 -4
View File
@@ -60,10 +60,7 @@ 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)?;
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)?;
to.set_size(from.inner_size().map_err(err)?).map_err(err)?;
} else {
to.maximize().map_err(err)?;
}
+1 -9
View File
@@ -298,8 +298,6 @@ 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}")))?;
@@ -312,10 +310,6 @@ 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 {
@@ -771,8 +765,6 @@ 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.
@@ -840,7 +832,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"))]
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
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.33",
"version": "0.1.32",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
-34
View File
@@ -1639,30 +1639,6 @@ 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,
@@ -3927,10 +3903,6 @@ 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,
@@ -3943,10 +3915,6 @@ 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,
@@ -5659,7 +5627,6 @@ export type Settings_Deserialize = {
deployment?: DeploymentSettings,
/** Build, Execution, Deployment › Run Targets. */
runTargets?: RunTarget[],
cmake?: CMakeSettings_Deserialize,
};
export type Settings_Serialize = {
@@ -5715,7 +5682,6 @@ 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,6 +3,7 @@
min-height: 0;
display: flex;
flex-direction: column;
container-type: inline-size;
}
.content {
flex: 1;
@@ -136,7 +137,7 @@
gap: 5px;
}
/* A docked conversation owns the tool window; history opens on demand. */
/* A conversation docked in the tool window, above the task list. */
.docked {
flex: 1 1 0;
min-height: 0;
@@ -167,31 +168,30 @@
flex-direction: column;
}
.tasks {
flex: 1;
flex: 2 1 0;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.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;
border-top: 1px solid var(--border);
}
.location {
display: flex;
align-items: center;
gap: 2px;
padding: 1px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
/* 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);
}
}
+29 -48
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 { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
import { AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, 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(),
setSessionLocation: vi.fn(),
undockTask: vi.fn(),
}));
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
@@ -103,8 +103,6 @@ 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);
});
@@ -117,8 +115,6 @@ afterEach(() => {
function setup() {
render(
<>
<AgentsLocationActions />
<AgentsHeaderActions />
<AgentsView />
<ContextMenuHost />
</>,
@@ -162,44 +158,24 @@ describe("Agents selection", () => {
});
describe("Agents docked view", () => {
it("opens session history on demand and closes it after switching tasks", () => {
it("shows the docked conversation above the list and switches it by selecting a task", () => {
useAgents.setState({ docked: "one" });
setup();
expect(screen.getByTestId("docked-session")).toBeTruthy();
expect(screen.getByRole("button", { name: "Chat in Editor" })).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(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("lets keyboard users browse history before confirming a session", () => {
it("hides the docked task from the list below", () => {
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.getByText("one")).toBeTruthy();
expect(tree.queryByText("one")).toBeNull();
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", () => {
@@ -209,15 +185,11 @@ describe("Agents docked view", () => {
expect(screen.getByTestId("docked-session")).toBeTruthy();
});
it("switches placement from the global Agents header", () => {
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
it("undocks from the docked conversation's bar", () => {
useAgents.setState({ docked: "one" });
setup();
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");
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
expect(undockTask).toHaveBeenCalledTimes(1);
});
it("opens tabs as usual without a docked conversation", () => {
@@ -310,12 +282,21 @@ describe("Agents bulk actions", () => {
});
describe("Agents task menu", () => {
it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => {
useAgents.setState({ docked });
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.
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
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" }));
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));
});
});
+106 -153
View File
@@ -7,7 +7,6 @@ import {
EmptyState,
Icon,
IconButton,
Popup,
Spinner,
T,
Tree,
@@ -19,29 +18,16 @@ 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 (
<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>
<>
<IconButton icon="plus" action="agents.newTask" size="small" />
</>
);
}
@@ -91,10 +77,6 @@ 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?.());
@@ -113,7 +95,11 @@ export function AgentsView() {
}),
[],
);
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
// 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 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);
@@ -140,156 +126,123 @@ 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;
// Opening any session follows the global placement preference.
const showTask = (task: string) => {
setHistoryOpen(false);
void executeAction("agents.openTask", task);
};
// 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);
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}>
{inline && (
{docked && (
<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) => docked && dragTask(e, docked)}
onPointerDown={(e) => dragTask(e, docked)}
>
{homeOpen ? <T>New Agent Task</T> : dockedTitle}
{dockedTitle}
</span>
<IconButton
icon="editor-only"
action="agents.undockTask"
actionArg={docked}
title="Open in Editor"
size="small"
/>
</div>
<div className={styles.dockedSession}>
{homeOpen ? (
<HomeEditor docked />
) : (
docked && (
<SessionEditor
docked
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
)
)}
<SessionEditor
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
</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>
{/* 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>
)}
</div>
</div>
-78
View File
@@ -1,7 +1,5 @@
/* 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;
@@ -46,85 +44,9 @@
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;
+24 -31
View File
@@ -403,37 +403,30 @@ export function Composer(props: ComposerProps) {
}}
/>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
<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>
{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>
</div>
);
+1 -26
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentConfigOption } from "@/bindings";
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
@@ -85,28 +85,3 @@ 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();
});
+39 -94
View File
@@ -2,11 +2,9 @@
* 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 { IconButton, Popup, Select, T } from "@/ui";
import { Select } from "@/ui";
import type { SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -45,100 +43,47 @@ 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 (
<>
{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)}
{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
}
/>
<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,47 +60,3 @@
.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;
}
+17 -31
View File
@@ -1,5 +1,4 @@
/** 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";
@@ -8,7 +7,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, type EditorInput } from "@/workbench/contributions";
import { maybeEditorService } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft } from "./composerModel";
@@ -18,7 +17,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({ docked = false }: { docked?: boolean; input?: EditorInput }) {
export function HomeEditor() {
const tx = useTx();
const txTemplate = useTxTemplate();
const draft = useAgents((s) => s.draft);
@@ -87,19 +86,15 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
return (
<div className={clsx(styles.home, docked && styles.docked)}>
<div className={styles.home}>
<div className={styles.inner}>
{!docked && (
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
)}
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
<Composer
id={HOME}
autoFocus
placeholder={
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
}
placeholder="Describe a task for the agent. @ references a file, / runs a command"
root={root}
projectRoot={root}
commands={agentCommands}
@@ -136,7 +131,6 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
<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) => {
@@ -145,15 +139,12 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
}}
/>
) : (
<span className={styles.project} title={root}>
{members.length ? basename(root) : project?.name}
</span>
<span className={styles.project}>{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") },
@@ -169,28 +160,23 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
)}
{isolation === "local" && current && (
<>
{!docked && (
<span className={styles.note}>
<T>on</T>
</span>
)}
<span className={styles.branch} title={current}>
<span className={styles.note}>
<T>on</T>
</span>
<span className={styles.branch}>
<Icon name="branch" size={14} />
<span className={styles.branchName}>{current}</span>
{current}
</span>
</>
)}
{isolation === "worktree" && branches.length > 0 && (
<>
{!docked && (
<span className={styles.note}>
<T>from</T>
</span>
)}
<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 })}
@@ -198,7 +184,7 @@ export function HomeEditor({ docked = false }: { docked?: boolean; input?: Edito
</>
)}
</div>
{probe && (!docked || probe.error) && (
{probe && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
+1 -25
View File
@@ -16,20 +16,6 @@
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,
@@ -582,7 +568,6 @@
width: 280px;
}
.changesHeader {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
@@ -592,17 +577,11 @@
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;
@@ -629,11 +608,8 @@
gap: 4px;
}
.changeName {
flex: 0 1 auto;
min-width: 0;
flex: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.changeDir {
flex: 1 1 0;
+14 -30
View File
@@ -68,13 +68,15 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio
import { taskMenu } from "./taskActions";
import styles from "./Session.module.css";
export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) {
export function SessionEditor({ input }: { input: EditorInput }) {
const id = (input.data as { task: string }).task;
return <TaskSession key={id} id={id} docked={docked} />;
return <TaskSession key={id} id={id} />;
}
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
function TaskSession({ id }: { id: string }) {
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);
@@ -82,7 +84,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
// 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;
@@ -94,10 +96,8 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
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, docked }: { id: string; docked: boolean }) {
}
}}
>
<div className={styles.conversation} hidden={docked && changesOpen}>
<div className={styles.conversation}>
<Header
task={task}
entries={entries ?? null}
@@ -158,13 +158,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
)}
</div>
{changesOpen && (
<ChangesPane
task={task}
changes={changes ?? null}
head={head}
docked={docked}
onHide={() => setChangesOpen(false)}
/>
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
)}
</div>
);
@@ -276,7 +270,6 @@ 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}
>
@@ -1299,22 +1292,16 @@ 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. Docked sessions stay in the conversation until
* the user explicitly opens changes; navigation there does not change editor preferences.
* only while the task has changes (so it opens when the agent first edits a file).
*/
function useChangesOpen(
task: string,
changes: AgentChange[] | null,
docked: boolean,
): [boolean, (open: boolean) => void] {
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
task,
open: docked ? false : loadChangesOpen()[task],
open: loadChangesOpen()[task],
}));
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
const chosen = state.task === task ? state.open : 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 {
@@ -1328,13 +1315,11 @@ 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());
@@ -1343,7 +1328,6 @@ 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>
@@ -1354,7 +1338,7 @@ function ChangesPane({
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
</div>
{(head ?? task.base) && (
<div className={styles.changesSummary}>
+23 -3
View File
@@ -54,7 +54,6 @@ 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(
@@ -81,10 +80,11 @@ export function activate(): () => void {
icon: () => "toolwindow-agents",
restorable: true,
}),
// An explicit editor drop sets the placement for subsequent sessions too.
// A task dragged from the tool window onto the editor opens as a tab (or undocks).
registerEditorDropHandler({
accepts: (type) => type === TASK_DRAG,
drop: ({ payload }) => void executeAction("agents.openTaskInEditor", payload),
drop: ({ payload }) =>
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
}),
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
registerMenuGroup({
@@ -96,6 +96,26 @@ 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,
{
+6 -50
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 { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import type { 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[]>(), changes: [] as AgentChange[] }));
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
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: backend.changes }),
agentChanges: async () => ({ status: "ok", data: [] }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
@@ -76,8 +76,6 @@ 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" }]],
@@ -85,8 +83,6 @@ 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),
});
@@ -96,13 +92,12 @@ 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", sessionLocation: "toolWindow" });
useAgents.setState({ docked: "A" });
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.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"));
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"), { button: 0 });
expect(await screen.findByText("Answer B")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
@@ -115,45 +110,6 @@ 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;
@@ -177,7 +133,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", sessionLocation: "toolWindow" });
useAgents.setState({ docked: "A" });
const dock = render(<AgentsView />);
await screen.findByText("Answer A");
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
+8 -77
View File
@@ -44,8 +44,6 @@ 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. */
@@ -69,15 +67,11 @@ 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 {
@@ -112,9 +106,6 @@ 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(),
}));
@@ -219,8 +210,6 @@ export function disconnect() {
entries: new Map(),
changes: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
});
}
@@ -364,76 +353,24 @@ 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 });
}
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
/** Shows the conversation inside the Agents tool window, closing its editor tab. */
export function dockTask(task: string) {
useAgents.setState({ docked: task });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
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);
}
/** Moves the docked conversation back to an editor tab. */
export function undockTask() {
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);
const docked = useAgents.getState().docked;
if (!docked) return;
useAgents.setState({ docked: null });
openTask(docked);
}
/** Opens the New Task page; `prompt` pre-fills it. */
@@ -445,12 +382,6 @@ 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 });
}
+11 -35
View File
@@ -4,18 +4,7 @@ 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,
openTaskInEditorLocation,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
@@ -70,29 +59,6 @@ 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",
@@ -165,10 +131,20 @@ 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"
? ([
+5 -64
View File
@@ -1,27 +1,15 @@
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, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import { dropOnEditor, useContributions } 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,
openHome,
openTask,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
import { connect, disconnect, dockTask, markVisible, 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(),
@@ -50,12 +38,7 @@ 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,
activeInput: () => backend.active,
openTabs: () => backend.tabs,
}),
editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
toolWindowService: () => ({ show: backend.showToolWindow }),
notifications: () => ({ notify: backend.notify }),
}));
@@ -98,10 +81,6 @@ 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();
});
@@ -272,7 +251,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.openTaskInEditor", "two");
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
@@ -281,7 +260,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.openTaskInEditor", "one");
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
@@ -290,47 +269,9 @@ 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,7 +6,6 @@ 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 }>)),
@@ -55,7 +54,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, updateExecutionPoint, useExecutionPoint, useDebug, type DebugSession } from "../state";
import { publishContext, useDebug, type DebugSession } from "../state";
import { focusDebugView, blurDebugView } from "../ui/focusedView";
import { useOverlays } from "../ui/popoverStore";
@@ -98,7 +97,6 @@ function paused(p: Partial<DebugSession> = {}) {
};
useDebug.setState({ sessions: { 3: s }, active: 3 });
publishContext();
updateExecutionPoint();
}
function caret(path: string, language: string, doc: string, line: number) {
@@ -156,41 +154,15 @@ 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);
});
@@ -208,7 +180,6 @@ 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,60 +166,6 @@ 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" } });
@@ -1,202 +0,0 @@
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");
});
});
@@ -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 { adoptSession, useDebug, useExecutionPoint } from "../state";
import { useExecutionPoint } from "../state";
import { useOverlays } from "../ui/popoverStore";
import {
calleeName,
@@ -92,8 +92,6 @@ 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,12 +52,6 @@ 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 => ({
+13 -44
View File
@@ -24,7 +24,6 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch";
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
import {
activeSession,
resumeExecution,
evaluate,
isLive,
rerunSession,
@@ -90,10 +89,7 @@ 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",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, path, line, false))),
);
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, path, line, false)));
}
function notSupported(what: string, s: DebugSession) {
@@ -119,17 +115,15 @@ async function smartStepInto() {
if (!t) return;
const frame = selectedFrame(t.s);
if (!frame || !sessionSupports("stepInTargets", t.s)) {
await resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into")));
await 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 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"),
),
await call(
targets[0]
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
: commands.debugStep(t.s.id, t.thread, "into"),
);
return;
}
@@ -323,11 +317,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
title,
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, kind))),
);
if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind)));
},
});
@@ -410,11 +400,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Resume failed",
resumeExecution(t.s.id, () => call(commands.debugResume(t.s.id, t.thread))),
);
if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread)));
},
},
{
@@ -446,11 +432,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Force Step Over failed",
resumeExecution(t.s.id, () => call(commands.debugForceStepOver(t.s.id, t.thread))),
);
if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread)));
},
},
{
@@ -463,11 +445,7 @@ export function registerDebugActions(): () => void {
when: paused,
run: () => {
const t = pausedTarget();
if (t)
void report(
"Force Step Into failed",
resumeExecution(t.s.id, () => call(commands.debugStep(t.s.id, t.thread, "into"))),
);
if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into")));
},
},
{
@@ -506,10 +484,7 @@ export function registerDebugActions(): () => void {
notSupported("Reset Frame", t.s);
return;
}
void report(
"Reset Frame failed",
resumeExecution(t.s.id, () => call(commands.debugRestartFrame(t.s.id, frame.id))),
);
void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id)));
},
},
{
@@ -556,10 +531,7 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report(
"Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))),
);
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
},
},
{
@@ -573,10 +545,7 @@ export function registerDebugActions(): () => void {
const t = pausedTarget();
const c = cursor();
if (t && c && breakpointPathAllowed(c.path))
void report(
"Force Run to Cursor failed",
resumeExecution(t.s.id, () => call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))),
);
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
},
},
{
+18 -33
View File
@@ -49,7 +49,6 @@ 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)}`);
@@ -81,48 +80,33 @@ 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) {
@@ -152,11 +136,12 @@ export function executionPoint(ctx: EditorDocContext): Extension {
destroy() {
this.destroyed = true;
this.unsub();
this.unsubSettings();
}
},
{ decorations: (v) => v.decorations },
);
return [execField, sync, inline];
return [execField, inline];
}
+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, resumeExecution } from "../state";
import { useExecutionPoint } 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 resumeExecution(s.session, () => call(commands.debugStepInTarget(s.session, s.thread, target))).catch((e) =>
void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) =>
notifications().notify({
group: "run",
level: "error",
+5 -38
View File
@@ -644,7 +644,6 @@ 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);
}
@@ -653,7 +652,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 && useDebug.getState().active === id && frame?.path) {
if (reveal && frame?.path) {
void maybeEditorService()?.openFile(frame.path, {
selection: { line: frame.line, col: frame.column },
focus: false,
@@ -663,7 +662,6 @@ 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));
@@ -671,7 +669,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 || s.frames !== before?.frames) return;
if (!s || s.status !== "paused" || s.selectedThread !== threadId) 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")) {
@@ -709,21 +707,19 @@ async function loadThreads(id: DebugSessionId) {
/** Locals of `frameId` (first non-expensive scope) for inline values. */
async function loadLocals(id: DebugSessionId, frameId: number) {
const frames = useDebug.getState().sessions[id]?.frames;
const values: Record<string, string> = Object.create(null);
const values: Record<string, string> = {};
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 && !Object.hasOwn(values, v.name)) values[v.name] = v.value;
for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value;
}
} catch {
// No inline values.
}
const s = useDebug.getState().sessions[id];
if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames)
return;
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return;
useExecutionPoint.setState({ values });
}
@@ -733,7 +729,6 @@ 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];
@@ -762,32 +757,6 @@ 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) {
@@ -893,7 +862,6 @@ 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;
@@ -908,7 +876,6 @@ 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: [],
+2 -4
View File
@@ -13,7 +13,7 @@ import {
updateBreakpoint,
useBreakpoints,
} from "../breakpoints/store";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug, resumeExecution } from "../state";
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
import {
addMethodBreakpoint,
removeAllSpecial,
@@ -978,9 +978,7 @@ function SmartStepChooser() {
const pick = (targetId: number) => {
if (!state) return;
close();
void resumeExecution(state.session, () =>
call(commands.debugStepInTarget(state.session, state.thread, targetId)),
).catch((e) =>
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
);
};
+1 -8
View File
@@ -538,14 +538,7 @@ export function activate(): () => void {
}),
// Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options).
onIdeEvent("settingsChanged", (e) => {
if (
!e.key ||
e.key === "languages" ||
e.key.startsWith("languages.") ||
e.key === "cmake" ||
e.key.startsWith("cmake.")
)
scheduleReload();
if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload();
}),
);
@@ -1,46 +0,0 @@
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" },
},
]);
});
});
@@ -1,72 +0,0 @@
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,29 +84,6 @@ 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,9 +87,6 @@ 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);
@@ -1,68 +0,0 @@
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,7 +4,6 @@
* 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";
@@ -83,7 +82,6 @@ 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. */
@@ -152,7 +150,6 @@ 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: [] },
@@ -257,7 +254,6 @@ 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),
@@ -1,4 +0,0 @@
.profile {
width: 100%;
text-align: left;
}
-151
View File
@@ -1,151 +0,0 @@
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, Select } from "../../components/Form";
import { NumberInput, Row } from "../../components/Form";
import { useSetting } from "../../components/context";
import type { PageSpec } from "../spec";
import { CODE_STYLE_SCOPE, CodeStyleMain, SchemeRow, formatterLabels } from "./codeStyleScheme";
@@ -159,7 +159,6 @@ 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>
@@ -239,62 +238,6 @@ 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). */
@@ -318,9 +261,6 @@ 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,7 +3,6 @@
* 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";
@@ -91,25 +90,6 @@ 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
@@ -1,38 +0,0 @@
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,9 +24,6 @@ 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;
+3 -29
View File
@@ -1364,10 +1364,12 @@
"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": "在编辑器中打开已移动的文件",
@@ -5438,33 +5440,5 @@
"copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内",
"cannot copy overlapping paths": "无法在相互包含的路径之间复制",
"cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制",
"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}"
"expected a relative comparison entry path": "需要比较条目的相对路径"
}
-2
View File
@@ -80,9 +80,7 @@ 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",