diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 453eb8f..5c41910 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { +/// Resolve a working-folder scope to a local directory via `p4 where`. +fn scope_local_dir(conn: &Conn, scope: &str) -> Option { 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 { + 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 { + 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 { + 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, diff --git a/src/App.css b/src/App.css index d4ee776..7301579 100644 --- a/src/App.css +++ b/src/App.css @@ -362,6 +362,27 @@ body.resizing{cursor:col-resize!important;user-select:none} .xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative} .xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px; background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite} + +/* MSBuild output overlay */ +.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column; + background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .3s ease} +.build-head{flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:13px 18px;border-bottom:1px solid var(--border-soft)} +.build-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;font-weight:700;font-size:16px;background:var(--panel-3);color:var(--muted)} +.build-ic.run{color:var(--accent-2)} +.build-ic.ok{background:rgba(62,207,142,.15);color:var(--add)} +.build-ic.fail{background:rgba(242,99,126,.15);color:var(--del)} +.build-ic svg{width:18px;height:18px} +.build-ttl{display:flex;flex-direction:column;gap:1px;min-width:0} +.build-ttl b{font-size:14px} +.build-ttl span{font-size:11.5px;color:var(--faint);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.build-x{margin-left:auto;width:28px;height:28px;flex:0 0 auto;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;cursor:pointer;display:grid;place-items:center} +.build-x:hover:not(:disabled){color:var(--txt);border-color:var(--accent)} +.build-x:disabled{opacity:.4;cursor:not-allowed} +.build-body{flex:1;overflow:auto;min-height:0;padding:10px 14px;font-family:var(--mono);font-size:11.5px;line-height:1.6;background:var(--stage-bg)} +.build-ln{white-space:pre-wrap;word-break:break-word;color:var(--muted)} +.build-ln.err{color:var(--del)} +.build-ln.warn{color:var(--edit)} +.build-ln.ok{color:var(--add);font-weight:600} .tzone.push .ic{color:var(--add)} .tzone.push .val{color:var(--add)} .commit .row{display:flex;gap:10px;align-items:center} diff --git a/src/App.tsx b/src/App.tsx index 6a9560e..d9c651d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ const I = { clock: , log: , vscode: , + hammer: , ue: , }; @@ -292,6 +293,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [transfer, setTransfer] = useState(null); // live sync/submit file transfer const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project + const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building) + const [buildLog, setBuildLog] = useState([]); // live MSBuild output + const [building, setBuilding] = useState(false); + const [buildOk, setBuildOk] = useState(null); + const [buildOpen, setBuildOpen] = useState(false); const [ctx, setCtx] = useState(null); // right-click menu const [logs, setLogs] = useState([]); // live p4 command log (P4V-style) const [logOpen, setLogOpen] = useState(false); @@ -385,13 +391,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const id = setTimeout(() => setShowXfer(true), 400); return () => clearTimeout(id); }, [xferActive]); - // detect a UE project in the current working folder → show the Launch button + // detect a UE project / .sln in the current working folder (Launch / Build actions) useEffect(() => { - if (!activePath) { setUproject(""); return; } + if (!activePath) { setUproject(""); setSlnPath(""); return; } let live = true; p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject("")); + p4.findSln(activePath).then((p) => live && setSlnPath(p)).catch(() => live && setSlnPath("")); return () => { live = false; }; }, [activePath]); + // live MSBuild output + useEffect(() => { + let un: (() => void) | undefined; + listen<{ line: string; done: boolean; ok: boolean }>("build-log", (e) => { + const p = e.payload; + setBuildLog((L) => (L.length > 4000 ? [...L.slice(-4000), p.line] : [...L, p.line])); + if (p.done) { setBuilding(false); setBuildOk(p.ok); } + }).then((u) => (un = u)); + return () => un?.(); + }, []); useEffect(() => { const close = () => setOpenMenu(null); document.addEventListener("click", close); @@ -511,6 +528,14 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set catch (e) { flash(String(e), true); } } + // build the solution found in the working folder (MSBuild) with live output + async function buildSln() { + if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } + setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); + try { await p4.buildSln(slnPath); } + catch { /* the overlay already shows the failure line */ } + } + // reveal a depot path / workspace root in Windows Explorer async function reveal(target: string) { if (!target) { flash(t("Path not set"), true); return; } @@ -647,7 +672,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }], Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }], - Tools: [{ label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], + Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }], }; @@ -690,6 +715,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
browseTo("")} title={t("Choose the project working folder · right-click for more")} onContextMenu={(e) => openCtx(e, [ ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []), + ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []), { label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) }, { label: t("Choose another folder…"), act: () => browseTo("") }, { label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) }, @@ -811,6 +837,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {ask && { ask.resolve(v); setAsk(null); }} />} {showXfer && transfer && } + {buildOpen && setBuildOpen(false)} />} {ctx && setCtx(null)} />} {toast &&
{toast.text}
}
@@ -1005,6 +1032,39 @@ function TransferDialog({ transfer }: { transfer: Transfer }) { ); } +/* ---------------- build output (MSBuild) ---------------- */ +function BuildOverlay({ lines, building, ok, sln, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onClose: () => void }) { + const ref = useRef(null); + useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]); + const name = sln.replace(/\\/g, "/").split("/").pop() || "solution"; + return ( +
+
+
+ + {building ? : ok ? "✓" : ok === false ? "✗" : I.hammer} + +
+ {building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")} + {name} +
+ +
+
+ {lines.map((ln, i) => { + const low = ln.toLowerCase(); + const cls = /\berror\b|\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : ""; + return
{ln}
; + })} + {building && lines.length === 0 &&
{t("Starting MSBuild…")}
} +
+
+
+ ); +} + /* ---------------- confirm modal ---------------- */ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { useEffect(() => { diff --git a/src/i18n.ts b/src/i18n.ts index de13292..7ded43b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -69,6 +69,14 @@ const D: Record = { "pull from server": { ru: "забрать с сервера (pull)", de: "vom Server holen (pull)", fr: "récupérer du serveur (pull)", es: "traer del servidor (pull)" }, "Launch": { ru: "Запустить", de: "Starten", fr: "Lancer", es: "Iniciar" }, "Launch Unreal Engine": { ru: "Запустить Unreal Engine", de: "Unreal Engine starten", fr: "Lancer Unreal Engine", es: "Iniciar Unreal Engine" }, + "Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" }, + "Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" }, + "No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." }, + "Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" }, + "Building…": { ru: "Сборка…", de: "Wird gebaut…", fr: "Compilation…", es: "Compilando…" }, + "Build succeeded": { ru: "Сборка успешна", de: "Build erfolgreich", fr: "Compilation réussie", es: "Compilación correcta" }, + "Build failed": { ru: "Сборка провалена", de: "Build fehlgeschlagen", fr: "Échec de la compilation", es: "Compilación fallida" }, + "Starting MSBuild…": { ru: "Запуск MSBuild…", de: "MSBuild wird gestartet…", fr: "Démarrage de MSBuild…", es: "Iniciando MSBuild…" }, "Open this project in Unreal Engine": { ru: "Открыть проект в Unreal Engine", de: "Projekt in Unreal Engine öffnen", fr: "Ouvrir le projet dans Unreal Engine", es: "Abrir el proyecto en Unreal Engine" }, "Launching Unreal…": { ru: "Запуск Unreal…", de: "Unreal wird gestartet…", fr: "Lancement d’Unreal…", es: "Iniciando Unreal…" }, diff --git a/src/p4.ts b/src/p4.ts index 5bac700..0fdf049 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -73,6 +73,8 @@ export const p4 = { undoChange: (change: string) => invoke("p4_undo_change", { change }), openInExplorer: (target: string) => invoke("open_in_explorer", { target }), findUproject: (scope: string) => invoke("find_uproject", { scope }), + findSln: (scope: string) => invoke("find_sln", { scope }), + buildSln: (path: string) => invoke("build_sln", { path }), launchFile: (path: string) => invoke("launch_file", { path }), openInVscode: (depot: string) => invoke("open_in_vscode", { depot }), readDepot: async (depot: string): Promise => {