Add Build Solution (MSBuild) with live output

find_sln locates a .sln in the working folder; build_sln locates MSBuild
via vswhere and builds the solution's default config (compiles the UE game
module for Unreal projects), streaming output as build-log events. A build
overlay shows the live log with error/warning coloring and a status. Wired
into Tools (Ctrl+B) and the Working-folder right-click menu when a .sln is
found. New strings translated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 16:04:03 +03:00
parent cd61de548d
commit b00b4f94b0
5 changed files with 208 additions and 26 deletions

View File

@ -651,52 +651,141 @@ async fn launch_file(path: String) -> Result<(), String> {
Ok(())
}
/// Look for an Unreal `.uproject` inside the working-folder scope (resolved to a
/// local dir via `p4 where`). Checks the folder itself and one level of subdirs.
/// Returns the local path of the first match, or "" if the scope isn't a UE project.
#[tauri::command]
async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result<String, String> {
/// Resolve a working-folder scope to a local directory via `p4 where`.
fn scope_local_dir(conn: &Conn, scope: &str) -> Option<String> {
if scope.is_empty() {
return Ok(String::new());
return None;
}
let conn = current(&state)?;
let spec = format!("{}/...", scope.trim_end_matches(['/', '.']));
let dir = match run_json(&conn, &["where", &spec])?
run_json(conn, &["where", &spec])
.ok()?
.into_iter()
.next()
.and_then(|v| v.get("path").and_then(|p| p.as_str()).map(String::from))
{
Some(p) => p.trim_end_matches(['.', '/', '\\']).to_string(),
None => return Ok(String::new()),
};
let is_uproject = |p: &std::path::Path| {
p.extension().map_or(false, |x| x.eq_ignore_ascii_case("uproject"))
};
// the scope folder itself
if let Ok(entries) = std::fs::read_dir(&dir) {
.map(|p| p.trim_end_matches(['.', '/', '\\']).to_string())
}
/// First file with the given extension in `dir` (checked, then one level of subdirs).
fn find_ext(dir: &str, ext: &str) -> String {
let matches = |p: &std::path::Path| p.extension().map_or(false, |x| x.eq_ignore_ascii_case(ext));
if let Ok(entries) = std::fs::read_dir(dir) {
let mut subdirs = Vec::new();
for e in entries.flatten() {
let p = e.path();
if is_uproject(&p) {
return Ok(p.to_string_lossy().to_string());
if matches(&p) {
return p.to_string_lossy().to_string();
}
if p.is_dir() {
subdirs.push(p);
}
}
// one level down
for sd in subdirs {
if let Ok(inner) = std::fs::read_dir(&sd) {
for e in inner.flatten() {
let p = e.path();
if is_uproject(&p) {
return Ok(p.to_string_lossy().to_string());
if matches(&p) {
return p.to_string_lossy().to_string();
}
}
}
}
}
Ok(String::new())
String::new()
}
/// Local path of a `.uproject` in the scope (or "" if not a UE project).
#[tauri::command]
async fn find_uproject(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "uproject")).unwrap_or_default())
}
/// Local path of a Visual Studio `.sln` in the scope (or "" if none).
#[tauri::command]
async fn find_sln(state: State<'_, AppState>, scope: String) -> Result<String, String> {
let conn = current(&state)?;
Ok(scope_local_dir(&conn, &scope).map(|d| find_ext(&d, "sln")).unwrap_or_default())
}
/// Build a Visual Studio solution with MSBuild (located via vswhere), streaming
/// its output line-by-line as `build-log` events. Uses the solution's default
/// configuration — for an Unreal project that compiles the game module.
#[tauri::command]
async fn build_sln(path: String) -> Result<String, String> {
if !std::path::Path::new(&path).exists() {
return Err(format!("Solution not found: {path}"));
}
let emit = |line: String, done: bool, ok: bool| {
if let Some(app) = APP.get() {
let _ = app.emit(
"build-log",
serde_json::json!({ "line": line, "done": done, "ok": ok }),
);
}
};
// locate MSBuild.exe via vswhere (falls back to PATH)
let pf86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".into());
let vswhere = format!("{pf86}\\Microsoft Visual Studio\\Installer\\vswhere.exe");
let msbuild = {
let mut vc = Command::new(&vswhere);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
vc.creation_flags(0x0800_0000);
}
vc.args(["-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\\**\\Bin\\MSBuild.exe"]);
match vc.output() {
Ok(o) => String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").trim().to_string(),
Err(_) => String::new(),
}
};
let msbuild = if msbuild.is_empty() { "MSBuild.exe".to_string() } else { msbuild };
emit(format!("MSBuild: {msbuild}"), false, false);
emit(format!("Building {path}"), false, false);
let mut cmd = Command::new(&msbuild);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
cmd.args([&path, "/m", "/nologo", "/v:minimal", "/clp:NoSummary"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let m = format!("Could not start MSBuild (Visual Studio C++ tools installed?): {e}");
emit(m.clone(), true, false);
return Err(m);
}
};
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
if !line.trim().is_empty() {
emit(line, false, false);
}
}
}
let status = child.wait().map_err(|e| e.to_string())?;
if let Some(mut se) = child.stderr.take() {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut se, &mut buf);
for line in buf.lines() {
if !line.trim().is_empty() {
emit(line.to_string(), false, false);
}
}
}
let ok = status.success();
emit(if ok { "✓ Build succeeded.".into() } else { "✗ Build FAILED.".into() }, true, ok);
if ok {
Ok("ok".into())
} else {
Err("Build failed".into())
}
}
/// Files and metadata of a submitted changelist (History detail).
@ -792,6 +881,8 @@ pub fn run() {
p4_undo_change,
open_in_explorer,
find_uproject,
find_sln,
build_sln,
launch_file,
open_in_vscode,
p4_describe,