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

@ -40,6 +40,7 @@ const I = {
clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
hammer: <svg viewBox="0 0 24 24" fill="none"><path d="M14 6l4 4M17 3l4 4-3 3-4-4 3-3ZM13 7 4 16a2 2 0 0 0 0 3l1 1a2 2 0 0 0 3 0l9-9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
};
@ -292,6 +293,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [transfer, setTransfer] = useState<Transfer | null>(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<string[]>([]); // live MSBuild output
const [building, setBuilding] = useState(false);
const [buildOk, setBuildOk] = useState<boolean | null>(null);
const [buildOpen, setBuildOpen] = useState(false);
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
const [logs, setLogs] = useState<LogEntry[]>([]); // 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
<div className="tzone" onClick={() => 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 && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{buildOpen && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onClose={() => setBuildOpen(false)} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
</div>
@ -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<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
return (
<div className="modal-back">
<div className="build">
<div className="build-head">
<span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
{building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
</span>
<div className="build-ttl">
<b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
<span>{name}</span>
</div>
<button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
</div>
<div className="build-body" ref={ref}>
{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 <div key={i} className={"build-ln" + cls}>{ln}</div>;
})}
{building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {