Fix build success detection + minimizable build window + de-dupe event logs

- build_sln now trusts UnrealBuildTool's "Result: Succeeded" (and absence of
  real compile errors) over MSBuild's exit code, which is non-zero for a side
  project even when the UE game module built fine.
- Fixed double event-listener registration (async unlisten under StrictMode)
  that duplicated every p4-log / p4-transfer / build-log line.
- Build overlay can be minimized to a small floating chip (spinner while
  building, ✓/✗ when done), click to expand, × to dismiss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 16:16:03 +03:00
parent b00b4f94b0
commit afc4824b26
4 changed files with 69 additions and 14 deletions

View File

@ -762,9 +762,25 @@ async fn build_sln(path: String) -> Result<String, String> {
}
};
// For Unreal, the .sln drives UnrealBuildTool; MSBuild can return non-zero for a
// side project even when UBT reports the game module built fine. So trust UBT's
// "Result: Succeeded" (and the absence of real compile errors) over the exit code.
let mut ubt_result = false;
let mut ubt_success = false;
let mut compile_error = false;
let mut scan = |line: &str| {
let l = line.to_ascii_lowercase();
if l.contains("result: succeeded") { ubt_result = true; ubt_success = true; }
if l.contains("result: failed") { ubt_result = true; }
if l.contains(": error ") || l.contains(") error ") || l.contains("error msb") {
compile_error = true;
}
};
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
if !line.trim().is_empty() {
scan(&line);
emit(line, false, false);
}
}
@ -775,11 +791,17 @@ async fn build_sln(path: String) -> Result<String, String> {
let _ = std::io::Read::read_to_string(&mut se, &mut buf);
for line in buf.lines() {
if !line.trim().is_empty() {
scan(line);
emit(line.to_string(), false, false);
}
}
}
let ok = status.success();
// UBT verdict wins when present; otherwise fall back to MSBuild's exit code
let ok = if ubt_result {
ubt_success && !compile_error
} else {
status.success()
};
emit(if ok { "✓ Build succeeded.".into() } else { "✗ Build FAILED.".into() }, true, ok);
if ok {
Ok("ok".into())

View File

@ -376,8 +376,23 @@ body.resizing{cursor:col-resize!important;user-select:none}
.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 + .build-x{margin-left:6px}
.build-x:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
.build-x:disabled{opacity:.4;cursor:not-allowed}
/* minimized build chip */
.buildchip{position:fixed;right:18px;bottom:18px;z-index:360;display:flex;align-items:center;gap:10px;
padding:9px 10px 9px 13px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;max-width:300px}
.buildchip.run{border-color:rgba(124,110,246,.45)}
.buildchip.ok{border-color:rgba(62,207,142,.5)}
.buildchip.fail{border-color:rgba(242,99,126,.5)}
.buildchip:hover{filter:brightness(1.06)}
.bc-ic{width:20px;height:20px;flex:0 0 auto;display:grid;place-items:center;font-weight:700;font-size:14px}
.buildchip.run .bc-ic{color:var(--accent-2)}.buildchip.ok .bc-ic{color:var(--add)}.buildchip.fail .bc-ic{color:var(--del)}
.bc-txt{font-size:12.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bc-x{width:22px;height:22px;flex:0 0 auto;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
.bc-x:hover{background:var(--hover);color:var(--txt)}
.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)}

View File

@ -298,6 +298,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [building, setBuilding] = useState(false);
const [buildOk, setBuildOk] = useState<boolean | null>(null);
const [buildOpen, setBuildOpen] = useState(false);
const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
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);
@ -365,24 +366,24 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
let un: (() => void) | undefined;
let un: (() => void) | undefined; let dead = false;
listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => {
setLogs((L) => {
const next = [...L, { ...e.payload, id: ++logId.current }];
return next.length > 600 ? next.slice(-600) : next;
});
}).then((u) => (un = u));
return () => un?.();
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
// live file-transfer progress (Get Latest / Submit) streamed from the backend
useEffect(() => {
let un: (() => void) | undefined;
let un: (() => void) | undefined; let dead = false;
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
const p = e.payload;
if (p.done) setTransfer(null);
else setTransfer({ op: p.op, count: p.count, file: p.file });
}).then((u) => (un = u));
return () => un?.();
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
// only surface the progress dialog once a transfer has run for a moment
const xferActive = transfer !== null;
@ -401,13 +402,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
}, [activePath]);
// live MSBuild output
useEffect(() => {
let un: (() => void) | undefined;
let un: (() => void) | undefined; let dead = false;
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?.();
}).then((u) => { if (dead) u(); else un = u; });
return () => { dead = true; un?.(); };
}, []);
useEffect(() => {
const close = () => setOpenMenu(null);
@ -531,7 +532,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// 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);
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
try { await p4.buildSln(slnPath); }
catch { /* the overlay already shows the failure line */ }
}
@ -837,7 +838,8 @@ 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)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} 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>
@ -1033,7 +1035,7 @@ 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 }) {
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; 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";
@ -1048,6 +1050,9 @@ function BuildOverlay({ lines, building, ok, sln, onClose }: { lines: string[];
<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={onMinimize} title={t("Minimize")}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
</button>
<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>
@ -1055,7 +1060,7 @@ function BuildOverlay({ lines, building, ok, sln, onClose }: { lines: string[];
<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" : "";
const cls = /: error |\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>}
@ -1064,6 +1069,18 @@ function BuildOverlay({ lines, building, ok, sln, onClose }: { lines: string[];
</div>
);
}
/* minimized build indicator — small floating chip, click to expand */
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
return (
<div className={"buildchip" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")} onClick={onExpand} title={t("Expand")}>
<span className="bc-ic">{building ? <span className="ldr sm" /> : ok ? "✓" : "✗"}</span>
<span className="bc-txt">{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}</span>
<button className="bc-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title={t("Close")}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
</button>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {

View File

@ -25,6 +25,7 @@ const D: Record<string, Tr> = {
"Minimize": { ru: "Свернуть", de: "Minimieren", fr: "Réduire", es: "Minimizar" },
"Maximize": { ru: "Развернуть", de: "Maximieren", fr: "Agrandir", es: "Maximizar" },
"Close": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
"Expand": { ru: "Развернуть", de: "Erweitern", fr: "Agrandir", es: "Expandir" },
"Theme": { ru: "Тема", de: "Thema", fr: "Thème", es: "Tema" },
"Restoring session…": { ru: "Восстановление сессии…", de: "Sitzung wird wiederhergestellt…", fr: "Restauration de la session…", es: "Restaurando sesión…" },
// startup splash