Files
exbyte-depot-viewer-perforce/src/App.tsx
Bonchellon fdaf126c00 Integrate / Merge / Copy between branches
- Tools -> Integrate / Merge / Copy: pick op + source/target paths, runs into a
  new pending changelist; resolve (if needed) via the Resolve UI and submit
- Backend p4 merge/copy/integrate -c

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:53:12 +03:00

2630 lines
162 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, KeyboardEvent as ReactKeyboardEvent, 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, isCodeFile, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
/* ---------------- 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>,
back: <svg viewBox="0 0 24 24" fill="none"><path d="M19 12H5M11 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
chevron: <svg viewBox="0 0 24 24" fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
spark: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M18.5 15.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z" fill="currentColor"/></svg>,
people: <svg viewBox="0 0 24 24" fill="none"><circle cx="9" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 6.1M17.5 14.2A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
terminal: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m7 9 3 3-3 3M13 15h4" 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>,
lock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>,
unlock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
shelf: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
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>,
power: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v9M7.5 6.5a7 7 0 1 0 9 0" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
up: <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
gear: <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>,
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="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);
// CSS `zoom` scales layout but leaves `100vh` measuring the *unzoomed* viewport,
// so at zoom<1 a gap grows at the bottom. Expose the factor so the root can
// compensate its height (see `.win` in App.css).
document.documentElement.style.setProperty("--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>
);
}
/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */
function Logo() {
return (
<svg className="exlogo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<defs>
<linearGradient id="exG" x1="6" y1="24" x2="26" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#8b6cf6" />
<stop offset="1" stopColor="#49a6ff" />
</linearGradient>
</defs>
<g stroke="url(#exG)" strokeWidth="2.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 16 H10 L14.5 9.5 H20" />
<path d="M10 16 L14.5 22.5 H20" />
<circle cx="22.5" cy="9.5" r="3" />
<circle cx="22.5" cy="22.5" r="3" />
</g>
<g stroke="url(#exG)" strokeWidth="1.9" strokeLinecap="round" opacity="0.5">
<path d="M15 14 H19" />
<path d="M15 16 H20" />
<path d="M15 18 H18" />
</g>
</svg>
);
}
/* ---------------- 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"><Logo /></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"><Logo /></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: "users" }
| { kind: "search" }
| { kind: "locks" }
| { kind: "typemap" }
| { kind: "labels" }
| { kind: "branch" }
| { kind: "client"; name: string; isNew: boolean }
| { kind: "blame"; spec: string; name: string }
| { kind: "diff"; title: string; text: string }
| { kind: "about" };
type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
type Transfer = { op: "sync" | "submit"; count: number; file?: string };
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };
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 [previewCL, setPreviewCL] = useState<OpenedFile | null>(null); // preview override (pending-CL file)
const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => void }>(null);
const [filter, setFilter] = useState("");
const [desc, setDesc] = useState(() => { try { return localStorage.getItem("exd-draft-summary") || ""; } catch { return ""; } }); // commit summary (first line) — persisted draft
const [descBody, setDescBody] = useState(() => { try { return localStorage.getItem("exd-draft-body") || ""; } catch { return ""; } }); // extended description (optional) — persisted draft
// keep the typed commit message as a draft so a refresh/restart never loses it; it is cleared only on Submit
useEffect(() => { try { localStorage.setItem("exd-draft-summary", desc); } catch {} }, [desc]);
useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
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 [histFile, setHistFile] = useState<OpenedFile | null>(null); // drilled-into submitted file (read-only preview)
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 [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
const notifPrimed = useRef(false); // don't toast on the very first poll
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 [buildPick, setBuildPick] = useState(false); // configuration picker before building
const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
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)
// bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
const [dockOpen, setDockOpen] = useState(false);
const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
const [dockH, setDockH] = useState<number>(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height
useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]);
const [termLines, setTermLines] = useState<TermLine[]>([]); // terminal output blocks
const [termInput, setTermInput] = useState("");
const [cmdHist, setCmdHist] = useState<string[]>([]); // recalled with ↑/↓
const [termBusy, setTermBusy] = useState(false);
const termId = useRef(0);
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; });
const [histW, setHistW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-hist-w")); return v >= 240 ? v : 340; }); // History file-list column width
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]);
useEffect(() => { try { localStorage.setItem("exd-hist-w", String(Math.round(histW))); } catch {} }, [histW]);
function startResize(e: ReactMouseEvent, kind: "left" | "sync" | "hist") {
e.preventDefault();
const startX = e.clientX, startLeft = leftW, startSync = syncW, startHist = histW;
const move = (ev: MouseEvent) => {
if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620)));
else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
};
const up = () => {
document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up);
window.removeEventListener("blur", up); document.body.classList.remove("resizing");
};
document.addEventListener("mousemove", move); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // mouse released outside the window → end the drag
document.body.classList.add("resizing");
}
// drag the top edge of the bottom dock to make it taller/shorter
function startDockResize(e: ReactMouseEvent) {
e.preventDefault();
const startY = e.clientY, startH = dockH;
const move = (ev: MouseEvent) => setDockH(Math.max(120, Math.min(startH - (ev.clientY - startY), window.innerHeight - 220)));
const up = () => {
document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up);
window.removeEventListener("blur", up); document.body.classList.remove("resizing-v");
};
document.addEventListener("mousemove", move); document.addEventListener("mouseup", up);
window.addEventListener("blur", up);
document.body.classList.add("resizing-v");
}
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 }));
}
// open the bottom dock on a given tab (toggle off if already showing it)
function openDock(tab: "log" | "terminal" | "unreal") {
setDockOpen((o) => (o && dockTab === tab ? false : true));
setDockTab(tab);
}
// run a p4 command typed into the terminal (combined stdout+stderr streamed back)
async function runConsole(raw: string) {
const line = raw.trim();
if (!line) return;
setCmdHist((h) => [...h.filter((x) => x !== line), line].slice(-100));
setTermInput("");
// split into args honouring quotes; drop a leading "p4" if the user typed it
const args = (line.match(/(?:[^\s"]+|"[^"]*")+/g) || []).map((a) => a.replace(/^"|"$/g, ""));
if (args[0]?.toLowerCase() === "p4") args.shift();
if (!args.length) return;
const id = ++termId.current;
setTermLines((l) => [...l.slice(-200), { id, cmd: line, text: "", running: true }]);
setTermBusy(true);
try {
const out = await p4.console(args);
setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: out.trimEnd() || t("(no output)"), running: false } : x)));
} catch (e) {
setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: String(e), err: true, running: false } : x)));
} finally { setTermBusy(false); }
}
// global keyboard shortcuts. Matches on e.code (layout-independent — works on a
// Russian keyboard where Backquote types "ё"); F5/Ctrl+R are intercepted so the
// WebView doesn't reload the whole app. Typing shortcuts are ignored while a
// text field is focused; the dock toggles (Ctrl+L/`) still work everywhere.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = e.target as HTMLElement | null;
const typing = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
const mod = e.ctrlKey || e.metaKey;
// dock toggles — allowed even while typing
if (mod && e.code === "KeyL") { e.preventDefault(); openDock("log"); return; }
if (mod && e.code === "Backquote") { e.preventDefault(); openDock("terminal"); return; }
// refresh — F5 or Ctrl+R (block the webview's built-in reload)
if (e.code === "F5" || (mod && e.code === "KeyR")) { e.preventDefault(); if (!typing) refresh(); return; }
if (typing) return;
if (mod && e.code === "KeyG") { e.preventDefault(); getLatest(); }
else if (mod && e.code === "KeyS") { e.preventDefault(); pushAll(); }
else if (mod && e.code === "KeyB") { e.preventDefault(); startBuild(); }
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
// eslint-disable-next-line
}, [dockOpen, dockTab, activePath, pending, slnPath]);
// load the currently-open files + pending changelists (fast — just reads p4 state)
// NB: preserves the user's checkbox selection — only NEW default files are
// auto-checked and vanished files are dropped, so a focus-refresh never
// silently re-checks files the user deselected before a commit.
// apply an already-fetched `p4 opened` result to the UI state
async function applyOpened(_s: string, f: OpenedFile[]) {
setFiles(f);
const defaults = f.filter((x) => (x.change || "default") === "default").map((x) => x.depotFile || "").filter(Boolean);
setChecked((prev) => {
const known = seenDefaults.current;
const next = new Set<string>();
for (const dp of defaults) {
// keep user's choice for files we've seen; auto-check genuinely new ones
if (!known.has(dp) || prev.has(dp)) next.add(dp);
}
seenDefaults.current = new Set(defaults);
return next;
});
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
setSelRows(new Set());
setPreviewCL(null);
try { setPending(await p4.changes()); } catch { setPending([]); }
void loadCollab(_s, f);
}
// who-else-has-these-files + pending conflicts (non-blocking; failures are silent)
async function loadCollab(s: string, mine: OpenedFile[]) {
const me = (info?.userName || "").toLowerCase();
const myClient = (info?.clientName || "").toLowerCase();
try {
const all = await p4.openedAll(s);
const map = new Map<string, { user: string; locked: boolean }>();
for (const o of all) {
const dp = o.depotFile || "";
if (!dp) continue;
const sameUser = (o.user || "").toLowerCase() === me;
const sameClient = (o.client || "").toLowerCase() === myClient;
if (sameUser && sameClient) continue; // it's my own checkout
// `p4 opened -a` includes an `ourLock` field only when the file is locked
const locked = (o as Record<string, unknown>).ourLock !== undefined;
map.set(dp, { user: o.user || "?", locked });
}
setOthers(map);
} catch { /* older server or no permission — skip indicators */ }
void mine;
try { setNeedResolve(await p4.resolveList()); } catch { setNeedResolve([]); }
}
const seenDefaults = useRef<Set<string>>(new Set()); // depot paths seen in a prior load
const refreshSeq = useRef(0); // guards against out-of-order refresh() results
const openChangeSeq = useRef(0); // guards against out-of-order describe() results
const committedDraft = useRef<{ s: string; b: string } | null>(null); // draft last moved into a commit
// clear the commit draft after Submit, but ONLY if the user hasn't started
// typing a new message since committing (so a fresh next-batch draft survives)
function clearDraftIfCommitted() {
const cd = committedDraft.current;
if (cd && cd.s === desc && cd.b === descBody) { setDesc(""); setDescBody(""); }
committedDraft.current = null;
}
// 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;
const seq = ++refreshSeq.current; // newest refresh wins; stale results are dropped
setBusy(true);
try { const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
finally { if (seq === refreshSeq.current) setBusy(false); }
if (scan) {
setScanning(true);
try { await p4.scan(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
finally { if (seq === refreshSeq.current) 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 installed code editors once (workspace-independent)
useEffect(() => { p4.listEditors().then(setEditors).catch(() => setEditors([])); }, []);
// resolve the effective editor: chosen → VS Code → first real → system default
const curEditor = editors.find((e) => e.id === editorId && editorId)
|| editors.find((e) => e.id === "vscode")
|| editors.find((e) => e.id !== "default")
|| editors[0];
const effEditorId = curEditor?.id || "default";
const effEditorName = curEditor?.name || t("System default");
const chooseEditor = (id: string) => { setEditorId(id); setEditorPref(id); };
// 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]);
// background poll: connection health (offline banner + auto-reconnect) and
// new-submit notifications from teammates. One lightweight timer for both.
useEffect(() => {
let alive = true;
const tick = async () => {
try {
const latest = await p4.latestChange(activePath);
if (!alive) return;
if (offline) { setOffline(false); flash(t("Reconnected to the server.")); refresh(); }
const n = (latest && latest.change) || "";
if (n) {
if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; }
else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) {
const who = latest?.user || "someone";
if ((who || "").toLowerCase() !== (info?.userName || "").toLowerCase()) {
flash(t("{user} submitted #{n}", { user: who, n }));
}
lastSubmit.current = n;
if (tab === "history") loadHistory();
}
}
} catch {
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
}
};
const id = window.setInterval(tick, 20000);
tick();
return () => { alive = false; clearInterval(id); };
// eslint-disable-next-line
}, [activePath, offline, 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 || "";
const seq = ++openChangeSeq.current; // ignore a slow describe if another CL was picked
setSelChange(n); // highlight the row immediately — no waiting for the load
setHistFile(null); // leave any drilled-in file view
setDetail(null);
setDetailBusy(true);
try { const d = await p4.describe(n); if (seq === openChangeSeq.current) setDetail(d); }
catch (e) { if (seq === openChangeSeq.current) flash(String(e), true); }
finally { if (seq === openChangeSeq.current) 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);
setHistFile(null);
if (t === "history") loadHistory(); // always refresh so it never shows a stale list
}
async function getLatest() {
setBusy(true);
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); } // sync may pull new submitted changelists → refresh History too
catch (e) { flash(String(e), true); setBusy(false); }
}
// sync the workspace to an exact changelist / label / date (Get Revision)
async function syncTo(rev: string) {
const r = rev.trim();
if (!r) return;
if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return;
setBusy(true);
try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: await p4.syncTo(activePath, r) })); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
function promptSyncTo() {
setPrompt({ title: t("Get Revision"), label: t("Changelist number, label name, or date (YYYY/MM/DD)"), value: "", onSave: (v) => { setPrompt(null); syncTo(v); } });
}
// create a new workspace: ask for a name, then open the spec editor with a template
function newWorkspace() {
setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
}
// 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); }
}
// open the configuration picker (Development / Shipping / …) before building
function startBuild() {
if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
setBuildPick(true);
}
// build the solution (MSBuild) with the chosen UE configuration, live output
async function buildSln(config: string) {
setBuildPick(false);
if (!slnPath) return;
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
try { await p4.buildSln(slnPath, config); }
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 }));
// keep the draft — a local commit is not final; it clears on Submit unless
// the user has meanwhile started a new message (see clearDraftIfCommitted)
committedDraft.current = { s: desc, b: descBody };
await refresh();
} catch (e) { flash(String(e), true); setBusy(false); }
}
// Ask the AI (OpenRouter) to draft a commit summary + description from the checked files.
async function genAiSummary() {
const picked = files.filter((f) => (f.change || "default") === "default" && checked.has(f.depotFile || ""));
if (!picked.length) { flash(t("Select some files first."), true); return; }
setAiBusy(true);
try {
const { summary, description } = await aiSummary(picked);
if (summary) setDesc(summary);
if (description) setDescBody(description);
flash(t("AI drafted a commit message — review & edit before committing."));
} catch (e) {
const msg = String(e);
flash(msg.includes("No OpenRouter API key") ? t("Set an OpenRouter API key in Settings → AI first.") : msg, true);
} finally { setAiBusy(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 })); clearDraftIfCommitted(); } // draft done → clear on successful Submit
await refresh();
await loadHistory(); // the submit created a new submitted changelist — refresh History
}
// Submit a single pending changelist to the server
async function submitOne(change: string) {
if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
setBusy(true);
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } // draft clears on successful Submit
catch (e) { flash(String(e), true); setBusy(false); }
}
async function shelveCL(change: string) {
setBusy(true);
try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); }
catch (e) { flash(String(e), true); } finally { setBusy(false); }
}
async function unshelveCL(change: string) {
setBusy(true);
try { await p4.unshelve(change); flash(t("Unshelved #{n} into the workspace.", { n: change })); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
async function deleteShelfCL(change: string) {
if (!(await confirm({ title: t("Delete shelved files?"), body: t("The shelved copy of #{n} on the server will be removed. Your local files are untouched.", { n: change }), confirm: t("Delete shelf"), danger: true }))) return;
setBusy(true);
try { await p4.deleteShelf(change); flash(t("Deleted shelved files of #{n}.", { n: change })); }
catch (e) { flash(String(e), true); } finally { setBusy(false); }
}
// Uncommit: move a changelist's files back into the working set (default)
async function uncommit(fs: OpenedFile[]) {
const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
if (!dps.length) return;
setBusy(true);
try { await p4.reopenDefault(dps); flash(t("Moved back to working changes.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
// Edit a pending changelist's description
function editDesc(cl: Change) {
setPrompt({
title: t("Edit description"), label: t("Changelist #{n}", { n: cl.change || "" }), value: (cl.desc || "").trim(),
onSave: async (v) => {
setPrompt(null);
if (!v.trim()) return;
setBusy(true);
try { await p4.setDesc(cl.change || "", v.trim()); flash(t("Description updated.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
},
});
}
function toggleCL(change: string) {
setExpandedCL((prev) => { const n = new Set(prev); n.has(change) ? n.delete(change) : n.add(change); return n; });
}
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]);
// committed pending changelists with their files (grouped from p4 opened)
const pendingCards = useMemo(() =>
pending.map((cl) => ({
cl,
files: files.filter((f) => String(f.change || "") === String(cl.change || "")),
})), [pending, files]);
const selFile = previewCL ?? (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);
setPreviewCL(null); // show the working-set selection in the preview
}
// 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 dps = targets.map((f) => f.depotFile || "").filter(Boolean);
const isCode = isCodeFile(splitPath(dp).name);
openCtx(e, [
{ label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
{ label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
{ label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
{ label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })),
...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
{ label: t("Open in Explorer"), act: () => reveal(dp) },
{ label: t("Copy path"), act: () => copyText(dp, t("Path")) },
]);
}
async function lockFiles(dps: string[], lock: boolean) {
if (!dps.length) return;
try { await (lock ? p4.lock(dps) : p4.unlock(dps)); flash(lock ? t("Locked {n} file(s).", { n: dps.length }) : t("Unlocked {n} file(s).", { n: dps.length })); await refresh(); }
catch (e) { flash(String(e), true); }
}
// switch opened files to an exclusive-lock file type (+l) so only one person edits them
async function setExclusiveType(dps: string[]) {
if (!dps.length) return;
try { await p4.reopenType(dps, "+l"); flash(t("{n} file(s) set to exclusive-lock type (+l).", { n: dps.length })); await refresh(); }
catch (e) { flash(String(e), true); }
}
async function moveToCL(change: string, dps: string[]) {
if (!change || !dps.length) return;
try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); }
catch (e) { flash(String(e), true); }
}
// diff a historical revision against the one before it (History preview)
async function showDiffPrev(file: OpenedFile) {
const dp = file.depotFile || "";
const rev = Number(file.rev || "0");
if (!dp || rev <= 1) { flash(t("No earlier revision to compare."), true); return; }
try {
const text = await p4.diff2(`${dp}#${rev - 1}`, `${dp}#${rev}`);
setModal({ kind: "diff", title: `${splitPath(dp).name} #${rev - 1} ↔ #${rev}`, text: text || t("(files are identical)") });
} catch (e) { flash(String(e), true); }
}
const showBlame = (spec: string, name: string) => setModal({ kind: "blame", spec, name });
async function doResolve(mode: "am" | "ay" | "at", file = "") {
try {
await p4.resolveFile(file, mode);
const rest = await p4.resolveList();
setNeedResolve(rest);
if (!rest.length) { setResolveOpen(false); flash(t("All conflicts resolved.")); }
await refresh();
} catch (e) { flash(String(e), true); }
}
const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => void }[]> = {
File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }],
Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }],
Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }],
Window: [
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
{ label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
],
Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
Help: [{ label: t("About"), icon: I.info, 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"><Logo /></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.icon && <span className="di-ic">{it.icon}</span>}
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div>
))}
</div>
</div>
))}
</div>
<div className="conn-pill" title={t("Connected server")}><span className="dot" />{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</div>
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
<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: startBuild }] : []),
{ 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>
{offline && (
<div className="netbar off">
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
</div>
)}
{needResolve.length > 0 && tab === "changes" && (
<div className="netbar warn" onClick={() => setResolveOpen(true)}>
{I.hex}{t("{n} file(s) need conflict resolution after sync.", { n: needResolve.length })}
<button className="netbar-btn">{t("Resolve…")}</button>
</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>
{pendingCards.length > 0 && (
<div className="pending">
<div className="pending-h">
<span className="pending-t"><span className="pushdot" />{t("Pending · ready to Submit")}</span>
<button className="pushall" onClick={pushAll} title={t("Submit all to the server")}>{t("Submit all")} </button>
</div>
{pendingCards.map(({ cl, files: cf }) => {
const ch = cl.change || "";
const open = expandedCL.has(ch);
return (
<div key={ch} className="clcard">
<div className="clcard-h" onClick={() => toggleCL(ch)}>
<span className={"cl-chev" + (open ? " open" : "")}>{I.chev}</span>
<span className="cl-num">#{ch}</span>
<span className="cl-desc">{(cl.desc || "").trim() || t("(no description)")}</span>
<span className="cl-count">{t("{n} files", { n: cf.length })}</span>
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}></button>
<button className="cl-more" onClick={(e) => openCtx(e, [
{ label: t("Edit description"), act: () => editDesc(cl) },
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
])} title={t("More")}></button>
</span>
</div>
{open && (
<div className="cl-files">
{cf.length === 0 && <div className="cl-empty">{t("(files are outside the current working folder)")}</div>}
{cf.map((f) => {
const sp = splitPath(f.depotFile);
const st = statusOf(f.action);
return (
<div key={f.depotFile} className={"cl-file" + (previewCL?.depotFile === f.depotFile ? " sel" : "")} onClick={() => setPreviewCL(f)}>
<span className={"stat " + st.cls}>{st.ch}</span>
<span className="cl-fname"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
</div>
);
})}
</div>
)}
</div>
);
})}
</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 ? "" : " off")} 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} others={others} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
)}
<div className="commit">
<div className="summrow">
<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(); }} />
<button className={"aibtn icon" + (aiBusy ? " busy" : "")} disabled={aiBusy || checked.size === 0} onClick={genAiSummary}
title={t("Draft a commit message with AI from the selected files")}>
{aiBusy ? <span className="ldr sm" /> : I.spark}
</button>
</div>
<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("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
{ 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} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
{histFile && (<>
<div className="vhandle histh" title={t("Drag to resize")}
onPointerDown={(e) => {
e.preventDefault();
const startX = e.clientX, start = histW;
document.body.classList.add("resizing");
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
// mouse fallback in case pointer events don't fire in the webview
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // released outside the window → end drag
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
</>)}
</div>
)}
</div>
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
height={dockH} onResize={startDockResize}
logs={logs} onClearLogs={() => setLogs([])}
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} uproject={uproject} />}
<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" />
{uproject && <button className="stlog" onClick={() => openDock("unreal")} title={t("Unreal Log")}>{I.hex}<span>{t("Unreal")}</span></button>}
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
<button className="stlog" onClick={() => openDock("log")} 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 && modal.kind !== "users" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{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, session, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => 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">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</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 className="info-row"><span className="rk">{t("Code editor")}</span>
<select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
{editors.length === 0 && <option value="default">{t("System default")}</option>}
{editors.map((ed) => (
<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>
))}
</select>
</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>
);
}
/* ---------------- avatar (initials, deterministic color, activity dot) ---------------- */
function Avatar({ user, name, size = 30, act }: { user: string; name?: string; size?: number; act?: "active" | "recent" | "idle" }) {
return (
<span className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * 0.38), background: avatarColor(user || name || "?") }} title={name ? `${name} (${user})` : user}>
{initials(name || "", user)}
{act && <span className={"adot " + act} />}
</span>
);
}
/* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */
function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) {
const [users, setUsers] = useState<User[]>([]);
const [groups, setGroups] = useState<string[]>([]);
const [busy, setBusy] = useState(true);
const [err, setErr] = useState("");
const [sel, setSel] = useState(""); // selected user id
const [myGroups, setMyGroups] = useState<string[]>([]); // groups of the selected user
const [gBusy, setGBusy] = useState(false);
const [q, setQ] = useState("");
const selSeq = useRef(0); // drop a slow groups() response if another user was picked
useEffect(() => {
let live = true;
(async () => {
try {
const [us, gs] = await Promise.all([p4.users(), p4.groups()]);
if (!live) return;
setUsers(us);
setGroups(gs.map((g) => g.group || "").filter(Boolean));
} catch (e) { if (live) setErr(String(e)); }
finally { if (live) setBusy(false); }
})();
return () => { live = false; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
async function selectUser(u: string) {
const seq = ++selSeq.current;
setSel(u); setMyGroups([]); setGBusy(true);
try { const g = await p4.groups(u); if (seq === selSeq.current) setMyGroups(g.map((x) => x.group || "").filter(Boolean)); }
catch (e) { if (seq === selSeq.current) onFlash(String(e), true); }
finally { if (seq === selSeq.current) setGBusy(false); }
}
async function toggleGroup(group: string) {
if (!sel) return;
const isMember = myGroups.includes(group);
setGBusy(true);
try {
await p4.groupMember(group, sel, !isMember);
setMyGroups((cur) => isMember ? cur.filter((x) => x !== group) : [...cur, group]);
onFlash(isMember ? t("Removed {u} from “{g}”.", { u: sel, g: group }) : t("Added {u} to “{g}”.", { u: sel, g: group }));
} catch (e) {
const msg = String(e);
onFlash(/permission|protect|admin|super|not allowed/i.test(msg) ? t("You need admin rights on the server to change roles.") : msg, true);
} finally { setGBusy(false); }
}
const s = q.trim().toLowerCase();
const filtered = users.filter((u) => !s || (u.User || "").toLowerCase().includes(s) || (u.FullName || "").toLowerCase().includes(s) || (u.Email || "").toLowerCase().includes(s));
const selUser = users.find((u) => u.User === sel);
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>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.people}<h3>{t("People & Roles")}</h3>
<span className="ph-sub">{t("{n} on this depot", { n: users.length })}</span>{X}</div>
{err ? <div className="viewer-msg err" style={{ position: "static", padding: 24 }}>{err}</div>
: busy ? <div className="viewer-msg" style={{ position: "static", padding: 28 }}><span className="ldr" />{t("Loading users…")}</div>
: (
<div className="ur-body">
<div className="ur-list">
<div className="ur-filter">{I.search}<input placeholder={t("Filter people…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="ur-scroll">
{filtered.map((u) => {
const act = activity(u.Access);
return (
<div key={u.User} className={"ur-row" + (sel === u.User ? " on" : "")} onClick={() => selectUser(u.User || "")}>
<Avatar user={u.User || ""} name={u.FullName} act={act} />
<span className="ur-meta">
<span className="ur-name">{u.FullName || u.User}{u.User === me && <span className="ur-you">{t("you")}</span>}</span>
<span className="ur-sub">{u.Email || u.User}</span>
</span>
<span className={"ur-actd " + act} title={act} />
</div>
);
})}
{filtered.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
</div>
</div>
<div className="ur-detail">
{!selUser ? <div className="viewer-msg" style={{ position: "static", padding: 28, flexDirection: "column" }}>{I.people}<span>{t("Pick a person to see and edit their roles.")}</span></div>
: (<>
<div className="ur-dhead">
<Avatar user={selUser.User || ""} name={selUser.FullName} size={46} act={activity(selUser.Access)} />
<div className="ur-dinfo">
<div className="ur-dname">{selUser.FullName || selUser.User}</div>
<div className="ur-dsub">{selUser.Email || "—"}{selUser.Access ? " · " + t("active {t}", { t: fmtTime(selUser.Access) }) : ""}</div>
</div>
</div>
<div className="ur-grouphd"><span>{t("Roles / groups")}</span>{gBusy && <span className="ldr sm" />}</div>
<div className="ur-groups">
{groups.length === 0 && <div className="ur-empty">{t("No groups defined on the server.")}</div>}
{groups.map((g) => {
const on = myGroups.includes(g);
return (
<button key={g} className={"ur-chip" + (on ? " on" : "")} disabled={gBusy} onClick={() => toggleGroup(g)}>
<span className="urc-box">{on ? I.check : null}</span>{g}
</button>
);
})}
</div>
<div className="ai-hint">{t("Toggling a group grants or revokes that role. Requires admin rights on the server.")}</div>
</>)}
</div>
</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>
);
}
// common p4 subcommands for the terminal's autocomplete
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm, uproject }: {
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void;
logs: LogEntry[]; onClearLogs: () => void;
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void; uproject: string;
}) {
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
{ key: "log", label: t("Log"), icon: I.log },
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []),
];
return (
<div className="dockpanel" style={{ flexBasis: height, height }}>
<div className="dockhandle" onMouseDown={onResize} title={t("Drag to resize")} />
<div className="dockhead">
<div className="docktabs">
{tabs.map((tb) => (
<button key={tb.key} className={"docktab" + (tab === tb.key ? " on" : "")} onClick={() => setTab(tb.key)}>
{tb.icon}<span>{tb.label}</span>
{tb.key === "log" && logs.length > 0 && <span className="logn">{logs.length}</span>}
</button>
))}
</div>
<span className="dockspace" />
{(tab === "log" || tab === "terminal") && <button className="loghbtn" onClick={tab === "log" ? onClearLogs : onClearTerm}>{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>
{tab === "log" && <LogTab logs={logs} />}
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
{tab === "unreal" && <UnrealTab uproject={uproject} />}
</div>
);
}
function LogTab({ logs }: { logs: LogEntry[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
return (
<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>
);
}
/* p4 console with command autocomplete + ↑/↓ history */
function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
lines: TermLine[]; input: string; setInput: (s: string) => void; busy: boolean; onRun: (cmd: string) => void; cmdHist: string[];
}) {
const bodyRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [hi, setHi] = useState(-1); // history cursor (-1 = live input)
const [sugIdx, setSugIdx] = useState(0);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [lines, busy]);
useEffect(() => { setSugIdx(0); }, [input]);
// suggest only while typing the first bare word (the p4 subcommand)
const firstWord = input.trim().split(/\s+/)[0] || "";
const typing = input.trim() === firstWord && firstWord.length > 0 && !input.endsWith(" ");
const sugs = typing ? P4_COMMANDS.filter((c) => c.startsWith(firstWord.toLowerCase()) && c !== firstWord.toLowerCase()).slice(0, 8) : [];
function accept(cmd: string) { setInput(cmd + " "); inputRef.current?.focus(); }
function onKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
if (sugs.length) {
if (e.key === "Tab") { e.preventDefault(); accept(sugs[sugIdx] || sugs[0]); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSugIdx((i) => (i + 1) % sugs.length); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSugIdx((i) => (i - 1 + sugs.length) % sugs.length); return; }
}
if (e.key === "Enter") { e.preventDefault(); if (!busy) onRun(input); setHi(-1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); const ni = hi < 0 ? cmdHist.length - 1 : Math.max(0, hi - 1); if (cmdHist[ni] != null) { setHi(ni); setInput(cmdHist[ni]); } return; }
if (e.key === "ArrowDown") { e.preventDefault(); if (hi < 0) return; const ni = hi + 1; if (ni >= cmdHist.length) { setHi(-1); setInput(""); } else { setHi(ni); setInput(cmdHist[ni]); } return; }
}
return (
<div className="term">
<div className="termbody" ref={bodyRef}>
{lines.length === 0 && <div className="termhint">{t("p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.")}</div>}
{lines.map((ln) => (
<div key={ln.id} className="termblock">
{ln.cmd && <div className="termcmd"><span className="termprompt">p4&gt;</span> {ln.cmd}</div>}
{ln.running ? <div className="termout run"><span className="ldr sm" /> {t("running…")}</div>
: <pre className={"termout" + (ln.err ? " err" : "")}>{ln.text}</pre>}
</div>
))}
</div>
<div className="terminput-wrap">
<span className="termprompt">p4&gt;</span>
<input ref={inputRef} className="terminput" value={input} placeholder={t("type a p4 command…")} spellCheck={false} autoFocus
onChange={(e) => setInput(e.target.value)} onKeyDown={onKeyDown} />
{busy && <span className="ldr sm" />}
{sugs.length > 0 && (
<div className="termsug">
{sugs.map((s, i) => (
<div key={s} className={"termsug-i" + (i === sugIdx ? " on" : "")} onMouseDown={(e) => { e.preventDefault(); accept(s); }}>
<b>{firstWord}</b>{s.slice(firstWord.length)}
</div>
))}
</div>
)}
</div>
</div>
);
}
/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */
function UnrealTab({ uproject }: { uproject: string }) {
const [text, setText] = useState("");
const [err, setErr] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let live = true;
const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e)));
load();
const iv = window.setInterval(load, 1500);
return () => { live = false; clearInterval(iv); };
}, [uproject]);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]);
return (
<div className="termbody" ref={ref}>
{err ? <div className="termhint err">{err}</div>
: text ? <pre className="termout ue">{text}</pre>
: <div className="termhint">{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}</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>
);
}
/* build configuration picker (Unreal: Development / Shipping / …) */
function BuildPicker({ onPick, onClose }: { onPick: (config: string) => void; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const opts: { config: string; name: string; desc: string }[] = [
{ config: "Development Editor", name: "Development Editor", desc: t("Editor build for working in-engine (default)") },
{ config: "Development", name: "Development", desc: t("Standalone game, dev config") },
{ config: "DebugGame", name: "DebugGame", desc: t("Game with debuggable game code") },
{ config: "Shipping", name: "Shipping", desc: t("Optimized release build") },
];
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hammer}<h3>{t("Build configuration")}</h3>
<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>
</div>
<div className="picker-list">
{opts.map((o) => (
<div key={o.config} className="picker-item" onClick={() => onPick(o.config)}>
<span className="pi-ic">{I.hammer}</span>
<span className="pi-body"><span className="pi-name">{o.name} · Win64</span><span className="pi-sub">{o.desc}</span></span>
</div>
))}
</div>
<div className="picker-foot">{t("Builds via MSBuild → UnrealBuildTool")}</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>
);
}
/* ---------------- text prompt (edit description) ---------------- */
function TextPrompt({ title, label, value, onSave, onClose }: { title: string; label: string; value: string; onSave: (v: string) => void; onClose: () => void }) {
const [v, setV] = useState(value);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(v); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [v]); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<div className="prompt-label">{label}</div>
<textarea ref={ref} className="prompt-input" value={v} onChange={(e) => setV(e.target.value)} />
<div className="modal-actions">
<button className="mbtn ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={() => onSave(v)}>{t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
const btn = useRef<HTMLButtonElement>(null);
useEffect(() => {
btn.current?.focus();
// only Escape is global; confirming requires the focused button (Enter/Space
// on it, or a click) so a stray Enter never triggers a destructive action
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); };
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 ref={btn} className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)}>{confirm}</button>
</div>
</div>
</div>
);
}
/* ---------------- depot file search ---------------- */
function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope: string; onClose: () => void; onOpenEditor: (dp: string) => void; onReveal: (dp: string) => void; onCopy: (dp: string) => void }) {
const [q, setQ] = useState("");
const [res, setRes] = useState<{ depotFile?: string; rev?: string }[]>([]);
const [busy, setBusy] = useState(false);
const seq = useRef(0);
const inp = useRef<HTMLInputElement>(null);
useEffect(() => { inp.current?.focus(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
useEffect(() => {
const s = ++seq.current;
if (q.trim().length < 2) { setRes([]); setBusy(false); return; }
setBusy(true);
const id = setTimeout(() => {
p4.searchFiles(q.trim(), scope).then((r) => { if (s === seq.current) { setRes(r); setBusy(false); } }).catch(() => { if (s === seq.current) { setRes([]); setBusy(false); } });
}, 250); // debounce
return () => clearTimeout(id);
}, [q, scope]);
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.search}<h3>{t("Search depot")}</h3>
<span className="ph-sub">{scope || "//…"}</span>
<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>
</div>
<div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input ref={inp} placeholder={t("Type at least 2 characters…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Searching…")}</div>}
{!busy && q.trim().length >= 2 && res.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
{res.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
return (
<div key={dp} className="srch-row" onContextMenu={(e) => { e.preventDefault(); }}>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
{isCodeFile(sp.name) && <button className="srch-act" title={t("Open in editor")} onClick={() => onOpenEditor(dp)}>{I.vscode}</button>}
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
<button className="srch-act" title={t("Copy path")} onClick={() => onCopy(dp)}></button>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const [lockedOnly, setLockedOnly] = useState(true);
const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const isLocked = (f: OpenedFile) => (f as Record<string, unknown>).ourLock !== undefined;
const s = q.trim().toLowerCase();
const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
const lockedCount = files.filter(isLocked).length;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
<span className="ph-sub">{t("{n} open · {m} locked", { n: files.length, m: lockedCount })}</span>
<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>
</div>
<div className="rslv-bar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
<button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
{rows.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
const locked = isLocked(f);
const mine = (f.user || "").toLowerCase() === me.toLowerCase();
return (
<div key={dp + (f.client || "")} className="srch-row">
<span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
{locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- integrate / merge / copy between branches ---------------- */
function BranchModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [op, setOp] = useState<"merge" | "copy" | "integrate">("merge");
const [source, setSource] = useState(scope ? scope + "/..." : "//depot/…/...");
const [target, setTarget] = useState("//depot/…/...");
const [busy, setBusy] = useState(false);
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
const DESC: Record<string, string> = {
merge: t("Merge changes from source into target — you resolve conflicts, then submit."),
copy: t("Copy source over target verbatim (no merge). Target becomes identical to source."),
integrate: t("Classic integrate — open target files for integration from source."),
};
async function run() {
if (!source.trim() || !target.trim()) { onFlash(t("Source and target are required."), true); return; }
setBusy(true);
try {
const out = await p4.branchOp(op, source.trim(), target.trim());
onFlash(t("{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}", { op, out: out.split("\n")[0] }));
onDone();
} catch (e) { onFlash(String(e), true); }
finally { setBusy(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" style={{ width: 560, maxWidth: "94%" }} onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.branch}<h3>{t("Integrate / Merge / Copy")}</h3>
<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>
</div>
<div className="info-body">
<div className="langsel" style={{ marginBottom: 12 }}>
{(["merge", "copy", "integrate"] as const).map((o) => (
<button key={o} className={"langbtn" + (op === o ? " on" : "")} onClick={() => setOp(o)}>
<span className="lname">{o === "merge" ? t("Merge") : o === "copy" ? t("Copy") : t("Integrate")}</span>
</button>
))}
</div>
<div className="tm-hint" style={{ border: "none", padding: "0 0 12px" }}>{DESC[op]}</div>
<div className="info-row"><span className="rk">{t("Source")}</span><input className="kf-in mono" value={source} onChange={(e) => setSource(e.target.value)} placeholder="//depot/dev/..." /></div>
<div className="info-row"><span className="rk">{t("Target")}</span><input className="kf-in mono" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="//depot/main/..." /></div>
<div className="modal-actions" style={{ marginTop: 16 }}>
<button className="mbtn ghost" onClick={onClose} disabled={busy}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={run} disabled={busy}>{busy ? <span className="ldr sm" /> : null}{t("Run {op}", { op: op === "merge" ? t("Merge") : op === "copy" ? t("Copy") : t("Integrate") })}</button>
</div>
</div>
</div>
</div>
);
}
/* ---------------- labels (list / create / sync-to) ---------------- */
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const load = () => { setBusy(true); p4.labels().then(setLabels).catch((e) => { onFlash(String(e), true); setLabels([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
async function create() {
const n = newName.trim();
if (!n) return;
setCreating(true);
try { await p4.labelTag(n, scope); onFlash(t("Label “{c}” tagged at current head.", { c: n })); setNewName(""); load(); }
catch (e) { onFlash(String(e), true); }
finally { setCreating(false); }
}
const s = q.trim().toLowerCase();
const rows = labels.filter((l) => !s || (l.label || "").toLowerCase().includes(s) || (l.Description || "").toLowerCase().includes(s));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.clock}<h3>{t("Labels")}</h3><span className="ph-sub">{t("{n} labels", { n: labels.length })}</span>
<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>
</div>
<div className="rslv-bar">
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter labels…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<input className="kf-in" style={{ maxWidth: 190 }} placeholder={t("New label name")} value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} />
<button className="mbtn primary" disabled={creating || !newName.trim()} onClick={create}>{creating ? <span className="ldr sm" /> : null}{t("Tag current")}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{t("No labels.")}</div>}
{rows.map((l) => (
<div key={l.label} className="srch-row">
<span className="held" style={{ marginRight: 4 }}>{I.clock}</span>
<span className="srch-body"><span className="n">{l.label}</span><span className="p">{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""}</span></span>
<button className="rslv-act" onClick={() => onSyncTo(l.label || "")}>{t("Sync to")}</button>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------------- workspace (client) spec editor ---------------- */
function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
const [spec, setSpec] = useState("");
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let live = true;
p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [name]); // eslint-disable-line
async function save() {
setSaving(true);
try {
await p4.clientSave(spec);
onFlash(isNew ? t("Workspace “{c}” created.", { c: name }) : t("Workspace “{c}” updated.", { c: name }));
onSaved(name);
} catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</span>
<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>
</div>
<div className="tm-hint">{t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- typemap / exclusive-lock rules ---------------- */
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
// only one person can check them out at a time, preventing lost binary merges.
const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [entries, setEntries] = useState<string[]>([]);
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
const [add, setAdd] = useState("");
const load = () => { setBusy(true); p4.typemapGet().then(setEntries).catch((e) => { onFlash(String(e), true); setEntries([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const patternOf = (e: string) => (e.split(/\s+/)[1] || "").toLowerCase();
function addRecommended() {
const have = new Set(entries.map(patternOf));
const add: string[] = [];
for (const ext of UE_LOCK_EXTS) {
const pat = `//....${ext}`;
if (!have.has(pat)) add.push(`binary+l ${pat}`);
}
if (!add.length) { onFlash(t("All recommended rules are already present."), false); return; }
setEntries((cur) => [...cur, ...add]);
}
function addOne() {
const v = add.trim();
if (!v) return;
// accept "binary+l //....ext" or just "//....ext" (defaults to binary+l)
const entry = /\s/.test(v) ? v : `binary+l ${v}`;
setEntries((cur) => [...cur, entry]);
setAdd("");
}
async function save() {
setSaving(true);
try { await p4.typemapSet(entries); onFlash(t("Typemap saved.")); onClose(); }
catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.lock}<h3>{t("Exclusive Locks (typemap)")}</h3>
<span className="ph-sub">{t("{n} rules", { n: entries.length })}</span>
<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>
</div>
<div className="tm-hint">{t("Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.")}</div>
<div className="rslv-bar">
<button className="mbtn primary" onClick={addRecommended}>{I.lock}{t("Add recommended Unreal rules")}</button>
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Add rule, e.g. binary+l //....uasset")} value={add} onChange={(e) => setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} /></div>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && entries.length === 0 && <div className="ur-empty">{t("No typemap rules yet.")}</div>}
{entries.map((e, i) => {
const parts = e.split(/\s+/);
const type = parts[0] || "";
const pat = parts.slice(1).join(" ");
const excl = /\+l/.test(type);
return (
<div key={i + e} className="srch-row">
<span className={"held" + (excl ? " locked" : "")} style={{ marginRight: 4 }}>{excl ? I.lock : I.unlock}<span>{type}</span></span>
<span className="srch-body"><span className="n" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{pat}</span></span>
<button className="srch-act" title={t("Remove")} onClick={() => setEntries((cur) => cur.filter((_, j) => j !== i))}></button>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save typemap")}</button>
</div>
</div>
</div>
);
}
/* ---------------- blame / annotate ---------------- */
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
let live = true;
p4.annotate(spec).then((s) => live && setText(s)).catch((e) => { if (live) { onFlash(String(e), true); setText(""); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [spec]); // eslint-disable-line
// annotate lines look like: "12345: <user> <code>" — split the prefix for coloring
const rows = (text || "").split("\n").map((ln, i) => {
const m = ln.match(/^(\d+):\s*(\S+)?\s?(.*)$/);
return m ? { n: i + 1, cl: m[1], user: m[2] || "", code: m[3] ?? "" } : { n: i + 1, cl: "", user: "", code: ln };
});
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.people}<h3>{t("Blame")}</h3><span className="ph-sub">{name}</span>
<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>
</div>
<div className="blame-body">
{text === null ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
: rows.map((r) => (
<div key={r.n} className="blame-row">
<span className="bl-cl">{r.cl && "#" + r.cl}</span>
<span className="bl-user">{r.user}</span>
<span className="bl-code">{r.code || " "}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------------- unified-diff viewer (diff2) ---------------- */
function DiffModal({ title, text, onClose }: { title: string; text: string; onClose: () => void }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
const lines = text.split("\n");
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.clock}<h3>{t("Diff")}</h3><span className="ph-sub">{title}</span>
<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>
</div>
<div className="diff-body">
{lines.map((ln, i) => {
const c = ln.startsWith("+") && !ln.startsWith("+++") ? "add" : ln.startsWith("-") && !ln.startsWith("---") ? "del" : ln.startsWith("@@") ? "hunk" : "";
return <div key={i} className={"diff-line " + c}>{ln || " "}</div>;
})}
</div>
</div>
</div>
);
}
/* ---------------- resolve conflicts ---------------- */
function ResolveModal({ files, onResolve, onClose }: { files: OpenedFile[]; onResolve: (mode: "am" | "ay" | "at", file?: string) => void; onClose: () => void }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{t("Resolve conflicts")}</h3><span className="ph-sub">{t("{n} file(s)", { n: files.length })}</span>
<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>
</div>
<div className="rslv-bar">
<button className="mbtn primary" onClick={() => onResolve("am")}>{t("Auto-merge all")}</button>
<button className="mbtn ghost" onClick={() => onResolve("at")}>{t("Accept theirs (all)")}</button>
<button className="mbtn ghost" onClick={() => onResolve("ay")}>{t("Accept yours (all)")}</button>
</div>
<div className="srch-list">
{files.length === 0 && <div className="ur-empty">{t("Nothing to resolve.")}</div>}
{files.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
return (
<div key={dp} className="srch-row">
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<button className="rslv-act" title={t("Auto-merge")} onClick={() => onResolve("am", dp)}>{t("Merge")}</button>
<button className="rslv-act" title={t("Accept theirs")} onClick={() => onResolve("at", dp)}>{t("Theirs")}</button>
<button className="rslv-act" title={t("Accept yours")} onClick={() => onResolve("ay", dp)}>{t("Yours")}</button>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean; width?: number }) {
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]);
const cls = "right" + (split ? " histlist" : "");
// width set → fixed, resizable column (direct inline style, no CSS-var indirection)
const wstyle: CSSProperties | undefined = width != null ? { width, flex: "none" } : undefined;
if (busy) return <div className={cls} style={wstyle}><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
if (!detail) return <div className={cls} style={wstyle}><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);
const key = `${f.depotFile}#${f.rev}`;
rows.push(
<div key={i} className={"crow click" + (selected === key ? " sel" : "")} style={{ top: i * CROW }}
onClick={() => onOpen?.({ depotFile: f.depotFile, action: f.action, rev: f.rev, type: "", change: detail.change, _spec: key })}>
<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>
<span className="crow-go">{I.chevron}</span>
</div>
);
}
return (
<div className={cls} style={wstyle}>
<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)}>
<Avatar user={c.user || "?"} size={26} />
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {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;
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
// real images → fetch the file bytes and blob them directly
const job = kind === "uasset"
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
: p4.readDepot(dp).then((buf) => {
const ext = (name.split(".").pop() || "png").toLowerCase();
return URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
});
job.then((u) => { 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, others, onRowClick, onToggle, onContext }:
{ files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; others: Map<string, { user: string; locked: boolean }>; 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);
const other = others.get(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>
{other && <span className={"held" + (other.locked ? " locked" : "")} title={other.locked ? t("Locked by {u}", { u: other.user }) : t("Also open by {u}", { u: other.user })}>{other.locked ? I.lock : I.people}<span>{other.user}</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, editorId, editorName, onBlame, onDiffPrev, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; uproject?: string }) {
const [explain, setExplain] = useState<string | null>(null); // AI code summary
const [explBusy, setExplBusy] = useState(false);
const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile
// on file change show its cached summary (persisted; removed only via ×)
useEffect(() => {
explSeq.current++;
setExplain(file ? getExplain(file._spec || file.depotFile || "") : null);
setExplBusy(false);
}, [file?.depotFile, file?._spec]);
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 spec = file._spec || ""; // set → read-only view of a submitted revision
const hist = !!spec;
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
const kind = kindOf(name);
const codeFile = isCodeFile(name); // gate Summary/IDE buttons to real source files
const rev = hist ? "#" + (file.rev || "?")
: file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
// ask the AI for a short description of what this source file does
async function doExplain() {
if (!file) return;
const seq = ++explSeq.current;
const key = spec || dp;
setExplBusy(true); setExplain(null);
try {
const buf = await fileBytes(file);
// guard against binaries slipping through: bail if it's not decodable text
const bytes = new Uint8Array(buf);
if (bytes.length && bytes.slice(0, 8000).includes(0)) throw new Error(t("Not a text file."));
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
const out = await aiExplainCode(name, text);
if (seq !== explSeq.current) return; // a different file is showing now
setExplain(out);
saveExplain(key, out); // persist until dismissed via ×
} catch (e) {
if (seq === explSeq.current) onFlash(String(e), true);
} finally { if (seq === explSeq.current) setExplBusy(false); }
}
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 ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""}</span>
</span>
<span className="tools">
{codeFile && (
<span className={"gbtn ai icon" + (explBusy ? " busy" : "")} onClick={() => !explBusy && doExplain()} title={t("AI summary — what this file does")}>
{explBusy ? <span className="ldr sm" /> : I.spark}
</span>
)}
{codeFile && (
<span className="gbtn vsc icon" title={t("Open in {editor}", { editor: editorName })}
onClick={async () => { try { await p4.openInEditor(dp, editorId); onFlash(t("Opening in {editor}…", { editor: editorName })); } catch (e) { onFlash(String(e), true); } }}>
{I.vscode}
</span>
)}
{codeFile && onBlame && (
<span className="gbtn icon" title={t("Blame — who changed each line")} onClick={() => onBlame(spec || dp, name)}>{I.people}</span>
)}
{hist && onDiffPrev && codeFile && (
<span className="gbtn icon" title={t("Diff against the previous revision")} onClick={() => onDiffPrev(file)}>{I.clock}</span>
)}
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
</span>
</div>
{(explain || explBusy) && (
<div className="explain">
<span className="explain-ic">{I.spark}</span>
<div className="explain-body">{explBusy ? <span className="explain-load"><span className="ldr sm" />{t("Reading the code…")}</span> : explain}</div>
{!explBusy && <button className="explain-x" onClick={() => { setExplain(null); dropExplain(spec || dp); }} title={t("Dismiss")}>×</button>}
</div>
)}
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
</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, spec }: { depot: string; name: string; spec?: string }) {
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState("");
const [dim, setDim] = useState("");
useEffect(() => {
let u = ""; let live = true;
(spec ? p4.printDepot(spec) : 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, spec]);
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 — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const hist = !!file._spec; // historical revision (3D export only works on the working copy)
const [thumb, setThumb] = useState<string | null>(null);
const [tried, setTried] = useState(false);
const [model, setModel] = useState<ArrayBuffer | null>(null); // exported FBX bytes
const [exporting, setExporting] = useState(false);
useEffect(() => {
let live = true; let url = "";
setThumb(null); setTried(false); setModel(null); setExporting(false);
// backend reads the asset and returns ONLY the embedded thumbnail PNG —
// works for the working copy and for exact submitted revisions (history)
const spec = file._spec || dp;
p4.uassetThumb(spec, !!file._spec).then((buf) => {
if (!live) return;
url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
setThumb(url);
setTried(true);
}).catch(() => live && setTried(true));
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [dp, file._spec]);
async function load3d() {
if (!uproject) { onFlash(t("Pick the Unreal project working folder first."), true); return; }
setExporting(true);
try {
const buf = await p4.exportUasset3d(dp, uproject);
setModel(buf);
} catch (e) { onFlash(String(e), true); }
finally { setExporting(false); }
}
// real, rotatable 3D once Unreal has exported the mesh
if (model) return <ModelViewer depot={dp} name={name} bytes={model} forceExt="fbx" />;
const canExport = !!uproject; // exports the version currently synced on disk
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("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}</div>
</div>
)}
{/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */}
<button className="load3d" onClick={load3d} disabled={!canExport || exporting}
title={!uproject ? t("Pick the Unreal project working folder first.") : hist ? t("Exports the version currently synced on disk.") : t("Export the mesh from Unreal and view it in 3D")}>
{exporting ? <><span className="ldr sm" />{t("Exporting from Unreal… (first time is slow)")}</> : <>{I.cube}{t("Load 3D model")}</>}
</button>
</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>
);
}