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:
@ -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,
|
||||
|
||||
45
src/App.css
45
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}
|
||||
|
||||
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(() => {
|
||||
|
||||
@ -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 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" },
|
||||
|
||||
Reference in New Issue
Block a user