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" ? (
<>
{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”.")}
>}
) : (
)}
>
) : (
openCtx(e, [
{ label: t("Open this changelist"), act: () => openChange(c) },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
)}
{tab === "changes"
?
:
}
{logOpen &&
setLogOpen(false)} onClear={() => setLogs([])} />}
{(busy || scanning || !!transfer) ? : I.check}
{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}
{peek && logs.length > 0 && (
{t("Log")} · {logs.length}
{logs.slice(-40).map((l) =>
)}
)}
{dragging && (
{t("Drop to add to Perforce")}
)}
{modal && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && { ask.resolve(v); setAsk(null); }} />}
{showXfer && transfer && }
{buildOpen && !buildMin && setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{buildOpen && buildMin && setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
{ctx && setCtx(null)} />}
{toast && {toast.text}
}
);
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const X =
;
const folder =
;
const depotIc =
;
return (
e.stopPropagation()}>
{modal.kind === "workspaces" && (<>
{folder}
{t("Choose workspace")}
{X}
{modal.items.length === 0 &&
{t("No workspaces available")}
}
{modal.items.map((c) => (
onPickWorkspace(c.client || "")}>
{folder}
{c.client}{(c.Root as string) || ""}
))}
{t("Active: {c}", { c: modal.current || "—" })}
>)}
{modal.kind === "scope" && (() => {
const path = modal.path;
const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
const up = parts.length > 0;
return (<>
{depotIc}
{t("Working folder")}
{X}
onBrowse("")}>//
{parts.map((seg, i) => (
/ onBrowse("//" + parts.slice(0, i + 1).join("/"))}>{seg}
))}
{up && (
onBrowse(parentPath)}>
{t(".. up")}
)}
{modal.dirs.length === 0 &&
{t("No subfolders")}
}
{modal.dirs.map((d) => (
onBrowse(d.dir || "")}>
{folder}
{leaf(d.dir)}{d.dir}
))}
>);
})()}
{modal.kind === "settings" && (<>
{t("Language")}
{LANGS.map((l) => (
))}
{t("Theme")}
{t("Interface scale")}
{t("Server")}{info?.serverAddress || "—"}
{t("User")}{info?.userName || "—"}
{t("Workspace")}{info?.clientName || "—"}
{t("Server version")}{(info?.serverVersion as string || "").split("/")[2] || "—"}
>)}
{modal.kind === "about" && (<>
{depotIc}
Exbyte Depot
{X}
{t("Exbyte Depot — native Perforce client")}
Tauri + React · three.js · v0.1.0
{t("Built by Exbyte Studios")}
>)}
);
}
/* ---------------- right-click context menu ---------------- */
function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: CtxItem[]; onClose: () => void }) {
useEffect(() => {
const close = () => onClose();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("click", close);
document.addEventListener("scroll", close, true);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("click", close); document.removeEventListener("scroll", close, true); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const style: CSSProperties = { left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - (items.length * 36 + 14)) };
const hasIcon = items.some((it) => it.icon);
return (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
{ onClose(); it.act(); }}>
{hasIcon && {it.icon}}
{it.label}
))}
);
}
/* ---------------- Perforce-style command log ---------------- */
function logCount(l: LogEntry): string {
return l.ok ? `{${l.count} ${t("item(s)")}}` : `— ${l.err || "error"}`;
}
function LogRow({ l }: { l: LogEntry }) {
return (
{l.ok ? "✓" : "!"}
{l.cmd}
{logCount(l)}
);
}
function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () => void; onClear: () => void }) {
const ref = useRef
(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
return (
{I.log}Log{logs.length}
{logs.length === 0 ?
{t("No commands yet.")}
: logs.map((l) =>
)}
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
const up = transfer.op === "submit";
return (
{up
?
: }
{up ? t("Submitting to server") : t("Getting latest from server")}
{t("{n} files", { n: transfer.count })}
{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}
);
}
/* ---------------- build output (MSBuild) ---------------- */
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; onClose: () => void }) {
const ref = useRef(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
return (
{building ? : ok ? "✓" : ok === false ? "✗" : I.hammer}
{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}
{name}
{lines.map((ln, i) => {
const low = ln.toLowerCase();
const cls = /: error |\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : "";
return
{ln}
;
})}
{building && lines.length === 0 &&
{t("Starting MSBuild…")}
}
);
}
/* minimized build indicator — small floating chip, click to expand */
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
return (
{building ? : ok ? "✓" : "✗"}
{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); if (e.key === "Enter") onClose(true); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
onClose(false)}>
e.stopPropagation()}>
{title}
{body}
);
}
/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolean }) {
const ref = useRef(null);
const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500);
const fileRows = useMemo(() => (detail ? describeFiles(detail) : []), [detail]);
useEffect(() => {
const el = ref.current; if (!el) return;
const onScroll = () => setScroll(el.scrollTop);
el.addEventListener("scroll", onScroll);
const ro = new ResizeObserver(() => setH(el.clientHeight));
ro.observe(el); setH(el.clientHeight);
return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
}, [detail]);
// jump back to top whenever a different changelist loads
useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);
if (busy) return {t("Loading file list…")}
;
if (!detail) return {I.clock}{t("Select a changelist in History")}
;
const first = Math.max(0, Math.floor(scroll / CROW) - 6);
const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
const rows = [];
for (let i = first; i < last; i++) {
const f = fileRows[i];
const { name, dir } = splitPath(f.depotFile);
const st = statusOf(f.action);
rows.push(
{st.ch}
{name}{dir}#{f.rev}
);
}
return (
#{detail.change}
{(detail.desc || "").trim() || t("(no description)")}
{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}
);
}
/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return {t("Loading history…")}
;
if (!history.length) return {t("No submitted changes.")}
;
return (
{history.map((c) => (
onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
#{c.change}
{(c.desc || "").trim() || t("(no description)")}
{c.user} · {fmtTime(c.time)}
))}
);
}
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map(); // depotFile -> objectURL | null(no thumb)
function Thumb({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const kind = kindOf(name);
const [url, setUrl] = useState(() => thumbCache.get(dp) ?? null);
useEffect(() => {
if (kind !== "image" && kind !== "uasset") return;
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
let live = true;
p4.readDepot(dp).then((buf) => {
if (!live) return;
let u: string | null = null;
if (kind === "image") {
const ext = (name.split(".").pop() || "png").toLowerCase();
u = URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
} else {
const png = extractPng(new Uint8Array(buf));
if (png) u = URL.createObjectURL(new Blob([png], { type: "image/png" }));
}
thumbCache.set(dp, u);
if (live) setUrl(u);
}).catch(() => { thumbCache.set(dp, null); });
return () => { live = false; };
}, [dp]);
if (url) return
;
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
return {ic};
}
/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContext }:
{ files: OpenedFile[]; sel: number | null; selRows: Set; checked: Set; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) {
const ref = useRef(null);
const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500);
useEffect(() => {
const el = ref.current!;
const onScroll = () => setScroll(el.scrollTop);
el.addEventListener("scroll", onScroll);
const ro = new ResizeObserver(() => setH(el.clientHeight));
ro.observe(el); setH(el.clientHeight);
return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
}, []);
const first = Math.max(0, Math.floor(scroll / ROW) - 4);
const last = Math.min(files.length, Math.ceil((scroll + h) / ROW) + 4);
const rows = [];
for (let i = first; i < last; i++) {
const f = files[i];
const dp = f.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(f.action);
const isCk = checked.has(dp);
rows.push(
onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}>
{ e.stopPropagation(); onToggle(i, dp); }}>{I.check}
{name}{dir}
{st.ch}
);
}
return ;
}
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert, onFlash }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) {
if (!file) return {I.cube}{t("Select a file on the left")}
;
const dp = file.depotFile || "";
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
const kind = kindOf(name);
const rev = file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
return (
{st.ch}
{name} — {dir}
{file.action} · {rev} · {file.type}
{kind === "code" && (
{ try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
{I.vscode}{t("Edit in VS Code")}
)}
onRevert(file)}>{I.revert}{t("Revert")}
{kind === "model" ?
: kind === "image" ?
: kind === "uasset" ?
:
}
);
}
function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
return {k}{v}
;
}
/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name }: { depot: string; name: string }) {
const [url, setUrl] = useState(null);
const [err, setErr] = useState("");
const [dim, setDim] = useState("");
useEffect(() => {
let u = ""; let live = true;
p4.readDepot(depot).then((buf) => {
if (!live) return;
const ext = (name.split(".").pop() || "png").toLowerCase();
const mime = ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}`;
u = URL.createObjectURL(new Blob([buf], { type: mime }));
setUrl(u);
}).catch((e) => live && setErr(String(e)));
return () => { live = false; if (u) URL.revokeObjectURL(u); };
}, [depot, name]);
return (
{t("Image")}
{err ?
{err}
: url ?

setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
:
{t("Loading…")}
}
);
}
/* ---------------- .uasset — extract embedded Unreal thumbnail (PNG) ---------------- */
function UassetPreview({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const [thumb, setThumb] = useState(null);
const [tried, setTried] = useState(false);
useEffect(() => {
let live = true; let url = "";
setThumb(null); setTried(false);
p4.readDepot(dp).then((buf) => {
if (!live) return;
const png = extractPng(new Uint8Array(buf));
if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
setTried(true);
}).catch(() => live && setTried(true));
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [dp]);
return (
{thumb ? (<>
{I.cube}{t("Unreal · embedded thumbnail")}

>) : (
{I.cube}
{name}
{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}
)}
);
}
// scan raw bytes for an embedded PNG and return just that PNG (sig → IEND+crc)
function extractPng(b: Uint8Array): Uint8Array | null {
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let i = 0; i < b.length - 8; i++) {
let ok = true;
for (let k = 0; k < 8; k++) if (b[i + k] !== sig[k]) { ok = false; break; }
if (!ok) continue;
for (let j = i + 8; j < b.length - 8; j++) {
if (b[j] === 0x49 && b[j + 1] === 0x45 && b[j + 2] === 0x4e && b[j + 3] === 0x44) return b.slice(i, j + 8);
}
return b.slice(i);
}
return null;
}