Files
exbyte-depot-viewer-perforce/src/App.tsx
Bonchellon afc4824b26 Fix build success detection + minimizable build window + de-dupe event logs
- 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>
2026-07-07 16:16:03 +03:00

1371 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, CSSProperties, 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&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="splash">
<div className="splash-logo">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
</div>
<div className="flow">
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
<div className="wire">
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
</div>
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
</div>
<div className="splash-title">Exbyte&nbsp;Depot</div>
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
<div className="splash-bar"><span /></div>
</div>
</div>
</div>
);
}
/* ---------------- connect screen ---------------- */
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
const [user, setUser] = useState(initial?.user || "Bonchellon");
const [password, setPassword] = useState("");
const [client, setClient] = useState(initial?.client || "");
const [clients, setClients] = useState<Client[]>([]);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function loadClients() {
try {
const cs = await p4.clients(server, user);
setClients(cs);
if (cs.length && !client) setClient(cs[0].client || "");
} catch { /* ignore */ }
}
// auto-load the workspace list as soon as the screen opens
useEffect(() => { loadClients(); /* eslint-disable-next-line */ }, []);
async function connect() {
setBusy(true); setErr("");
try {
const i = await p4.login(server, user, password, client);
onDone(i, { server, user, client });
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="card">
<div className="clogo">{I.cube}</div>
<h2>{t("Connect to Perforce")}</h2>
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
<div className="fld"><label>{t("Server")}</label>
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
<div className="fld"><label>{t("User")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
<div className="fld"><label>{t("Workspace")}</label>
{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&nbsp;Depot <span> Perforce</span></div></div>
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
{t(name)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div>
))}
</div>
</div>
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => openCtx(e, [
{ label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
{ label: t("Switch workspace…"), act: openWorkspaces },
{ label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="chev">{I.chev}</span>
</div>
<div className="tgroup">
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
...(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;
}