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 (
); } /* ---------------- icons ---------------- */ const I = { hex: , monitor: , branch: , sync: , chev: , search: , check: , moon: , sun: , arrow: , spinner: , cube: , revert: , clock: , log: , }; /* ---------------- 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); 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(null); const [session, setSession] = useState(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((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 = ; else if (phase === "connect") content = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; else content = { setSession(s); saveSession(s); }} onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />; return <>{content}; } /* ---------------- 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 (
{I.hex}
Exbyte Depot — Perforce
{[0, 1, 2, 3, 4, 5].map((i) => )}
Exbyte Depot
{steps[Math.min(step, steps.length - 1)]}
); } /* ---------------- 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([]); 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 (
{I.hex}
Exbyte Depot — Perforce
{I.cube}

{t("Connect to Perforce")}

{t("Exbyte Depot — sign in to your P4 server")}
{I.monitor} setServer(e.target.value)} />
setUser(e.target.value)} onBlur={loadClients} />
setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} />
{clients.length ? ( ) : ( setClient(e.target.value)} /> )}
{err &&
{err}
}
); } /* ---------------- 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("changes"); const [modal, setModal] = useState(null); const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); const [files, setFiles] = useState([]); const [checked, setChecked] = useState>(new Set()); const [sel, setSel] = useState(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(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); const [detail, setDetail] = useState(null); const [selChange, setSelChange] = useState(""); // highlighted History row (instant) const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading const [dragging, setDragging] = useState(false); const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists const [transfer, setTransfer] = useState(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); // right-click menu const [logs, setLogs] = useState([]); // 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(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(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; }); const [syncW, setSyncW] = useState(() => { 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 { 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 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 (
{I.hex}
Exbyte Depot — Perforce
e.stopPropagation()}> {Object.entries(menus).map(([name, items]) => (
setOpenMenu(openMenu === name ? null : name)}> {t(name)}
{items.map((it) => (
it.act?.()}> {it.label}{it.kb && {it.kb}}
))}
))}
{info?.serverAddress || "connected"}
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")) }, ])}> {I.monitor} {t("Workspace")}{info?.clientName || "—"} {I.chev}
startResize(e, "sync")} title={t("Drag to resize")} />
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")) }, ])}> {I.branch} {t("Working folder")}{activePath || t("whole workspace (//…)")} {I.chev}
{pending.length > 0 ? (
{t("Submit to server")}{t("{n} changelist(s) → push", { n: pending.length })}
) : (
{I.sync} {t("Get Latest")}{busy ? t("Working…") : t("pull from server")}
)}
startResize(e, "left")} title={t("Drag to resize")} />
{tab === "changes" ? ( <>
{I.search} setFilter(e.target.value)} />
{pending.length > 0 && (
{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}
)} {scanning && (
{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}
)}
{allChecked ? I.check : null} {t("{a} of {b} selected", { a: checked.size, b: view.length })}
{view.length === 0 ? (
{scanning ? <>{t("Scanning disk for changes…")} : <> {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”.")} }
) : ( setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} /> )}
setDesc(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />