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