mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-12 11:31:28 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22947c7b9 | ||
|
|
6e16d3cab4 | ||
|
|
5362259e57 | ||
|
|
c381bab9ec | ||
|
|
75a119e3db | ||
|
|
37bab41f4b | ||
|
|
192eaff6c2 |
No files matched your search
@@ -79,23 +79,26 @@ fn scan(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
/// One planned step as fingerprinted: resolved program, args, cwd and env.
|
||||
type StepFingerprint = (PathBuf, Vec<String>, PathBuf, BTreeMap<String, String>);
|
||||
|
||||
/// Environment, planned steps and input paths shared by every Maven build
|
||||
/// fingerprint: the filtered process environment plus the wrapper, settings
|
||||
/// and JDK files the planned invocation reads.
|
||||
struct Collected {
|
||||
inputs: Vec<PathBuf>,
|
||||
environment: BTreeMap<String, String>,
|
||||
steps: Vec<StepFingerprint>,
|
||||
inherited_hash: u64,
|
||||
}
|
||||
|
||||
fn collect(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
projects: &[&maven::MavenProject],
|
||||
plan: &BuildPlan,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
) -> IdeResult<Option<Collected>> {
|
||||
let Some(mut inputs) = maven::launch_inputs(root, projects, local_repo) else { return Ok(None) };
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
@@ -165,23 +168,100 @@ pub fn launch_fingerprint(
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
let steps = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| (process::program_path(&s.program, &s.env), s.args.clone(), s.cwd.clone(), s.env.clone()))
|
||||
.collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(Some(Collected { inputs, environment, steps, inherited_hash: inherited_hash.finish() }))
|
||||
}
|
||||
|
||||
/// Scans every input, storing project inputs relative to the project.
|
||||
fn scan_all(
|
||||
root: &Path,
|
||||
inputs: Vec<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<BTreeMap<PathBuf, Option<Stamp>>> {
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
Ok(files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let Some(collected) = collect(root, &projects, &plan, local_repo)? else { return Ok(None) };
|
||||
let Collected { inputs, environment, steps, inherited_hash } = collected;
|
||||
let parameters = serde_json::to_string(&(root, module, test, steps, environment, inherited_hash, projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
|
||||
}
|
||||
|
||||
/// Inputs fingerprint of a menu build (`Build Project` / `Build Module`): the
|
||||
/// built modules' sources, resources and POM chain plus the exact planned
|
||||
/// steps, so an unchanged project skips Maven entirely (#2476). Only `Build`
|
||||
/// callers reuse it, never `Rebuild` or `Artifacts`; `None` disables reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn menu_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let plan = plan(root, model, BuildKind::Build, file, None)?;
|
||||
if plan.system != BuildSystem::Maven {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects: Vec<&maven::MavenProject> = match file {
|
||||
Some(file) => {
|
||||
// The innermost linked project owning the file, as in single-file compilation.
|
||||
let rel = rel_str(root, file);
|
||||
let module = m
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
|
||||
.max_by_key(|p| p.dir.len());
|
||||
match module {
|
||||
Some(project) if project.packaging != "pom" => {
|
||||
maven_launch_projects(root, m, &subdir(root, &project.dir), true)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
// A project build walks every linked project.
|
||||
None => m.projects.iter().collect(),
|
||||
};
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(collected) = collect(root, &projects, &plan, None)? else { return Ok(None) };
|
||||
let Collected { inputs, environment, steps, inherited_hash } = collected;
|
||||
let scope = file.map(|f| rel_str(root, f));
|
||||
let parameters = serde_json::to_string(&("menu-build", scope, steps, environment, inherited_hash))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files: scan_all(root, inputs, cancel)? }))
|
||||
}
|
||||
|
||||
impl LaunchFingerprint {
|
||||
|
||||
@@ -5,8 +5,14 @@
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
mod single;
|
||||
mod up_to_date;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint, menu_fingerprint};
|
||||
pub use single::{
|
||||
SingleFileTarget, compile_file_plan, find_javac, javac_step, single_file_classpath, single_file_target,
|
||||
};
|
||||
pub use up_to_date::{menu_up_to_date, record_menu_build};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Single-file Java compilation with `javac` for the Compile File action (#2476).
|
||||
//!
|
||||
//! A module build (`-pl … -am`) still walks every upstream module, and packaging
|
||||
//! walks the whole reactor; compiling one file only needs that file's module
|
||||
//! outputs plus its dependency classpath, which is much faster to assemble.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command as ProcCommand;
|
||||
|
||||
/// Output file of the `dependency:build-classpath` probe, relative to each module.
|
||||
const CP_FILE: &str = "target/ide-compile-cp.txt";
|
||||
|
||||
/// A `.java` source and the module build that compiles it.
|
||||
pub struct SingleFileTarget {
|
||||
/// Absolute module directory.
|
||||
pub module: PathBuf,
|
||||
/// The source is under `src/test`: compile to test classes with the test classpath.
|
||||
pub test: bool,
|
||||
/// Absolute `target/classes` (or `target/test-classes`).
|
||||
pub out_dir: PathBuf,
|
||||
/// Reactor root building the module (owns the Maven command and options).
|
||||
pub top: maven::MavenProject,
|
||||
/// `-pl` value of the module relative to the reactor root; empty when the
|
||||
/// module is the root itself.
|
||||
pub pl: String,
|
||||
/// `maven.compiler.release` (or equivalent) of the module's project.
|
||||
pub java_version: Option<u32>,
|
||||
}
|
||||
|
||||
/// Locates the linked Maven module owning a `.java` source file.
|
||||
pub fn single_file_target(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<SingleFileTarget> {
|
||||
if file.extension().is_none_or(|ext| ext != "java") {
|
||||
return Err(IdeError::invalid("only Java sources can be compiled as a single file"));
|
||||
}
|
||||
if !file.is_file() {
|
||||
return Err(IdeError::not_found("no Java source found for single-file compilation"));
|
||||
}
|
||||
let Some(m) = &model.maven else {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
};
|
||||
// The innermost project whose directory contains the file owns it; the
|
||||
// aggregator root (`""`) is a prefix of everything, so take the longest match.
|
||||
let rel = rel_str(root, file);
|
||||
let project = m
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| rel == p.dir || rel.starts_with(&format!("{}/", p.dir)) || p.dir.is_empty())
|
||||
.max_by_key(|p| p.dir.len())
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
if project.packaging == "pom" {
|
||||
// An aggregator owns no sources itself: a file that only matches it
|
||||
// belongs to no linked module.
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let module = subdir(root, &project.dir);
|
||||
let top = maven_reactor(m, &project.dir)
|
||||
.ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
let pl = module.strip_prefix(&top_dir).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
|
||||
// Test sources see the test classpath and compile into test classes.
|
||||
let parts: Vec<&str> = rel.split('/').collect();
|
||||
let test = parts.windows(2).any(|w| w == ["src", "test"]);
|
||||
let out_dir = module.join("target").join(if test { "test-classes" } else { "classes" });
|
||||
Ok(SingleFileTarget { module, test, out_dir, top: top.clone(), pl, java_version: project.java_version })
|
||||
}
|
||||
|
||||
/// `javac` for the module: its `JAVA_HOME`, else `PATH`.
|
||||
pub fn find_javac(env: &BTreeMap<String, String>) -> String {
|
||||
let home = env.get("JAVA_HOME").map(Path::new);
|
||||
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
|
||||
if let Some(javac) = home.map(|h| h.join("bin").join(exe)).filter(|p| p.is_file()) {
|
||||
return javac.to_string_lossy().into_owned();
|
||||
}
|
||||
exe.to_owned()
|
||||
}
|
||||
|
||||
/// Dependency classpath of the module: existing reactor class directories plus
|
||||
/// `mvn dependency:build-classpath` (which resolves without compiling anything,
|
||||
/// so `-am` stays cheap). Entries are platform-separated (`;` on Windows).
|
||||
pub fn single_file_classpath(root: &Path, model: &BuildModel, target: &SingleFileTarget) -> IdeResult<String> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
// Upstream reactor modules first: their freshly built classes shadow
|
||||
// installed snapshots of the same artifacts.
|
||||
for project in maven_launch_projects(root, m, &target.module, target.test) {
|
||||
let dir = subdir(root, &project.dir);
|
||||
if dir == target.module {
|
||||
continue;
|
||||
}
|
||||
for classes in ["target/classes", "target/test-classes"] {
|
||||
let path = dir.join(classes);
|
||||
if path.is_dir() {
|
||||
entries.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let probe = CP_FILE;
|
||||
let mut args = vec!["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG];
|
||||
args.extend(target.top.options.iter().map(String::as_str));
|
||||
if !target.pl.is_empty() {
|
||||
args.extend(["-pl", target.pl.as_str(), "-am"]);
|
||||
}
|
||||
let output_arg = format!("-Dmdep.outputFile={probe}");
|
||||
args.extend(["dependency:build-classpath", output_arg.as_str(), "-Dmdep.includeScope=test", "-q"]);
|
||||
let top_dir = subdir(root, &target.top.dir);
|
||||
let status = ProcCommand::new(&target.top.command)
|
||||
.args(&args)
|
||||
.envs(&target.top.env)
|
||||
.current_dir(&top_dir)
|
||||
.output()
|
||||
.map_err(|e| IdeError::internal(format!("cannot resolve the compile classpath: {e}")))?;
|
||||
if !status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||
let tail: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let tail = tail.iter().rev().take(5).rev().cloned().collect::<Vec<_>>().join("\n");
|
||||
return Err(IdeError::internal(format!("cannot resolve the compile classpath:\n{tail}")));
|
||||
}
|
||||
// With `-am` every visited module writes its own file; only this module's counts.
|
||||
let cp_file = target.module.join(probe);
|
||||
let content = fs::read_to_string(&cp_file).map_err(|e| {
|
||||
IdeError::internal(format!("cannot resolve the compile classpath: {} ({e})", cp_file.display()))
|
||||
})?;
|
||||
let separator = if cfg!(windows) { ';' } else { ':' };
|
||||
entries.extend(content.split([separator, '\n', '\r']).map(str::trim).filter(|e| !e.is_empty()).map(str::to_owned));
|
||||
entries.dedup();
|
||||
Ok(entries.join(&separator.to_string()))
|
||||
}
|
||||
|
||||
/// The `javac` step compiling `file` into `target`'s output directory.
|
||||
pub fn javac_step(target: &SingleFileTarget, file: &Path, classpath: &str, javac: &str) -> IdeResult<BuildStep> {
|
||||
fs::create_dir_all(&target.out_dir)
|
||||
.map_err(|e| IdeError::internal(format!("cannot create {} ({e})", target.out_dir.display())))?;
|
||||
let mut step = BuildStep::new(javac, &[], &target.module, Format::Gradle);
|
||||
// Raw `javac` prints `path:line: error: …`, the same form Gradle forwards.
|
||||
step.args.push("-encoding".into());
|
||||
step.args.push("UTF-8".into());
|
||||
step.args.push("-d".into());
|
||||
step.args.push(target.out_dir.to_string_lossy().into_owned());
|
||||
step.args.push("-cp".into());
|
||||
step.args.push(classpath.to_owned());
|
||||
if let Some(version) = target.java_version {
|
||||
step.args.push("--release".into());
|
||||
step.args.push(version.to_string());
|
||||
}
|
||||
step.args.push(file.to_string_lossy().into_owned());
|
||||
step.env = target.top.env.clone();
|
||||
Ok(step)
|
||||
}
|
||||
|
||||
/// Resolves the classpath and plans the `javac` invocation for one Java source.
|
||||
pub fn compile_file_plan(root: &Path, model: &BuildModel, file: &Path) -> IdeResult<BuildPlan> {
|
||||
let target = single_file_target(root, model, file)?;
|
||||
let classpath = single_file_classpath(root, model, &target)?;
|
||||
let javac = find_javac(&target.top.env);
|
||||
let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok(BuildPlan {
|
||||
system: BuildSystem::Maven,
|
||||
title: format!("Compile {name}"),
|
||||
steps: vec![javac_step(&target, file, &classpath, &javac)?],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "app/src/main/java/demo/Main.java", "class Main {}");
|
||||
write(root, "app/src/test/java/demo/MainTest.java", "class MainTest {}");
|
||||
write(root, "lib/target/classes/Lib.class", "built");
|
||||
crate::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_finds_the_owning_module_and_the_test_scope() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let main = single_file_target(root, &model, &root.join("app/src/main/java/demo/Main.java")).unwrap();
|
||||
assert_eq!(main.module, root.join("app"));
|
||||
assert!(!main.test);
|
||||
assert_eq!(main.out_dir, root.join("app/target/classes"));
|
||||
let test = single_file_target(root, &model, &root.join("app/src/test/java/demo/MainTest.java")).unwrap();
|
||||
assert!(test.test);
|
||||
assert_eq!(test.out_dir, root.join("app/target/test-classes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_rejects_non_java_missing_and_unlinked_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
assert!(single_file_target(root, &model, &root.join("app/pom.xml")).is_err());
|
||||
assert!(single_file_target(root, &model, &root.join("app/src/main/java/demo/Missing.java")).is_err());
|
||||
write(root, "other/Main.java", "class Main {}");
|
||||
assert!(single_file_target(root, &model, &root.join("other/Main.java")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javac_prefers_the_module_jdk_and_scopes_test_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let file = root.join("app/src/test/java/demo/MainTest.java");
|
||||
let target = single_file_target(root, &model, &file).unwrap();
|
||||
let jdk = root.join("jdk");
|
||||
fs::create_dir_all(jdk.join("bin")).unwrap();
|
||||
let exe = if cfg!(windows) { "javac.exe" } else { "javac" };
|
||||
fs::write(jdk.join("bin").join(exe), "fake").unwrap();
|
||||
let mut target = target;
|
||||
target.top.env.insert("JAVA_HOME".into(), jdk.to_string_lossy().into_owned());
|
||||
let step = javac_step(&target, &file, "/repo/a.jar", &find_javac(&target.top.env)).unwrap();
|
||||
assert!(step.program.ends_with(exe));
|
||||
assert!(target.out_dir.is_dir());
|
||||
let args = step.args.join(" ");
|
||||
assert!(args.contains("-encoding UTF-8"), "{args}");
|
||||
assert!(args.contains("-d ") && args.contains("test-classes"), "{args}");
|
||||
assert!(args.contains("-cp /repo/a.jar"), "{args}");
|
||||
assert!(args.contains("MainTest.java"), "{args}");
|
||||
// No `JAVA_HOME`: plain `javac` from `PATH`.
|
||||
assert_eq!(find_javac(&BTreeMap::new()), exe);
|
||||
}
|
||||
|
||||
/// The classpath probe runs the project's Maven command with `-pl … -am`
|
||||
/// `dependency:build-classpath`; the fake `mvnw` below only records its
|
||||
/// arguments and writes a canned classpath file for the `-pl` module.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn classpath_probe_writes_one_file_per_module_and_reads_the_compiled_one() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\npl='.'\nwhile [ $# -gt 0 ]; do\nif [ \"$1\" = \"-pl\" ]; then pl=\"$2\"; shift 2; else shift; fi\ndone\nmkdir -p \"$pl/target\"\nprintf '/repo/a.jar:/repo/b.jar' > \"$pl/target/ide-compile-cp.txt\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let model = crate::load(root, &root.join("cache"), None);
|
||||
let file = root.join("app/src/main/java/demo/Main.java");
|
||||
let target = single_file_target(root, &model, &file).unwrap();
|
||||
assert!(target.top.command.ends_with("mvnw"));
|
||||
assert_eq!(target.pl, "app");
|
||||
let cp = single_file_classpath(root, &model, &target).unwrap();
|
||||
let separator = ':';
|
||||
// Reactor class directories first, so fresh outputs shadow installed snapshots.
|
||||
let lib = root.join("lib/target/classes").to_string_lossy().into_owned();
|
||||
assert!(cp.starts_with(&lib), "{cp}");
|
||||
assert!(cp.contains("/repo/a.jar"), "{cp}");
|
||||
assert!(cp.contains("/repo/b.jar"), "{cp}");
|
||||
assert!(cp.split(separator).count() >= 3, "{cp}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Up-to-date checks for menu builds (`Build Project` / `Build Module`, #2476).
|
||||
//!
|
||||
//! Menu builds have no fingerprint record on the launch path, so a second
|
||||
//! click always pays a full offline Maven run. This module stores one JSON
|
||||
//! record per scope (project, or one module file's build) after a successful
|
||||
//! build and compares it before the next build; on a match the build is
|
||||
//! skipped entirely. Like launch reuse, timestamp-preserving external edits
|
||||
//! are re-stamped first so Maven actually recompiles them on the next run.
|
||||
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Record of the last successful menu build of one scope.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Record {
|
||||
fingerprint: LaunchFingerprint,
|
||||
}
|
||||
|
||||
fn record_path(cache: &Path, root: &Path, file: Option<&Path>) -> PathBuf {
|
||||
let base = cache.join("menu-build").join("records");
|
||||
match file {
|
||||
Some(f) => base.join(f.strip_prefix(root).unwrap_or(f)).with_extension("json"),
|
||||
None => base.join("project.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-stamps files whose content changed without advancing mtime (git
|
||||
/// checkout, external sync): timestamp-incremental Maven would otherwise skip
|
||||
/// them as "unchanged" and the rebuild would compile nothing.
|
||||
fn restamp(root: &Path, edits: &[PathBuf]) {
|
||||
let now = std::time::SystemTime::now();
|
||||
for rel in edits {
|
||||
let path = root.join(rel);
|
||||
let _ = fs::File::options().write(true).open(&path).and_then(|f| f.set_modified(now)).or_else(|_| {
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.and_then(|f| f.set_modified(now))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last successful menu build of this scope is still fresh: its
|
||||
/// record exists and the fingerprint matches. On a mismatch caused by
|
||||
/// timestamp-preserving external edits the files are re-stamped first, so the
|
||||
/// rebuild Maven runs actually recompiles them.
|
||||
pub fn menu_up_to_date(
|
||||
cache: &Path,
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) -> bool {
|
||||
let current = match menu_fingerprint(root, model, file, cancel) {
|
||||
Ok(Some(current)) => current,
|
||||
_ => return false,
|
||||
};
|
||||
let bytes = match fs::read(record_path(cache, root, file)) {
|
||||
Ok(bytes) => bytes,
|
||||
_ => return false,
|
||||
};
|
||||
let record: Record = match serde_json::from_slice(&bytes) {
|
||||
Ok(record) => record,
|
||||
_ => return false,
|
||||
};
|
||||
if record.fingerprint != current {
|
||||
restamp(root, &record.fingerprint.mtime_preserved_edits(¤t));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Records a successful menu build as the baseline for future up-to-date
|
||||
/// checks. Failures are swallowed: a missing record only costs one Maven run.
|
||||
pub fn record_menu_build(
|
||||
cache: &Path,
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
file: Option<&Path>,
|
||||
cancel: &CancellationToken,
|
||||
) {
|
||||
let current = match menu_fingerprint(root, model, file, cancel) {
|
||||
Ok(Some(current)) => current,
|
||||
_ => return,
|
||||
};
|
||||
// A torn write fails to parse later and is treated as "no record", so a
|
||||
// plain write is enough.
|
||||
let path = record_path(cache, root, file);
|
||||
if path.parent().is_some_and(|p| fs::create_dir_all(p).is_err()) {
|
||||
return;
|
||||
}
|
||||
let bytes = match serde_json::to_vec(&Record { fingerprint: current }) {
|
||||
Ok(bytes) => bytes,
|
||||
_ => return,
|
||||
};
|
||||
let _ = fs::write(&path, bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
maven_two_module(root, "int x;");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn maven_two_module(root: &Path, extra_app: &str) {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><groupId>t</groupId><artifactId>root</artifactId><version>1</version><packaging>pom</packaging><modules><module>app</module><module>lib</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>app</artifactId><dependencies><dependency><groupId>t</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><modelVersion>4.0.0</modelVersion><parent><groupId>t</groupId><artifactId>root</artifactId><version>1</version></parent><artifactId>lib</artifactId></project>"#,
|
||||
);
|
||||
write(root, "app/src/main/java/t/A.java", &format!("package t; public class A {{ {extra_app} }}"));
|
||||
write(root, "lib/src/main/java/t/L.java", "package t; public class L {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_then_skip_without_changes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
// The project scope is independent of the module scope.
|
||||
assert!(!menu_up_to_date(&cache, root, &model, None, &cancel));
|
||||
record_menu_build(&cache, root, &model, None, &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, None, &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_invalidates_and_rerecord_restores_skip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
write(root, "app/src/main/java/t/A.java", "package t; public class A { int y; }");
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
assert!(menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtime_preserved_edit_forces_rebuild_and_restamps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let rel = "app/src/main/java/t/A.java";
|
||||
let file = root.join(rel);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
record_menu_build(&cache, root, &model, Some(&file), &cancel);
|
||||
let before = fs::metadata(&file).unwrap().modified().unwrap();
|
||||
// Same length, same mtime, different content: an external edit that
|
||||
// preserved the timestamp.
|
||||
write(root, rel, "package t; public class A { int z; }");
|
||||
fs::File::options().write(true).open(&file).and_then(|f| f.set_modified(before)).unwrap();
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
assert!(fs::metadata(&file).unwrap().modified().unwrap() > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_record_never_skips() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let file = root.join("app/src/main/java/t/A.java");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
fs::create_dir_all(cache.join("menu-build").join("records").join("app/src/main/java/t")).unwrap();
|
||||
fs::write(cache.join("menu-build").join("records").join("app/src/main/java/t/A.json"), b"{not json").unwrap();
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&file), &cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_builds_never_skip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(root, "other/Main.java", "public class Main {}");
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
// A file outside any non-aggregator module has no fingerprint.
|
||||
let stray = root.join("other/Main.java");
|
||||
record_menu_build(&cache, root, &model, Some(&stray), &cancel);
|
||||
assert!(!menu_up_to_date(&cache, root, &model, Some(&stray), &cancel));
|
||||
}
|
||||
}
|
||||
@@ -538,6 +538,35 @@ pub async fn build_run(
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
// Fast path (#2476): an unchanged single-project Maven build skips
|
||||
// Maven entirely. Previous diagnostics stay: nothing changed, so they
|
||||
// are still accurate.
|
||||
let single = cmake.is_none() && ide_workspace::workspace::Workspace::load(&root)?.is_none();
|
||||
if single && kind == BuildKind::Build {
|
||||
let model = with_profiles(load(&root, &cache, &settings), &cache);
|
||||
if compile::menu_up_to_date(&cache, &root, &model, file.as_deref(), &token) {
|
||||
let started = std::time::Instant::now();
|
||||
let plan = prepare_plan(
|
||||
compile::plan(&root, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
|
||||
&root,
|
||||
&settings,
|
||||
);
|
||||
let _ = on_event.send(BuildEvent::Started { system: plan.system, title: plan.title.clone() });
|
||||
let _ = on_event.send(BuildEvent::Output {
|
||||
text: "Up to date: no changes, build skipped.\n".into(),
|
||||
stderr: false,
|
||||
});
|
||||
return Ok(BuildResult {
|
||||
system: plan.system,
|
||||
title: plan.title,
|
||||
success: true,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
messages: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
@@ -584,6 +613,51 @@ pub async fn build_run(
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
if result.success && single && kind == BuildKind::Build {
|
||||
let model = with_profiles(load(&root, &cache, &settings), &cache);
|
||||
compile::record_menu_build(&cache, &root, &model, file.as_deref(), &token);
|
||||
}
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Build › Compile File: compiles one Java source of a linked Maven module with
|
||||
/// `javac`, streaming the console and messages on `on_event`; cancellable with
|
||||
/// `cancel_request(request)`. Diagnostics of `clear` (the files of the previous
|
||||
/// build) are cleared first; the new messages are published as `build`
|
||||
/// diagnostics.
|
||||
pub async fn build_compile_file(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
file: std::path::PathBuf,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::compile_file_plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), &file)?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
@@ -653,6 +727,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("build_compile_file", |c| async move {
|
||||
build_compile_file(&c.host, &c.window, c.arg("file")?, c.arg("request")?, c.arg("clear")?, c.sender("onEvent")?)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -173,3 +173,29 @@ pub async fn build_run(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Build › Compile File: compiles one Java source of a linked Maven module with
|
||||
/// `javac`, streaming the console and messages on `on_event`; cancellable with
|
||||
/// `cancel_request(request)`. Diagnostics of `clear` (the files of the previous
|
||||
/// build) are cleared first; the new messages are published as `build`
|
||||
/// diagnostics.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn build_compile_file(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
file: std::path::PathBuf,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
ide_host::ops::build::build_compile_file(
|
||||
&state.host,
|
||||
window.label(),
|
||||
file,
|
||||
request,
|
||||
clear,
|
||||
crate::commands::sender(on_event),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -408,6 +408,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
build::maven_exclude_dependency,
|
||||
build::maven_goto_declaration,
|
||||
build::build_run,
|
||||
build::build_compile_file,
|
||||
java::java_resolve_launch,
|
||||
java::java_debug_server,
|
||||
java::java_build_workspace,
|
||||
|
||||
@@ -826,6 +826,14 @@ export const commands = {
|
||||
* are cleared first; the new messages are published as `build` diagnostics.
|
||||
*/
|
||||
buildRun: (kind: BuildKind, file: string | null, cmake: string | null, request: RequestId, clear: string[], onEvent: Channel<BuildEvent>) => typedError<BuildResult, IdeError>(__TAURI_INVOKE("build_run", { kind, file, cmake, request, clear, onEvent })),
|
||||
/**
|
||||
* Build › Compile File: compiles one Java source of a linked Maven module with
|
||||
* `javac`, streaming the console and messages on `on_event`; cancellable with
|
||||
* `cancel_request(request)`. Diagnostics of `clear` (the files of the previous
|
||||
* build) are cleared first; the new messages are published as `build`
|
||||
* diagnostics.
|
||||
*/
|
||||
buildCompileFile: (file: string, request: RequestId, clear: string[], onEvent: Channel<BuildEvent>) => typedError<BuildResult, IdeError>(__TAURI_INVOKE("build_compile_file", { file, request, clear, onEvent })),
|
||||
/**
|
||||
* Cancellable with `cancel_request(request)`.
|
||||
* Builds according to `policy`, then resolves the runtime (or test)
|
||||
|
||||
@@ -30,7 +30,16 @@ import {
|
||||
rootNode,
|
||||
MAX_CONSOLE_LINES,
|
||||
} from "./model";
|
||||
import { BUILD_TASK_ID, setBuildBackend, startBuild, stopBuild, useBuildRun, type BuildBackend } from "./store";
|
||||
import {
|
||||
BUILD_TASK_ID,
|
||||
rerunBuild,
|
||||
setBuildBackend,
|
||||
startBuild,
|
||||
startCompileFile,
|
||||
stopBuild,
|
||||
useBuildRun,
|
||||
type BuildBackend,
|
||||
} from "./store";
|
||||
|
||||
const err = (path: string | null, line: number, text = "cannot find symbol"): BuildMessage => ({
|
||||
severity: "error",
|
||||
@@ -107,6 +116,14 @@ class FakeBackend implements BuildBackend {
|
||||
this.fail = reject;
|
||||
});
|
||||
}
|
||||
compileFile(file: string, _request: number, clear: string[], onEvent: (e: BuildEvent) => void) {
|
||||
this.calls.push({ kind: "compileFile", file, clear });
|
||||
this.emit = onEvent;
|
||||
return new Promise<BuildResult>((resolve, reject) => {
|
||||
this.finish = resolve;
|
||||
this.fail = reject;
|
||||
});
|
||||
}
|
||||
cancel(request: number) {
|
||||
this.cancelled.push(request);
|
||||
}
|
||||
@@ -392,4 +409,38 @@ describe("build feature", () => {
|
||||
expect(useBuildRun.getState().status).toBe("cancelled");
|
||||
expect(notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("compiles one file without its module and reruns it from the tool window", async () => {
|
||||
const run = startCompileFile("/p/core/src/A.java");
|
||||
await act(async () => {});
|
||||
expect(fake.calls.at(-1)).toMatchObject({ kind: "compileFile", file: "/p/core/src/A.java" });
|
||||
expect(useBuildRun.getState().title).toBe("Compile File");
|
||||
await act(async () => {
|
||||
fake.resolve(result([], true));
|
||||
await run;
|
||||
});
|
||||
expect(useBuildRun.getState().status).toBe("success");
|
||||
// Rerun repeats the single-file compilation, not a module build.
|
||||
const rerun = rerunBuild();
|
||||
await act(async () => {});
|
||||
expect(fake.calls.at(-1)).toMatchObject({ kind: "compileFile", file: "/p/core/src/A.java" });
|
||||
await act(async () => {
|
||||
fake.resolve(result([], true));
|
||||
await rerun;
|
||||
});
|
||||
expect(useBuildRun.getState().status).toBe("success");
|
||||
});
|
||||
|
||||
it("runs Compile File for the active Java file", async () => {
|
||||
provideEditorService({
|
||||
openFile,
|
||||
saveAll: () => Promise.resolve(),
|
||||
activePath: () => "/p/core/src/A.java",
|
||||
} as unknown as EditorService);
|
||||
await executeAction("build.compileFile", { activate: false });
|
||||
expect(fake.calls.at(-1)).toMatchObject({ kind: "compileFile", file: "/p/core/src/A.java" });
|
||||
await act(async () => {
|
||||
fake.resolve(result([], true));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/workbench/contributions";
|
||||
import { BuildTitleItem } from "./BuildTitleItem";
|
||||
import { BuildHeaderActions, BuildView } from "./BuildView";
|
||||
import { BUILD_RUNNING, resetBuildRun, startBuild, stopBuild, useBuildRun } from "./store";
|
||||
import { BUILD_RUNNING, resetBuildRun, startBuild, startCompileFile, stopBuild, useBuildRun } from "./store";
|
||||
|
||||
const WHEN = `projectOpen && !${BUILD_RUNNING}`;
|
||||
|
||||
@@ -86,6 +86,20 @@ export function activate(): () => void {
|
||||
if ("wait" in options && options.wait === true) return build.then(() => undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "build.compileFile",
|
||||
title: "Compile File",
|
||||
description: "Compiles the current Java file with javac, without building its module",
|
||||
category: "Build",
|
||||
when: WHEN,
|
||||
keywords: ["javac", "single file", "recompile"],
|
||||
run: ({ arg }) => {
|
||||
const options = typeof arg === "object" && arg !== null ? arg : {};
|
||||
const activate = !("activate" in options && options.activate === false);
|
||||
const file = moduleTarget(arg);
|
||||
if (file) void startCompileFile(file, activate);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "build.rebuildProject",
|
||||
title: "Rebuild Project",
|
||||
@@ -131,6 +145,18 @@ export function activate(): () => void {
|
||||
? [{ kind: "action", action: "build.buildModule", arg: ctx.paths[0] }]
|
||||
: [],
|
||||
}),
|
||||
// One Java file in the editor, its tab or the project view: compile it with
|
||||
// javac instead of building its module.
|
||||
registerMenuGroup({
|
||||
id: "build.compileFile",
|
||||
menus: ["editor", "editorTab", "projectView"],
|
||||
order: 40,
|
||||
items: (_menu, { paths, dirs }) => {
|
||||
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
|
||||
if (!path || !/\.java$/i.test(path) || !useProject.getState().project) return [];
|
||||
return [{ kind: "action", action: "build.compileFile", arg: path }];
|
||||
},
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetBuildRun();
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,12 @@ export interface BuildBackend {
|
||||
onEvent: (e: BuildEvent) => void,
|
||||
cmake: string | null,
|
||||
): Promise<BuildResult>;
|
||||
compileFile(
|
||||
file: string,
|
||||
request: RequestId,
|
||||
clear: string[],
|
||||
onEvent: (e: BuildEvent) => void,
|
||||
): Promise<BuildResult>;
|
||||
cancel(request: RequestId): void;
|
||||
}
|
||||
|
||||
@@ -52,6 +58,11 @@ const tauriBackend: BuildBackend = {
|
||||
channel.onmessage = onEvent;
|
||||
return call(commands.buildRun(kind, file, cmake, request, clear, channel));
|
||||
},
|
||||
compileFile(file, request, clear, onEvent) {
|
||||
const channel = new Channel<BuildEvent>();
|
||||
channel.onmessage = onEvent;
|
||||
return call(commands.buildCompileFile(file, request, clear, channel));
|
||||
},
|
||||
cancel(request) {
|
||||
void commands.cancelRequest(request);
|
||||
},
|
||||
@@ -149,6 +160,50 @@ export async function startBuild(
|
||||
}
|
||||
}
|
||||
upsertTask({ id: BUILD_TASK_ID, title: taskTitle, message: null, fraction: null, done: false, cancel });
|
||||
return executeBuild(
|
||||
request,
|
||||
taskTitle,
|
||||
(files, onEvent) => backend.run(kind, file, request, files, onEvent, cmake),
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles one file without building its module. Saves all documents first; a
|
||||
* second build while one runs is ignored (IDEA disables the actions).
|
||||
*/
|
||||
export async function startCompileFile(file: string, activate = true): Promise<BuildResult | null> {
|
||||
if (isBuilding() || !useProject.getState().project) return null;
|
||||
const request = nextRequestId();
|
||||
const taskTitle = "Compile File";
|
||||
const cancel = () => backend.cancel(request);
|
||||
const previous = useBuildRun.getState().files;
|
||||
useBuildRun.setState({ ...INITIAL, request, kind: null, file, title: taskTitle, status: "running", files: previous });
|
||||
setContext(BUILD_LAST_STATUS, null);
|
||||
setContext(BUILD_RUNNING, true);
|
||||
if (activate) {
|
||||
try {
|
||||
toolWindowService().show("build", false);
|
||||
} catch {
|
||||
// No workbench (tests / settings window).
|
||||
}
|
||||
}
|
||||
upsertTask({ id: BUILD_TASK_ID, title: taskTitle, message: null, fraction: null, done: false, cancel });
|
||||
return executeBuild(
|
||||
request,
|
||||
taskTitle,
|
||||
(files, onEvent) => backend.compileFile(file, request, files, onEvent),
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/** Streams one backend build into the Build tool window until it finishes. */
|
||||
async function executeBuild(
|
||||
request: RequestId,
|
||||
taskTitle: string,
|
||||
invoke: (previous: string[], onEvent: (e: BuildEvent) => void) => Promise<BuildResult>,
|
||||
cancel: () => void,
|
||||
): Promise<BuildResult | null> {
|
||||
await maybeEditorService()
|
||||
?.saveAll()
|
||||
.catch(() => undefined);
|
||||
@@ -173,7 +228,7 @@ export async function startBuild(
|
||||
|
||||
let result: BuildResult | null = null;
|
||||
try {
|
||||
result = await backend.run(kind, file, request, previous, onEvent, cmake);
|
||||
result = await invoke(useBuildRun.getState().files, onEvent);
|
||||
clearTimeout(timer);
|
||||
flush();
|
||||
const status: BuildStatus = result.success ? "success" : "failed";
|
||||
@@ -274,7 +329,8 @@ export function stopBuild() {
|
||||
/** Runs the last build again (the tool window's rerun button). */
|
||||
export function rerunBuild() {
|
||||
const { kind, file } = useBuildRun.getState();
|
||||
return startBuild(kind ?? "build", file);
|
||||
if (!kind) return file ? startCompileFile(file) : startBuild("build", null);
|
||||
return startBuild(kind, file);
|
||||
}
|
||||
|
||||
export function resetBuildRun() {
|
||||
|
||||
@@ -609,6 +609,8 @@
|
||||
"Stop All": "Stop All",
|
||||
"Open in Browser": "Open in Browser",
|
||||
"Build Module": "Build Module",
|
||||
"Compile File": "Compile File",
|
||||
"Compiles the current Java file with javac, without building its module": "Compiles the current Java file with javac, without building its module",
|
||||
"Build Artifacts": "Build Artifacts",
|
||||
"Build Module '{target}'": "Build Module '{target}'",
|
||||
"Build cancelled": "Build cancelled",
|
||||
|
||||
@@ -514,6 +514,8 @@
|
||||
"Build": "构建",
|
||||
"Build Artifacts": "构建工件",
|
||||
"Build Module": "构建模块",
|
||||
"Compile File": "编译文件",
|
||||
"Compiles the current Java file with javac, without building its module": "使用 javac 仅编译当前 Java 文件,不构建其模块",
|
||||
"Build Project": "构建项目",
|
||||
"Build failed": "构建失败",
|
||||
"Build files changed since the last reload": "自上次重新加载以来构建文件已更改",
|
||||
|
||||
Reference in new issue
Block a user