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 (
); } /* ---------------- icons ---------------- */ const I = { hex: , monitor: , branch: , sync: , chev: , search: , check: , moon: , sun: , arrow: , spinner: , cube: , revert: , back: , chevron: , spark: , people: , terminal: , clock: , log: , vscode: , hammer: , lock: , unlock: , shelf: , folder: , power: , up: , gear: , info: , scan: , ue: , }; /* ---------------- theme hook ---------------- */ function useTheme(): [boolean, () => void] { const [light, setLight] = useState(() => { try { return localStorage.getItem("exd-theme") === "light"; } catch { return false; } }); useEffect(() => { document.body.classList.toggle("light", light); try { localStorage.setItem("exd-theme", light ? "light" : "dark"); } catch {} }, [light]); return [light, () => setLight((l) => !l)]; } /* ---------------- language hook (whole tree re-renders on change) ---------------- */ function useLang(): [Lang, (l: Lang) => void] { const [lang, setLang] = useState(LANG); return [lang, (l) => { setLangGlobal(l); setLang(l); }]; } /* ---------------- UI zoom hook (scales the whole webview) ---------------- */ function useZoom(): [number, (z: number) => void] { const [zoom, setZoom] = useState(() => { const v = Number(localStorage.getItem("exd-zoom")); return v >= 0.5 && v <= 2 ? v : 1; }); useEffect(() => { (document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom); // 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(null); const [session, setSession] = useState(null); const [light, toggleTheme] = useTheme(); const [lang, setLang] = useLang(); const [zoom, setZoom] = useZoom(); const saved = useMemo(() => loadSession(), []); // try to restore the last session from the on-disk p4 ticket. // keep the splash on screen for at least a beat so the intro animation reads. useEffect(() => { if (!saved) { const id = setTimeout(() => setPhase("connect"), 900); return () => clearTimeout(id); } const min = new Promise((r) => setTimeout(r, 1000)); p4.restore(saved.server, saved.user, saved.client) .then(async (i) => { await min; setInfo(i); setSession(saved); setPhase("main"); }) .catch(async () => { await min; setPhase("connect"); }); }, []); // eslint-disable-line let content; if (phase === "loading") content = ; else if (phase === "connect") content = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; else content = { setSession(s); saveSession(s); }} onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />; return <>{content}; } /* ---------------- custom styled dropdown (replaces the ugly native setServer(e.target.value)} />
setUser(e.target.value)} onBlur={loadClients} />
setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} />
{clients.length ? ( setClient(e.target.value)} />
)} {err &&
{err}
} ); } /* ---------------- main workbench ---------------- */ type Tab = "changes" | "history"; type CtxItem = { label: string; danger?: boolean; 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("changes"); const [modal, setModal] = useState(null); const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); const [files, setFiles] = useState([]); const [checked, setChecked] = useState>(new Set()); const [sel, setSel] = useState(null); // primary row (preview + shift anchor) const [selRows, setSelRows] = useState>(new Set()); // multi-selection (Shift/Ctrl-click) const [previewCL, setPreviewCL] = useState(null); // preview override (pending-CL file) const [expandedCL, setExpandedCL] = useState>(new Set()); // expanded pending changelist cards const [prompt, setPrompt] = useState 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(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); const [detail, setDetail] = useState(null); const [selChange, setSelChange] = useState(""); // highlighted History row (instant) const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading const [histFile, setHistFile] = useState(null); // drilled-into submitted file (read-only preview) const [dragging, setDragging] = useState(false); const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists const [transfer, setTransfer] = useState(null); // live sync/submit file transfer const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog const [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([]); // code editors installed on this machine const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick) const [others, setOthers] = useState>(new Map()); // files opened/locked by OTHER users const [needResolve, setNeedResolve] = useState([]); // files awaiting conflict resolution const [resolveOpen, setResolveOpen] = useState(false); const [offline, setOffline] = useState(false); // lost connection to the server const lastSubmit = useRef(""); // newest submitted CL seen (new-submit toast) const notifPrimed = useRef(false); // don't toast on the very first poll const [buildLog, setBuildLog] = useState([]); // live MSBuild output const [building, setBuilding] = useState(false); const [buildOk, setBuildOk] = useState(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); // right-click menu const [logs, setLogs] = useState([]); // 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(() => { 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([]); // terminal output blocks const [termInput, setTermInput] = useState(""); const [cmdHist, setCmdHist] = useState([]); // 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(null); function showPeek() { if (peekTimer.current) { clearTimeout(peekTimer.current); peekTimer.current = null; } setPeek(true); } function hidePeek() { if (peekTimer.current) clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 220); } // resizable panels (persisted): left panel width + Get-Latest zone width const [leftW, setLeftW] = useState(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; }); const [syncW, setSyncW] = useState(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; }); const [histW, setHistW] = useState(() => { 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 { 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(); 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(); 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).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>(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(); 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 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 (
Exbyte Depot — Perforce
e.stopPropagation()}> {Object.entries(menus).map(([name, items]) => (
setOpenMenu(openMenu === name ? null : name)}> {t(name)}
{items.map((it) => (
it.act?.()}> {it.icon && {it.icon}} {it.label}{it.kb && {it.kb}}
))}
))}
{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}
openCtx(e, [ { label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) }, { label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) }, ])}> {I.monitor} {t("Workspace")}{info?.clientName || "—"} {I.chev}
startResize(e, "sync")} title={t("Drag to resize")} />
browseTo("")} title={t("Choose the project working folder · right-click for more")} onContextMenu={(e) => openCtx(e, [ ...(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")) }, ])}> {I.branch} {t("Working folder")}{activePath || t("whole workspace (//…)")} {I.chev}
{pending.length > 0 ? (
{t("Submit to server")}{t("{n} changelist(s) → push", { n: pending.length })}
) : (
{I.sync} {t("Get Latest")}{busy ? t("Working…") : t("pull from server")}
)}
{offline && (
{t("Disconnected from the server — retrying…")}
)} {needResolve.length > 0 && tab === "changes" && (
setResolveOpen(true)}> {I.hex}{t("{n} file(s) need conflict resolution after sync.", { n: needResolve.length })}
)}
startResize(e, "left")} title={t("Drag to resize")} />
{tab === "changes" ? ( <>
{I.search} { setFilter(e.target.value); setSelRows(new Set()); }} />
{pendingCards.length > 0 && (
{t("Pending · ready to Submit")}
{pendingCards.map(({ cl, files: cf }) => { const ch = cl.change || ""; const open = expandedCL.has(ch); return (
toggleCL(ch)}> {I.chev} #{ch} {(cl.desc || "").trim() || t("(no description)")} {t("{n} files", { n: cf.length })} e.stopPropagation()}>
{open && (
{cf.length === 0 &&
{t("(files are outside the current working folder)")}
} {cf.map((f) => { const sp = splitPath(f.depotFile); const st = statusOf(f.action); return (
setPreviewCL(f)}> {st.ch} {sp.name}{sp.dir}
); })}
)}
); })}
)} {scanning && (
{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}
)}
{allChecked ? I.check : null} {t("{a} of {b} selected", { a: checked.size, b: view.length })}
{view.length === 0 ? (
{scanning ? <>{t("Scanning disk for changes…")} : <> {busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")} }
) : ( )}
setDesc(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />