From 80f2acf40dc00846f0c15e6a32748cf2b2d19238 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 7 Jul 2026 14:28:01 +0300 Subject: [PATCH] Add Perforce-style command Log panel + UI scale setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/src/lib.rs | 58 +++++++++++++++++++++----- src/App.css | 45 +++++++++++++++++++++ src/App.tsx | 96 +++++++++++++++++++++++++++++++++++++++++--- src/i18n.ts | 8 ++++ 4 files changed, 191 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b19d1a..e0b98a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = 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, 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, 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, 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 { - 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, diff --git a/src/App.css b/src/App.css index b14ab67..d0b058e 100644 --- a/src/App.css +++ b/src/App.css @@ -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} diff --git a/src/App.tsx b/src/App.tsx index 2e3dd4c..c6ead11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: , revert: , clock: , + log: , }; /* ---------------- 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(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 = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; else - content = { 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("changes"); const [modal, setModal] = useState(null); const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); @@ -205,6 +222,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists const [ctx, setCtx] = useState(null); // right-click menu + const [logs, setLogs] = useState([]); // 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(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; }); const [syncW, setSyncW] = useState(() => { 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 : } + {logOpen && setLogOpen(false)} onClear={() => setLogs([])} />} + +
setPeek(true)} onMouseLeave={() => setPeek(false)}> + {(busy || scanning || !!pushing) ? : I.check} + {logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"} + + {peek && logs.length > 0 && ( +
+ {logs.slice(-14).map((l) => )} +
+ )} +
+ {dragging && (
@@ -607,7 +653,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
)} - {modal && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {ask && { 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
{t("Theme")}
+
{t("Interface scale")} +
+ + + +
+
{t("Server")}{info?.serverAddress || "—"}
{t("User")}{info?.userName || "—"}
{t("Workspace")}{info?.clientName || "—"}
@@ -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 ( +
+ {l.ok ? "✓" : "!"} + {l.cmd} + {logCount(l)} +
+ ); +} +function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () => void; onClear: () => void }) { + const ref = useRef(null); + useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]); + return ( +
+
+ {I.log}Log{logs.length} + + +
+
+ {logs.length === 0 ?
{t("No commands yet.")}
+ : logs.map((l) => )} +
+
+ ); +} + /* ---------------- confirm modal ---------------- */ function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { useEffect(() => { diff --git a/src/i18n.ts b/src/i18n.ts index 4b899f5..87e1bd1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -153,6 +153,14 @@ const D: Record = { // 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 l’interface", 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 l’instant.", 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" },