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 serde_json::Value;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Mutex, OnceLock};
|
||||||
use tauri::State;
|
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)]
|
#[derive(Default, Clone)]
|
||||||
struct Conn {
|
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.
|
/// Run a tagged (JSON) p4 command and collect the objects it prints.
|
||||||
fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
|
fn run_json(conn: &Conn, args: &[&str]) -> Result<Vec<Value>, String> {
|
||||||
let out = p4_cmd(conn, true)
|
let out = match p4_cmd(conn, true).args(args).output() {
|
||||||
.args(args)
|
Ok(o) => o,
|
||||||
.output()
|
Err(e) => {
|
||||||
.map_err(|e| format!("Failed to start p4: {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 text = String::from_utf8_lossy(&out.stdout);
|
||||||
let mut items = Vec::new();
|
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")
|
.unwrap_or("unknown p4 error")
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
log_p4(args, 0, false, Some(&msg));
|
||||||
return Err(msg);
|
return Err(msg);
|
||||||
}
|
}
|
||||||
// informational message (e.g. "File(s) not opened."), not a data record — skip
|
// 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() {
|
if items.is_empty() && !out.status.success() {
|
||||||
let e = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let e = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if !e.is_empty() {
|
if !e.is_empty() {
|
||||||
|
log_p4(args, 0, false, Some(&e));
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log_p4(args, items.len() as i64, true, None);
|
||||||
Ok(items)
|
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.
|
/// Run a non-tagged p4 command (raw text out). Errors carry stderr.
|
||||||
fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
|
fn run_text(conn: &Conn, args: &[&str]) -> Result<String, String> {
|
||||||
let out = p4_cmd(conn, false)
|
let out = match p4_cmd(conn, false).args(args).output() {
|
||||||
.args(args)
|
Ok(o) => o,
|
||||||
.output()
|
Err(e) => {
|
||||||
.map_err(|e| format!("Failed to start p4: {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 stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if !out.status.success() && !stderr.is_empty() {
|
if !out.status.success() && !stderr.is_empty() {
|
||||||
|
log_p4(args, 0, false, Some(&stderr));
|
||||||
return Err(stderr);
|
return Err(stderr);
|
||||||
}
|
}
|
||||||
|
log_p4(args, stdout.lines().count() as i64, true, None);
|
||||||
Ok(if stdout.is_empty() { stderr } else { stdout })
|
Ok(if stdout.is_empty() { stderr } else { stdout })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -568,6 +601,11 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_process::init());
|
.plugin(tauri_plugin_process::init());
|
||||||
}
|
}
|
||||||
builder
|
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())
|
.manage(AppState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
p4_clients,
|
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.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 .lflag{font-size:14px;line-height:1}
|
||||||
.langbtn .lname{font-weight:500}
|
.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 .ic{color:var(--add)}
|
||||||
.tzone.push .val{color:var(--add)}
|
.tzone.push .val{color:var(--add)}
|
||||||
.commit .row{display:flex;gap:10px;align-items:center}
|
.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 type { MouseEvent as ReactMouseEvent, CSSProperties } from "react";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import ModelViewer from "./ModelViewer";
|
import ModelViewer from "./ModelViewer";
|
||||||
import UpdateBanner from "./Updater";
|
import UpdateBanner from "./Updater";
|
||||||
import "./App.css";
|
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>,
|
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>,
|
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>,
|
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 ---------------- */
|
/* ---------------- theme hook ---------------- */
|
||||||
@ -56,6 +58,18 @@ function useLang(): [Lang, (l: Lang) => void] {
|
|||||||
return [lang, (l) => { setLangGlobal(l); setLang(l); }];
|
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() {
|
export default function App() {
|
||||||
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
|
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
|
||||||
@ -63,6 +77,7 @@ export default function App() {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [light, toggleTheme] = useTheme();
|
const [light, toggleTheme] = useTheme();
|
||||||
const [lang, setLang] = useLang();
|
const [lang, setLang] = useLang();
|
||||||
|
const [zoom, setZoom] = useZoom();
|
||||||
const saved = useMemo(() => loadSession(), []);
|
const saved = useMemo(() => loadSession(), []);
|
||||||
|
|
||||||
// try to restore the last session from the on-disk p4 ticket
|
// 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}
|
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
|
||||||
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
|
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
|
||||||
else
|
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); }}
|
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
|
||||||
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
|
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
|
||||||
|
|
||||||
@ -181,8 +196,10 @@ type ModalState =
|
|||||||
| { kind: "settings" }
|
| { kind: "settings" }
|
||||||
| { kind: "about" };
|
| { kind: "about" };
|
||||||
|
|
||||||
function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, onSession, onDisconnect }:
|
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
|
||||||
{ 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 }) {
|
|
||||||
|
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 [tab, setTab] = useState<Tab>("changes");
|
||||||
const [modal, setModal] = useState<ModalState | null>(null);
|
const [modal, setModal] = useState<ModalState | null>(null);
|
||||||
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
|
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 [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 [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 [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
|
// 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 [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; });
|
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
|
// 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.
|
// here on their own — no reconcile needed. Reconcile is a manual fallback.
|
||||||
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
|
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(() => {
|
useEffect(() => {
|
||||||
const close = () => setOpenMenu(null);
|
const close = () => setOpenMenu(null);
|
||||||
document.addEventListener("click", close);
|
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 }],
|
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("") }],
|
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() }],
|
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" }) }],
|
Tools: [{ label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
|
||||||
Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }],
|
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} />}
|
: <ChangeDetail detail={detail} busy={detailBusy} />}
|
||||||
</div>
|
</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 && (
|
{dragging && (
|
||||||
<div className="dropzone">
|
<div className="dropzone">
|
||||||
<div className="dropinner">
|
<div className="dropinner">
|
||||||
@ -607,7 +653,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
|
|||||||
</div>
|
</div>
|
||||||
</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} />}
|
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||||
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
|
||||||
{pushing && (
|
{pushing && (
|
||||||
@ -627,8 +673,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, onInfo, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
|
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
|
||||||
function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
|
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; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
|
{ 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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
@ -705,6 +751,13 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, onClose, onP
|
|||||||
</div>
|
</div>
|
||||||
</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("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("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("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>
|
<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 ---------------- */
|
/* ---------------- confirm modal ---------------- */
|
||||||
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -153,6 +153,14 @@ const D: Record<string, Tr> = {
|
|||||||
// app modal — settings
|
// app modal — settings
|
||||||
"Settings": { ru: "Настройки", de: "Einstellungen", fr: "Paramètres", es: "Ajustes" },
|
"Settings": { ru: "Настройки", de: "Einstellungen", fr: "Paramètres", es: "Ajustes" },
|
||||||
"Language": { ru: "Язык", de: "Sprache", fr: "Langue", es: "Idioma" },
|
"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 ☀" },
|
"Light ☀": { ru: "Светлая ☀", de: "Hell ☀", fr: "Clair ☀", es: "Claro ☀" },
|
||||||
"Dark ☾": { ru: "Тёмная ☾", de: "Dunkel ☾", fr: "Sombre ☾", es: "Oscuro ☾" },
|
"Dark ☾": { ru: "Тёмная ☾", de: "Dunkel ☾", fr: "Sombre ☾", es: "Oscuro ☾" },
|
||||||
"Server version": { ru: "Версия сервера", de: "Serverversion", fr: "Version du serveur", es: "Versión del servidor" },
|
"Server version": { ru: "Версия сервера", de: "Serverversion", fr: "Version du serveur", es: "Versión del servidor" },
|
||||||
|
|||||||
Reference in New Issue
Block a user