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:
@ -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())
|
||||
|
||||
15
src/App.css
15
src/App.css
@ -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)}
|
||||
|
||||
43
src/App.tsx
43
src/App.tsx
@ -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 }) {
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user