diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 66786c1d..a65400a6 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -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) -> 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()); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index a6a96e77..41cf2875 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -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, /// Whether the command was found (on `PATH`). pub installed: bool, + /// Environment variables it starts with (from its agent file). + pub env: BTreeMap, /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. pub config: Vec, /// Slash commands it offered last time. diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 0b39eade..410bb89e 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -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) -> 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(); diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 758cebb9..9662dc07 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option, +) -> IdeResult<()> { + host.require_trust(window)?; + host.project(window)?.agents.set_builtin_env(&id, env) +} + /// Where ACP Registry agents are saved (`/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 diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index d024fd0c..472411a3 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -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> { + 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 }); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 35139a57..b052cd72 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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")); diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index b9bbaefa..c8a2c36c 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -52,11 +52,16 @@ fn local_folder(local: &str, project_root: &Path) -> Option { { 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"))); } diff --git a/crates/ide-sdk/src/jdtls.rs b/crates/ide-sdk/src/jdtls.rs index 937aeff5..6bd8ae15 100644 --- a/crates/ide-sdk/src/jdtls.rs +++ b/crates/ide-sdk/src/jdtls.rs @@ -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 { 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) { } /// 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 { let parsed = split_script_args(script_args); let mut args: Vec = 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 = 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(); diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 0871150d..40a61920 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -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 { + 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 = + 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(); diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index cf870663..f26c37bf 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -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, +) -> 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] diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 9f661d5b..7daaf11e 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -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> { + 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] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 16e6d465..af70e0da 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -98,6 +98,7 @@ pub fn builder() -> tauri_specta::Builder { 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 { 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, diff --git a/src/bindings.ts b/src/bindings.ts index 17ffc6c1..3367ae16 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__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(__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) => typedError(__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(__TAURI_INVOKE("gradle_published_catalog", { coordinates })), /** Where the direct dependency `dependency` of the Maven module `pom` is declared. */ mavenDependencyDeclaration: (pom: string, dependency: ArtifactKey) => typedError(__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. */ diff --git a/src/editor/core/colorSchemeRuntime.ts b/src/editor/core/colorSchemeRuntime.ts index 41e3a68f..443dc91c 100644 --- a/src/editor/core/colorSchemeRuntime.ts +++ b/src/editor/core/colorSchemeRuntime.ts @@ -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 | 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"); diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 760a5eb2..13a4c879 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -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(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({ )}

)} + {agent.commandReadOnlyReason !== "registry" && agent.commandReadOnlyReason !== "customAgent" && ( + <> + +
+ env.set(e.target.value)} + /> +
+

+ Passed to the agent process, replacing variables of the same name. Changes apply to new sessions. +

+ + )} {agent.loginHint && ( <>

@@ -193,6 +221,25 @@ function Details({ ); } +/** `NAME=value` pairs separated by `;` or new lines. */ +function parseEnv(text: string): Record { + const env: Record = {}; + 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) => + 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(); diff --git a/src/features/agents/agentRegistry.test.tsx b/src/features/agents/agentRegistry.test.tsx index 98624633..76aadae1 100644 --- a/src/features/agents/agentRegistry.test.tsx +++ b/src/features/agents/agentRegistry.test.tsx @@ -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][] = []; + async setEnv(id: string, env: Record) { + 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 = [ diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index d4d38312..a0675ba2 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -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")], }; diff --git a/src/features/agents/registry.ts b/src/features/agents/registry.ts index 60cb80b8..04780dd4 100644 --- a/src/features/agents/registry.ts +++ b/src/features/agents/registry.ts @@ -22,6 +22,7 @@ export interface RegistryBackend { export interface CommandsBackend { providers(): Promise; set(id: string, command: string | null): Promise; + setEnv(id: string, env: Record): Promise; chooseExecutable(title: string, current?: string): Promise; } @@ -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 = { diff --git a/src/features/build-tools/versionCatalog.test.ts b/src/features/build-tools/versionCatalog.test.ts index bc2c695a..24968aa5 100644 --- a/src/features/build-tools/versionCatalog.test.ts +++ b/src/features/build-tools/versionCatalog.test.ts @@ -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 = { + "/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); + }); }); diff --git a/src/features/build-tools/versionCatalog.ts b/src/features/build-tools/versionCatalog.ts index 64330a12..803ad380 100644 --- a/src/features/build-tools/versionCatalog.ts +++ b/src/features/build-tools/versionCatalog.ts @@ -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 { +/** + * 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 { 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(); @@ -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) { diff --git a/src/features/services/store.ts b/src/features/services/store.ts index 27d1b835..a9f5bd8f 100644 --- a/src/features/services/store.ts +++ b/src/features/services/store.ts @@ -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)), ); } diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index 8357c230..aef869b3 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -297,7 +297,7 @@ export function JavaPage() { call(commands.vcsStageHunks(path, indices))); + await runOp(tx`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices))); await refreshNow(); } finally { setBusy(false); diff --git a/src/features/vcs/LocalHistory.tsx b/src/features/vcs/LocalHistory.tsx index da3c35f9..2c467bcf 100644 --- a/src/features/vcs/LocalHistory.tsx +++ b/src/features/vcs/LocalHistory.tsx @@ -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, }, ); diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 0d5c7796..6244d82a 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -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); diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index b5f4adb0..6bd3f894 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -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 { 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}`, diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index be93de05..a50adc30 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -90,8 +90,23 @@ export async function commitChanges(): Promise { 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 { 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))); } diff --git a/src/features/vcs/confirmations.tsx b/src/features/vcs/confirmations.tsx index e71a546c..b0a0ab7c 100644 --- a/src/features/vcs/confirmations.tsx +++ b/src/features/vcs/confirmations.tsx @@ -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)), ); } diff --git a/src/features/vcs/conflictsUi.tsx b/src/features/vcs/conflictsUi.tsx index c24969bd..2777e3ea 100644 --- a/src/features/vcs/conflictsUi.tsx +++ b/src/features/vcs/conflictsUi.tsx @@ -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` }, ); } diff --git a/src/features/vcs/gitMenuDialogs.tsx b/src/features/vcs/gitMenuDialogs.tsx index 12171251..cc19fb22 100644 --- a/src/features/vcs/gitMenuDialogs.tsx +++ b/src/features/vcs/gitMenuDialogs.tsx @@ -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(); }; diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index 87e1b0d2..c292d945 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -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( diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index d0a9bbe0..51ac6ec4 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -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(); }; diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index db134452..504dd309 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -89,7 +89,7 @@ export async function runOp(title: string, fn: () => Promise, opts: OpOpti notifications().notify({ group: "vcs", level: "error", - title: `${title} failed`, + title: tx`${title} failed`, body: message, actions: opts.errorActions?.(message), }); diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index ab3a5883..0f4ee082 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -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"); diff --git a/src/features/vcs/rebase.tsx b/src/features/vcs/rebase.tsx index 044b0218..a6a74155 100644 --- a/src/features/vcs/rebase.tsx +++ b/src/features/vcs/rebase.tsx @@ -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, diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 9e808b91..07d8bf59 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -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 }, ); diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index 67cecc22..08e95786 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -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; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ed0d5867..4a0dfc89 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}" }