Files
exbyte-depot-viewer-perforce/src/App.tsx
Bonchellon 8e7908ae09 0.3.6 — plugin registry, Unreal extracted to a plugin, in-app update check + fixes
- Official plugin registry hosted on Gitea; client fetches registry.json over
  HTTPS and installs plugins via backend plugin_write_file.
- Auto-install autoInstall plugins on first connect; per-machine enabled state.
- Extract all Unreal tooling (Build Solution / Unreal Log) into a separate
  "unreal" plugin (defaultEnabled) driven through the new host.ue bridge;
  core UI no longer hard-wires Unreal.
- Settings → Plugins: "Official plugins" section with Install buttons.
- Status bar: "Updates" button (right of Log) triggers an update check without
  restarting the client.
- Fixes: NEW badge hard-right/centered without shifting text; History commit
  size no longer flickers (cached per change); giant icons in empty plugin/
  People lists; Get Latest hover contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:13:02 +03:00

4032 lines
258 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="wctrls">
<button className="wbtn" onClick={() => w.minimize()} title={t("Minimize")}><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
<button className="wbtn" onClick={() => w.toggleMaximize()} title={t("Maximize")}><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
<button className="wbtn close" onClick={() => w.close()} title={t("Close")}><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
</div>
);
}
/* ---------------- icons ---------------- */
const I = {
hex: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.6"/></svg>,
monitor: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
branch: <svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 0a9 9 0 0 1-9 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
sync: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5M20 12a8 8 0 0 1-14 5.3L3 15M3 20v-5h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
chev: <svg viewBox="0 0 24 24" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
search: <svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
check: <svg viewBox="0 0 24 24" fill="none"><path d="m5 12 5 5L20 7" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
moon: <svg viewBox="0 0 24 24" fill="none"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"/></svg>,
sun: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.7"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
arrow: <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
spinner: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.3)" strokeWidth="2"/><path d="M12 3a9 9 0 0 1 9 9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/></svg>,
cube: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.5"/></svg>,
revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
back: <svg viewBox="0 0 24 24" fill="none"><path d="M19 12H5M11 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
chevron: <svg viewBox="0 0 24 24" fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
spark: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M18.5 15.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z" fill="currentColor"/></svg>,
people: <svg viewBox="0 0 24 24" fill="none"><circle cx="9" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 6.1M17.5 14.2A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
terminal: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m7 9 3 3-3 3M13 15h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
hammer: <svg viewBox="0 0 24 24" fill="none"><path d="M14 6l4 4M17 3l4 4-3 3-4-4 3-3ZM13 7 4 16a2 2 0 0 0 0 3l1 1a2 2 0 0 0 3 0l9-9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
lock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>,
unlock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
shelf: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
folder: <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>,
power: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v9M7.5 6.5a7 7 0 1 0 9 0" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
up: <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
gear: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3"/></svg>,
info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
trash: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M18 7l-.8 12a2 2 0 0 1-2 1.9H8.8a2 2 0 0 1-2-1.9L6 7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 11v5M14 11v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
copy: <svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
reveal: <svg viewBox="0 0 24 24" fill="none"><path d="M3 8a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 8v9a2 2 0 0 0 2 2h13.5a1.5 1.5 0 0 0 1.46-1.14L21.5 12H8.2a1.5 1.5 0 0 0-1.46 1.14L5 20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
move: <svg viewBox="0 0 24 24" fill="none"><path d="M14 5h5a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5M3 12h11M10 8l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
pencil: <svg viewBox="0 0 24 24" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
down: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
/* ---------------- 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>(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<P4Info | null>(null);
const [session, setSession] = useState<Session | null>(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<void>((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 = <Splash light={light} toggleTheme={toggleTheme} />;
else if (phase === "connect")
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <>{content}<UpdateBanner /></>;
}
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
function Select({ value, options, onChange, icon, placeholder }:
{ value: string; options: { value: string; label: string; sub?: string }[]; onChange: (v: string) => void; icon?: ReactNode; placeholder?: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
}, []);
const cur = options.find((o) => o.value === value);
return (
<div className={"xsel" + (open ? " open" : "")} ref={ref}>
<button type="button" className="xsel-trig" onClick={() => setOpen((o) => !o)}>
{icon}
<span className="xsel-val">{cur ? cur.label : <span className="xsel-ph">{placeholder}</span>}</span>
<span className="xsel-chev">{I.chev}</span>
</button>
{open && (
<div className="xsel-list">
{options.length === 0 && <div className="xsel-empty">{placeholder}</div>}
{options.map((o) => (
<div key={o.value} className={"xsel-item" + (o.value === value ? " on" : "")} onClick={() => { onChange(o.value); setOpen(false); }}>
<span className="xsel-ic">{o.value === value ? I.check : null}</span>
<span className="xsel-body"><span className="xsel-name">{o.label}</span>{o.sub && <span className="xsel-sub">{o.sub}</span>}</span>
</div>
))}
</div>
)}
</div>
);
}
/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */
function Logo() {
return (
<svg className="exlogo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<defs>
<linearGradient id="exG" x1="6" y1="24" x2="26" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#8b6cf6" />
<stop offset="1" stopColor="#49a6ff" />
</linearGradient>
</defs>
<g stroke="url(#exG)" strokeWidth="2.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 16 H10 L14.5 9.5 H20" />
<path d="M10 16 L14.5 22.5 H20" />
<circle cx="22.5" cy="9.5" r="3" />
<circle cx="22.5" cy="22.5" r="3" />
</g>
<g stroke="url(#exG)" strokeWidth="1.9" strokeLinecap="round" opacity="0.5">
<path d="M15 14 H19" />
<path d="M15 16 H20" />
<path d="M15 18 H18" />
</g>
</svg>
);
}
/* ---------------- animated startup splash ---------------- */
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
const [step, setStep] = useState(0);
useEffect(() => {
const id = setInterval(() => setStep((s) => s + 1), 750);
return () => clearInterval(id);
}, []);
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="splash">
<div className="splash-logo">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
</div>
<div className="flow">
<div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
<div className="wire">
{[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
</div>
<div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
</div>
<div className="splash-title">Exbyte&nbsp;Depot</div>
<div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
<div className="splash-bar"><span /></div>
</div>
</div>
</div>
);
}
/* ---------------- connect screen ---------------- */
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
const [user, setUser] = useState(initial?.user || "Bonchellon");
const [password, setPassword] = useState("");
const [client, setClient] = useState(initial?.client || "");
const [clients, setClients] = useState<Client[]>([]);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
async function loadClients() {
try {
const cs = await p4.clients(server, user);
setClients(cs);
if (cs.length && !client) setClient(cs[0].client || "");
} catch { /* ignore */ }
}
// auto-load the workspace list as soon as the screen opens
useEffect(() => { loadClients(); /* eslint-disable-next-line */ }, []);
async function connect() {
setBusy(true); setErr("");
try {
const i = await p4.login(server, user, password, client);
onDone(i, { server, user, client });
} catch (e) { setErr(String(e)); }
finally { setBusy(false); }
}
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="overlay">
<div className="card">
<div className="clogo">{I.cube}</div>
<h2>{t("Connect to Perforce")}</h2>
<div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>
<div className="fld"><label>{t("Server")}</label>
<div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
<div className="fld"><label>{t("User")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
<input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
<div className="fld"><label>{t("Workspace")}</label>
{clients.length ? (
<Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
icon={<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>}
options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
) : (
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
<input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
</div>
)}</div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<button className="connect" disabled={busy || !password} onClick={connect}>
{busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
</button>
</div>
</div>
</div>
);
}
/* ---------------- 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<Tab>("changes");
const [modal, setModal] = useState<ModalState | null>(null);
const [, setPluginVer] = useState(0); // bumped when the plugin registry changes → re-render menus
const [pluginView, setPluginView] = useState<PluginView | null>(null); // open plugin view modal
const pluginsLoaded = useRef(false);
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
const [files, setFiles] = useState<OpenedFile[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [sel, setSel] = useState<number | null>(null); // primary row (preview + shift anchor)
const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
const [previewCL, setPreviewCL] = useState<OpenedFile | null>(null); // preview override (pending-CL file)
const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => 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<string | null>(null);
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
const sizesRef = useRef<Record<string, number>>({}); // mirror of changeSizes so we only fetch new changes (no flicker)
const histSizeScope = useRef<string>(""); // scope the cached sizes belong to
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
const [histFile, setHistFile] = useState<OpenedFile | null>(null); // drilled-into submitted file (read-only preview)
const [dragging, setDragging] = useState(false);
const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
const [transfer, setTransfer] = useState<Transfer | null>(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<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // 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<string>(""); // newest submitted CL seen (new-submit toast)
const notifPrimed = useRef(false); // don't toast on the very first poll
const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
const [building, setBuilding] = useState(false);
const [buildOk, setBuildOk] = useState<boolean | null>(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 | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
const [logs, setLogs] = useState<LogEntry[]>([]); // 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<ViewTab[]>(() => {
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<ViewTab>("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<number>(() => { 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<TermLine[]>([]); // terminal output blocks
const [termInput, setTermInput] = useState("");
const [cmdHist, setCmdHist] = useState<string[]>([]); // 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<number | null>(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<number>(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; });
const [syncW, setSyncW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; });
const [histW, setHistW] = useState<number>(() => { 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<boolean> {
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<string>();
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<string, { user: string; locked: boolean }>();
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<string, unknown>).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<Set<string>>(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<number>();
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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => 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 (
<div className="win" style={{ "--left-w": `${leftW}px`, "--right-zone-w": `${syncW}px` } as CSSProperties}>
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
{t(name)}
<div className="dropdown">
{items.map((it) => (
<div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()} title={it.hint}>
{it.icon && <span className="di-ic">{it.icon}</span>}
{it.label}{it.kb && <span className="kb">{it.kb}</span>}
</div>
))}
</div>
</div>
))}
</div>
<div className="conn-pill" title={t("Connected server")}><span className="dot" />{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</div>
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
<div className="toolbar">
<div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
onContextMenu={(e) => 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")) },
])}>
<span className="ic">{I.monitor}</span>
<span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
<span className="chev">{I.chev}</span>
</div>
<div className="tgroup">
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => 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) }] : []),
])}>
<span className="ic">{I.branch}</span>
<span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
<span className="chev">{I.chev}</span>
</div>
{pending.length > 0 ? (
<div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="tzmeta"><span className="val">{t("Submit to server")}</span><span className="sub">{t("{n} changelist(s) → push", { n: pending.length })}</span></span>
</div>
) : (
<div className={"tzone sync" + (busy ? " busy" : "")} onClick={getLatest}>
<span className="ic">{I.sync}</span>
<span className="tzmeta"><span className="val">{t("Get Latest")}</span><span className="sub">{busy ? t("Working…") : t("pull from server")}</span></span>
</div>
)}
</div>
</div>
{workOffline ? (
<div className="netbar offmode">
{I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
<button className="netbar-btn" onClick={() => setModal({ kind: "reconcile" })}>{t("Reconcile…")}</button>
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Go online")}</button>
</div>
) : offline && (
<div className="netbar off">
<span className="ldr sm" />{t("Disconnected from the server — retrying…")}
<button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
</div>
)}
{needResolve.length > 0 && tab === "changes" && (
<div className="netbar warn" onClick={() => setResolveOpen(true)}>
{I.hex}{t("{n} file(s) need conflict resolution after sync.", { n: needResolve.length })}
<button className="netbar-btn">{t("Resolve…")}</button>
</div>
)}
{behind && behind.count > 0 && !workOffline && (
<div className="netbar behind" onClick={getLatest} title={behind.sample.join(", ")}>
{I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })}
{behind.sample.length > 0 ? <span className="behind-names">{behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""}</span> : null}
<button className="netbar-btn">{t("Get Latest")}</button>
</div>
)}
<div className="main">
<div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
<div className="left">
<div className="tabs">
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
<span className="tab-slider" style={{ left: tab === "changes" ? "16px" : "calc(50% + 16px)" }} />
</div>
{tab === "changes" ? (
<div className="tabpane" key="changes">
<div className="filter">
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
</div>
{pendingCards.length > 0 && (
<div className="pending">
<div className="pending-h">
<span className="pending-t"><span className="pushdot" />{t("Pending · ready to Submit")}</span>
<button className="pushall" onClick={pushAll} title={t("Submit all to the server")}>{t("Submit all")} </button>
</div>
{pendingCards.map(({ cl, files: cf }) => {
const ch = cl.change || "";
const open = expandedCL.has(ch);
return (
<div key={ch} className="clcard">
<div className="clcard-h" onClick={() => toggleCL(ch)}>
<span className={"cl-chev" + (open ? " open" : "")}>{I.chev}</span>
<span className="cl-num">#{ch}</span>
<span className="cl-desc">{(cl.desc || "").trim() || t("(no description)")}</span>
<span className="cl-count">{t("{n} files", { n: cf.length })}</span>
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}></button>
<button className="cl-more" onClick={(e) => openCtx(e, [
{ label: t("Edit description"), icon: I.pencil, act: () => editDesc(cl) },
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
{ label: t("Unshelve into workspace"), icon: I.down, act: () => unshelveCL(ch) },
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
{ label: t("Delete shelved files"), icon: I.trash, danger: true, act: () => deleteShelfCL(ch) },
{ label: t("Uncommit (back to working)"), icon: I.back, act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), icon: I.trash, danger: true, act: () => discard(cf) },
])} title={t("More")}></button>
</span>
</div>
{open && (
<div className="cl-files">
{cf.length === 0 && <div className="cl-empty">{t("(files are outside the current working folder)")}</div>}
{cf.map((f) => {
const sp = splitPath(f.depotFile);
const st = statusOf(f.action);
return (
<div key={f.depotFile} className={"cl-file" + (previewCL?.depotFile === f.depotFile ? " sel" : "")} onClick={() => setPreviewCL(f)}>
<span className={"stat " + st.cls}>{st.ch}</span>
<span className="cl-fname"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{scanning && (
<div className="scanbar">
<span className="ldr" />
<span>
{t("Scanning disk for changes…")}
{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}
{scanCount === 0 && !activePath && t(" for the whole workspace this is slow — pick a project folder above")}
</span>
<button className="scan-cancel" onClick={() => { p4.scanCancel().catch(() => {}); }} title={t("Stop the scan. Files already found stay staged.")}>{t("Cancel")}</button>
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " off")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
</button>
</div>
{view.length === 0 ? (
<div className="empty">
{scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}</> : <>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
{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”.")}
</>}
</div>
) : (
<FileList files={view} sel={sel} selRows={selRows} checked={checked} others={others} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
)}
<div className="commit">
<div className="summrow">
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<button className={"aibtn icon" + (aiBusy ? " busy" : "")} disabled={aiBusy || checked.size === 0} onClick={genAiSummary}
title={t("Draft a commit message with AI from the selected files")}>
{aiBusy ? <span className="ldr sm" /> : I.spark}
</button>
</div>
<textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
<button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
{commitProg ? (
<><span className="ldr sm" />{t("Committing…")} <b>{commitProg.count.toLocaleString()} / {commitProg.total.toLocaleString()}</b></>
) : (
<>{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b></>
)}
</button>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
</div>
) : (
<div className="tabpane" key="history">
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
{ label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
{ label: t("Revert (undo) #{n}", { n: c.change || "" }), icon: I.revert, danger: true, act: () => revertChange(c) },
{ label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
</div>
)}
</div>
<div className="viewpanel">
{viewTabs.length > 1 && (
<div className="vtabs">
{viewTabs.map((k) => (
<div key={k} className={"vtab" + (viewTab === k ? " on" : "")} onClick={() => setViewTab(k)}>
<span className="vtab-ic">{k === "viewer" ? FILE_GLYPH : k === "tree" ? I.folder : I.lock}</span>
{k === "viewer" ? t("File Viewer") : k === "tree" ? t("File Tree") : t("File Locks")}
<span className="vtab-x" onClick={(e) => { e.stopPropagation(); closeViewTab(k); }} title={t("Close")}>×</span>
</div>
))}
</div>
)}
<div className="viewbody">
{viewTabs.length === 0 && <div className="empty">{I.folder}{t("No panel open.\nAdd File Viewer or File Tree from the Window menu.")}</div>}
{viewTabs.includes("viewer") && (
<div className="vpane" style={{ display: viewTab === "viewer" ? "flex" : "none" }}>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
{histFile && (<>
<div className="vhandle histh" title={t("Drag to resize")}
onPointerDown={(e) => {
e.preventDefault();
const startX = e.clientX, start = histW;
document.body.classList.add("resizing");
const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
// mouse fallback in case pointer events don't fire in the webview
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // released outside the window → end drag
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
</>)}
</div>
)}
</div>
)}
{viewTabs.includes("tree") && (
<div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
<FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} onNewFolder={(side) => newFolder(side === "depot" ? activePath : "")} onFolderMenu={folderMenu} initialSide={treeSide} sideNonce={treeNonce} />
</div>
)}
{viewTabs.includes("locks") && (
<div className="vpane tree" style={{ display: viewTab === "locks" ? "flex" : "none" }}>
<LocksPanel me={info?.userName || ""} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />
</div>
)}
</div>
</div>
</div>
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
height={dockH} onResize={startDockResize}
logs={logs} onClearLogs={() => setLogs([])}
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
<span className={"stdot" + ((busy || scanning || !!transfer) ? " busy" : "")}>{(busy || scanning || !!transfer) ? <span className="ldr sm" /> : I.check}</span>
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span>
<span className="stspace" />
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
<button className="stlog" onClick={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
<button className="stlog" onClick={() => window.dispatchEvent(new Event("exd-check-update"))} title={t("Check for updates")}>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span>{t("Updates")}</span>
</button>
{peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<div className="logpeek-h">{t("Log")} · {logs.length}</div>
{logs.slice(-40).map((l) => <LogRow key={l.id} l={l} />)}
</div>
)}
</div>
{dragging && (
<div className="dropzone">
<div className="dropinner">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
<span>{t("Drop to add to Perforce")}</span>
</div>
</div>
)}
{modal && modal.kind !== "users" && modal.kind !== "settings" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "settings" && <SettingsModal section={modal.section} info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom} editors={editors} editorId={effEditorId} onPickEditor={chooseEditor} onReloadPlugins={reloadPlugins} onDisconnect={onDisconnect} onFlash={flash} onClose={() => setModal(null)} />}
{pluginView && <PluginViewModal view={pluginView} onClose={() => setPluginView(null)} />}
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
{modal?.kind === "folderprops" && <FolderPropsModal path={modal.path} name={modal.name} onClose={() => setModal(null)} onReveal={reveal} onRename={() => { const p = modal.path; setModal(null); renameFolder(p); }} onDelete={() => { const p = modal.path; setModal(null); deleteFolder(p); }} onCopy={(p) => copyText(p, t("Path"))} />}
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={async (c) => { const wasNew = modal.isNew; setModal(null); if (wasNew) { switchWorkspace(c); return; } await refresh(); if (await confirm({ title: t("Workspace mapping changed"), body: t("Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync."), confirm: t("Get Latest") })) getLatest(); }} />}
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
{showXfer && transfer && !xferMin && <TransferDialog transfer={transfer} onCancel={cancelTransfer} onMinimize={() => setXferMin(true)} />}
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
</div>
);
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => 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 = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;
const folder = <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6" /></svg>;
const depotIc = <svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" /><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6" /></svg>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
{modal.kind === "workspaces" && (<>
<div className="picker-head">{folder}<h3>{t("Choose workspace")}</h3>{X}</div>
<div className="picker-list">
{modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>{t("No workspaces available")}</div>}
{modal.items.map((c) => (
<div key={c.client} className={"picker-item" + (c.client === modal.current ? " on" : "")} onClick={() => onPickWorkspace(c.client || "")}>
<span className="pi-ic">{folder}</span>
<span className="pi-body"><span className="pi-name">{c.client}</span><span className="pi-sub">{(c.Root as string) || ""}</span></span>
</div>
))}
</div>
<div className="picker-foot">{t("Active: {c}", { c: modal.current || "—" })}</div>
</>)}
{modal.kind === "scope" && (() => {
const path = modal.path;
const recents = recentScopes(info?.clientName || "");
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 (<>
<div className="picker-head">{depotIc}<h3>{t("Working folder")}</h3>{X}</div>
<div style={{ padding: "10px 16px", borderBottom: "1px solid var(--border-soft)" }}>
<div className="crumb">
<span className="seg" onClick={() => onBrowse("")}>//</span>
{parts.map((seg, i) => (
<span key={i}><span className="sep">/</span><span className="seg" onClick={() => onBrowse("//" + parts.slice(0, i + 1).join("/"))}>{seg}</span></span>
))}
</div>
</div>
<div className="picker-list">
{recents.length > 0 && (
<div className="pick-recent">
<div className="pick-recent-h">{t("Recently")}</div>
{recents.map((rp) => (
<div key={rp} className="picker-item" onClick={() => onChoose(rp)}>
<span className="pi-ic clock"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" /><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg></span>
<span className="pi-body"><span className="pi-name">{leaf(rp)}</span><span className="pi-sub">{rp}</span></span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
))}
</div>
)}
{up && (
<div className="picker-item" onClick={() => onBrowse(parentPath)}>
<span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<span className="pi-body"><span className="pi-name up">{t(".. up")}</span></span>
</div>
)}
{modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>{t("No subfolders")}</div>}
{modal.dirs.map((d) => (
<div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
<span className="pi-ic">{folder}</span>
<span className="pi-body"><span className="pi-name">{leaf(d.dir)}{d.local ? <span className="local-tag">{t("local")}</span> : null}</span><span className="pi-sub">{d.dir}</span></span>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
</div>
))}
</div>
<button className="scope-choose" onClick={() => onChoose(path)}>
{path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
</button>
</>);
})()}
{modal.kind === "settings" && (<>
<div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
<div className="info-body">
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</div>
</div>
<div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Interface scale")}</span>
<div className="zoomsel">
<button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title=""></button>
<button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Code editor")}</span>
<select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
{editors.length === 0 && <option value="default">{t("System default")}</option>}
{editors.map((ed) => (
<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>
))}
</select>
</div>
<div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
</div>
</>)}
{modal.kind === "about" && (<>
<div className="picker-head">{depotIc}<h3>Exbyte Depot</h3>{X}</div>
<div className="info-body" style={{ textAlign: "center" }}>
<div style={{ width: 60, height: 60, margin: "6px auto 14px", borderRadius: 16, background: "linear-gradient(145deg,var(--accent-2),var(--accent-deep))", display: "grid", placeItems: "center", boxShadow: "0 0 30px rgba(124,110,246,.5)" }}>
<svg viewBox="0 0 24 24" width="30" height="30" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round" /><path d="m3 7 9 5 9-5M12 12v10" stroke="#fff" strokeWidth="1.5" /></svg>
</div>
<p style={{ marginBottom: 8 }}>{t("Exbyte Depot — native Perforce client")}</p>
<p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />{t("Built by Exbyte Studios")}</p>
</div>
</>)}
</div>
</div>
);
}
/* ---------------- avatar (initials, deterministic color, activity dot) ---------------- */
function Avatar({ user, name, size = 30, act }: { user: string; name?: string; size?: number; act?: "active" | "recent" | "idle" }) {
return (
<span className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * 0.38), background: avatarColor(user || name || "?") }} title={name ? `${name} (${user})` : user}>
{initials(name || "", user)}
{act && <span className={"adot " + act} />}
</span>
);
}
/* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */
function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) {
const [users, setUsers] = useState<User[]>([]);
const [groups, setGroups] = useState<string[]>([]);
const [busy, setBusy] = useState(true);
const [err, setErr] = useState("");
const [sel, setSel] = useState(""); // selected user id
const [myGroups, setMyGroups] = useState<string[]>([]); // groups of the selected user
const [gBusy, setGBusy] = useState(false);
const [q, setQ] = useState("");
const selSeq = useRef(0); // drop a slow groups() response if another user was picked
useEffect(() => {
let live = true;
(async () => {
try {
const [us, gs] = await Promise.all([p4.users(), p4.groups()]);
if (!live) return;
setUsers(us);
setGroups(gs.map((g) => g.group || "").filter(Boolean));
} catch (e) { if (live) setErr(String(e)); }
finally { if (live) setBusy(false); }
})();
return () => { live = false; };
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
async function selectUser(u: string) {
const seq = ++selSeq.current;
setSel(u); setMyGroups([]); setGBusy(true);
try { const g = await p4.groups(u); if (seq === selSeq.current) setMyGroups(g.map((x) => x.group || "").filter(Boolean)); }
catch (e) { if (seq === selSeq.current) onFlash(String(e), true); }
finally { if (seq === selSeq.current) setGBusy(false); }
}
async function toggleGroup(group: string) {
if (!sel) return;
const isMember = myGroups.includes(group);
setGBusy(true);
try {
await p4.groupMember(group, sel, !isMember);
setMyGroups((cur) => isMember ? cur.filter((x) => x !== group) : [...cur, group]);
onFlash(isMember ? t("Removed {u} from “{g}”.", { u: sel, g: group }) : t("Added {u} to “{g}”.", { u: sel, g: group }));
} catch (e) {
const msg = String(e);
onFlash(/permission|protect|admin|super|not allowed/i.test(msg) ? t("You need admin rights on the server to change roles.") : msg, true);
} finally { setGBusy(false); }
}
const s = q.trim().toLowerCase();
const filtered = users.filter((u) => !s || (u.User || "").toLowerCase().includes(s) || (u.FullName || "").toLowerCase().includes(s) || (u.Email || "").toLowerCase().includes(s));
const selUser = users.find((u) => u.User === sel);
const X = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.people}<h3>{t("People & Roles")}</h3>
<span className="ph-sub">{t("{n} on this depot", { n: users.length })}</span>{X}</div>
{err ? <div className="viewer-msg err" style={{ position: "static", padding: 24 }}>{err}</div>
: busy ? <div className="viewer-msg" style={{ position: "static", padding: 28 }}><span className="ldr" />{t("Loading users…")}</div>
: (
<div className="ur-body">
<div className="ur-list">
<div className="ur-filter">{I.search}<input placeholder={t("Filter people…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="ur-scroll">
{filtered.map((u) => {
const act = activity(u.Access);
return (
<div key={u.User} className={"ur-row" + (sel === u.User ? " on" : "")} onClick={() => selectUser(u.User || "")}>
<Avatar user={u.User || ""} name={u.FullName} act={act} />
<span className="ur-meta">
<span className="ur-name">{u.FullName || u.User}{u.User === me && <span className="ur-you">{t("you")}</span>}</span>
<span className="ur-sub">{u.Email || u.User}</span>
</span>
<span className={"ur-actd " + act} title={act} />
</div>
);
})}
{filtered.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
</div>
</div>
<div className="ur-detail">
{!selUser ? <div className="viewer-msg" style={{ position: "static", padding: 28, flexDirection: "column" }}>{I.people}<span>{t("Pick a person to see and edit their roles.")}</span></div>
: (<>
<div className="ur-dhead">
<Avatar user={selUser.User || ""} name={selUser.FullName} size={46} act={activity(selUser.Access)} />
<div className="ur-dinfo">
<div className="ur-dname">{selUser.FullName || selUser.User}</div>
<div className="ur-dsub">{selUser.Email || "—"}{selUser.Access ? " · " + t("active {t}", { t: fmtTime(selUser.Access) }) : ""}</div>
</div>
</div>
<div className="ur-grouphd"><span>{t("Roles / groups")}</span>{gBusy && <span className="ldr sm" />}</div>
<div className="ur-groups">
{groups.length === 0 && <div className="ur-empty">{t("No groups defined on the server.")}</div>}
{groups.map((g) => {
const on = myGroups.includes(g);
return (
<button key={g} className={"ur-chip" + (on ? " on" : "")} disabled={gBusy} onClick={() => toggleGroup(g)}>
<span className="urc-box">{on ? I.check : null}</span>{g}
</button>
);
})}
</div>
<div className="ai-hint">{t("Toggling a group grants or revokes that role. Requires admin rights on the server.")}</div>
</>)}
</div>
</div>
)}
</div>
</div>
);
}
/* ---------------- 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 (
<div className="ctxmenu" style={style} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>
{hasIcon && <span className="ctx-ic">{it.icon}</span>}
<span>{it.label}</span>
</div>
))}
</div>
);
}
/* ---------------- 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 (
<div className={"logrow" + (l.ok ? "" : " err")} title={l.err || ""}>
<span className="logst">{l.ok ? "✓" : "!"}</span>
<span className="logcmd">{l.cmd}</span>
<span className="logcount">{logCount(l)}</span>
</div>
);
}
// common p4 subcommands for the terminal's autocomplete
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void;
logs: LogEntry[]; onClearLogs: () => void;
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
}) {
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
{ key: "log", label: t("Log"), icon: I.log },
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
];
return (
<div className="dockpanel" style={{ flexBasis: height, height }}>
<div className="dockhandle" onMouseDown={onResize} title={t("Drag to resize")} />
<div className="dockhead">
<div className="docktabs">
{tabs.map((tb) => (
<button key={tb.key} className={"docktab" + (tab === tb.key ? " on" : "")} onClick={() => setTab(tb.key)}>
{tb.icon}<span>{tb.label}</span>
{tb.key === "log" && logs.length > 0 && <span className="logn">{logs.length}</span>}
</button>
))}
</div>
<span className="dockspace" />
{(tab === "log" || tab === "terminal") && <button className="loghbtn" onClick={tab === "log" ? onClearLogs : onClearTerm}>{t("Clear")}</button>}
<button className="loghbtn x" onClick={onClose}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="dockpane" key={tab}>
{tab === "log" && <LogTab logs={logs} />}
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
</div>
</div>
);
}
function LogTab({ logs }: { logs: LogEntry[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
return (
<div className="logbody" ref={ref}>
{logs.length === 0 ? <div className="logempty">{t("No commands yet.")}</div>
: logs.map((l) => <LogRow key={l.id} l={l} />)}
</div>
);
}
/* p4 console with command autocomplete + ↑/↓ history */
function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
lines: TermLine[]; input: string; setInput: (s: string) => void; busy: boolean; onRun: (cmd: string) => void; cmdHist: string[];
}) {
const bodyRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [hi, setHi] = useState(-1); // history cursor (-1 = live input)
const [sugIdx, setSugIdx] = useState(0);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [lines, busy]);
useEffect(() => { setSugIdx(0); }, [input]);
// suggest only while typing the first bare word (the p4 subcommand)
const firstWord = input.trim().split(/\s+/)[0] || "";
const typing = input.trim() === firstWord && firstWord.length > 0 && !input.endsWith(" ");
const sugs = typing ? P4_COMMANDS.filter((c) => c.startsWith(firstWord.toLowerCase()) && c !== firstWord.toLowerCase()).slice(0, 8) : [];
function accept(cmd: string) { setInput(cmd + " "); inputRef.current?.focus(); }
function onKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
if (sugs.length) {
if (e.key === "Tab") { e.preventDefault(); accept(sugs[sugIdx] || sugs[0]); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSugIdx((i) => (i + 1) % sugs.length); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSugIdx((i) => (i - 1 + sugs.length) % sugs.length); return; }
}
if (e.key === "Enter") { e.preventDefault(); if (!busy) onRun(input); setHi(-1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); const ni = hi < 0 ? cmdHist.length - 1 : Math.max(0, hi - 1); if (cmdHist[ni] != null) { setHi(ni); setInput(cmdHist[ni]); } return; }
if (e.key === "ArrowDown") { e.preventDefault(); if (hi < 0) return; const ni = hi + 1; if (ni >= cmdHist.length) { setHi(-1); setInput(""); } else { setHi(ni); setInput(cmdHist[ni]); } return; }
}
return (
<div className="term">
<div className="termbody" ref={bodyRef}>
{lines.length === 0 && <div className="termhint">{t("p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.")}</div>}
{lines.map((ln) => (
<div key={ln.id} className="termblock">
{ln.cmd && <div className="termcmd"><span className="termprompt">p4&gt;</span> {ln.cmd}</div>}
{ln.running ? <div className="termout run"><span className="ldr sm" /> {t("running…")}</div>
: <pre className={"termout" + (ln.err ? " err" : "")}>{ln.text}</pre>}
</div>
))}
</div>
<div className="terminput-wrap">
<span className="termprompt">p4&gt;</span>
<input ref={inputRef} className="terminput" value={input} placeholder={t("type a p4 command…")} spellCheck={false} autoFocus
onChange={(e) => setInput(e.target.value)} onKeyDown={onKeyDown} />
{busy && <span className="ldr sm" />}
{sugs.length > 0 && (
<div className="termsug">
{sugs.map((s, i) => (
<div key={s} className={"termsug-i" + (i === sugIdx ? " on" : "")} onMouseDown={(e) => { e.preventDefault(); accept(s); }}>
<b>{firstWord}</b>{s.slice(firstWord.length)}
</div>
))}
</div>
)}
</div>
</div>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer, onCancel, onMinimize }: { transfer: Transfer; onCancel: () => void; onMinimize: () => void }) {
const up = transfer.op === "submit";
const total = transfer.total || 0;
const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [transfer.log.length]);
return (
<div className="modal-back xfer-back">
<div className="xfer">
<div className="xfer-head">
<span className={"xfer-ic" + (up ? " up" : " down")}>
{up
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
</span>
<div className="xfer-ttl">
<b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
<span>{total > 0
? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%`
: t("{n} files", { n: transfer.count })}</span>
</div>
<div className="xfer-actions">
{up && (
<button className="xfer-cancel" onClick={onCancel} disabled={transfer.cancelling}
title={t("Stop before the server commit. Files already sent are not applied; the changelist stays pending.")}>
{transfer.cancelling ? t("Cancelling…") : t("Cancel")}
</button>
)}
<button className="xfer-min" onClick={onMinimize} title={t("Minimize — the transfer keeps running in the background")}>
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
</div>
</div>
<div className={"xfer-bar" + (total > 0 ? " det" : "")}>
<span style={total > 0 ? { width: pct + "%" } : undefined} />
</div>
{transfer.log.length > 0
? <div className="xfer-log" ref={logRef}>{transfer.log.map((f, i) => <div key={i}>{f}</div>)}</div>
: <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>}
</div>
</div>
);
}
/* minimized transfer indicator — small floating chip; the transfer keeps running.
Click to expand back to the full dialog. */
function TransferChip({ transfer, onExpand, onCancel }: { transfer: Transfer; onExpand: () => void; onCancel: () => void }) {
const up = transfer.op === "submit";
const total = transfer.total || 0;
const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
return (
<div className={"xferchip" + (up ? " up" : " down")} onClick={onExpand} title={t("Expand")}>
<span className="xc-ic"><span className="ldr sm" /></span>
<span className="xc-txt">
<b>{up ? t("Submitting") : t("Getting latest")}</b>
<span>{total > 0 ? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%` : t("{n} files", { n: transfer.count })}</span>
</span>
{up && (
<button className="xc-cancel" disabled={transfer.cancelling} title={t("Cancel")}
onClick={(e) => { e.stopPropagation(); onCancel(); }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
</button>
)}
{total > 0 && <span className="xc-bar"><span style={{ width: pct + "%" }} /></span>}
</div>
);
}
/* ---------------- 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<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
return (
<div className="modal-back">
<div className="build">
<div className="build-head">
<span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
{building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
</span>
<div className="build-ttl">
<b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
<span>{name}</span>
</div>
<button className="build-x" onClick={onMinimize} title={t("Minimize")}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
</button>
<button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
</div>
<div className="build-body" ref={ref}>
{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 <div key={i} className={"build-ln" + cls}>{ln}</div>;
})}
{building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
</div>
</div>
</div>
);
}
/* build configuration picker (Unreal: Development / Shipping / …) */
function BuildPicker({ onPick, onClose }: { onPick: (config: string) => void; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const opts: { config: string; name: string; desc: string }[] = [
{ config: "Development Editor", name: "Development Editor", desc: t("Editor build for working in-engine (default)") },
{ config: "Development", name: "Development", desc: t("Standalone game, dev config") },
{ config: "DebugGame", name: "DebugGame", desc: t("Game with debuggable game code") },
{ config: "Shipping", name: "Shipping", desc: t("Optimized release build") },
];
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hammer}<h3>{t("Build configuration")}</h3>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="picker-list">
{opts.map((o) => (
<div key={o.config} className="picker-item" onClick={() => onPick(o.config)}>
<span className="pi-ic">{I.hammer}</span>
<span className="pi-body"><span className="pi-name">{o.name} · Win64</span><span className="pi-sub">{o.desc}</span></span>
</div>
))}
</div>
<div className="picker-foot">{t("Builds via MSBuild → UnrealBuildTool")}</div>
</div>
</div>
);
}
/* 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 (
<div className={"buildchip" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")} onClick={onExpand} title={t("Expand")}>
<span className="bc-ic">{building ? <span className="ldr sm" /> : ok ? "✓" : "✗"}</span>
<span className="bc-txt">{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}</span>
<button className="bc-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title={t("Close")}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
</button>
</div>
);
}
/* ---------------- text prompt (edit description) ---------------- */
function TextPrompt({ title, label, value, onSave, onClose }: { title: string; label: string; value: string; onSave: (v: string) => void; onClose: () => void }) {
const [v, setV] = useState(value);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(v); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [v]); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<div className="prompt-label">{label}</div>
<textarea ref={ref} className="prompt-input" value={v} onChange={(e) => setV(e.target.value)} />
<div className="modal-actions">
<button className="mbtn ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={() => onSave(v)}>{t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
const btn = useRef<HTMLButtonElement>(null);
useEffect(() => {
btn.current?.focus();
// only Escape is global; confirming requires the focused button (Enter/Space
// on it, or a click) so a stray Enter never triggers a destructive action
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="modal-back" onClick={() => onClose(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className={"modal-icon" + (danger ? " danger" : "")}>
{danger
? <svg viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.3 3.9 2 18a2 2 0 0 0 1.7 3h16.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
: <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<h3>{title}</h3>
<p>{body}</p>
<div className="modal-actions">
<button className="mbtn ghost" onClick={() => onClose(false)}>{t("Cancel")}</button>
<button ref={btn} className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)}>{confirm}</button>
</div>
</div>
</div>
);
}
/* ---------------- depot file search ---------------- */
function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope: string; onClose: () => void; onOpenEditor: (dp: string) => void; onReveal: (dp: string) => void; onCopy: (dp: string) => void }) {
const [q, setQ] = useState("");
const [res, setRes] = useState<{ depotFile?: string; rev?: string }[]>([]);
const [busy, setBusy] = useState(false);
const seq = useRef(0);
const inp = useRef<HTMLInputElement>(null);
useEffect(() => { inp.current?.focus(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
useEffect(() => {
const s = ++seq.current;
if (q.trim().length < 2) { setRes([]); setBusy(false); return; }
setBusy(true);
const id = setTimeout(() => {
p4.searchFiles(q.trim(), scope).then((r) => { if (s === seq.current) { setRes(r); setBusy(false); } }).catch(() => { if (s === seq.current) { setRes([]); setBusy(false); } });
}, 250); // debounce
return () => clearTimeout(id);
}, [q, scope]);
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.search}<h3>{t("Search depot")}</h3>
<span className="ph-sub">{scope || "//…"}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input ref={inp} placeholder={t("Type at least 2 characters…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Searching…")}</div>}
{!busy && q.trim().length >= 2 && res.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
{res.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
return (
<div key={dp} className="srch-row" onContextMenu={(e) => { e.preventDefault(); }}>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
{isCodeFile(sp.name) && <button className="srch-act" title={t("Open in editor")} onClick={() => onOpenEditor(dp)}>{I.vscode}</button>}
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
<button className="srch-act" title={t("Copy path")} onClick={() => onCopy(dp)}></button>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
// the File Locks list (bar + rows), reused by the modal AND the dockable panel
function LocksPanel({ me, onReveal, onFlash, onUnlock }: { me: string; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const [lockedOnly, setLockedOnly] = useState(true);
const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); }, []); // eslint-disable-line
const isLocked = (f: OpenedFile) => (f as Record<string, unknown>).ourLock !== undefined;
const s = q.trim().toLowerCase();
const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
return (
<>
<div className="rslv-bar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
<button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
{rows.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
const locked = isLocked(f);
const mine = (f.user || "").toLowerCase() === me.toLowerCase();
return (
<div key={dp + (f.client || "")} className="srch-row">
<span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
{locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
<button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
</div>
);
})}
</div>
</>
);
}
function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<LocksPanel me={me} onReveal={onReveal} onFlash={onFlash} onUnlock={onUnlock} />
</div>
</div>
);
}
/* ---------------- .p4ignore editor ---------------- */
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\n# (build intermediates come down read-only and break rebuilds if committed)\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\nobj/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let live = true;
p4.ignoreRead().then((s) => { if (live) { setText(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, []); // eslint-disable-line
async function save() {
setSaving(true);
try { await p4.ignoreWrite(text); onFlash(t(".p4ignore saved.")); onClose(); }
catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.gear}<h3>.p4ignore</h3><span className="ph-sub">{t("at the workspace root")}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{t("Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).")}
{!busy && !text.trim() && <> <button className="ce-btn" style={{ display: "inline-flex", marginLeft: 6 }} onClick={() => setText(P4IGNORE_TEMPLATE)}>{t("Insert Unreal template")}</button></>}</div>
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
: <textarea className="code-edit" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} placeholder={"Binaries/\nIntermediate/\nSaved/\n*.tmp"} />}
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- clean workspace (make it match the depot) ---------------- */
function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [applying, setApplying] = useState(false);
useEffect(() => {
let live = true;
p4.cleanPreview(scope).then((f) => live && setFiles(f)).catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } }).finally(() => live && setBusy(false));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [scope]); // eslint-disable-line
async function apply() {
setApplying(true);
try { await p4.cleanApply(scope); onFlash(t("Workspace cleaned to match the depot.")); onDone(); }
catch (e) { onFlash(String(e), true); }
finally { setApplying(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.revert}<h3>{t("Clean workspace")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint" style={{ color: "var(--del)" }}>{t("Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.")}</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning…")}</div>}
{!busy && files.length === 0 && <div className="ur-empty">{t("Workspace already matches the depot — nothing to clean.")}</div>}
{files.map((f) => {
const dp = f.depotFile || f.clientFile || "";
const sp = splitPath(dp);
const st = statusOf(f.action);
return (
<div key={dp} className="srch-row">
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.action}</span>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
<button className="mbtn danger" onClick={apply} disabled={applying || busy || files.length === 0}>{applying ? <span className="ldr sm" /> : null}{t("Clean {n} file(s)", { n: files.length })}</button>
</div>
</div>
</div>
);
}
/* ---------------- folder properties ---------------- */
function FolderPropsModal({ path, name, onClose, onReveal, onRename, onDelete, onCopy }: { path: string; name: string; onClose: () => void; onReveal: (p: string) => void; onRename: () => void; onDelete: () => void; onCopy: (p: string) => void }) {
const [info, setInfo] = useState<{ fileCount: number; size: number } | null>(null);
const isDepot = path.startsWith("//");
useEffect(() => {
let live = true;
p4.folderInfo(path).then((i) => live && setInfo(i)).catch(() => live && setInfo({ fileCount: 0, size: 0 }));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [path]); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.folder}<h3>{t("Folder properties")}</h3>
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="fp-body">
<div className="fp-name">{I.folder}<b>{name}</b></div>
<div className="fp-grid">
<span className="k">{t("Path")}</span><span className="v mono">{path}</span>
<span className="k">{t("Files")}</span><span className="v">{info ? info.fileCount.toLocaleString() : "…"}</span>
<span className="k">{t("Total size")}</span><span className="v">{info ? humanSize(info.size) : "…"}</span>
<span className="k">{t("Location")}</span><span className="v">{isDepot ? t("depot") : t("local only")}</span>
</div>
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)", gap: 8 }}>
<button className="mbtn ghost" onClick={() => onReveal(path)}>{t("Open in Explorer")}</button>
<button className="mbtn ghost" onClick={() => onCopy(path)}>{t("Copy path")}</button>
{isDepot && <button className="mbtn ghost" onClick={onRename}>{t("Rename…")}</button>}
<button className="mbtn danger" style={{ marginLeft: "auto" }} onClick={onDelete}>{t("Delete folder…")}</button>
</div>
</div>
</div>
);
}
/* ---------------- reconcile offline work ---------------- */
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);
const [busy, setBusy] = useState(true);
const [applying, setApplying] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set());
const keyOf = (f: OpenedFile) => f.depotFile || f.clientFile || "";
useEffect(() => {
let live = true;
p4.reconcilePreview(scope)
.then((f) => { if (live) { setFiles(f); setSel(new Set(f.map(keyOf).filter(Boolean))); } })
.catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } })
.finally(() => live && setBusy(false));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [scope]); // eslint-disable-line
const toggle = (k: string) => setSel((s) => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });
const allOn = files.length > 0 && sel.size === files.length;
const toggleAll = () => setSel(allOn ? new Set() : new Set(files.map(keyOf).filter(Boolean)));
async function apply() {
const paths = files.map(keyOf).filter((k) => sel.has(k));
if (!paths.length) return;
setApplying(true);
try {
const opened = await p4.reconcileApply(paths);
onFlash(t("Reconciled {n} file(s) into a changelist.", { n: opened.length || paths.length }));
onDone();
} catch (e) { onFlash(String(e), true); }
finally { setApplying(false); }
}
// group for a readable header count
const counts = { add: 0, edit: 0, delete: 0 } as Record<string, number>;
for (const f of files) { const a = (f.action || "").toLowerCase(); if (a.includes("add")) counts.add++; else if (a.includes("delete")) counts.delete++; else counts.edit++; }
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.scan}<h3>{t("Reconcile Offline Work")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{t("These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.")}</div>
{!busy && files.length > 0 && (
<div className="rec-summary">
<span style={{ color: "var(--add)", fontWeight: 700 }}>+{counts.add}</span> {t("added")} · <span style={{ color: "var(--edit)", fontWeight: 700 }}>±{counts.edit}</span> {t("modified")} · <span style={{ color: "var(--del)", fontWeight: 700 }}>{counts.delete}</span> {t("deleted")}
<span className="rec-all" onClick={toggleAll}>{allOn ? t("Deselect all") : t("Select all")}</span>
</div>
)}
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning workspace for offline changes…")}</div>}
{!busy && files.length === 0 && <div className="ur-empty">{t("Nothing to reconcile — the workspace matches what the server knows.")}</div>}
{files.map((f) => {
const dp = keyOf(f);
const sp = splitPath(dp);
const st = statusOf(f.action);
const on = sel.has(dp);
return (
<div key={dp} className="srch-row rec-row" onClick={() => toggle(dp)}>
<span className={"chk" + (on ? "" : " off")}>{on ? I.check : null}</span>
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<span className="lk-who">{f.action}</span>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
<button className="mbtn" onClick={apply} disabled={applying || busy || sel.size === 0}>{applying ? <span className="ldr sm" /> : null}{t("Reconcile {n} file(s)", { n: sel.size })}</button>
</div>
</div>
</div>
);
}
/* ---------------- Depot & Workspace file/size explorer ---------------- */
const FILE_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.5" /><path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.5" /></svg>;
const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) || Number(y.isDir) - Number(x.isDir) || x.name.localeCompare(y.name));
// one row in the tree; folders lazily load their children on first expand,
// files trigger onOpen (open in the File Viewer / reveal).
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen, onMenu }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void; onMenu?: (n: FsEntry, e: ReactMouseEvent) => void }) {
const [open, setOpen] = useState(false);
const [kids, setKids] = useState<FsEntry[] | null>(null);
const [busy, setBusy] = useState(false);
async function activate() {
if (!node.isDir) { onOpen?.(node); return; }
if (!open && kids === null) {
setBusy(true);
try { setKids(sortBySize(await load(node.path))); }
catch (e) { onFlash(String(e), true); setKids([]); }
finally { setBusy(false); }
}
setOpen((o) => !o);
}
const pct = Math.max(2, Math.round((node.size / max) * 100));
const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
return (
<div className="exp-node">
<div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} onContextMenu={(e) => { if (node.isDir && onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(node, e); } }} title={node.path}>
<span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
<span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
<span className="exp-name">{node.name}</span>
{node.isDir && node.fileCount > 0 ? <span className="exp-count">{t("{n} files", { n: node.fileCount })}</span> : null}
<span className="exp-bar"><span style={{ width: pct + "%" }} /></span>
<span className="exp-size">{humanSize(node.size)}</span>
<span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
</div>
{open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} onMenu={onMenu} />)}
</div>
);
}
// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
// Files & Sizes modal AND the embedded File Tree view-panel.
function FileTree({ scope, onReveal, onFlash, onOpenFile, onNewFolder, onFolderMenu, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; onNewFolder?: (side: "depot" | "workspace") => void; onFolderMenu?: (n: FsEntry, e: ReactMouseEvent) => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
const [kids, setKids] = useState<FsEntry[] | null>(null);
const [busy, setBusy] = useState(true);
const [err, setErr] = useState("");
const load = (path: string) => (side === "depot" ? p4.depotLs(path) : p4.fsLs(path));
const rootPath = side === "depot" ? scope : "";
const rootLabel = side === "depot" ? (scope || t("Whole depot")) : t("Workspace root");
useEffect(() => {
let live = true;
setBusy(true); setKids(null); setErr("");
load(rootPath).then((k) => { if (live) setKids(sortBySize(k)); }).catch((e) => { if (live) { setErr(String(e)); setKids([]); } }).finally(() => live && setBusy(false));
return () => { live = false; };
}, [side, scope]); // eslint-disable-line
const total = (kids || []).reduce((a, b) => a + b.size, 0);
const max = Math.max(1, ...(kids || []).map((k) => k.size));
return (
<>
<div className="ft-bar">
<div className="exp-tabs">
<button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
<button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
</div>
<span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
{onNewFolder && <button className="ft-new" title={t("New folder / project…")} onClick={() => onNewFolder(side)}>{I.folder}<span>{t("New")}</span></button>}
</div>
<div className="exp-tree">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
{!busy && err && <div className="ur-empty">{err}</div>}
{!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
{!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} onMenu={onFolderMenu} />)}
</div>
</>
);
}
function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
useEffect(() => {
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => document.removeEventListener("keydown", k);
}, []); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
<button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.")}</div>
<FileTree scope={scope} onReveal={onReveal} onFlash={onFlash} onOpenFile={(n) => onReveal(n.path)} />
</div>
</div>
);
}
/* ---------------- file history timeline (filelog) ---------------- */
function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) {
const [revs, setRevs] = useState<FileRev[]>([]);
const [busy, setBusy] = useState(true);
useEffect(() => {
let live = true;
p4.filelog(depot).then((v) => { if (live) { setRevs(filelogRevs(v)); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [depot]); // eslint-disable-line
async function diffPrev(rev: string) {
const n = Number(rev);
if (n <= 1) { onFlash(t("No earlier revision to compare."), true); return; }
try { const text = await p4.diff2(`${depot}#${n - 1}`, `${depot}#${n}`); onShowDiff(`${name} #${n - 1} ↔ #${n}`, text || t("(files are identical)")); }
catch (e) { onFlash(String(e), true); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.log}<h3>{t("File history")}</h3><span className="ph-sub">{name}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="flog-body">
{busy && <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>}
{!busy && revs.length === 0 && <div className="ur-empty">{t("No history.")}</div>}
{revs.map((r) => {
const st = statusOf(r.action);
return (
<div key={r.rev} className="flog-row">
<span className="flog-rail"><span className={"flog-dot " + st.cls} />{Number(r.rev) > 1 && <span className="flog-line" />}</span>
<div className="flog-main">
<div className="flog-head"><span className="rev">#{r.rev}</span><span className={"stat " + st.cls}>{st.ch}</span><span className="flog-cl">#{r.change}</span><span className="flog-user">{r.user}</span><span className="flog-time">{fmtTime(r.time)}</span></div>
<div className="flog-desc">{(r.desc || "").trim() || t("(no description)")}</div>
</div>
{Number(r.rev) > 1 && <button className="rslv-act" onClick={() => diffPrev(r.rev)}>{t("Diff ↔ prev")}</button>}
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- jobs (list / create / edit) ---------------- */
function JobsModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [jobs, setJobs] = useState<{ Job?: string; Status?: string; Description?: string; User?: string }[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const [edit, setEdit] = useState<null | { spec: string; isNew: boolean }>(null);
const [saving, setSaving] = useState(false);
const load = () => { setBusy(true); p4.jobs().then(setJobs).catch((e) => { onFlash(String(e), true); setJobs([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && (edit ? setEdit(null) : onClose()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [edit]); // eslint-disable-line
async function openEdit(job: string) {
try { const spec = await p4.jobSpec(job); setEdit({ spec, isNew: !job }); }
catch (e) { onFlash(String(e), true); }
}
async function saveEdit() {
if (!edit) return;
setSaving(true);
try { await p4.jobSave(edit.spec); onFlash(t("Job saved.")); setEdit(null); load(); }
catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
const s = q.trim().toLowerCase();
const rows = jobs.filter((j) => !s || (j.Job || "").toLowerCase().includes(s) || (j.Description || "").toLowerCase().includes(s) || (j.User || "").toLowerCase().includes(s));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.log}<h3>{t("Jobs")}</h3><span className="ph-sub">{edit ? (edit.isNew ? t("New job") : t("Edit job")) : t("{n} jobs", { n: jobs.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{edit ? (<>
<div className="tm-hint">{t("Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.")}</div>
<textarea className="code-edit" value={edit.spec} spellCheck={false} onChange={(e) => setEdit({ ...edit, spec: e.target.value })} />
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => setEdit(null)} disabled={saving}>{t("Back")}</button>
<button className="mbtn primary" onClick={saveEdit} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
</div>
</>) : (<>
<div className="rslv-bar">
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter jobs…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<button className="mbtn primary" onClick={() => openEdit("")}>{t("New job")}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{t("No jobs.")}</div>}
{rows.map((j) => (
<div key={j.Job} className="srch-row" onClick={() => openEdit(j.Job || "")} style={{ cursor: "pointer" }}>
<span className="srch-body"><span className="n">{j.Job} <span style={{ color: "var(--faint)", fontWeight: 400 }}>· {j.Status}</span></span><span className="p">{(j.Description || "").trim().slice(0, 120)}{j.User ? " — " + j.User : ""}</span></span>
</div>
))}
</div>
</>)}
</div>
</div>
);
}
/* ---------------- streams (list / switch / merge down / copy up) ---------------- */
function StreamsModal({ current, onClose, onFlash, onSwitch, onInteg }: { current: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSwitch: (s: string) => void; onInteg: (s: string, dir: "down" | "up") => void }) {
const [streams, setStreams] = useState<{ Stream?: string; Name?: string; Type?: string; Parent?: string }[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
useEffect(() => {
let live = true;
p4.streams().then((s) => live && setStreams(s)).catch((e) => { if (live) { onFlash(String(e), true); setStreams([]); } }).finally(() => live && setBusy(false));
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, []); // eslint-disable-line
const s = q.trim().toLowerCase();
const rows = streams.filter((x) => !s || (x.Stream || "").toLowerCase().includes(s) || (x.Name || "").toLowerCase().includes(s));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.branch}<h3>{t("Streams")}</h3><span className="ph-sub">{current || t("(not on a stream)")}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{current && (
<div className="rslv-bar">
<span style={{ flex: 1, fontSize: 12, color: "var(--muted)" }}>{t("Current stream integration")}</span>
<button className="mbtn ghost" onClick={() => onInteg(current, "down")}>{t("Merge down")}</button>
<button className="mbtn ghost" onClick={() => onInteg(current, "up")}>{t("Copy up")}</button>
</div>
)}
<div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input placeholder={t("Filter streams…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{t("No streams (this depot may be classic, not stream-based).")}</div>}
{rows.map((x) => {
const st = x.Stream || "";
const on = st === current;
return (
<div key={st} className={"srch-row" + (on ? " on" : "")}>
<span className={"held" + (on ? " locked" : "")} style={{ marginRight: 4 }}>{I.branch}<span>{x.Type || ""}</span></span>
<span className="srch-body"><span className="n">{x.Name || st}{on && " · " + t("current")}</span><span className="p">{st}{x.Parent && x.Parent !== "none" ? " ← " + x.Parent : ""}</span></span>
{!on && <button className="rslv-act" onClick={() => onSwitch(st)}>{t("Switch")}</button>}
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- integrate / merge / copy between branches ---------------- */
function BranchModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [op, setOp] = useState<"merge" | "copy" | "integrate">("merge");
const [source, setSource] = useState(scope ? scope + "/..." : "//depot/…/...");
const [target, setTarget] = useState("//depot/…/...");
const [busy, setBusy] = useState(false);
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
const DESC: Record<string, string> = {
merge: t("Merge changes from source into target — you resolve conflicts, then submit."),
copy: t("Copy source over target verbatim (no merge). Target becomes identical to source."),
integrate: t("Classic integrate — open target files for integration from source."),
};
async function run() {
if (!source.trim() || !target.trim()) { onFlash(t("Source and target are required."), true); return; }
setBusy(true);
try {
const out = await p4.branchOp(op, source.trim(), target.trim());
onFlash(t("{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}", { op, out: out.split("\n")[0] }));
onDone();
} catch (e) { onFlash(String(e), true); }
finally { setBusy(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" style={{ width: 560, maxWidth: "94%" }} onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.branch}<h3>{t("Integrate / Merge / Copy")}</h3>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="info-body">
<div className="langsel" style={{ marginBottom: 12 }}>
{(["merge", "copy", "integrate"] as const).map((o) => (
<button key={o} className={"langbtn" + (op === o ? " on" : "")} onClick={() => setOp(o)}>
<span className="lname">{o === "merge" ? t("Merge") : o === "copy" ? t("Copy") : t("Integrate")}</span>
</button>
))}
</div>
<div className="tm-hint" style={{ border: "none", padding: "0 0 12px" }}>{DESC[op]}</div>
<div className="info-row"><span className="rk">{t("Source")}</span><input className="kf-in mono" value={source} onChange={(e) => setSource(e.target.value)} placeholder="//depot/dev/..." /></div>
<div className="info-row"><span className="rk">{t("Target")}</span><input className="kf-in mono" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="//depot/main/..." /></div>
<div className="modal-actions" style={{ marginTop: 16 }}>
<button className="mbtn ghost" onClick={onClose} disabled={busy}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={run} disabled={busy}>{busy ? <span className="ldr sm" /> : null}{t("Run {op}", { op: op === "merge" ? t("Merge") : op === "copy" ? t("Copy") : t("Integrate") })}</button>
</div>
</div>
</div>
</div>
);
}
/* ---------------- labels (list / create / sync-to) ---------------- */
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);
const [busy, setBusy] = useState(true);
const [q, setQ] = useState("");
const load = () => { setBusy(true); p4.labels().then(setLabels).catch((e) => { onFlash(String(e), true); setLabels([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
async function create() {
const n = newName.trim();
if (!n) return;
setCreating(true);
try { await p4.labelTag(n, scope); onFlash(t("Label “{c}” tagged at current head.", { c: n })); setNewName(""); load(); }
catch (e) { onFlash(String(e), true); }
finally { setCreating(false); }
}
const s = q.trim().toLowerCase();
const rows = labels.filter((l) => !s || (l.label || "").toLowerCase().includes(s) || (l.Description || "").toLowerCase().includes(s));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.clock}<h3>{t("Labels")}</h3><span className="ph-sub">{t("{n} labels", { n: labels.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="rslv-bar">
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter labels…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
<input className="kf-in" style={{ maxWidth: 190 }} placeholder={t("New label name")} value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} />
<button className="mbtn primary" disabled={creating || !newName.trim()} onClick={create}>{creating ? <span className="ldr sm" /> : null}{t("Tag current")}</button>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && rows.length === 0 && <div className="ur-empty">{t("No labels.")}</div>}
{rows.map((l) => (
<div key={l.label} className="srch-row">
<span className="held" style={{ marginRight: 4 }}>{I.clock}</span>
<span className="srch-body"><span className="n">{l.label}</span><span className="p">{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""}</span></span>
<button className="rslv-act" onClick={() => onSyncTo(l.label || "")}>{t("Sync to")}</button>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------------- workspace (client) spec editor ---------------- */
/* ---------------- workspace View (client mapping) — P4V-style include/exclude tree ---------------- */
type ViewRule = { exclude: boolean; depot: string; client: string };
function clientNameOf(spec: string): string {
const m = spec.match(/^Client:\s*(\S+)/m);
return m ? m[1] : "";
}
// parse the View: block of a client spec into ordered mapping rules
function parseViewRules(spec: string): ViewRule[] {
const rules: ViewRule[] = [];
let inView = false;
for (const ln of spec.split(/\r?\n/)) {
if (/^View:/.test(ln)) { inView = true; continue; }
if (!inView) continue;
if (/^\S/.test(ln)) break; // next section starts at column 0
const s = ln.trim();
if (!s || s.startsWith("#")) continue;
const toks = s.match(/"[^"]*"|\S+/g) || [];
const [d0, c0] = toks;
if (!d0 || !c0) continue;
let depot = d0.replace(/^"|"$/g, "");
const client = c0.replace(/^"|"$/g, "");
let exclude = false;
if (depot.startsWith("-")) { exclude = true; depot = depot.slice(1); }
else if (depot.startsWith("+")) { depot = depot.slice(1); }
rules.push({ exclude, depot, client });
}
return rules;
}
const quoteIf = (s: string) => (/\s/.test(s) ? `"${s}"` : s);
// write the given rules back into the spec's View: block (replacing the old one)
function buildViewSpec(spec: string, rules: ViewRule[]): string {
const lines = spec.split(/\r?\n/);
const out: string[] = [];
const viewLines = () => rules.map((r) => `\t${(r.exclude ? "-" : "") + quoteIf(r.depot)} ${quoteIf(r.client)}`);
let inView = false, wrote = false;
for (const ln of lines) {
if (/^View:/.test(ln)) { out.push("View:"); out.push(...viewLines()); inView = true; wrote = true; continue; }
if (inView) { if (/^\S/.test(ln)) { inView = false; out.push(ln); } continue; }
out.push(ln);
}
if (!wrote) { out.push("View:"); out.push(...viewLines()); }
return out.join("\n");
}
const rulePrefix = (depot: string) => depot.replace(/\.\.\.$/, "");
// is folder `depot` currently mapped in, and does any descendant rule differ (mixed)?
function nodeState(depot: string, rules: ViewRule[]): { included: boolean; mixed: boolean } {
const dSlash = depot.endsWith("/") ? depot : depot + "/";
let included = false, mixed = false;
for (const r of rules) {
const p = rulePrefix(r.depot);
if (dSlash.startsWith(p)) included = !r.exclude; // rule covers this whole folder
}
for (const r of rules) {
const p = rulePrefix(r.depot);
if (p.startsWith(dSlash) && p !== dSlash && !r.exclude !== included) mixed = true; // a descendant differs
}
return { included, mixed };
}
// include or exclude a folder: drop prior self/descendant rules, append the new one
function setNode(rules: ViewRule[], depot: string, include: boolean, client: string): ViewRule[] {
const dSlash = depot + "/";
const rel = depot.replace(/^\/\/[^/]+\//, "");
const clientPat = `//${client}/${rel}/...`;
const kept = rules.filter((r) => { const p = rulePrefix(r.depot); return !(p === dSlash || p.startsWith(dSlash)); });
kept.push({ exclude: !include, depot: `${dSlash}...`, client: clientPat });
return kept;
}
function ViewTreeNode({ depot, name, depth, rules, onToggle }: { depot: string; name: string; depth: number; rules: ViewRule[]; onToggle: (depot: string, include: boolean) => void }) {
const [open, setOpen] = useState(depth === 0);
const [kids, setKids] = useState<{ depot: string; name: string }[] | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || kids !== null) return;
setLoading(true);
p4.dirs(depot)
.then((ds) => setKids(ds.filter((d) => d.dir).map((d) => ({ depot: d.dir as string, name: (d.dir as string).split("/").filter(Boolean).pop() || (d.dir as string) }))))
.catch(() => setKids([]))
.finally(() => setLoading(false));
}, [open]); // eslint-disable-line
const st = nodeState(depot, rules);
return (
<div className="vt-node">
<div className="vt-row" style={{ paddingLeft: 8 + depth * 15 }}>
<span className="vt-chev" onClick={() => setOpen((o) => !o)}>
<svg viewBox="0 0 24 24" style={{ transform: open ? "rotate(90deg)" : "none" }} fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
<span className={"vt-chk" + (st.mixed ? " mixed" : st.included ? " on" : "")} onClick={() => onToggle(depot, !st.included)} title={st.included ? t("Included — click to exclude (wont be synced)") : t("Excluded — click to include (will be synced)")}>
{st.mixed ? <svg viewBox="0 0 24 24" fill="none"><path d="M6 12h12" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" /></svg>
: st.included ? <svg viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg> : null}
</span>
<span className="vt-ic">{I.folder}</span>
<span className="vt-name">{name}</span>
</div>
{open && kids && kids.map((k) => <ViewTreeNode key={k.depot} depot={k.depot} name={k.name} depth={depth + 1} rules={rules} onToggle={onToggle} />)}
{open && loading && <div className="vt-loading" style={{ paddingLeft: 8 + (depth + 1) * 15 }}><span className="ldr sm" /></div>}
{open && kids && kids.length === 0 && !loading && <div className="vt-empty" style={{ paddingLeft: 8 + (depth + 1) * 15 }}>{t("(no subfolders)")}</div>}
</div>
);
}
function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
const [spec, setSpec] = useState("");
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
const [mode, setMode] = useState<"tree" | "text">("tree");
useEffect(() => {
let live = true;
p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [name]); // eslint-disable-line
async function save() {
setSaving(true);
try {
await p4.clientSave(spec);
onFlash(isNew ? t("Workspace “{c}” created.", { c: name }) : t("Workspace “{c}” updated.", { c: name }));
onSaved(name);
} catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
const rules = mode === "tree" ? parseViewRules(spec) : [];
const toggleNode = (depot: string, include: boolean) => setSpec((s) => buildViewSpec(s, setNode(parseViewRules(s), depot, include, clientNameOf(s) || name)));
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</span>
<div className="vt-modes" title={t("Switch between the folder tree and the raw spec")}>
<button className={mode === "tree" ? "on" : ""} onClick={() => setMode("tree")} title={t("Folder tree (include / exclude)")}>{I.folder}</button>
<button className={mode === "text" ? "on" : ""} onClick={() => setMode("text")} title={t("Raw spec text")}>{I.log}</button>
</div>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{mode === "tree"
? t("Check a folder to include it in this workspace, uncheck to exclude. Excluded folders are not synced (not downloaded). This edits the depot → workspace View mapping.")
: t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
: mode === "tree"
? <div className="vt-tree"><ViewTreeNode depot="//depot" name="depot" depth={0} rules={rules} onToggle={toggleNode} /></div>
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- typemap / exclusive-lock rules ---------------- */
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
// only one person can check them out at a time, preventing lost binary merges.
/* ---------------- Settings — sectioned hub (General / Appearance / Plugins) ---------------- */
type SetSection = "general" | "appearance" | "plugins";
function SettingsModal(props: {
section?: SetSection; 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;
editors: Editor[]; editorId: string; onPickEditor: (id: string) => void;
onReloadPlugins: () => Promise<{ loaded: number; errors: string[] }>;
onDisconnect: () => void; onFlash: (t: string, e?: boolean) => void; onClose: () => void;
}) {
const { info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onReloadPlugins, onDisconnect, onFlash, onClose } = props;
const [sec, setSec] = useState<SetSection>(props.section || "general");
function close() { applyTheme(resolveTheme(themeId)); onClose(); } // drop any unsaved live theme preview
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && close(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const NAV: { id: SetSection; label: string; icon: ReactNode }[] = [
{ id: "general", label: t("General"), icon: I.gear },
{ id: "appearance", label: t("Appearance"), icon: I.sun },
{ id: "plugins", label: t("Plugins"), icon: I.hex },
];
return (
<div className="modal-back" onClick={close}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="set-nav">
<div className="set-nav-h"><span className="set-nav-logo">{I.gear}</span><b>{t("Settings")}</b></div>
{NAV.map((n) => (
<button key={n.id} className={"set-nav-item" + (sec === n.id ? " on" : "")} onClick={() => setSec(n.id)}>
<span className="sni-ic">{n.icon}</span>{n.label}
</button>
))}
<div className="set-nav-sp" />
<button className="set-nav-item danger" onClick={() => { close(); onDisconnect(); }}>{I.power}{t("Disconnect")}</button>
</div>
<div className="set-main">
<div className="set-main-h"><h3>{NAV.find((n) => n.id === sec)?.label}</h3>
<button className="x" onClick={close}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{sec === "general" && (
<div className="info-body" style={{ overflow: "auto" }}>
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</div>
</div>
<div className="info-row"><span className="rk">{t("Base theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Interface scale")}</span>
<div className="zoomsel">
<button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title=""></button>
<button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Code editor")}</span>
<select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
{editors.length === 0 && <option value="default">{t("System default")}</option>}
{editors.map((ed) => (<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>))}
</select>
</div>
<div className="set-sep" />
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
</div>
)}
{sec === "appearance" && <ThemesModal embedded activeId={themeId} onApply={setThemeId} onFlash={onFlash} />}
{sec === "plugins" && <PluginsModal embedded onReload={onReloadPlugins} onFlash={onFlash} />}
</div>
</div>
</div>
);
}
/* ---------------- plugins: manage installed plugins ---------------- */
function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () => void; onReload: () => Promise<{ loaded: number; errors: string[] }>; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
const [list, setList] = useState<PluginInfo[]>([]);
const [busy, setBusy] = useState(true);
const [dir, setDir] = useState("");
const [reg, setReg] = useState<RegistryEntry[]>([]);
const [installing, setInstalling] = useState<string>("");
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); fetchRegistry().then(setReg).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
async function installReg(entry: RegistryEntry) {
setInstalling(entry.id);
try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
catch (e) { onFlash(String(e), true); }
finally { setInstalling(""); }
}
async function toggle(p: PluginInfo, enabled: boolean) {
try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
}
async function remove(p: PluginInfo) {
try { await p4.pluginRemove(p.id); load(); await onReload(); onFlash(t("Removed plugin “{n}”.", { n: p.name })); } catch (e) { onFlash(String(e), true); }
}
async function install() {
try {
const sel = await openDialog({ directory: true, title: t("Pick a plugin folder (contains plugin.json)") });
if (!sel || Array.isArray(sel)) return;
const id = await p4.pluginInstall(sel);
load(); await onReload();
onFlash(t("Installed plugin “{id}”.", { id }));
} catch (e) { onFlash(String(e), true); }
}
const body = (<>
<div className="tm-hint">{t("Plugins add features on top of the core app. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.")}</div>
<div className="pl-list">
{busy ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
: list.length === 0 ? <div className="ur-empty">{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}</div>
: list.map((p) => (
<div key={p.id} className={"pl-card" + (p.enabled ? "" : " off")}>
<div className="pl-main">
<div className="pl-top"><b>{p.name || p.id}</b><span className="pl-ver">v{p.version}</span></div>
{p.description && <div className="pl-desc">{p.description}</div>}
<div className="pl-meta">
{p.author && <span className="pl-tag">{p.author}</span>}
{(p.permissions || []).map((perm: string) => <span key={perm} className="pl-perm">{perm}</span>)}
</div>
</div>
<div className="pl-actions">
<button className={"pl-toggle" + (p.enabled ? " on" : "")} onClick={() => toggle(p, !p.enabled)} title={p.enabled ? t("Disable") : t("Enable")}><span /></button>
<button className="pl-del" onClick={() => remove(p)} title={t("Remove")}>{I.trash}</button>
</div>
</div>
))}
{reg.filter((e) => !list.some((p) => p.id === e.id)).length > 0 && (<>
<div className="pl-official-h">{t("Official plugins")}</div>
{reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
<div key={e.id} className="pl-card">
<div className="pl-main">
<div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div>
{e.description && <div className="pl-desc">{e.description}</div>}
<div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div>
</div>
<div className="pl-actions">
<button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
</div>
</div>
))}
</>)}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>
<button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => onReload().then(({ loaded }) => onFlash(t("{n} plugin(s) loaded", { n: loaded })))}>{I.sync}{t("Reload")}</button>
<button className="mbtn primary" onClick={install}>{I.open}{t("Install from folder…")}</button>
</div>
</>);
if (embedded) return <div className="set-panel">{body}</div>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{t("Plugins")}</h3><span className="ph-sub">{t("{n} installed", { n: list.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{body}
</div>
</div>
);
}
/* a plugin-contributed view, mounted into a plain DOM container */
function PluginViewModal({ view, onClose }: { view: PluginView; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current; if (!el) return;
let cleanup: void | (() => void);
try { cleanup = view.render(el); } catch (e) { el.textContent = String(e); }
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { document.removeEventListener("keydown", k); if (typeof cleanup === "function") { try { cleanup(); } catch {} } };
}, []); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{view.title}</h3><span className="ph-sub">{view.pluginId}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="plugin-view" ref={ref} />
</div>
</div>
);
}
/* ---------------- themes (switch + custom theme editor) ---------------- */
function ThemesModal({ activeId, onApply, onClose, onFlash, embedded }: { activeId: string; onApply: (id: string) => void; onClose?: () => void; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
const [themes, setThemes] = useState<Theme[]>(() => allThemes());
const [editing, setEditing] = useState<Theme | null>(null); // custom theme being edited/created
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && (editing ? setEditing(null) : onClose?.()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [editing]); // eslint-disable-line
// live preview while editing; restore the active theme when leaving the editor
useEffect(() => { if (editing) applyTheme(editing); else applyTheme(resolveTheme(activeId)); }, [editing]); // eslint-disable-line
function refreshList() { setThemes(allThemes()); }
function startNew() {
const base = BUILTIN_THEMES[0]!;
const vars: Record<string, string> = {};
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(base, tk.var);
setEditing({ id: newCustomId(), name: t("My theme"), base: "dark", vars });
}
function startEdit(th: Theme) {
const vars: Record<string, string> = {};
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(th, tk.var);
setEditing({ ...th, vars });
}
function saveEditing() {
if (!editing) return;
const customs = loadCustomThemes().filter((c) => c.id !== editing.id);
customs.push({ ...editing, builtin: false });
saveCustomThemes(customs);
refreshList();
setEditing(null);
onApply(editing.id);
onFlash(t("Theme “{n}” saved.", { n: editing.name }));
}
function del(th: Theme) {
saveCustomThemes(loadCustomThemes().filter((c) => c.id !== th.id));
refreshList();
if (activeId === th.id) onApply("dark");
else applyTheme(resolveTheme(activeId));
}
function doExport(th: Theme) {
const blob = new Blob([exportTheme(th)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = `${th.name.replace(/\s+/g, "-").toLowerCase()}.exbyte-theme.json`; a.click();
URL.revokeObjectURL(a.href);
}
function onImportFile(e: { target: HTMLInputElement }) {
const f = e.target.files?.[0]; if (!f) return;
f.text().then((txt) => { const th = importTheme(txt); const customs = loadCustomThemes(); customs.push(th); saveCustomThemes(customs); refreshList(); onApply(th.id); onFlash(t("Theme “{n}” imported.", { n: th.name })); })
.catch((err) => onFlash(String(err), true));
}
const groups = Array.from(new Set(THEME_TOKENS.map((tk) => tk.group)));
const body = (!editing ? (<>
<div className="th-grid">
{themes.map((th) => (
<div key={th.id} className={"th-card" + (th.id === activeId ? " on" : "")} onClick={() => onApply(th.id)}>
<div className="th-swatch" style={{ background: th.vars["--bg"] || (th.base === "light" ? "#eef0f5" : "#0b0b12") }}>
<span style={{ background: th.vars["--panel"] || (th.base === "light" ? "#fff" : "#161621") }} />
<span style={{ background: th.vars["--accent"] || "#7c6ef6" }} />
<span style={{ background: th.vars["--accent-2"] || "#9a8bf9" }} />
</div>
<div className="th-meta"><b>{th.name}</b><span>{th.base === "light" ? t("Light") : t("Dark")}{th.builtin ? "" : " · " + t("custom")}</span></div>
{th.id === activeId && <span className="th-on">{I.check}</span>}
{!th.builtin && (
<div className="th-actions" onClick={(e) => e.stopPropagation()}>
<button title={t("Edit")} onClick={() => startEdit(th)}>{I.pencil}</button>
<button title={t("Export")} onClick={() => doExport(th)}>{I.down}</button>
<button title={t("Delete")} className="danger" onClick={() => del(th)}>{I.trash}</button>
</div>
)}
</div>
))}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<input ref={fileRef} type="file" accept=".json" style={{ display: "none" }} onChange={onImportFile} />
<button className="mbtn ghost" onClick={() => fileRef.current?.click()}>{I.open}{t("Import…")}</button>
<button className="mbtn primary" onClick={startNew}><svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>{t("New custom theme")}</button>
</div>
</>) : (<>
<div className="th-edit">
<div className="th-edit-row">
<label>{t("Name")}</label>
<input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
<div className="th-base">
<button className={editing.base === "dark" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "dark" })}>{I.moon} {t("Dark")}</button>
<button className={editing.base === "light" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "light" })}>{I.sun} {t("Light")}</button>
</div>
</div>
{groups.map((g) => (
<div key={g} className="th-group">
<div className="th-group-h">{g}</div>
<div className="th-tokens">
{THEME_TOKENS.filter((tk) => tk.group === g).map((tk) => (
<label key={tk.var} className="th-token">
<input type="color" value={editing.vars[tk.var] || "#000000"} onChange={(e) => setEditing({ ...editing, vars: { ...editing.vars, [tk.var]: e.target.value } })} />
<span>{tk.label}</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => setEditing(null)}>{t("Cancel")}</button>
<button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => doExport(editing)}>{I.down}{t("Export")}</button>
<button className="mbtn primary" onClick={saveEditing}>{I.check}{t("Save & apply")}</button>
</div>
</>));
if (embedded) return <div className="set-panel">{body}</div>;
return (
<div className="modal-back" onClick={() => (editing ? setEditing(null) : onClose?.())}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.sun}<h3>{editing ? t("Custom theme") : t("Themes")}</h3>
<span className="ph-sub">{editing ? editing.name : t("{n} themes", { n: themes.length })}</span>
<button className="x" onClick={() => (editing ? setEditing(null) : onClose?.())}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{body}
</div>
</div>
);
}
const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [entries, setEntries] = useState<string[]>([]);
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
const [add, setAdd] = useState("");
const load = () => { setBusy(true); p4.typemapGet().then(setEntries).catch((e) => { onFlash(String(e), true); setEntries([]); }).finally(() => setBusy(false)); };
useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const patternOf = (e: string) => (e.split(/\s+/)[1] || "").toLowerCase();
function addRecommended() {
const have = new Set(entries.map(patternOf));
const add: string[] = [];
for (const ext of UE_LOCK_EXTS) {
const pat = `//....${ext}`;
if (!have.has(pat)) add.push(`binary+l ${pat}`);
}
if (!add.length) { onFlash(t("All recommended rules are already present."), false); return; }
setEntries((cur) => [...cur, ...add]);
}
function addOne() {
const v = add.trim();
if (!v) return;
// accept "binary+l //....ext" or just "//....ext" (defaults to binary+l)
const entry = /\s/.test(v) ? v : `binary+l ${v}`;
setEntries((cur) => [...cur, entry]);
setAdd("");
}
async function save() {
setSaving(true);
try { await p4.typemapSet(entries); onFlash(t("Typemap saved.")); onClose(); }
catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.lock}<h3>{t("Exclusive Locks (typemap)")}</h3>
<span className="ph-sub">{t("{n} rules", { n: entries.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{t("Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.")}</div>
<div className="rslv-bar">
<button className="mbtn primary" onClick={addRecommended}>{I.lock}{t("Add recommended Unreal rules")}</button>
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Add rule, e.g. binary+l //....uasset")} value={add} onChange={(e) => setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} /></div>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && entries.length === 0 && <div className="ur-empty">{t("No typemap rules yet.")}</div>}
{entries.map((e, i) => {
const parts = e.split(/\s+/);
const type = parts[0] || "";
const pat = parts.slice(1).join(" ");
const excl = /\+l/.test(type);
return (
<div key={i + e} className="srch-row">
<span className={"held" + (excl ? " locked" : "")} style={{ marginRight: 4 }}>{excl ? I.lock : I.unlock}<span>{type}</span></span>
<span className="srch-body"><span className="n" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{pat}</span></span>
<button className="srch-act" title={t("Remove")} onClick={() => setEntries((cur) => cur.filter((_, j) => j !== i))}></button>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save typemap")}</button>
</div>
</div>
</div>
);
}
/* ---------------- blame / annotate ---------------- */
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
let live = true;
p4.annotate(spec).then((s) => live && setText(s)).catch((e) => { if (live) { onFlash(String(e), true); setText(""); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, [spec]); // eslint-disable-line
// annotate lines look like: "12345: <user> <code>" — split the prefix for coloring
const rows = (text || "").split("\n").map((ln, i) => {
const m = ln.match(/^(\d+):\s*(\S+)?\s?(.*)$/);
return m ? { n: i + 1, cl: m[1], user: m[2] || "", code: m[3] ?? "" } : { n: i + 1, cl: "", user: "", code: ln };
});
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.people}<h3>{t("Blame")}</h3><span className="ph-sub">{name}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="blame-body">
{text === null ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
: rows.map((r) => (
<div key={r.n} className="blame-row">
<span className="bl-cl">{r.cl && "#" + r.cl}</span>
<span className="bl-user">{r.user}</span>
<span className="bl-code">{r.code || " "}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ---------------- unified-diff viewer (diff2) ---------------- */
function DiffModal({ title, text, onClose }: { title: string; text: string; onClose: () => void }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
const lines = text.split("\n");
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.clock}<h3>{t("Diff")}</h3><span className="ph-sub">{title}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="diff-body">
{lines.map((ln, i) => {
const c = ln.startsWith("+") && !ln.startsWith("+++") ? "add" : ln.startsWith("-") && !ln.startsWith("---") ? "del" : ln.startsWith("@@") ? "hunk" : "";
return <div key={i} className={"diff-line " + c}>{ln || " "}</div>;
})}
</div>
</div>
</div>
);
}
/* ---------------- resolve conflicts ---------------- */
function ResolveModal({ files, onResolve, onClose }: { files: OpenedFile[]; onResolve: (mode: "am" | "ay" | "at", file?: string) => void; onClose: () => void }) {
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{t("Resolve conflicts")}</h3><span className="ph-sub">{t("{n} file(s)", { n: files.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="rslv-bar">
<button className="mbtn primary" onClick={() => onResolve("am")}>{t("Auto-merge all")}</button>
<button className="mbtn ghost" onClick={() => onResolve("at")}>{t("Accept theirs (all)")}</button>
<button className="mbtn ghost" onClick={() => onResolve("ay")}>{t("Accept yours (all)")}</button>
</div>
<div className="srch-list">
{files.length === 0 && <div className="ur-empty">{t("Nothing to resolve.")}</div>}
{files.map((f) => {
const dp = f.depotFile || "";
const sp = splitPath(dp);
return (
<div key={dp} className="srch-row">
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
<button className="rslv-act" title={t("Auto-merge")} onClick={() => onResolve("am", dp)}>{t("Merge")}</button>
<button className="rslv-act" title={t("Accept theirs")} onClick={() => onResolve("at", dp)}>{t("Theirs")}</button>
<button className="rslv-act" title={t("Accept yours")} onClick={() => onResolve("ay", dp)}>{t("Yours")}</button>
</div>
);
})}
</div>
</div>
</div>
);
}
/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean; width?: number }) {
const ref = useRef<HTMLDivElement>(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]);
const cls = "right" + (split ? " histlist" : "");
// width set → fixed, resizable column (direct inline style, no CSS-var indirection)
const wstyle: CSSProperties | undefined = width != null ? { width, flex: "none" } : undefined;
if (busy) return <div className={cls} style={wstyle}><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
if (!detail) return <div className={cls} style={wstyle}><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;
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);
const key = `${f.depotFile}#${f.rev}`;
rows.push(
<div key={i} className={"crow click" + (selected === key ? " sel" : "")} style={{ top: i * CROW }}
onClick={() => onOpen?.({ depotFile: f.depotFile, action: f.action, rev: f.rev, type: "", change: detail.change, _spec: key })}>
<span className={"stat " + st.cls}>{st.ch}</span>
<span className="hbody"><span className="hdesc" style={{ WebkitLineClamp: 1 }}>{name}</span><span className="hmeta">{dir}#{f.rev}</span></span>
<span className="crow-go">{I.chevron}</span>
</div>
);
}
return (
<div className={cls} style={wstyle}>
<div className="diffhead">
<span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
<span className="ttl">
<span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
<span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
</span>
</div>
<div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
</div>
);
}
/* ---------------- history list ---------------- */
function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { history: Change[]; sizes?: Record<string, number>; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
return (
<div className="files" style={{ position: "relative" }}>
{history.map((c) => {
const sz = sizes?.[c.change || ""];
// "NEW" badge on commits submitted within the last 3 hours (recomputed on
// every history refresh, so it fades away on its own)
const ageMs = c.time ? Date.now() - Number(c.time) * 1000 : Infinity;
const fresh = ageMs >= 0 && ageMs < 3 * 3600 * 1000;
return (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<Avatar user={c.user || "?"} size={26} />
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
</span>
{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}
</div>
);
})}
</div>
);
}
/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
// image formats the webview can actually decode in an <img>. Game/DCC formats
// (tga, dds, exr, psd, tiff, hdr…) can't — those get the glyph, not a broken img.
const IMG_RENDERABLE = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "bmp"]);
function Thumb({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const kind = kindOf(name);
const ext = (name.split(".").pop() || "").toLowerCase();
const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
useEffect(() => {
const renderableImage = kind === "image" && IMG_RENDERABLE.has(ext);
if (!renderableImage && kind !== "uasset") return;
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
let live = true;
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
// real images → fetch the file bytes and blob them directly
const job = kind === "uasset"
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
: p4.readDepot(dp).then((buf) =>
URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }))
);
job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
.catch(() => { thumbCache.set(dp, null); });
return () => { live = false; };
}, [dp]);
// onError: some formats (or corrupt files) still fail to decode after all —
// fall back to the glyph instead of the browser's broken-image icon
if (url) return <img className="fthumb" src={url} alt="" onError={() => { thumbCache.set(dp, null); setUrl(null); }} />;
const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
return <span className={"fthumb ico k-" + kind}>{ic}</span>;
}
/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle, onContext }:
{ files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; others: Map<string, { user: string; locked: boolean }>; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) {
const ref = useRef<HTMLDivElement>(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);
const other = others.get(dp);
rows.push(
<div key={dp + i} className={"file" + (selRows.has(i) ? " sel" : "") + (i === sel ? " primary" : "")} style={{ top: i * ROW }} onClick={(e) => onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}>
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, dp); }}>{I.check}</span>
<Thumb file={f} />
<span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
{other && <span className={"held" + (other.locked ? " locked" : "")} title={other.locked ? t("Locked by {u}", { u: other.user }) : t("Also open by {u}", { u: other.user })}>{other.locked ? I.lock : I.people}<span>{other.user}</span></span>}
<span className={"stat " + st.cls}>{st.ch}</span>
</div>
);
}
return <div className="files" ref={ref}><div className="vspace" style={{ height: files.length * ROW }}>{rows}</div></div>;
}
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, onFilelog, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; onFilelog?: (depot: string, name: string) => void; uproject?: string }) {
const [explain, setExplain] = useState<string | null>(null); // AI code summary
const [explBusy, setExplBusy] = useState(false);
const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile
// on file change show its cached summary (persisted; removed only via ×)
useEffect(() => {
explSeq.current++;
setExplain(file ? getExplain(file._spec || file.depotFile || "") : null);
setExplBusy(false);
}, [file?.depotFile, file?._spec]);
if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
const dp = file.depotFile || "";
const spec = file._spec || ""; // set → read-only view of a submitted revision
const hist = !!spec;
const { name, dir } = splitPath(dp);
const st = statusOf(file.action);
const kind = kindOf(name);
const codeFile = isCodeFile(name); // gate Summary/IDE buttons to real source files
const rev = hist ? "#" + (file.rev || "?")
: file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");
// ask the AI for a short description of what this source file does
async function doExplain() {
if (!file) return;
const seq = ++explSeq.current;
const key = spec || dp;
setExplBusy(true); setExplain(null);
try {
const buf = await fileBytes(file);
// guard against binaries slipping through: bail if it's not decodable text
const bytes = new Uint8Array(buf);
if (bytes.length && bytes.slice(0, 8000).includes(0)) throw new Error(t("Not a text file."));
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
const out = await aiExplainCode(name, text);
if (seq !== explSeq.current) return; // a different file is showing now
setExplain(out);
saveExplain(key, out); // persist until dismissed via ×
} catch (e) {
if (seq === explSeq.current) onFlash(String(e), true);
} finally { if (seq === explSeq.current) setExplBusy(false); }
}
return (
<div className="right">
<div className="diffhead">
<span className={"stat " + st.cls} style={{ width: 26, height: 26, fontSize: 13 }}>{st.ch}</span>
<span className="ttl">
<span className="n">{name} <span> {dir}</span></span>
<span className="m">{file.action} · <span className="rev">{rev}</span>{file.type ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""}</span>
</span>
<span className="tools">
{codeFile && (
<span className={"gbtn ai icon" + (explBusy ? " busy" : "")} onClick={() => !explBusy && doExplain()} title={t("AI summary — what this file does")}>
{explBusy ? <span className="ldr sm" /> : I.spark}
</span>
)}
{codeFile && (
<span className="gbtn vsc icon" title={t("Open in {editor}", { editor: editorName })}
onClick={async () => { try { await p4.openInEditor(dp, editorId); onFlash(t("Opening in {editor}…", { editor: editorName })); } catch (e) { onFlash(String(e), true); } }}>
{I.vscode}
</span>
)}
{codeFile && onBlame && (
<span className="gbtn icon" title={t("Blame — who changed each line")} onClick={() => onBlame(spec || dp, name)}>{I.people}</span>
)}
{hist && onDiffPrev && codeFile && (
<span className="gbtn icon" title={t("Diff against the previous revision")} onClick={() => onDiffPrev(file)}>{I.clock}</span>
)}
{onFilelog && dp && <span className="gbtn icon" title={t("File history")} onClick={() => onFilelog(dp, name)}>{I.log}</span>}
{!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
</span>
</div>
{(explain || explBusy) && (
<div className="explain">
<span className="explain-ic">{I.spark}</span>
<div className="explain-body">{explBusy ? <span className="explain-load"><span className="ldr sm" />{t("Reading the code…")}</span> : explain}</div>
{!explBusy && <button className="explain-x" onClick={() => { setExplain(null); dropExplain(spec || dp); }} title={t("Dismiss")}>×</button>}
</div>
)}
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "audio" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={false} />
: kind === "video" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={true} />
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
</div>
);
}
function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
return <div className="mi"><span className="k">{k}</span><span className={"v" + (acc ? " acc" : "")}>{v}</span></div>;
}
/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState("");
const [dim, setDim] = useState("");
useEffect(() => {
let u = ""; let live = true;
(spec ? p4.printDepot(spec) : 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, spec]);
return (
<div className="asset">
<div className="stage">
<span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>{t("Image")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: url ? <img src={url} alt={name} style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 10, boxShadow: "0 20px 50px -20px #000" }}
onLoad={(e) => setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
: <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>}
</div>
<div className="assetmeta">
<Mi k={t("File")} v={name} /><Mi k={t("Resolution")} v={dim || "—"} /><Mi k={t("Format")} v={(name.split(".").pop() || "").toUpperCase()} /><Mi k={t("Source")} v={t("working copy")} acc />
</div>
</div>
);
}
/* ---------------- audio / video preview with playback (like Anchorpoint) ---------------- */
const MEDIA_MIME: Record<string, string> = {
wav: "audio/wav", mp3: "audio/mpeg", ogg: "audio/ogg", oga: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", opus: "audio/opus",
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", ogv: "video/ogg",
};
function MediaViewer({ depot, name, spec, video }: { depot: string; name: string; spec?: string; video: boolean }) {
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState("");
const [dur, setDur] = useState("");
useEffect(() => {
let u = ""; let live = true;
setUrl(null); setErr(""); setDur("");
(spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
if (!live) return;
const ext = (name.split(".").pop() || "").toLowerCase();
u = URL.createObjectURL(new Blob([buf], { type: MEDIA_MIME[ext] || (video ? "video/mp4" : "audio/mpeg") }));
setUrl(u);
}).catch((e) => live && setErr(String(e)));
return () => { live = false; if (u) URL.revokeObjectURL(u); };
}, [depot, name, spec, video]);
const onMeta = (e: { currentTarget: HTMLMediaElement }) => {
const d = e.currentTarget.duration;
if (isFinite(d)) { const m = Math.floor(d / 60), s = Math.round(d % 60); setDur(`${m}:${s.toString().padStart(2, "0")}`); }
};
const ext = (name.split(".").pop() || "").toUpperCase();
return (
<div className="asset">
<div className="stage">
<span className="lbl2">{video ? I.hex : SOUND_GLYPH}{video ? t("Video") : t("Audio")}</span>
{err ? <div className="viewer-msg err">{err}</div>
: !url ? <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>
: video
? <video className="mediaplayer vid" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
: (
<div className="audiobox">
<div className="audio-art">{SOUND_GLYPH}</div>
<div className="audio-name">{name}</div>
<audio className="mediaplayer" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
</div>
)}
</div>
<div className="assetmeta">
<Mi k={t("File")} v={name} /><Mi k={t("Duration")} v={dur || "—"} /><Mi k={t("Format")} v={ext} /><Mi k={t("Source")} v={spec ? t("server revision") : t("working copy")} acc />
</div>
</div>
);
}
const SOUND_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M11 5 6 9H3v6h3l5 4V5Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" /><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>;
/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const hist = !!file._spec; // historical revision (3D export only works on the working copy)
const [thumb, setThumb] = useState<string | null>(null);
const [tried, setTried] = useState(false);
const [model, setModel] = useState<ArrayBuffer | null>(null); // exported FBX bytes
const [exporting, setExporting] = useState(false);
useEffect(() => {
let live = true; let url = "";
setThumb(null); setTried(false); setModel(null); setExporting(false);
// backend reads the asset and returns ONLY the embedded thumbnail PNG —
// works for the working copy and for exact submitted revisions (history)
const spec = file._spec || dp;
p4.uassetThumb(spec, !!file._spec).then((buf) => {
if (!live) return;
url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
setThumb(url);
setTried(true);
}).catch(() => live && setTried(true));
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [dp, file._spec]);
async function load3d() {
if (!uproject) { onFlash(t("Pick the Unreal project working folder first."), true); return; }
setExporting(true);
try {
const buf = await p4.exportUasset3d(dp, uproject);
setModel(buf);
} catch (e) { onFlash(String(e), true); }
finally { setExporting(false); }
}
// real, rotatable 3D once Unreal has exported the mesh
if (model) return <ModelViewer depot={dp} name={name} bytes={model} forceExt="fbx" />;
const canExport = !!uproject; // exports the version currently synced on disk
return (
<div className="asset">
<div className="stage">
{thumb ? (<>
<span className="lbl2">{I.cube}{t("Unreal · embedded thumbnail")}</span>
<img src={thumb} alt={name} style={{ maxWidth: "82%", maxHeight: "82%", borderRadius: 12, boxShadow: "0 20px 50px -20px #000" }} />
</>) : (
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}</div>
</div>
)}
{/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */}
<button className="load3d" onClick={load3d} disabled={!canExport || exporting}
title={!uproject ? t("Pick the Unreal project working folder first.") : hist ? t("Exports the version currently synced on disk.") : t("Export the mesh from Unreal and view it in 3D")}>
{exporting ? <><span className="ldr sm" />{t("Exporting from Unreal… (first time is slow)")}</> : <>{I.cube}{t("Load 3D model")}</>}
</button>
</div>
<div className="assetmeta">
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
</div>
</div>
);
}