import { useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { listen } from "@tauri-apps/api/event"; import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; import "./App.css"; import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4"; import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; /* ---------------- window controls (frameless) ---------------- */ function WinControls() { const w = getCurrentWindow(); return (
); } /* ---------------- icons ---------------- */ const I = { hex: , monitor: , branch: , sync: , chev: , search: , check: , moon: , sun: , arrow: , spinner: , cube: , revert: , clock: , log: , vscode: , hammer: , 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); 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: "about" }; type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null }; type Transfer = { op: "sync" | "submit"; count: number; file?: string }; function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }: { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) { const [tab, setTab] = useState("changes"); const [modal, setModal] = useState(null); const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); const [files, setFiles] = useState([]); const [checked, setChecked] = useState>(new Set()); const [sel, setSel] = useState(null); // primary row (preview + shift anchor) const [selRows, setSelRows] = useState>(new Set()); // multi-selection (Shift/Ctrl-click) const [filter, setFilter] = useState(""); const [desc, setDesc] = useState(""); // commit summary (first line) const [descBody, setDescBody] = useState(""); // extended description (optional) const [busy, setBusy] = useState(false); const [scanning, setScanning] = useState(false); // background reconcile in progress const [openMenu, setOpenMenu] = useState(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); const [detail, setDetail] = useState(null); const [selChange, setSelChange] = useState(""); // highlighted History row (instant) const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading const [dragging, setDragging] = useState(false); const [ask, setAsk] = useState void }>(null); const [pending, setPending] = useState([]); // committed-but-not-pushed changelists const [transfer, setTransfer] = useState(null); // live sync/submit file transfer const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building) const [buildLog, setBuildLog] = useState([]); // 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 [ctx, setCtx] = useState(null); // right-click menu const [logs, setLogs] = useState([]); // live p4 command log (P4V-style) const [logOpen, setLogOpen] = useState(false); const [peek, setPeek] = useState(false); // hover-over-loader log preview const logId = useRef(0); const peekTimer = useRef(null); function showPeek() { if (peekTimer.current) { clearTimeout(peekTimer.current); peekTimer.current = null; } setPeek(true); } function hidePeek() { if (peekTimer.current) clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 220); } // resizable panels (persisted): left panel width + Get-Latest zone width const [leftW, setLeftW] = useState(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; }); const [syncW, setSyncW] = useState(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; }); useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]); useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]); function startResize(e: ReactMouseEvent, kind: "left" | "sync") { e.preventDefault(); const startX = e.clientX, startLeft = leftW, startSync = syncW; const move = (ev: MouseEvent) => { if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420))); else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520))); }; const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); document.body.classList.add("resizing"); } function flash(text: string, err = false) { setToast({ text, err }); setTimeout(() => setToast(null), 4500); } function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise { return new Promise((resolve) => setAsk({ ...opts, resolve })); } // load the currently-open files + pending changelists (fast — just reads p4 state) async function loadOpened(s: string) { const f = await p4.opened(s); setFiles(f); const uncommitted = f.filter((x) => (x.change || "default") === "default"); setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean))); setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null)); setSelRows(new Set()); try { setPending(await p4.changes()); } catch { setPending([]); } } // refresh the Changes view. `scan=true` also auto-detects out-of-band changes // (new/edited/deleted files on disk — e.g. assets Unreal wrote directly) via a // background reconcile, so they show up on their own. The reconcile runs off the // UI thread (async command) and never freezes the window. async function refresh(scope?: string, scan = false) { const s = scope !== undefined ? scope : activePath; setBusy(true); try { await loadOpened(s); } catch (e) { flash(String(e), true); } finally { setBusy(false); } if (scan) { setScanning(true); try { await p4.scan(s); await loadOpened(s); } catch (e) { flash(String(e), true); } finally { setScanning(false); } } } // Default = Perforce-native: just read what's checked out (p4 opened). With // Source Control enabled in Unreal, UE auto-checks-out files, so they appear // here on their own — no reconcile needed. Reconcile is a manual fallback. useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []); // live Perforce command log — the Rust backend emits one event per p4 call useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => { setLogs((L) => { const next = [...L, { ...e.payload, id: ++logId.current }]; return next.length > 600 ? next.slice(-600) : next; }); }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); // live file-transfer progress (Get Latest / Submit) streamed from the backend useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => { const p = e.payload; if (p.done) setTransfer(null); else setTransfer({ op: p.op, count: p.count, file: p.file }); }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); // only surface the progress dialog once a transfer has run for a moment const xferActive = transfer !== null; useEffect(() => { if (!xferActive) { setShowXfer(false); return; } const id = setTimeout(() => setShowXfer(true), 400); return () => clearTimeout(id); }, [xferActive]); // detect a UE project / .sln in the current working folder (Launch / Build actions) useEffect(() => { if (!activePath) { setUproject(""); setSlnPath(""); return; } let live = true; p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject("")); p4.findSln(activePath).then((p) => live && setSlnPath(p)).catch(() => live && setSlnPath("")); return () => { live = false; }; }, [activePath]); // live MSBuild output useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ line: string; done: boolean; ok: boolean }>("build-log", (e) => { const p = e.payload; setBuildLog((L) => (L.length > 4000 ? [...L.slice(-4000), p.line] : [...L, p.line])); if (p.done) { setBuilding(false); setBuildOk(p.ok); } }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); useEffect(() => { const close = () => setOpenMenu(null); document.addEventListener("click", close); return () => document.removeEventListener("click", close); }, []); // keep the changelist live: re-scan opened files when the window regains focus useEffect(() => { const onFocus = () => { if (tab === "changes") refresh(); }; window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); // eslint-disable-next-line }, [tab]); // drag & drop files/folders onto the window → reconcile them into the changelist useEffect(() => { let unlisten: (() => void) | undefined; getCurrentWebview() .onDragDropEvent(async (e) => { const dt = (e.payload as { type: string }).type; if (dt === "over") { setDragging(true); return; } if (dt === "leave") { setDragging(false); return; } if (dt === "drop") { setDragging(false); const paths = (e.payload as { paths: string[] }).paths || []; if (!paths.length) return; setBusy(true); let n = 0; for (const p of paths) { try { n += (await p4.reconcile(p)).length; } catch (err) { flash(String(err), true); } } flash(n ? t("Added/updated in changelist: {n}", { n }) : t("No changes to add")); await refresh(); } }) .then((u) => (unlisten = u)); return () => unlisten?.(); // eslint-disable-next-line }, []); async function revertAll() { const list = files.map((f) => f.depotFile || "").filter(Boolean); if (!list.length) return; if (!(await confirm({ title: t("Revert all changes?"), body: t("Edits in all {n} files will be undone. Local changes are lost permanently.", { n: list.length }), confirm: t("Revert all"), danger: true }))) return; setBusy(true); try { await p4.revert(list); flash(t("All changes reverted.")); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } async function openChange(c: Change) { const n = c.change || ""; setSelChange(n); // highlight the row immediately — no waiting for the load setDetail(null); setDetailBusy(true); try { setDetail(await p4.describe(n)); } catch (e) { flash(String(e), true); } finally { setDetailBusy(false); } } async function openWorkspaces() { if (!session) { flash(t("No session data for the workspace list"), true); return; } try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); } catch (e) { flash(String(e), true); } } async function switchWorkspace(client: string) { setModal(null); setBusy(true); try { const i = await p4.switchClient(client); onInfo(i); if (session) onSession({ ...session, client }); const scope = loadScope(client); // restore this workspace's saved working folder setActivePath(scope); await refresh(scope); flash(t("Active workspace: {c}", { c: client })); } catch (e) { flash(String(e), true); setBusy(false); } } async function browseTo(path: string) { try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); } catch (e) { flash(String(e), true); } } function chooseScope(path: string) { setModal(null); setActivePath(path); saveScope(info?.clientName || "", path); // remember the working folder per workspace setHistory([]); setDetail(null); setSel(null); refresh(path); if (tab === "history") loadHistory(path); } async function loadHistory(scope?: string) { const f = scope !== undefined ? scope : activePath; const path = f ? f + "/..." : ""; setBusy(true); try { setHistory(await p4.history(path)); } catch (e) { flash(String(e), true); } finally { setBusy(false); } } function switchTab(t: Tab) { setTab(t); if (t === "history" && history.length === 0) loadHistory(); } async function getLatest() { setBusy(true); try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } // Manual re-scan (same background reconcile as auto, on demand). function rescan() { refresh(activePath, true); } function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); } // launch Unreal by opening the detected .uproject (file association starts UE) async function launchUE() { if (!uproject) return; try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); } catch (e) { flash(String(e), true); } } // build the solution found in the working folder (MSBuild) with live output async function buildSln() { if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false); try { await p4.buildSln(slnPath); } catch { /* the overlay already shows the failure line */ } } // reveal a depot path / workspace root in Windows Explorer async function reveal(target: string) { if (!target) { flash(t("Path not set"), true); return; } try { await p4.openInExplorer(target); } catch (e) { flash(String(e), true); } } async function copyText(text: string, what: string) { try { await navigator.clipboard.writeText(text); flash(t("{what} copied", { what })); } catch { flash(t("Copy failed"), true); } } // roll back an already-submitted changelist (Perforce `p4 undo` → new revert change) async function revertChange(c: Change) { const n = c.change || ""; if (!(await confirm({ title: t("Revert changelist #{n}?", { n }), body: t("Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”", { n, desc: (c.desc || "").trim() || t("no description") }), confirm: t("Revert #{n}", { n }), danger: true }))) return; setBusy(true); try { await p4.undoChange(n); flash(t("Changelist #{n} reverted by a new changelist.", { n })); await loadHistory(); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } // Commit (local): move checked uncommitted files into a new pending changelist // with the description. Nothing goes to the server — revertable. (like git commit) async function doCommit() { const list = files .filter((f) => (f.change || "default") === "default") .map((f) => f.depotFile || "") .filter((d) => checked.has(d)); if (!list.length || !desc.trim()) return; const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim(); setBusy(true); try { const cl = await p4.commit(message, list); flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl })); setDesc(""); setDescBody(""); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } // Submit / push: send all committed (pending) changelists to the server. (like git push) async function pushAll() { if (!pending.length) return; const n = pending.length; if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return; setBusy(true); const errors: string[] = []; for (const c of pending) { try { await p4.submitChange(c.change || ""); } catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); } } if (errors.length) flash(errors.join(" · "), true); else flash(t("Sent to the server: {n} changelist(s).", { n })); await refresh(); await loadHistory(); // the submit created a new submitted changelist — refresh History } async function doRevert(file: OpenedFile) { const dp = file.depotFile || ""; if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return; setBusy(true); try { await p4.revert([dp]); flash(t("Reverted: {name}", { name: splitPath(dp).name })); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } const uncommitted = useMemo(() => files.filter((f) => (f.change || "default") === "default"), [files]); const view = useMemo(() => { if (!filter.trim()) return uncommitted; const q = filter.toLowerCase(); return uncommitted.filter((f) => (f.depotFile || "").toLowerCase().includes(q)); }, [uncommitted, filter]); const selFile = sel != null ? view[sel] : undefined; const allChecked = view.length > 0 && view.every((f) => checked.has(f.depotFile || "")); function toggleAll() { setChecked((s) => { const n = new Set(s); if (allChecked) view.forEach((f) => n.delete(f.depotFile || "")); else view.forEach((f) => n.add(f.depotFile || "")); return n; }); } // row click with Shift (range) / Ctrl (toggle) multi-selection function rowClick(i: number, e: ReactMouseEvent) { if (e.shiftKey && sel != null) { const a = Math.min(sel, i), b = Math.max(sel, i); const s = new Set(); for (let k = a; k <= b; k++) s.add(k); setSelRows(s); } else if (e.ctrlKey || e.metaKey) { setSelRows((prev) => { const n = new Set(prev); n.has(i) ? n.delete(i) : n.add(i); return n; }); } else { setSelRows(new Set([i])); } setSel(i); } // checkbox: if the row is part of a multi-selection, toggle the whole selection together function toggleCheck(i: number, dp: string) { const idxs = selRows.has(i) && selRows.size > 1 ? [...selRows] : [i]; const dps = idxs.map((k) => view[k]?.depotFile || "").filter(Boolean); const target = !checked.has(dp); setChecked((prev) => { const n = new Set(prev); dps.forEach((d) => (target ? n.add(d) : n.delete(d))); return n; }); } // Discard local changes (p4 revert) for the given files — the "undo" for opened files. async function discard(fs: OpenedFile[]) { const dps = fs.map((f) => f.depotFile || "").filter(Boolean); if (!dps.length) return; const label = dps.length === 1 ? splitPath(dps[0]).name : t("{n} files", { n: dps.length }); if (!(await confirm({ title: t("Discard changes?"), body: t("Local changes to {name} will be lost permanently — the file returns to the server version.", { name: label }), confirm: t("Discard"), danger: true }))) return; setBusy(true); try { await p4.revert(dps); flash(t("Discarded: {name}", { name: label })); await refresh(); } catch (e) { flash(String(e), true); setBusy(false); } } // right-click on a Changes row → file actions (operates on the selection if the row is in it) function fileCtx(i: number, e: ReactMouseEvent) { const multi = selRows.has(i) && selRows.size > 1; if (!selRows.has(i)) { setSelRows(new Set([i])); setSel(i); } // right-click selects the row const idxs = multi ? [...selRows] : [i]; const targets = idxs.map((k) => view[k]).filter(Boolean) as OpenedFile[]; const dp = view[i]?.depotFile || ""; const isCode = kindOf(splitPath(dp).name) === "code"; openCtx(e, [ { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) }, ...(isCode ? [{ label: t("Edit in VS Code"), icon: I.vscode, act: () => { p4.openInVscode(dp).then(() => flash(t("Opening in VS Code…"))).catch((err) => flash(String(err), true)); } }] : []), { label: t("Open in Explorer"), act: () => reveal(dp) }, { label: t("Copy path"), act: () => copyText(dp, t("Path")) }, ]); } const menus: Record void }[]> = { File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }], Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }], Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }], Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }], Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }], Help: [{ label: t("About"), act: () => setModal({ kind: "about" }) }], }; return (
{I.hex}
Exbyte Depot — Perforce
e.stopPropagation()}> {Object.entries(menus).map(([name, items]) => (
setOpenMenu(openMenu === name ? null : name)}> {t(name)}
{items.map((it) => (
it.act?.()}> {it.label}{it.kb && {it.kb}}
))}
))}
{info?.serverAddress || "connected"}
openCtx(e, [ { label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) }, { label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) }, ])}> {I.monitor} {t("Workspace")}{info?.clientName || "—"} {I.chev}
startResize(e, "sync")} title={t("Drag to resize")} />
browseTo("")} title={t("Choose the project working folder · right-click for more")} onContextMenu={(e) => openCtx(e, [ ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []), ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []), { label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) }, { label: t("Choose another folder…"), act: () => browseTo("") }, { label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) }, ])}> {I.branch} {t("Working folder")}{activePath || t("whole workspace (//…)")} {I.chev}
{pending.length > 0 ? (
{t("Submit to server")}{t("{n} changelist(s) → push", { n: pending.length })}
) : (
{I.sync} {t("Get Latest")}{busy ? t("Working…") : t("pull from server")}
)}
startResize(e, "left")} title={t("Drag to resize")} />
{tab === "changes" ? ( <>
{I.search} { setFilter(e.target.value); setSelRows(new Set()); }} />
{pending.length > 0 && (
{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}
)} {scanning && (
{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}
)}
{allChecked ? I.check : null} {t("{a} of {b} selected", { a: checked.size, b: view.length })}
{view.length === 0 ? (
{scanning ? <>{t("Scanning disk for changes…")} : <> {busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")} }
) : ( )}
setDesc(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />