mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Merge remote-tracking branch 'origin/main' into review/pr1147
This commit is contained in:
@@ -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,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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` },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user