Merge remote-tracking branch 'origin/main' into review/pr1147

This commit is contained in:
l0ng-ai
2026-11-04 11:41:59 +08:00
38 changed files with 596 additions and 131 deletions
+8
View File
@@ -345,6 +345,14 @@ impl AgentManager {
provider::set_builtin_command(&dir, id, command.as_deref())
}
/// Sets the environment variables built-in `id` starts with; empty clears them.
pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap<String, String>) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::set_builtin_env(&dir, id, env)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
+3
View File
@@ -3,6 +3,7 @@
use ide_core::IpcPath;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -357,6 +358,8 @@ pub struct AgentProvider {
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
/// Whether the command was found (on `PATH`).
pub installed: bool,
/// Environment variables it starts with (from its agent file).
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Slash commands it offered last time.
+30 -2
View File
@@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
commands: vec![],
source: spec.source.clone(),
@@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
/// Overrides built-in `id`'s command through its agent file, preserving other
/// customizations. `None` restores the built-in command and removes a redundant file.
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
}
/// The environment built-in `id` starts with, through its agent file (empty clears it).
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
update_builtin(custom_dir, id, |agent, _| agent.env = env)
}
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
let Some(builtin) = builtin_spec(id) else {
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
};
@@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
}
if command.is_none()
change(&mut agent, &builtin);
if agent.command == builtin.command
&& agent.name.as_deref() == Some(builtin.name.as_str())
&& agent.driver == builtin.driver
&& agent.args == builtin.args
@@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
}
return Ok(());
}
agent.command = command.unwrap_or(&builtin.command).to_owned();
agent.write(&file)?;
Ok(())
}
@@ -368,6 +379,23 @@ mod tests {
assert!(installed("sh"));
}
#[test]
fn set_builtin_env_writes_and_clears_the_environment() {
let dir = tempfile::tempdir().unwrap();
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
assert_eq!(describe(&claude).env, env);
// The command override and the environment are kept independently.
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
set_builtin_command(dir.path(), "claude", None).unwrap();
assert!(dir.path().join("claude.toml").is_file());
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
assert!(!dir.path().join("claude.toml").exists());
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
+12
View File
@@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option<String
host.project(window)?.agents.set_builtin_command(&id, command)
}
/// Environment variables built-in agent `id` starts with; empty clears them.
pub fn set_env(
host: &Host,
window: &str,
id: String,
env: std::collections::BTreeMap<String, String>,
) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.set_builtin_env(&id, env)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -195,6 +206,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
d.add("agent_registry_install", |c| async move {
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
+19
View File
@@ -222,6 +222,22 @@ fn declaring_pom(
)))
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`).
pub async fn gradle_published_catalog(
host: &Host,
window: &str,
coordinates: String,
) -> IdeResult<Option<ide_core::IpcPath>> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates)
.map(Into::into)
})
.await
.map_err(join_err)
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
pub async fn maven_dependency_declaration(
host: &Host,
@@ -348,6 +364,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
)
.await
});
d.add("gradle_published_catalog", |c| async move {
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
});
d.add("maven_dependency_declaration", |c| async move {
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
});
+10 -1
View File
@@ -1064,7 +1064,16 @@ fn plugin_syntax_for_languages_without_a_grammar() {
let sol = LanguageId::new("solidity");
let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n";
assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages");
let tokens = provider.highlights(&sol, text).unwrap();
// The first call of a new instance can run past the edit-path budget on a
// slow machine; its answer is dropped and a later call asks again.
let deadline = Instant::now() + Duration::from_secs(10);
let tokens = loop {
match provider.highlights(&sol, text) {
Some(tokens) => break tokens,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
None => panic!("no highlights"),
}
};
let kind_at =
|s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str());
assert_eq!(kind_at("contract"), Some("keyword"));
+17 -11
View File
@@ -52,11 +52,16 @@ fn local_folder(local: &str, project_root: &Path) -> Option<PathBuf> {
{
return Some(project_root.join(rel));
}
let rel = local.trim_matches('/');
let relative = project_root.join(rel);
let relative = project_root.join(without_root(path));
if !path.is_absolute() || relative.is_dir() || !path.exists() { Some(relative) } else { None }
}
/// `path` without its drive and root (`/a/b`, `C:\a\b` → `a/b`), to read it
/// relative to the project: joining an absolute path would replace the root.
fn without_root(path: &Path) -> PathBuf {
path.components().filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir)).collect()
}
impl Mappings {
/// `remote_root` is the server root resolved on the host (its login
/// directory when the setting is empty). A server without mappings maps the
@@ -247,14 +252,15 @@ mod tests {
#[test]
fn local_paths_may_be_absolute_inside_the_project() {
let root = Path::new("/p");
let m = Mappings::new(&server(vec![("/p", "/", ""), ("/p/static/", "cdn", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(PathBuf::from("/p/static/x.css")));
let root = Path::new(if cfg!(windows) { r"C:\p" } else { "/p" });
let (inside, statics) = (root.to_str().unwrap(), format!("{}/static/", root.display()));
let m = Mappings::new(&server(vec![(inside, "/", ""), (&statics, "cdn", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(&root.join("src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(root.join("static/x.css")));
// A leading `/` that is not the project stays project-relative.
let m = Mappings::new(&server(vec![("/static/", "", "")]), root, "/mnt/deploy");
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
}
#[test]
@@ -264,7 +270,7 @@ mod tests {
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir(&outside).unwrap();
let nested = root.join(outside.strip_prefix("/").unwrap()).join("a.txt");
let nested = root.join(without_root(&outside)).join("a.txt");
std::fs::create_dir_all(nested.parent().unwrap().parent().unwrap()).unwrap();
// A nested project file at the outside path's spelling is not a mapping folder.
std::fs::write(nested.parent().unwrap(), "nested").unwrap();
@@ -288,7 +294,7 @@ mod tests {
assert_eq!(local_folder("/", &root), Some(root.clone()));
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
let relative = root.join(outside.strip_prefix("/").unwrap());
let relative = root.join(without_root(&outside));
std::fs::create_dir_all(&relative).unwrap();
assert_eq!(local_folder(outside.to_str().unwrap(), &root), Some(relative.clone()));
let nested = relative.join("a.txt");
@@ -297,7 +303,7 @@ mod tests {
assert_eq!(m.remote(&nested).as_deref(), Some("/srv/a.txt"));
assert_eq!(m.local("/srv/a.txt"), Some(nested));
let missing = tmp.path().join("missing");
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(missing.strip_prefix("/").unwrap())));
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(without_root(&missing))));
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
}
+42 -24
View File
@@ -105,15 +105,17 @@ fn split_script_args(args: &[String]) -> ScriptArgs {
out
}
/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector
/// tuned to give memory back to the OS (resident memory stays near what is live instead of
/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical
/// JVM options jdtls runs with unless the user's set the same thing: G1 with a periodic
/// collection while idle and a narrow free-heap band, so the heap shrinks and memory goes
/// back to the OS once an import or a build is over (a parallel collector keeps its peak
/// committed for good), a 2 GB heap limit (the JVM's own is a quarter of physical
/// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at
/// start (they are passed as `java.configuration.runtimes`).
pub const DEFAULT_VM_ARGS: &[&str] = &[
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-XX:+UseG1GC",
"-XX:G1PeriodicGCInterval=60000",
"-XX:MinHeapFreeRatio=10",
"-XX:MaxHeapFreeRatio=30",
"-Dsun.zip.disableMemoryMapping=true",
"-Xmx2G",
"-Xms100m",
@@ -130,6 +132,8 @@ fn option_key(arg: &str) -> Option<String> {
let name = name.split_once('=').map_or(name, |(n, _)| n);
match name {
"MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(),
// The JVM rejects a minimum above the maximum, so they go together.
"MinHeapFreeRatio" | "MaxHeapFreeRatio" => "XX:HeapFreeRatio".to_owned(),
"InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(),
_ if is_collector(name) => "collector".to_owned(),
_ => format!("XX:{name}"),
@@ -153,8 +157,8 @@ fn is_collector(name: &str) -> bool {
name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len()
}
/// Our tuning of the parallel collector, meaningless (or rejected) with another one.
const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"];
/// Our tuning of the default collector, meant for it alone.
const COLLECTOR_TUNING: &[&str] = &["G1PeriodicGCInterval", "HeapFreeRatio"];
/// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line)
/// sets itself. Choosing a collector drops our collector and its tuning altogether.
@@ -180,7 +184,10 @@ pub fn add_default_vm_args(args: &mut Vec<String>) {
}
/// The JVM command line `bin/jdtls` builds: its system properties and module
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-nosplash` and
/// `-data`. Without `-nosplash` the launcher's native library loads AppKit on macOS,
/// which registers the server as an app that never answers events and shows as
/// "Not Responding" in Activity Monitor.
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
let parsed = split_script_args(script_args);
let mut args: Vec<String> = Vec::new();
@@ -211,6 +218,9 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
);
args.extend(parsed.jvm);
args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]);
if !parsed.rest.iter().any(|a| a == "-nosplash") {
args.push("-nosplash".to_owned());
}
if let Some(data) = parsed.data {
args.extend(["-data".to_owned(), data]);
}
@@ -342,6 +352,7 @@ mod tests {
&format!("-javaagent:{agent}"),
"-jar",
&format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"),
"-nosplash",
"-data",
data,
])
@@ -356,7 +367,9 @@ mod tests {
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
}
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
const G1: &[&str] =
&["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
const HEAP_FREE: &[&str] = &["-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
#[test]
fn user_options_override_the_defaults() {
@@ -369,13 +382,15 @@ mod tests {
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
(&["-Xms1G"], &["-Xms100m"]),
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
(&["-XX:+UseG1GC"], PARALLEL),
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
(&["-XX:+UseShenandoahGC"], PARALLEL),
(&["-XX:+UseSerialGC"], PARALLEL),
(&["-XX:-UseParallelGC"], PARALLEL),
(&["-XX:+UseParallelGC"], PARALLEL),
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
(&["-XX:+UseParallelGC"], G1),
(&["-XX:+UseZGC", "-XX:+ZGenerational"], G1),
(&["-XX:+UseShenandoahGC"], G1),
(&["-XX:+UseSerialGC"], G1),
(&["-XX:-UseG1GC"], G1),
(&["-XX:+UseG1GC"], G1),
(&["-XX:G1PeriodicGCInterval=0"], &["-XX:G1PeriodicGCInterval=60000"]),
(&["-XX:MinHeapFreeRatio=40"], HEAP_FREE),
(&["-XX:MaxHeapFreeRatio=70"], HEAP_FREE),
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
@@ -389,10 +404,10 @@ mod tests {
#[test]
fn defaults_go_ahead_of_the_users_options_once() {
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseZGC"]);
let mut args = user.clone();
add_default_vm_args(&mut args);
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
let dropped: Vec<&str> = G1.iter().copied().chain(["-Xmx2G"]).collect();
let expected: Vec<String> =
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
assert_eq!(args, expected);
@@ -413,14 +428,14 @@ mod tests {
let args = java_args(&install, 25, &script);
let jar = args.iter().position(|a| a == "-jar").unwrap();
let jvm = &args[..jar];
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
for flag in ["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
}
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
assert_eq!(args[jar + 2..], strings(&["-nosplash", "-data", r"C:\Users\张 三\ws"]));
}
#[test]
@@ -439,18 +454,21 @@ mod tests {
assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21");
assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned()));
assert_eq!(
args[args.len() - 7..],
args[args.len() - 8..],
strings(&[
"java.base/java.lang=ALL-UNNAMED",
"-Dlog.level=ALL",
"-jar",
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
"-nosplash",
"-data",
"/d",
"--extra",
])
);
assert!(!args.iter().any(|a| a.contains("validate-java-version")));
let own = java_args(&install, 21, &strings(&["-data", "/d", "-nosplash"]));
assert_eq!(own.iter().filter(|a| *a == "-nosplash").count(), 1, "{own:?}");
}
#[test]
@@ -526,7 +544,7 @@ mod tests {
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", "/ws"]));
let jre = dir.path().join("jre-25");
std::fs::create_dir_all(jre.join("bin")).unwrap();
@@ -640,7 +658,7 @@ mod tests {
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
assert_eq!(direct.args[jar + 2..], strings(&["-data", r"C:\ws"]));
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", r"C:\ws"]));
// A shim whose descriptor is gone is someone's own wrapper: keep the command.
std::fs::remove_file(shims.join("jdtls.shim")).unwrap();
+50
View File
@@ -294,6 +294,36 @@ pub(crate) fn dependency_libraries(
.collect()
}
/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's
/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else
/// the local Maven repository. `None` until a build has downloaded it.
pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option<PathBuf> {
let mut parts = coordinates.trim().split(':');
let d = BuildDependency {
gradle: true,
group: parts.next()?.to_owned(),
artifact: parts.next()?.to_owned(),
version: parts.next()?.to_owned(),
};
if parts.next().is_some() || !valid_coordinate(&d) {
return None;
}
let file = format!("{}-{}.toml", d.artifact, d.version);
let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home);
let cached = gradle.and_then(|home| {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut found: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect();
found.sort();
found.into_iter().next()
});
cached.or_else(|| {
let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?;
let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file);
path.is_file().then_some(path)
})
}
/// No path separators, interpolations or `..` segments (coordinates become paths).
fn valid_coordinate(d: &BuildDependency) -> bool {
[&d.group, &d.artifact, &d.version]
@@ -530,6 +560,26 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
#[cfg(test)]
mod go_cache_tests {
use super::*;
#[test]
fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned();
s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml");
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached));
let local = root.join("m2/com/acme/other/2.0/other-2.0.toml");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local));
for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] {
assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}");
}
}
#[test]
fn vendor_roots_include_local_replacements_once() {
let dir = tempfile::tempdir().unwrap();
+13
View File
@@ -49,6 +49,19 @@ pub fn agent_set_command(
agent::set_command(&state.host, window.label(), id, command)
}
/// Environment variables built-in agent `id` starts with (saved in its agent file);
/// an empty map clears them.
#[tauri::command]
#[specta::specta]
pub fn agent_set_env(
window: Window,
state: State<'_, AppState>,
id: String,
env: std::collections::BTreeMap<String, String>,
) -> IdeResult<()> {
agent::set_env(&state.host, window.label(), id, env)
}
/// Agents of the ACP Registry this machine can run, with what is installed; the list
/// saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`.
#[tauri::command]
+12
View File
@@ -43,6 +43,18 @@ pub async fn build_sync(
.await
}
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
/// build has downloaded it.
#[tauri::command]
#[specta::specta]
pub async fn gradle_published_catalog(
window: Window,
state: State<'_, AppState>,
coordinates: String,
) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::build::gradle_published_catalog(&state.host, window.label(), coordinates).await
}
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
#[tauri::command]
#[specta::specta]
+2
View File
@@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
agent::agent_subscribe,
agent::agent_providers,
agent::agent_set_command,
agent::agent_set_env,
agent::agent_registry_list,
agent::agent_registry_install,
agent::agent_registry_remove,
@@ -337,6 +338,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
run::run_test_reports,
build::build_model,
build::build_sync,
build::gradle_published_catalog,
build::maven_dependency_declaration,
build::maven_exclude_dependency,
build::build_run,
+12
View File
@@ -91,6 +91,11 @@ export const commands = {
* built-in's driver and arguments), or returns it to auto-detection with `None`.
*/
agentSetCommand: (id: string, command: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_command", { id, command })),
/**
* Environment variables built-in agent `id` starts with (saved in its agent file);
* an empty map clears them.
*/
agentSetEnv: (id: string, env: { [key in string]: string }) => typedError<null, IdeError>(__TAURI_INVOKE("agent_set_env", { id, env })),
/**
* Agents of the ACP Registry this machine can run, with what is installed; the list
* saved earlier when the registry is unreachable. Cancel with `cancel_request(request)`.
@@ -638,6 +643,11 @@ export const commands = {
* Returns the refreshed model; a failed build is reported in its `syncError`.
*/
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
/**
* The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
* build has downloaded it.
*/
gradlePublishedCatalog: (coordinates: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("gradle_published_catalog", { coordinates })),
/** Where the direct dependency `dependency` of the Maven module `pom` is declared. */
mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError<Location, IdeError>(__TAURI_INVOKE("maven_dependency_declaration", { pom, dependency })),
/**
@@ -1388,6 +1398,8 @@ export type AgentProvider = {
commandReadOnlyReason: AgentCommandReadOnlyReason | null,
/** Whether the command was found (on `PATH`). */
installed: boolean,
/** Environment variables it starts with (from its agent file). */
env: { [key in string]: string },
/** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */
config: AgentConfigOption[],
/** Slash commands it offered last time. */
+15 -2
View File
@@ -2,7 +2,8 @@
* Applies Settings › Editor › Color Scheme (scheme + attribute overrides) as one
* stylesheet, and Editor › Font › Typography Settings as the root's
* `--fw-code` / `--fw-code-bold`. When the IDE theme turns light or dark, a chosen
* scheme of the other kind is switched back to the theme's own.
* scheme of the other kind is switched back to the theme's own, unless it was chosen
* in the same change as the theme (one Apply of both).
*/
import { useProject } from "@/platform/project";
import { editorPrefs, fontWeightVars } from "@/platform/editorSettings";
@@ -24,6 +25,10 @@ export function installColorScheme(): () => void {
if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value);
};
let kind = root.dataset.theme;
// The scheme set by the latest settings change, until the next task: the theme's
// `data-theme` change from the same change is observed before then.
let picked: string | undefined;
let clearPicked: ReturnType<typeof setTimeout> | undefined;
const followTheme = () => {
const next = root.dataset.theme;
if (next === kind) return;
@@ -31,6 +36,7 @@ export function installColorScheme(): () => void {
kind = next;
const settings = useProject.getState().settings;
if (!previous || !settings || (next !== "light" && next !== "dark")) return;
if (picked !== undefined && picked === settings.editor?.colorScheme) return;
const scheme = schemeAfterThemeChange(settings.editor?.colorScheme, next, editorPrefs(settings).customSchemes);
if (scheme === null) return;
if (!isTauri()) {
@@ -44,7 +50,13 @@ export function installColorScheme(): () => void {
};
apply();
const unsubscribe = useProject.subscribe((s, prev) => {
if (s.settings !== prev.settings) apply();
if (s.settings === prev.settings) return;
apply();
const scheme = s.settings?.editor?.colorScheme;
if (scheme === prev.settings?.editor?.colorScheme) return;
picked = scheme;
clearTimeout(clearPicked);
clearPicked = setTimeout(() => (picked = undefined));
});
const observer =
typeof MutationObserver !== "undefined"
@@ -59,6 +71,7 @@ export function installColorScheme(): () => void {
});
return () => {
unsubscribe();
clearTimeout(clearPicked);
observer?.disconnect();
el.remove();
root.style.removeProperty("--fw-code");
+47
View File
@@ -93,6 +93,14 @@ function Details({
onChanged?.();
return command === agent.defaultProgram ? "" : (command ?? "");
});
const envBase = formatEnv(agent.env);
const env = form.useExternal(`agents.${agent.id}.env`, envBase, async (draft) => {
const parsed = parseEnv(draft);
await backend.setEnv(agent.id, parsed);
await refresh();
onChanged?.();
return formatEnv(parsed);
});
const [error, setError] = useState<string | null>(null);
// An edit can have been staged by a previous page instance. Its Apply callback
// refreshes that instance; the mounted one follows the newly applied value too.
@@ -174,6 +182,26 @@ function Details({
)}
</p>
)}
{agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && (
<>
<label className={styles.label} htmlFor={`agent-env-${agent.id}`}>
<T>Environment variables</T>
</label>
<div className={styles.controls}>
<Input
id={`agent-env-${agent.id}`}
mono
value={env.value}
disabled={env.saving}
placeholder="NAME=value; OTHER=value"
onChange={(e) => env.set(e.target.value)}
/>
</div>
<p className={styles.hint}>
<T>Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.</T>
</p>
</>
)}
{agent.loginHint && (
<>
<h4 className={styles.section}>
@@ -193,6 +221,25 @@ function Details({
);
}
/** `NAME=value` pairs separated by `;` or new lines. */
function parseEnv(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const raw of text.split(/[;\n]/)) {
const pair = raw.trim();
if (!pair) continue;
const eq = pair.indexOf("=");
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(tx`Expected NAME=value: ${pair}`);
env[key] = pair.slice(eq + 1).trim();
}
return env;
}
const formatEnv = (env: Record<string, string>) =>
Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
/** Login hints are plain text with optional backtick-delimited command spans. */
function LoginHint({ text }: { text: string }) {
const translate = useTx();
@@ -145,6 +145,7 @@ describe("agent picker", () => {
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
config: [],
commands: [],
...p,
@@ -312,6 +313,11 @@ class FakeCommands implements CommandsBackend {
saved: [string, string | null][] = [];
providers = vi.fn(async () => this.list);
chooseExecutable = vi.fn(async () => "/chosen/agent");
savedEnv: [string, Record<string, string>][] = [];
async setEnv(id: string, env: Record<string, string>) {
this.savedEnv.push([id, env]);
this.list = this.list.map((p) => (p.id === id ? { ...p, env } : p));
}
async set(id: string, command: string | null) {
this.saved.push([id, command]);
// What the agent list shows after the override takes effect.
@@ -341,6 +347,7 @@ const claudeProvider = (command: string): AgentProvider => ({
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
config: [],
commands: [],
source: { kind: "builtin" },
@@ -436,6 +443,23 @@ describe("Built-in executable overrides", () => {
} else expect(screen.queryByRole("dialog")).toBeNull();
});
it("writes the agent's environment variables on Apply and shows them again", async () => {
const commands = new FakeCommands();
commands.list = [claudeProvider("claude")];
await openCommands(commands);
const env = () => screen.getByRole("textbox", { name: "Environment variables" });
fireEvent.change(env(), { target: { value: "CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080" } });
expect(commands.savedEnv).toEqual([]);
await act(async () => fireEvent.click(apply()));
expect(commands.savedEnv).toEqual([
["claude", { CLAUDE_CODE_ENTRYPOINT: "openidea", HTTPS_PROXY: "http://proxy:8080" }],
]);
expect(env()).toHaveValue("CLAUDE_CODE_ENTRYPOINT=openidea; HTTPS_PROXY=http://proxy:8080");
fireEvent.change(env(), { target: { value: "" } });
await act(async () => fireEvent.click(apply()));
expect(commands.savedEnv.at(-1)).toEqual(["claude", {}]);
});
it("Cancel discards command edits without writing", async () => {
const commands = new FakeCommands();
commands.list = [
@@ -50,6 +50,7 @@ const claude: AgentProvider = {
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
env: {},
// Offered by the agent's last session in this project.
commands: [command("compact", "Free up context")],
};
+4
View File
@@ -22,6 +22,7 @@ export interface RegistryBackend {
export interface CommandsBackend {
providers(): Promise<AgentProvider[]>;
set(id: string, command: string | null): Promise<void>;
setEnv(id: string, env: Record<string, string>): Promise<void>;
chooseExecutable(title: string, current?: string): Promise<string | null>;
}
@@ -47,6 +48,9 @@ export const tauriCommands: CommandsBackend = {
set: async (id, command) => {
await call(commands.agentSetCommand(id, command));
},
setEnv: async (id, env) => {
await call(commands.agentSetEnv(id, env));
},
};
export const DISTRIBUTION_LABEL: Record<RegistryDistribution, string> = {
@@ -1,5 +1,24 @@
import { describe, expect, it } from "vitest";
import { accessorSegments, catalogEntry, chainAt, declaredCatalogs, parseCatalog } from "./versionCatalog";
import { describe, expect, it, vi } from "vitest";
const ipc = vi.hoisted(() => ({ fsReadText: vi.fn(), gradlePublishedCatalog: vi.fn() }));
vi.mock("@/platform/ipc", () => ({
call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => {
const r = await p;
if (r.status !== "ok") throw r.error;
return r.data;
},
commands: ipc,
onIdeEvent: () => () => {},
}));
import {
accessorSegments,
catalogEntry,
chainAt,
declaredCatalogs,
parseCatalog,
readCatalogs,
} from "./versionCatalog";
const toml = `[versions]
springBoot = "3.5.16"
@@ -64,11 +83,35 @@ describe("Gradle version catalogs", () => {
create('tools') {
from(files('gradle/tools.versions.toml'))
}
create("platformLibs") { from("com.acme:platform-catalog:1.4.0") }
}
}`;
expect(declaredCatalogs(settings)).toEqual([
{ name: "commonLibs", file: "../shared/common.versions.toml" },
{ name: "tools", file: "gradle/tools.versions.toml" },
{ name: "platformLibs", artifact: "com.acme:platform-catalog:1.4.0" },
]);
});
it("reads a published catalog from where a build downloaded it", async () => {
const files: Record<string, string> = {
"/p/settings.gradle.kts": `dependencyResolutionManagement { versionCatalogs {
create("platformLibs") { from("com.acme:platform-catalog:1.4.0") }
create("later") { from("com.acme:later:1.0") }
} }`,
"/cache/platform-catalog-1.4.0.toml": '[libraries]\nspring-core = "org.springframework:spring-core:6.2.0"\n',
};
ipc.fsReadText.mockImplementation(async (path: string) =>
path in files ? { status: "ok", data: files[path] } : { status: "error", error: { kind: "notFound" } },
);
ipc.gradlePublishedCatalog.mockImplementation(async (coordinates: string) => ({
status: "ok",
data: coordinates === "com.acme:platform-catalog:1.4.0" ? "/cache/platform-catalog-1.4.0.toml" : null,
}));
const { catalogs, complete } = await readCatalogs("/p/build.gradle.kts", "/p");
expect(catalogs.map((c) => [c.name, c.path])).toEqual([["platformLibs", "/cache/platform-catalog-1.4.0.toml"]]);
expect(catalogEntry(catalogs[0]!, ["spring", "core"])?.value).toBe('"org.springframework:spring-core:6.2.0"');
// `later` isn't downloaded yet: read again next time.
expect(complete).toBe(false);
});
});
+33 -16
View File
@@ -99,19 +99,29 @@ export function chainAt(line: string, pos: number): { from: number; to: number;
return segments.length > 1 ? { from, to, segments } : null;
}
/** Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the build root. */
export function declaredCatalogs(settings: string): Array<{ name: string; file: string }> {
const out: Array<{ name: string; file: string }> = [];
const re = /create\(\s*["'](\w+)["']\s*\)\s*\{[^}]*?from\(\s*files\(\s*["']([^"']+)["']/g;
for (let m = re.exec(settings); m; m = re.exec(settings)) out.push({ name: m[1]!, file: m[2]! });
export type DeclaredCatalog = { name: string; file: string } | { name: string; artifact: string };
/**
* Catalogs declared in settings: `create("name") { from(files("path")) }`, paths relative to the
* build root, or `create("name") { from("group:artifact:version") }`, a published catalog.
*/
export function declaredCatalogs(settings: string): DeclaredCatalog[] {
const out: DeclaredCatalog[] = [];
const re =
/create\(\s*["'](\w+)["']\s*\)\s*\{[^}]*?from\(\s*(?:files\(\s*["']([^"']+)["']|["']([^"':]+:[^"':]+:[^"']+)["'])/g;
for (let m = re.exec(settings); m; m = re.exec(settings))
out.push(m[2] !== undefined ? { name: m[1]!, file: m[2] } : { name: m[1]!, artifact: m[3]! });
return out;
}
const readText = (path: string) => call(commands.fsReadText(path)).catch(() => null);
const join = (dir: string, rel: string) => (rel.startsWith("/") ? rel : `${dir}/${rel.replace(/^\.\//, "")}`);
/** The catalogs a build script sees: those of the nearest enclosing build (its settings file). */
async function readCatalogs(script: string, root: string): Promise<Catalog[]> {
/**
* The catalogs a build script sees: those of the nearest enclosing build (its settings file).
* `complete` is false while a published catalog isn't downloaded yet.
*/
export async function readCatalogs(script: string, root: string): Promise<{ catalogs: Catalog[]; complete: boolean }> {
let dir = dirname(script);
let settings: string | null = null;
for (;;) {
@@ -123,13 +133,17 @@ async function readCatalogs(script: string, root: string): Promise<Catalog[]> {
const declared = declaredCatalogs(settings ?? "");
if (!declared.some((d) => d.name === "libs")) declared.push({ name: "libs", file: "gradle/libs.versions.toml" });
const catalogs = await Promise.all(
declared.map(async ({ name, file }) => {
const path = join(buildRoot, file);
const text = await readText(path);
return text === null ? null : parseCatalog(name, path, text);
declared.map(async (d) => {
const path =
"file" in d
? join(buildRoot, d.file)
: await call(commands.gradlePublishedCatalog(d.artifact)).catch(() => null);
const text = path ? await readText(path) : null;
return text === null ? null : parseCatalog(d.name, path!, text);
}),
);
return catalogs.filter((c): c is Catalog => c !== null);
const complete = declared.every((d, i) => "file" in d || catalogs[i] !== null);
return { catalogs: catalogs.filter((c): c is Catalog => c !== null), complete };
}
const loaded = new Map<string, Catalog[]>();
@@ -148,11 +162,14 @@ function load(script: string): void {
pending.clear();
scripts.forEach(load);
});
const p = readCatalogs(script, root);
pending.set(script, p);
void p.then((c) => {
if (pending.get(script) === p) loaded.set(script, c);
const p = readCatalogs(script, root).then(({ catalogs, complete }) => {
if (pending.get(script) !== p) return catalogs;
loaded.set(script, catalogs);
// Read again when the script is next opened, once a build has downloaded the catalog.
if (!complete) pending.delete(script);
return catalogs;
});
pending.set(script, p);
}
function open(entry: CatalogEntry, path: string) {
+24 -3
View File
@@ -363,13 +363,34 @@ export const pullImage = (connId: string, reference: string) =>
call(commands.dockerPull(r, endpointOf(connId), reference, out)),
);
export const prune = (connId: string, target: PruneTarget) =>
task(connId, `Pruning ${target === "allImages" ? "unused images" : target}…`, tx("Could not prune"), (r, out) =>
export function prune(connId: string, target: PruneTarget) {
const title = {
containers: tx("Pruning stopped containers…"),
images: tx("Pruning dangling images…"),
allImages: tx("Pruning unused images…"),
volumes: tx("Pruning unused volumes…"),
networks: tx("Pruning unused networks…"),
system: tx("Pruning the system…"),
}[target];
return task(connId, title, tx("Could not prune"), (r, out) =>
call(commands.dockerPrune(r, endpointOf(connId), target, out)),
);
}
export function composeOp(connId: string, project: string, files: string[], workingDir: string | null, op: ComposeOp) {
return task(connId, `Compose ${op} ${project}…`, tx`Compose ${op} failed for ${project}`, (r, out) =>
const { title, failure } = {
start: { title: tx`Starting Compose project ${project}…`, failure: tx`Could not start Compose project ${project}` },
stop: { title: tx`Stopping Compose project ${project}…`, failure: tx`Could not stop Compose project ${project}` },
restart: {
title: tx`Restarting Compose project ${project}…`,
failure: tx`Could not restart Compose project ${project}`,
},
down: {
title: tx`Taking down Compose project ${project}…`,
failure: tx`Could not take down Compose project ${project}`,
},
}[op];
return task(connId, title, failure, (r, out) =>
call(commands.dockerComposeOp(r, endpointOf(connId), project, files, workingDir, op, out)),
);
}
@@ -297,7 +297,7 @@ export function JavaPage() {
<Row
label="JVM options"
htmlFor={id}
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseG1GC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches."
hint="Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseG1GC -XX:G1PeriodicGCInterval=60000 -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseZGC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches."
>
<Input
id={id}
+1 -1
View File
@@ -724,7 +724,7 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
logConsole("info", "Nothing to stage for this change");
return;
}
await runOp(`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices)));
await runOp(tx`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices)));
await refreshNow();
} finally {
setBusy(false);
+3 -2
View File
@@ -97,12 +97,13 @@ export function LocalHistoryEditor({ input }: { input: EditorInput }) {
const ok = await confirmDialog({ title: "Revert", message: question, okLabel: "Revert" });
if (!ok) return;
await runOp(
"Revert from Local History",
tx("Revert from Local History"),
async () => {
for (const e of targets) await call(commands.historyRestore(e.path, e.id));
},
{
success: targets.length === 1 ? `${baseName(targets[0]!.path)} reverted` : `${targets.length} files reverted`,
success:
targets.length === 1 ? tx`${baseName(targets[0]!.path)} reverted` : tx`${targets.length} files reverted`,
refresh: true,
},
);
+2 -2
View File
@@ -259,14 +259,14 @@ export function MergeEditor({ input }: { input: EditorInput }) {
/** Saves `text` as the resolved file and stages it; like IDEA, that closes the merge editor. */
const writeResolved = async (text: string | null) => {
const ok = await runOp(
`Resolve ${baseName(path)}`,
tx`Resolve ${baseName(path)}`,
async () => {
if (text === null) await call(commands.fsDelete(path));
else await writeFileText(path, text, { save: true, exact: true });
await call(commands.vcsStage([path]));
return true;
},
{ success: `${baseName(path)}: conflicts resolved` },
{ success: tx`${baseName(path)}: conflicts resolved` },
);
if (ok) {
setResolved(true);
+21 -17
View File
@@ -79,7 +79,7 @@ async function performCheckout(
let options: CheckoutOptions = { mode: "normal", diverged: null };
for (;;) {
const opts = options;
const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts)));
const outcome = await runOp(tx`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts)));
if (!outcome) return false;
// `success` may name the branch now checked out.
if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow();
@@ -167,7 +167,7 @@ export async function newBranch(from?: string) {
const previous = useVcs.getState().branch?.head;
if (checkout) await saveAll();
const create = () => call(commands.vcsCreateBranch(repo, r.value, from ?? null, checkout));
const ok = await runOp(`Create branch ${r.value}`, create, {
const ok = await runOp(tx`Create branch ${r.value}`, create, {
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
});
if (ok !== undefined && checkout) {
@@ -208,8 +208,8 @@ export async function openConflictsInstead(): Promise<boolean> {
export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo()) {
const current = (repo === currentRepo() ? useVcs.getState().branch?.head : null) ?? "HEAD";
await saveAll();
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), {
success: `Merged ${branch} into ${current}`,
await runOp(tx`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), {
success: tx`Merged ${branch} into ${current}`,
errorActions: conflictActions,
onError: openConflictsInstead,
});
@@ -219,8 +219,8 @@ export async function rebaseOnto(branch: string) {
const repo = currentRepo();
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), {
success: `Rebased ${current} onto ${branch}`,
await runOp(tx`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(repo, branch)), {
success: tx`Rebased ${current} onto ${branch}`,
errorActions: conflictActions,
onError: openConflictsInstead,
});
@@ -238,8 +238,8 @@ export async function renameBranch(name: string) {
});
if (!r) return;
const to = r.value.trim();
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), {
success: `Renamed branch ${name} to ${to}`,
const ok = await runOp(tx`Rename branch ${name}`, () => call(commands.vcsRenameBranch(repo, name, to)), {
success: tx`Renamed branch ${name} to ${to}`,
});
if (ok !== undefined) {
try {
@@ -257,7 +257,7 @@ export async function renameBranch(name: string) {
*/
export async function deleteBranch(name: string) {
const repo = currentRepo();
const deleted = await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(repo, name)));
const deleted = await runOp(tx`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(repo, name)));
if (!deleted) return;
const tracked = deleted.upstream;
const otherTracking = useVcs.getState().branches.some((b) => !b.remote && b.name !== name && b.upstream === tracked);
@@ -274,8 +274,8 @@ export async function deleteBranch(name: string) {
label: "Restore",
run: () => {
done();
void runOp(`Restore branch ${name}`, () => call(commands.vcsRestoreBranch(repo, deleted)), {
success: `Restored branch ${name}`,
void runOp(tx`Restore branch ${name}`, () => call(commands.vcsRestoreBranch(repo, deleted)), {
success: tx`Restored branch ${name}`,
});
},
},
@@ -379,9 +379,13 @@ export async function newBranchInRepos(roots: readonly string[], from?: string)
await eachRepo(
roots,
async (root) =>
(await runOp(`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), {
refresh: false,
})) !== undefined,
(await runOp(
tx`Create branch ${name}`,
() => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)),
{
refresh: false,
},
)) !== undefined,
r.checked ? `Created and checked out ${name}` : `Created branch ${name}`,
);
}
@@ -392,7 +396,7 @@ export async function mergeInRepos(branch: string, roots: readonly string[]) {
await eachRepo(
roots,
async (root) =>
(await runOp(`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), {
(await runOp(tx`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), {
refresh: false,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -407,7 +411,7 @@ export async function rebaseInRepos(branch: string, roots: readonly string[]) {
await eachRepo(
roots,
async (root) =>
(await runOp(`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), {
(await runOp(tx`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), {
refresh: false,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -428,7 +432,7 @@ export async function deleteInRepos(name: string, roots: readonly string[]) {
await eachRepo(
roots,
async (root) =>
(await runOp(`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), {
(await runOp(tx`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), {
refresh: false,
})) !== undefined,
`Deleted branch ${name}`,
+27 -12
View File
@@ -90,8 +90,23 @@ export async function commitChanges(): Promise<string | undefined> {
success: () => {
const n = plan.included.length;
const summary = firstLine(message);
const where = repos.length > 1 ? ` in ${repos.length} repositories` : "";
return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${where}${summary ? `: ${summary}` : ""}`;
const several = repos.length;
const done = amend
? several > 1
? n === 1
? tx`1 file amended in ${several} repositories`
: tx`${n} files amended in ${several} repositories`
: n === 1
? tx("1 file amended")
: tx`${n} files amended`
: several > 1
? n === 1
? tx`1 file committed in ${several} repositories`
: tx`${n} files committed in ${several} repositories`
: n === 1
? tx("1 file committed")
: tx`${n} files committed`;
return summary ? `${done}: ${summary}` : done;
},
},
);
@@ -199,12 +214,12 @@ function RollbackDialog({ files, onClose }: { files: FileStatus[]; onClose: () =
await saveAll();
const { unstage, discard } = rollbackPlan(chosen, deleteAdded);
await runOp(
"Rollback",
tx("Rollback"),
async () => {
if (unstage.length) await call(commands.vcsUnstage(unstage));
if (discard.length) await call(commands.vcsDiscard(discard));
},
{ success: `Rolled back ${chosen.length} file${chosen.length === 1 ? "" : "s"}` },
{ success: chosen.length === 1 ? tx("Rolled back 1 file") : tx`Rolled back ${chosen.length} files` },
);
};
return (
@@ -284,7 +299,7 @@ export async function addToVcs(paths: readonly string[]) {
});
return;
}
await runOp(`Add ${targets.length} file${targets.length === 1 ? "" : "s"} to Git`, () =>
await runOp(targets.length === 1 ? tx("Add 1 file to Git") : tx`Add ${targets.length} files to Git`, () =>
call(commands.vcsStage(targets.map(stripSlash))),
);
}
@@ -299,9 +314,9 @@ export function stashChanges(repo = currentRepo()): Promise<void> {
if (r) {
await saveAll();
await runOp(
"Stash changes",
tx("Stash changes"),
() => call(commands.vcsStashPush(repo, r.message || null, r.includeUntracked, r.keepIndex)),
{ success: "Changes stashed" },
{ success: tx("Changes stashed") },
);
void loadStashes().catch(() => undefined);
}
@@ -372,10 +387,10 @@ export interface UnstashOptions {
export function unstashTitles(o: UnstashOptions): { title: string; success: string } {
const ref = `stash@{${o.index}}`;
if (o.branch)
return { title: tx`Unstash ${ref} as branch ${o.branch}`, success: `Unstashed ${ref} on branch ${o.branch}` };
return { title: tx`Unstash ${ref} as branch ${o.branch}`, success: tx`Unstashed ${ref} on branch ${o.branch}` };
return o.pop
? { title: tx`Pop ${ref}`, success: `Unstashed ${ref}` }
: { title: tx`Apply ${ref}`, success: `Applied ${ref}` };
? { title: tx`Pop ${ref}`, success: tx`Unstashed ${ref}` }
: { title: tx`Apply ${ref}`, success: tx`Applied ${ref}` };
}
export async function unstash(o: UnstashOptions, repo = currentRepo()) {
@@ -482,8 +497,8 @@ export async function dropStash(index: number, message: string, repo = currentRe
danger: true,
});
if (!ok) return;
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(repo, index)), {
success: `Dropped stash@{${index}}`,
await runOp(tx`Drop ${`stash@{${index}}`}`, () => call(commands.vcsStashDrop(repo, index)), {
success: tx`Dropped ${`stash@{${index}}`}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
}
+7 -2
View File
@@ -4,6 +4,7 @@
* the deletion staged (`git rm`), with the same three choices.
*/
import { onFileOperation, type FileOperation } from "@/platform/fileOps";
import { tx } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { loadSettings } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
@@ -80,8 +81,12 @@ async function handle(op: FileOperation) {
const n = files.length;
await runOp(
op.kind === "created"
? `Add ${n} file${n === 1 ? "" : "s"} to Git`
: `Remove ${n} file${n === 1 ? "" : "s"} from Git`,
? n === 1
? tx("Add 1 file to Git")
: tx`Add ${n} files to Git`
: n === 1
? tx("Remove 1 file from Git")
: tx`Remove ${n} files from Git`,
() => call(commands.vcsStage(files)),
);
}
+2 -2
View File
@@ -31,7 +31,7 @@ export function openMergeEditor(path: string) {
async function acceptSide(f: FileStatus, side: "ours" | "theirs") {
const path = stripSlash(f.path);
await runOp(
`Accept ${side === "ours" ? "yours" : "theirs"} for ${baseName(path)}`,
side === "ours" ? tx`Accept yours for ${baseName(path)}` : tx`Accept theirs for ${baseName(path)}`,
async () => {
const v = await call(commands.vcsConflictVersions(path));
const text = side === "ours" ? v.ours : v.theirs;
@@ -43,7 +43,7 @@ async function acceptSide(f: FileStatus, side: "ours" | "theirs") {
}
await call(commands.vcsStage([path]));
},
{ success: `${baseName(path)} resolved` },
{ success: tx`${baseName(path)} resolved` },
);
}
+4 -4
View File
@@ -238,9 +238,9 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
await saveAll();
const target = head ?? "HEAD";
await runOp(
`Merge ${branch} into ${target}`,
tx`Merge ${branch} into ${target}`,
() => call(commands.vcsMergeWith(repo, [branch], mergeOptionsOf(flags, withMessage ? message : null))),
{ success: `Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead },
{ success: tx`Merged ${branch} into ${target}`, errorActions: conflictActions, onError: openConflictsInstead },
);
await refreshNow();
};
@@ -313,7 +313,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
const target = useOnto && onto ? onto : upstream;
const current = head ?? "HEAD";
await runOp(
`Rebase ${current} onto ${target}`,
tx`Rebase ${current} onto ${target}`,
() =>
call(
commands.vcsRebaseWith(repo, upstream, {
@@ -322,7 +322,7 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
keepEmpty,
}),
),
{ success: `Rebased ${current} onto ${target}`, errorActions: conflictActions, onError: openConflictsInstead },
{ success: tx`Rebased ${current} onto ${target}`, errorActions: conflictActions, onError: openConflictsInstead },
);
await refreshNow();
};
+2 -2
View File
@@ -184,7 +184,7 @@ export async function getFromRevision(changes: readonly LogChange[]) {
if (!ok) return;
await saveAll();
await runOp(
"Get from Revision",
tx("Get from Revision"),
async () => {
for (const c of changes) {
const path = absolutePath(c.path, c.repo);
@@ -211,7 +211,7 @@ export async function revertSelectedChanges(changes: readonly LogChange[], commi
const ids = [...commits].reverse().map((c) => c.id);
const paths = new Set(changes.flatMap((c) => (c.oldPath ? [c.path, c.oldPath] : [c.path])));
const outcome = await runOp(
"Revert selected changes",
tx("Revert selected changes"),
async () => {
const patch = filterPatch(
await call(
+10 -10
View File
@@ -165,7 +165,7 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e
{
progress,
success,
failure: `${controlLabel(op, control)} failed`,
failure: tx`${controlLabel(op, control)} failed`,
onError: control === "abort" ? undefined : conflictsInstead,
},
);
@@ -241,8 +241,8 @@ export async function cherryPick(commits: Commit[]) {
{ op: "cherryPick", commits: ordered.map((c) => c.id) },
{
progress: "Cherry-picking",
success: `Cherry-picked ${label}`,
failure: "Cherry-pick failed",
success: tx`Cherry-picked ${label}`,
failure: tx("Cherry-pick failed"),
onError: conflictsInstead,
},
commitRepo(commits[0]!) ?? currentRepo(),
@@ -256,7 +256,7 @@ export async function revertCommits(commits: Commit[]) {
ordered.length === 1 ? `${short(ordered[0]!.id)} “${ordered[0]!.summary}”` : `${ordered.length} commits`;
await runGitOperation(
{ op: "revert", commits: ordered.map((c) => c.id) },
{ progress: "Reverting", success: `Reverted ${label}`, failure: "Revert failed", onError: conflictsInstead },
{ progress: "Reverting", success: tx`Reverted ${label}`, failure: tx("Revert failed"), onError: conflictsInstead },
commitRepo(commits[0]!) ?? currentRepo(),
);
}
@@ -294,8 +294,8 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void
{ op: "reset", commit: rev, mode },
{
progress: "Resetting",
success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`,
failure: "Reset failed",
success: tx`Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`,
failure: tx("Reset failed"),
},
repo,
);
@@ -374,8 +374,8 @@ export async function newTagOn(commit: Commit) {
});
if (!r) return;
const create = () => call(commands.vcsCreateTag(repo, r.value, commit.id, null, false));
const ok = await runOp(`Create tag ${r.value}`, create, {
success: `Created tag ${r.value} on ${short(commit.id)}`,
const ok = await runOp(tx`Create tag ${r.value}`, create, {
success: tx`Created tag ${r.value} on ${short(commit.id)}`,
});
// Tags are not part of the refs signature that reloads the log.
if (ok !== undefined) void reloadLog();
@@ -402,9 +402,9 @@ function TagDialog({ onClose }: { onClose: () => void }) {
onClose();
const tag = name.trim();
const ok = await runOp(
`Create tag ${tag}`,
tx`Create tag ${tag}`,
() => call(commands.vcsCreateTag(repo, tag, commit.trim() || "HEAD", message || null, force)),
{ success: `Created tag ${tag}` },
{ success: tx`Created tag ${tag}` },
);
if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog();
};
+1 -1
View File
@@ -89,7 +89,7 @@ export async function runOp<T>(title: string, fn: () => Promise<T>, opts: OpOpti
notifications().notify({
group: "vcs",
level: "error",
title: `${title} failed`,
title: tx`${title} failed`,
body: message,
actions: opts.errorActions?.(message),
});
+2 -2
View File
@@ -116,7 +116,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
reverse,
};
const done = await runOp(
"Create patch",
tx("Create patch"),
async () => {
const patch =
origin.kind === "shelf"
@@ -250,7 +250,7 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s
const run = async () => {
onClose();
await saveAll();
const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), {
const outcome = await runOp(tx`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), {
refresh: false,
});
if (outcome) await reportApply(outcome, "Patch applied");
+3 -3
View File
@@ -62,8 +62,8 @@ export async function undoCommit(commit: Commit) {
if (!ok) return;
}
const undo = () => call(commands.vcsUndoCommit(repo, commit.id));
const message = await runOp(`Undo commit ${short(commit.id)}`, undo, {
success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
const message = await runOp(tx`Undo commit ${short(commit.id)}`, undo, {
success: tx`Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
});
// IDEA offers the undone message for the next commit.
if (message !== undefined && !useCommit.getState().message.trim()) useCommit.setState({ message });
@@ -123,7 +123,7 @@ async function runRebase(base: string | null, entries: RebaseEntry[], done: stri
{
progress: "Rebasing",
success: () => (inProgress() === "rebase" ? "Rebase stopped for editing; continue the rebase when done" : done),
failure: "Rebase failed",
failure: tx("Rebase failed"),
onError: conflictsInstead,
},
repo,
+5 -5
View File
@@ -144,7 +144,7 @@ function ShelveDialog({
if (!chosen.length || !name.trim()) return;
onClose();
await saveAll();
const entry = await runOp(`Shelve changes “${name.trim()}”`, () => call(commands.vcsShelve(name.trim(), chosen)));
const entry = await runOp(tx`Shelve changes “${name.trim()}”`, () => call(commands.vcsShelve(name.trim(), chosen)));
if (!entry) return;
void loadShelves().catch(() => undefined);
notifications().notify({
@@ -218,7 +218,7 @@ function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; pa
onClose();
await saveAll();
const outcome = await runOp(
`Unshelve “${entry.name}”`,
tx`Unshelve “${entry.name}”`,
() => call(commands.vcsUnshelve(entry.id, remove, path ?? null)),
{
refresh: false,
@@ -272,7 +272,7 @@ export async function deleteShelf(entry: ShelvedChangeList, path?: string) {
danger: true,
});
if (!ok) return;
await runOp(`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id, path ?? null)), {
await runOp(tx`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id, path ?? null)), {
refresh: false,
});
void loadShelves().catch(() => undefined);
@@ -286,7 +286,7 @@ export async function renameShelf(entry: ShelvedChangeList) {
okLabel: "Rename",
});
if (!r || r.value === entry.name) return;
await runOp("Rename shelved changes", () => call(commands.vcsShelfRename(entry.id, r.value)), { refresh: false });
await runOp(tx("Rename shelved changes"), () => call(commands.vcsShelfRename(entry.id, r.value)), { refresh: false });
void loadShelves().catch(() => undefined);
}
@@ -388,7 +388,7 @@ export async function importShelfPatches(paths?: string[]) {
if (!picked) return;
for (const path of typeof picked === "string" ? [picked] : picked) {
await runOp(
"Import patches",
tx("Import patches"),
async () => call(commands.vcsShelfImport(repo, baseName(path), await call(commands.fsReadText(path)))),
{ refresh: false },
);
+13
View File
@@ -92,6 +92,19 @@ describe("color schemes", () => {
root.dataset.theme = "dark";
await Promise.resolve();
expect(scheme()).toBe("dark");
// A scheme chosen in the same change as the theme (one Apply) goes with it.
await new Promise((r) => setTimeout(r));
useProject.setState({ settings: { editor: { colorScheme: "solarized-dark" } } as unknown as Settings });
root.dataset.theme = "light";
await Promise.resolve();
expect(scheme()).toBe("solarized-dark");
// A later theme change on its own still switches it back.
await new Promise((r) => setTimeout(r));
root.dataset.theme = "dark";
await Promise.resolve();
root.dataset.theme = "light";
await Promise.resolve();
expect(scheme()).toBe("theme-default");
} finally {
dispose();
delete root.dataset.colorTheme;
+69 -4
View File
@@ -1478,7 +1478,7 @@
"Passed as -Dmaven.repo.local.": "以 -Dmaven.repo.local 传入。",
"Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。",
"Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseG1GC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx4g。jdtls 默认使用 -XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Xmx2G -Xms100m 运行;此处设置的选项会替换同一设置的默认值,指定其他垃圾回收器(例如 -XX:+UseG1GC)时会替换默认的回收器选项。会自动添加 Lombok 代理:优先使用在“语言服务器”中安装的版本;否则当项目使用 Lombok(构建文件、继承的父 POM 或 Gradle 构建、源码中的 import)时,使用 Maven / Gradle 缓存中最新的版本。",
"Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx4g. jdtls runs with -XX:+UseG1GC -XX:G1PeriodicGCInterval=60000 -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30 -Xmx2G -Xms100m by default; an option set here replaces the default for the same setting, and another collector (e.g. -XX:+UseZGC) replaces the default collector options. A Lombok agent is added automatically: the one installed in Language Servers, else, when the project uses Lombok (its build, an inherited parent POM or Gradle build, or its sources' imports), the newest from the Maven / Gradle caches.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx4g。jdtls 默认使用 -XX:+UseG1GC -XX:G1PeriodicGCInterval=60000 -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30 -Xmx2G -Xms100m 运行;此处设置的选项会替换同一设置的默认值,指定其他垃圾回收器(例如 -XX:+UseZGC)时会替换默认的回收器选项。会自动添加 Lombok 代理:优先使用在“语言服务器”中安装的版本;否则当项目使用 Lombok(构建文件、继承的父 POM 或 Gradle 构建、源码中的 import)时,使用 Maven / Gradle 缓存中最新的版本。",
"Pop stash": "弹出储藏",
"Preview Source": "预览源代码",
"Reinstate index": "恢复索引",
@@ -3420,13 +3420,11 @@
"Remove all networks not used by a container?": "移除所有未被容器使用的网络?",
"Remove stopped containers, unused networks, dangling images and the build cache?": "移除已停止的容器、未使用的网络、悬空镜像和构建缓存?",
"Prune {target}": "清理{target}",
"Pruning {target}…": "正在清理 {target}…",
"Pulling {name}…": "正在拉取 {name}…",
"containers": "容器",
"images": "镜像",
"volumes": "卷",
"networks": "网络",
"unused images": "未使用的镜像",
"Compose project '{name}'": "Compose 项目 '{name}'",
"Engine {server} · CLI {client}": "引擎 {server} · CLI {client}",
" · context {name}": " · 上下文 {name}",
@@ -5847,5 +5845,72 @@
"Could not remove network": "无法删除网络",
"Could not pull {reference}": "无法拉取 {reference}",
"Could not prune": "无法清理",
"Compose {op} failed for {project}": "Compose {op} 在 {project} 上失败"
"Pruning stopped containers…": "正在清理已停止的容器…",
"Pruning dangling images…": "正在清理悬空镜像…",
"Pruning unused images…": "正在清理未使用的镜像…",
"Pruning unused volumes…": "正在清理未使用的卷…",
"Pruning unused networks…": "正在清理未使用的网络…",
"Pruning the system…": "正在清理系统…",
"Starting Compose project {project}…": "正在启动 Compose 项目 {project}…",
"Stopping Compose project {project}…": "正在停止 Compose 项目 {project}…",
"Restarting Compose project {project}…": "正在重启 Compose 项目 {project}…",
"Taking down Compose project {project}…": "正在停止并移除 Compose 项目 {project}…",
"Could not start Compose project {project}": "无法启动 Compose 项目 {project}",
"Could not stop Compose project {project}": "无法停止 Compose 项目 {project}",
"Could not restart Compose project {project}": "无法重启 Compose 项目 {project}",
"Could not take down Compose project {project}": "无法停止并移除 Compose 项目 {project}",
"Stage hunk of {path}": "暂存 {path} 的块",
"{path} reverted": "已还原 {path}",
"{length} files reverted": "已还原 {length} 个文件",
"Resolve {path}": "解决 {path}",
"{path}: conflicts resolved": "{path}:冲突已解决",
"Create branch {value}": "创建分支 {value}",
"Merge {branch} into {current}": "将 {branch} 合并到 {current}",
"Merged {branch} into {current}": "已将 {branch} 合并到 {current}",
"Rebase {current} onto {branch}": "将 {current} 变基到 {branch}",
"Rebased {current} onto {branch}": "已将 {current} 变基到 {branch}",
"Rename branch {name}": "重命名分支 {name}",
"Renamed branch {name} to {to}": "已将分支 {name} 重命名为 {to}",
"Delete branch {name}": "删除分支 {name}",
"Restore branch {name}": "恢复分支 {name}",
"Merge {branch} ({root})": "合并 {branch}({root})",
"Rebase onto {branch} ({root})": "变基到 {branch}({root})",
"Delete branch {name} ({root})": "删除分支 {name}({root})",
"1 file amended in {several} repositories": "已在 {several} 个仓库中修改 1 个文件的提交",
"{n} files amended in {several} repositories": "已在 {several} 个仓库中修改 {n} 个文件的提交",
"1 file amended": "已修改 1 个文件的提交",
"{n} files amended": "已修改 {n} 个文件的提交",
"1 file committed in {several} repositories": "已在 {several} 个仓库中提交 1 个文件",
"{n} files committed in {several} repositories": "已在 {several} 个仓库中提交 {n} 个文件",
"1 file committed": "已提交 1 个文件",
"{n} files committed": "已提交 {n} 个文件",
"Rolled back 1 file": "已回滚 1 个文件",
"Rolled back {length} files": "已回滚 {length} 个文件",
"Add 1 file to Git": "将 1 个文件添加到 Git",
"Add {length} files to Git": "将 {length} 个文件添加到 Git",
"Stash changes": "储藏更改",
"Changes stashed": "已储藏更改",
"Unstashed {ref} on branch {branch}": "已在分支 {branch} 上取消储藏 {ref}",
"Unstashed {ref}": "已取消储藏 {ref}",
"Applied {ref}": "已应用 {ref}",
"Dropped {ref}": "已删除 {ref}",
"Remove 1 file from Git": "从 Git 中移除 1 个文件",
"Remove {n} files from Git": "从 Git 中移除 {n} 个文件",
"Accept yours for {path}": "对 {path} 接受你的版本",
"Accept theirs for {path}": "对 {path} 接受他们的版本",
"{path} resolved": "{path} 已解决",
"Revert selected changes": "还原所选更改",
"Create tag {value}": "创建标签 {value}",
"Created tag {value} on {id}": "已在 {id} 上创建标签 {value}",
"Created tag {tag}": "已创建标签 {tag}",
"Create patch": "创建补丁",
"Apply patch ({source})": "应用补丁({source})",
"Undo commit {id}": "撤销提交 {id}",
"Undid commit {id} “{summary}”; its changes are staged": "已撤销提交 {id}“{summary}”,其更改已暂存",
"Shelve changes “{trim}”": "搁置更改“{trim}”",
"Unshelve “{name}”": "取消搁置“{name}”",
"Delete shelved changes “{name}”": "删除搁置的更改“{name}”",
"Rename shelved changes": "重命名搁置的更改",
"Passed to the agent process, replacing variables of the same name. Changes apply to new sessions.": "传给智能体进程,覆盖同名变量。对新会话生效。",
"Expected NAME=value: {pair}": "应为 NAME=value:{pair}"
}