Files
Bonchellon 0615c02742 Limit log-peek hover to the status text, not the whole bar
.stfront no longer flex:1 across the bar — it hugs the icon+command text
(max 70%), with an inert spacer filling the rest, so the peek only appears
when hovering the actual text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:59:28 +03:00

1211 lines
68 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
/* ---------------- window controls (frameless) ---------------- */
function WinControls() {
const w = getCurrentWindow();
return (
<div className="wctrls">
<button className="wbtn" onClick={() => w.minimize()} title={t("Minimize")}><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title={t("Maximize")}><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title={t("Close")}><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
</div>
);
}
/* ---------------- icons ---------------- */
const I = {
hex: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.6"/></svg>,
monitor: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
branch: <svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 0a9 9 0 0 1-9 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
sync: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5M20 12a8 8 0 0 1-14 5.3L3 15M3 20v-5h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
chev: <svg viewBox="0 0 24 24" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
search: <svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
check: <svg viewBox="0 0 24 24" fill="none"><path d="m5 12 5 5L20 7" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
moon: <svg viewBox="0 0 24 24" fill="none"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"/></svg>,
sun: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.7"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
arrow: <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
spinner: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.3)" strokeWidth="2"/><path d="M12 3a9 9 0 0 1 9 9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/></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>,
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 ---------------- */
function useTheme(): [boolean, () => void] {
const [light, setLight] = useState(() => {
try { return localStorage.getItem("exd-theme") === "light"; } catch { return false; }
});
useEffect(() => {
document.body.classList.toggle("light", light);
try { localStorage.setItem("exd-theme", light ? "light" : "dark"); } catch {}
}, [light]);
return [light, () => setLight((l) => !l)];
}
/* ---------------- language hook (whole tree re-renders on change) ---------------- */
function useLang(): [Lang, (l: Lang) => void] {
const [lang, setLang] = useState<Lang>(LANG);
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");
const [info, setInfo] = useState<P4Info | null>(null);
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.
// keep the splash on screen for at least a beat so the intro animation reads.
useEffect(() => {
if (!saved) { const id = setTimeout(() => setPhase("connect"), 900); return () => clearTimeout(id); }
const min = new Promise<void>((r) => setTimeout(r, 1000));
p4.restore(saved.server, saved.user, saved.client)
.then(async (i) => { await min; setInfo(i); setSession(saved); setPhase("main"); })
.catch(async () => { await min; setPhase("connect"); });
}, []); // eslint-disable-line
let content;
if (phase === "loading")
content = <Splash light={light} toggleTheme={toggleTheme} />;
else if (phase === "connect")
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} zoom={zoom} setZoom={setZoom}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <>{content}<UpdateBanner /></>;
}
/* ---------------- animated startup splash ---------------- */
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
const [step, setStep] = useState(0);
useEffect(() => {
const id = setInterval(() => setStep((s) => s + 1), 750);
return () => clearInterval(id);
}, []);
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="splash">
<div className="splash-logo">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
</div>
<div className="flow">
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
<div className="wire">
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
</div>
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
</div>
<div className="splash-title">Exbyte&nbsp;Depot</div>
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
<div className="splash-bar"><span /></div>
</div>
</div>
</div>
);
}
/* ---------------- connect screen ---------------- */
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
const [user, setUser] = useState(initial?.user || "Bonchellon");
const [password, setPassword] = useState("");
const [client, setClient] = useState(initial?.client || "");
const [clients, setClients] = useState<Client[]>([]);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function loadClients() {
try {
const cs = await p4.clients(server, user);
setClients(cs);
if (cs.length && !client) setClient(cs[0].client || "");
} catch { /* ignore */ }
}
// auto-load the workspace list as soon as the screen opens
useEffect(() => { loadClients(); /* eslint-disable-next-line */ }, []);
async function connect() {
setBusy(true); setErr("");
try {
const i = await p4.login(server, user, password, client);
onDone(i, { server, user, client });
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="card">
<div className="clogo">{I.cube}</div>
<h2>{t("Connect to Perforce")}</h2>
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
<div className="fld"><label>{t("Server")}</label>
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
<div className="fld"><label>{t("User")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
<div className="fld"><label>{t("Workspace")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
{clients.length ? (
<select value={client} onChange={(e) => setClient(e.target.value)}>
{clients.map((c) => <option key={c.client} value={c.client}>{c.client}{c.Root ? `${c.Root}` : ""}</option>)}
</select>
) : (
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
)}
</div></div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<button className="connect" disabled={busy || !password} onClick={connect}>
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
</button>
</div>
</div>
</div>
);
}
/* ---------------- main workbench ---------------- */
type Tab = "changes" | "history";
type CtxItem = { label: string; danger?: boolean; act: () => void };
type ModalState =
| { kind: "workspaces"; items: Client[]; current: string }
| { kind: "scope"; path: string; dirs: Dir[] }
| { kind: "settings" }
| { kind: "about" };
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
type Transfer = { op: "sync" | "submit"; count: number; file?: string };
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 || ""));
const [files, setFiles] = useState<OpenedFile[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [sel, setSel] = useState<number | null>(null);
const [filter, setFilter] = useState("");
const [desc, setDesc] = useState(""); // commit summary (first line)
const [descBody, setDescBody] = useState(""); // extended description (optional)
const [busy, setBusy] = useState(false);
const [scanning, setScanning] = useState(false); // background reconcile in progress
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
const [dragging, setDragging] = useState(false);
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 [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
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);
const peekTimer = useRef<number | null>(null);
function showPeek() { if (peekTimer.current) { clearTimeout(peekTimer.current); peekTimer.current = null; } setPeek(true); }
function hidePeek() { if (peekTimer.current) clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 220); }
// 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; });
useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]);
useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]);
function startResize(e: ReactMouseEvent, kind: "left" | "sync") {
e.preventDefault();
const startX = e.clientX, startLeft = leftW, startSync = syncW;
const move = (ev: MouseEvent) => {
if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
};
const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); };
document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); document.body.classList.add("resizing");
}
function flash(text: string, err = false) {
setToast({ text, err });
setTimeout(() => setToast(null), 4500);
}
function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise<boolean> {
return new Promise((resolve) => setAsk({ ...opts, resolve }));
}
// load the currently-open files + pending changelists (fast — just reads p4 state)
async function loadOpened(s: string) {
const f = await p4.opened(s);
setFiles(f);
const uncommitted = f.filter((x) => (x.change || "default") === "default");
setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
try { setPending(await p4.changes()); } catch { setPending([]); }
}
// refresh the Changes view. `scan=true` also auto-detects out-of-band changes
// (new/edited/deleted files on disk — e.g. assets Unreal wrote directly) via a
// background reconcile, so they show up on their own. The reconcile runs off the
// UI thread (async command) and never freezes the window.
async function refresh(scope?: string, scan = false) {
const s = scope !== undefined ? scope : activePath;
setBusy(true);
try { await loadOpened(s); }
catch (e) { flash(String(e), true); }
finally { setBusy(false); }
if (scan) {
setScanning(true);
try { await p4.scan(s); await loadOpened(s); }
catch (e) { flash(String(e), true); }
finally { setScanning(false); }
}
}
// Default = Perforce-native: just read what's checked out (p4 opened). With
// 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?.();
}, []);
// live file-transfer progress (Get Latest / Submit) streamed from the backend
useEffect(() => {
let un: (() => void) | undefined;
listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
const p = e.payload;
if (p.done) setTransfer(null);
else setTransfer({ op: p.op, count: p.count, file: p.file });
}).then((u) => (un = u));
return () => un?.();
}, []);
// only surface the progress dialog once a transfer has run for a moment
const xferActive = transfer !== null;
useEffect(() => {
if (!xferActive) { setShowXfer(false); return; }
const id = setTimeout(() => setShowXfer(true), 400);
return () => clearTimeout(id);
}, [xferActive]);
useEffect(() => {
const close = () => setOpenMenu(null);
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, []);
// keep the changelist live: re-scan opened files when the window regains focus
useEffect(() => {
const onFocus = () => { if (tab === "changes") refresh(); };
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
// eslint-disable-next-line
}, [tab]);
// drag & drop files/folders onto the window → reconcile them into the changelist
useEffect(() => {
let unlisten: (() => void) | undefined;
getCurrentWebview()
.onDragDropEvent(async (e) => {
const dt = (e.payload as { type: string }).type;
if (dt === "over") { setDragging(true); return; }
if (dt === "leave") { setDragging(false); return; }
if (dt === "drop") {
setDragging(false);
const paths = (e.payload as { paths: string[] }).paths || [];
if (!paths.length) return;
setBusy(true);
let n = 0;
for (const p of paths) {
try { n += (await p4.reconcile(p)).length; } catch (err) { flash(String(err), true); }
}
flash(n ? t("Added/updated in changelist: {n}", { n }) : t("No changes to add"));
await refresh();
}
})
.then((u) => (unlisten = u));
return () => unlisten?.();
// eslint-disable-next-line
}, []);
async function revertAll() {
const list = files.map((f) => f.depotFile || "").filter(Boolean);
if (!list.length) return;
if (!(await confirm({ title: t("Revert all changes?"), body: t("Edits in all {n} files will be undone. Local changes are lost permanently.", { n: list.length }), confirm: t("Revert all"), danger: true }))) return;
setBusy(true);
try { await p4.revert(list); flash(t("All changes reverted.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
async function openChange(c: Change) {
const n = c.change || "";
setSelChange(n); // highlight the row immediately — no waiting for the load
setDetail(null);
setDetailBusy(true);
try { setDetail(await p4.describe(n)); }
catch (e) { flash(String(e), true); }
finally { setDetailBusy(false); }
}
async function openWorkspaces() {
if (!session) { flash(t("No session data for the workspace list"), true); return; }
try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); }
catch (e) { flash(String(e), true); }
}
async function switchWorkspace(client: string) {
setModal(null); setBusy(true);
try {
const i = await p4.switchClient(client);
onInfo(i);
if (session) onSession({ ...session, client });
const scope = loadScope(client); // restore this workspace's saved working folder
setActivePath(scope);
await refresh(scope);
flash(t("Active workspace: {c}", { c: client }));
} catch (e) { flash(String(e), true); setBusy(false); }
}
async function browseTo(path: string) {
try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); }
catch (e) { flash(String(e), true); }
}
function chooseScope(path: string) {
setModal(null);
setActivePath(path);
saveScope(info?.clientName || "", path); // remember the working folder per workspace
setHistory([]); setDetail(null); setSel(null);
refresh(path);
if (tab === "history") loadHistory(path);
}
async function loadHistory(scope?: string) {
const f = scope !== undefined ? scope : activePath;
const path = f ? f + "/..." : "";
setBusy(true);
try { setHistory(await p4.history(path)); }
catch (e) { flash(String(e), true); } finally { setBusy(false); }
}
function switchTab(t: Tab) {
setTab(t);
if (t === "history" && history.length === 0) loadHistory();
}
async function getLatest() {
setBusy(true);
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
// Manual re-scan (same background reconcile as auto, on demand).
function rescan() { refresh(activePath, true); }
function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }
// reveal a depot path / workspace root in Windows Explorer
async function reveal(target: string) {
if (!target) { flash(t("Path not set"), true); return; }
try { await p4.openInExplorer(target); }
catch (e) { flash(String(e), true); }
}
async function copyText(text: string, what: string) {
try { await navigator.clipboard.writeText(text); flash(t("{what} copied", { what })); }
catch { flash(t("Copy failed"), true); }
}
// roll back an already-submitted changelist (Perforce `p4 undo` → new revert change)
async function revertChange(c: Change) {
const n = c.change || "";
if (!(await confirm({ title: t("Revert changelist #{n}?", { n }), body: t("Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”", { n, desc: (c.desc || "").trim() || t("no description") }), confirm: t("Revert #{n}", { n }), danger: true }))) return;
setBusy(true);
try {
await p4.undoChange(n);
flash(t("Changelist #{n} reverted by a new changelist.", { n }));
await loadHistory();
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
}
// Commit (local): move checked uncommitted files into a new pending changelist
// with the description. Nothing goes to the server — revertable. (like git commit)
async function doCommit() {
const list = files
.filter((f) => (f.change || "default") === "default")
.map((f) => f.depotFile || "")
.filter((d) => checked.has(d));
if (!list.length || !desc.trim()) return;
const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim();
setBusy(true);
try {
const cl = await p4.commit(message, list);
flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
setDesc(""); setDescBody("");
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
}
// Submit / push: send all committed (pending) changelists to the server. (like git push)
async function pushAll() {
if (!pending.length) return;
const n = pending.length;
if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
setBusy(true);
const errors: string[] = [];
for (const c of pending) {
try { await p4.submitChange(c.change || ""); }
catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
}
if (errors.length) flash(errors.join(" · "), true);
else flash(t("Sent to the server: {n} changelist(s).", { n }));
await refresh();
}
async function doRevert(file: OpenedFile) {
const dp = file.depotFile || "";
if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
setBusy(true);
try { await p4.revert([dp]); flash(t("Reverted: {name}", { name: splitPath(dp).name })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
const uncommitted = useMemo(() => files.filter((f) => (f.change || "default") === "default"), [files]);
const view = useMemo(() => {
if (!filter.trim()) return uncommitted;
const q = filter.toLowerCase();
return uncommitted.filter((f) => (f.depotFile || "").toLowerCase().includes(q));
}, [uncommitted, filter]);
const selFile = sel != null ? view[sel] : undefined;
const allChecked = view.length > 0 && view.every((f) => checked.has(f.depotFile || ""));
function toggleAll() {
setChecked((s) => {
const n = new Set(s);
if (allChecked) view.forEach((f) => n.delete(f.depotFile || ""));
else view.forEach((f) => n.add(f.depotFile || ""));
return n;
});
}
const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
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" }) }],
};
return (
<div className="win" style={{ "--left-w": `${leftW}px`, "--right-zone-w": `${syncW}px` } as CSSProperties}>
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
{t(name)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div>
))}
</div>
</div>
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), act: openWorkspaces },
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="chev">{I.chev}</span>
</div>
<div className="tgroup">
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), act: () => browseTo("") },
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
])}>
<span className="ic">{I.branch}</span>
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
<span className="chev">{I.chev}</span>
</div>
{pending.length > 0 ? (
<div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="tzmeta"><span className="val">{t("Submit to server")}</span><span className="sub">{t("{n} changelist(s) → push", { n: pending.length })}</span></span>
</div>
) : (
<div className={"tzone sync" + (busy ? " busy" : "")} onClick={getLatest}>
<span className="ic">{I.sync}</span>
<span className="tzmeta"><span className="val">{t("Get Latest")}</span><span className="sub">{busy ? t("Working…") : t("pull from server")}</span></span>
</div>
)}
</div>
</div>
<div className="main">
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
<div className="left">
<div className="tabs">
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
</div>
{tab === "changes" ? (
<>
<div className="filter">
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => setFilter(e.target.value)} /></div>
</div>
{pending.length > 0 && (
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
<span className="pushdot" />
<span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
<span className="pusharr"></span>
</div>
)}
{scanning && (
<div className="scanbar">
<span className="ldr" />
<span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
</button>
</div>
{view.length === 0 ? (
<div className="empty">
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
{busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
</>}
</div>
) : (
<FileList files={view} sel={sel} checked={checked} onSelect={setSel}
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
)}
<div className="commit">
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
</button>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
</>
) : (
<HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
)}
</div>
{tab === "changes"
? <Preview file={selFile} onRevert={doRevert} />
: <ChangeDetail detail={detail} busy={detailBusy} />}
</div>
{logOpen && <LogPanel logs={logs} onClose={() => setLogOpen(false)} onClear={() => setLogs([])} />}
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
<span className={"stdot" + ((busy || scanning || !!transfer) ? " busy" : "")}>{(busy || scanning || !!transfer) ? <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>
</span>
<span className="stspace" />
<button className="stlog" onClick={() => setLogOpen((o) => !o)} title="Ctrl+L">{I.log}<span>Log</span></button>
{peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<div className="logpeek-h">{t("Log")} · {logs.length}</div>
{logs.slice(-40).map((l) => <LogRow key={l.id} l={l} />)}
</div>
)}
</div>
{dragging && (
<div className="dropzone">
<div className="dropinner">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
<span>{t("Drop to add to Perforce")}</span>
</div>
</div>
)}
{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); }} />}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
</div>
);
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
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);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const X = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;
const folder = <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6" /></svg>;
const depotIc = <svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" /><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6" /></svg>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
{modal.kind === "workspaces" && (<>
<div className="picker-head">{folder}<h3>{t("Choose workspace")}</h3>{X}</div>
<div className="picker-list">
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>{t("No workspaces available")}</div>}
{modal.items.map((c) => (
<div key={c.client} className={"picker-item" + (c.client === modal.current ? " on" : "")} onClick={() => onPickWorkspace(c.client || "")}>
<span className="pi-ic">{folder}</span>
<span className="pi-body"><span className="pi-name">{c.client}</span><span className="pi-sub">{(c.Root as string) || ""}</span></span>
</div>
))}
</div>
<div className="picker-foot">{t("Active: {c}", { c: modal.current || "—" })}</div>
</>)}
{modal.kind === "scope" && (() => {
const path = modal.path;
const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
const up = parts.length > 0;
return (<>
<div className="picker-head">{depotIc}<h3>{t("Working folder")}</h3>{X}</div>
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--border-soft)" }}>
<div className="crumb">
<span className="seg" onClick={() => onBrowse("")}>//</span>
{parts.map((seg, i) => (
<span key={i}><span className="sep">/</span><span className="seg" onClick={() => onBrowse("//" + parts.slice(0, i + 1).join("/"))}>{seg}</span></span>
))}
</div>
</div>
<div className="picker-list">
{up && (
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="pi-body"><span className="pi-name up">{t(".. up")}</span></span>
</div>
)}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>{t("No subfolders")}</div>}
{modal.dirs.map((d) => (
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
<span className="pi-ic">{folder}</span>
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}</span><span className="pi-sub">{d.dir}</span></span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
))}
</div>
<button className="scope-choose" onClick={() => onChoose(path)}>
{path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
</button>
</>);
})()}
{modal.kind === "settings" && (<>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
<div className="info-body">
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</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>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
</div>
</>)}
{modal.kind === "about" && (<>
<div className="picker-head">{depotIc}<h3>Exbyte Depot</h3>{X}</div>
<div className="info-body" style={{ textAlign: "center" }}>
<div style={{ width: 60, height: 60, margin: "6px auto 14px", borderRadius: 16, background: "linear-gradient(145deg,var(--accent-2),var(--accent-deep))", display: "grid", placeItems: "center", boxShadow: "0 0 30px rgba(124,110,246,.5)" }}>
<svg viewBox="0 0 24 24" width="30" height="30" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round" /><path d="m3 7 9 5 9-5M12 12v10" stroke="#fff" strokeWidth="1.5" /></svg>
</div>
<p style={{ marginBottom: 8 }}>{t("Exbyte Depot — native Perforce client")}</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />{t("Built by Exbyte Studios")}</p>
</div>
</>)}
</div>
</div>
);
}
/* ---------------- right-click context menu ---------------- */
function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: CtxItem[]; onClose: () => void }) {
useEffect(() => {
const close = () => onClose();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("click", close);
document.addEventListener("scroll", close, true);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("click", close); document.removeEventListener("scroll", close, true); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const style: CSSProperties = { left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - (items.length * 36 + 14)) };
return (
<div className="ctxmenu" style={style} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>{it.label}</div>
))}
</div>
);
}
/* ---------------- 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>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
const up = transfer.op === "submit";
return (
<div className="modal-back xfer-back">
<div className="xfer">
<div className="xfer-head">
<span className={"xfer-ic" + (up ? " up" : " down")}>
{up
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
<div className="xfer-ttl">
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
<span>{t("{n} files", { n: transfer.count })}</span>
</div>
</div>
<div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
<div className="xfer-bar"><span /></div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); if (e.key === "Enter") onClose(true); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="modal-back" onClick={() => onClose(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className={"modal-icon" + (danger ? " danger" : "")}>
{danger
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.3 3.9 2 18a2 2 0 0 0 1.7 3h16.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<h3>{title}</h3>
<p>{body}</p>
<div className="modal-actions">
<button className="mbtn ghost" onClick={() => onClose(false)}>{t("Cancel")}</button>
<button className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)} autoFocus>{confirm}</button>
</div>
</div>
</div>
);
}
/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500);
const fileRows = useMemo(() => (detail ? describeFiles(detail) : []), [detail]);
useEffect(() => {
const el = ref.current; if (!el) return;
const onScroll = () => setScroll(el.scrollTop);
el.addEventListener("scroll", onScroll);
const ro = new ResizeObserver(() => setH(el.clientHeight));
ro.observe(el); setH(el.clientHeight);
return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
}, [detail]);
// jump back to top whenever a different changelist loads
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
if (!detail) return <div className="right"><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
const rows = [];
for (let i = first; i < last; i++) {
const f = fileRows[i];
const { name, dir } = splitPath(f.depotFile);
const st = statusOf(f.action);
rows.push(
<div key={i} className="crow" style={{ top: i * CROW }}>
<span className={"stat " + st.cls}>{st.ch}</span>
<span className="hbody"><span className="hdesc" style={{ WebkitLineClamp: 1 }}>{name}</span><span className="hmeta">{dir}#{f.rev}</span></span>
</div>
);
}
return (
<div className="right">
<div className="diffhead">
<span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
<span className="ttl">
<span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
<span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
</span>
</div>
<div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
</div>
);
}
/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
return (
<div className="files" style={{ position: "relative" }}>
{history.map((c) => (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<span className="hnum">#{c.change}</span>
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
</span>
</div>
))}
</div>
);
}
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
function Thumb({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const kind = kindOf(name);
const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
useEffect(() => {
if (kind !== "image" && kind !== "uasset") return;
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
let live = true;
p4.readDepot(dp).then((buf) => {
if (!live) return;
let u: string | null = null;
if (kind === "image") {
const ext = (name.split(".").pop() || "png").toLowerCase();
u = URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
} else {
const png = extractPng(new Uint8Array(buf));
if (png) u = URL.createObjectURL(new Blob([png], { type: "image/png" }));
}
thumbCache.set(dp, u);
if (live) setUrl(u);
}).catch(() => { thumbCache.set(dp, null); });
return () => { live = false; };
}, [dp]);
if (url) return <img className="fthumb" src={url} alt="" />;
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
return <span className={"fthumb ico k-" + kind}>{ic}</span>;
}
/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, checked, onSelect, onToggle }:
{ files: OpenedFile[]; sel: number | null; checked: Set<string>; onSelect: (i: number) => void; onToggle: (dp: string) => void }) {
const ref = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500);
useEffect(() => {
const el = ref.current!;
const onScroll = () => setScroll(el.scrollTop);
el.addEventListener("scroll", onScroll);
const ro = new ResizeObserver(() => setH(el.clientHeight));
ro.observe(el); setH(el.clientHeight);
return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
}, []);
const first = Math.max(0, Math.floor(scroll / ROW) - 4);
const last = Math.min(files.length, Math.ceil((scroll + h) / ROW) + 4);
const rows = [];
for (let i = first; i < last; i++) {
const f = files[i];
const dp = f.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(f.action);
const isCk = checked.has(dp);
rows.push(
<div key={dp + i} className={"file" + (i === sel ? " sel" : "")} style={{ top: i * ROW }} onClick={() => onSelect(i)}>
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(dp); }}>{I.check}</span>
<Thumb file={f} />
<span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
<span className={"stat " + st.cls}>{st.ch}</span>
</div>
);
}
return <div className="files" ref={ref}><div className="vspace" style={{ height: files.length * ROW }}>{rows}</div></div>;
}
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void }) {
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
const dp = file.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
const kind = kindOf(name);
const rev = file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
return (
<div className="right">
<div className="diffhead">
<span className={"stat " + st.cls} style={{ width: 26, height: 26, fontSize: 13 }}>{st.ch}</span>
<span className="ttl">
<span className="n">{name} <span> {dir}</span></span>
<span className="m">{file.action} · <span className="rev">{rev}</span> · {file.type}</span>
</span>
<span className="tools">
<span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
</span>
</div>
{kind === "model" ? <ModelViewer depot={dp} name={name} />
: kind === "image" ? <ImageViewer depot={dp} name={name} />
: kind === "uasset" ? <UassetPreview file={file} />
: <DiffView file={file} />}
</div>
);
}
function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
return <div className="mi"><span className="k">{k}</span><span className={"v" + (acc ? " acc" : "")}>{v}</span></div>;
}
/* ---------------- text diff ---------------- */
function DiffView({ file }: { file: OpenedFile }) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
const dp = file.depotFile || "";
useEffect(() => {
let live = true; setText(null); setErr("");
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (text === null) return <div className="nofile">{t("Loading diff…")}</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>{t("No text differences (or binary file).")}</span></div>;
const lines = text.split("\n");
return (
<div className="diff">
{lines.map((ln, i) => {
let cls = "";
if (ln.startsWith("@@")) cls = "hunk";
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
return <div key={i} className={"dl " + cls}><span className="g" /><span className="c">{ln || " "}</span></div>;
})}
</div>
);
}
/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name }: { depot: string; name: string }) {
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState("");
const [dim, setDim] = useState("");
useEffect(() => {
let u = ""; let live = true;
p4.readDepot(depot).then((buf) => {
if (!live) return;
const ext = (name.split(".").pop() || "png").toLowerCase();
const mime = ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}`;
u = URL.createObjectURL(new Blob([buf], { type: mime }));
setUrl(u);
}).catch((e) => live && setErr(String(e)));
return () => { live = false; if (u) URL.revokeObjectURL(u); };
}, [depot, name]);
return (
<div className="asset">
<div className="stage">
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>{t("Image")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: url ? <img src={url} alt={name} style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 10, boxShadow: "0 20px 50px -20px #000" }}
onLoad={(e) => setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
: <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>}
</div>
<div className="assetmeta">
<Mi k={t("File")} v={name} /><Mi k={t("Resolution")} v={dim || "—"} /><Mi k={t("Format")} v={(name.split(".").pop() || "").toUpperCase()} /><Mi k={t("Source")} v={t("working copy")} acc />
</div>
</div>
);
}
/* ---------------- .uasset — extract embedded Unreal thumbnail (PNG) ---------------- */
function UassetPreview({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const [thumb, setThumb] = useState<string | null>(null);
const [tried, setTried] = useState(false);
useEffect(() => {
let live = true; let url = "";
setThumb(null); setTried(false);
p4.readDepot(dp).then((buf) => {
if (!live) return;
const png = extractPng(new Uint8Array(buf));
if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
setTried(true);
}).catch(() => live && setTried(true));
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [dp]);
return (
<div className="asset">
<div className="stage">
{thumb ? (<>
<span className="lbl2">{I.cube}{t("Unreal · embedded thumbnail")}</span>
<img src={thumb} alt={name} style={{ maxWidth: "82%", maxHeight: "82%", borderRadius: 12, boxShadow: "0 20px 50px -20px #000" }} />
</>) : (
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}</div>
</div>
)}
</div>
<div className="assetmeta">
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
</div>
</div>
);
}
// scan raw bytes for an embedded PNG and return just that PNG (sig → IEND+crc)
function extractPng(b: Uint8Array): Uint8Array | null {
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let i = 0; i < b.length - 8; i++) {
let ok = true;
for (let k = 0; k < 8; k++) if (b[i + k] !== sig[k]) { ok = false; break; }
if (!ok) continue;
for (let j = i + 8; j < b.length - 8; j++) {
if (b[j] === 0x49 && b[j + 1] === 0x45 && b[j + 2] === 0x4e && b[j + 3] === 0x44) return b.slice(i, j + 8);
}
return b.slice(i);
}
return null;
}