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, [
{ 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")} />
switchTab("changes")}>{t("Changes")} {uncommitted.length}
switchTab("history")}>{t("History")}
{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 })}
refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}{scanning ? t("Scanning…") : t("Refresh")}
{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”.")}
>}
) : (
setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
)}
>
) : (
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"}
setLogOpen((o) => !o)} title="Ctrl+L">{I.log}Log
{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 && }
{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}
))}
onChoose(path)}>
{path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
>);
})()}
{modal.kind === "settings" && (<>
{t("Language")}
{LANGS.map((l) => (
setLang(l.code)} title={l.name}>
{l.flag} {l.name}
))}
{t("Theme")} {light ? t("Light ☀") : t("Dark ☾")}
{t("Interface scale")}
setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title="−">−
setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%
setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+
{t("Server")} {info?.serverAddress || "—"}
{t("User")} {info?.userName || "—"}
{t("Workspace")} {info?.clientName || "—"}
{t("Server version")} {(info?.serverVersion as string || "").split("/")[2] || "—"}
{ onClose(); onDisconnect(); }}>{t("Disconnect")}
>)}
{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)) };
return (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
{ onClose(); it.act(); }}>{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}
{t("Clear")}
{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…"))}
);
}
/* ---------------- 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}
onClose(false)}>{t("Cancel")}
onClose(true)} autoFocus>{confirm}
);
}
/* ---------------- 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, checked, onSelect, onToggle }:
{ files: OpenedFile[]; sel: number | null; checked: Set; onSelect: (i: number) => void; onToggle: (dp: string) => 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(
onSelect(i)}>
{ e.stopPropagation(); onToggle(dp); }}>{I.check}
{name} {dir}
{st.ch}
);
}
return ;
}
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFile) => 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}
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}
;
}
/* ---------------- text diff ---------------- */
function DiffView({ file }: { file: OpenedFile }) {
const [text, setText] = useState(null);
const [err, setErr] = useState("");
const dp = file.depotFile || "";
useEffect(() => {
let live = true; setText(null); setErr("");
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
if (err) return {err}
;
if (text === null) return {t("Loading diff…")}
;
if (!text.trim()) return {I.check}{t("No text differences (or binary file).")}
;
const lines = text.split("\n");
return (
{lines.map((ln, i) => {
let cls = "";
if (ln.startsWith("@@")) cls = "hunk";
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
return
{ln || " "}
;
})}
);
}
/* ---------------- 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;
}