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

@ -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 }) {