Add Perforce-style command Log panel + UI scale setting

Backend emits a p4-log event per command (cmd + item count + status).
Frontend adds a bottom Log panel toggled from a new Window menu (Ctrl+L),
an always-on status bar showing the last command, and a hover log-peek
popover over the activity indicator. Settings gains an interface-scale
(zoom) control (50–200%, persisted). All new strings translated (5 langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 14:28:01 +03:00
parent 46a3be4489
commit 80f2acf40d
4 changed files with 191 additions and 16 deletions

View File

@ -4,8 +4,28 @@
use serde_json::Value;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use tauri::State;
use std::sync::{Mutex, OnceLock};
use tauri::{AppHandle, Emitter, State};
/// App handle stashed at startup so the low-level p4 helpers can emit a live
/// command log to the frontend (a Perforce-style "Log" panel).
static APP: OnceLock<AppHandle> = OnceLock::new();
/// Emit one p4-log entry to the UI: the command line, how many items/lines it
/// returned, whether it succeeded, and any error text.
fn log_p4(args: &[&str], count: i64, ok: bool, err: Option<&str>) {
if let Some(app) = APP.get() {
let _ = app.emit(
"p4-log",
serde_json::json!({
"cmd": format!("p4 {}", args.join(" ")),
"count": count,
"ok": ok,
"err": err,
}),
);
}
}
#[derive(Default, Clone)]
struct Conn {
@ -43,10 +63,14 @@ fn p4_cmd(conn: &Conn, tagged: bool) -> Command {
/// Run a tagged (JSON) p4 command and collect the objects it prints.
fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
let out = p4_cmd(conn, true)
.args(args)
.output()
.map_err(|e| format!("Failed to start p4: {e}"))?;
let out = match p4_cmd(conn, true).args(args).output() {
Ok(o) => o,
Err(e) => {
let m = format!("Failed to start p4: {e}");
log_p4(args, 0, false, Some(&m));
return Err(m);
}
};
let text = String::from_utf8_lossy(&out.stdout);
let mut items = Vec::new();
@ -69,6 +93,7 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
.unwrap_or("unknown p4 error")
.trim()
.to_string();
log_p4(args, 0, false, Some(&msg));
return Err(msg);
}
// informational message (e.g. "File(s) not opened."), not a data record — skip
@ -82,9 +107,11 @@ fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
if items.is_empty() && !out.status.success() {
let e = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !e.is_empty() {
log_p4(args, 0, false, Some(&e));
return Err(e);
}
}
log_p4(args, items.len() as i64, true, None);
Ok(items)
}
@ -279,15 +306,21 @@ async fn p4_disconnect(state: State<'_, AppState>) -> Result<(), String> {
/// Run a non-tagged p4 command (raw text out). Errors carry stderr.
fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
let out = p4_cmd(conn, false)
.args(args)
.output()
.map_err(|e| format!("Failed to start p4: {e}"))?;
let out = match p4_cmd(conn, false).args(args).output() {
Ok(o) => o,
Err(e) => {
let m = format!("Failed to start p4: {e}");
log_p4(args, 0, false, Some(&m));
return Err(m);
}
};
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !out.status.success() && !stderr.is_empty() {
log_p4(args, 0, false, Some(&stderr));
return Err(stderr);
}
log_p4(args, stdout.lines().count() as i64, true, None);
Ok(if stdout.is_empty() { stderr } else { stdout })
}
@ -568,6 +601,11 @@ pub fn run() {
.plugin(tauri_plugin_process::init());
}
builder
.setup(|app| {
// stash the app handle so p4 helpers can emit the live command log
let _ = APP.set(app.handle().clone());
Ok(())
})
.manage(AppState::default())
.invoke_handler(tauri::generate_handler![
p4_clients,

View File

@ -237,6 +237,51 @@ body.resizing{cursor:col-resize!important;user-select:none}
.langbtn.on{color:#fff;border-color:transparent;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 6px 16px -6px rgba(124,110,246,.7)}
.langbtn .lflag{font-size:14px;line-height:1}
.langbtn .lname{font-weight:500}
/* zoom selector (Settings) */
.zoomsel{display:flex;align-items:center;gap:5px}
.zbtn{width:28px;height:28px;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;
cursor:pointer;font-size:16px;line-height:1;display:grid;place-items:center;font-family:var(--font)}
.zbtn:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
.zbtn:disabled{opacity:.4;cursor:not-allowed}
.zval{min-width:58px;height:28px;border:1px solid var(--border);background:var(--panel);color:var(--txt);border-radius:8px;
cursor:pointer;font-size:12px;font-variant-numeric:tabular-nums}
.zval:hover{border-color:var(--accent)}
/* bottom status bar (always visible) + hover log peek */
.statusbar{flex:0 0 26px;display:flex;align-items:center;gap:10px;padding:0 12px;border-top:1px solid var(--border);
background:var(--bar);font-size:11.5px;color:var(--muted);position:relative;user-select:none}
.stdot{width:16px;height:16px;display:grid;place-items:center;flex:0 0 auto;color:var(--add)}
.stdot svg{width:12px;height:12px}
.stdot.busy{color:var(--accent-2)}
.ldr.sm{width:12px;height:12px;border-width:2px}
.stlast{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono);font-size:11px}
.stlog{display:flex;align-items:center;gap:6px;flex:0 0 auto;border:1px solid var(--border);background:var(--panel);
color:var(--muted);border-radius:7px;padding:3px 10px;cursor:pointer;font-size:11.5px}
.stlog:hover{color:var(--txt);border-color:var(--accent)}
.stlog svg{width:13px;height:13px}
.logpeek{position:absolute;right:10px;bottom:32px;width:460px;max-width:calc(100vw - 30px);max-height:300px;overflow:auto;z-index:250;
background:var(--elevated);border:1px solid var(--border);border-radius:11px;padding:6px;box-shadow:0 18px 46px -12px rgba(0,0,0,.6)}
/* log panel (Window → Log) — sits under the main area, above the status bar */
.logpanel{flex:0 0 190px;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border);background:var(--sunk)}
.loghead{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:7px 12px;border-bottom:1px solid var(--border-soft)}
.logtitle{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--muted)}
.logtitle svg{width:14px;height:14px}.logtitle b{color:var(--txt);font-weight:600}
.logn{font-size:10.5px;color:var(--faint);background:var(--panel-3);border-radius:9px;padding:0 7px;font-variant-numeric:tabular-nums}
.loghbtn{margin-left:auto;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:7px;
padding:3px 10px;cursor:pointer;font-size:11px;display:grid;place-items:center}
.loghbtn:hover{color:var(--txt);border-color:var(--accent)}
.loghbtn.x{margin-left:0;padding:3px 8px}
.logbody{flex:1;overflow-y:auto;min-height:0;padding:4px 0;font-family:var(--mono)}
.logempty{padding:20px;text-align:center;color:var(--faint);font-size:12px;font-family:var(--font)}
.logrow{display:flex;align-items:center;gap:9px;padding:3px 12px;font-size:11.5px;line-height:1.55}
.logrow:hover{background:var(--hover)}
.logst{width:12px;flex:0 0 auto;text-align:center;color:var(--add);font-weight:700}
.logrow.err .logst{color:var(--del)}
.logcmd{color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.logcount{margin-left:auto;flex:0 0 auto;color:var(--faint);white-space:nowrap}
.logrow.err .logcount{color:var(--del)}
.tzone.push .ic{color:var(--add)}
.tzone.push .val{color:var(--add)}
.commit .row{display:flex;gap:10px;align-items:center}

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, CSSProperties } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import ModelViewer from "./ModelViewer";
import UpdateBanner from "./Updater";
import "./App.css";
@ -36,6 +37,7 @@ const I = {
cube: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.5"/></svg>,
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
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>,
};
/* ---------------- theme hook ---------------- */
@ -56,6 +58,18 @@ function useLang(): [Lang, (l: Lang) => void] {
return [lang, (l) => { setLangGlobal(l); setLang(l); }];
}
/* ---------------- UI zoom hook (scales the whole webview) ---------------- */
function useZoom(): [number, (z: number) => void] {
const [zoom, setZoom] = useState(() => {
const v = Number(localStorage.getItem("exd-zoom")); return v >= 0.5 && v <= 2 ? v : 1;
});
useEffect(() => {
(document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom);
try { localStorage.setItem("exd-zoom", String(zoom)); } catch {}
}, [zoom]);
return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))];
}
/* ============================================================= */
export default function App() {
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
@ -63,6 +77,7 @@ export default function App() {
const [session, setSession] = useState<Session | null>(null);
const [light, toggleTheme] = useTheme();
const [lang, setLang] = useLang();
const [zoom, setZoom] = useZoom();
const saved = useMemo(() => loadSession(), []);
// try to restore the last session from the on-disk p4 ticket
@ -89,7 +104,7 @@ export default function App() {
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
@ -181,8 +196,10 @@ type ModalState =
| { kind: "settings" }
| { kind: "about" };
function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
const [tab, setTab] = useState<Tab>("changes");
const [modal, setModal] = useState<ModalState | null>(null);
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
@ -205,6 +222,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
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);
const [peek, setPeek] = useState(false); // hover-over-loader log preview
const logId = useRef(0);
// resizable panels (persisted): left panel width + Get-Latest zone width
const [leftW, setLeftW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; });
const [syncW, setSyncW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; });
@ -261,6 +282,17 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
// Source Control enabled in Unreal, UE auto-checks-out files, so they appear
// here on their own — no reconcile needed. Reconcile is a manual fallback.
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
let un: (() => void) | undefined;
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?.();
}, []);
useEffect(() => {
const close = () => setOpenMenu(null);
document.addEventListener("click", close);
@ -464,6 +496,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
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" }) }],
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
};
@ -599,6 +632,19 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
: <ChangeDetail detail={detail} busy={detailBusy} />}
</div>
{logOpen && <LogPanel logs={logs} onClose={() => setLogOpen(false)} onClear={() => setLogs([])} />}
<div className="statusbar" onMouseEnter={() => setPeek(true)} onMouseLeave={() => setPeek(false)}>
<span className={"stdot" + ((busy || scanning || !!pushing) ? " busy" : "")}>{(busy || scanning || !!pushing) ? <span className="ldr sm" /> : I.check}</span>
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
<button className="stlog" onClick={() => setLogOpen((o) => !o)} title="Ctrl+L">{I.log}<span>Log</span></button>
{peek && logs.length > 0 && (
<div className="logpeek">
{logs.slice(-14).map((l) => <LogRow key={l.id} l={l} />)}
</div>
)}
</div>
{dragging && (
<div className="dropzone">
<div className="dropinner">
@ -607,7 +653,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
</div>
</div>
)}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang}
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{pushing && (
@ -627,8 +673,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
@ -705,6 +751,13 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onP
</div>
</div>
<div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Interface scale")}</span>
<div className="zoomsel">
<button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title=""></button>
<button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
@ -748,6 +801,37 @@ function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: Ct
);
}
/* ---------------- Perforce-style command log ---------------- */
function logCount(l: LogEntry): string {
return l.ok ? `{${l.count} ${t("item(s)")}}` : `${l.err || "error"}`;
}
function LogRow({ l }: { l: LogEntry }) {
return (
<div className={"logrow" + (l.ok ? "" : " err")} title={l.err || ""}>
<span className="logst">{l.ok ? "✓" : "!"}</span>
<span className="logcmd">{l.cmd}</span>
<span className="logcount">{logCount(l)}</span>
</div>
);
}
function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () => void; onClear: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
return (
<div className="logpanel">
<div className="loghead">
<span className="logtitle">{I.log}<b>Log</b><span className="logn">{logs.length}</span></span>
<button className="loghbtn" onClick={onClear}>{t("Clear")}</button>
<button className="loghbtn x" onClick={onClose}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="logbody" ref={ref}>
{logs.length === 0 ? <div className="logempty">{t("No commands yet.")}</div>
: logs.map((l) => <LogRow key={l.id} l={l} />)}
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {

View File

@ -153,6 +153,14 @@ const D: Record<string, Tr> = {
// app modal — settings
"Settings": { ru: "Настройки", de: "Einstellungen", fr: "Paramètres", es: "Ajustes" },
"Language": { ru: "Язык", de: "Sprache", fr: "Langue", es: "Idioma" },
"Interface scale": { ru: "Масштаб интерфейса", de: "Oberflächenskalierung", fr: "Échelle de linterface", es: "Escala de interfaz" },
"Reset": { ru: "Сбросить", de: "Zurücksetzen", fr: "Réinitialiser", es: "Restablecer" },
// window menu + log panel
"Window": { ru: "Окно", de: "Fenster", fr: "Fenêtre", es: "Ventana" },
"Log": { ru: "Лог", de: "Protokoll", fr: "Journal", es: "Registro" },
"Clear": { ru: "Очистить", de: "Leeren", fr: "Effacer", es: "Limpiar" },
"item(s)": { ru: "элем.", de: "Einträge", fr: "élément(s)", es: "elemento(s)" },
"No commands yet.": { ru: "Пока нет команд.", de: "Noch keine Befehle.", fr: "Aucune commande pour linstant.", es: "Aún no hay comandos." },
"Light ☀": { ru: "Светлая ☀", de: "Hell ☀", fr: "Clair ☀", es: "Claro ☀" },
"Dark ☾": { ru: "Тёмная ☾", de: "Dunkel ☾", fr: "Sombre ☾", es: "Oscuro ☾" },
"Server version": { ru: "Версия сервера", de: "Serverversion", fr: "Version du serveur", es: "Versión del servidor" },