Compare commits

..
Author SHA1 Message Date
l0ng-ai fe68d57aa2 chore(i18n): drop catalog entries no longer used in the source 2026-11-01 18:25:59 +08:00
l0ng-ai d14a067ee1 Merge remote-tracking branch 'origin/main' into feat/java-idea-code-style
# Conflicts:
#	src/platform/locales/text/zh.json
2026-11-01 18:23:46 +08:00
l0ng-ai b3b4c835dd fix(java): preserve manual formatter line breaks 2026-11-01 18:11:50 +08:00
fuqiuluo 041e72106b fix(linux): restore window geometry after GTK mapping (#619)
On Linux the window-state plugin restored the saved size before GTK's decorations and output scale had settled, so the saved physical inner size went through the creation-time CSD/scale conversion and drifted on every restart (52 px per axis under Mutter, the remembered size collapsing on other setups), and maximize transitions overwrote the remembered normal size.

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

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

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

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

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

Fixes #664
2026-11-01 17:19:00 +08:00
41 changed files with 2504 additions and 408 deletions

No files matched your search

Generated
+1
View File
@@ -4374,6 +4374,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
+6
View File
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
+6 -4
View File
@@ -172,12 +172,14 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers", "projectStructure"]
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
{
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if let Some(bridge) = &project.kotlin_bridge {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
+23
View File
@@ -129,6 +129,8 @@ impl Host {
let root = root.clone();
let data_dir = self.data_dir.clone();
let config_dir = self.config_dir.clone();
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
@@ -142,6 +144,27 @@ impl Host {
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
cfg,
effective.editor.code_style.languages.get("java"),
root,
&data_dir,
effective.language_servers.get("jdtls"),
)
.err()
.map(|e| e.to_string());
let mut previous = formatter_problem.lock().unwrap();
if problem != *previous {
if let Some(body) = &problem {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Java formatter configuration unavailable".into(),
body: Some(body.clone()),
});
}
*previous = problem;
}
drop(previous);
ide_lsp::jdtls::configure_source_paths(
cfg,
ide_workspace::roots::unmanaged_java_source_paths(
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
+3
View File
@@ -17,6 +17,9 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
mod formatter;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
/// A workspace build of a large multi-module project can take far longer than
+224
View File
@@ -0,0 +1,224 @@
use ide_core::IdeResult;
use ide_core::editor_settings::LanguageCodeStyle;
use ide_core::settings::LanguageServerSettings;
use ide_language::LanguageServerConfig;
use serde_json::json;
use std::path::Path;
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
const XML: &str = include_str!("intellij.xml");
/// Protocol settings only: switching profiles reaches a running jdtls through
/// didChangeConfiguration. Explicit language-server settings are applied later.
pub fn configure_formatter(
cfg: &mut LanguageServerConfig,
style: Option<&LanguageCodeStyle>,
project_root: &Path,
data_dir: &Path,
overrides: Option<&LanguageServerSettings>,
) -> IdeResult<()> {
if cfg.name != "jdtls" {
return Ok(());
}
// jdtls retains omitted preferences. Send empty values when returning to
// Default, while preserving any profile from the base server configuration.
let inherited = crate::handler::all_settings(cfg);
let defaults = json!({
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
});
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
cfg.settings = Some(settings);
let Some(style) = style else { return Ok(()) };
let (path, profile) = match style.formatter.as_deref() {
Some("intellij") => {
let path = data_dir.join("lsp/jdtls/intellij.xml");
std::fs::create_dir_all(path.parent().unwrap())?;
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
ide_vfs::write_atomic(&path, XML.as_bytes())?;
}
(path, PROFILE)
}
Some("custom") => {
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
if value.is_empty() {
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
}
let path = project_root.join(value);
// Fail without poisoning the server launch; code intelligence remains available.
std::fs::File::open(&path).map_err(|error| {
let path = path.display();
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
})?;
if !path.is_file() {
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
}
(path, "")
}
_ => return Ok(()),
};
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(
json!({
"url": crate::uri::path_to_uri_string(&path),
"profile": profile,
}),
overrides,
);
cfg.settings = Some(settings);
Ok(())
}
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
if let Some(overrides) = overrides {
// all_settings merges protocol settings over initializationOptions.
// Mirror explicit formatter keys so generated protocol values cannot
// accidentally mask an advanced initializationOptions override.
for layer in [
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
]
.into_iter()
.flatten()
{
for key in ["url", "profile"] {
if let Some(value) = layer.get(key) {
format[key] = value.clone();
}
}
}
}
format
}
#[cfg(test)]
mod tests {
use super::*;
fn config(name: &str) -> LanguageServerConfig {
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
}
#[test]
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project with 空格");
let data = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
let custom = root.join("style.xml");
std::fs::write(&custom, XML).unwrap();
for name in ["jdtls", "rust-analyzer"] {
for mode in ["default", "intellij", "custom", "unknown"] {
let mut cfg = LanguageServerConfig {
name: name.into(),
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
..config(name)
};
let initial = cfg.clone();
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("style.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
if name != "jdtls" {
assert_eq!(cfg, initial);
continue;
}
let settings = crate::handler::all_settings(&cfg);
if !["intellij", "custom"].contains(&mode) {
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
continue;
}
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
assert_eq!(
settings["java"]["format"]["settings"]["profile"],
if mode == "custom" { "" } else { PROFILE }
);
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
}
}
}
#[test]
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
let dir = tempfile::tempdir().unwrap();
for path in ["", "missing.xml", "."] {
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.into()),
..Default::default()
};
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
assert!(cfg.launch_error.is_none());
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
}
}
#[test]
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.xml");
std::fs::write(&path, XML).unwrap();
let mut cfg = config("jdtls");
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
}))
.unwrap(),
);
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({"url": "existing.xml", "profile": "Existing"})
);
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
crate::uri::path_to_uri_string(&path)
);
}
#[test]
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
for mode in ["default", "intellij", "custom"] {
for layer in ["initialization", "wrapped", "direct"] {
let mut overrides = LanguageServerSettings {
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
"url": "advanced.xml", "profile": "Advanced"
}}}}})),
..Default::default()
};
if layer != "initialization" {
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
}
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({
"url": "advanced.xml",
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
})
);
}
}
}
}
+235
View File
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
A formatter profile does not sort/group imports (Organize Imports is a separate action).
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
-->
<profiles version="23">
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<!-- IndentOptions.TAB_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<!-- IndentOptions.INDENT_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<!-- INDENT_CASE_FROM_SWITCH = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
<!-- INDENT_BREAK_FROM_CASE = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
</profile>
</profiles>
@@ -0,0 +1,25 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
@Deprecated @SuppressWarnings("unused") private String first="first";
private String second="second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter){
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value->value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter){combine(firstParameter, // retain call break
secondParameter, "third");}
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
@@ -0,0 +1,65 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated
@SuppressWarnings("unused")
public class FormatterSample {
@Deprecated
@SuppressWarnings("unused")
private String first = "first";
private String second = "second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated
@SuppressWarnings("unused")
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
if (firstParameterWithAVeryLongName == null) {
return "none";
} else {
try {
return List
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
thirdParameterWithAVeryLongName)
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
.orElse("none");
} catch (RuntimeException problem) {
throw problem;
} finally {
System.out.println("done");
}
}
}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter) {
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value -> value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter) {
combine(firstParameter, // retain call break
secondParameter, "third");
}
public void arrays() {
int[] values = new int[]{1, 2, 3};
int[] empty = new int[]{};
Function<String, String> clean = value -> {
return value.trim();
};
Runnable run = () -> {
System.out.println(values.length);
};
}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
+64
View File
@@ -1017,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() {
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
+47
View File
@@ -62,6 +62,53 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "requires jdtls on PATH and a compatible JDK"]
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let source = include_str!("fixtures/FormatterSample.input.java");
let expected = include_str!("fixtures/FormatterSample.intellij.java");
let path = root.join("src/demo/FormatterSample.java");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, source).unwrap();
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
let server = &mut java.language_servers[0];
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
server.command = bin;
}
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
mgr.set_config_hook(Box::new({
let root = root.clone();
move |cfg, _| {
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
let style = ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
..Default::default()
};
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
}
}));
let doc = DocId::next();
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
retry("jdtls ready", Duration::from_secs(120), || {
let status = last_status(&events, "jdtls");
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
panic!("jdtls failed: {msg:?}");
}
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
})
.await;
let edits =
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
let mut document = ide_text::Document::new(source);
document.apply_edits(&edits.unwrap()).unwrap();
assert_eq!(document.text(), expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn missing_binary_is_reported_with_install_hint() {
let dir = tempfile::tempdir().unwrap();
+83 -83
View File
@@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -465,89 +548,6 @@ mod tests {
}
}
/// Runs a formatter with separate pipes so diagnostics can never become document text.
pub(crate) fn run_input(
cmd: &mut Command,
input: &str,
timeout: Duration,
cancel: &CancellationToken,
) -> IdeResult<String> {
use std::io::Write;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let mut stdin = child.stdin.take().unwrap();
let input = input.to_owned();
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
enum Pipe {
Written(std::io::Result<()>),
Read(bool, std::io::Result<Vec<u8>>),
}
let (tx, rx) = mpsc::channel();
let writer = tx.clone();
std::thread::spawn(move || {
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
});
for (is_stderr, mut pipe) in [
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
] {
let tx = tx.clone();
std::thread::spawn(move || {
let mut bytes = Vec::new();
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
});
}
drop(tx);
let started = Instant::now();
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
let result = loop {
if cancel.is_cancelled() {
break Err(IdeError::cancelled());
}
if started.elapsed() >= timeout {
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
}
if status.is_none() {
match child.try_wait() {
Ok(s) => status = s,
Err(e) => break Err(e.into()),
}
}
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
break Ok(());
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(Pipe::Written(result)) => written = Some(result),
Ok(Pipe::Read(true, result)) => errors = Some(result),
Ok(Pipe::Read(false, result)) => output = Some(result),
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
std::thread::sleep(Duration::from_millis(10));
}
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if result.is_err() {
kill(&mut child);
}
result?;
let output = output.unwrap()?;
let errors = errors.unwrap()?;
let status = status.unwrap();
if !status.success() {
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
format!("Formatter exited with {status}")
} else {
diagnostics
}));
}
written.unwrap()?;
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
}
#[cfg(all(test, unix))]
mod input_tests {
use super::*;
+16 -9
View File
@@ -8,6 +8,8 @@ mod commands;
#[cfg(debug_assertions)]
mod dev;
mod events;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
mod menu;
mod remote_dev;
@@ -68,16 +70,18 @@ pub fn run() {
// Single instance first: a second launch focuses this one and opens its path argument.
// Debug builds have a separate identifier per worktree.
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
#[cfg(target_os = "linux")]
let app = app.plugin(linux_window_state::plugin());
#[cfg(not(target_os = "linux"))]
let app = app.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
// One remembered geometry shared by every project's settings window.
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
.build(),
);
let app = app
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
// One remembered geometry shared by every project's settings window.
.map_label(|l| if windows::is_settings_window(l) { "settings" } else { l })
.build(),
)
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
.plugin(tauri_plugin_updater::Builder::new().build());
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
@@ -152,6 +156,8 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| match event {
#[cfg(target_os = "linux")]
tauri::WindowEvent::CloseRequested { .. } => linux_window_state::save_before_close(window.app_handle()),
tauri::WindowEvent::Focused(true) => {
application_focus::changed(window.app_handle(), true);
if let Some(w) = window.try_state::<WindowsState>() {
@@ -204,6 +210,7 @@ pub fn run() {
});
}
#[cfg(not(target_os = "linux"))]
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
use tauri_plugin_window_state::StateFlags;
StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN
+483
View File
@@ -0,0 +1,483 @@
//! Restore GTK geometry after mapping, using the scale of the window's actual output.
mod geometry;
#[cfg(target_os = "linux")]
use geometry::*;
use tauri::utils::config::WindowConfig;
fn build_hidden(config: &mut WindowConfig) -> bool {
std::mem::replace(&mut config.visible, false)
}
#[cfg(target_os = "linux")]
mod runtime {
use super::*;
use parking_lot::Mutex;
use std::collections::HashMap;
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
#[derive(Default)]
enum Phase {
#[default]
Mapping,
Size {
target: Size,
scale: f64,
request: u64,
},
Mode,
Tracking,
}
#[derive(Default)]
struct Pending {
limits: Limits,
configured: bool,
presented: bool,
presentation_request: u64,
saved: Geometry,
normal: Size,
history: ModeHistory,
restore_position: bool,
phase: Phase,
mode_requested: bool,
mode_request: u64,
}
#[derive(Default)]
struct Cache {
saved: HashMap<String, Geometry>,
windows: HashMap<String, Pending>,
next_request: u64,
}
struct Frame {
size: Size,
scale: f64,
mode: Mode,
position: Option<tauri::PhysicalPosition<i32>>,
monitors: Vec<tauri::Monitor>,
work_area: Option<Size>,
visible: bool,
}
fn frame(window: &Window) -> Option<Frame> {
let size = window.inner_size().ok()?;
Some(Frame {
visible: window.is_visible().ok()?,
size: Size(size.width, size.height),
scale: window.scale_factor().ok()?,
mode: Mode {
maximized: window.is_maximized().ok()?,
fullscreen: window.is_fullscreen().ok()?,
minimized: window.is_minimized().ok()?,
},
position: window.outer_position().ok(),
monitors: window.available_monitors().unwrap_or_default(),
work_area: window
.current_monitor()
.ok()
.flatten()
.map(|m| Size(m.work_area().size.width, m.work_area().size.height)),
})
}
fn key(label: &str) -> &str {
if crate::windows::is_settings_window(label) { "settings" } else { label }
}
pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool {
let hint_monitor = app.primary_monitor().ok().flatten();
let hint_scale = hint_monitor.as_ref().map_or(1.0, |m| m.scale_factor());
let hint_area = hint_monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height));
let cache = app.state::<Mutex<Cache>>();
let mut cache = cache.lock();
let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry {
maximized: config.maximized,
fullscreen: config.fullscreen,
..Default::default()
});
cache.next_request += 1;
let presentation_request = cache.next_request;
let pending = Pending {
presentation_request,
limits: Limits {
min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)),
max: (config.max_width, config.max_height),
},
normal: Size(saved.width, saved.height),
saved,
restore_position: true,
..Default::default()
};
if pending.saved.width > 0 && pending.saved.height > 0 {
// This only reduces the first-frame jump; mapped geometry is authoritative.
let size = restore_size(pending.normal, hint_scale, pending.limits.in_work_area(hint_area, hint_scale));
config.width = f64::from(size.0) / hint_scale;
config.height = f64::from(size.1) / hint_scale;
}
cache.windows.insert(config.label.clone(), pending);
// Establish a normal window before asking GTK to maximize or fullscreen it.
config.maximized = false;
config.fullscreen = false;
build_hidden(config)
}
fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) {
let Some(frame) = frame(window) else { return };
if !frame.visible {
return;
}
let cache = window.state::<Mutex<Cache>>();
let mut cache = cache.lock();
cache.next_request += 1;
let request = cache.next_request;
let Some(pending) = cache.windows.get_mut(window.label()) else { return };
if !pending.presented && pending.saved.width == 0 {
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
}
if !pending.configured || !pending.presented {
return;
}
if !allow_restore && !matches!(pending.phase, Phase::Tracking) {
return;
}
let transition = if matches!(pending.phase, Phase::Tracking) {
pending.history.observe(frame.mode, allow_restore)
} else {
NormalTransition::Stable
};
if transition == NormalTransition::WaitForResize {
let normal = pending.normal;
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
(saved.width, saved.height) = (normal.0, normal.1);
saved.maximized = frame.mode.maximized;
saved.fullscreen = frame.mode.fullscreen;
return;
}
if transition == NormalTransition::Restore {
// The compositor may have changed output/DPI while maximized or minimized.
pending.saved.set_normal_size(pending.normal);
pending.phase = Phase::Mapping;
pending.mode_requested = false;
}
let mut fit_settings = false;
let mut position = None;
if matches!(pending.phase, Phase::Mapping) {
if pending.restore_position {
pending.restore_position = false;
let saved = &pending.saved;
let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) };
if frame.monitors.iter().any(|m| {
let p = m.position();
let s = m.size();
i64::from(x) < i64::from(p.x) + i64::from(s.width)
&& i64::from(x) + i64::from(saved.width) > i64::from(p.x)
&& i64::from(y) < i64::from(p.y) + i64::from(s.height)
&& i64::from(y) + i64::from(saved.height) > i64::from(p.y)
}) {
position = Some(tauri::PhysicalPosition::new(x, y));
}
}
if pending.saved.width > 0 && pending.saved.height > 0 {
let target = restore_size(
Size(pending.saved.width, pending.saved.height),
frame.scale,
pending.limits.in_work_area(frame.work_area, frame.scale),
);
pending.phase = Phase::Size { target, scale: frame.scale, request: 0 };
} else {
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
pending.phase = Phase::Mode;
}
}
if let Phase::Size { target, scale, request: sent } = pending.phase {
if frame.size != target && !accept_constrained_size {
if sent != 0 && scale == frame.scale {
return;
}
// Re-evaluate after output scale changes, rather than reusing monitor 0's scale.
let target = restore_size(
Size(pending.saved.width, pending.saved.height),
frame.scale,
pending.limits.in_work_area(frame.work_area, frame.scale),
);
pending.phase = Phase::Size { target, scale: frame.scale, request };
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) {
tracing::warn!("cannot restore Linux window size: {error}");
}
settle_constrained_size(window, request);
return;
}
if scale != frame.scale && !accept_constrained_size {
pending.phase = Phase::Mapping;
drop(cache);
sample(window, true, false);
return;
}
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
pending.phase = Phase::Mode;
}
if matches!(pending.phase, Phase::Mode) {
let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen);
let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized };
let action = mode_action(reached, pending.mode_requested, accept_constrained_size);
if action == ModeAction::Wait {
return;
}
if action == ModeAction::Request {
pending.mode_requested = true;
pending.mode_request = request;
let mut snapshot = pending.saved.clone();
(snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1);
cache.saved.insert(key(window.label()).to_owned(), snapshot);
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if maximize {
let _ = window.maximize();
}
if fullscreen {
let _ = window.set_fullscreen(true);
}
settle_constrained_size(window, request);
return;
}
pending.phase = Phase::Tracking;
fit_settings = crate::windows::is_settings_window(window.label());
}
pending.history = ModeHistory(frame.mode);
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
let normal = pending.normal;
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
(saved.width, saved.height) = (normal.0, normal.1);
saved.maximized = frame.mode.maximized;
saved.fullscreen = frame.mode.fullscreen;
if frame.mode.normal()
&& let Some(pos) = frame.position
{
(saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y);
}
drop(cache);
if let Some(position) = position {
let _ = window.set_position(position);
}
if fit_settings {
let app = window.app_handle().clone();
let label = window.label().to_owned();
let _ = window.run_on_main_thread(move || {
if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p))
&& let Some(settings) = app.get_webview_window(&label)
&& let Err(error) = crate::windows::place_settings_window(&settings, &parent)
{
tracing::warn!("cannot fit restored settings window: {error}");
}
});
}
}
fn settle_constrained_size(window: &Window, request: u64) {
let window = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let cloned = window.clone();
let _ = window.run_on_main_thread(move || {
let waiting = cloned.state::<Mutex<Cache>>().lock().windows.get(cloned.label()).is_some_and(|p| {
matches!(p.phase, Phase::Size { request: current, .. } if current == request)
|| matches!(p.phase, Phase::Mode) && p.mode_request == request
});
if waiting {
// Window-manager constraints may reject a size without producing an exact match.
sample(&cloned, true, true);
}
});
});
}
pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize<u32>) {
let cache = app.state::<Mutex<Cache>>();
let mut cache = cache.lock();
cache.next_request += 1;
let request = cache.next_request;
if let Some(pending) = cache.windows.get_mut(label) {
pending.normal = Size(size.width, size.height);
pending.saved.set_normal_size(pending.normal);
pending.mode_requested = false;
pending.presented = false;
pending.presentation_request = request;
pending.phase = Phase::Mapping;
}
let saved = cache.saved.entry(key(label).to_owned()).or_default();
saved.set_normal_size(Size(size.width, size.height));
drop(cache);
if let Some(webview) = app.get_webview_window(label) {
request_presentation(webview.as_ref(), request);
}
}
fn request_presentation(webview: &tauri::Webview, request: u64) {
// A configure precedes output-enter. A second animation frame follows the
// first paint, allowing GTK to learn the scale of the assigned output.
let script = r#"
(() => {
const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false };
state.request = __REQUEST_ID__;
state.ready ??= () => {
if (document.hidden || state.scheduled) return;
const request = state.request;
state.scheduled = true;
requestAnimationFrame(() => requestAnimationFrame(() => {
state.scheduled = false;
if (document.hidden) return;
const api = window.__TAURI_INTERNALS__;
const label = api?.metadata?.currentWindow?.label;
if (label) api.invoke('plugin:event|emit', {
event: 'openidea://window-state-presented', payload: { label, request }
});
if (request !== state.request) state.ready();
}));
};
if (!state.listening) {
state.listening = true;
document.addEventListener('visibilitychange', state.ready);
}
state.ready();
})();
"#
.replace("__REQUEST_ID__", &request.to_string());
let _ = webview.eval(script);
}
pub fn save_before_close(app: &AppHandle) {
for window in app.webview_windows().values() {
sample(&window.as_ref().window(), false, false);
}
persist(app);
}
fn persist(app: &AppHandle) {
let write = || -> Result<(), Box<dyn std::error::Error>> {
let dir = app.path().app_config_dir()?;
std::fs::create_dir_all(&dir)?;
let cache = app.state::<Mutex<Cache>>();
let data = serde_json::to_vec_pretty(&cache.lock().saved)?;
let temporary = dir.join(format!("{FILE}.tmp"));
std::fs::write(&temporary, data)?;
std::fs::rename(temporary, dir.join(FILE))?;
Ok(())
};
if let Err(error) = write() {
tracing::warn!("cannot save Linux window state: {error}");
}
}
pub fn plugin() -> TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("linux-window-state")
.setup(|app, _| {
let saved = app
.path()
.app_config_dir()
.ok()
.and_then(|dir| std::fs::read(dir.join(FILE)).ok())
.and_then(|data| serde_json::from_slice(&data).ok())
.unwrap_or_default();
app.manage(Mutex::new(Cache { saved, ..Default::default() }));
let handle = app.clone();
app.listen("openidea://window-state-presented", move |event| {
#[derive(serde::Deserialize)]
struct Presented {
label: String,
request: u64,
}
let Ok(presented) = serde_json::from_str::<Presented>(event.payload()) else { return };
let Some(webview) = handle.get_webview_window(&presented.label) else { return };
let window = webview.as_ref().window();
let cloned = window.clone();
let _ = window.run_on_main_thread(move || {
if !cloned.is_visible().unwrap_or(false) {
return;
}
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
if pending.presentation_request != presented.request {
return;
}
pending.presented = true;
}
sample(&cloned, true, false);
});
});
Ok(())
})
.on_page_load(|webview, payload| {
if payload.event() == tauri::webview::PageLoadEvent::Finished {
let request = webview
.state::<Mutex<Cache>>()
.lock()
.windows
.get(webview.window().label())
.map(|p| p.presentation_request);
if let Some(request) = request {
request_presentation(webview, request);
}
}
})
.on_window_ready(|window| {
let cloned = window.clone();
window.on_window_event(move |event| match event {
WindowEvent::Resized(_) => {
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
pending.configured = true;
}
let request = cloned
.state::<Mutex<Cache>>()
.lock()
.windows
.get(cloned.label())
.filter(|p| !p.presented)
.map(|p| p.presentation_request);
if let Some(request) = request
&& let Some(webview) = cloned.app_handle().get_webview_window(cloned.label())
{
request_presentation(webview.as_ref(), request);
}
sample(&cloned, true, false);
}
WindowEvent::Moved(_) => sample(&cloned, false, false),
WindowEvent::Destroyed => {
cloned.state::<Mutex<Cache>>().lock().windows.remove(cloned.label());
}
_ => {}
});
})
.on_event(|app, event| {
if let tauri::RunEvent::Exit = event {
persist(app);
}
})
.build()
}
}
#[cfg(target_os = "linux")]
pub use runtime::{normal_size_requested, plugin, prepare, save_before_close};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hidden_creation_preserves_geometry() {
let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() };
assert!(build_hidden(&mut config));
assert!(!config.visible);
assert_eq!((config.width, config.height), (1201.0, 801.0));
assert!(!build_hidden(&mut config));
}
}
@@ -0,0 +1,237 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct Size(pub(super) u32, pub(super) u32);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct Mode {
pub(super) maximized: bool,
pub(super) fullscreen: bool,
pub(super) minimized: bool,
}
impl Mode {
pub(super) fn normal(self) -> bool {
!self.maximized && !self.fullscreen && !self.minimized
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct ModeHistory(pub(super) Mode);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum NormalTransition {
Stable,
WaitForResize,
Restore,
}
impl ModeHistory {
pub(super) fn observe(&mut self, current: Mode, can_restore: bool) -> NormalTransition {
let returned = !self.0.normal() && current.normal();
if returned && !can_restore {
return NormalTransition::WaitForResize;
}
self.0 = current;
if returned { NormalTransition::Restore } else { NormalTransition::Stable }
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct Limits {
pub(super) min: (f64, f64),
pub(super) max: (Option<f64>, Option<f64>),
}
impl Limits {
pub(super) fn in_work_area(self, area: Option<Size>, scale: f64) -> Self {
let Some(Size(width, height)) = area.filter(|s| s.0 > 0 && s.1 > 0) else { return self };
Self {
max: (
Some(self.max.0.unwrap_or(f64::INFINITY).min((f64::from(width) / scale).floor())),
Some(self.max.1.unwrap_or(f64::INFINITY).min((f64::from(height) / scale).floor())),
),
..self
}
}
}
pub(super) fn restore_size(saved: Size, scale: f64, limits: Limits) -> Size {
let dimension = |pixels: u32, min: f64, max: Option<f64>| {
let logical = (f64::from(pixels) / scale).max(min).max(1.0);
// gtk_window_resize takes integer logical dimensions.
(logical.min(max.unwrap_or(f64::INFINITY).max(min)).round() * scale).round() as u32
};
Size(dimension(saved.0, limits.min.0, limits.max.0), dimension(saved.1, limits.min.1, limits.max.1))
}
pub(super) fn remember_size(previous: Size, current: Size, restoring: bool, mode: Mode) -> Size {
if restoring || !mode.normal() || current.0 == 0 || current.1 == 0 { previous } else { current }
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ModeAction {
Request,
Wait,
Track,
}
pub(super) fn mode_action(reached: bool, requested: bool, timed_out: bool) -> ModeAction {
if reached {
ModeAction::Track
} else if !requested {
ModeAction::Request
} else if timed_out {
ModeAction::Track
} else {
ModeAction::Wait
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub(super) struct Geometry {
pub(super) width: u32,
pub(super) height: u32,
pub(super) x: i32,
pub(super) y: i32,
pub(super) prev_x: i32,
pub(super) prev_y: i32,
pub(super) maximized: bool,
pub(super) fullscreen: bool,
pub(super) visible: bool,
pub(super) decorated: bool,
}
impl Geometry {
pub(super) fn set_normal_size(&mut self, size: Size) {
(self.width, self.height) = (size.0, size.1);
self.maximized = false;
self.fullscreen = false;
}
}
impl Default for Geometry {
fn default() -> Self {
Self {
width: 0,
height: 0,
x: 0,
y: 0,
prev_x: 0,
prev_y: 0,
maximized: false,
fullscreen: false,
visible: true,
decorated: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constrained_size_does_not_skip_the_first_mode_request() {
assert_eq!(mode_action(false, false, true), ModeAction::Request);
assert_eq!(mode_action(false, false, false), ModeAction::Request);
assert_eq!(mode_action(false, true, false), ModeAction::Wait);
assert_eq!(mode_action(false, true, true), ModeAction::Track);
assert_eq!(mode_action(true, false, false), ModeAction::Track);
}
#[test]
fn physical_sizes_use_the_actual_output_scale() {
for scale in [1.0, 2.0] {
assert_eq!(restore_size(Size(2468, 1356), scale, Limits::default()), Size(2468, 1356));
}
// Saved at scale 1, mapped on scale 2: keep physical pixels, not twice that size.
assert_eq!(restore_size(Size(1234, 678), 2.0, Limits::default()), Size(1234, 678));
}
#[test]
fn gtk_limits_and_integer_logical_dimensions_are_applied_once() {
let limits = Limits { min: (800.0, 500.0), max: (Some(1600.0), Some(1000.0)) };
assert_eq!(restore_size(Size(1201, 801), 1.0, limits), Size(1201, 801));
assert_eq!(restore_size(Size(1201, 801), 2.0, limits), Size(1600, 1000));
assert_eq!(restore_size(Size(2001, 1401), 2.0, limits), Size(2002, 1402));
assert_eq!(restore_size(Size(9000, 6000), 2.0, limits), Size(3200, 2000));
}
#[test]
fn damaged_sizes_are_bounded_in_the_actual_outputs_units() {
let limits = Limits { min: (800.0, 500.0), ..Default::default() };
for scale in [1.0, 2.0] {
let bounded = limits.in_work_area(Some(Size(3840, 2160)), scale);
assert_eq!(restore_size(Size(45068, 30000), scale, bounded), Size(3840, 2160));
assert_eq!(restore_size(Size(2468, 1356), scale, bounded), Size(2468, 1356));
}
}
#[test]
fn restoration_and_non_normal_events_preserve_the_normal_size() {
let normal = Size(1234, 678);
for current in [Size(2468, 1356), Size(2648, 1602), Size(5476, 3204)] {
assert_eq!(remember_size(normal, current, true, Mode::default()), normal);
for mode in [
Mode { maximized: true, ..Default::default() },
Mode { fullscreen: true, ..Default::default() },
Mode { minimized: true, ..Default::default() },
] {
assert_eq!(remember_size(normal, current, false, mode), normal);
}
}
assert_eq!(remember_size(normal, Size(1201, 801), false, Mode::default()), Size(1201, 801));
assert_eq!(remember_size(normal, Size(0, 678), false, Mode::default()), normal);
}
#[test]
fn a_mixed_dpi_maximized_launch_does_not_compound() {
let mut saved = Size(2468, 1356);
for _ in 0..3 {
saved = remember_size(saved, Size(saved.0 * 2, saved.1 * 2), true, Mode::default());
let restored = restore_size(saved, 2.0, Limits::default());
saved = remember_size(saved, restored, false, Mode::default());
saved = remember_size(saved, Size(3840, 2160), false, Mode { maximized: true, ..Default::default() });
assert_eq!(saved, Size(2468, 1356));
}
}
#[test]
fn moved_does_not_consume_the_unmaximize_resize() {
for mode in [
Mode { maximized: true, ..Default::default() },
Mode { fullscreen: true, ..Default::default() },
Mode { minimized: true, ..Default::default() },
] {
let mut history = ModeHistory(mode);
assert_eq!(history.observe(Mode::default(), false), NormalTransition::WaitForResize);
assert_eq!(history.0, mode);
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Restore);
assert_eq!(history.observe(Mode::default(), true), NormalTransition::Stable);
}
}
#[test]
fn an_explicit_normal_frame_overrides_old_window_modes() {
let mut saved = Geometry { maximized: true, fullscreen: true, x: 123, y: 456, ..Default::default() };
saved.set_normal_size(Size(1100, 700));
assert_eq!((saved.width, saved.height), (1100, 700));
assert!(!saved.maximized && !saved.fullscreen);
assert_eq!((saved.x, saved.y), (123, 456));
}
#[test]
fn state_files_remain_compatible_and_keep_closed_windows() {
let saved: std::collections::HashMap<String, Geometry> = serde_json::from_str(
r#"{"main":{"width":1234,"height":678,"maximized":true},"settings":{"width":1001,"height":701}}"#,
)
.unwrap();
let copy: std::collections::HashMap<String, Geometry> =
serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
assert_eq!(copy["settings"].height, 701);
assert!(copy["main"].maximized);
assert_eq!(copy["main"].width, 1234);
}
}
+4 -1
View File
@@ -60,7 +60,10 @@ fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> {
if !maximized {
to.unmaximize().map_err(err)?;
to.set_position(from.outer_position().map_err(err)?).map_err(err)?;
to.set_size(from.inner_size().map_err(err)?).map_err(err)?;
let size = from.inner_size().map_err(err)?;
#[cfg(target_os = "linux")]
crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size);
to.set_size(size).map_err(err)?;
} else {
to.maximize().map_err(err)?;
}
+9 -1
View File
@@ -298,6 +298,8 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
} else if let Some(a) = &appearance {
config.background_color = Some(frame_color(a, false));
}
#[cfg(target_os = "linux")]
let show_after_build = crate::linux_window_state::prepare(app, &mut config);
let window = WebviewWindowBuilder::from_config(app, &config)
.and_then(|b| dev_font_preferences(b).build())
.map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?;
@@ -310,6 +312,10 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult<
{
let _ = window.set_background_color(Some(frame_color(&a, true)));
}
#[cfg(target_os = "linux")]
if show_after_build {
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
}
if visible {
window.show().map_err(|e| IdeError::internal(format!("cannot show window: {e}")))?;
if focused {
@@ -765,6 +771,8 @@ pub fn open_settings_window(
config.background_color = Some(color);
}
windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() });
#[cfg(target_os = "linux")]
crate::linux_window_state::prepare(app, &mut config);
let builder = WebviewWindowBuilder::from_config(app, &config);
// AppKit child windows can disappear when dragged to another display's Space.
// The settings label already tracks ownership and parent-close cleanup.
@@ -832,7 +840,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri:
/// may still be on another display with a different scale, so its size is converted with its own
/// scale and its position is given in physical pixels.
#[cfg(not(target_os = "macos"))]
fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> {
let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else {
return Ok(());
};
+8
View File
@@ -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,
+22 -22
View File
@@ -3,7 +3,6 @@
min-height: 0;
display: flex;
flex-direction: column;
container-type: inline-size;
}
.content {
flex: 1;
@@ -137,7 +136,7 @@
gap: 5px;
}
/* A conversation docked in the tool window, above the task list. */
/* A docked conversation owns the tool window; history opens on demand. */
.docked {
flex: 1 1 0;
min-height: 0;
@@ -168,30 +167,31 @@
flex-direction: column;
}
.tasks {
flex: 2 1 0;
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
}
.history {
display: flex;
flex-direction: column;
width: min(400px, calc(100vw - 24px));
height: min(420px, 65vh);
overflow: hidden;
}
.historyTitle {
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
font-weight: 600;
}
/* Keep the conversation usable in a short bottom tool window; the tree scrolls. */
.docked + .tasks {
flex: 0 0 20%;
max-height: 160px;
}
/* A wide bottom window has room beside the conversation, preserving message height. */
@container (min-width: 600px) {
.content {
flex-direction: row;
}
.docked + .tasks {
flex: 0 0 220px;
max-width: 30%;
max-height: none;
border-top: 0;
border-left: 1px solid var(--border);
}
.location {
display: flex;
align-items: center;
gap: 2px;
padding: 1px;
border: 1px solid var(--border);
border-radius: var(--radius-small);
}
+48 -29
View File
@@ -6,9 +6,9 @@ import { registerActions } from "@/platform/actions";
import { EDITOR_TAB_MIME, type EditorInput } from "@/workbench/contributions";
import { confirmChecked, showError } from "@/workbench/messages";
import { ContextMenuHost, dragSession, hideContextMenu } from "@/ui";
import { AgentsView } from "./AgentsView";
import { AgentsHeaderActions, AgentsLocationActions, AgentsView } from "./AgentsView";
import { SESSION_KIND } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, undockTask, useAgents } from "./store";
import { archiveTask, unarchiveTask, deleteTask, dockTask, openTask, setSessionLocation, useAgents } from "./store";
import { taskActions, taskMenu } from "./taskActions";
import styles from "./Agents.module.css";
@@ -39,7 +39,7 @@ vi.mock("./store", async (original) => ({
unarchiveTask: vi.fn(async () => {}),
deleteTask: vi.fn(async () => {}),
dockTask: vi.fn(),
undockTask: vi.fn(),
setSessionLocation: vi.fn(),
}));
vi.mock("./SessionEditor", () => ({ SessionEditor: () => <div data-testid="docked-session" /> }));
@@ -103,6 +103,8 @@ beforeEach(() => {
error: null,
tasks: new Map([task("one"), task("two"), task("three"), task("four")].map((t) => [t.id, t])),
docked: null,
homeOpen: false,
sessionLocation: "editor",
});
dispose = registerActions(taskActions);
});
@@ -115,6 +117,8 @@ afterEach(() => {
function setup() {
render(
<>
<AgentsLocationActions />
<AgentsHeaderActions />
<AgentsView />
<ContextMenuHost />
</>,
@@ -158,24 +162,44 @@ describe("Agents selection", () => {
});
describe("Agents docked view", () => {
it("shows the docked conversation above the list and switches it by selecting a task", () => {
it("opens session history on demand and closes it after switching tasks", () => {
useAgents.setState({ docked: "one" });
setup();
expect(screen.getByTestId("docked-session")).toBeTruthy();
expect(screen.getByRole("button", { name: "Open in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
select("two");
expect(actions.executeAction).toHaveBeenCalledWith("agents.dockTask", "two");
expect(dockTask).toHaveBeenCalledWith("two");
expect(screen.getByRole("button", { name: "Chat in Editor" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("two"));
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
expect(actions.executeAction).toHaveBeenCalledWith("agents.openTask", "two");
expect(openTask).toHaveBeenCalledWith("two");
});
it("hides the docked task from the list below", () => {
it("lets keyboard users browse history before confirming a session", () => {
useAgents.setState({ docked: "one" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = screen.getByRole("tree", { name: "Agent tasks" });
fireEvent.keyDown(tree, { key: "ArrowDown" });
expect(screen.getByRole("dialog", { name: "Agent sessions" })).toBeTruthy();
expect(openTask).not.toHaveBeenCalled();
fireEvent.keyDown(tree, { key: "Enter" });
expect(openTask).toHaveBeenCalledWith("two");
expect(screen.queryByRole("dialog", { name: "Agent sessions" })).toBeNull();
});
it("includes the current session in history and keeps the conversation mounted", () => {
useAgents.setState({ docked: "one" });
setup();
const session = screen.getByTestId("docked-session");
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
const tree = within(screen.getByRole("tree", { name: "Agent tasks" }));
expect(tree.queryByText("one")).toBeNull();
expect(tree.getByText("one")).toBeTruthy();
expect(tree.getByText("two")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("tree", { name: "Agent tasks" })).toBeNull();
expect(screen.getByTestId("docked-session")).toBe(session);
});
it("gives the whole tool window to the conversation when the docked task is the only one", () => {
@@ -185,11 +209,15 @@ describe("Agents docked view", () => {
expect(screen.getByTestId("docked-session")).toBeTruthy();
});
it("undocks from the docked conversation's bar", () => {
useAgents.setState({ docked: "one" });
it("switches placement from the global Agents header", () => {
useAgents.setState({ docked: "one", sessionLocation: "toolWindow" });
setup();
fireEvent.click(screen.getByRole("button", { name: "Open in Editor" }));
expect(undockTask).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "Open in Editor" })).toBeNull();
expect(screen.getByRole("button", { name: "Chat in Tool Window" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Chat in Editor" }));
expect(setSessionLocation).toHaveBeenCalledWith("editor");
fireEvent.click(screen.getByRole("button", { name: "Chat in Tool Window" }));
expect(setSessionLocation).toHaveBeenCalledWith("toolWindow");
});
it("opens tabs as usual without a docked conversation", () => {
@@ -282,21 +310,12 @@ describe("Agents bulk actions", () => {
});
describe("Agents task menu", () => {
it("offers to dock a task into the tool window", async () => {
setup();
context("one");
fireEvent.click(screen.getByRole("menuitem", { name: "Open in Tool Window" }));
await waitFor(() => expect(dockTask).toHaveBeenCalledWith("one"));
});
it("offers to move the docked task back to an editor", async () => {
useAgents.setState({ docked: "one" });
setup();
// The docked task is not listed; its menu lives on the docked conversation's header.
it.each([null, "one"])("keeps placement out of session menus (docked: %s)", (docked) => {
useAgents.setState({ docked });
const menu = taskMenu([useAgents.getState().tasks.get("one")!]);
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.undockTask", label: "Open in Editor" }));
await taskActions.find((action) => action.id === "agents.undockTask")!.run({ ctx: {}, arg: "one" });
await waitFor(() => expect(undockTask).toHaveBeenCalledTimes(1));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.dockTask" }));
expect(menu).not.toContainEqual(expect.objectContaining({ action: "agents.undockTask" }));
expect(menu).toContainEqual(expect.objectContaining({ action: "agents.openTask" }));
});
});
+153 -106
View File
@@ -7,6 +7,7 @@ import {
EmptyState,
Icon,
IconButton,
Popup,
Spinner,
T,
Tree,
@@ -18,16 +19,29 @@ import { SESSION_KIND, TASK_DRAG, age, branchLabel, groupTasks, sessionKey, type
import { openHome, useAgents } from "./store";
import { taskMenu } from "./taskActions";
import { SessionEditor } from "./SessionEditor";
import { HomeEditor } from "./HomeEditor";
import styles from "./Agents.module.css";
import { basename, useProject } from "@/platform/project";
import { useTx } from "@/platform/i18n";
import { executeAction } from "@/platform/actions";
export function AgentsHeaderActions() {
return <IconButton icon="plus" action="agents.newTask" size="small" />;
}
export function AgentsLocationActions() {
const location = useAgents((s) => s.sessionLocation);
const tx = useTx();
return (
<>
<IconButton icon="plus" action="agents.newTask" size="small" />
</>
<div role="group" aria-label={tx("Conversation location")} className={styles.location}>
<IconButton
icon="open-in-tool-window"
action="agents.useToolWindow"
pressed={location === "toolWindow"}
size="small"
/>
<IconButton icon="editor-only" action="agents.useEditor" pressed={location === "editor"} size="small" />
</div>
);
}
@@ -77,6 +91,10 @@ export function AgentsView() {
const unread = useAgents((s) => s.unread);
const providers = useAgents((s) => s.providers);
const docked = useAgents((s) => s.docked);
const homeOpen = useAgents((s) => s.homeOpen);
const inline = docked !== null || homeOpen;
const historyRef = useRef<HTMLButtonElement>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
const [selected, setSelected] = useState<Set<string>>(() => {
const task = sessionTask(maybeEditorService()?.activeInput?.());
@@ -95,11 +113,7 @@ export function AgentsView() {
}),
[],
);
// The docked conversation is already shown above; listing it again is noise.
const roots = useMemo<Node[]>(
() => groupTasks([...tasks.values()].filter((t) => t.id !== docked)).map((group) => ({ kind: "group", group })),
[tasks, docked],
);
const roots = useMemo<Node[]>(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]);
const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]);
// Dropping a session editor tab anywhere on the tool window docks it.
const viewRef = useRef<HTMLDivElement>(null);
@@ -126,123 +140,156 @@ export function AgentsView() {
);
if (error) return <EmptyState title="Cannot load agent tasks">{error}</EmptyState>;
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
// While a conversation is docked, clicking a task switches it instead of opening a tab.
const showTask = (task: string) => void executeAction(docked ? "agents.dockTask" : "agents.openTask", task);
// Opening any session follows the global placement preference.
const showTask = (task: string) => {
setHistoryOpen(false);
void executeAction("agents.openTask", task);
};
const dockedTitle = docked ? (tasks.get(docked)?.title ?? docked) : null;
const dragTask = (e: ReactPointerEvent<HTMLSpanElement>, task: string) =>
startPointerDrag(e, e.currentTarget, { type: TASK_DRAG, payload: task });
const taskList = (
<div className={styles.tasks}>
<Tree<Node>
aria-label="Agent tasks"
roots={roots}
getKey={nodeKey}
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
getChildren={(n) =>
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
}
isExpandable={(n) => n.kind === "group"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(nodeKey(node));
else s.add(nodeKey(node));
return s;
})
}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (!inline && sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
<span className={styles.groupLabel}>
<T>{n.group.label}</T>
</span>
<span className={styles.count}>{n.group.tasks.length}</span>
</span>
) : (
<span
className={styles.row}
title={n.task.error ?? n.task.title}
onPointerDown={(e) => dragTask(e, n.task.id)}
onClick={(e) => {
if (inline && !e.metaKey && !e.ctrlKey && !e.shiftKey) showTask(n.task.id);
}}
>
<TaskStatusIcon status={n.task.status} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{n.task.title}
</span>
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
}
empty={
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
);
return (
<div ref={viewRef} className={clsx(styles.view, tabOver && styles.dropTarget)}>
<div className={styles.content}>
{docked && (
{inline && (
<div className={styles.docked}>
<div className={styles.dockedBar}>
<IconButton
ref={historyRef}
icon="history"
title="Agent sessions"
size="small"
aria-haspopup="dialog"
aria-expanded={historyOpen}
onClick={() => {
setSelected(new Set(docked ? [docked] : []));
setHistoryOpen((open) => !open);
}}
/>
<span
className={styles.dockedTitle}
title={dockedTitle ?? undefined}
onPointerDown={(e) => dragTask(e, docked)}
onPointerDown={(e) => docked && dragTask(e, docked)}
>
{dockedTitle}
{homeOpen ? <T>New Agent Task</T> : dockedTitle}
</span>
<IconButton
icon="editor-only"
action="agents.undockTask"
actionArg={docked}
title="Open in Editor"
size="small"
/>
</div>
<div className={styles.dockedSession}>
<SessionEditor
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
{homeOpen ? (
<HomeEditor docked />
) : (
docked && (
<SessionEditor
docked
input={{
kind: SESSION_KIND,
key: sessionKey(docked),
data: { task: docked, title: tasks.get(docked)?.title },
}}
/>
)
)}
</div>
</div>
)}
{/* Docked with nothing else to list: give the whole window to the conversation. */}
{(roots.length > 0 || !docked) && (
<div className={styles.tasks}>
<Tree<Node>
aria-label="Agent tasks"
roots={roots}
getKey={nodeKey}
getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)}
getChildren={(n) =>
n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined
}
isExpandable={(n) => n.kind === "group"}
expanded={expanded}
onExpandedChange={(_next, node, open) =>
setCollapsed((c) => {
const s = new Set(c);
if (open) s.delete(nodeKey(node));
else s.add(nodeKey(node));
return s;
})
}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (sel.size === 1 && lead?.kind === "task" && sel.has(lead.task.id)) showTask(lead.task.id);
}}
onActivate={(n) => n.kind === "task" && showTask(n.task.id)}
onContextMenu={(n, sel, e) => {
if (n?.kind !== "task") return;
const selection = [...sel].flatMap((id) => {
const task = tasks.get(id);
return task ? [task] : [];
});
showContextMenu(e, taskMenu(selection));
}}
renderNode={(n) =>
n.kind === "group" ? (
<span className={styles.row}>
<span className={styles.groupLabel}>
<T>{n.group.label}</T>
</span>
<span className={styles.count}>{n.group.tasks.length}</span>
</span>
) : (
<span
className={styles.row}
title={n.task.error ?? n.task.title}
onPointerDown={(e) => dragTask(e, n.task.id)}
>
<TaskStatusIcon status={n.task.status} />
<span className={styles.title} data-unread={unread.has(n.task.id) || undefined}>
{n.task.title}
</span>
{unread.has(n.task.id) && (
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
)}
{project?.workspace && (
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
{basename(n.task.projectRoot ?? project.root)}
</span>
)}
<span className={styles.detail}>
{providerName(n.task.provider)}
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
</span>
<span className={styles.age}>{age(n.task.updatedMs ?? 0)}</span>
</span>
)
}
empty={
<div className={styles.empty}>
<T>No agent tasks yet</T>
<button type="button" className={styles.link} onClick={() => openHome()}>
<T>New Task…</T>
</button>
</div>
}
/>
</div>
{!inline && taskList}
{inline && (
<Popup
open={historyOpen}
anchor={historyRef}
onClose={() => setHistoryOpen(false)}
aria-label={tx("Agent sessions")}
className={styles.history}
>
<div className={styles.historyTitle}>
<T>Agent sessions</T>
</div>
{taskList}
</Popup>
)}
</div>
</div>
+78
View File
@@ -1,5 +1,7 @@
/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */
.composer {
container: agent-composer / inline-size;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
@@ -44,9 +46,85 @@
padding: 4px 6px 6px;
cursor: default;
}
.toolbar {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: center;
min-width: 0;
gap: 2px;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
}
.spacer {
flex: 1;
}
.configPicker {
display: flex;
min-width: 0;
}
.configPicker > button {
max-width: 100%;
}
.settingsMenu {
display: none;
}
.settingsPopup {
width: min(320px, calc(100vw - 24px));
padding: 12px;
}
.settingsTitle {
margin-bottom: 12px;
font-weight: 600;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(0, 80px) minmax(0, 1fr);
align-items: center;
gap: 12px;
margin-top: 8px;
}
.settingsLabel {
min-width: 0;
color: var(--fg-muted);
overflow-wrap: anywhere;
}
@container agent-composer (max-width: 600px) {
.bar {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.toolbar {
flex-wrap: nowrap;
}
.toolbar > button,
.toolbar > .configPicker {
flex: 0 1 auto;
min-width: 0;
max-width: calc((100% - 28px) / 2);
}
.configPicker > button {
width: 100%;
}
.inlineSetting {
display: none;
}
.settingsMenu {
display: flex;
flex: none;
margin-left: auto;
}
.actions {
width: 100%;
margin-left: 0;
}
}
.pickerValue {
display: inline-flex;
align-items: baseline;
+31 -24
View File
@@ -403,30 +403,37 @@ export function Composer(props: ComposerProps) {
}}
/>
<div className={styles.bar} onClick={(e) => e.stopPropagation()}>
{props.toolbar}
<span className={styles.spacer} />
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton icon="file-image" title="Attach Image…" size="small" onClick={() => imageInput.current?.click()} />
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
{props.actions}
<div className={styles.toolbar}>{props.toolbar}</div>
<div className={styles.actions}>
<IconButton
icon="file"
title="Reference a File (@)"
size="small"
onClick={() => {
const glue = text && !/\s$/.test(text) ? " " : "";
setText(`${text}${glue}@`, text.length + glue.length + 1);
}}
/>
<IconButton
icon="file-image"
title="Attach Image…"
size="small"
onClick={() => imageInput.current?.click()}
/>
<input
ref={imageInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
hidden
onChange={(e) => {
readImageFiles(e.target.files ?? []);
e.target.value = "";
}}
/>
<span className={styles.spacer} />
{props.actions}
</div>
</div>
</div>
);
+26 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentConfigOption } from "@/bindings";
import { ConfigPickers, configPickerOptions } from "./ConfigPickers";
@@ -85,3 +85,28 @@ it("sends the complete nested model id through ConfigPickers", () => {
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
});
it("edits secondary settings from the compact popup and restores focus when dismissed", () => {
const approval: AgentConfigOption = {
id: "approvals",
name: "Approvals",
category: "approval",
description: null,
current: "ask",
via: "option",
values: [value("ask", "Ask first"), value("bypass", "Bypass approvals")],
};
const onChange = vi.fn();
render(<ConfigPickers config={[approval]} values={{ approvals: "bypass" }} onChange={onChange} />);
const trigger = screen.getByRole("button", { name: "Agent settings" });
trigger.focus();
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "Agent settings" });
fireEvent.click(within(dialog).getByRole("button", { name: "Approvals" }));
fireEvent.click(screen.getByRole("option", { name: "Ask first" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("approvals", "ask");
expect(dialog).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "Agent settings" })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
+94 -39
View File
@@ -2,9 +2,11 @@
* Model, mode, approvals and thinking-effort dropdowns for a composer's bottom row.
* Modes and approvals use icons; levels are plain text.
*/
import { useRef, useState } from "react";
import clsx from "clsx";
import type { AgentConfigOption } from "@/bindings";
import { useTx } from "@/platform/i18n";
import { Select } from "@/ui";
import { IconButton, Popup, Select, T } from "@/ui";
import type { SelectOption } from "@/ui/Select";
import { mainOptions, modeIcon } from "./composerModel";
import styles from "./Composer.module.css";
@@ -43,47 +45,100 @@ export function ConfigPickers({
onChange: (option: string, value: string) => void;
}) {
const tx = useTx();
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
const options = mainOptions(config);
const secondary = options.filter((o) => o.category !== "model");
const approval = secondary.find((o) => o.category === "approval" || o.category === "mode");
const summary = secondary
.map((o) => {
const value = values?.[o.id] ?? o.current;
return `${tx(o.name)}: ${o.values.find((v) => v.value === value)?.name ?? tx("Default")}`;
})
.join(" · ");
return (
<>
{mainOptions(config).map((o) => {
const isMode = o.category === "mode";
const value = values?.[o.id] ?? o.current ?? null;
const options = configPickerOptions(o);
return (
<Select
key={o.id}
variant="ghost"
aria-label={o.name}
value={value}
placeholder={tx(o.name)}
options={options.map((opt) => ({
...opt,
icon:
o.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: isMode
? modeIcon(opt.value)
: undefined,
}))}
searchable={o.category === "model"}
onChange={(v) => onChange(o.id, v)}
// Effort and other levels say what they are: "Effort Medium".
renderValue={
o.category === "thought_level"
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{o.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
{options.map((o) => (
<span key={o.id} className={clsx(styles.configPicker, o.category !== "model" && styles.inlineSetting)}>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} />
</span>
))}
{secondary.length > 0 && (
<div className={styles.settingsMenu}>
<IconButton
ref={anchor}
icon={approval ? modeIcon(values?.[approval.id] ?? approval.current ?? "") : "settings"}
title={`${tx("Agent settings")}: ${summary}`}
aria-label={tx("Agent settings")}
aria-haspopup="dialog"
aria-expanded={open}
size="small"
onClick={() => setOpen((v) => !v)}
/>
);
})}
<Popup
open={open}
anchor={anchor}
onClose={() => setOpen(false)}
aria-label={tx("Agent settings")}
className={styles.settingsPopup}
>
<div className={styles.settingsTitle}>
<T>Agent settings</T>
</div>
{secondary.map((o) => (
<div key={o.id} className={styles.settingsRow}>
<span className={styles.settingsLabel}>{tx(o.name)}</span>
<ConfigPicker option={o} value={values?.[o.id] ?? o.current ?? null} onChange={onChange} framed />
</div>
))}
</Popup>
</div>
)}
</>
);
}
function ConfigPicker({
option,
value,
onChange,
framed = false,
}: {
option: AgentConfigOption;
value: string | null;
onChange: (option: string, value: string) => void;
framed?: boolean;
}) {
const tx = useTx();
return (
<Select
variant={framed ? "framed" : "ghost"}
aria-label={option.name}
value={value}
placeholder={tx(option.name)}
options={configPickerOptions(option).map((opt) => ({
...opt,
icon:
option.category === "approval"
? opt.value === "bypass"
? "unlock"
: "lock"
: option.category === "mode"
? modeIcon(opt.value)
: undefined,
}))}
searchable={option.category === "model"}
onChange={(v) => onChange(option.id, v)}
renderValue={
option.category === "thought_level" && !framed
? (current) => (
<span className={styles.pickerValue}>
<span className={styles.pickerName}>{option.name}</span>
{current?.label ?? tx("Default")}
</span>
)
: undefined
}
/>
);
}
+44
View File
@@ -60,3 +60,47 @@
.warning {
color: var(--danger);
}
.docked {
justify-content: flex-end;
padding: 16px 12px 12px;
}
.docked .inner {
gap: 8px;
}
.docked .where {
order: -1;
flex-wrap: nowrap;
min-width: 0;
gap: 6px;
}
.docked .project,
.docked .projectPicker {
flex: 0 1 auto;
min-width: 0;
max-width: 30%;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docked .branch,
.docked .basePicker {
order: 1;
flex: 1 1 0;
min-width: 0;
padding: 0;
}
.docked .isolation {
order: 2;
flex: 0 1 auto;
min-width: 0;
max-width: 45%;
margin-left: auto;
}
.branchName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+31 -17
View File
@@ -1,4 +1,5 @@
/** New Task page: one prompt box with the agent and its settings; where it works under it. */
import clsx from "clsx";
import { useEffect, useState } from "react";
import type { AgentProbe, Branch } from "@/bindings";
import { executeAction } from "@/platform/actions";
@@ -7,7 +8,7 @@ import { useTx, useTxTemplate } from "@/platform/i18n";
import { basename, useProject } from "@/platform/project";
import { Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { maybeEditorService, type EditorInput } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft } from "./composerModel";
@@ -17,7 +18,7 @@ import { ADD_AGENT, providerOptions as agentOptions } from "./providerOptions";
import { HOME, NO_COMMANDS, refreshProviders, startTask, updateDraft, useAgentCommands, useAgents } from "./store";
import styles from "./Home.module.css";
export function HomeEditor() {
export function HomeEditor({ docked = false }: { docked?: boolean; input?: EditorInput }) {
const tx = useTx();
const txTemplate = useTxTemplate();
const draft = useAgents((s) => s.draft);
@@ -86,15 +87,19 @@ export function HomeEditor() {
const setOption = (id: string, value: string) => updateDraft({ config: { ...draft.config, [id]: value } });
return (
<div className={styles.home}>
<div className={clsx(styles.home, docked && styles.docked)}>
<div className={styles.inner}>
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
{!docked && (
<h1 className={styles.heading}>
<T>What should the agent do?</T>
</h1>
)}
<Composer
id={HOME}
autoFocus
placeholder="Describe a task for the agent. @ references a file, / runs a command"
placeholder={
docked ? "Describe a task…" : "Describe a task for the agent. @ references a file, / runs a command"
}
root={root}
projectRoot={root}
commands={agentCommands}
@@ -131,6 +136,7 @@ export function HomeEditor() {
<Select
variant="ghost"
aria-label={tx("Project")}
className={styles.projectPicker}
value={root}
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
onChange={(path) => {
@@ -139,12 +145,15 @@ export function HomeEditor() {
}}
/>
) : (
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
<span className={styles.project} title={root}>
{members.length ? basename(root) : project?.name}
</span>
)}
{isGit && (
<Select
variant="ghost"
aria-label="Isolation"
className={styles.isolation}
value={isolation}
options={[
{ value: "worktree", label: tx("New worktree"), icon: "branch", detail: tx("away from your changes") },
@@ -160,23 +169,28 @@ export function HomeEditor() {
)}
{isolation === "local" && current && (
<>
<span className={styles.note}>
<T>on</T>
</span>
<span className={styles.branch}>
{!docked && (
<span className={styles.note}>
<T>on</T>
</span>
)}
<span className={styles.branch} title={current}>
<Icon name="branch" size={14} />
{current}
<span className={styles.branchName}>{current}</span>
</span>
</>
)}
{isolation === "worktree" && branches.length > 0 && (
<>
<span className={styles.note}>
<T>from</T>
</span>
{!docked && (
<span className={styles.note}>
<T>from</T>
</span>
)}
<Select
variant="ghost"
aria-label="Base branch"
className={styles.basePicker}
value={base}
options={branches.map((b) => ({ value: b.name, label: b.name }))}
onChange={(b) => updateDraft({ base: b === current ? null : b })}
@@ -184,7 +198,7 @@ export function HomeEditor() {
</>
)}
</div>
{probe && (
{probe && (!docked || probe.error) && (
<p className={probe.error ? styles.warning : styles.note}>
{probe.error
? txTemplate(`${provider?.name ?? probe.provider} didn't start: ${probe.error}`)
+25 -1
View File
@@ -16,6 +16,20 @@
border-right: 1px solid var(--border);
}
.conversation[hidden] {
display: none;
}
.docked {
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
}
.docked .conversation {
border-right: 0;
}
.docked .changes {
width: auto;
}
/* The conversation hugs its content; spare space stays below the composer
instead of inside the chat. */
.streamArea,
@@ -568,6 +582,7 @@
width: 280px;
}
.changesHeader {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
@@ -577,11 +592,17 @@
border-bottom: 1px solid var(--border);
}
.changesTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--fg-strong);
}
.changesSummary {
flex: none;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 10px;
color: var(--fg-subtle);
white-space: pre;
@@ -608,8 +629,11 @@
gap: 4px;
}
.changeName {
flex: none;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.changeDir {
flex: 1 1 0;
+30 -14
View File
@@ -68,15 +68,13 @@ import { setFocusedSession, focusedSession, type FocusedSession } from "./sessio
import { taskMenu } from "./taskActions";
import styles from "./Session.module.css";
export function SessionEditor({ input }: { input: EditorInput }) {
export function SessionEditor({ input, docked = false }: { input: EditorInput; docked?: boolean }) {
const id = (input.data as { task: string }).task;
return <TaskSession key={id} id={id} />;
return <TaskSession key={id} id={id} docked={docked} />;
}
function TaskSession({ id }: { id: string }) {
function TaskSession({ id, docked }: { id: string; docked: boolean }) {
const task = useAgents((s) => s.tasks.get(id));
// The tool window bar above already shows the title of a docked conversation.
const docked = useAgents((s) => s.docked === id);
const entries = useAgents((s) => s.entries.get(id));
const changes = useAgents((s) => s.changes.get(id));
const project = useProject((s) => s.project);
@@ -84,7 +82,7 @@ function TaskSession({ id }: { id: string }) {
task?.isolation === "local" ? task.status : null,
project?.workspace ? (task?.projectRoot ?? project.root) : null,
);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null);
const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null, docked);
// The subagent conversation shown, with the subagents that lead to it (empty: the task's own).
const [path, setPath] = useState<string[]>([]);
const view = path.at(-1) ?? null;
@@ -96,8 +94,10 @@ function TaskSession({ id }: { id: string }) {
useEffect(() => {
void loadEntries(id).catch(() => undefined);
void refreshChanges(id);
return markVisible(id);
}, [id]);
useEffect(() => {
if (!docked || !changesOpen) return markVisible(id);
}, [id, docked, changesOpen]);
useEffect(() => {
if (view) void loadEntries(id, view).catch(() => undefined);
}, [id, view]);
@@ -127,7 +127,7 @@ function TaskSession({ id }: { id: string }) {
}
}}
>
<div className={styles.conversation}>
<div className={styles.conversation} hidden={docked && changesOpen}>
<Header
task={task}
entries={entries ?? null}
@@ -158,7 +158,13 @@ function TaskSession({ id }: { id: string }) {
)}
</div>
{changesOpen && (
<ChangesPane task={task} changes={changes ?? null} head={head} onHide={() => setChangesOpen(false)} />
<ChangesPane
task={task}
changes={changes ?? null}
head={head}
docked={docked}
onHide={() => setChangesOpen(false)}
/>
)}
</div>
);
@@ -270,6 +276,7 @@ function Header({
className={styles.changesToggle}
aria-pressed={changesOpen}
disabled={count === 0 && !changesOpen}
aria-label={changesOpen ? tx("Hide changes") : tx("Show changes")}
title={changesOpen ? tx("Hide changes") : tx("Show changes")}
onClick={onToggleChanges}
>
@@ -1292,16 +1299,22 @@ function loadChangesOpen(): Record<string, boolean> {
/**
* Whether the Changes pane is shown for `task`: the user's choice once made, else
* only while the task has changes (so it opens when the agent first edits a file).
* only while the task has changes. Docked sessions stay in the conversation until
* the user explicitly opens changes; navigation there does not change editor preferences.
*/
function useChangesOpen(task: string, changes: AgentChange[] | null): [boolean, (open: boolean) => void] {
function useChangesOpen(
task: string,
changes: AgentChange[] | null,
docked: boolean,
): [boolean, (open: boolean) => void] {
const [state, setState] = useState<{ task: string; open: boolean | undefined }>(() => ({
task,
open: loadChangesOpen()[task],
open: docked ? false : loadChangesOpen()[task],
}));
const chosen = state.task === task ? state.open : loadChangesOpen()[task];
const chosen = state.task === task ? state.open : docked ? false : loadChangesOpen()[task];
const set = (open: boolean) => {
setState({ task, open });
if (docked) return;
try {
globalThis.localStorage?.setItem(CHANGES_OPEN_KEY, JSON.stringify({ ...loadChangesOpen(), [task]: open }));
} catch {
@@ -1315,11 +1328,13 @@ function ChangesPane({
task,
changes,
head,
docked,
onHide,
}: {
task: AgentTask;
changes: AgentChange[] | null;
head: string | null;
docked: boolean;
onHide: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set());
@@ -1328,6 +1343,7 @@ function ChangesPane({
return (
<aside className={styles.changes} aria-label={tx("Changes")}>
<div className={styles.changesHeader}>
{docked && <IconButton icon="arrow-left" title="Back to conversation" size="small" onClick={onHide} />}
<span className={styles.changesTitle}>
<T>Changes</T>
</span>
@@ -1338,7 +1354,7 @@ function ChangesPane({
)}
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
<IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
</div>
{(head ?? task.base) && (
<div className={styles.changesSummary}>
+3 -23
View File
@@ -54,6 +54,7 @@ export function activate(): () => void {
anchor: "right",
order: 0.5,
component: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsView)),
headerContent: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsLocationActions)),
headerActions: lazyComponent(() => import("./AgentsView").then((m) => m.AgentsHeaderActions)),
useBadge: () => {
const n = useAgents(
@@ -80,11 +81,10 @@ export function activate(): () => void {
icon: () => "toolwindow-agents",
restorable: true,
}),
// A task dragged from the tool window onto the editor opens as a tab (or undocks).
// An explicit editor drop sets the placement for subsequent sessions too.
registerEditorDropHandler({
accepts: (type) => type === TASK_DRAG,
drop: ({ payload }) =>
void executeAction(useAgents.getState().docked === payload ? "agents.undockTask" : "agents.openTask", payload),
drop: ({ payload }) => void executeAction("agents.openTaskInEditor", payload),
}),
registerStatusItem({ id: "agents.status", align: "right", priority: 3, title: "Agents", component: AgentsStatus }),
registerMenuGroup({
@@ -96,26 +96,6 @@ export function activate(): () => void {
? [{ kind: "action", action: "agents.sendToAgent", arg: menu === "projectView" ? paths : undefined }]
: [],
}),
registerMenuGroup({
// Session tabs have no path; "Open in Tool Window" docks them beside the task list.
id: "agents.tab",
menus: ["editorTab"],
order: 50,
items: (_menu, { paths, input }) => {
const task = input?.kind === SESSION_KIND ? (input.data as { task?: string } | undefined)?.task : undefined;
return paths.length || !task
? []
: [
{
kind: "action",
action: "agents.dockTask",
label: "Open in Tool Window",
icon: "open-in-tool-window",
arg: task,
},
];
},
}),
registerActions([
...taskActions,
{
+50 -6
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { registerActions } from "@/platform/actions";
import type { AgentEntry, AgentTask } from "@/bindings";
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
import { AgentsView } from "./AgentsView";
import { SessionEditor } from "./SessionEditor";
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
@@ -9,7 +9,7 @@ import { disconnect, useAgents } from "./store";
import { taskActions } from "./taskActions";
import styles from "./Session.module.css";
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>() }));
const backend = vi.hoisted(() => ({ entries: new Map<string, AgentEntry[]>(), changes: [] as AgentChange[] }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: {
@@ -17,7 +17,7 @@ vi.mock("@/platform/ipc", async (original) => ({
status: "ok",
data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [],
}),
agentChanges: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: backend.changes }),
vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }),
},
}));
@@ -76,6 +76,8 @@ afterEach(() => {
beforeEach(() => {
disconnect();
localStorage.clear();
backend.changes = [];
backend.entries = new Map([
["A", [{ type: "agent", text: "Answer A" }, subagent]],
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
@@ -83,6 +85,8 @@ beforeEach(() => {
]);
useAgents.setState({
loaded: true,
sessionLocation: "editor",
homeOpen: false,
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
entries: new Map(backend.entries),
});
@@ -92,12 +96,13 @@ describe("session state across tasks and hosts", () => {
it("returns to the task conversation when the docked task changes after opening a subagent", async () => {
const dispose = registerActions(taskActions);
try {
useAgents.setState({ docked: "A" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
fireEvent.click(screen.getByRole("button", { name: /Explore task A/ }));
expect(await screen.findByText("Child answer")).toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Message" })).not.toBeInTheDocument();
fireEvent.mouseDown(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"), { button: 0 });
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
fireEvent.click(within(screen.getByRole("tree", { name: "Agent tasks" })).getByText("B"));
expect(await screen.findByText("Answer B")).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Message" })).toBeInTheDocument();
expect(screen.queryByText("Child answer")).not.toBeInTheDocument();
@@ -110,6 +115,45 @@ describe("session state across tasks and hosts", () => {
}
});
it("keeps docked changes opt-in, preserves the draft, and returns to the same conversation", async () => {
backend.changes = [
{
path: "/project/very-long-file-name.ts",
relPath: "very-long-file-name.ts",
kind: "modified",
added: 8,
removed: 2,
},
];
localStorage.setItem("openidea.agents.changesOpen.v1", JSON.stringify({ A: true }));
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
render(<AgentsView />);
await screen.findByText("Answer A");
await waitFor(() => expect(screen.getByRole("button", { name: "Show changes" })).toBeEnabled());
expect(screen.queryByRole("complementary", { name: "Changes" })).toBeNull();
const markVisible = vi.spyOn(await import("./store"), "markVisible");
vi.spyOn(document, "hasFocus").mockReturnValue(true);
const message = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(message, { target: { value: "Draft to keep" } });
const conversation = stream();
fireEvent.click(screen.getByRole("button", { name: "Show changes" }));
expect(screen.getByRole("complementary", { name: "Changes" })).toBeVisible();
expect(screen.getByText("Answer A")).not.toBeVisible();
expect(screen.queryByRole("textbox", { name: "Message" })).toBeNull();
act(() => useAgents.setState({ unread: new Set(["A"]) }));
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.has("A")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Back to conversation" }));
expect(screen.getByText("Answer A")).toBeVisible();
expect(stream()).toBe(conversation);
expect(markVisible).toHaveBeenCalledWith("A");
expect(useAgents.getState().unread.has("A")).toBe(false);
expect(screen.getByRole("textbox", { name: "Message" })).toBe(message);
expect(message).toHaveValue("Draft to keep");
expect(useAgents.getState().composers.get("A")?.text).toBe("Draft to keep");
expect(JSON.parse(localStorage.getItem("openidea.agents.changesOpen.v1")!).A).toBe(true);
});
it.each([false, true])("retains scrolling and follow-output (%s) when moving in both directions", async (stick) => {
// Give jsdom measurable scroll geometry; each host has a different viewport.
let viewport = 400;
@@ -133,7 +177,7 @@ describe("session state across tasks and hosts", () => {
expect(useAgents.getState().scrollPositions.get("A")).toEqual({ top: stick ? 1600 : 230, stick });
view.unmount();
viewport = 150;
useAgents.setState({ docked: "A" });
useAgents.setState({ docked: "A", sessionLocation: "toolWindow" });
const dock = render(<AgentsView />);
await screen.findByText("Answer A");
expect(stream().scrollTop).toBe(stick ? height - viewport : 230);
+77 -8
View File
@@ -44,6 +44,8 @@ export interface Draft {
/** Composer key of the New Task page; sessions use their task id. */
export const HOME = "home";
export type SessionLocation = "editor" | "toolWindow";
interface AgentsState {
loaded: boolean;
/** Why the tasks could not be loaded. */
@@ -67,11 +69,15 @@ interface AgentsState {
focus: { key: string; seq: number } | null;
/** Task shown inside the Agents tool window instead of an editor tab. */
docked: string | null;
sessionLocation: SessionLocation;
lastTask: string | null;
homeOpen: boolean;
}
const DRAFT_KEY = "openidea.agents.draft.v2";
const HISTORY_KEY = "openidea.agents.history.v1";
const HISTORY_SIZE = 50;
const SESSION_LOCATION_KEY = "openidea.agents.sessionLocation.v1";
function load<T>(key: string, fallback: T): T {
try {
@@ -106,6 +112,9 @@ export const useAgents = create<AgentsState>(() => ({
history: load<string[]>(HISTORY_KEY, []),
focus: null,
docked: null,
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
}));
@@ -210,6 +219,8 @@ export function disconnect() {
entries: new Map(),
changes: new Map(),
docked: null,
lastTask: null,
homeOpen: false,
scrollPositions: new Map(),
});
}
@@ -353,24 +364,76 @@ export function useAgentCommands(
}
export function openTask(task: string) {
if (useAgents.getState().sessionLocation === "toolWindow") {
showDockedTask(task);
return;
}
useAgents.setState({ lastTask: task, homeOpen: false });
openTaskInEditor(task);
}
function openTaskInEditor(task: string) {
// The title rides along so a restored tab has it before the tasks load.
const title = useAgents.getState().tasks.get(task)?.title;
void editorService().openInput({ kind: SESSION_KIND, key: sessionKey(task), data: { task, title } }, { focus: true });
}
/** Shows the conversation inside the Agents tool window, closing its editor tab. */
export function dockTask(task: string) {
useAgents.setState({ docked: task });
function showDockedTask(task: string) {
useAgents.setState({ docked: task, lastTask: task, homeOpen: false });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(sessionKey(task));
}
/** Moves the docked conversation back to an editor tab. */
function saveSessionLocation(location: SessionLocation) {
useAgents.setState({ sessionLocation: location });
save(SESSION_LOCATION_KEY, location);
if (location === "toolWindow") {
for (const { input } of editorService().openTabs?.() ?? []) {
if (input.kind === SESSION_KIND || input.kind === HOME_KIND) void editorService().closeInput?.(input.key);
}
}
}
/** Conversation placement is an Agents-wide preference, shared by every task. */
export function setSessionLocation(location: SessionLocation) {
const s = useAgents.getState();
if (s.sessionLocation === location) return;
const active = editorService().activeInput?.();
const activeTask = active?.kind === SESSION_KIND ? (active.data as { task: string }).task : null;
const home = s.homeOpen || active?.kind === HOME_KIND;
const task =
s.docked ??
activeTask ??
(s.lastTask && s.tasks.has(s.lastTask) ? s.lastTask : null) ??
[...s.tasks.values()]
.filter((t) => t.status !== "archived")
.sort((a, b) => (b.updatedMs ?? b.createdMs ?? 0) - (a.updatedMs ?? a.createdMs ?? 0))[0]?.id;
saveSessionLocation(location);
useAgents.setState({ docked: null, homeOpen: false });
if (home) {
openHome();
} else if (task) {
openTask(task);
} else if (location === "toolWindow") {
openHome();
}
}
/** Dragging a session into the tool window selects the same placement for other tasks. */
export function dockTask(task: string) {
saveSessionLocation("toolWindow");
showDockedTask(task);
}
export function undockTask() {
const docked = useAgents.getState().docked;
if (!docked) return;
useAgents.setState({ docked: null });
openTask(docked);
setSessionLocation("editor");
}
/** An explicit editor drop also updates where subsequent sessions open. */
export function openTaskInEditorLocation(task: string) {
saveSessionLocation("editor");
useAgents.setState({ docked: null, homeOpen: false });
openTask(task);
}
/** Opens the New Task page; `prompt` pre-fills it. */
@@ -382,6 +445,12 @@ export function openHome(prompt?: string) {
updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) });
}
if (prompt !== undefined) updateComposer(HOME, { text: prompt });
if (useAgents.getState().sessionLocation === "toolWindow") {
useAgents.setState({ docked: null, homeOpen: true });
toolWindowService().show(TOOL_WINDOW, true);
void editorService().closeInput?.(HOME_KEY);
return;
}
void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true });
}
+35 -11
View File
@@ -4,7 +4,18 @@ import { tx } from "@/platform/i18n";
import type { MenuItem } from "@/ui";
import { showError, confirmChecked } from "@/workbench/messages";
import { isBusy } from "./model";
import { archiveTask, unarchiveTask, deleteTask, dockTask, forkTask, openTask, undockTask, useAgents } from "./store";
import {
archiveTask,
unarchiveTask,
deleteTask,
dockTask,
forkTask,
openTask,
openTaskInEditorLocation,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const canArchive = (task: AgentTask) => !isBusy(task.status) && task.status !== "archived";
const canFork = (task: AgentTask) => canArchive(task) && !!task.capabilities?.fork;
@@ -59,6 +70,29 @@ async function confirmDelete(tasks: AgentTask[]) {
}
export const taskActions: Action[] = [
{
id: "agents.useToolWindow",
title: "Chat in Tool Window",
category: "Agents",
icon: "open-in-tool-window",
run: () => setSessionLocation("toolWindow"),
},
{
id: "agents.useEditor",
title: "Chat in Editor",
category: "Agents",
icon: "editor-only",
run: () => setSessionLocation("editor"),
},
{
id: "agents.openTaskInEditor",
title: "Open Agent Task in Editor",
category: "Agents",
hidden: true,
run: ({ arg }) => {
if (typeof arg === "string") openTaskInEditorLocation(arg);
},
},
{
id: "agents.openTask",
title: "Open Agent Task",
@@ -131,20 +165,10 @@ export function taskMenu(tasks: AgentTask[]): MenuItem[] {
const multiple = tasks.length > 1;
const archived = tasks.filter((task) => task.status === "archived").length;
const archivable = tasks.filter(canArchive).length;
const docked = useAgents.getState().docked;
return [
...(!multiple
? ([
{ kind: "action", action: "agents.openTask", label: "Open", arg: task.id },
docked === task.id
? { kind: "action", action: "agents.undockTask", label: "Open in Editor", icon: "editor-only" }
: {
kind: "action",
action: "agents.dockTask",
label: "Open in Tool Window",
icon: "open-in-tool-window",
arg: task.id,
},
{ kind: "action", action: "terminal.openAt", label: "Open in Terminal", icon: "terminal", arg: task.cwd },
...(task.capabilities?.fork && task.status !== "archived"
? ([
+64 -5
View File
@@ -1,15 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
import { dropOnEditor, useContributions } from "@/workbench/contributions";
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import { AgentsView } from "./AgentsView";
import { AgentsStatus } from "./AgentsStatus";
import { activate } from "./index";
import * as actions from "@/platform/actions";
import { TASK_DRAG } from "./model";
import { connect, disconnect, dockTask, markVisible, undockTask, useAgents } from "./store";
import {
connect,
disconnect,
dockTask,
markVisible,
openHome,
openTask,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
active: null as EditorInput | null,
tabs: [] as OpenEditorTab[],
channel: null as { onmessage: (event: AgentEvent) => void } | null,
tasks: [] as AgentTask[],
notify: vi.fn(),
@@ -38,7 +50,12 @@ vi.mock("@/platform/ipc", async (original) => ({
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openInput: backend.openInput, closeInput: backend.closeInput }),
editorService: () => ({
openInput: backend.openInput,
closeInput: backend.closeInput,
activeInput: () => backend.active,
openTabs: () => backend.tabs,
}),
toolWindowService: () => ({ show: backend.showToolWindow }),
notifications: () => ({ notify: backend.notify }),
}));
@@ -81,6 +98,10 @@ beforeEach(async () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
disconnect();
backend.active = null;
backend.tabs = [];
useAgents.setState({ sessionLocation: "editor" });
localStorage.clear();
backend.tasks = [task("one"), task("two", "ready")];
await connect();
});
@@ -251,7 +272,7 @@ describe("docking a conversation in the tool window", () => {
const dispose = activate();
const execute = vi.spyOn(actions, "executeAction");
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
expect(execute).toHaveBeenCalledWith("agents.openTask", "two");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "two");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
@@ -260,7 +281,7 @@ describe("docking a conversation in the tool window", () => {
backend.openInput.mockClear();
act(() => dockTask("one"));
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
expect(execute).toHaveBeenCalledWith("agents.undockTask", "one");
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "one");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
@@ -269,9 +290,47 @@ describe("docking a conversation in the tool window", () => {
dispose();
});
it("moves the active session and applies the header choice to every subsequent session", () => {
backend.active = { kind: "agent-session", key: "agent:two", data: { task: "two" } };
backend.tabs = ["one", "two"].map((id) => ({
input: { kind: "agent-session", key: `agent:${id}`, data: { task: id } },
title: id,
icon: "toolwindow-agents",
lastActive: 0,
}));
act(() => setSessionLocation("toolWindow"));
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
expect(backend.closeInput).toHaveBeenCalledWith("agent:two");
expect(useAgents.getState().docked).toBe("two");
expect(backend.openInput).not.toHaveBeenCalled();
act(() => openTask("one"));
expect(useAgents.getState().docked).toBe("one");
expect(backend.openInput).not.toHaveBeenCalled();
expect(JSON.parse(localStorage.getItem("openidea.agents.sessionLocation.v1")!)).toBe("toolWindow");
act(() => setSessionLocation("editor"));
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:one" }), { focus: true });
act(() => openTask("two"));
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:two" }), { focus: true });
});
it("opens new tasks in the selected location and moves an unfinished draft with the layout", () => {
act(() => setSessionLocation("toolWindow"));
backend.openInput.mockClear();
act(() => openHome("Keep this draft"));
expect(useAgents.getState().homeOpen).toBe(true);
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).not.toHaveBeenCalled();
act(() => setSessionLocation("editor"));
expect(useAgents.getState().homeOpen).toBe(false);
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "agent-home" }), { focus: true });
expect(useAgents.getState().composers.get("home")?.text).toBe("Keep this draft");
});
it("drops the dock when the project changes", async () => {
act(() => dockTask("one"));
disconnect();
expect(useAgents.getState().docked).toBeNull();
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
});
});
@@ -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} />,
}));
+3
View File
@@ -24,6 +24,9 @@ export interface CustomColorScheme {
export type CustomColorSchemes = Record<string, CustomColorScheme>;
export interface LanguageCodeStyle {
/** Java formatter; relative XML paths start at the project root. */
formatter?: "default" | "intellij" | "custom";
formatterPath?: string;
useTab?: boolean;
tabSize?: number;
indent?: number;
+17 -3
View File
@@ -1364,12 +1364,10 @@
"Open console for tool output": "为工具输出打开控制台",
"Open documents": "打开的文档",
"Open in Browser": "在浏览器中打开",
"Open in Editor": "在编辑器中打开",
"Open in Find Tool Window": "在“查找”工具窗口中打开",
"Open in Find Window": "在查找窗口中打开",
"Open in Opposite Group": "在相对组中打开",
"Open in New Window": "在新窗口中打开",
"Open in Tool Window": "在工具窗口中打开",
"Open in Terminal": "在终端中打开",
"Open in editor": "在编辑器中打开",
"Open moved files in editor": "在编辑器中打开已移动的文件",
@@ -5440,5 +5438,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}"
}
+1
View File
@@ -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",