- build_sln now trusts UnrealBuildTool's "Result: Succeeded" (and absence of real compile errors) over MSBuild's exit code, which is non-zero for a side project even when the UE game module built fine. - Fixed double event-listener registration (async unlisten under StrictMode) that duplicated every p4-log / p4-transfer / build-log line. - Build overlay can be minimized to a small floating chip (spinner while building, ✓/✗ when done), click to expand, × to dismiss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1371 lines
80 KiB
TypeScript
1371 lines
80 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } 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 CodeView from "./CodeView";
|
||
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>,
|
||
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||
hammer: <svg viewBox="0 0 24 24" fill="none"><path d="M14 6l4 4M17 3l4 4-3 3-4-4 3-3ZM13 7 4 16a2 2 0 0 0 0 3l1 1a2 2 0 0 0 3 0l9-9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></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 /></>;
|
||
}
|
||
|
||
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
|
||
function Select({ value, options, onChange, icon, placeholder }:
|
||
{ value: string; options: { value: string; label: string; sub?: string }[]; onChange: (v: string) => void; icon?: ReactNode; placeholder?: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||
document.addEventListener("mousedown", onDoc);
|
||
document.addEventListener("keydown", onKey);
|
||
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
|
||
}, []);
|
||
const cur = options.find((o) => o.value === value);
|
||
return (
|
||
<div className={"xsel" + (open ? " open" : "")} ref={ref}>
|
||
<button type="button" className="xsel-trig" onClick={() => setOpen((o) => !o)}>
|
||
{icon}
|
||
<span className="xsel-val">{cur ? cur.label : <span className="xsel-ph">{placeholder}</span>}</span>
|
||
<span className="xsel-chev">{I.chev}</span>
|
||
</button>
|
||
{open && (
|
||
<div className="xsel-list">
|
||
{options.length === 0 && <div className="xsel-empty">{placeholder}</div>}
|
||
{options.map((o) => (
|
||
<div key={o.value} className={"xsel-item" + (o.value === value ? " on" : "")} onClick={() => { onChange(o.value); setOpen(false); }}>
|
||
<span className="xsel-ic">{o.value === value ? I.check : null}</span>
|
||
<span className="xsel-body"><span className="xsel-name">{o.label}</span>{o.sub && <span className="xsel-sub">{o.sub}</span>}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 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 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 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 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>
|
||
{clients.length ? (
|
||
<Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
|
||
icon={<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>}
|
||
options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
|
||
) : (
|
||
<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>
|
||
<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; icon?: ReactNode; 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); // primary row (preview + shift anchor)
|
||
const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
|
||
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 [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
|
||
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
|
||
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
|
||
const [building, setBuilding] = useState(false);
|
||
const [buildOk, setBuildOk] = useState<boolean | null>(null);
|
||
const [buildOpen, setBuildOpen] = useState(false);
|
||
const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
|
||
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));
|
||
setSelRows(new Set());
|
||
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; let dead = false;
|
||
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) => { if (dead) u(); else un = u; });
|
||
return () => { dead = true; un?.(); };
|
||
}, []);
|
||
// live file-transfer progress (Get Latest / Submit) streamed from the backend
|
||
useEffect(() => {
|
||
let un: (() => void) | undefined; let dead = false;
|
||
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) => { if (dead) u(); else un = u; });
|
||
return () => { dead = true; 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]);
|
||
// detect a UE project / .sln in the current working folder (Launch / Build actions)
|
||
useEffect(() => {
|
||
if (!activePath) { setUproject(""); setSlnPath(""); return; }
|
||
let live = true;
|
||
p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject(""));
|
||
p4.findSln(activePath).then((p) => live && setSlnPath(p)).catch(() => live && setSlnPath(""));
|
||
return () => { live = false; };
|
||
}, [activePath]);
|
||
// live MSBuild output
|
||
useEffect(() => {
|
||
let un: (() => void) | undefined; let dead = false;
|
||
listen<{ line: string; done: boolean; ok: boolean }>("build-log", (e) => {
|
||
const p = e.payload;
|
||
setBuildLog((L) => (L.length > 4000 ? [...L.slice(-4000), p.line] : [...L, p.line]));
|
||
if (p.done) { setBuilding(false); setBuildOk(p.ok); }
|
||
}).then((u) => { if (dead) u(); else un = u; });
|
||
return () => { dead = true; un?.(); };
|
||
}, []);
|
||
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 }); }
|
||
|
||
// launch Unreal by opening the detected .uproject (file association starts UE)
|
||
async function launchUE() {
|
||
if (!uproject) return;
|
||
try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); }
|
||
catch (e) { flash(String(e), true); }
|
||
}
|
||
|
||
// build the solution found in the working folder (MSBuild) with live output
|
||
async function buildSln() {
|
||
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
|
||
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
|
||
try { await p4.buildSln(slnPath); }
|
||
catch { /* the overlay already shows the failure line */ }
|
||
}
|
||
|
||
// 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();
|
||
await loadHistory(); // the submit created a new submitted changelist — refresh History
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|
||
// row click with Shift (range) / Ctrl (toggle) multi-selection
|
||
function rowClick(i: number, e: ReactMouseEvent) {
|
||
if (e.shiftKey && sel != null) {
|
||
const a = Math.min(sel, i), b = Math.max(sel, i);
|
||
const s = new Set<number>();
|
||
for (let k = a; k <= b; k++) s.add(k);
|
||
setSelRows(s);
|
||
} else if (e.ctrlKey || e.metaKey) {
|
||
setSelRows((prev) => { const n = new Set(prev); n.has(i) ? n.delete(i) : n.add(i); return n; });
|
||
} else {
|
||
setSelRows(new Set([i]));
|
||
}
|
||
setSel(i);
|
||
}
|
||
// checkbox: if the row is part of a multi-selection, toggle the whole selection together
|
||
function toggleCheck(i: number, dp: string) {
|
||
const idxs = selRows.has(i) && selRows.size > 1 ? [...selRows] : [i];
|
||
const dps = idxs.map((k) => view[k]?.depotFile || "").filter(Boolean);
|
||
const target = !checked.has(dp);
|
||
setChecked((prev) => { const n = new Set(prev); dps.forEach((d) => (target ? n.add(d) : n.delete(d))); return n; });
|
||
}
|
||
|
||
// Discard local changes (p4 revert) for the given files — the "undo" for opened files.
|
||
async function discard(fs: OpenedFile[]) {
|
||
const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
|
||
if (!dps.length) return;
|
||
const label = dps.length === 1 ? splitPath(dps[0]).name : t("{n} files", { n: dps.length });
|
||
if (!(await confirm({ title: t("Discard changes?"), body: t("Local changes to {name} will be lost permanently — the file returns to the server version.", { name: label }), confirm: t("Discard"), danger: true }))) return;
|
||
setBusy(true);
|
||
try { await p4.revert(dps); flash(t("Discarded: {name}", { name: label })); await refresh(); }
|
||
catch (e) { flash(String(e), true); setBusy(false); }
|
||
}
|
||
// right-click on a Changes row → file actions (operates on the selection if the row is in it)
|
||
function fileCtx(i: number, e: ReactMouseEvent) {
|
||
const multi = selRows.has(i) && selRows.size > 1;
|
||
if (!selRows.has(i)) { setSelRows(new Set([i])); setSel(i); } // right-click selects the row
|
||
const idxs = multi ? [...selRows] : [i];
|
||
const targets = idxs.map((k) => view[k]).filter(Boolean) as OpenedFile[];
|
||
const dp = view[i]?.depotFile || "";
|
||
const isCode = kindOf(splitPath(dp).name) === "code";
|
||
openCtx(e, [
|
||
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
|
||
...(isCode ? [{ label: t("Edit in VS Code"), icon: I.vscode, act: () => { p4.openInVscode(dp).then(() => flash(t("Opening in VS Code…"))).catch((err) => flash(String(err), true)); } }] : []),
|
||
{ label: t("Open in Explorer"), act: () => reveal(dp) },
|
||
{ label: t("Copy path"), act: () => copyText(dp, t("Path")) },
|
||
]);
|
||
}
|
||
|
||
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: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { 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 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, [
|
||
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
|
||
...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []),
|
||
{ 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); setSelRows(new Set()); }} /></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} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
|
||
)}
|
||
<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} onFlash={flash} />
|
||
: <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} />}
|
||
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
|
||
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
|
||
{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)) };
|
||
const hasIcon = items.some((it) => it.icon);
|
||
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(); }}>
|
||
{hasIcon && <span className="ctx-ic">{it.icon}</span>}
|
||
<span>{it.label}</span>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
/* ---------------- build output (MSBuild) ---------------- */
|
||
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; onClose: () => void }) {
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
|
||
const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
|
||
return (
|
||
<div className="modal-back">
|
||
<div className="build">
|
||
<div className="build-head">
|
||
<span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
|
||
{building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
|
||
</span>
|
||
<div className="build-ttl">
|
||
<b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
|
||
<span>{name}</span>
|
||
</div>
|
||
<button className="build-x" onClick={onMinimize} title={t("Minimize")}>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
|
||
</button>
|
||
<button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
|
||
<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>
|
||
</div>
|
||
<div className="build-body" ref={ref}>
|
||
{lines.map((ln, i) => {
|
||
const low = ln.toLowerCase();
|
||
const cls = /: error |\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : "";
|
||
return <div key={i} className={"build-ln" + cls}>{ln}</div>;
|
||
})}
|
||
{building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
/* minimized build indicator — small floating chip, click to expand */
|
||
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
|
||
return (
|
||
<div className={"buildchip" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")} onClick={onExpand} title={t("Expand")}>
|
||
<span className="bc-ic">{building ? <span className="ldr sm" /> : ok ? "✓" : "✗"}</span>
|
||
<span className="bc-txt">{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}</span>
|
||
<button className="bc-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title={t("Close")}>
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
|
||
</button>
|
||
</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, selRows, checked, onRowClick, onToggle, onContext }:
|
||
{ files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => 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" + (selRows.has(i) ? " sel" : "") + (i === sel ? " primary" : "")} style={{ top: i * ROW }} onClick={(e) => onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}>
|
||
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, 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, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => 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">
|
||
{kind === "code" && (
|
||
<span className="gbtn vsc" onClick={async () => { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
|
||
{I.vscode}{t("Edit in VS Code")}
|
||
</span>
|
||
)}
|
||
<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} />
|
||
: <CodeView 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>;
|
||
}
|
||
|
||
/* ---------------- 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;
|
||
}
|
||
|