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 ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";

/* ---------------- 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>,
};

/* ---------------- theme hook ---------------- */
function useTheme(): [boolean, () => void] {
  const [light, setLight] = useState(() => {
    try { return localStorage.getItem("exd-theme") === "light"; } catch { return false; }
  });
  useEffect(() => {
    document.body.classList.toggle("light", light);
    try { localStorage.setItem("exd-theme", light ? "light" : "dark"); } catch {}
  }, [light]);
  return [light, () => setLight((l) => !l)];
}

/* ---------------- language hook (whole tree re-renders on change) ---------------- */
function useLang(): [Lang, (l: Lang) => void] {
  const [lang, setLang] = useState<Lang>(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] = 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} 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 ModalState =
  | { kind: "workspaces"; items: Client[]; current: string }
  | { kind: "scope"; path: string; dirs: Dir[] }
  | { kind: "settings" }
  | { kind: "users" }
  | { kind: "search" }
  | { kind: "locks" }
  | { kind: "typemap" }
  | { kind: "labels" }
  | { kind: "branch" }
  | { 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; file?: string };
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };

function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
  { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
  const [tab, setTab] = useState<Tab>("changes");
  const [modal, setModal] = useState<ModalState | null>(null);
  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 [openMenu, setOpenMenu] = useState<string | null>(null);
  const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
  const [history, setHistory] = useState<Change[]>([]);
  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 [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
  const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
  const [slnPath, setSlnPath] = useState("");   // local .sln path in the scope (for building)
  const [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 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");
  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);
      try { await p4.scan(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); }
    }
  }
  // Default = Perforce-native: just read what's checked out (p4 opened). With
  // Source Control enabled in Unreal, UE auto-checks-out files, so they appear
  // here on their own — no reconcile needed. Reconcile is a manual fallback.
  useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
  // live Perforce command log — the Rust backend emits one event per p4 call
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => {
      setLogs((L) => {
        const next = [...L, { ...e.payload, id: ++logId.current }];
        return next.length > 600 ? next.slice(-600) : next;
      });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // live file-transfer progress (Get Latest / Submit) streamed from the backend
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
      const p = e.payload;
      if (p.done) setTransfer(null);
      else setTransfer({ op: p.op, count: p.count, file: p.file });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // only surface the progress dialog once a transfer has run for a moment
  const xferActive = transfer !== null;
  useEffect(() => {
    if (!xferActive) { setShowXfer(false); return; }
    const id = setTimeout(() => setShowXfer(true), 400);
    return () => clearTimeout(id);
  }, [xferActive]);
  // detect 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 () => {
      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";
            if ((who || "").toLowerCase() !== (info?.userName || "").toLowerCase()) {
              flash(t("{user} submitted #{n}", { user: who, n }));
            }
            lastSubmit.current = n;
            if (tab === "history") loadHistory();
          }
        }
      } 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]);

  // 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); }
  }
  function chooseScope(path: string) {
    setModal(null);
    setActivePath(path);
    saveScope(info?.clientName || "", path); // remember the working folder per workspace
    setHistory([]); setDetail(null); setSel(null);
    refresh(path);
    if (tab === "history") loadHistory(path);
  }


  async function loadHistory(scope?: string) {
    const f = scope !== undefined ? scope : activePath;
    const path = f ? f + "/..." : "";
    setBusy(true);
    try { setHistory(await p4.history(path)); }
    catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  function switchTab(t: Tab) {
    setTab(t);
    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(); } // sync may pull new submitted changelists → refresh History too
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  // 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 }); } });
  }

  // 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) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
    setBuildPick(true);
  }
  // build the solution (MSBuild) with the chosen UE configuration, live output
  async function buildSln(config: string) {
    setBuildPick(false);
    if (!slnPath) return;
    setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
    try { await p4.buildSln(slnPath, config); }
    catch { /* the overlay already shows the failure line */ }
  }

  // reveal a depot path / workspace root in Windows Explorer
  async function reveal(target: string) {
    if (!target) { flash(t("Path not set"), true); return; }
    try { await p4.openInExplorer(target); }
    catch (e) { flash(String(e), true); }
  }
  async function copyText(text: string, what: string) {
    try { await navigator.clipboard.writeText(text); flash(t("{what} copied", { what })); }
    catch { flash(t("Copy failed"), true); }
  }

  // roll back an already-submitted changelist (Perforce `p4 undo` → new revert change)
  async function revertChange(c: Change) {
    const n = c.change || "";
    if (!(await confirm({ title: t("Revert changelist #{n}?", { n }), body: t("Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”", { n, desc: (c.desc || "").trim() || t("no description") }), confirm: t("Revert #{n}", { n }), danger: true }))) return;
    setBusy(true);
    try {
      await p4.undoChange(n);
      flash(t("Changelist #{n} reverted by a new changelist.", { n }));
      await loadHistory();
      await refresh();
    } catch (e) { flash(String(e), true); setBusy(false); }
  }

  // Commit (local): move checked uncommitted files into a new pending changelist
  // with the description. Nothing goes to the server — revertable. (like git commit)
  async function doCommit() {
    const list = files
      .filter((f) => (f.change || "default") === "default")
      .map((f) => f.depotFile || "")
      .filter((d) => checked.has(d));
    if (!list.length || !desc.trim()) return;
    const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim();
    setBusy(true);
    try {
      const cl = await p4.commit(message, list);
      flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
      // 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); }
  }

  // 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); }
  }
  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); }
  }
  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"), 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) },
      ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), 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)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
      { label: t("Open in Explorer"), act: () => reveal(dp) },
      { label: t("Copy path"), act: () => copyText(dp, t("Path")) },
    ]);
  }
  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); }
  }
  // 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; act?: () => void }[]> = {
    File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }],
    Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }],
    Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }],
    Window: [
      { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") },
      { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") },
      ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []),
      { label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) },
    ],
    Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }],
    Help: [{ label: t("About"), icon: I.info, 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?.()}>
                    {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("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
            { label: t("Switch workspace…"), act: openWorkspaces },
            { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
          ])}>
          <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, [
            ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
            ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
            { label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
            { label: t("Choose another folder…"), act: () => browseTo("") },
            { label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
          ])}>
          <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>

      {offline && (
        <div className="netbar off">
          <span className="ldr sm" />{t("Disconnected from the server — retrying…")}
        </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>
      )}
      <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>
          </div>

          {tab === "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"), act: () => editDesc(cl) },
                              { label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
                              { label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
                              { label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
                              { label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
                              { label: t("Discard changes · {n} files", { n: cf.length }), 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…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
                </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…")}</> : <>
                    <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}>
                  {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>
            </>
          ) : (
            <HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
              onContext={(c, e) => openCtx(e, [
                { label: t("Open this changelist"), 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 || "" }), danger: true, act: () => revertChange(c) },
                { label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
                { label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
              ])} />
          )}
        </div>

        {tab === "changes" ? (
          <Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} 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} uproject={uproject} />
            </>)}
          </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([])} uproject={uproject} />}

      <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" />
        {uproject && <button className="stlog" onClick={() => openDock("unreal")} title={t("Unreal Log")}>{I.hex}<span>{t("Unreal")}</span></button>}
        <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>
        {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" && <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 === "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 === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
      {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 && <TransferDialog transfer={transfer} />}
      {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 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">
              {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)}</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, uproject }: {
  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; uproject: string;
}) {
  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 },
    ...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []),
  ];
  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>
      {tab === "log" && <LogTab logs={logs} />}
      {tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
      {tab === "unreal" && <UnrealTab uproject={uproject} />}
    </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>
  );
}

/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */
function UnrealTab({ uproject }: { uproject: string }) {
  const [text, setText] = useState("");
  const [err, setErr] = useState("");
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let live = true;
    const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e)));
    load();
    const iv = window.setInterval(load, 1500);
    return () => { live = false; clearInterval(iv); };
  }, [uproject]);
  useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]);
  return (
    <div className="termbody" ref={ref}>
      {err ? <div className="termhint err">{err}</div>
        : text ? <pre className="termout ue">{text}</pre>
          : <div className="termhint">{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}</div>}
    </div>
  );
}

/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
  const up = transfer.op === "submit";
  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="currentColor" 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="currentColor" 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>{t("{n} files", { n: transfer.count })}</span>
          </div>
        </div>
        <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
        <div className="xfer-bar"><span /></div>
      </div>
    </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 ---------------- */
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 }) {
  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(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // 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)));
  const lockedCount = files.filter(isLocked).length;
  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>
          <span className="ph-sub">{t("{n} open · {m} locked", { n: files.length, m: lockedCount })}</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" 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>
      </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 ---------------- */
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);
  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); }
  }
  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>
          <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("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>
          : <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.
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, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
  if (busy && !history.length) return <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) => (
        <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)}</span>
          </span>
        </div>
      ))}
    </div>
  );
}

/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
function Thumb({ file }: { file: OpenedFile }) {
  const dp = file.depotFile || "";
  const { name } = splitPath(dp);
  const kind = kindOf(name);
  const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
  useEffect(() => {
    if (kind !== "image" && 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) => {
          const ext = (name.split(".").pop() || "png").toLowerCase();
          return 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]);

  if (url) return <img className="fthumb" src={url} alt="" />;
  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, 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; 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>
          )}
          {!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 === "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>
  );
}

/* ---------------- .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>
  );
}

