Compare commits

...
Author SHA1 Message Date
l0ng-ai 764ce2ee0e fix(vcs): Route a nested repository's own entry to the enclosing one and harden multi-root
Review fixes for several repositories in one project:

- A path equal to a nested repository's root (a submodule's entry, an untracked
  clone) belongs to the enclosing repository, in the backend and the Commit view.
  It was routed to the nested repository as ".", so Rollback ran `git restore` and
  `git clean` over the whole submodule and Commit staged everything in it.
- Repository-scoped commands take the repository root exactly and fail when it is
  gone instead of running in an enclosing repository; path-based ones resolve the
  file's repository (Project::with_repo_of).
- The rescan on a repository appearing or going watches `.git/HEAD`: the watcher
  never reports a `.git` path itself, so the old check never fired.
- Status skips a repository that cannot be read; refresh falls back to the primary
  repository when the selected one is gone instead of failing every time.
- Commit and Compare-with-Local diffs resolve repository-relative paths against the
  selected repository's root, not the project root.
- Update Project and Fetch go on after a failing repository and report the failures
  together; Update skips repositories with a detached HEAD or no upstream.
- The Push dialog leaves out a repository that cannot be read.
- Shelve and patches fail clearly for changes of a nested repository instead of
  leaving them out silently (they still support the main repository only).
- Fix the repository discovery test on Windows (path separators); Chinese text for
  the new strings.
2026-10-23 23:01:07 +08:00
l0ng-ai 48ebbe15e7 fix(java): Apply the Maven settings to jdtls and Lombok, find more Maven installs
Review fixes for the Project JDK / Maven settings:

- jdtls gets the user settings.xml as java.configuration.maven.userSettings, so the
  editor resolves dependencies through the same mirrors and servers as sync and builds.
- The configured local repository (else <localRepository> of the chosen settings.xml)
  is where the static POM model looks for parent POMs / BOMs and where the Lombok
  agent is looked for.
- Maven discovery finds mvn.cmd on PATH on Windows and the homes Maven Wrapper 3.3+
  downloads (dists/<name>/<hash> without a nested folder).
- A Project JDK pointing at the JDK's bin directory is accepted.
- Detected run configurations reload when language settings change, so Maven and
  Gradle runs use the new JDK, Maven home and options.
- Chinese text for the new settings.
2026-10-23 23:01:07 +08:00
l0ng-ai 825f190030 feat(vcs): Support several git repositories in one project (multi-root)
Only the repository containing the project root was known, so nested clones and
submodules (or a folder of side-by-side service repositories) showed no changes and
could not be committed or pushed. The project now has IDEA's VCS roots: the enclosing
repository plus those found below the project (scanned in the background, rescanned when
a .git appears or goes). Status covers all of them; path-based commands go to the file's
repository and repository-scoped ones take the selected repository.

The Commit view groups changes by repository and commits each one with included changes;
the branch widget names the selected repository and switches between them; Update Project
and Fetch run in every repository; Push lists every repository with outgoing commits; a
repository that stops in a merge or rebase is selected so Continue / Abort act on it.

Closes #12
2026-10-23 23:01:07 +08:00
l0ng-ai 7fa1d08b94 feat(java): Add Project JDK and Maven home / settings.xml / local repository settings
The JDK was always picked automatically (JAVA_HOME, PATH, the usual install dirs, by the
module's Java version) and Maven was the project's mvnw or mvn on PATH, with no way to
override either. languages.java.jdk (Languages & Frameworks › Java › Project JDK) now sets
the default jdtls runtime, the JDK of Run/Debug and JAVA_HOME for Maven and Gradle;
languages.java.maven (Build Tools › Maven) sets the Maven home, -s and
-Dmaven.repo.local for sync, builds, tool-window goals and detected run configurations.
JDKs and Maven installations are detected for the pickers.

Closes #13
2026-10-23 23:00:48 +08:00
58 changed files with 1799 additions and 245 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ fn main() {
eprintln!("synced in {:?}: {r:?}", started.elapsed());
}
let started = std::time::Instant::now();
let model = ide_build::load(&root, &cache);
let model = ide_build::load(&root, &cache, None);
println!("{}", serde_json::to_string_pretty(&model).unwrap());
eprintln!("loaded in {:?}", started.elapsed());
}
+3 -2
View File
@@ -225,6 +225,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -671,7 +672,7 @@ mod tests {
);
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
write(&root, "core/src/main/java/A.java", "class A {}");
let model = crate::load(&root, &tmp.path().join("cache"));
let model = crate::load(&root, &tmp.path().join("cache"), None);
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
@@ -685,7 +686,7 @@ mod tests {
let g = tmp.path().join("g");
write(&g, "settings.gradle", "include 'app'");
write(&g, "app/build.gradle", "apply plugin: 'java'");
let model = crate::load(&g, &tmp.path().join("cache2"));
let model = crate::load(&g, &tmp.path().join("cache2"), None);
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
assert_eq!(p.system, BuildSystem::Gradle);
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
+6 -5
View File
@@ -18,7 +18,7 @@ pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -36,9 +36,10 @@ pub struct BuildModel {
}
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
pub fn load(root: &Path, cache_dir: &Path) -> BuildModel {
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
let maven = maven::is_maven_project(root).then(|| {
let mut m = maven::load(root);
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
sync::apply_maven_cache(root, cache_dir, &mut m);
m
});
@@ -68,10 +69,10 @@ mod tests {
let cache = tmp.path().join("cache");
let root = tmp.path().join("p");
std::fs::create_dir_all(root.join("svc")).unwrap();
assert_eq!(load(&root, &cache), BuildModel::default());
assert_eq!(load(&root, &cache, None), BuildModel::default());
std::fs::write(root.join("svc/pom.xml"), "<project><artifactId>svc</artifactId></project>").unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
let m = load(&root, &cache);
let m = load(&root, &cache, None);
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
+4 -1
View File
@@ -92,6 +92,8 @@ pub struct MavenProject {
pub java_version: Option<u32>,
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
pub command: String,
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
pub options: Vec<String>,
/// Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell.
pub env: BTreeMap<String, String>,
}
@@ -410,7 +412,7 @@ struct Resolver {
}
/// `~/.m2/repository`, or `<localRepository>` from `~/.m2/settings.xml`.
fn default_local_repo() -> Option<PathBuf> {
pub(crate) fn default_local_repo() -> Option<PathBuf> {
let m2 = dirs::home_dir()?.join(".m2");
if let Some(settings) = std::fs::read_to_string(m2.join("settings.xml")).ok().and_then(|t| xml::parse(&t))
&& let Some(repo) = settings.text_of("localRepository")
@@ -924,6 +926,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
profiles: pom.profiles.iter().map(|p| p.id.clone()).collect(),
java_version: jv,
command: wrapper(root, &dir),
options: Vec::new(),
env: BTreeMap::new(),
});
for m in modules.into_iter().rev() {
+7 -1
View File
@@ -29,6 +29,10 @@ pub struct SyncOptions {
pub profiles: Vec<String>,
/// Extra environment (`JAVA_HOME`).
pub env: BTreeMap<String, String>,
/// Maven launcher replacing the project's `mvnw` / `mvn` (Settings › Maven home).
pub maven_command: Option<String>,
/// Maven options before the goals (`-s`, `-Dmaven.repo.local=`).
pub maven_options: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -184,6 +188,7 @@ pub fn sync_maven(
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
let _ = std::fs::remove_file(&effective);
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
args.extend(options.maven_options.iter().cloned());
if options.offline {
args.push("-o".into());
}
@@ -194,7 +199,8 @@ pub fn sync_maven(
args.push(format!("-Doutput={}", effective.to_string_lossy()));
args.push("dependency:tree".into());
progress(SyncProgress { message: format!("Reading {}", top.display_name), fraction: None });
let out = run(&top.command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
let command = options.maven_command.as_deref().unwrap_or(&top.command);
let out = run(command, &args, &root.join(&top.dir), &options.env, cancel, |line| {
if let Some((name, i, total)) = maven_building(line) {
let fraction = (i.saturating_sub(1) as f32 / total.max(1) as f32).min(1.0);
progress(SyncProgress { message: format!("Building model of {name}"), fraction: Some(fraction) });
+17
View File
@@ -401,9 +401,26 @@ pub struct GoSettings {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Build Tools › Maven.
pub maven: MavenSettings,
}
/// Build Tools › Maven.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+75 -1
View File
@@ -56,6 +56,55 @@ fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
setting_path(root, &s.languages.go.goroot)
}
/// The project JDK (`languages.java.jdk`): a JDK home, its `bin` or its `bin/java`.
pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
let p = setting_path(root, &s.languages.java.jdk)?;
let jdk = p.ancestors().take(3).find_map(crate::java::jdk_at);
if jdk.is_none() {
tracing::warn!("project JDK {} is not a JDK home", p.display());
}
jdk
}
/// `mvn` of the configured Maven home (`languages.java.maven.home`): the
/// executable itself, `<home>/bin/mvn` or `<bin>/mvn`.
pub fn maven_command(root: &Path, s: &Settings) -> Option<PathBuf> {
let p = setting_path(root, &s.languages.java.maven.home)?;
let name = if cfg!(windows) { "mvn.cmd" } else { "mvn" };
let found = [p.clone(), p.join("bin").join(name), p.join(name)].into_iter().find(|c| c.is_file());
if found.is_none() {
tracing::warn!("Maven home {} has no {name}", p.display());
}
found
}
/// Maven options from settings, placed before the goals: `-s <settings.xml>`
/// and `-Dmaven.repo.local=<dir>`.
pub fn maven_options(root: &Path, s: &Settings) -> Vec<String> {
let m = &s.languages.java.maven;
let mut out = Vec::new();
if let Some(file) = setting_path(root, &m.user_settings) {
out.extend(["-s".to_owned(), file.to_string_lossy().into_owned()]);
}
if let Some(dir) = setting_path(root, &m.local_repository) {
out.push(format!("-Dmaven.repo.local={}", dir.to_string_lossy()));
}
out
}
/// The Maven local repository from settings: the configured one, else
/// `<localRepository>` of the configured user `settings.xml`; `None` = Maven's default.
pub fn maven_local_repository(root: &Path, s: &Settings) -> Option<PathBuf> {
let m = &s.languages.java.maven;
setting_path(root, &m.local_repository).or_else(|| {
let text = std::fs::read_to_string(setting_path(root, &m.user_settings)?).ok()?;
let repo = crate::java::xml_value(&crate::lombok::strip_xml_comments(&text), "localRepository")?;
let home = crate::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
let repo = repo.replace("${user.home}", &home);
(!repo.is_empty() && !repo.contains("${")).then(|| PathBuf::from(repo))
})
}
/// TypeScript `lib` directory for the selected version; `None` = the server's bundled one.
pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
match s.languages.typescript.version.trim() {
@@ -170,6 +219,14 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
// The jdtls launcher forwards `--jvm-arg=` values to the JVM it starts.
let args = s.languages.java.jdtls_vm_args.split_whitespace().map(|a| format!("--jvm-arg={a}"));
cfg.args.extend(args);
// m2e (the Maven import) reads mirrors, servers and `<localRepository>` from it.
if let Some(file) = setting_path(root, &s.languages.java.maven.user_settings) {
let file = file.to_string_lossy();
merge_init_options(
cfg,
json!({ "settings": { "java": { "configuration": { "maven": { "userSettings": file } } } } }),
);
}
}
if let Some(lib) = tsdk(server_root, s).or_else(|| tsdk(root, s)) {
@@ -206,7 +263,8 @@ const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"];
/// Points run configurations at the selected toolchains: Python configs use the
/// project interpreter, npm scripts the chosen package manager and Node.js,
/// cargo / go configs the selected toolchain.
/// cargo / go configs the selected toolchain, Maven / Gradle the project JDK
/// (and Maven the configured home and options).
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
let explicit_python = setting_path(root, &s.languages.python.interpreter);
let python = python_interpreter(root, s);
@@ -215,6 +273,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
let rust = rust_bin(root, s);
let go = goroot(root, s);
let gopath = setting_path(root, &s.languages.go.gopath);
let jdk = project_jdk(root, s);
let mvn = maven_command(root, s);
let mvn_options = maven_options(root, s);
for c in configs.iter_mut() {
let mut path_dirs: Vec<PathBuf> = Vec::new();
@@ -281,6 +342,19 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned());
}
}
"maven" | "gradle" => {
if let Some(jdk) = &jdk {
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
}
if c.kind == "maven" && c.detected {
if let Some(mvn) = &mvn {
c.command = mvn.to_string_lossy().into_owned();
}
if !c.args.starts_with(&mvn_options) {
c.args.splice(0..0, mvn_options.iter().cloned());
}
}
}
_ => {}
}
if !path_dirs.is_empty() && !c.env.contains_key("PATH") {
+107
View File
@@ -18,6 +18,8 @@ pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec<Sdk> {
SdkKind::Rust => rust(),
SdkKind::Go => go(),
SdkKind::TypeScript => typescript(root),
SdkKind::Jdk => jdk(),
SdkKind::Maven => maven(),
}
}
@@ -382,3 +384,108 @@ fn typescript(root: Option<&Path>) -> Vec<Sdk> {
}
out
}
// ------------------------------------------------------------------ jdk
/// `KEY="value"` of a JDK `release` file.
fn release_value(release: &str, key: &str) -> Option<String> {
let line = release.lines().find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('='))?;
Some(line.trim().trim_matches('"').to_owned()).filter(|v| !v.is_empty())
}
fn jdk() -> Vec<Sdk> {
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok());
crate::java::rescan_jdks()
.iter()
.map(|j| {
let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default();
let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string());
let name = match release_value(&release, "IMPLEMENTOR") {
Some(vendor) => format!("{vendor} {version}"),
None => format!("JDK {version}"),
};
let source = if java_home.as_ref() == Some(&j.home) { "JAVA_HOME" } else { "system" };
Sdk {
path: j.home.clone().into(),
name,
version: Some(version),
source: source.to_owned(),
home: Some(j.home.clone().into()),
extra: None,
}
})
.collect()
}
// ------------------------------------------------------------------ maven
/// Maven version from `lib/maven-core-<version>.jar`.
fn maven_version(home: &Path) -> Option<String> {
std::fs::read_dir(home.join("lib")).ok()?.flatten().find_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
Some(name.strip_prefix("maven-core-")?.strip_suffix(".jar")?.to_owned())
})
}
/// A Maven home (`bin/mvn` and `lib/`); Homebrew's formula keeps it in `libexec`.
fn maven_home(dir: &Path) -> Option<PathBuf> {
let mvn = if cfg!(windows) { "mvn.cmd" } else { "mvn" };
[dir.join("libexec"), dir.to_path_buf()]
.into_iter()
.find(|h| h.join("bin").join(mvn).is_file() && h.join("lib").is_dir())
}
fn maven() -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
// Windows' Maven ships `mvn.cmd` only (`which` looks for `mvn.exe`).
let on_path = if cfg!(windows) {
std::env::split_paths(&search_path()).map(|d| d.join("mvn.cmd")).find(|p| p.is_file())
} else {
which("mvn")
};
if let Some(mvn) = on_path.and_then(|p| dunce::canonicalize(p).ok())
&& let Some(home) = mvn.parent().and_then(Path::parent)
{
found.push((home.to_path_buf(), "path"));
}
for var in ["MAVEN_HOME", "M2_HOME"] {
if let Some(h) = std::env::var_os(var) {
found.push((PathBuf::from(h), "env"));
}
}
for p in ["/opt/homebrew/opt/maven", "/usr/local/opt/maven", "/usr/share/maven", "/opt/maven"] {
found.push((PathBuf::from(p), "system"));
}
if let Some(home) = home_dir() {
for d in subdirs(&home.join(".sdkman/candidates/maven")) {
found.push((d, "sdkman"));
}
// Distributions the Maven wrapper downloaded: `dists/apache-maven-3.9.6-bin/<hash>/apache-maven-3.9.6`,
// or since wrapper 3.3 the home itself, `dists/apache-maven-3.9.6/<hash>`.
for dist in subdirs(&home.join(".m2/wrapper/dists")) {
for hash in subdirs(&dist) {
found.extend(subdirs(&hash).into_iter().map(|d| (d, "wrapper")));
found.push((hash, "wrapper"));
}
}
}
let mut seen = HashSet::new();
let mut out = Vec::new();
for (dir, source) in found {
let Some(home) = maven_home(&dir) else { continue };
let home = dunce::canonicalize(&home).unwrap_or(home);
if !seen.insert(home.clone()) {
continue;
}
let version = maven_version(&home);
out.push(Sdk {
path: home.clone().into(),
name: format!("Maven {}", version.clone().unwrap_or_default()).trim().to_owned(),
version,
source: source.to_owned(),
home: Some(home.into()),
extra: None,
});
}
out
}
+71 -15
View File
@@ -25,7 +25,8 @@ pub fn parse_release(release: &str) -> Option<u32> {
if first == 1 { parts.next()?.parse().ok() } else { Some(first) }
}
fn jdk_at(home: &Path) -> Option<Jdk> {
/// The JDK at `home`, if it is one (a `release` file and `bin/java`).
pub fn jdk_at(home: &Path) -> Option<Jdk> {
let release = std::fs::read_to_string(home.join("release")).ok()?;
if !home.join("bin").join(crate::exe("java")).is_file() {
return None;
@@ -117,6 +118,11 @@ fn rescan(slot: &mut Option<Scan>) -> Arc<[Jdk]> {
slot.insert(Scan { at: Instant::now(), jdks: scan().into() }).jdks.clone()
}
/// Scans for installed JDKs again (Settings › Detect again).
pub fn rescan_jdks() -> Arc<[Jdk]> {
rescan(&mut JDKS.lock().unwrap_or_else(|e| e.into_inner()))
}
/// Installed JDKs (scanned on first use; [`jdtls_jdks`] may refresh them).
pub fn installed_jdks() -> Arc<[Jdk]> {
let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner());
@@ -258,12 +264,15 @@ const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot
/// directory, one per server root, and no Eclipse metadata is written into
/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its
/// launcher reads), installed JDKs are registered as runtimes and `bundles`
/// (e.g. the java-debug plugin jar) are loaded into the server.
/// (e.g. the java-debug plugin jar) are loaded into the server. The project
/// JDK, when set, is the default runtime and preferred for running the server.
#[allow(clippy::too_many_arguments)]
pub fn configure_jdtls(
cfg: &mut LanguageServerConfig,
server_root: &Path,
data_dir: &Path,
jdks: &[Jdk],
project_jdk: Option<&Jdk>,
min_java: u32,
bundles: &[PathBuf],
lombok: Option<&Path>,
@@ -294,6 +303,9 @@ pub fn configure_jdtls(
{
cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()));
}
let jdks: Vec<Jdk> =
project_jdk.into_iter().chain(jdks.iter().filter(|j| Some(*j) != project_jdk)).cloned().collect();
let jdks = jdks.as_slice();
if !cfg.env.contains_key("JAVA_HOME") {
match jdtls_runtime(jdks, min_java) {
Some(jdk) => {
@@ -302,7 +314,15 @@ pub fn configure_jdtls(
None => cfg.launch_error = Some(missing_runtime_message(jdks, min_java)),
}
}
let runtimes = runtimes(jdks);
let mut runtimes = runtimes(jdks);
if let Some(project) = project_jdk {
let home = project.home.to_string_lossy();
for r in &mut runtimes {
if r["path"] == *home {
r["default"] = json!(true);
}
}
}
if !runtimes.is_empty() {
merge_init_options(cfg, json!({ "settings": { "java": { "configuration": { "runtimes": runtimes } } } }));
}
@@ -424,7 +444,16 @@ mod tests {
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None);
configure_jdtls(
&mut cfg,
Path::new("/p/demo"),
Path::new("/data"),
&jdks,
None,
JDTLS_MIN_JAVA,
&bundles,
None,
);
assert_eq!(cfg.args[0], "-data");
assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]);
assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false");
@@ -439,11 +468,38 @@ mod tests {
// Deterministic (reconfigure compares configs) and idempotent.
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None);
configure_jdtls(
&mut cfg,
Path::new("/p/demo"),
Path::new("/data"),
&jdks,
None,
JDTLS_MIN_JAVA,
&bundles,
None,
);
assert_eq!(cfg, before);
// The project JDK is the default runtime, and runs the server when new enough.
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
configure_jdtls(
&mut cfg,
Path::new("/p"),
Path::new("/data"),
&jdks,
Some(&project),
JDTLS_MIN_JAVA,
&[],
None,
);
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles, None);
configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, None, JDTLS_MIN_JAVA, &bundles, None);
assert!(other.args.is_empty() && other.initialization_options.is_none());
}
@@ -452,23 +508,23 @@ mod tests {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let jar = Path::new("/m2/lombok-1.18.48.jar");
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar));
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar));
let agent = format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy());
assert!(cfg.args.contains(&agent), "{:?}", cfg.args);
let mut plain = jdtls();
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None);
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], None);
assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent");
let before = cfg.clone();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar));
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar));
assert_eq!(cfg, before, "idempotent");
let mut cfg = jdtls();
cfg.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into());
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], Some(jar));
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar));
assert!(!cfg.args.contains(&agent), "the user's agent wins");
let mut own = jdtls();
own.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into());
configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None);
configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], None);
assert_eq!(cfg.args, own.args, "its workspace follows the agent in use");
}
@@ -477,19 +533,19 @@ mod tests {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[], None);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, JDTLS_MIN_JAVA, &[], None);
assert!(!cfg.env.contains_key("JAVA_HOME"));
let err = cfg.launch_error.unwrap();
assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}");
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[], None);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], None);
assert!(cfg.launch_error.unwrap().contains("no JDK was found"));
// An explicit JAVA_HOME is the user's call.
let mut cfg = jdtls();
cfg.env.insert("JAVA_HOME".into(), "/custom".into());
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[], None);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, JDTLS_MIN_JAVA, &[], None);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None));
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }];
@@ -497,7 +553,7 @@ mod tests {
// A jdtls whose bundles need 25 doesn't start on 21, and says what it needs.
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[], None);
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, None, 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}");
}
+4
View File
@@ -31,6 +31,10 @@ pub enum SdkKind {
Rust,
Go,
TypeScript,
/// JDK homes.
Jdk,
/// Maven homes.
Maven,
}
/// One installed interpreter / toolchain.
+12 -6
View File
@@ -64,8 +64,14 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
}
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
/// options) wins, then `managed` (the IDE-installed jar), then the build caches.
pub fn lombok_agent(root: &Path, own: Option<PathBuf>, managed: Option<PathBuf>) -> LombokAgent {
/// options) wins, then `managed` (the IDE-installed jar), then the build caches
/// (`maven_repo`, the configured Maven local repository, else the default one).
pub fn lombok_agent(
root: &Path,
own: Option<PathBuf>,
managed: Option<PathBuf>,
maven_repo: Option<PathBuf>,
) -> LombokAgent {
if let Some(jar) = own {
let version = jar
.file_stem()
@@ -82,7 +88,7 @@ pub fn lombok_agent(root: &Path, own: Option<PathBuf>, managed: Option<PathBuf>)
}
let home = home_dir();
let mut dirs = Vec::new();
if let Some(repo) = maven_repository(home.as_deref()) {
if let Some(repo) = maven_repo.or_else(|| maven_repository(home.as_deref())) {
dirs.push(repo.join("org").join("projectlombok").join("lombok"));
}
if let Some(gradle) =
@@ -137,7 +143,7 @@ fn maven_repository(home: Option<&Path>) -> Option<PathBuf> {
Some(custom.unwrap_or_else(|| m2.join("repository")))
}
fn strip_xml_comments(text: &str) -> String {
pub(crate) fn strip_xml_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find("<!--") {
@@ -249,10 +255,10 @@ mod tests {
let own = agent_in_args(["--jvm-arg=-Xmx2g", "--jvm-arg=-javaagent:/opt/lombok-1.18.26.jar"]);
assert_eq!(own.as_deref(), Some(Path::new("/opt/lombok-1.18.26.jar")));
assert_eq!(agent_in_args(["-javaagent:/opt/other.jar"]), None);
let agent = lombok_agent(tmp.path(), own, Some("/managed/lombok.jar".into()));
let agent = lombok_agent(tmp.path(), own, Some("/managed/lombok.jar".into()), None);
assert_eq!(agent.jar(), Some(Path::new("/opt/lombok-1.18.26.jar")));
assert!(agent.outdated(), "its version is read from the file name");
let plain = lombok_agent(tmp.path(), agent_in_args(["-javaagent:/opt/lombok.jar"]), None);
let plain = lombok_agent(tmp.path(), agent_in_args(["-javaagent:/opt/lombok.jar"]), None, None);
assert_eq!(plain.problem(), None, "a user agent is never reported missing");
}
+50 -1
View File
@@ -1,4 +1,7 @@
use crate::apply::{apply_to_run_configs, configure_server, python_interpreter, terminal_activation, tsdk};
use crate::apply::{
apply_to_run_configs, configure_server, maven_command, maven_options, project_jdk, python_interpreter,
terminal_activation, tsdk,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::Settings;
@@ -153,6 +156,52 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
}
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
}
#[test]
fn project_jdk_and_maven_settings() {
if cfg!(windows) {
return; // Unix paths and shells
}
let tmp = tempfile::tempdir().unwrap();
let jdk = tmp.path().join("jdk17");
fake_jdk(&jdk);
let maven = tmp.path().join("maven");
std::fs::create_dir_all(maven.join("bin")).unwrap();
std::fs::write(maven.join("bin/mvn"), "").unwrap();
let mut s = Settings::default();
assert_eq!(project_jdk(tmp.path(), &s), None);
assert_eq!(maven_command(tmp.path(), &s), None);
assert!(maven_options(tmp.path(), &s).is_empty());
// Project-relative paths, a JDK's `bin/java`, a Maven home.
s.languages.java.jdk = "jdk17/bin/java".into();
s.languages.java.maven.home = maven.to_string_lossy().into_owned();
s.languages.java.maven.user_settings = "/m2/settings.xml".into();
s.languages.java.maven.local_repository = "/m2/repo".into();
assert_eq!(project_jdk(tmp.path(), &s).map(|j| j.major), Some(17));
assert_eq!(maven_command(tmp.path(), &s), Some(maven.join("bin/mvn")));
assert_eq!(maven_options(tmp.path(), &s), ["-s", "/m2/settings.xml", "-Dmaven.repo.local=/m2/repo"]);
let mut configs = vec![run("maven", "./mvnw", &["package"]), run("gradle", "./gradlew", &["build"])];
apply_to_run_configs(&mut configs, tmp.path(), &s);
let home = project_jdk(tmp.path(), &s).unwrap().home.to_string_lossy().into_owned();
assert_eq!(configs[0].command, maven.join("bin/mvn").to_string_lossy());
assert_eq!(configs[0].args, ["-s", "/m2/settings.xml", "-Dmaven.repo.local=/m2/repo", "package"]);
assert_eq!(configs[0].env["JAVA_HOME"], home);
assert_eq!(configs[1].env["JAVA_HOME"], home);
apply_to_run_configs(&mut configs, tmp.path(), &s);
assert_eq!(configs[0].args.len(), 4, "applied once");
s.languages.java.jdk = "missing".into();
assert_eq!(project_jdk(tmp.path(), &s), None);
}
#[test]
fn terminal_activation_for_project_venv() {
if cfg!(windows) {
+6
View File
@@ -268,6 +268,7 @@ mod patch;
mod refs;
mod remotes;
mod repo;
mod roots;
pub mod shelf;
mod status;
mod write;
@@ -275,6 +276,7 @@ mod write;
pub use cli::{detected_git_executable, git_version, set_git_executable};
pub use diff::build_partial_patch;
pub use remotes::Remote;
pub use roots::Repos;
/// Clones `url` into `dest` (which must not exist or be empty), streaming progress.
pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
@@ -320,6 +322,10 @@ impl Repo {
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
refs::branch_info(self)
}
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
pub fn operation(&self) -> IdeResult<Option<String>> {
Ok(refs::operation(&self.gix()?))
}
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
/// `rev` may be any revision git understands, or `":"` / `":0"` for the index.
pub fn file_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
+1 -1
View File
@@ -30,7 +30,7 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult<BranchInfo> {
Ok(info)
}
fn operation(repo: &gix::Repository) -> Option<String> {
pub(crate) fn operation(repo: &gix::Repository) -> Option<String> {
use gix::state::InProgress::*;
let op = match repo.state()? {
ApplyMailbox | ApplyMailboxRebase => "am",
+243
View File
@@ -0,0 +1,243 @@
//! The repositories of a project (IDEA's VCS roots): the one containing the project
//! root plus those nested below it (separate clones side by side, submodules).
use crate::{FileStatus, Repo};
use ide_core::IdeResult;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// How deep below the project root nested repositories are looked for.
const MAX_DEPTH: usize = 4;
/// Folders never searched for nested repositories (dependencies, build output).
const SKIPPED: &[&str] = &["node_modules", "target", "build", "dist", "out", "vendor", "venv", "__pycache__"];
/// The repositories of a project, sorted by root; lookups pick the innermost one.
#[derive(Clone, Default)]
pub struct Repos {
repos: Vec<Arc<Repo>>,
primary: Option<usize>,
}
impl Repos {
/// The repository containing `project_root` and every repository whose worktree
/// root lies below it (up to [`MAX_DEPTH`] levels; hidden and dependency folders and
/// folders `skip` rejects are not searched).
pub fn discover(project_root: &Path, skip: &dyn Fn(&Path) -> bool) -> IdeResult<Self> {
let mut repos: Vec<Repo> = Vec::new();
if let Some(r) = Repo::discover(project_root)? {
repos.push(r);
}
let mut stack: Vec<(PathBuf, usize)> = vec![(project_root.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
let path = entry.path();
if name.starts_with('.') || SKIPPED.contains(&name.as_ref()) || skip(&path) {
continue;
}
if path.join(".git").exists()
&& let Ok(Some(r)) = Repo::discover(&path)
&& r.root() == path
&& !repos.iter().any(|x| x.root() == r.root())
{
repos.push(r);
}
if depth + 1 < MAX_DEPTH {
stack.push((path, depth + 1));
}
}
}
Ok(Self::new(repos, project_root))
}
/// Only the repository containing `project_root` (the quick lookup on project open).
pub fn enclosing(project_root: &Path) -> IdeResult<Self> {
Ok(Self::new(Repo::discover(project_root)?.into_iter().collect(), project_root))
}
/// Worktree roots, sorted.
pub fn roots(&self) -> Vec<PathBuf> {
self.repos.iter().map(|r| r.root().to_path_buf()).collect()
}
fn new(mut repos: Vec<Repo>, project_root: &Path) -> Self {
repos.sort_by(|a, b| a.root().cmp(b.root()));
let repos: Vec<Arc<Repo>> = repos.into_iter().map(Arc::new).collect();
let roots: Vec<&Path> = repos.iter().map(|r| r.root()).collect();
let primary = innermost(&roots, project_root, false).or((!repos.is_empty()).then_some(0));
Self { repos, primary }
}
pub fn is_empty(&self) -> bool {
self.repos.is_empty()
}
pub fn all(&self) -> &[Arc<Repo>] {
&self.repos
}
/// The repository of the project root, else the first one.
pub fn primary(&self) -> Option<&Arc<Repo>> {
self.primary.map(|i| &self.repos[i])
}
/// The innermost repository containing `path` (a file or folder). A nested repository's
/// root is an entry of the enclosing repository (a submodule or untracked folder there), so
/// it belongs to that one; to the repository itself only when nothing encloses it.
pub fn for_path(&self, path: &Path) -> Option<&Arc<Repo>> {
let roots: Vec<&Path> = self.repos.iter().map(|r| r.root()).collect();
let found = innermost(&roots, path, true).or_else(|| {
// Another spelling of the same location (symlinks, /var vs /private/var).
let canon = dunce::canonicalize(path).ok()?;
let roots: Vec<PathBuf> =
roots.iter().map(|r| dunce::canonicalize(r).unwrap_or_else(|_| r.to_path_buf())).collect();
innermost(&roots.iter().map(PathBuf::as_path).collect::<Vec<_>>(), &canon, true)
});
found.map(|i| &self.repos[i])
}
/// The repository whose worktree root is `root` (the repository a repository-scoped
/// command was given).
pub fn at_root(&self, root: &Path) -> Option<&Arc<Repo>> {
self.repos.iter().find(|r| r.root() == root).or_else(|| {
let canon = dunce::canonicalize(root).ok()?;
self.repos.iter().find(|r| dunce::canonicalize(r.root()).is_ok_and(|r| r == canon))
})
}
/// `paths` grouped by the repository each belongs to (paths outside every repository are dropped).
pub fn group(&self, paths: &[PathBuf]) -> Vec<(Arc<Repo>, Vec<PathBuf>)> {
let mut out: Vec<(Arc<Repo>, Vec<PathBuf>)> = Vec::new();
for p in paths {
let Some(repo) = self.for_path(p) else { continue };
match out.iter_mut().find(|(r, _)| Arc::ptr_eq(r, repo)) {
Some((_, list)) => list.push(p.clone()),
None => out.push((repo.clone(), vec![p.clone()])),
}
}
out
}
/// Changes of every repository. A nested repository reports its own files, so the
/// enclosing one's "untracked folder" entry for it is dropped. A repository that cannot
/// be read (e.g. deleted since the last scan) is left out; fails only when all fail.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
let mut first_error = None;
let mut read = 0;
for repo in &self.repos {
let nested: Vec<&Path> = self
.repos
.iter()
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
let status = match repo.status() {
Ok(status) => {
read += 1;
status
}
Err(e) => {
tracing::warn!("git status of {}: {e}", repo.root().display());
first_error.get_or_insert(e);
continue;
}
};
out.extend(status.into_iter().filter(|f| {
let untracked_root =
f.unstaged == Some(crate::ChangeKind::Untracked) && nested.iter().any(|n| f.path.0 == *n);
!untracked_root
}));
}
match first_error {
Some(e) if read == 0 => Err(e),
_ => Ok(out),
}
}
}
/// Index of the innermost of `roots` containing `path`. With `strict`, a root equal to
/// `path` counts only when no other root contains it.
fn innermost(roots: &[&Path], path: &Path, strict: bool) -> Option<usize> {
let best = |strict: bool| {
roots
.iter()
.enumerate()
.filter(|(_, r)| path.starts_with(r) && !(strict && **r == path))
.max_by_key(|(_, r)| r.as_os_str().len())
.map(|(i, _)| i)
};
best(strict).or_else(|| best(false))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let ok = Command::new("git").args(["init", "-q"]).current_dir(dir).status().is_ok_and(|s| s.success());
assert!(ok, "git init in {}", dir.display());
}
#[test]
fn finds_nested_repositories_and_routes_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
git_init(&root);
git_init(&root.join("services/api"));
git_init(&root.join("web"));
git_init(&root.join("node_modules/dep"));
git_init(&root.join(".cache/repo"));
std::fs::write(root.join("services/api/main.rs"), "fn main() {}").unwrap();
std::fs::write(root.join("README.md"), "x").unwrap();
let repos = Repos::discover(&root, &|_| false).unwrap();
let roots: Vec<&Path> = repos.all().iter().map(|r| r.root()).collect();
assert_eq!(roots, [root.as_path(), &root.join("services/api"), &root.join("web")]);
assert_eq!(repos.primary().unwrap().root(), root);
assert_eq!(repos.for_path(&root.join("services/api/main.rs")).unwrap().root(), root.join("services/api"));
assert_eq!(repos.for_path(&root.join("services/other.txt")).unwrap().root(), root);
// A nested root is the enclosing repository's entry (submodule / untracked folder).
assert_eq!(repos.for_path(&root.join("services/api")).unwrap().root(), root);
assert_eq!(repos.for_path(&root).unwrap().root(), root);
assert_eq!(repos.at_root(&root.join("services/api")).unwrap().root(), root.join("services/api"));
assert!(repos.at_root(&root.join("services")).is_none());
let status = repos.status().unwrap();
let paths: Vec<String> =
status.iter().map(|f| f.path.0.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/")).collect();
assert!(paths.contains(&"README.md".to_owned()), "{paths:?}");
assert!(paths.contains(&"services/api/main.rs".to_owned()), "{paths:?}");
assert!(
!paths.iter().any(|p| p.trim_end_matches('/') == "web"),
"nested root is not an untracked folder: {paths:?}"
);
let groups = repos.group(&[root.join("README.md"), root.join("services/api/main.rs"), root.join("web/x")]);
assert_eq!(groups.len(), 3);
assert_eq!(groups[1].1, [root.join("services/api/main.rs")]);
let skipped = Repos::discover(&root, &|p| p.ends_with("services")).unwrap();
assert_eq!(skipped.all().len(), 2);
}
#[test]
fn side_by_side_repositories_without_an_enclosing_one() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
git_init(&root.join("a"));
git_init(&root.join("b"));
let repos = Repos::discover(&root, &|_| false).unwrap();
assert_eq!(repos.all().len(), 2);
assert_eq!(repos.primary().unwrap().root(), root.join("a"), "the first when none contains the project root");
assert!(repos.for_path(&root.join("c.txt")).is_none());
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
fn project_info(project: &crate::state::Project) -> ProjectInfo {
ProjectInfo {
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
is_git: project.repo.lock().is_some(),
is_git: !project.repos.read().is_empty(),
settings: project.settings.effective(),
root: project.root.clone().into(),
}
+32 -14
View File
@@ -4,6 +4,7 @@
use crate::state::AppState;
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{IdeError, IdeEvent, IdeResult, RequestId};
use std::collections::BTreeMap;
use std::path::Path;
@@ -14,22 +15,32 @@ fn join_err(e: tauri::Error) -> IdeError {
IdeError::internal(e.to_string())
}
/// `JAVA_HOME` of the installed JDK matching `release` (none when the project names no version).
fn java_env(release: Option<u32>) -> BTreeMap<String, String> {
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
/// when the project names no version).
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
let jdks = ide_sdk::java::installed_jdks();
let jdk = release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r)));
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
}
pub(crate) fn load(root: &Path, cache: &Path) -> BuildModel {
let mut model = ide_build::load(root, cache);
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
/// the Maven home and options from Settings › Maven.
pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
let jdk = ide_sdk::apply::project_jdk(root, settings);
if let Some(m) = &mut model.maven {
let mvn = ide_sdk::apply::maven_command(root, settings);
let options = ide_sdk::apply::maven_options(root, settings);
for p in &mut m.projects {
p.env = java_env(p.java_version);
p.env = java_env(jdk.as_ref(), p.java_version);
if let Some(mvn) = &mvn {
p.command = mvn.to_string_lossy().into_owned();
}
p.options = options.clone();
}
}
if let Some(g) = &mut model.gradle {
g.env = java_env(g.java_version);
g.env = java_env(jdk.as_ref(), g.java_version);
}
model
}
@@ -39,8 +50,8 @@ pub(crate) fn load(root: &Path, cache: &Path) -> BuildModel {
#[specta::specta]
pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResult<BuildModel> {
let project = state.project(window.label())?;
let (root, cache) = (project.root.clone(), project.build_cache_dir());
tauri::async_runtime::spawn_blocking(move || load(&root, &cache)).await.map_err(join_err)
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tauri::async_runtime::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
@@ -58,10 +69,10 @@ pub async fn build_sync(
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
let project = state.project(window.label())?;
let (root, cache) = (project.root.clone(), project.build_cache_dir());
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = state.begin_request(request);
let result = tauri::async_runtime::spawn_blocking(move || {
let current = load(&root, &cache);
let current = load(&root, &cache, &settings);
let env = match tool {
BuildTool::Maven => current
.maven
@@ -71,7 +82,13 @@ pub async fn build_sync(
.unwrap_or_default(),
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
};
let options = SyncOptions { offline, profiles, env };
let options = SyncOptions {
offline,
profiles,
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
@@ -79,7 +96,7 @@ pub async fn build_sync(
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok(load(&root, &cache))
Ok(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
@@ -104,6 +121,7 @@ pub async fn build_run(
) -> IdeResult<BuildResult> {
let project = state.project(window.label())?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let token = state.begin_request(request);
let result = tauri::async_runtime::spawn_blocking(move || {
for path in clear {
@@ -113,7 +131,7 @@ pub async fn build_run(
items: Vec::new(),
});
}
let plan = compile::plan(&root, &load(&root, &cache), kind, file.as_deref())?;
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
})?;
+9 -4
View File
@@ -22,10 +22,12 @@ pub async fn java_resolve_launch(
) -> IdeResult<JavaLaunchInfo> {
let project = state.project(window.label())?;
let module = project.root.join(module);
let settings = project.settings.effective();
let built = if build && compile::has_non_java_sources(&module) {
let (root, cache, module) = (project.root.clone(), project.build_cache_dir(), module.clone());
let (root, cache, module, settings) =
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
tauri::async_runtime::spawn_blocking(move || {
compile::build_for_launch(&root, &load(&root, &cache), &module, test, &CancellationToken::new())
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??
@@ -40,19 +42,22 @@ pub async fn java_resolve_launch(
}
}
info.class_paths = class_paths;
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
let java = from_jdtls.or_else(|| {
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
if info.error_count > 0 {
let vm_args = project.settings.effective().languages.java.jdtls_vm_args;
let vm_args = &settings.languages.java.jdtls_vm_args;
let lombok = ide_sdk::lombok::lombok_agent(
&project.root,
ide_sdk::lombok::agent_in_args(vm_args.split_whitespace()),
ide_tools::managed_bundle(&state.data_dir, "lombok"),
ide_sdk::apply::maven_local_repository(&project.root, &settings),
);
info.hints.extend(lombok.problem());
}
+1
View File
@@ -129,6 +129,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::apply_workspace_edit,
lsp::lsp_execute_command,
vcs::vcs_status,
vcs::vcs_roots,
vcs::vcs_branch_info,
vcs::vcs_line_hunks,
vcs::vcs_file_at,
+187 -48
View File
@@ -1,4 +1,4 @@
use crate::state::AppState;
use crate::state::{AppState, Project};
use ide_core::{IdeResult, RequestId};
use ide_vcs::*;
use serde::{Deserialize, Serialize};
@@ -13,18 +13,59 @@ pub(crate) fn apply_git_executable(settings: &ide_core::settings::Settings) {
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
}
/// Changes of every repository of the project.
#[tauri::command]
#[specta::specta]
pub async fn vcs_status(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<FileStatus>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.status())).await
blocking(move || {
let settings = project.settings.effective();
apply_git_executable(&settings);
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status()
})
.await
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct VcsRoot {
/// Worktree root.
pub root: ide_core::IpcPath,
/// Merge / rebase / cherry-pick / revert in progress.
pub operation: Option<String>,
}
/// The project's repositories, the primary one first.
#[tauri::command]
#[specta::specta]
pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<VcsRoot>> {
let repos = state.project(window.label())?.repos();
let primary = repos.primary().map(|r| r.root().to_path_buf());
let mut all: Vec<&std::sync::Arc<Repo>> = repos.all().iter().collect();
all.sort_by_key(|r| Some(r.root()) != primary.as_deref());
Ok(all
.into_iter()
.map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() })
.collect())
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branch_info(window: Window, state: State<'_, AppState>) -> IdeResult<BranchInfo> {
pub async fn vcs_branch_info(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
) -> IdeResult<BranchInfo> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.branch_info())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -33,7 +74,7 @@ pub async fn vcs_branch_info(window: Window, state: State<'_, AppState>) -> IdeR
pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
let project = state.project(window.label())?;
let text = project.text_of(&path)?;
project.with_repo(|r| r.line_hunks(&path, &text, false))
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
}
#[tauri::command]
@@ -45,7 +86,7 @@ pub async fn vcs_file_at(
rev: Option<String>,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.file_at(&path, rev.as_deref()))).await
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
}
#[tauri::command]
@@ -53,14 +94,19 @@ pub async fn vcs_file_at(
pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
let project = state.project(window.label())?;
let text = project.documents.text_of(&path);
blocking(move || project.with_repo(|r| r.blame(&path, text.as_deref()))).await
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_log(window: Window, state: State<'_, AppState>, query: LogQuery) -> IdeResult<Vec<Commit>> {
pub async fn vcs_log(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
query: LogQuery,
) -> IdeResult<Vec<Commit>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.log(&query))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
#[tauri::command]
@@ -68,38 +114,39 @@ pub async fn vcs_log(window: Window, state: State<'_, AppState>, query: LogQuery
pub async fn vcs_commit_files(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.commit_files(&commit))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branches(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<Branch>> {
pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.branches())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stashes(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<Stash>> {
pub async fn vcs_stashes(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stashes())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stage(&paths))).await
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.unstage(&paths))).await
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
#[tauri::command]
@@ -113,7 +160,7 @@ pub async fn vcs_stage_patch(window: Window, state: State<'_, AppState>, patch:
#[specta::specta]
pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Rollback"), |r| r.discard(&paths))).await
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
#[tauri::command]
@@ -121,19 +168,27 @@ pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec<
pub async fn vcs_commit(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
message: String,
options: CommitOptions,
) -> IdeResult<String> {
// Commit hooks may run for a long time.
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.commit_with(&message, &options))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_checkout(window: Window, state: State<'_, AppState>, branch: String, create: bool) -> IdeResult<()> {
pub async fn vcs_checkout(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
create: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git checkout"), |r| r.checkout(&branch, create))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout(&branch, create)))
.await
}
/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out.
@@ -142,22 +197,31 @@ pub async fn vcs_checkout(window: Window, state: State<'_, AppState>, branch: St
pub async fn vcs_create_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
name: String,
start: Option<String>,
checkout: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
let recorded = checkout.then_some("Git checkout");
blocking(move || project.with_repo_recorded(recorded, |r| r.create_branch(&name, start.as_deref(), checkout))).await
blocking(move || {
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
})
.await
}
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
/// Returns the undone commit's message.
#[tauri::command]
#[specta::specta]
pub async fn vcs_undo_commit(window: Window, state: State<'_, AppState>, commit: String) -> IdeResult<String> {
pub async fn vcs_undo_commit(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<String> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.undo_commit(&commit))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
}
#[tauri::command]
@@ -165,32 +229,49 @@ pub async fn vcs_undo_commit(window: Window, state: State<'_, AppState>, commit:
pub async fn vcs_delete_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
force: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.delete_branch(&branch, force))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch(&branch, force))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_rename_branch(window: Window, state: State<'_, AppState>, from: String, to: String) -> IdeResult<()> {
pub async fn vcs_rename_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
from: String,
to: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.rename_branch(&from, &to))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_merge(window: Window, state: State<'_, AppState>, branch: String) -> IdeResult<()> {
pub async fn vcs_merge(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git merge"), |r| r.merge(&branch))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_rebase(window: Window, state: State<'_, AppState>, onto: String) -> IdeResult<()> {
pub async fn vcs_rebase(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
onto: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git rebase"), |r| r.rebase(&onto))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
}
#[tauri::command]
@@ -198,28 +279,43 @@ pub async fn vcs_rebase(window: Window, state: State<'_, AppState>, onto: String
pub async fn vcs_stash_push(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
message: Option<String>,
include_untracked: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(Some("Git stash"), |r| r.stash_push(message.as_deref(), include_untracked))
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked)
})
})
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stash_apply(window: Window, state: State<'_, AppState>, index: u32, pop: bool) -> IdeResult<()> {
pub async fn vcs_stash_apply(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
index: u32,
pop: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git unstash"), |r| r.stash_apply(index, pop))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| r.stash_apply(index, pop)))
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stash_drop(window: Window, state: State<'_, AppState>, index: u32) -> IdeResult<()> {
pub async fn vcs_stash_drop(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
index: u32,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stash_drop(index))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
}
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -258,6 +354,7 @@ pub enum RemoteOp {
pub async fn vcs_remote(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: RequestId,
op: RemoteOp,
on_output: Channel<String>,
@@ -269,7 +366,7 @@ pub async fn vcs_remote(
let _ = on_output.send(line);
};
let recorded = matches!(op, RemoteOp::Pull { .. }).then_some("Git pull");
project.with_repo_recorded(recorded, |r| match op {
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
RemoteOp::Fetch => r.fetch(&cancel, &out),
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
@@ -304,7 +401,7 @@ pub async fn vcs_conflict_versions(
) -> IdeResult<ConflictVersions> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo(|r| {
project.with_repo_of(&path, |r| {
r.conflict_versions(&path).map(|(base, ours, theirs)| ConflictVersions { base, ours, theirs })
})
})
@@ -320,7 +417,7 @@ pub async fn vcs_diff_text(
staged: bool,
) -> IdeResult<String> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.diff_text(&path, staged))).await
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
}
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
@@ -337,7 +434,7 @@ pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec<LineHunk> {
pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
let project = state.project(window.label())?;
let current = ide_vfs::load_text(&path)?.text;
project.with_repo(|r| {
project.with_repo_of(&path, |r| {
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
@@ -408,9 +505,14 @@ pub fn vcs_detected_git() -> Option<String> {
/// `git config --get <key>` in the project repository; `None` when unset.
#[tauri::command]
#[specta::specta]
pub async fn vcs_config_get(window: Window, state: State<'_, AppState>, key: String) -> IdeResult<Option<String>> {
pub async fn vcs_config_get(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.config_get(&key))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
// ---- in-progress operations, history rewriting (Git Log actions) ----
@@ -448,6 +550,7 @@ pub enum GitOperation {
pub async fn vcs_operation(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: RequestId,
op: GitOperation,
on_output: Channel<String>,
@@ -465,7 +568,7 @@ pub async fn vcs_operation(
GitOperation::InteractiveRebase { .. } => Some("Git rebase"),
_ => None,
};
project.with_repo_recorded(recorded, |r| match op {
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
GitOperation::Continue => r.continue_operation(&cancel, &out),
GitOperation::Abort => r.abort_operation(&cancel, &out),
GitOperation::Skip => r.skip_operation(&cancel, &out),
@@ -488,20 +591,22 @@ pub async fn vcs_operation(
pub async fn vcs_create_tag(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
name: String,
commit: String,
message: Option<String>,
force: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.create_tag(&name, &commit, message.as_deref(), force))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_tags(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<String>> {
pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.tags())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
@@ -510,10 +615,42 @@ pub async fn vcs_tags(window: Window, state: State<'_, AppState>) -> IdeResult<V
pub async fn vcs_diff_with_worktree(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.diff_with_worktree(&rev))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Runs `f` once per repository with the `paths` that belong to it.
fn per_repo(
project: &Project,
paths: &[PathBuf],
recorded: Option<&str>,
f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>,
) -> IdeResult<()> {
let groups = project.repos().group(paths);
if groups.is_empty() {
return project.with_repo_recorded(None, recorded, |r| f(r, paths));
}
for (repo, paths) in groups {
project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?;
}
Ok(())
}
/// Shelf and patches work in the primary repository only: changes of a nested one
/// fail instead of being left out silently.
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
let repos = project.repos();
let primary = repos.primary().map(|r| r.root());
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
Some(p) => Err(ide_core::IdeError::invalid(format!(
"{} is in another git repository; shelving and patches support the project's main repository only",
p.display()
))),
None => Ok(()),
}
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -539,6 +676,7 @@ pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source
project.with_repo(|r| match source {
PatchSource::Changes { paths, reverse } => {
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
in_primary_repo(&project, &paths)?;
r.create_patch(&paths, reverse)
}
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
@@ -586,8 +724,9 @@ pub async fn vcs_shelve(
) -> IdeResult<ShelvedChangeList> {
let project = state.project(window.label())?;
blocking(move || {
in_primary_repo(&project, &paths)?;
keep_private(&project.root);
project.with_repo_recorded(Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
@@ -611,7 +750,7 @@ pub async fn vcs_unshelve(
) -> IdeResult<ApplyOutcome> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
})
.await
}
+91 -18
View File
@@ -8,7 +8,7 @@ use ide_run::ProcessManager;
use ide_syntax::StructureService;
use ide_terminal::TerminalManager;
use ide_text::{AppliedChange, ByteEdit};
use ide_vcs::Repo;
use ide_vcs::{Repo, Repos};
use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{
@@ -56,7 +56,8 @@ pub struct Project {
pub moves: FileMoves,
pub index: Arc<ProjectIndex>,
pub lsp: Arc<LspManager>,
pub repo: Mutex<Option<Repo>>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -242,7 +243,8 @@ impl AppState {
let root = root.clone();
let data_dir = self.data_dir.clone();
lsp.set_config_hook(Box::new(move |cfg, server_root| {
ide_sdk::apply::configure_server(cfg, &root, server_root, &settings.effective());
let effective = settings.effective();
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
if cfg.name == "jdtls" {
let bundles: Vec<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
@@ -252,6 +254,7 @@ impl AppState {
server_root,
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
ide_tools::managed_bundle(&data_dir, "lombok"),
ide_sdk::apply::maven_local_repository(&root, &effective),
);
if let Some(problem) = lombok.problem() {
tracing::warn!("{problem}");
@@ -261,6 +264,7 @@ impl AppState {
server_root,
&data_dir,
&jdks,
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
min_java,
&bundles,
lombok.jar(),
@@ -277,7 +281,10 @@ impl AppState {
documents.add_observer(Arc::new(LspObserver(lsp.clone())));
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
let watcher = {
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
let documents = documents.clone();
let sink = sink.clone();
@@ -303,6 +310,19 @@ impl AppState {
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
}
// A repository was cloned / initialised / removed below the project: its
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
let known = repos.read().roots();
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
else {
return false;
};
known.iter().any(|r| r == dir) != p.is_file()
});
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
}
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}),
)?
@@ -329,8 +349,13 @@ impl AppState {
};
mark("watcher");
let repo = Repo::discover(&root)?;
let branch = repo.as_ref().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
let branch = repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
{
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
std::thread::Builder::new()
.name("openidea-vcs-roots".into())
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
}
mark("git");
self.recent.touch(&root, branch)?;
mark("recent");
@@ -347,7 +372,7 @@ impl AppState {
moves: FileMoves::new(),
index,
lsp,
repo: Mutex::new(repo),
repos,
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -389,18 +414,65 @@ impl AppState {
}
}
/// Looks for repositories nested below the project (and the one containing it);
/// emits `VcsChanged` when the set of roots changed.
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
match Repos::discover(root, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
/// Runs `f` with the git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
/// The project's git repositories.
pub fn repos(&self) -> Repos {
self.repos.read().clone()
}
/// Runs `f` with the repository whose root is `at`, the project's primary repository
/// when `None`; fails when there is none (a repository-scoped command never falls back
/// to another repository).
pub fn with_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let repos = self.repos();
match at {
Some(root) => match repos.at_root(root) {
Some(repo) => self.run_in(repo, f),
None => {
Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display())))
}
},
None => match repos.primary() {
Some(repo) => self.run_in(repo, f),
None => Err(IdeError::unavailable("project is not a git repository")),
},
}
}
/// Runs `f` with the repository `path` (a file or folder) belongs to.
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
match self.repos().for_path(path) {
Some(repo) => self.run_in(repo, f),
None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))),
}
}
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
crate::commands::vcs::apply_git_executable(&settings);
match self.repo.lock().as_ref() {
Some(repo) => {
repo.set_ignored_names(settings.ignored_names());
f(repo)
}
None => Err(IdeError::unavailable("project is not a git repository")),
}
repo.set_ignored_names(settings.ignored_names());
f(repo)
}
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(None, f)
}
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
@@ -409,19 +481,20 @@ impl Project {
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
pub fn with_repo_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&Repo) -> IdeResult<T>,
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_repo(f) };
let Some(name) = operation else { return self.with_repo_at(at, f) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo(|r| r.status()) {
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
tracing::debug!("local history: {e}");
}
}
}
let result = self.with_repo(f);
let result = self.with_repo_at(at, f);
self.history_label(&format!("After {name}"));
result
}
+57 -23
View File
@@ -249,38 +249,41 @@ export const commands = {
* follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
*/
lspExecuteCommand: (doc: DocId, command: Command) => typedError<null, IdeError>(__TAURI_INVOKE("lsp_execute_command", { doc, command })),
/** Changes of every repository of the project. */
vcsStatus: () => typedError<FileStatus[], IdeError>(__TAURI_INVOKE("vcs_status")),
vcsBranchInfo: () => typedError<BranchInfo, IdeError>(__TAURI_INVOKE("vcs_branch_info")),
/** The project's repositories, the primary one first. */
vcsRoots: () => typedError<VcsRoot[], IdeError>(__TAURI_INVOKE("vcs_roots")),
vcsBranchInfo: (repo: string | null) => typedError<BranchInfo, IdeError>(__TAURI_INVOKE("vcs_branch_info", { repo })),
/** Gutter change markers for a file, computed against its current (unsaved) text. */
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
vcsBlame: (path: string) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path })),
vcsLog: (query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { query })),
vcsCommitFiles: (commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { commit })),
vcsBranches: () => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches")),
vcsStashes: () => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes")),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
vcsCommit: (message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { message, options })),
vcsCheckout: (branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { branch, create })),
vcsDeleteBranch: (branch: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { branch, force })),
vcsCommit: (repo: string | null, message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { repo, message, options })),
vcsCheckout: (repo: string | null, branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { repo, branch, create })),
vcsDeleteBranch: (repo: string | null, branch: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { repo, branch, force })),
/** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */
vcsCreateBranch: (name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { name, start, checkout })),
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })),
/**
* Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
* Returns the undone commit's message.
*/
vcsUndoCommit: (commit: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_undo_commit", { commit })),
vcsRenameBranch: (from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { from, to })),
vcsMerge: (branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { branch })),
vcsRebase: (onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { onto })),
vcsStashPush: (message: string | null, includeUntracked: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { message, includeUntracked })),
vcsStashApply: (index: number, pop: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { index, pop })),
vcsStashDrop: (index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { index })),
vcsUndoCommit: (repo: string | null, commit: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_undo_commit", { repo, commit })),
vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })),
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { repo, onto })),
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })),
vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })),
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { repo, index })),
/** Network operation; progress lines stream through `on_output`. Cancellable. */
vcsRemote: (request: RequestId, op: RemoteOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remote", { request, op, onOutput })),
vcsRemote: (repo: string | null, request: RequestId, op: RemoteOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })),
vcsConflictVersions: (path: string) => typedError<ConflictVersions, IdeError>(__TAURI_INVOKE("vcs_conflict_versions", { path })),
vcsDiffText: (path: string, staged: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_diff_text", { path, staged })),
/** Line hunks between two arbitrary texts (diff viewer, commit dialog). */
@@ -300,16 +303,16 @@ export const commands = {
/** The auto-detected git executable (shown as the field's placeholder). */
vcsDetectedGit: () => __TAURI_INVOKE<string | null>("vcs_detected_git"),
/** `git config --get <key>` in the project repository; `None` when unset. */
vcsConfigGet: (key: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_config_get", { key })),
vcsConfigGet: (repo: string | null, key: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_config_get", { repo, key })),
/**
* Local history operation that may take a while (hooks, large checkouts); git's
* output streams through `on_output`. Cancellable.
*/
vcsOperation: (request: RequestId, op: GitOperation, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_operation", { request, op, onOutput })),
vcsCreateTag: (name: string, commit: string, message: string | null, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_tag", { name, commit, message, force })),
vcsTags: () => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags")),
vcsOperation: (repo: string | null, request: RequestId, op: GitOperation, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_operation", { repo, request, op, onOutput })),
vcsCreateTag: (repo: string | null, name: string, commit: string, message: string | null, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_tag", { repo, name, commit, message, force })),
vcsTags: (repo: string | null) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags", { repo })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { rev })),
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
vcsCreatePatch: (source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { source })),
vcsPatchPreview: (patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { patch })),
vcsApplyPatch: (patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { patch })),
@@ -2341,11 +2344,18 @@ export type JavaLaunchInfo = {
};
export type JavaSettings = {
/**
* Project JDK home; empty = per module, the installed JDK matching the
* Java version it targets.
*/
jdk?: string,
/**
* Extra JVM options for the Java language server (jdtls), space-separated
* (VS Code's `java.jdt.ls.vmargs`).
*/
jdtlsVmArgs?: string,
/** Build Tools › Maven. */
maven?: MavenSettings,
};
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
@@ -2635,10 +2645,22 @@ export type MavenProject = {
javaVersion: number | null,
/** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */
command: string,
/** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */
options: string[],
/** Environment for runs (`JAVA_HOME` of the matching JDK); filled in by the shell. */
env: { [key in string]: string },
};
/** Build Tools › Maven. */
export type MavenSettings = {
/** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */
home?: string,
/** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */
userSettings?: string,
/** Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`. */
localRepository?: string,
};
export type MemoryCategory =
/** The backend and its WebView processes. */
"app" | "languageServer" |
@@ -3281,7 +3303,11 @@ export type Sdk = {
/** What [`detect`] looks for. */
export type SdkKind = "python" | "node" |
/** npm / pnpm / yarn executables. */
"packageManager" | "rust" | "go" | "typeScript";
"packageManager" | "rust" | "go" | "typeScript" |
/** JDK homes. */
"jdk" |
/** Maven homes. */
"maven";
export type SearchEvent = { type: "file"; file: FileMatches } | { type: "done"; summary: SearchSummary };
@@ -3874,6 +3900,14 @@ export type VcsConfirmationSettings = {
onDelete?: string,
};
/** A repository of the project (IDEA's VCS root). */
export type VcsRoot = {
/** Worktree root. */
root: IpcPath,
/** Merge / rebase / cherry-pick / revert in progress. */
operation: string | null,
};
/** Version Control (main page) and its sub-pages. */
export type VcsSettings = {
/** Colour folders that contain changed files (project tree, tabs). */
@@ -27,6 +27,7 @@ const mavenModel: BuildModel = {
profiles: ["ci"],
javaVersion: 21,
command: "/p/mvnw",
options: [],
env: {},
},
],
+4
View File
@@ -95,6 +95,10 @@ export function activate(): () => void {
if ([...e.created, ...e.modified, ...e.removed].some((p) => BUILD_FILE.test(p))) scheduleLoad();
}),
onIdeEvent("projectRootsChanged", scheduleLoad),
// Project JDK / Maven home and options are applied to the model's launchers.
onIdeEvent("settingsChanged", (e) => {
if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad();
}),
registerActions([
{
id: "maven.reload",
+6
View File
@@ -37,6 +37,7 @@ function project(over: Partial<MavenProject>): MavenProject {
profiles: [],
javaVersion: 21,
command: "/p/mvnw",
options: [],
env: { JAVA_HOME: "/jdk21" },
...over,
};
@@ -184,6 +185,11 @@ describe("Maven runs", () => {
const root = mavenGoalConfig(maven.projects[0]!, ["clean"], maven, { offline: false, skipTests: false, profiles: {} });
expect(root.cwd).toBeNull();
expect(root.isTest).toBe(false);
// Settings › Maven options go before the goals.
const configured = { ...core, options: ["-s", "/m2/settings.xml"] };
expect(mavenGoalConfig(configured, ["test"], maven, { offline: false, skipTests: true, profiles: {} }).args).toEqual(
["-s", "/m2/settings.xml", "test", "-DskipTests"],
);
});
it("parses typed command lines", () => {
+1 -1
View File
@@ -401,7 +401,7 @@ export function mavenGoalConfig(
model: Pick<MavenModel, "profiles">,
options: MavenRunOptions,
): RunConfig {
const args = mavenArgs(goals, model, options);
const args = [...project.options, ...mavenArgs(goals, model, options)];
return {
id: `maven:goal:${project.pom}:${goals.join(" ")}`,
name: mavenConfigName(project, goals),
+1 -1
View File
@@ -71,7 +71,7 @@ function CreateDialog(props: {
useEffect(() => {
let alive = true;
// Base candidates: branches of the remotes (without `origin/`), the default first.
call(commands.vcsBranches())
call(commands.vcsBranches(null))
.then((branches) => {
const names = new Set<string>();
if (props.defaultBase) names.add(props.defaultBase);
+1 -1
View File
@@ -89,7 +89,7 @@ export async function createPullRequest() {
let needsPush = true;
let title = "";
try {
const info = await call(commands.vcsBranchInfo());
const info = await call(commands.vcsBranchInfo(null));
head = info.head;
needsPush = !info.upstream || info.ahead > 0;
if (head) title = head.replace(/^[^/]+\//, "").replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
+4
View File
@@ -386,6 +386,10 @@ export function activate(): () => void {
const paths = [...e.created, ...e.modified, ...e.removed];
if (paths.some((p) => CONFIG_FILES.test(p))) scheduleReload();
}),
// Detected configs carry the selected toolchains (interpreter, JDK, Maven home and options).
onIdeEvent("settingsChanged", (e) => {
if (e.key === "languages" || e.key.startsWith("languages.")) scheduleReload();
}),
);
return () => {
@@ -21,7 +21,7 @@ describe("languages settings", () => {
node: { packageManager: "pip" },
rust: { checkCommand: "clippy", checkOnSave: false },
typescript: { version: "" },
java: { jdtlsVmArgs: " -Xmx2g " },
java: { jdtlsVmArgs: " -Xmx2g ", jdk: 21, maven: { home: " /opt/maven " } },
});
expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true });
expect(v.node.packageManager).toBe("");
@@ -29,6 +29,8 @@ describe("languages settings", () => {
expect(v.rust.checkOnSave).toBe(false);
expect(v.typescript.version).toBe("bundled");
expect(v.java.jdtlsVmArgs).toBe("-Xmx2g");
expect(v.java.jdk).toBe("");
expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "" });
expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES);
});
@@ -1,7 +1,7 @@
/**
* Settings › Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript: the
* toolchain each language's server and run configurations use (applied by
* `ide-sdk` when language servers start and when run configurations load).
* Settings › Languages & Frameworks › Go / Java / Node.js / Rust / TypeScript and
* Build Tools › Maven: the toolchain each language's server and run configurations
* use (applied by `ide-sdk` when language servers start and when run configurations load).
*/
import { useId, useMemo } from "react";
import { IconButton, Input, type SelectOption } from "@/ui";
@@ -16,12 +16,15 @@ function PathInput({
placeholder,
hint,
title,
file,
}: {
k: string;
label: string;
placeholder: string;
hint?: string;
title: string;
/** Browse for a file instead of a directory. */
file?: boolean;
}) {
const [value, set] = useProjectSetting<string>(k);
const id = useId();
@@ -41,7 +44,7 @@ function PathInput({
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: true, title, defaultPath: value || undefined });
const p = await choosePath({ directory: !file, title, defaultPath: value || undefined });
if (p) set(p);
}}
/>
@@ -217,6 +220,19 @@ export function JavaPage() {
const id = useId();
return (
<div className={styles.page}>
<Section title="Project JDK">
<SdkRow
k="languages.java.jdk"
label="SDK"
kind="jdk"
emptyLabel="Auto"
emptyDetail="per module, the JDK of its Java version"
directory
browseTitle="Select JDK Home Directory"
aria-label="Project JDK"
hint="Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle."
/>
</Section>
<Section title="Language Server">
<Row
label="JVM options"
@@ -237,6 +253,44 @@ export function JavaPage() {
);
}
// ---------------------------------------------------------------------------
// Build Tools › Maven
export function MavenPage() {
return (
<div className={styles.page}>
<Section>
<SdkRow
k="languages.java.maven.home"
label="Maven home path"
kind="maven"
emptyLabel="Auto"
emptyDetail="Maven wrapper, else mvn on PATH"
directory
browseTitle="Select Maven Home Directory"
aria-label="Maven home path"
hint="Runs sync, builds and Maven run configurations. A chosen home is used instead of the project's mvnw."
/>
<PathInput
k="languages.java.maven.userSettings"
label="User settings file"
placeholder="~/.m2/settings.xml"
title="Select Maven settings.xml"
file
hint="Passed as -s (mirrors, servers, proxies)."
/>
<PathInput
k="languages.java.maven.localRepository"
label="Local repository"
placeholder="From settings.xml, else ~/.m2/repository"
title="Select Local Repository"
hint="Passed as -Dmaven.repo.local."
/>
</Section>
</div>
);
}
// ---------------------------------------------------------------------------
// TypeScript
+11 -1
View File
@@ -1,11 +1,12 @@
/**
* Build, Execution, Deployment pages: Console, Debugger (+ Data Views,
* Build, Execution, Deployment pages: Maven, Console, Debugger (+ Data Views,
* Stepping), Python Debugger and Run/Debug. Consumed by the run and debugger
* features (and `ide-run` for encoding / termination timeout).
*/
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
import type { SelectOption } from "@/ui";
import { GlobListEditor, NumberInput, Row } from "../components/Form";
import { MavenPage } from "../languages/LanguagePages";
import { useSetting } from "../components/context";
import type { PageSpec } from "./spec";
import { Indented, ToggleNumber } from "./workflowControls";
@@ -89,6 +90,15 @@ function TooltipDelayRow() {
}
export const EXECUTION_PAGES: PageSpec[] = [
{
id: "build.maven",
title: "Maven",
group: BUILD_GROUP,
// IntelliJ IDEA: Build Tools › Maven, before Console / Debugger.
order: 359,
keywords: ["maven home", "mvn", "mvnw", "wrapper", "settings.xml", "user settings", "local repository", "m2"],
component: MavenPage,
},
{
id: "build.console",
title: "Console",
+11 -1
View File
@@ -52,7 +52,17 @@ export const LANGUAGE_PAGES: PageSpec[] = [
title: "Java",
group: LANGUAGES_GROUP,
order: 395,
keywords: ["jdtls", "jvm options", "vmargs", "lombok", "javaagent", "eclipse jdt"],
keywords: [
"jdk",
"project sdk",
"java home",
"jdtls",
"jvm options",
"vmargs",
"lombok",
"javaagent",
"eclipse jdt",
],
component: JavaPage,
},
{
+42
View File
@@ -18,6 +18,48 @@
overflow: hidden;
text-overflow: ellipsis;
}
.repo {
flex: none;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-muted);
}
.repo::after {
content: " /";
color: var(--fg-subtle);
}
.repos {
flex: none;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-popup);
}
.repoButton {
max-width: 160px;
height: 22px;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: none;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-secondary);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.repoButton:hover {
background: var(--bg-hover);
}
.repoSelected,
.repoSelected:hover {
background: var(--bg-selected);
color: var(--fg);
}
.operation {
color: var(--warning);
font-size: var(--fs-tiny);
+29 -1
View File
@@ -19,7 +19,7 @@ import { buildBranchRows, type BranchRow, type PopupAction } from "./branches";
import { branchMenu } from "./branchMenu";
import { recentBranches } from "./branchOps";
import { inProgress, operationTitle, type InProgress } from "./operations";
import { loadBranches, useVcs } from "./store";
import { loadBranches, repoName, selectRepo, useVcs } from "./store";
import styles from "./BranchWidget.module.css";
export const useBranchesPopup = create<{ open: boolean }>(() => ({ open: false }));
@@ -34,6 +34,8 @@ export function headLabel(branch: { head: string | null; headCommit: string | nu
export function BranchWidget() {
const available = useVcs((s) => s.available);
const branch = useVcs((s) => s.branch);
// With several repositories the widget names the selected one, like IDEA's.
const repo = useVcs((s) => (s.roots.length > 1 && s.repo ? repoName(s.repo) : null));
const open = useBranchesPopup((s) => s.open);
const ref = useRef<HTMLButtonElement>(null);
if (!available || !branch) return null;
@@ -43,6 +45,7 @@ export function BranchWidget() {
branch.ahead > 0 ? `${branch.ahead} outgoing` : "",
].filter(Boolean);
const tooltip =
(repo ? `Repository: ${repo}\n` : "") +
(branch.upstream
? `Git branch: ${headLabel(branch)}${op}\nTracking ${branch.upstream}`
: `Git branch: ${headLabel(branch)}${op}`) + (sync.length ? `\n${sync.join(", ")} commits` : "");
@@ -58,6 +61,7 @@ export function BranchWidget() {
onClick={() => useBranchesPopup.setState({ open: !open })}
>
<Icon name={branch.head ? "branch" : "commit"} size={16} className={styles.icon} />
{repo && <span className={styles.repo}>{repo}</span>}
<span className={styles.name}>
{inProgress(branch.operation) ? operationTitle(inProgress(branch.operation)!, branch.head) : headLabel(branch)}
</span>
@@ -179,6 +183,7 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
aria-label="Git Branches"
className={styles.popup}
>
<RepositoryBar />
<div className={styles.search}>
<SearchField
data-autofocus
@@ -237,6 +242,29 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
);
}
/** Repository switcher (several repositories): branches, log and push act on the selected one. */
function RepositoryBar() {
const roots = useVcs((s) => s.roots);
const repo = useVcs((s) => s.repo);
if (roots.length < 2) return null;
return (
<div className={styles.repos} role="group" aria-label="Repository">
{roots.map((r) => (
<button
key={r}
type="button"
className={clsx(styles.repoButton, r === repo && styles.repoSelected)}
aria-pressed={r === repo}
title={r}
onClick={() => void selectRepo(r).then(() => loadBranches().catch(() => undefined))}
>
{repoName(r)}
</button>
))}
</div>
);
}
function BranchRowView({ row }: { row: BranchRow }) {
if (row.type === "action") {
const keys = shortcutLabel(row.action.id);
+15 -3
View File
@@ -43,7 +43,7 @@ import { KIND_COLOR, KIND_LABEL, formatTime, stashLabel, stripSlash } from "./mo
import { call, commands } from "@/platform/ipc";
import { OperationBanner } from "./operations";
import { ShelfPanel, loadShelves, useShelf } from "./shelf";
import { loadStashes, refreshNow, useVcs } from "./store";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./CommitView.module.css";
/** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */
@@ -243,7 +243,11 @@ function ChangesPanel() {
const treeRef = useRef<TreeHandle>(null);
const viewRef = useRef<HTMLButtonElement>(null);
const roots = useMemo(() => buildChangeTree(status, root, groupByDir, lists), [status, root, groupByDir, lists]);
const repos = useVcs((s) => s.roots);
const roots = useMemo(
() => buildChangeTree(status, root, groupByDir, lists, repos),
[status, root, groupByDir, lists, repos],
);
const [byKey, parents] = useMemo(() => {
const m = new Map<string, ChangeNode>();
const up = new Map<string, string>();
@@ -556,7 +560,15 @@ function CommitMessage({ included }: { included: number }) {
if (v && !useCommit.getState().message.trim()) {
try {
const [last] = await call(
commands.vcsLog({ branch: "HEAD", path: null, author: null, text: null, range: null, skip: 0, limit: 1 }),
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: null,
skip: 0,
limit: 1,
}),
);
if (last && !useCommit.getState().message.trim()) {
amendMessage.current = last.message;
+2 -2
View File
@@ -6,7 +6,7 @@ import { call, commands } from "@/platform/ipc";
import { Dialog, List, Spinner, Tabs, T } from "@/ui";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
const LIMIT = 2000;
@@ -14,7 +14,7 @@ const LIMIT = 2000;
/** Commits reachable from `to` but not from `from` (git's `from..to`), computed by git, unbounded by any window. */
const commitsBetween = (from: string, to: string) =>
call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: null,
path: null,
author: null,
+2 -2
View File
@@ -48,7 +48,7 @@ import {
} from "./logStore";
import { KIND_COLOR, baseName, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { loadBranches, useVcs } from "./store";
import { currentRepo, loadBranches, useVcs } from "./store";
import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
@@ -711,7 +711,7 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
useEffect(() => {
if (!selected) return;
let alive = true;
call(commands.vcsCommitFiles(selected))
call(commands.vcsCommitFiles(currentRepo(), selected))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
+60 -1
View File
@@ -1,6 +1,15 @@
import { describe, expect, it } from "vitest";
import type { FileStatus } from "@/bindings";
import { aggregateCheck, buildChangeTree, initialCheck, planCommit, type CheckState, type ChangeNode } from "../changes";
import {
aggregateCheck,
buildChangeTree,
commitRepos,
initialCheck,
planCommit,
type CheckState,
type ChangeNode,
} from "../changes";
import { rootOf } from "../model";
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
@@ -54,6 +63,56 @@ describe("buildChangeTree", () => {
});
});
describe("several repositories", () => {
const nested = [...status, fs("libs/core/Lib.kt", null, "modified"), fs("libs/core/new.txt", null, "untracked")];
const repos = ["/r", "/r/libs/core"];
it("groups each change group by repository", () => {
expect(shape(buildChangeTree(nested, "/r", false, undefined, repos))).toEqual([
"Merge Conflicts",
" r",
" conf.yml",
"Changes",
" r",
" App.kt",
" AppTest.kt",
" Partial.kt",
" README.md",
" libs/core",
" Lib.kt",
"Unversioned Files",
" r",
" notes.txt",
" libs/core",
" new.txt",
]);
const changes = buildChangeTree(nested, "/r", true, undefined, repos)[1]!;
expect(changes.children!.map((n) => [n.label, n.path])).toEqual([
["r", "/r"],
["libs/core", "/r/libs/core"],
]);
expect(shape(changes.children![1]!.children!)).toEqual(["Lib.kt"]);
// One repository: no repository level.
const single = buildChangeTree(status, "/r", false, undefined, ["/r"]);
expect(shape(single)).toEqual(shape(buildChangeTree(status, "/r", false)));
});
it("commits every repository with included changes, the selected one last", () => {
const included = [nested[0]!, nested[7]!];
expect(commitRepos(included, repos, "/r")).toEqual(["/r/libs/core", "/r"]);
expect(commitRepos(included.slice(0, 1), repos, "/r/libs/core")).toEqual(["/r"]);
expect(commitRepos([], repos, "/r/libs/core")).toEqual(["/r/libs/core"]);
});
it("routes a nested repository's own entry (submodule) to the enclosing repository", () => {
expect(rootOf("/r/libs/core/Lib.kt", repos)).toBe("/r/libs/core");
expect(rootOf("/r/libs/core", repos)).toBe("/r");
expect(rootOf("/r/libs/core/", repos)).toBe("/r");
expect(rootOf("/r", repos)).toBe("/r");
expect(rootOf("/other", repos)).toBeNull();
});
});
describe("commit inclusion", () => {
it("defaults: tracked changes included, partial files keep their staged part, unversioned excluded", () => {
expect(initialCheck(status[0]!)).toBe("all");
@@ -11,6 +11,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real.commands,
vcsStatus: () => vcsStatus(),
vcsBranchInfo: async () => ({ status: "ok", data: info() }),
vcsRoots: async () => ({ status: "ok", data: [{ root: "/p", operation: null }] }),
},
};
});
+33 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { messageProblems, wrapAtMargin } from "../CommitView";
import { linkifyIssues } from "../issueLinks";
import { isProtectedBranch, isPushRejected } from "../remoteUi";
import { defaultPushChecked, isProtectedBranch, isPushRejected, pushCandidates, type RepoPush } from "../remoteUi";
import { attentionRepo } from "../store";
import { statusDecorations } from "../model";
const cfg = { blankLineAfterSubject: true, limitSubject: true, subjectLimit: 10, limitBody: true, bodyLimit: 8 };
@@ -59,3 +60,34 @@ describe("git settings helpers", () => {
expect(statusDecorations(status, false).get("/p/src/a.ts")?.propagate).toBeUndefined();
});
});
describe("several repositories", () => {
it("selects a repository that just stopped in an operation", () => {
expect(attentionRepo("/p", {}, { "/p/lib": "merge" })).toBe("/p/lib");
expect(attentionRepo("/p", { "/p/lib": "merge" }, { "/p/lib": "merge" })).toBeNull();
expect(attentionRepo("/p", {}, { "/p": "rebase", "/p/lib": "merge" })).toBeNull();
expect(attentionRepo("/p", {}, {})).toBeNull();
});
it("lists repositories with something to push", () => {
const branch = (head: string | null, upstream: string | null) => ({
head,
headCommit: "c1",
upstream,
ahead: 0,
behind: 0,
operation: null,
});
const commit = { id: "c1", shortId: "c1" } as RepoPush["commits"][number];
const repos: RepoPush[] = [
{ root: "/p", branch: branch("main", "origin/main"), commits: [commit] },
{ root: "/p/a", branch: branch("main", "origin/main"), commits: [] },
{ root: "/p/b", branch: branch("feature", null), commits: [commit] },
{ root: "/p/c", branch: branch(null, null), commits: [] },
];
const candidates = pushCandidates(repos);
expect(candidates.map((r) => r.root)).toEqual(["/p", "/p/b"]);
expect([...defaultPushChecked(candidates, "/p")]).toEqual(["/p"]);
expect([...defaultPushChecked(candidates, "/p/b")]).toEqual(["/p", "/p/b"]);
});
});
+2 -2
View File
@@ -16,7 +16,7 @@ import { openDialog } from "./dialogs";
import { gotoChange } from "./gutter";
import { openLocalHistory } from "./localHistoryKind";
import { revealCommit, setLogFilters, useGitLog, emptyFilters } from "./logStore";
import { runRemote } from "./ops";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
import { showCloneDialog } from "./clone";
@@ -150,7 +150,7 @@ export function registerVcsActions(): () => void {
category: "Git",
icon: "git-fetch",
when: VCS,
run: () => void runRemote({ op: "fetch" }),
run: () => void runRemote({ op: "fetch" }, undefined, allRepos()),
},
{
id: "vcs.refresh",
+11 -10
View File
@@ -7,7 +7,7 @@ import { notifications } from "@/workbench/contributions";
import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches";
import { confirmDialog, promptText } from "./dialogs";
import { runOp, runRemote, saveAll } from "./ops";
import { conflictedFiles, refreshNow, useVcs } from "./store";
import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store";
const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`;
@@ -46,7 +46,7 @@ export function validateBranchName(name: string): string | null {
export async function checkoutBranch(name: string) {
const previous = useVcs.getState().branch?.head;
await saveAll();
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(name, false)), {
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(currentRepo(), name, false)), {
success: () => `Checked out ${useVcs.getState().branch?.head ?? name}`,
});
if (ok !== undefined) {
@@ -72,7 +72,8 @@ export async function newBranch(from?: string) {
const checkout = r.checked;
const previous = useVcs.getState().branch?.head;
if (checkout) await saveAll();
const ok = await runOp(`Create branch ${r.value}`, () => call(commands.vcsCreateBranch(r.value, from ?? null, checkout)), {
const create = () => call(commands.vcsCreateBranch(currentRepo(), r.value, from ?? null, checkout));
const ok = await runOp(`Create branch ${r.value}`, create, {
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
});
if (ok !== undefined && checkout) {
@@ -91,7 +92,7 @@ export async function checkoutRevision() {
});
if (!r) return;
await saveAll();
await runOp(`Checkout ${r.value}`, () => call(commands.vcsCheckout(r.value, false)), {
await runOp(`Checkout ${r.value}`, () => call(commands.vcsCheckout(currentRepo(), r.value, false)), {
success: `Checked out ${r.value}`,
});
}
@@ -116,7 +117,7 @@ async function openConflictsInstead(): Promise<boolean> {
export async function mergeIntoCurrent(branch: string) {
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(branch)), {
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(currentRepo(), branch)), {
success: `Merged ${branch} into ${current}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -126,7 +127,7 @@ export async function mergeIntoCurrent(branch: string) {
export async function rebaseOnto(branch: string) {
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(branch)), {
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(currentRepo(), branch)), {
success: `Rebased ${current} onto ${branch}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -144,7 +145,7 @@ export async function renameBranch(name: string) {
});
if (!r) return;
const to = r.value.trim();
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(name, to)), {
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(currentRepo(), name, to)), {
success: `Renamed branch ${name} to ${to}`,
});
if (ok !== undefined) {
@@ -164,7 +165,7 @@ export async function deleteBranch(name: string) {
danger: true,
});
if (!ok) return;
await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(name, false)), {
await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(currentRepo(), name, false)), {
success: `Deleted branch ${name}`,
errorActions: (m) =>
/not fully merged/i.test(m)
@@ -172,7 +173,7 @@ export async function deleteBranch(name: string) {
{
label: "Force Delete",
run: () =>
void runOp(`Force delete ${name}`, () => call(commands.vcsDeleteBranch(name, true)), {
void runOp(`Force delete ${name}`, () => call(commands.vcsDeleteBranch(currentRepo(), name, true)), {
success: `Deleted branch ${name}`,
}),
},
@@ -198,7 +199,7 @@ export async function checkoutAndRebase(name: string) {
const current = useVcs.getState().branch?.head;
if (!current) return;
await saveAll();
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(name, false)));
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(currentRepo(), name, false)));
if (ok === undefined) return;
rememberBranch(current);
rememberBranch(name);
+13 -7
View File
@@ -4,14 +4,14 @@ import type { FileStatus } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { groupOf, planCommit, renamedFromPath } from "./changes";
import { commitRepos, groupOf, planCommit, renamedFromPath } from "./changes";
import { useChangelists, withListDefaults } from "./changelists";
import { runCommitChecks } from "./commitChecks";
import { rememberMessage, useCommit } from "./commitStore";
import { confirmDialog, openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, stripSlash } from "./model";
import { logConsole, runOp, saveAll } from "./ops";
import { loadStashes, refreshNow, useVcs } from "./store";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
export function firstLine(message: string): string {
@@ -48,18 +48,24 @@ export async function commitChanges(): Promise<string | undefined> {
}
const candidates = plan.included.map((f) => ({ path: stripSlash(f.path), exists: effectiveKind(f) !== "deleted" }));
if (!(await runCommitChecks(candidates, amend))) return undefined;
// Like IDEA, every repository with included changes gets a commit with the same message.
const repos = commitRepos(plan.included, useVcs.getState().roots, currentRepo());
const id = await runOp(
amend ? "Amend commit" : "Commit",
async () => {
if (plan.unstage.length) await call(commands.vcsUnstage(plan.unstage));
if (plan.stage.length) await call(commands.vcsStage(plan.stage));
return call(commands.vcsCommit(message.trim(), { amend, signOff, author: author.trim() || null, runHooks }));
const options = { amend, signOff, author: author.trim() || null, runHooks };
let id = "";
for (const repo of repos) id = await call(commands.vcsCommit(repo, message.trim(), options));
return id;
},
{
success: () => {
const n = plan.included.length;
const summary = firstLine(message);
return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${summary ? `: ${summary}` : ""}`;
const where = repos.length > 1 ? ` in ${repos.length} repositories` : "";
return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${where}${summary ? `: ${summary}` : ""}`;
},
},
);
@@ -221,7 +227,7 @@ export async function stashChanges() {
});
if (!r) return;
await saveAll();
await runOp("Stash changes", () => call(commands.vcsStashPush(r.value || null, r.checked)), {
await runOp("Stash changes", () => call(commands.vcsStashPush(currentRepo(), r.value || null, r.checked)), {
success: "Changes stashed",
});
void loadStashes().catch(() => undefined);
@@ -229,7 +235,7 @@ export async function stashChanges() {
export async function applyStash(index: number, pop: boolean) {
await saveAll();
await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(index, pop)), {
await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(currentRepo(), index, pop)), {
success: pop ? `Unstashed stash@{${index}}` : `Applied stash@{${index}}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
@@ -243,7 +249,7 @@ export async function dropStash(index: number, message: string) {
danger: true,
});
if (!ok) return;
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(index)), {
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(currentRepo(), index)), {
success: `Dropped stash@{${index}}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
+51 -4
View File
@@ -1,6 +1,6 @@
/** Commit tool window model: change groups, directory tree, inclusion (pure). */
import type { ChangelistState, FileStatus } from "@/bindings";
import { baseName, dirName, effectiveKind, relTo, stripSlash, type EffectiveKind } from "./model";
import { baseName, dirName, effectiveKind, relTo, repoLabel, rootOf, stripSlash, type EffectiveKind } from "./model";
export type GroupId = "conflicts" | "changes" | "unversioned";
@@ -156,6 +156,37 @@ function childNodes(files: readonly FileStatus[], root: string, group: GroupId,
.map((f) => fileNode(f, root, group, true, scope));
}
/** With several repositories, a node per repository (IDEA's "Group by Repository") above the files. */
function repoNodes(
files: readonly FileStatus[],
root: string,
group: GroupId,
groupByDir: boolean,
scope: string,
repos: readonly string[],
): ChangeNode[] {
if (repos.length < 2) return childNodes(files, root, group, groupByDir, scope);
const byRepo = new Map<string, FileStatus[]>();
for (const f of files) {
const repo = rootOf(f.path, repos) ?? root;
let list = byRepo.get(repo);
if (!list) byRepo.set(repo, (list = []));
list.push(f);
}
const order = (r: string) => (repos.includes(r) ? repos.indexOf(r) : repos.length);
return [...byRepo]
.sort(([a], [b]) => order(a) - order(b))
.map(([repo, own]) => ({
key: `${scope}:repo:${repo}`,
type: "dir" as const,
label: repoLabel(repo, root),
group,
children: childNodes(own, repo, group, groupByDir, `${scope}@${repo}`),
files: own,
path: repo,
}));
}
function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
for (const n of nodes) {
n.changelist = id;
@@ -167,13 +198,15 @@ function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
/**
* Group roots (Merge Conflicts / Changes / Unversioned Files) with flat or directory
* children. With change lists, "Changes" is split into one group per list
* (`ChangelistState.files` keys are project-relative paths).
* (`ChangelistState.files` keys are project-relative paths). With several
* repositories (`repos`), each group's files are grouped by repository first.
*/
export function buildChangeTree(
status: readonly FileStatus[],
root: string,
groupByDir: boolean,
lists?: ChangelistState,
repos: readonly string[] = [],
): ChangeNode[] {
const byGroup = new Map<GroupId, FileStatus[]>();
for (const fs of status) {
@@ -202,7 +235,7 @@ export function buildChangeTree(
type: "group",
label: l.name,
group: g,
children: tagList(childNodes(own, root, g, groupByDir, scope), l.id),
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos), l.id),
files: own,
changelist: l.id,
active: l.id === lists.active,
@@ -212,7 +245,7 @@ export function buildChangeTree(
continue;
}
if (!files.length) continue;
const children = childNodes(files, root, g, groupByDir, g);
const children = repoNodes(files, root, g, groupByDir, g, repos);
roots.push({ key: `group:${g}`, type: "group", label: GROUP_LABEL[g], group: g, children, files });
}
return roots;
@@ -225,6 +258,20 @@ export interface CommitPlan {
included: FileStatus[];
}
/**
* Repositories to commit: those holding `included` files (the selected one last, so its
* commit id is returned), else just the selected one (amend, concluding a merge).
*/
export function commitRepos(
included: readonly FileStatus[],
roots: readonly string[],
selected: string | null,
): Array<string | null> {
const repos = [...new Set(included.map((f) => rootOf(f.path, roots)).filter((r): r is string => !!r))];
if (!repos.length) return [selected];
return selected && repos.includes(selected) ? [...repos.filter((r) => r !== selected), selected] : repos;
}
/** Absolute path of the source of a staged rename (`renamedFrom` is repository-relative). */
export function renamedFromPath(fs: FileStatus): string | null {
if (!fs.renamedFrom) return null;
+2 -2
View File
@@ -13,7 +13,7 @@ import { maybeEditorService, toolWindowService } from "@/workbench/contributions
import { ask } from "@/workbench/messages";
import { baseName, relTo } from "./model";
import { logConsole } from "./ops";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
// ---------------------------------------------------------------------------
@@ -308,7 +308,7 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
}
if (git.warnCrlf) {
const autocrlf = (await call(commands.vcsConfigGet("core.autocrlf")).catch(() => null))?.toLowerCase();
const autocrlf = (await call(commands.vcsConfigGet(currentRepo(), "core.autocrlf")).catch(() => null))?.toLowerCase();
if (autocrlf !== "true" && autocrlf !== "input") {
const crlf: string[] = [];
for (const p of existing) {
+5 -3
View File
@@ -4,7 +4,8 @@ import { call, commands } from "@/platform/ipc";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { baseName, effectiveKind } from "./model";
import { readWorkingText } from "./ops";
import { statusOf, useVcs } from "./store";
import { renamedFromPath } from "./changes";
import { currentRepoRoot, statusOf } from "./store";
export type DiffSide =
/** File content at a git revision (`HEAD`, `:0` = index, a commit id, `<id>^`). */
@@ -104,15 +105,16 @@ export function workingDiffData(path: string, line?: number): DiffData {
kind === "untracked" || kind === "added"
? { type: "text", text: "", label: "(does not exist)" }
: st?.renamedFrom
? { type: "rev", rev: "HEAD", path: joinRoot(st.renamedFrom), label: `HEAD (${st.renamedFrom})` }
? { type: "rev", rev: "HEAD", path: renamedFromPath(st) ?? undefined, label: `HEAD (${st.renamedFrom})` }
: { type: "rev", rev: "HEAD" };
const right: DiffSide =
kind === "deleted" ? { type: "text", text: "", label: "(deleted)" } : { type: "worktree" };
return { path, left, right, line };
}
/** A path of the selected repository's log (repository-relative) made absolute. */
function joinRoot(rel: string): string {
const root = useVcs.getState().root;
const root = currentRepoRoot();
return root ? `${root}/${rel}` : rel;
}
+3 -3
View File
@@ -42,7 +42,7 @@ import {
} from "./rebase";
import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches";
import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
const VCS = "vcsAvailable";
const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git";
@@ -83,7 +83,7 @@ function withCommits(fn: (cs: Commit[]) => unknown) {
async function openCommitInBrowser(c: Commit) {
const upstream = useVcs.getState().branch?.upstream;
const remote = upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
const url = await call(commands.vcsConfigGet(`remote.${remote}.url`)).catch(() => null);
const url = await call(commands.vcsConfigGet(currentRepo(), `remote.${remote}.url`)).catch(() => null);
const page = url ? commitWebUrl(url, c.id) : null;
if (page) await openUrl(page);
else notifications().notify({ group: "vcs", level: "warning", title: `No web URL for remote '${remote}'` });
@@ -122,7 +122,7 @@ function shelfArg(arg: unknown) {
async function checkoutCommit(c: Commit) {
await saveAll();
await runOp(`Checkout ${c.shortId}`, () => call(commands.vcsCheckout(c.id, false)), {
await runOp(`Checkout ${c.shortId}`, () => call(commands.vcsCheckout(currentRepo(), c.id, false)), {
success: `Checked out ${c.shortId} (detached HEAD)`,
});
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { logConsole } from "./ops";
import { loadBranches, refreshNow, useVcs } from "./store";
import { currentRepo, loadBranches, refreshNow, useVcs } from "./store";
const FIRST_DELAY = 20_000;
const INTERVAL = 15 * 60_000;
@@ -32,7 +32,7 @@ export function installIncomingCheck(): () => void {
const channel = new Channel<string>();
channel.onmessage = () => undefined;
try {
await call(commands.vcsRemote(nextRequestId(), { op: "fetch" }, channel));
await call(commands.vcsRemote(currentRepo(), nextRequestId(), { op: "fetch" }, channel));
await refreshNow();
if (useVcs.getState().branchesLoaded) void loadBranches().catch(() => undefined);
} catch (e) {
+31 -9
View File
@@ -5,7 +5,7 @@ import { call, commands } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph";
import { clearConsole, errorMessage } from "./ops";
import { useVcs } from "./store";
import { currentRepo, repoOf, selectRepo, useVcs } from "./store";
export interface LogFilters {
branch: string | null;
@@ -71,19 +71,33 @@ export const useGitLog = create<LogState>(() => ({ ...initialLog(), generation:
let loadPromise: Promise<void> | null = null;
/** Another repository (the window switched projects): forget its commits, filters, selection and console. */
/**
* Another project (the window switched projects): forget its commits, filters, selection
* and console. Another repository of the project selected: show its log.
*/
export function installGitLog(): () => void {
return useVcs.subscribe((s, prev) => {
if (s.root === prev.root) return;
loadPromise = null;
useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 }));
clearConsole();
if (s.root !== prev.root) {
loadPromise = null;
useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 }));
clearConsole();
} else if (s.repo !== prev.repo && prev.repo !== null) {
const l = useGitLog.getState();
if (l.generation === 0 && !l.filters.path) return;
const { path } = useGitLog.getState().filters;
// A path filter belongs to the repository it is in.
if (path && repoOf(path) !== s.repo) useGitLog.setState((l) => ({ filters: { ...l.filters, path: null } }));
void reloadLog();
}
});
}
export function setLogFilters(patch: Partial<LogFilters>) {
useGitLog.setState((s) => ({ filters: { ...s.filters, ...patch } }));
void reloadLog();
// History of a file in another repository: that repository's log.
const repo = patch.path ? repoOf(patch.path) : null;
if (repo && repo !== currentRepo()) void selectRepo(repo);
else void reloadLog();
}
/** Drops loaded commits and loads the first page. */
@@ -106,7 +120,15 @@ async function loadOutgoing() {
if (!upstream) return useGitLog.setState({ outgoing: null });
try {
const commits = await call(
commands.vcsLog({ branch: "HEAD", path: null, author: null, text: null, range: "@{u}..HEAD", skip: 0, limit: 0 }),
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: "@{u}..HEAD",
skip: 0,
limit: 0,
}),
);
useGitLog.setState({ outgoing: new Set(commits.map((c) => c.id)) });
} catch {
@@ -137,7 +159,7 @@ export function loadMore(): Promise<void> {
const p = (async () => {
try {
const page = await call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: f.branch,
path: f.path,
author: f.author.trim() || null,
+18
View File
@@ -92,6 +92,24 @@ export function relTo(root: string, path: string): string {
return path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
}
/**
* The innermost of `roots` (repository roots) containing `path`. A nested repository's root
* is an entry of the enclosing one (submodule, untracked folder), so it belongs there; to
* itself only when no root encloses it (as the backend routes it).
*/
export function rootOf(path: string, roots: readonly string[]): string | null {
const p = stripSlash(path);
let best: string | null = null;
for (const r of roots) if (p.startsWith(`${r}/`) && (!best || r.length > best.length)) best = r;
return best ?? (roots.includes(p) ? p : null);
}
/** Repository name for display: its folder relative to the project, else its folder name. */
export function repoLabel(root: string, projectRoot: string | null): string {
if (projectRoot && root.startsWith(`${projectRoot}/`)) return root.slice(projectRoot.length + 1);
return baseName(root) || root;
}
export function baseName(path: string): string {
const p = stripSlash(path);
const i = p.lastIndexOf("/");
+16 -10
View File
@@ -14,7 +14,7 @@ import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { runGitOperation, runOp } from "./ops";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, refreshNow, useVcs } from "./store";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
// ---------------------------------------------------------------------------
@@ -84,7 +84,10 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e
await refreshNow();
const op = inProgress();
if (!op || (expected && op !== expected)) return notInProgress(expected);
if (control === "continue" && conflictedFiles().length) {
// Only the selected repository's conflicts block it (others may be in their own merge).
const repo = currentRepo();
const conflicts = conflictedFiles().filter((f) => !repo || repoOf(f.path) === repo);
if (control === "continue" && conflicts.length) {
notifications().notify({ group: "vcs", level: "warning", title: "Resolve all conflicts before continuing" });
void executeAction("vcs.resolveConflicts");
return;
@@ -301,7 +304,7 @@ export function validateTagName(name: string, existing: readonly string[] = []):
/** Log › New Tag…: a lightweight tag on the selected commit. */
export async function newTagOn(commit: Commit) {
const existing = await call(commands.vcsTags()).catch(() => [] as string[]);
const existing = await call(commands.vcsTags(currentRepo())).catch(() => [] as string[]);
const r = await promptText({
title: `Create New Tag On ${short(commit.id)}`,
label: "Enter the name of new tag:",
@@ -310,7 +313,8 @@ export async function newTagOn(commit: Commit) {
validate: (v) => validateTagName(v, existing),
});
if (!r) return;
const ok = await runOp(`Create tag ${r.value}`, () => call(commands.vcsCreateTag(r.value, commit.id, null, false)), {
const create = () => call(commands.vcsCreateTag(currentRepo(), r.value, commit.id, null, false));
const ok = await runOp(`Create tag ${r.value}`, create, {
success: `Created tag ${r.value} on ${short(commit.id)}`,
});
// Tags are not part of the refs signature that reloads the log.
@@ -329,7 +333,7 @@ function TagDialog({ onClose }: { onClose: () => void }) {
const [force, setForce] = useState(false);
const [existing, setExisting] = useState<string[]>([]);
useEffect(() => {
void call(commands.vcsTags()).then(setExisting, () => undefined);
void call(commands.vcsTags(currentRepo())).then(setExisting, () => undefined);
}, []);
const error = validateTagName(name, force ? [] : existing);
const run = async () => {
@@ -338,7 +342,7 @@ function TagDialog({ onClose }: { onClose: () => void }) {
const tag = name.trim();
const ok = await runOp(
`Create tag ${tag}`,
() => call(commands.vcsCreateTag(tag, commit.trim() || "HEAD", message || null, force)),
() => call(commands.vcsCreateTag(currentRepo(), tag, commit.trim() || "HEAD", message || null, force)),
{ success: `Created tag ${tag}` },
);
if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog();
@@ -405,19 +409,21 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
const [active, setActive] = useState(0);
useEffect(() => {
let alive = true;
call(commands.vcsDiffWithWorktree(rev))
call(commands.vcsDiffWithWorktree(currentRepo(), rev))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [rev]);
const open = (f: ChangedFile) =>
const open = (f: ChangedFile) => {
const repoRoot = currentRepoRoot() ?? root;
void openDiff({
path: `${root}/${f.path}`,
left: { type: "rev", rev, label, path: f.oldPath ? `${root}/${f.oldPath}` : undefined },
path: `${repoRoot}/${f.path}`,
left: { type: "rev", rev, label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: { type: "worktree" },
});
};
const current = files?.[active];
return (
<Dialog
+39 -5
View File
@@ -9,7 +9,7 @@ import { call, commands, emitLocalIdeEvent, isCancelled, nextRequestId } from "@
import { editorsFor } from "@/editor/api";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { progressFraction } from "./model";
import { refreshNow, useVcs } from "./store";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
// ---------------------------------------------------------------------------
// Console (Git tool window → Console tab)
@@ -130,8 +130,21 @@ export function cancelRemote() {
if (activeRemote !== null) void commands.cancelRequest(activeRemote);
}
/** Runs fetch / pull / push with progress in the status bar and output in the console. */
export async function runRemote(op: RemoteOp, detail?: string): Promise<boolean> {
/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */
export function allRepos(): Array<string | null> {
const roots = useVcs.getState().roots;
return roots.length ? roots : [null];
}
/**
* Runs fetch / pull / push with progress in the status bar and output in the console,
* in each of `repos` (default: the selected repository) in turn.
*/
export async function runRemote(
op: RemoteOp,
detail?: string,
repos: ReadonlyArray<string | null> = [currentRepo()],
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
return false;
@@ -163,7 +176,28 @@ export async function runRemote(op: RemoteOp, detail?: string): Promise<boolean>
task(text || null, progressFraction(text), false);
};
try {
await call(commands.vcsRemote(request, op, channel));
// Every repository is tried; the failures are reported together. A pull skips the
// repositories that have nothing to pull from (detached HEAD, e.g. submodules; no upstream).
const failures: string[] = [];
for (const repo of repos) {
const name = repos.length > 1 && repo ? repoName(repo) : null;
if (name) logConsole("info", name);
if (op.op === "pull" && repos.length > 1) {
const info = await call(commands.vcsBranchInfo(repo)).catch(() => null);
if (info && (!info.head || !info.upstream)) {
logConsole("info", info.head ? "No upstream branch, skipped" : "Detached HEAD, skipped");
continue;
}
}
try {
await call(commands.vcsRemote(repo, request, op, channel));
} catch (e) {
if (isCancelled(e) || !name) throw e;
logConsole("err", errorMessage(e));
failures.push(`${name}: ${errorMessage(e)}`);
}
}
if (failures.length) throw new Error(failures.join("\n"));
lastRemoteError = null;
notifications().notify({
group: "vcs",
@@ -239,7 +273,7 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
task(line.trim() || null, false);
};
try {
await call(commands.vcsOperation(request, op, channel));
await call(commands.vcsOperation(currentRepo(), request, op, channel));
let success = opts.success;
if (typeof success === "function") {
await refreshNow();
+4 -3
View File
@@ -26,7 +26,7 @@ import {
type RebaseRange,
type RebaseRow,
} from "./rebasePlan";
import { refreshNow, useVcs } from "./store";
import { currentRepo, refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
const short = (id: string) => id.slice(0, 8);
@@ -56,7 +56,8 @@ export async function undoCommit(commit: Commit) {
});
if (!ok) return;
}
const message = await runOp(`Undo commit ${short(commit.id)}`, () => call(commands.vcsUndoCommit(commit.id)), {
const undo = () => call(commands.vcsUndoCommit(currentRepo(), commit.id));
const message = await runOp(`Undo commit ${short(commit.id)}`, undo, {
success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
});
// IDEA offers the undone message for the next commit.
@@ -78,7 +79,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
const parent = target.parents[0];
try {
const history = await call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
+191 -12
View File
@@ -1,14 +1,15 @@
/** Update Project and Push dialogs. */
import { useEffect, useState } from "react";
import type { Commit, PushTags, RemoteOp } from "@/bindings";
import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications } from "@/workbench/contributions";
import { Checkbox, Dialog, List, Select, Spinner, T } from "@/ui";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
import { lastRemoteFailure, runRemote, saveAll } from "./ops";
import { refreshNow, useVcs } from "./store";
import { allRepos, lastRemoteFailure, runRemote, saveAll } from "./ops";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
import styles from "./common.module.css";
export function showUpdateDialog() {
@@ -22,7 +23,14 @@ function UpdateDialog({ onClose }: { onClose: () => void }) {
const run = async () => {
onClose();
await saveAll();
await runRemote({ op: "pull", rebase }, branch?.upstream ? `${branch.head} ← ${branch.upstream}` : undefined);
const repos = allRepos();
const detail =
repos.length > 1
? `${repos.length} repositories`
: branch?.upstream
? `${branch.head} ← ${branch.upstream}`
: undefined;
await runRemote({ op: "pull", rebase }, detail, repos);
};
return (
<Dialog
@@ -70,8 +78,8 @@ function UpdateDialog({ onClose }: { onClose: () => void }) {
);
}
const logOf = (branch: string, limit: number, range: string | null = null) =>
call(commands.vcsLog({ branch, path: null, author: null, text: null, range, skip: 0, limit }));
const logOf = (branch: string, limit: number, range: string | null = null, repo = currentRepo()) =>
call(commands.vcsLog(repo, { branch, path: null, author: null, text: null, range, skip: 0, limit }));
/** Outgoing commits of the current branch: `@{u}..HEAD`, or recent HEAD history without an upstream. */
export function outgoingQuery(upstream: string | null): { branch: string; range: string | null; limit: number } {
@@ -87,7 +95,174 @@ export interface PushTarget {
export async function showPushDialog(target: PushTarget = {}) {
await refreshNow();
openDialog((close) => <PushDialog target={target} onClose={close} />);
// Several repositories: IDEA's dialog lists each one with outgoing commits.
const multi = useVcs.getState().roots.length > 1 && !target.branch && !target.revision;
openDialog((close) => (multi ? <MultiPushDialog onClose={close} /> : <PushDialog target={target} onClose={close} />));
}
/** A repository in the multi-repository Push dialog. */
export interface RepoPush {
root: string;
branch: BranchInfo;
commits: Commit[];
}
/** Repositories with something to push: outgoing commits, or a branch without upstream. */
export function pushCandidates(repos: readonly RepoPush[]): RepoPush[] {
return repos.filter((r) => r.branch.head && (r.branch.upstream ? r.commits.length > 0 : true));
}
/** Checked by default: repositories with outgoing commits (a new branch only in the selected one). */
export function defaultPushChecked(repos: readonly RepoPush[], selected: string | null): Set<string> {
return new Set(repos.filter((r) => (r.branch.upstream ? r.commits.length > 0 : r.root === selected)).map((r) => r.root));
}
type PushRow = { type: "repo"; repo: RepoPush } | { type: "commit"; repo: RepoPush; commit: Commit };
function MultiPushDialog({ onClose }: { onClose: () => void }) {
const roots = useVcs((s) => s.roots);
const [repos, setRepos] = useState<RepoPush[] | null>(null);
const [checked, setChecked] = useState<Set<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
const [pushTags, setPushTags] = useState(false);
const [tagsMode, setTagsMode] = useState<PushTags>("all");
useEffect(() => {
let alive = true;
// A repository that cannot be read (e.g. its upstream branch was deleted) is left out.
Promise.allSettled(
roots.map(async (root) => {
const branch = await call(commands.vcsBranchInfo(root));
const q = outgoingQuery(branch.upstream);
const commits = branch.head ? await logOf(q.branch, q.limit, q.range, root) : [];
return { root, branch, commits };
}),
).then((results) => {
if (!alive) return;
const loaded = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (!loaded.length && failed) {
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
return;
}
const list = pushCandidates(loaded);
setRepos(list);
setChecked(defaultPushChecked(list, currentRepo()));
});
return () => {
alive = false;
};
}, [roots]);
const toggle = (root: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (!next.delete(root)) next.add(root);
return next;
});
const targetOf = (r: RepoPush) => (r.branch.upstream ? r.branch.upstream.slice(r.branch.upstream.indexOf("/") + 1) : r.branch.head!);
const picked = (repos ?? []).filter((r) => checked.has(r.root));
const push = async () => {
onClose();
for (const r of picked) {
const upstream = r.branch.upstream;
const ok = await pushWithAutoUpdate(
{
op: "push",
force_with_lease: force && !isProtectedBranch(targetOf(r)),
set_upstream: !upstream,
tags: pushTags ? tagsMode : null,
},
`${repoName(r.root)}: ${r.branch.head} → ${upstream ?? "new remote branch"}`,
r.root,
);
if (!ok) return;
}
};
const rows: PushRow[] = (repos ?? []).flatMap((repo) => [
{ type: "repo" as const, repo },
...repo.commits.map((commit) => ({ type: "commit" as const, repo, commit })),
]);
return (
<Dialog
open
title="Push Commits"
onClose={onClose}
width={620}
footerStart={
<>
<Checkbox checked={force} onChange={setForce} label="Force (with lease)" />
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
<Select<PushTags>
value={tagsMode}
disabled={!pushTags}
aria-label="Tags to push"
onChange={setTagsMode}
options={[
{ value: "all", label: "All" },
{ value: "currentBranch", label: "Current Branch" },
]}
/>
</>
}
buttons={[
{
label: force ? "Force Push" : "Push",
role: "default",
autoFocus: true,
disabled: !picked.length,
onClick: () => void push(),
},
{ label: "Cancel", role: "cancel" },
]}
>
<div style={{ height: 340, display: "flex", flexDirection: "column", padding: "14px" }}>
{error ? (
<div className={styles.error}>{error}</div>
) : !repos ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : (
<List
items={rows}
getKey={(r) => (r.type === "repo" ? `repo:${r.repo.root}` : `${r.repo.root}:${r.commit.id}`)}
rowHeight={22}
aria-label="Outgoing commits"
className={styles.boxList}
// Clicks on the checkbox toggle it themselves.
onActivate={(r, _i, e) => {
if (r.type === "repo" && !(e.target as HTMLElement | null)?.closest?.("label")) toggle(r.repo.root);
}}
empty={<span>Everything is up to date</span>}
renderItem={(r) =>
r.type === "repo" ? (
<span className={styles.commitRow} style={{ flex: 1, padding: 0 }}>
<Checkbox
checked={checked.has(r.repo.root)}
onChange={() => toggle(r.repo.root)}
label={repoName(r.repo.root)}
/>
<span className={styles.commitMeta}>
{r.repo.branch.head} → {r.repo.branch.upstream ?? tx("new remote branch")}
</span>
</span>
) : (
<span className={styles.commitRow} style={{ flex: 1, padding: "0 0 0 24px" }}>
<span className={styles.commitHash}>{r.commit.shortId}</span>
<span className={styles.commitSubject}>{r.commit.summary}</span>
<span className={styles.commitMeta}>{r.commit.author}</span>
<span className={styles.commitMeta}>{formatTime(r.commit.timeMs)}</span>
</span>
)
}
/>
)}
</div>
</Dialog>
);
}
function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClose: () => void }) {
@@ -262,16 +437,20 @@ export function isPushRejected(message: string): boolean {
* update method) and pushes again ("Auto-update if push of the current branch
* was rejected") or offers to.
*/
export async function pushWithAutoUpdate(op: Extract<RemoteOp, { op: "push" }>, detail: string): Promise<boolean> {
if (await runRemote(op, detail)) return true;
export async function pushWithAutoUpdate(
op: Extract<RemoteOp, { op: "push" }>,
detail: string,
repo: string | null = currentRepo(),
): Promise<boolean> {
if (await runRemote(op, detail, [repo])) return true;
const error = lastRemoteFailure();
if (!error || op.force_with_lease || !isPushRejected(error)) return false;
const git = workflowSettings().vcs.git;
const updateAndPush = async () => {
await saveAll();
if (!(await runRemote({ op: "pull", rebase: git.updateMethod === "rebase" }, "updating after rejected push")))
return false;
return runRemote(op, detail);
const pull = { op: "pull" as const, rebase: git.updateMethod === "rebase" };
if (!(await runRemote(pull, "updating after rejected push", [repo]))) return false;
return runRemote(op, detail, [repo]);
};
if (git.autoUpdateOnPushRejected) return updateAndPush();
notifications().notify({
+106 -8
View File
@@ -2,6 +2,11 @@
* Repository state shared by all VCS views: working tree status, HEAD /
* upstream info, branches and stashes. Refreshed (debounced) on `vcsChanged`
* and `fsChanged` events and after every operation.
*
* A project may hold several repositories (IDEA's VCS roots: nested clones,
* submodules). Status covers all of them; branch info, branches and stashes are
* those of the selected repository (`repo`), which branch / log / remote
* operations act on.
*/
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { create } from "zustand";
@@ -10,12 +15,18 @@ import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { setFileDecorations } from "@/workbench/contributions";
import { effectiveKind, statusDecorations, stripSlash } from "./model";
import { effectiveKind, repoLabel, rootOf, statusDecorations, stripSlash } from "./model";
export interface VcsState {
/** The open project is a git repository. */
available: boolean;
root: string | null;
/** Repository roots, the project's own repository first. */
roots: string[];
/** Root → merge / rebase / cherry-pick / revert in progress there. */
operations: Record<string, string>;
/** The selected repository's root (branch widget, log, push…). */
repo: string | null;
status: FileStatus[];
/** `status` reflects the repository (false until the first refresh). */
statusLoaded: boolean;
@@ -37,6 +48,9 @@ export interface VcsState {
const initial: VcsState = {
available: false,
root: null,
roots: [],
operations: {},
repo: null,
status: [],
statusLoaded: false,
byPath: new Map(),
@@ -53,6 +67,48 @@ const initial: VcsState = {
export const useVcs = create<VcsState>(() => ({ ...initial }));
/** Root of the selected repository; `null` = the project's primary one. */
export function currentRepo(): string | null {
return useVcs.getState().repo;
}
/** Root of the selected repository, which its log's and diffs' repository-relative paths are relative to. */
export function currentRepoRoot(): string | null {
const s = useVcs.getState();
return s.repo ?? s.roots[0] ?? s.root;
}
/** Root of the innermost repository containing `path`. */
export function repoOf(path: string, roots: readonly string[] = useVcs.getState().roots): string | null {
return rootOf(path, roots);
}
/** Repository name for display. */
export function repoName(root: string): string {
return repoLabel(root, useVcs.getState().root);
}
/**
* A repository that just stopped in a merge / rebase / cherry-pick / revert (e.g. on
* conflicts after Update Project) while `shown` has none in progress: it is selected so
* the banner, the branch widget and Continue / Abort act on it.
*/
export function attentionRepo(
shown: string | null,
before: Record<string, string>,
now: Record<string, string>,
): string | null {
if (shown && now[shown]) return null;
return Object.keys(now).find((root) => !before[root]) ?? null;
}
/** Selects the repository branch / log / remote operations act on. */
export function selectRepo(root: string): Promise<void> {
if (useVcs.getState().repo === root) return Promise.resolve();
useVcs.setState({ repo: root, branches: [], branchesLoaded: false, stashes: [], stashesLoaded: false });
return refreshNow();
}
export function conflictedFiles(status: readonly FileStatus[] = useVcs.getState().status): FileStatus[] {
return status.filter((f) => effectiveKind(f) === "conflicted");
}
@@ -98,14 +154,41 @@ export function refreshNow(): Promise<void> {
useVcs.setState({ loading: true });
try {
const s = useVcs.getState();
const [status, branch, branches, stashes] = await Promise.all([
const scoped = Promise.all([
call(commands.vcsBranchInfo(s.repo)),
s.branchesLoaded ? call(commands.vcsBranches(s.repo)) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes(s.repo)) : Promise.resolve(s.stashes),
]).then(
(values) => ({ values }),
(error: unknown) => ({ error }),
);
const [vcsRoots, status, selected] = await Promise.all([
call(commands.vcsRoots()),
call(commands.vcsStatus()),
call(commands.vcsBranchInfo()),
s.branchesLoaded ? call(commands.vcsBranches()) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes()) : Promise.resolve(s.stashes),
scoped,
]);
const roots = vcsRoots.map((r) => r.root);
if ("error" in selected) {
// The selected repository is gone (removed since the last scan): fall back to the primary one.
if (!s.repo || roots.includes(s.repo)) throw selected.error;
useVcs.setState({ roots, repo: null, branchesLoaded: false, stashesLoaded: false });
void refreshNow();
return;
}
const [branch, branches, stashes] = selected.values;
const operations = Object.fromEntries(vcsRoots.filter((r) => r.operation).map((r) => [r.root, r.operation!]));
// `null` (before the first refresh) is the primary repository, listed first.
const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null);
const repo = attentionRepo(shown, s.operations, operations) ?? shown;
useVcs.setState({ operations });
if (repo !== s.repo || roots.join("\n") !== s.roots.join("\n")) useVcs.setState({ roots, repo });
applyStatus(status, branch);
useVcs.setState({ branches, stashes, error: null });
// Branch info and branches above are of another repository: load the selected one's.
if (repo !== shown || (s.repo && repo !== s.repo)) {
useVcs.setState({ branchesLoaded: false, stashesLoaded: false });
void refreshNow();
}
} catch (e) {
useVcs.setState({ error: e instanceof Error ? e.message : String(e) });
} finally {
@@ -127,13 +210,13 @@ export function applyStatus(status: FileStatus[], branch: BranchInfo | null) {
}
export async function loadBranches(): Promise<Branch[]> {
const branches = await call(commands.vcsBranches());
const branches = await call(commands.vcsBranches(currentRepo()));
useVcs.setState({ branches, branchesLoaded: true });
return branches;
}
export async function loadStashes(): Promise<Stash[]> {
const stashes = await call(commands.vcsStashes());
const stashes = await call(commands.vcsStashes(currentRepo()));
useVcs.setState({ stashes, stashesLoaded: true });
return stashes;
}
@@ -142,6 +225,17 @@ export function statusOf(path: string): FileStatus | undefined {
return useVcs.getState().byPath.get(stripSlash(path));
}
/** Repositories found below a project that is not itself under git (scanned in the background). */
async function discoverRoots() {
const root = useVcs.getState().root;
if (!root || !isTauri()) return;
const roots = await call(commands.vcsRoots()).catch(() => []);
if (!roots.length || useVcs.getState().root !== root || useVcs.getState().available) return;
useVcs.setState({ available: true });
setContext("vcsAvailable", true);
void refreshNow();
}
/** Tracks the open project and backend events. Returns a disposer. */
export function installVcsStore(): () => void {
const onProject = () => {
@@ -154,12 +248,16 @@ export function installVcsStore(): () => void {
setContext("vcsAvailable", available);
setFileDecorations("vcs", new Map());
if (available && isTauri()) void refreshNow();
else if (project) void discoverRoots();
};
onProject();
const unsubProject = useProject.subscribe((s, prev) => {
if (s.project !== prev.project) onProject();
});
const offVcs = onIdeEvent("vcsChanged", () => scheduleRefresh(200));
const offVcs = onIdeEvent("vcsChanged", () => {
if (useVcs.getState().available) scheduleRefresh(200);
else void discoverRoots();
});
const offFs = onIdeEvent("fsChanged", () => scheduleRefresh(250));
const offSettings = onWorkflowSettingsChange(
(s) => s.vcs.showDirtyDirectories,
+16 -3
View File
@@ -11,7 +11,11 @@ export interface LanguagesValid {
rust: { toolchain: string; stdlib: string; checkOnSave: boolean; checkCommand: "check" | "clippy"; rustfmtArgs: string };
go: { goroot: string; gopath: string };
typescript: { version: string };
java: { jdtlsVmArgs: string };
java: {
jdk: string;
jdtlsVmArgs: string;
maven: { home: string; userSettings: string; localRepository: string };
};
}
export interface ProjectStructureValid {
@@ -30,7 +34,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
go: { goroot: "", gopath: "" },
typescript: { version: "bundled" },
java: { jdtlsVmArgs: "" },
java: { jdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } },
};
export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = {
@@ -76,6 +80,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
const go = obj(l.go);
const ts = obj(l.typescript);
const java = obj(l.java);
const maven = obj(java.maven);
return {
python: {
interpreter: text(py.interpreter, d.python.interpreter),
@@ -94,7 +99,15 @@ export function validateLanguages(raw: unknown): LanguagesValid {
},
go: { goroot: text(go.goroot, d.go.goroot), gopath: text(go.gopath, d.go.gopath) },
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
java: { jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs) },
java: {
jdk: text(java.jdk, d.java.jdk),
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
maven: {
home: text(maven.home, d.java.maven.home),
userSettings: text(maven.userSettings, d.java.maven.userSettings),
localRepository: text(maven.localRepository, d.java.maven.localRepository),
},
},
};
}
+19
View File
@@ -345,6 +345,7 @@
"Compare with Clipboard": "与剪贴板比较",
"Compare with Local": "与本地比较",
"Compilation failed: fix the errors and reload again.": "编译失败:请修复错误后重新加载。",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "用于编译和运行项目:jdtls 的默认运行时、运行/调试,以及 Maven 和 Gradle 的 JAVA_HOME。",
"Compiles the module of the current file": "编译当前文件所在的模块",
"Compiles the project's sources and tests with its build system": "使用项目的构建系统编译其源代码和测试",
"Completions": "补全",
@@ -629,6 +630,7 @@
"Evaluate expression (↩) or add a watch (⇧⌘↩)": "计算表达式 (↩) 或添加监视 (⇧⌘↩)",
"Evaluate in the debug console": "在调试控制台中计算",
"Evaluation result": "计算结果",
"Everything is up to date": "一切都是最新的",
"Exception Breakpoints": "异常断点",
"Exception breakpoints": "异常断点",
"Exclude": "排除",
@@ -766,6 +768,7 @@
"Fragment": "片段",
"Frames": "帧",
"From Template…": "从模板…",
"From settings.xml, else ~/.m2/repository": "取自 settings.xml,否则为 ~/.m2/repository",
"Function": "函数",
"Function component": "函数组件",
"Function with return type": "带返回类型的函数",
@@ -1001,6 +1004,7 @@
"Local History of all projects will be deleted.": "所有项目的本地历史记录都将被删除。",
"Local History: Put Label…": "本地历史记录:添加标签…",
"Local History: Show History": "本地历史记录:显示历史记录",
"Local repository": "本地仓库",
"Locate Duplicates": "查找重复项",
"Locate Duplicates…": "查找重复项…",
"Location": "位置",
@@ -1036,8 +1040,10 @@
"Matches": "匹配项",
"Maven actions": "Maven 操作",
"Maven command line": "Maven 命令行",
"Maven home path": "Maven 主路径",
"Maven projects": "Maven 项目",
"Maven sync finished with errors": "Maven 同步完成,但有错误",
"Maven wrapper, else mvn on PATH": "Maven wrapper,否则使用 PATH 中的 mvn",
"Maximize": "最大化",
"Maximize Tool Window": "最大化工具窗口",
"Maximum local history size per project": "每个项目的最大本地历史记录大小",
@@ -1301,6 +1307,8 @@
"Parses (hit / incr. / full)": "解析(命中/增量/完整)",
"Partially staged: only staged hunks are committed": "部分暂存:仅提交已暂存的块",
"Passed": "已通过",
"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. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。",
"Password": "密码",
@@ -1394,6 +1402,7 @@
"Project Errors": "项目错误",
"Project Files": "项目文件",
"Project GOPATH": "项目 GOPATH",
"Project JDK": "项目 JDK",
"Project Production Files": "项目生产文件",
"Project Settings": "项目设置",
"Project Structure": "项目结构",
@@ -1428,6 +1437,7 @@
"Purple": "紫色",
"Push": "推送",
"Push All up to Here…": "推送到此处为止的所有提交…",
"Push Commits": "推送提交",
"Push rejected": "推送被拒绝",
"Push tags:": "推送标签:",
"Push…": "推送…",
@@ -1556,6 +1566,7 @@
"Replacements": "替换项",
"Replacing…": "正在替换…",
"Reports errors and warnings from language servers.": "报告来自语言服务器的错误和警告。",
"Repository": "仓库",
"Request": "请求",
"Request Changes…": "请求更改…",
"Require": "必需",
@@ -1669,6 +1680,7 @@
"Runs Maven with -o": "使用 -o 运行 Maven",
"Runs Optimize Imports when the file has no errors, unless it would change the caret line.": "文件没有错误时运行“优化导入”,除非这会更改光标所在行。",
"Runs npm scripts and Node-based language servers (vtsls, eslint, pyright…).": "运行 npm 脚本和基于 Node 的语言服务器(vtsls、eslint、pyright…)。",
"Runs sync, builds and Maven run configurations. A chosen home is used instead of the project's mvnw.": "用于同步、构建和 Maven 运行配置。选定后将代替项目的 mvnw。",
"Runs the language server's Organize Imports action.": "运行语言服务器的“整理导入”操作。",
"Runs the language server's formatter before an explicit save (Actions on Save).": "在显式保存前运行语言服务器的格式化程序(保存时操作)。",
"Runs this project": "运行此项目",
@@ -1727,6 +1739,10 @@
"Select Base Interpreter": "选择基础解释器",
"Select Folders…": "选择文件夹…",
"Select GOPATH": "选择 GOPATH",
"Select JDK Home Directory": "选择 JDK 主目录",
"Select Local Repository": "选择本地仓库",
"Select Maven Home Directory": "选择 Maven 主目录",
"Select Maven settings.xml": "选择 Maven settings.xml",
"Select Next Tab": "选择下一个标签页",
"Select Opened File": "选择已打开的文件",
"Select Previous Tab": "选择上一个标签页",
@@ -2140,6 +2156,7 @@
"Used for new files.": "用于新文件。",
"User": "用户",
"User name or email": "用户名或电子邮件",
"User settings file": "用户设置文件",
"User:": "用户:",
"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent.": "使用 ~/.ssh/config 中的 HostName、User、Port、IdentityFile 和 ProxyJump,以及 SSH 代理中的密钥。",
"VCS Operations": "VCS 操作",
@@ -2242,6 +2259,7 @@
"match expression": "match 表达式",
"match one node,": "匹配一个节点,",
"new column definition": "新列定义",
"new remote branch": "新远程分支",
"new table definition": "新表定义",
"new view definition": "新视图定义",
"no file is open in the editor": "编辑器中没有打开的文件",
@@ -2251,6 +2269,7 @@
"occurrences ·": "个匹配项 ·",
"or click a line number to set a breakpoint.": "或点击行号设置断点。",
"or pick one in the toolbar.": "或在工具栏中选择一个。",
"per module, the JDK of its Java version": "按模块使用与其 Java 版本匹配的 JDK",
"pip output": "pip 输出",
"places the caret. A line with": "放置光标。包含",
"println! with Debug": "使用 Debug 的 println!",