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 { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import UpdateBanner from "./Updater"; import "./App.css"; import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4"; import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n"; import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes"; import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; // native OS notification (works even when the window is hidden in the tray) let notifyReady: boolean | null = null; async function notify(title: string, body: string) { try { if (notifyReady === null) { notifyReady = await isPermissionGranted(); if (!notifyReady) notifyReady = (await requestPermission()) === "granted"; } if (notifyReady) sendNotification({ title, body }); } catch { /* notifications unavailable — the in-app toast still shows */ } } /* ---------------- 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: , trash: , copy: , reveal: , move: , pencil: , down: , open: , }; /* ---------------- theme hook (built-in + custom themes) ---------------- */ function useTheme(): [boolean, () => void, string, (id: string) => void] { const [themeId, setThemeId] = useState(() => loadActiveId()); useEffect(() => { applyTheme(resolveTheme(themeId)); saveActiveId(themeId); }, [themeId]); const light = resolveTheme(themeId).base === "light"; const toggleTheme = () => setThemeId((id) => (resolveTheme(id).base === "light" ? "dark" : "light")); return [light, toggleTheme, themeId, setThemeId]; } /* ---------------- 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, themeId, setThemeId] = 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 ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view area type ModalState = | { kind: "workspaces"; items: Client[]; current: string } | { kind: "scope"; path: string; dirs: Dir[] } | { kind: "settings"; section?: "general" | "appearance" | "plugins" } | { kind: "users" } | { kind: "search" } | { kind: "locks" } | { kind: "typemap" } | { kind: "labels" } | { kind: "branch" } | { kind: "streams" } | { kind: "jobs" } | { kind: "clean" } | { kind: "reconcile" } | { kind: "explorer" } | { kind: "folderprops"; path: string; name: string } | { kind: "ignore" } | { kind: "filelog"; depot: string; name: string } | { 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; total?: number; file?: string; log: string[]; cancelling?: boolean }; type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean }; function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }: { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => 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 [, setPluginVer] = useState(0); // bumped when the plugin registry changes → re-render menus const [pluginView, setPluginView] = useState(null); // open plugin view modal const pluginsLoaded = useRef(false); 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 [scanCount, setScanCount] = useState(0); // live "found N files" during a streaming scan const [openMenu, setOpenMenu] = useState(null); const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null); const [history, setHistory] = useState([]); const [changeSizes, setChangeSizes] = useState>({}); // per-changelist byte weight for History const sizesRef = useRef>({}); // mirror of changeSizes so we only fetch new changes (no flicker) const histSizeScope = useRef(""); // scope the cached sizes belong to 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 [commitProg, setCommitProg] = useState<{ count: number; total: number } | null>(null); // live commit (reopen) progress const [xferMin, setXferMin] = useState(false); // transfer dialog collapsed to a floating chip (keeps running) 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 uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures 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 [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace 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"); // right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab. const [viewTabs, setViewTabs] = useState(() => { try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {} return ["viewer"]; }); const [viewTab, setViewTab] = useState("viewer"); const [treeSide, setTreeSide] = useState<"depot" | "workspace">("depot"); // which side the embedded File Tree shows const [treeNonce, setTreeNonce] = useState(0); // bump to force the tree to (re)focus a side 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); setScanCount(0); // stream the reconcile so the banner shows a live "found N files" counter // (and can be cancelled) instead of an opaque, never-ending spinner try { await p4.scanStream(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); setScanCount(0); } } } } // 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 */ }, []); // ---- plugins: (re)load enabled plugins; they register menu/panel/hook contributions async function reloadPlugins() { const res = await loadPlugins({ flash, notify: (title, body) => { void notify(title, body); }, info: () => info, selectedFile: () => selFile || null, refresh: () => { void refresh(); }, // Unreal bridge — core keeps buildSln/overlay/log; the plugin just drives them ue: { hasProject: () => !!uprojectRef.current, uproject: () => uprojectRef.current, build: () => { startBuild(); }, readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000), }, }); setPluginVer((v) => v + 1); return res; } useEffect(() => { uprojectRef.current = uproject; }, [uproject]); // keep the ue-bridge mirror live useEffect(() => { if (pluginsLoaded.current || !info?.clientName) return; pluginsLoaded.current = true; (async () => { // auto-install official plugins (e.g. Unreal) on first run so no one loses tooling try { const installed = new Set((await p4.pluginList()).map((p) => p.id)); const added = await autoInstallOfficial(installed); if (added.length) console.log("[plugins] auto-installed:", added); } catch { /* offline — skip */ } const { loaded, errors } = await reloadPlugins(); if (errors.length) console.warn("[plugins] load errors:", errors); if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded })); })().catch(() => {}); }, [info?.clientName]); // eslint-disable-line useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []); // 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. // Accumulates a rolling file log and tracks total for an "N of total" bar. useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ op: "sync" | "submit"; count: number; total?: number; file?: string; done: boolean; cancelled?: boolean }>("p4-transfer", (e) => { const p = e.payload; if (p.done) { setTransfer(null); setXferMin(false); return; } setTransfer((prev) => { const log = prev ? prev.log : []; const next = p.file && (log.length === 0 || log[log.length - 1] !== p.file) ? [...log, p.file].slice(-200) : log; return { op: p.op, count: p.count, total: p.total || prev?.total, file: p.file, log: next, cancelling: prev?.cancelling }; }); }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); // live commit progress: p4_commit reopens files in batches and emits one event // per batch, so we can show "N / total committed" instead of a mute spinner useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ count: number; total: number; done: boolean }>("p4-commit", (e) => { const p = e.payload; setCommitProg(p.done ? null : { count: p.count, total: p.total }); }).then((u) => { if (dead) u(); else un = u; }); return () => { dead = true; un?.(); }; }, []); // live disk-scan progress: the streaming reconcile emits one event per file it // opens, so we can show a rising "found N files" counter during a Rescan useEffect(() => { let un: (() => void) | undefined; let dead = false; listen<{ count: number; file?: string; done: boolean }>("p4-scan", (e) => { if (!e.payload.done) setScanCount(e.payload.count); }).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 () => { if (workOffline) return; // explicit offline-work mode: don't poll the server 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"; const mine = (who || "").toLowerCase() === (info?.userName || "").toLowerCase(); if (!mine) { const desc = (latest?.desc || "").trim().replace(/\s+/g, " ").slice(0, 160) || t("(no description)"); flash(t("{user} submitted #{n}", { user: who, n })); notify(t("{user} submitted #{n}", { user: who, n }), desc); // native toast — arrives even from the tray } lastSubmit.current = n; if (tab === "history") loadHistory(); checkBehind(); // a new submit landed → am I now behind? } } } 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, workOffline]); // remember which view-panel tabs are open across restarts useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]); // In a shipped (production) build, kill the WebView's own right-click menu // (Back / Reload / Save as / Print / Inspect) and the devtools hotkeys — this // is a desktop app, not a web page. In dev we keep them for debugging. useEffect(() => { if (import.meta.env.DEV) return; const noCtx = (e: MouseEvent) => e.preventDefault(); // our own context menus render as React popups, unaffected const noKeys = (e: KeyboardEvent) => { const k = e.key.toUpperCase(); if (k === "F12" || (e.ctrlKey && e.shiftKey && (k === "I" || k === "J" || k === "C")) || (e.ctrlKey && k === "U")) { e.preventDefault(); } }; document.addEventListener("contextmenu", noCtx); window.addEventListener("keydown", noKeys, true); return () => { document.removeEventListener("contextmenu", noCtx); window.removeEventListener("keydown", noKeys, true); }; }, []); // check "am I behind the server" when connected or the working folder changes useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]); // window was closed to the tray → tell the user once per session it's still alive useEffect(() => { let shown = false; const un = listen("tray-hidden", () => { if (shown) return; shown = true; notify(t("Exbyte Depot is still running"), t("It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.")); }); return () => { un.then((f) => f()); }; }, []); // 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); } } async function doSwitchStream(stream: string) { setModal(null); setBusy(true); try { const i = await p4.switchStream(stream); onInfo(i); flash(t("Switched to stream {s}", { s: stream })); await refresh(); await loadHistory(); } catch (e) { flash(String(e), true); setBusy(false); } } async function doStreamInteg(stream: string, dir: "down" | "up") { setBusy(true); try { const out = await p4.streamInteg(stream, dir); flash(t("{d} opened into a pending changelist — resolve & submit. {out}", { d: dir === "up" ? t("Copy up") : t("Merge down"), out: out.split("\n")[0] })); await refresh(); } catch (e) { flash(String(e), true); } finally { setBusy(false); } } function chooseScope(path: string) { setModal(null); setActivePath(path); saveScope(info?.clientName || "", path); // remember the working folder per workspace if (path) pushRecentScope(info?.clientName || "", path); // Recently list (skip whole-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 { const h = await p4.history(path); setHistory(h); // commit weights load in the background so the list shows instantly. Sizes are // cached per changelist and only fetched for NEW changes — so a poll/refresh // on the same scope never re-fetches (which used to make the badges flicker). const scopeChanged = f !== histSizeScope.current; histSizeScope.current = f; if (scopeChanged) { sizesRef.current = {}; setChangeSizes({}); } const nums = h.map((c) => c.change || "").filter(Boolean); const need = nums.filter((n) => sizesRef.current[n] === undefined); if (need.length) { p4.changeSizes(need, f).then((rows) => { const m = { ...sizesRef.current }; for (const r of rows) m[r.change] = r.size; sizesRef.current = m; setChangeSizes(m); }).catch(() => {}); } } 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(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too catch (e) { flash(String(e), true); setBusy(false); } } // check whether the server has newer content than my workspace (files behind) const behindRef = useRef(0); async function checkBehind() { if (workOffline) return; try { const b = await p4.behind(activePath); const was = behindRef.current; behindRef.current = b.count; setBehind(b.count > 0 ? b : null); // notify only when we *newly* fall behind (not on every recheck) if (b.count > 0 && was === 0) { notify(t("Your workspace is behind"), t("{n} file(s) on the server are newer than your copy. Get Latest to update.", { n: b.count })); } } catch { /* ignore — non-critical */ } } // 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 }); } }); } // create a new folder / project in the workspace under `scope` (depot path, or "" = root) function newFolder(scope: string) { const where = scope || t("workspace root"); setPrompt({ title: t("New folder in {where}", { where }), label: t("Folder name — nest with / (e.g. NewGame or NewGame/Source)"), value: "", onSave: async (v) => { setPrompt(null); const n = v.trim(); if (!n) return; try { await p4.makeFolder(scope, n); flash(t("Created “{name}” with a README placeholder — commit it to keep the folder on the server.", { name: n })); await refresh(); } catch (e) { flash(String(e), true); } } }); } // rename / move a depot folder (p4 move) — review in Changes, then Submit function renameFolder(path: string) { const cur = splitPath(path).name || path; setPrompt({ title: t("Rename folder"), label: t("New name for “{name}”", { name: cur }), value: cur, onSave: async (v) => { setPrompt(null); const n = v.trim(); if (!n || n === cur) return; try { const r = await p4.renameFolder(path, n); flash(t("Renamed to “{name}” — {n} file(s) moved. Commit to apply.", { name: n, n: r.length })); await refresh(); } catch (e) { flash(String(e), true); } } }); } // delete a folder — marks its files for delete (reviewable), removes fresh/local ones async function deleteFolder(path: string) { const name = splitPath(path).name || path; if (!(await confirm({ title: t("Delete folder “{name}”?", { name }), body: t("Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away."), confirm: t("Delete"), danger: true }))) return; try { const r = await p4.deleteFolder(path); flash(r.length ? t("Marked {n} file(s) for delete — Submit to remove the folder.", { n: r.length }) : t("Folder removed.")); await refresh(); } catch (e) { flash(String(e), true); } } // right-click on a folder in the File Tree → folder operations function folderMenu(n: FsEntry, e: ReactMouseEvent) { const isDepot = n.path.startsWith("//"); openCtx(e, [ { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(n.path) }, ...(isDepot ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(n.path) }] : []), { label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: n.path, name: n.name }) }, { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(n.path) }, { label: t("Copy path"), icon: I.copy, act: () => copyText(n.path, t("Path")) }, { label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(n.path) }, ]); } // 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 && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } setBuildPick(true); } // build with the chosen UE configuration, live output. For a real UE project we // pass the .uproject so the backend builds the game module via UnrealBuildTool. async function buildSln(config: string) { setBuildPick(false); if (!slnPath && !uproject) return; setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false); try { await p4.buildSln(slnPath, config, uproject); } 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; // plugin submit hooks can block (e.g. a reference-integrity guard) if (getRegistry().submitHooks.length) { const verdict = await runSubmitHooks(list.map((d) => ({ depotFile: d }))); if (!verdict.ok) { if (!(await confirm({ title: t("A plugin flagged this commit"), body: verdict.message || t("A plugin blocked the commit."), confirm: t("Commit anyway"), danger: true }))) 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); } } // Cancel an in-progress submit/sync. Safe for submit before the server commit: // p4 submit is transactional, so an aborted transfer leaves the CL pending. function cancelTransfer() { setTransfer((prev) => (prev ? { ...prev, cancelling: true } : prev)); p4.transferCancel().catch(() => {}); } // 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); } } function attachJob(change: string) { setPrompt({ title: t("Attach job to #{n}", { n: change }), label: t("Job ID"), value: "", onSave: async (v) => { setPrompt(null); const j = v.trim(); if (!j) return; try { await p4.fix(j, change); flash(t("Job {j} linked to #{n}.", { j, n: change })); } catch (e) { flash(String(e), true); } } }); } 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); } } // submit variants: reopen after submit (-r), revert-unchanged, or submit a shelf (-e) async function submitWithOpts(change: string, reopen: boolean, revertUnchanged: boolean) { 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.submitOpts(change, reopen, revertUnchanged); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } catch (e) { flash(String(e), true); setBusy(false); } } async function submitShelved(change: string) { if (!(await confirm({ title: t("Submit shelved #{n}?", { n: change }), body: t("The shelved files of #{n} are submitted directly from the server.", { n: change }), confirm: t("Submit") }))) return; setBusy(true); try { await p4.submitShelved(change); flash(t("Submitted shelved changelist #{n}.", { n: change })); await refresh(); await loadHistory(); } catch (e) { flash(String(e), true); 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"), icon: I.trash, 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) }, ...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []), ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), icon: I.move, 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)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) }, { label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) }, ...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: I.hex, act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })), ]); } 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); } } // toggle explicit "Work Offline" mode (P4V-style): stop polling the server; // edits stay local until reconciled. function toggleWorkOffline() { setWorkOffline((v) => { const nv = !v; try { localStorage.setItem("exd-workoffline", nv ? "1" : "0"); } catch {} if (nv) { setOffline(false); flash(t("Working offline — edits stay local. Run Reconcile Offline Work to sync.")); } else { flash(t("Back online.")); refresh(); } return nv; }); } // right-panel view tabs: open/focus, close, and Window-menu toggle function focusViewTab(k: ViewTab) { setViewTabs((ts) => (ts.includes(k) ? ts : [...ts, k])); setViewTab(k); } function closeViewTab(k: ViewTab) { setViewTabs((ts) => { const nt = ts.filter((x) => x !== k); if (viewTab === k) setViewTab((nt[nt.length - 1] as ViewTab) || "viewer"); return nt; }); } function toggleViewTab(k: ViewTab) { if (viewTabs.includes(k)) closeViewTab(k); else focusViewTab(k); } // open the File Tree view-tab focused on a given side (Depot or Workspace) function showInTree(side: "depot" | "workspace") { setTreeSide(side); setTreeNonce((n) => n + 1); focusViewTab("tree"); } // open a file picked in the File Tree: depot files preview in the File Viewer, // local files open in the OS file browser. function openTreeFile(n: FsEntry, side: "depot" | "workspace") { if (side === "depot") { if (tab !== "changes") switchTab("changes"); setPreviewCL({ depotFile: n.path, _spec: n.path, action: "browse", rev: "", type: "", change: "" } as unknown as OpenedFile); focusViewTab("viewer"); } else { reveal(n.path); } } // clear the read-only bit on selected local files so they can be edited while // offline (before a checkout). Reconcile picks the changes up later. async function makeWritable(targets: OpenedFile[]) { const locals = targets.map((f) => f.clientFile || "").filter(Boolean) as string[]; if (!locals.length) { flash(t("No local path for the selection."), true); return; } try { await p4.setWritable(locals, true); flash(t("{n} file(s) made writable for offline editing.", { n: locals.length })); } 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, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace }, { label: t("New folder / project…"), icon: I.folder, hint: t("Create a new folder in the working area (with a README placeholder) — e.g. a separate project."), act: () => newFolder(activePath) }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect }, ], Connection: [ { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") }, ], Actions: [ { label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, hint: t("Scan the workspace for files changed outside the app (reconcile)."), act: rescan }, { label: (workOffline ? "✓ " : "") + t("Work Offline"), icon: I.power, hint: t("Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V."), act: toggleWorkOffline }, { label: t("Reconcile Offline Work…"), icon: I.scan, hint: t("Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them."), act: () => setModal({ kind: "reconcile" }) }, { label: t("Clean workspace…"), icon: I.revert, hint: t("Make the workspace exactly match the depot — discards un-opened local changes."), act: () => setModal({ kind: "clean" }) }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, hint: t("Sync the workspace to the newest revision (pull)."), act: getLatest }, { label: t("Get Revision…"), icon: I.clock, hint: t("Sync the workspace to a specific changelist, label or date."), act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, hint: t("Save selected files into a local pending changelist (not sent yet)."), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, hint: t("Send pending changelists to the server (push)."), act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, hint: t("Resolve merge conflicts left after a sync."), act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, hint: t("Discard every open change and restore depot versions."), act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() }, ], Window: [ { label: (viewTabs.includes("viewer") ? "✓ " : "") + t("File Viewer"), icon: FILE_GLYPH, hint: t("The preview panel for the selected file (image, 3D model, code, diff)."), act: () => toggleViewTab("viewer") }, { label: (viewTabs.includes("tree") ? "✓ " : "") + t("File Tree"), icon: I.folder, hint: t("A folder hierarchy of the Depot and your Workspace, docked into the view panel."), act: () => toggleViewTab("tree") }, { label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") }, { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") }, { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") }, ], Tools: [ { label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) }, { label: t("Files & Sizes…"), icon: I.folder, hint: t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs."), act: () => setModal({ kind: "explorer" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, hint: t("Set which binary asset types are exclusive-checkout (+l) so only one person edits them."), act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, hint: t("Named snapshots of file revisions: tag files, or sync to a label."), act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, hint: t("Move changes between branches of the depot."), act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) }, ...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })), ...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })), { label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) }, ], Help: [{ label: t("About"), icon: I.info, hint: t("Version and information about Exbyte Depot."), 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?.()} title={it.hint}> {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("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") }, { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(String(info?.clientRoot || "")) }, { label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("Copy name"), icon: I.copy, 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, [ { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(activePath) }, ...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []), ...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []), { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") }, ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []), { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) }, { label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) }, ...(activePath ? [{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(activePath) }] : []), ])}> {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")}
)}
{workOffline ? (
{I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
) : 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 })}
)} {behind && behind.count > 0 && !workOffline && (
{I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })} {behind.sample.length > 0 ? {behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""} : null}
)}
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…")} {scanCount > 0 && {t("found {n} files", { n: scanCount.toLocaleString() })}} {scanCount === 0 && !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…")}{scanCount > 0 && {t("found {n} files", { n: scanCount.toLocaleString() })}} : <> {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(); }} />