- 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>
2630 lines
162 KiB
TypeScript
2630 lines
162 KiB
TypeScript
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 Depot <span>— Perforce</span></div></div>
|
||
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
|
||
<WinControls />
|
||
</div>
|
||
<div className="overlay">
|
||
<div className="splash">
|
||
<div className="splash-logo">
|
||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
|
||
</div>
|
||
<div className="flow">
|
||
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
|
||
<div className="wire">
|
||
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
|
||
</div>
|
||
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
|
||
</div>
|
||
<div className="splash-title">Exbyte Depot</div>
|
||
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
|
||
<div className="splash-bar"><span /></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- connect screen ---------------- */
|
||
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
|
||
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
|
||
const [user, setUser] = useState(initial?.user || "Bonchellon");
|
||
const [password, setPassword] = useState("");
|
||
const [client, setClient] = useState(initial?.client || "");
|
||
const [clients, setClients] = useState<Client[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
const [err, setErr] = useState("");
|
||
|
||
async function loadClients() {
|
||
try {
|
||
const cs = await p4.clients(server, user);
|
||
setClients(cs);
|
||
if (cs.length && !client) setClient(cs[0].client || "");
|
||
} catch { /* ignore */ }
|
||
}
|
||
// auto-load the workspace list as soon as the screen opens
|
||
useEffect(() => { loadClients(); /* eslint-disable-next-line */ }, []);
|
||
|
||
async function connect() {
|
||
setBusy(true); setErr("");
|
||
try {
|
||
const i = await p4.login(server, user, password, client);
|
||
onDone(i, { server, user, client });
|
||
} catch (e) { setErr(String(e)); }
|
||
finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="win">
|
||
<div className="titlebar" data-tauri-drag-region>
|
||
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte Depot <span>— Perforce</span></div></div>
|
||
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
|
||
<WinControls />
|
||
</div>
|
||
<div className="overlay">
|
||
<div className="card">
|
||
<div className="clogo">{I.cube}</div>
|
||
<h2>{t("Connect to Perforce")}</h2>
|
||
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
|
||
|
||
<div className="fld"><label>{t("Server")}</label>
|
||
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
|
||
<div className="fld"><label>{t("User")}</label>
|
||
<div className="inp">
|
||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
|
||
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
|
||
<div className="fld"><label>{t("Password")}</label>
|
||
<div className="inp">
|
||
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
|
||
<div className="fld"><label>{t("Workspace")}</label>
|
||
{clients.length ? (
|
||
<Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
|
||
icon={<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>}
|
||
options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
|
||
) : (
|
||
<div className="inp">
|
||
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
|
||
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
|
||
</div>
|
||
)}</div>
|
||
|
||
{err && <div className="err"><span>⚠</span><span>{err}</span></div>}
|
||
<button className="connect" disabled={busy || !password} onClick={connect}>
|
||
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- main workbench ---------------- */
|
||
type Tab = "changes" | "history";
|
||
|
||
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
|
||
|
||
type ModalState =
|
||
| { kind: "workspaces"; items: Client[]; current: string }
|
||
| { kind: "scope"; path: string; dirs: Dir[] }
|
||
| { kind: "settings" }
|
||
| { kind: "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 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></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></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>
|
||
);
|
||
}
|
||
|