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:
96
src/App.tsx
96
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: <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(() => {
|
||||
|
||||
Reference in New Issue
Block a user