import { useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent, CSSProperties, ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";

// native OS notification (works even when the window is hidden in the tray)
let notifyReady: boolean | null = null;
async function notify(title: string, body: string) {
  try {
    if (notifyReady === null) {
      notifyReady = await isPermissionGranted();
      if (!notifyReady) notifyReady = (await requestPermission()) === "granted";
    }
    if (notifyReady) sendNotification({ title, body });
  } catch { /* notifications unavailable — the in-app toast still shows */ }
}

/* ---------------- window controls (frameless) ---------------- */
function WinControls() {
  const w = getCurrentWindow();
  return (
    <div className="wctrls">
      <button className="wbtn" onClick={() => w.minimize()} title={t("Minimize")}><svg viewBox="0 0 12 12"><rect x="2" y="5.6" width="8" height="1" fill="currentColor" /></svg></button>
      <button className="wbtn" onClick={() => w.toggleMaximize()} title={t("Maximize")}><svg viewBox="0 0 12 12" fill="none"><rect x="2.5" y="2.5" width="7" height="7" stroke="currentColor" strokeWidth="1" /></svg></button>
      <button className="wbtn close" onClick={() => w.close()} title={t("Close")}><svg viewBox="0 0 12 12" fill="none"><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg></button>
    </div>
  );
}

/* ---------------- icons ---------------- */
const I = {
  hex: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.6"/></svg>,
  monitor: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  branch: <svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 0a9 9 0 0 1-9 9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  sync: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5M20 12a8 8 0 0 1-14 5.3L3 15M3 20v-5h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  chev: <svg viewBox="0 0 24 24" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  search: <svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
  check: <svg viewBox="0 0 24 24" fill="none"><path d="m5 12 5 5L20 7" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  moon: <svg viewBox="0 0 24 24" fill="none"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"/></svg>,
  sun: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.7"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
  arrow: <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  spinner: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,.3)" strokeWidth="2"/><path d="M12 3a9 9 0 0 1 9 9" stroke="#fff" strokeWidth="2" strokeLinecap="round"/></svg>,
  cube: <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.5"/></svg>,
  revert: <svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 14-5.3L21 9M21 4v5h-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  back: <svg viewBox="0 0 24 24" fill="none"><path d="M19 12H5M11 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  chevron: <svg viewBox="0 0 24 24" fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  spark: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M18.5 15.5l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2Z" fill="currentColor"/></svg>,
  people: <svg viewBox="0 0 24 24" fill="none"><circle cx="9" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0M16 5.2a3.2 3.2 0 0 1 0 6.1M17.5 14.2A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  terminal: <svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m7 9 3 3-3 3M13 15h4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  clock: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6"/><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  log: <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 8h8M8 12h8M8 16h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  vscode: <svg viewBox="0 0 24 24" fill="none"><path d="m9 8-5 4 5 4M15 8l5 4-5 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  hammer: <svg viewBox="0 0 24 24" fill="none"><path d="M14 6l4 4M17 3l4 4-3 3-4-4 3-3ZM13 7 4 16a2 2 0 0 0 0 3l1 1a2 2 0 0 0 3 0l9-9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  lock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>,
  unlock: <svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  shelf: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  folder: <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>,
  power: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v9M7.5 6.5a7 7 0 1 0 9 0" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
  up: <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  gear: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3"/></svg>,
  info: <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6"/><path d="M12 11v5M12 8h.01" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>,
  scan: <svg viewBox="0 0 24 24" fill="none"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M4 12h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
  trash: <svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M18 7l-.8 12a2 2 0 0 1-2 1.9H8.8a2 2 0 0 1-2-1.9L6 7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 11v5M14 11v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>,
  copy: <svg viewBox="0 0 24 24" fill="none"><rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  reveal: <svg viewBox="0 0 24 24" fill="none"><path d="M3 8a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2M3 8v9a2 2 0 0 0 2 2h13.5a1.5 1.5 0 0 0 1.46-1.14L21.5 12H8.2a1.5 1.5 0 0 0-1.46 1.14L5 20" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  move: <svg viewBox="0 0 24 24" fill="none"><path d="M14 5h5a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5M3 12h11M10 8l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  pencil: <svg viewBox="0 0 24 24" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  down: <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};

/* ---------------- theme hook (built-in + custom themes) ---------------- */
function useTheme(): [boolean, () => void, string, (id: string) => void] {
  const [themeId, setThemeId] = useState(() => loadActiveId());
  useEffect(() => { applyTheme(resolveTheme(themeId)); saveActiveId(themeId); }, [themeId]);
  const light = resolveTheme(themeId).base === "light";
  const toggleTheme = () => setThemeId((id) => (resolveTheme(id).base === "light" ? "dark" : "light"));
  return [light, toggleTheme, themeId, setThemeId];
}

/* ---------------- language hook (whole tree re-renders on change) ---------------- */
function useLang(): [Lang, (l: Lang) => void] {
  const [lang, setLang] = useState<Lang>(LANG);
  return [lang, (l) => { setLangGlobal(l); setLang(l); }];
}

/* ---------------- UI zoom hook (scales the whole webview) ---------------- */
function useZoom(): [number, (z: number) => void] {
  const [zoom, setZoom] = useState(() => {
    const v = Number(localStorage.getItem("exd-zoom")); return v >= 0.5 && v <= 2 ? v : 1;
  });
  useEffect(() => {
    (document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom);
    // CSS `zoom` scales layout but leaves `100vh` measuring the *unzoomed* viewport,
    // so at zoom<1 a gap grows at the bottom. Expose the factor so the root can
    // compensate its height (see `.win` in App.css).
    document.documentElement.style.setProperty("--zoom", String(zoom));
    try { localStorage.setItem("exd-zoom", String(zoom)); } catch {}
  }, [zoom]);
  return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))];
}

/* ============================================================= */
export default function App() {
  const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
  const [info, setInfo] = useState<P4Info | null>(null);
  const [session, setSession] = useState<Session | null>(null);
  const [light, toggleTheme, themeId, setThemeId] = useTheme();
  const [lang, setLang] = useLang();
  const [zoom, setZoom] = useZoom();
  const saved = useMemo(() => loadSession(), []);

  // try to restore the last session from the on-disk p4 ticket.
  // keep the splash on screen for at least a beat so the intro animation reads.
  useEffect(() => {
    if (!saved) { const id = setTimeout(() => setPhase("connect"), 900); return () => clearTimeout(id); }
    const min = new Promise<void>((r) => setTimeout(r, 1000));
    p4.restore(saved.server, saved.user, saved.client)
      .then(async (i) => { await min; setInfo(i); setSession(saved); setPhase("main"); })
      .catch(async () => { await min; setPhase("connect"); });
  }, []); // eslint-disable-line

  let content;
  if (phase === "loading")
    content = <Splash light={light} toggleTheme={toggleTheme} />;
  else if (phase === "connect")
    content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
      onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
  else
    content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
      onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
      onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;

  return <>{content}<UpdateBanner /></>;
}

/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
function Select({ value, options, onChange, icon, placeholder }:
  { value: string; options: { value: string; label: string; sub?: string }[]; onChange: (v: string) => void; icon?: ReactNode; placeholder?: string }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, []);
  const cur = options.find((o) => o.value === value);
  return (
    <div className={"xsel" + (open ? " open" : "")} ref={ref}>
      <button type="button" className="xsel-trig" onClick={() => setOpen((o) => !o)}>
        {icon}
        <span className="xsel-val">{cur ? cur.label : <span className="xsel-ph">{placeholder}</span>}</span>
        <span className="xsel-chev">{I.chev}</span>
      </button>
      {open && (
        <div className="xsel-list">
          {options.length === 0 && <div className="xsel-empty">{placeholder}</div>}
          {options.map((o) => (
            <div key={o.value} className={"xsel-item" + (o.value === value ? " on" : "")} onClick={() => { onChange(o.value); setOpen(false); }}>
              <span className="xsel-ic">{o.value === value ? I.check : null}</span>
              <span className="xsel-body"><span className="xsel-name">{o.label}</span>{o.sub && <span className="xsel-sub">{o.sub}</span>}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */
function Logo() {
  return (
    <svg className="exlogo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
      <defs>
        <linearGradient id="exG" x1="6" y1="24" x2="26" y2="8" gradientUnits="userSpaceOnUse">
          <stop offset="0" stopColor="#8b6cf6" />
          <stop offset="1" stopColor="#49a6ff" />
        </linearGradient>
      </defs>
      <g stroke="url(#exG)" strokeWidth="2.7" strokeLinecap="round" strokeLinejoin="round">
        <path d="M6 16 H10 L14.5 9.5 H20" />
        <path d="M10 16 L14.5 22.5 H20" />
        <circle cx="22.5" cy="9.5" r="3" />
        <circle cx="22.5" cy="22.5" r="3" />
      </g>
      <g stroke="url(#exG)" strokeWidth="1.9" strokeLinecap="round" opacity="0.5">
        <path d="M15 14 H19" />
        <path d="M15 16 H20" />
        <path d="M15 18 H18" />
      </g>
    </svg>
  );
}

/* ---------------- animated startup splash ---------------- */
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
  const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
  const [step, setStep] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setStep((s) => s + 1), 750);
    return () => clearInterval(id);
  }, []);
  return (
    <div className="win">
      <div className="titlebar" data-tauri-drag-region>
        <div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span>— Perforce</span></div></div>
        <button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
        <WinControls />
      </div>
      <div className="overlay">
        <div className="splash">
          <div className="splash-logo">
            <svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" strokeWidth="1.4"/></svg>
          </div>
          <div className="flow">
            <div className="node"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 21h8M12 17v4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg></div>
            <div className="wire">
              {[0, 1, 2, 3, 4, 5].map((i) => <span key={i} className="packet" style={{ animationDelay: `${i * 0.28}s` }} />)}
            </div>
            <div className="node"><svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6"/></svg></div>
          </div>
          <div className="splash-title">Exbyte&nbsp;Depot</div>
          <div className="splash-step">{steps[Math.min(step, steps.length - 1)]}</div>
          <div className="splash-bar"><span /></div>
        </div>
      </div>
    </div>
  );
}

/* ---------------- connect screen ---------------- */
function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; toggleTheme: () => void; initial: Session | null; onDone: (i: P4Info, s: Session) => void }) {
  const [server, setServer] = useState(initial?.server || "ssl:pf.exbytestudios.com:1666");
  const [user, setUser] = useState(initial?.user || "Bonchellon");
  const [password, setPassword] = useState("");
  const [client, setClient] = useState(initial?.client || "");
  const [clients, setClients] = useState<Client[]>([]);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  async function loadClients() {
    try {
      const cs = await p4.clients(server, user);
      setClients(cs);
      if (cs.length && !client) setClient(cs[0].client || "");
    } catch { /* ignore */ }
  }
  // auto-load the workspace list as soon as the screen opens
  useEffect(() => { loadClients(); /* eslint-disable-next-line */ }, []);

  async function connect() {
    setBusy(true); setErr("");
    try {
      const i = await p4.login(server, user, password, client);
      onDone(i, { server, user, client });
    } catch (e) { setErr(String(e)); }
    finally { setBusy(false); }
  }

  return (
    <div className="win">
      <div className="titlebar" data-tauri-drag-region>
        <div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span>— Perforce</span></div></div>
        <button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
        <WinControls />
      </div>
      <div className="overlay">
        <div className="card">
          <div className="clogo">{I.cube}</div>
          <h2>{t("Connect to Perforce")}</h2>
          <div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>

          <div className="fld"><label>{t("Server")}</label>
            <div className="inp">{I.monitor}<input value={server} onChange={(e) => setServer(e.target.value)} /></div></div>
          <div className="fld"><label>{t("User")}</label>
            <div className="inp">
              <svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6"/><path d="M4 21a8 8 0 0 1 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/></svg>
              <input value={user} onChange={(e) => setUser(e.target.value)} onBlur={loadClients} /></div></div>
          <div className="fld"><label>{t("Password")}</label>
            <div className="inp">
              <svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6"/></svg>
              <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && connect()} /></div></div>
          <div className="fld"><label>{t("Workspace")}</label>
            {clients.length ? (
              <Select value={client} onChange={setClient} placeholder={t("Choose workspace")}
                icon={<svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>}
                options={clients.map((c) => ({ value: c.client || "", label: c.client || "", sub: (c.Root as string) || "" }))} />
            ) : (
              <div className="inp">
                <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6"/></svg>
                <input placeholder={t("workspace name (not a folder path!)")} value={client} onChange={(e) => setClient(e.target.value)} />
              </div>
            )}</div>

          {err && <div className="err"><span>⚠</span><span>{err}</span></div>}
          <button className="connect" disabled={busy || !password} onClick={connect}>
            {busy ? <span className="spin">{I.spinner}</span> : I.arrow}{busy ? t("Connecting…") : t("Connect")}
          </button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- main workbench ---------------- */
type Tab = "changes" | "history";

type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };

type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view area
type ModalState =
  | { kind: "workspaces"; items: Client[]; current: string }
  | { kind: "scope"; path: string; dirs: Dir[] }
  | { kind: "settings"; section?: "general" | "appearance" | "plugins" }
  | { kind: "users" }
  | { kind: "search" }
  | { kind: "locks" }
  | { kind: "typemap" }
  | { kind: "labels" }
  | { kind: "branch" }
  | { kind: "streams" }
  | { kind: "jobs" }
  | { kind: "clean" }
  | { kind: "reconcile" }
  | { kind: "explorer" }
  | { kind: "folderprops"; path: string; name: string }
  | { kind: "ignore" }
  | { kind: "filelog"; depot: string; name: string }
  | { kind: "client"; name: string; isNew: boolean }
  | { kind: "blame"; spec: string; name: string }
  | { kind: "diff"; title: string; text: string }
  | { kind: "about" };

type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
type Transfer = { op: "sync" | "submit"; count: number; total?: number; file?: string; log: string[]; cancelling?: boolean };
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };

function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
  { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
  const [tab, setTab] = useState<Tab>("changes");
  const [modal, setModal] = useState<ModalState | null>(null);
  const [, setPluginVer] = useState(0); // bumped when the plugin registry changes → re-render menus
  const [pluginView, setPluginView] = useState<PluginView | null>(null); // open plugin view modal
  const pluginsLoaded = useRef(false);
  const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
  const [files, setFiles] = useState<OpenedFile[]>([]);
  const [checked, setChecked] = useState<Set<string>>(new Set());
  const [sel, setSel] = useState<number | null>(null);       // primary row (preview + shift anchor)
  const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
  const [previewCL, setPreviewCL] = useState<OpenedFile | null>(null); // preview override (pending-CL file)
  const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
  const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => void }>(null);
  const [filter, setFilter] = useState("");
  const [desc, setDesc] = useState(() => { try { return localStorage.getItem("exd-draft-summary") || ""; } catch { return ""; } });        // commit summary (first line) — persisted draft
  const [descBody, setDescBody] = useState(() => { try { return localStorage.getItem("exd-draft-body") || ""; } catch { return ""; } }); // extended description (optional) — persisted draft
  // keep the typed commit message as a draft so a refresh/restart never loses it; it is cleared only on Submit
  useEffect(() => { try { localStorage.setItem("exd-draft-summary", desc); } catch {} }, [desc]);
  useEffect(() => { try { localStorage.setItem("exd-draft-body", descBody); } catch {} }, [descBody]);
  const [busy, setBusy] = useState(false);
  const [scanning, setScanning] = useState(false); // background reconcile in progress
  const [scanCount, setScanCount] = useState(0); // live "found N files" during a streaming scan
  const [openMenu, setOpenMenu] = useState<string | null>(null);
  const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
  const [history, setHistory] = useState<Change[]>([]);
  const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
  const sizesRef = useRef<Record<string, number>>({}); // mirror of changeSizes so we only fetch new changes (no flicker)
  const histSizeScope = useRef<string>(""); // scope the cached sizes belong to
  const [detail, setDetail] = useState<Describe | null>(null);
  const [selChange, setSelChange] = useState<string>("");   // highlighted History row (instant)
  const [detailBusy, setDetailBusy] = useState(false);      // right-panel files loading
  const [histFile, setHistFile] = useState<OpenedFile | null>(null); // drilled-into submitted file (read-only preview)
  const [dragging, setDragging] = useState(false);
  const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
  const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
  const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
  const [commitProg, setCommitProg] = useState<{ count: number; total: number } | null>(null); // live commit (reopen) progress
  const [xferMin, setXferMin] = useState(false); // transfer dialog collapsed to a floating chip (keeps running)
  const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
  const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
  const uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures
  const [slnPath, setSlnPath] = useState("");   // local .sln path in the scope (for building)
  const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
  const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
  const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users
  const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
  const [resolveOpen, setResolveOpen] = useState(false);
  const [offline, setOffline] = useState(false); // lost connection to the server
  const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
  const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
  const lastSubmit = useRef<string>("");   // newest submitted CL seen (new-submit toast)
  const notifPrimed = useRef(false);        // don't toast on the very first poll
  const [buildLog, setBuildLog] = useState<string[]>([]); // live MSBuild output
  const [building, setBuilding] = useState(false);
  const [buildOk, setBuildOk] = useState<boolean | null>(null);
  const [buildOpen, setBuildOpen] = useState(false);
  const [buildMin, setBuildMin] = useState(false); // collapsed to a small floating chip
  const [buildPick, setBuildPick] = useState(false); // configuration picker before building
  const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
  const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
  const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
  // bottom dock: a tabbed panel (Log / Terminal / Unreal), opened from Window menu
  const [dockOpen, setDockOpen] = useState(false);
  const [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("log");
  // right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab.
  const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => {
    try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {}
    return ["viewer"];
  });
  const [viewTab, setViewTab] = useState<ViewTab>("viewer");
  const [treeSide, setTreeSide] = useState<"depot" | "workspace">("depot"); // which side the embedded File Tree shows
  const [treeNonce, setTreeNonce] = useState(0); // bump to force the tree to (re)focus a side
  const [dockH, setDockH] = useState<number>(() => { const v = Number(localStorage.getItem("exd-dock-h")); return v >= 120 ? v : 240; }); // dock panel height
  useEffect(() => { try { localStorage.setItem("exd-dock-h", String(Math.round(dockH))); } catch {} }, [dockH]);
  const [termLines, setTermLines] = useState<TermLine[]>([]); // terminal output blocks
  const [termInput, setTermInput] = useState("");
  const [cmdHist, setCmdHist] = useState<string[]>([]); // recalled with ↑/↓
  const [termBusy, setTermBusy] = useState(false);
  const termId = useRef(0);
  const [peek, setPeek] = useState(false); // hover-over-loader log preview
  const logId = useRef(0);
  const peekTimer = useRef<number | null>(null);
  function showPeek() { if (peekTimer.current) { clearTimeout(peekTimer.current); peekTimer.current = null; } setPeek(true); }
  function hidePeek() { if (peekTimer.current) clearTimeout(peekTimer.current); peekTimer.current = window.setTimeout(() => setPeek(false), 220); }
  // resizable panels (persisted): left panel width + Get-Latest zone width
  const [leftW, setLeftW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-left-w")); return v >= 280 ? v : 380; });
  const [syncW, setSyncW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-sync-w")); return v >= 150 ? v : 220; });
  const [histW, setHistW] = useState<number>(() => { const v = Number(localStorage.getItem("exd-hist-w")); return v >= 240 ? v : 340; }); // History file-list column width
  useEffect(() => { try { localStorage.setItem("exd-left-w", String(Math.round(leftW))); } catch {} }, [leftW]);
  useEffect(() => { try { localStorage.setItem("exd-sync-w", String(Math.round(syncW))); } catch {} }, [syncW]);
  useEffect(() => { try { localStorage.setItem("exd-hist-w", String(Math.round(histW))); } catch {} }, [histW]);

  function startResize(e: ReactMouseEvent, kind: "left" | "sync" | "hist") {
    e.preventDefault();
    const startX = e.clientX, startLeft = leftW, startSync = syncW, startHist = histW;
    const move = (ev: MouseEvent) => {
      if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
      else if (kind === "hist") setHistW(Math.max(240, Math.min(startHist + (ev.clientX - startX), 620)));
      else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
    };
    const up = () => {
      document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up);
      window.removeEventListener("blur", up); document.body.classList.remove("resizing");
    };
    document.addEventListener("mousemove", move); document.addEventListener("mouseup", up);
    window.addEventListener("blur", up); // mouse released outside the window → end the drag
    document.body.classList.add("resizing");
  }

  // drag the top edge of the bottom dock to make it taller/shorter
  function startDockResize(e: ReactMouseEvent) {
    e.preventDefault();
    const startY = e.clientY, startH = dockH;
    const move = (ev: MouseEvent) => setDockH(Math.max(120, Math.min(startH - (ev.clientY - startY), window.innerHeight - 220)));
    const up = () => {
      document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up);
      window.removeEventListener("blur", up); document.body.classList.remove("resizing-v");
    };
    document.addEventListener("mousemove", move); document.addEventListener("mouseup", up);
    window.addEventListener("blur", up);
    document.body.classList.add("resizing-v");
  }

  function flash(text: string, err = false) {
    setToast({ text, err });
    setTimeout(() => setToast(null), 4500);
  }
  function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise<boolean> {
    return new Promise((resolve) => setAsk({ ...opts, resolve }));
  }

  // open the bottom dock on a given tab (toggle off if already showing it)
  function openDock(tab: "log" | "terminal" | "unreal") {
    setDockOpen((o) => (o && dockTab === tab ? false : true));
    setDockTab(tab);
  }
  // run a p4 command typed into the terminal (combined stdout+stderr streamed back)
  async function runConsole(raw: string) {
    const line = raw.trim();
    if (!line) return;
    setCmdHist((h) => [...h.filter((x) => x !== line), line].slice(-100));
    setTermInput("");
    // split into args honouring quotes; drop a leading "p4" if the user typed it
    const args = (line.match(/(?:[^\s"]+|"[^"]*")+/g) || []).map((a) => a.replace(/^"|"$/g, ""));
    if (args[0]?.toLowerCase() === "p4") args.shift();
    if (!args.length) return;
    const id = ++termId.current;
    setTermLines((l) => [...l.slice(-200), { id, cmd: line, text: "", running: true }]);
    setTermBusy(true);
    try {
      const out = await p4.console(args);
      setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: out.trimEnd() || t("(no output)"), running: false } : x)));
    } catch (e) {
      setTermLines((l) => l.map((x) => (x.id === id ? { ...x, text: String(e), err: true, running: false } : x)));
    } finally { setTermBusy(false); }
  }

  // global keyboard shortcuts. Matches on e.code (layout-independent — works on a
  // Russian keyboard where Backquote types "ё"); F5/Ctrl+R are intercepted so the
  // WebView doesn't reload the whole app. Typing shortcuts are ignored while a
  // text field is focused; the dock toggles (Ctrl+L/`) still work everywhere.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const el = e.target as HTMLElement | null;
      const typing = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
      const mod = e.ctrlKey || e.metaKey;
      // dock toggles — allowed even while typing
      if (mod && e.code === "KeyL") { e.preventDefault(); openDock("log"); return; }
      if (mod && e.code === "Backquote") { e.preventDefault(); openDock("terminal"); return; }
      // refresh — F5 or Ctrl+R (block the webview's built-in reload)
      if (e.code === "F5" || (mod && e.code === "KeyR")) { e.preventDefault(); if (!typing) refresh(); return; }
      if (typing) return;
      if (mod && e.code === "KeyG") { e.preventDefault(); getLatest(); }
      else if (mod && e.code === "KeyS") { e.preventDefault(); pushAll(); }
      else if (mod && e.code === "KeyB") { e.preventDefault(); startBuild(); }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
    // eslint-disable-next-line
  }, [dockOpen, dockTab, activePath, pending, slnPath]);

  // load the currently-open files + pending changelists (fast — just reads p4 state)
  // NB: preserves the user's checkbox selection — only NEW default files are
  // auto-checked and vanished files are dropped, so a focus-refresh never
  // silently re-checks files the user deselected before a commit.
  // apply an already-fetched `p4 opened` result to the UI state
  async function applyOpened(_s: string, f: OpenedFile[]) {
    setFiles(f);
    const defaults = f.filter((x) => (x.change || "default") === "default").map((x) => x.depotFile || "").filter(Boolean);
    setChecked((prev) => {
      const known = seenDefaults.current;
      const next = new Set<string>();
      for (const dp of defaults) {
        // keep user's choice for files we've seen; auto-check genuinely new ones
        if (!known.has(dp) || prev.has(dp)) next.add(dp);
      }
      seenDefaults.current = new Set(defaults);
      return next;
    });
    setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
    setSelRows(new Set());
    setPreviewCL(null);
    try { setPending(await p4.changes()); } catch { setPending([]); }
    void loadCollab(_s, f);
  }
  // who-else-has-these-files + pending conflicts (non-blocking; failures are silent)
  async function loadCollab(s: string, mine: OpenedFile[]) {
    const me = (info?.userName || "").toLowerCase();
    const myClient = (info?.clientName || "").toLowerCase();
    try {
      const all = await p4.openedAll(s);
      const map = new Map<string, { user: string; locked: boolean }>();
      for (const o of all) {
        const dp = o.depotFile || "";
        if (!dp) continue;
        const sameUser = (o.user || "").toLowerCase() === me;
        const sameClient = (o.client || "").toLowerCase() === myClient;
        if (sameUser && sameClient) continue; // it's my own checkout
        // `p4 opened -a` includes an `ourLock` field only when the file is locked
        const locked = (o as Record<string, unknown>).ourLock !== undefined;
        map.set(dp, { user: o.user || "?", locked });
      }
      setOthers(map);
    } catch { /* older server or no permission — skip indicators */ }
    void mine;
    try { setNeedResolve(await p4.resolveList()); } catch { setNeedResolve([]); }
  }
  const seenDefaults = useRef<Set<string>>(new Set()); // depot paths seen in a prior load
  const refreshSeq = useRef(0); // guards against out-of-order refresh() results
  const openChangeSeq = useRef(0); // guards against out-of-order describe() results
  const committedDraft = useRef<{ s: string; b: string } | null>(null); // draft last moved into a commit
  // clear the commit draft after Submit, but ONLY if the user hasn't started
  // typing a new message since committing (so a fresh next-batch draft survives)
  function clearDraftIfCommitted() {
    const cd = committedDraft.current;
    if (cd && cd.s === desc && cd.b === descBody) { setDesc(""); setDescBody(""); }
    committedDraft.current = null;
  }

  // refresh the Changes view. `scan=true` also auto-detects out-of-band changes
  // (new/edited/deleted files on disk — e.g. assets Unreal wrote directly) via a
  // background reconcile, so they show up on their own. The reconcile runs off the
  // UI thread (async command) and never freezes the window.
  async function refresh(scope?: string, scan = false) {
    const s = scope !== undefined ? scope : activePath;
    const seq = ++refreshSeq.current; // newest refresh wins; stale results are dropped
    setBusy(true);
    try { const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
    catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
    finally { if (seq === refreshSeq.current) setBusy(false); }
    if (scan) {
      setScanning(true);
      setScanCount(0);
      // stream the reconcile so the banner shows a live "found N files" counter
      // (and can be cancelled) instead of an opaque, never-ending spinner
      try { await p4.scanStream(s); const f = await p4.opened(s); if (seq === refreshSeq.current) await applyOpened(s, f); }
      catch (e) { if (seq === refreshSeq.current) flash(String(e), true); }
      finally { if (seq === refreshSeq.current) { setScanning(false); setScanCount(0); } }
    }
  }
  // Default = Perforce-native: just read what's checked out (p4 opened). With
  // Source Control enabled in Unreal, UE auto-checks-out files, so they appear
  // here on their own — no reconcile needed. Reconcile is a manual fallback.
  useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
  // ---- plugins: (re)load enabled plugins; they register menu/panel/hook contributions
  async function reloadPlugins() {
    const res = await loadPlugins({
      flash,
      notify: (title, body) => { void notify(title, body); },
      info: () => info,
      selectedFile: () => selFile || null,
      refresh: () => { void refresh(); },
      // Unreal bridge — core keeps buildSln/overlay/log; the plugin just drives them
      ue: {
        hasProject: () => !!uprojectRef.current,
        uproject: () => uprojectRef.current,
        build: () => { startBuild(); },
        readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
      },
    });
    setPluginVer((v) => v + 1);
    return res;
  }
  useEffect(() => { uprojectRef.current = uproject; }, [uproject]); // keep the ue-bridge mirror live
  useEffect(() => {
    if (pluginsLoaded.current || !info?.clientName) return;
    pluginsLoaded.current = true;
    (async () => {
      // auto-install official plugins (e.g. Unreal) on first run so no one loses tooling
      try {
        const installed = new Set((await p4.pluginList()).map((p) => p.id));
        const added = await autoInstallOfficial(installed);
        if (added.length) console.log("[plugins] auto-installed:", added);
      } catch { /* offline — skip */ }
      const { loaded, errors } = await reloadPlugins();
      if (errors.length) console.warn("[plugins] load errors:", errors);
      if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
    })().catch(() => {});
  }, [info?.clientName]); // eslint-disable-line
  useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
  // live Perforce command log — the Rust backend emits one event per p4 call
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => {
      setLogs((L) => {
        const next = [...L, { ...e.payload, id: ++logId.current }];
        return next.length > 600 ? next.slice(-600) : next;
      });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // live file-transfer progress (Get Latest / Submit) streamed from the backend.
  // Accumulates a rolling file log and tracks total for an "N of total" bar.
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ op: "sync" | "submit"; count: number; total?: number; file?: string; done: boolean; cancelled?: boolean }>("p4-transfer", (e) => {
      const p = e.payload;
      if (p.done) { setTransfer(null); setXferMin(false); return; }
      setTransfer((prev) => {
        const log = prev ? prev.log : [];
        const next = p.file && (log.length === 0 || log[log.length - 1] !== p.file)
          ? [...log, p.file].slice(-200) : log;
        return { op: p.op, count: p.count, total: p.total || prev?.total, file: p.file, log: next, cancelling: prev?.cancelling };
      });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // live commit progress: p4_commit reopens files in batches and emits one event
  // per batch, so we can show "N / total committed" instead of a mute spinner
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ count: number; total: number; done: boolean }>("p4-commit", (e) => {
      const p = e.payload;
      setCommitProg(p.done ? null : { count: p.count, total: p.total });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // live disk-scan progress: the streaming reconcile emits one event per file it
  // opens, so we can show a rising "found N files" counter during a Rescan
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ count: number; file?: string; done: boolean }>("p4-scan", (e) => {
      if (!e.payload.done) setScanCount(e.payload.count);
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // only surface the progress dialog once a transfer has run for a moment
  const xferActive = transfer !== null;
  useEffect(() => {
    if (!xferActive) { setShowXfer(false); return; }
    const id = setTimeout(() => setShowXfer(true), 400);
    return () => clearTimeout(id);
  }, [xferActive]);
  // detect installed code editors once (workspace-independent)
  useEffect(() => { p4.listEditors().then(setEditors).catch(() => setEditors([])); }, []);
  // resolve the effective editor: chosen → VS Code → first real → system default
  const curEditor = editors.find((e) => e.id === editorId && editorId)
    || editors.find((e) => e.id === "vscode")
    || editors.find((e) => e.id !== "default")
    || editors[0];
  const effEditorId = curEditor?.id || "default";
  const effEditorName = curEditor?.name || t("System default");
  const chooseEditor = (id: string) => { setEditorId(id); setEditorPref(id); };

  // detect a UE project / .sln in the current working folder (Launch / Build actions)
  useEffect(() => {
    if (!activePath) { setUproject(""); setSlnPath(""); return; }
    let live = true;
    p4.findUproject(activePath).then((p) => live && setUproject(p)).catch(() => live && setUproject(""));
    p4.findSln(activePath).then((p) => live && setSlnPath(p)).catch(() => live && setSlnPath(""));
    return () => { live = false; };
  }, [activePath]);
  // live MSBuild output
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ line: string; done: boolean; ok: boolean }>("build-log", (e) => {
      const p = e.payload;
      setBuildLog((L) => (L.length > 4000 ? [...L.slice(-4000), p.line] : [...L, p.line]));
      if (p.done) { setBuilding(false); setBuildOk(p.ok); }
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  useEffect(() => {
    const close = () => setOpenMenu(null);
    document.addEventListener("click", close);
    return () => document.removeEventListener("click", close);
  }, []);
  // keep the changelist live: re-scan opened files when the window regains focus
  useEffect(() => {
    const onFocus = () => { if (tab === "changes") refresh(); };
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
    // eslint-disable-next-line
  }, [tab]);

  // background poll: connection health (offline banner + auto-reconnect) and
  // new-submit notifications from teammates. One lightweight timer for both.
  useEffect(() => {
    let alive = true;
    const tick = async () => {
      if (workOffline) return; // explicit offline-work mode: don't poll the server
      try {
        const latest = await p4.latestChange(activePath);
        if (!alive) return;
        if (offline) { setOffline(false); flash(t("Reconnected to the server.")); refresh(); }
        const n = (latest && latest.change) || "";
        if (n) {
          if (!notifPrimed.current) { lastSubmit.current = n; notifPrimed.current = true; }
          else if (n !== lastSubmit.current && Number(n) > Number(lastSubmit.current || 0)) {
            const who = latest?.user || "someone";
            const mine = (who || "").toLowerCase() === (info?.userName || "").toLowerCase();
            if (!mine) {
              const desc = (latest?.desc || "").trim().replace(/\s+/g, " ").slice(0, 160) || t("(no description)");
              flash(t("{user} submitted #{n}", { user: who, n }));
              notify(t("{user} submitted #{n}", { user: who, n }), desc); // native toast — arrives even from the tray
            }
            lastSubmit.current = n;
            if (tab === "history") loadHistory();
            checkBehind(); // a new submit landed → am I now behind?
          }
        }
      } catch {
        if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
      }
    };
    const id = window.setInterval(tick, 20000);
    tick();
    return () => { alive = false; clearInterval(id); };
    // eslint-disable-next-line
  }, [activePath, offline, tab, workOffline]);

  // remember which view-panel tabs are open across restarts
  useEffect(() => { try { localStorage.setItem("exd-viewtabs", JSON.stringify(viewTabs)); } catch {} }, [viewTabs]);

  // In a shipped (production) build, kill the WebView's own right-click menu
  // (Back / Reload / Save as / Print / Inspect) and the devtools hotkeys — this
  // is a desktop app, not a web page. In dev we keep them for debugging.
  useEffect(() => {
    if (import.meta.env.DEV) return;
    const noCtx = (e: MouseEvent) => e.preventDefault(); // our own context menus render as React popups, unaffected
    const noKeys = (e: KeyboardEvent) => {
      const k = e.key.toUpperCase();
      if (k === "F12" || (e.ctrlKey && e.shiftKey && (k === "I" || k === "J" || k === "C")) || (e.ctrlKey && k === "U")) {
        e.preventDefault();
      }
    };
    document.addEventListener("contextmenu", noCtx);
    window.addEventListener("keydown", noKeys, true);
    return () => { document.removeEventListener("contextmenu", noCtx); window.removeEventListener("keydown", noKeys, true); };
  }, []);

  // check "am I behind the server" when connected or the working folder changes
  useEffect(() => { if (info?.clientName && !workOffline) checkBehind(); /* eslint-disable-next-line */ }, [activePath, info?.clientName, workOffline]);

  // window was closed to the tray → tell the user once per session it's still alive
  useEffect(() => {
    let shown = false;
    const un = listen("tray-hidden", () => {
      if (shown) return; shown = true;
      notify(t("Exbyte Depot is still running"), t("It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit."));
    });
    return () => { un.then((f) => f()); };
  }, []);

  // drag & drop files/folders onto the window → reconcile them into the changelist
  useEffect(() => {
    let unlisten: (() => void) | undefined;
    getCurrentWebview()
      .onDragDropEvent(async (e) => {
        const dt = (e.payload as { type: string }).type;
        if (dt === "over") { setDragging(true); return; }
        if (dt === "leave") { setDragging(false); return; }
        if (dt === "drop") {
          setDragging(false);
          const paths = (e.payload as { paths: string[] }).paths || [];
          if (!paths.length) return;
          setBusy(true);
          let n = 0;
          for (const p of paths) {
            try { n += (await p4.reconcile(p)).length; } catch (err) { flash(String(err), true); }
          }
          flash(n ? t("Added/updated in changelist: {n}", { n }) : t("No changes to add"));
          await refresh();
        }
      })
      .then((u) => (unlisten = u));
    return () => unlisten?.();
    // eslint-disable-next-line
  }, []);

  async function revertAll() {
    const list = files.map((f) => f.depotFile || "").filter(Boolean);
    if (!list.length) return;
    if (!(await confirm({ title: t("Revert all changes?"), body: t("Edits in all {n} files will be undone. Local changes are lost permanently.", { n: list.length }), confirm: t("Revert all"), danger: true }))) return;
    setBusy(true);
    try { await p4.revert(list); flash(t("All changes reverted.")); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }

  async function openChange(c: Change) {
    const n = c.change || "";
    const seq = ++openChangeSeq.current; // ignore a slow describe if another CL was picked
    setSelChange(n);        // highlight the row immediately — no waiting for the load
    setHistFile(null);      // leave any drilled-in file view
    setDetail(null);
    setDetailBusy(true);
    try { const d = await p4.describe(n); if (seq === openChangeSeq.current) setDetail(d); }
    catch (e) { if (seq === openChangeSeq.current) flash(String(e), true); }
    finally { if (seq === openChangeSeq.current) setDetailBusy(false); }
  }

  async function openWorkspaces() {
    if (!session) { flash(t("No session data for the workspace list"), true); return; }
    try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); }
    catch (e) { flash(String(e), true); }
  }
  async function switchWorkspace(client: string) {
    setModal(null); setBusy(true);
    try {
      const i = await p4.switchClient(client);
      onInfo(i);
      if (session) onSession({ ...session, client });
      const scope = loadScope(client); // restore this workspace's saved working folder
      setActivePath(scope);
      await refresh(scope);
      flash(t("Active workspace: {c}", { c: client }));
    } catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function browseTo(path: string) {
    try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); }
    catch (e) { flash(String(e), true); }
  }
  async function doSwitchStream(stream: string) {
    setModal(null); setBusy(true);
    try { const i = await p4.switchStream(stream); onInfo(i); flash(t("Switched to stream {s}", { s: stream })); await refresh(); await loadHistory(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function doStreamInteg(stream: string, dir: "down" | "up") {
    setBusy(true);
    try { const out = await p4.streamInteg(stream, dir); flash(t("{d} opened into a pending changelist — resolve & submit. {out}", { d: dir === "up" ? t("Copy up") : t("Merge down"), out: out.split("\n")[0] })); await refresh(); }
    catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  function chooseScope(path: string) {
    setModal(null);
    setActivePath(path);
    saveScope(info?.clientName || "", path); // remember the working folder per workspace
    if (path) pushRecentScope(info?.clientName || "", path); // Recently list (skip whole-workspace)
    setHistory([]); setDetail(null); setSel(null);
    refresh(path);
    if (tab === "history") loadHistory(path);
  }


  async function loadHistory(scope?: string) {
    const f = scope !== undefined ? scope : activePath;
    const path = f ? f + "/..." : "";
    setBusy(true);
    try {
      const h = await p4.history(path);
      setHistory(h);
      // commit weights load in the background so the list shows instantly. Sizes are
      // cached per changelist and only fetched for NEW changes — so a poll/refresh
      // on the same scope never re-fetches (which used to make the badges flicker).
      const scopeChanged = f !== histSizeScope.current;
      histSizeScope.current = f;
      if (scopeChanged) { sizesRef.current = {}; setChangeSizes({}); }
      const nums = h.map((c) => c.change || "").filter(Boolean);
      const need = nums.filter((n) => sizesRef.current[n] === undefined);
      if (need.length) {
        p4.changeSizes(need, f).then((rows) => {
          const m = { ...sizesRef.current };
          for (const r of rows) m[r.change] = r.size;
          sizesRef.current = m;
          setChangeSizes(m);
        }).catch(() => {});
      }
    } catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  function switchTab(t: Tab) {
    setTab(t);
    setHistFile(null);
    if (t === "history") loadHistory(); // always refresh so it never shows a stale list
  }

  async function getLatest() {
    setBusy(true);
    try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  // check whether the server has newer content than my workspace (files behind)
  const behindRef = useRef(0);
  async function checkBehind() {
    if (workOffline) return;
    try {
      const b = await p4.behind(activePath);
      const was = behindRef.current;
      behindRef.current = b.count;
      setBehind(b.count > 0 ? b : null);
      // notify only when we *newly* fall behind (not on every recheck)
      if (b.count > 0 && was === 0) {
        notify(t("Your workspace is behind"), t("{n} file(s) on the server are newer than your copy. Get Latest to update.", { n: b.count }));
      }
    } catch { /* ignore — non-critical */ }
  }
  // sync the workspace to an exact changelist / label / date (Get Revision)
  async function syncTo(rev: string) {
    const r = rev.trim();
    if (!r) return;
    if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return;
    setBusy(true);
    try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: await p4.syncTo(activePath, r) })); await refresh(); await loadHistory(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  function promptSyncTo() {
    setPrompt({ title: t("Get Revision"), label: t("Changelist number, label name, or date (YYYY/MM/DD)"), value: "", onSave: (v) => { setPrompt(null); syncTo(v); } });
  }
  // create a new workspace: ask for a name, then open the spec editor with a template
  function newWorkspace() {
    setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } });
  }
  // create a new folder / project in the workspace under `scope` (depot path, or "" = root)
  function newFolder(scope: string) {
    const where = scope || t("workspace root");
    setPrompt({ title: t("New folder in {where}", { where }), label: t("Folder name — nest with / (e.g. NewGame or NewGame/Source)"), value: "", onSave: async (v) => {
      setPrompt(null);
      const n = v.trim();
      if (!n) return;
      try {
        await p4.makeFolder(scope, n);
        flash(t("Created “{name}” with a README placeholder — commit it to keep the folder on the server.", { name: n }));
        await refresh();
      } catch (e) { flash(String(e), true); }
    } });
  }
  // rename / move a depot folder (p4 move) — review in Changes, then Submit
  function renameFolder(path: string) {
    const cur = splitPath(path).name || path;
    setPrompt({ title: t("Rename folder"), label: t("New name for “{name}”", { name: cur }), value: cur, onSave: async (v) => {
      setPrompt(null);
      const n = v.trim();
      if (!n || n === cur) return;
      try { const r = await p4.renameFolder(path, n); flash(t("Renamed to “{name}” — {n} file(s) moved. Commit to apply.", { name: n, n: r.length })); await refresh(); }
      catch (e) { flash(String(e), true); }
    } });
  }
  // delete a folder — marks its files for delete (reviewable), removes fresh/local ones
  async function deleteFolder(path: string) {
    const name = splitPath(path).name || path;
    if (!(await confirm({ title: t("Delete folder “{name}”?", { name }), body: t("Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away."), confirm: t("Delete"), danger: true }))) return;
    try { const r = await p4.deleteFolder(path); flash(r.length ? t("Marked {n} file(s) for delete — Submit to remove the folder.", { n: r.length }) : t("Folder removed.")); await refresh(); }
    catch (e) { flash(String(e), true); }
  }
  // right-click on a folder in the File Tree → folder operations
  function folderMenu(n: FsEntry, e: ReactMouseEvent) {
    const isDepot = n.path.startsWith("//");
    openCtx(e, [
      { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(n.path) },
      ...(isDepot ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(n.path) }] : []),
      { label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: n.path, name: n.name }) },
      { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(n.path) },
      { label: t("Copy path"), icon: I.copy, act: () => copyText(n.path, t("Path")) },
      { label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(n.path) },
    ]);
  }

  // Manual re-scan (same background reconcile as auto, on demand).
  function rescan() { refresh(activePath, true); }

  function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }

  // launch Unreal by opening the detected .uproject (file association starts UE)
  async function launchUE() {
    if (!uproject) return;
    try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); }
    catch (e) { flash(String(e), true); }
  }

  // open the configuration picker (Development / Shipping / …) before building
  function startBuild() {
    if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
    setBuildPick(true);
  }
  // build with the chosen UE configuration, live output. For a real UE project we
  // pass the .uproject so the backend builds the game module via UnrealBuildTool.
  async function buildSln(config: string) {
    setBuildPick(false);
    if (!slnPath && !uproject) return;
    setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
    try { await p4.buildSln(slnPath, config, uproject); }
    catch { /* the overlay already shows the failure line */ }
  }

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

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

  // Commit (local): move checked uncommitted files into a new pending changelist
  // with the description. Nothing goes to the server — revertable. (like git commit)
  async function doCommit() {
    const list = files
      .filter((f) => (f.change || "default") === "default")
      .map((f) => f.depotFile || "")
      .filter((d) => checked.has(d));
    if (!list.length || !desc.trim()) return;
    // plugin submit hooks can block (e.g. a reference-integrity guard)
    if (getRegistry().submitHooks.length) {
      const verdict = await runSubmitHooks(list.map((d) => ({ depotFile: d })));
      if (!verdict.ok) {
        if (!(await confirm({ title: t("A plugin flagged this commit"), body: verdict.message || t("A plugin blocked the commit."), confirm: t("Commit anyway"), danger: true }))) return;
      }
    }
    const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim();
    setBusy(true);
    try {
      const cl = await p4.commit(message, list);
      flash(t("Committed locally · changelist {cl}. Press Submit at the top to send it to the server.", { cl }));
      // keep the draft — a local commit is not final; it clears on Submit unless
      // the user has meanwhile started a new message (see clearDraftIfCommitted)
      committedDraft.current = { s: desc, b: descBody };
      await refresh();
    } catch (e) { flash(String(e), true); setBusy(false); }
  }

  // Ask the AI (OpenRouter) to draft a commit summary + description from the checked files.
  async function genAiSummary() {
    const picked = files.filter((f) => (f.change || "default") === "default" && checked.has(f.depotFile || ""));
    if (!picked.length) { flash(t("Select some files first."), true); return; }
    setAiBusy(true);
    try {
      const { summary, description } = await aiSummary(picked);
      if (summary) setDesc(summary);
      if (description) setDescBody(description);
      flash(t("AI drafted a commit message — review & edit before committing."));
    } catch (e) {
      const msg = String(e);
      flash(msg.includes("No OpenRouter API key") ? t("Set an OpenRouter API key in Settings → AI first.") : msg, true);
    } finally { setAiBusy(false); }
  }

  // Cancel an in-progress submit/sync. Safe for submit before the server commit:
  // p4 submit is transactional, so an aborted transfer leaves the CL pending.
  function cancelTransfer() {
    setTransfer((prev) => (prev ? { ...prev, cancelling: true } : prev));
    p4.transferCancel().catch(() => {});
  }

  // Submit / push: send all committed (pending) changelists to the server. (like git push)
  async function pushAll() {
    if (!pending.length) return;
    const n = pending.length;
    if (!(await confirm({ title: t("Submit — send to the server"), body: t("{n} changelist(s) will go to the Perforce depot and become available to the team. This is a push — it cannot be undone.", { n }), confirm: t("Submit {n}", { n }) }))) return;
    setBusy(true);
    const errors: string[] = [];
    for (const c of pending) {
      try { await p4.submitChange(c.change || ""); }
      catch (e) { errors.push(`CL ${c.change}: ${String(e)}`); }
    }
    if (errors.length) flash(errors.join(" · "), true);
    else { flash(t("Sent to the server: {n} changelist(s).", { n })); clearDraftIfCommitted(); } // draft done → clear on successful Submit
    await refresh();
    await loadHistory(); // the submit created a new submitted changelist — refresh History
  }

  // Submit a single pending changelist to the server
  async function submitOne(change: string) {
    if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
    setBusy(true);
    try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); } // draft clears on successful Submit
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  function attachJob(change: string) {
    setPrompt({ title: t("Attach job to #{n}", { n: change }), label: t("Job ID"), value: "", onSave: async (v) => {
      setPrompt(null); const j = v.trim(); if (!j) return;
      try { await p4.fix(j, change); flash(t("Job {j} linked to #{n}.", { j, n: change })); }
      catch (e) { flash(String(e), true); }
    } });
  }
  async function shelveCL(change: string) {
    setBusy(true);
    try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); }
    catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  // submit variants: reopen after submit (-r), revert-unchanged, or submit a shelf (-e)
  async function submitWithOpts(change: string, reopen: boolean, revertUnchanged: boolean) {
    if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
    setBusy(true);
    try { await p4.submitOpts(change, reopen, revertUnchanged); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function submitShelved(change: string) {
    if (!(await confirm({ title: t("Submit shelved #{n}?", { n: change }), body: t("The shelved files of #{n} are submitted directly from the server.", { n: change }), confirm: t("Submit") }))) return;
    setBusy(true);
    try { await p4.submitShelved(change); flash(t("Submitted shelved changelist #{n}.", { n: change })); await refresh(); await loadHistory(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function unshelveCL(change: string) {
    setBusy(true);
    try { await p4.unshelve(change); flash(t("Unshelved #{n} into the workspace.", { n: change })); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function deleteShelfCL(change: string) {
    if (!(await confirm({ title: t("Delete shelved files?"), body: t("The shelved copy of #{n} on the server will be removed. Your local files are untouched.", { n: change }), confirm: t("Delete shelf"), danger: true }))) return;
    setBusy(true);
    try { await p4.deleteShelf(change); flash(t("Deleted shelved files of #{n}.", { n: change })); }
    catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  // Uncommit: move a changelist's files back into the working set (default)
  async function uncommit(fs: OpenedFile[]) {
    const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
    if (!dps.length) return;
    setBusy(true);
    try { await p4.reopenDefault(dps); flash(t("Moved back to working changes.")); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  // Edit a pending changelist's description
  function editDesc(cl: Change) {
    setPrompt({
      title: t("Edit description"), label: t("Changelist #{n}", { n: cl.change || "" }), value: (cl.desc || "").trim(),
      onSave: async (v) => {
        setPrompt(null);
        if (!v.trim()) return;
        setBusy(true);
        try { await p4.setDesc(cl.change || "", v.trim()); flash(t("Description updated.")); await refresh(); }
        catch (e) { flash(String(e), true); setBusy(false); }
      },
    });
  }
  function toggleCL(change: string) {
    setExpandedCL((prev) => { const n = new Set(prev); n.has(change) ? n.delete(change) : n.add(change); return n; });
  }

  async function doRevert(file: OpenedFile) {
    const dp = file.depotFile || "";
    if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
    setBusy(true);
    try { await p4.revert([dp]); flash(t("Reverted: {name}", { name: splitPath(dp).name })); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }

  const uncommitted = useMemo(() => files.filter((f) => (f.change || "default") === "default"), [files]);
  const view = useMemo(() => {
    if (!filter.trim()) return uncommitted;
    const q = filter.toLowerCase();
    return uncommitted.filter((f) => (f.depotFile || "").toLowerCase().includes(q));
  }, [uncommitted, filter]);
  // committed pending changelists with their files (grouped from p4 opened)
  const pendingCards = useMemo(() =>
    pending.map((cl) => ({
      cl,
      files: files.filter((f) => String(f.change || "") === String(cl.change || "")),
    })), [pending, files]);
  const selFile = previewCL ?? (sel != null ? view[sel] : undefined);
  const allChecked = view.length > 0 && view.every((f) => checked.has(f.depotFile || ""));
  function toggleAll() {
    setChecked((s) => {
      const n = new Set(s);
      if (allChecked) view.forEach((f) => n.delete(f.depotFile || ""));
      else view.forEach((f) => n.add(f.depotFile || ""));
      return n;
    });
  }
  // row click with Shift (range) / Ctrl (toggle) multi-selection
  function rowClick(i: number, e: ReactMouseEvent) {
    if (e.shiftKey && sel != null) {
      const a = Math.min(sel, i), b = Math.max(sel, i);
      const s = new Set<number>();
      for (let k = a; k <= b; k++) s.add(k);
      setSelRows(s);
    } else if (e.ctrlKey || e.metaKey) {
      setSelRows((prev) => { const n = new Set(prev); n.has(i) ? n.delete(i) : n.add(i); return n; });
    } else {
      setSelRows(new Set([i]));
    }
    setSel(i);
    setPreviewCL(null); // show the working-set selection in the preview
  }
  // checkbox: if the row is part of a multi-selection, toggle the whole selection together
  function toggleCheck(i: number, dp: string) {
    const idxs = selRows.has(i) && selRows.size > 1 ? [...selRows] : [i];
    const dps = idxs.map((k) => view[k]?.depotFile || "").filter(Boolean);
    const target = !checked.has(dp);
    setChecked((prev) => { const n = new Set(prev); dps.forEach((d) => (target ? n.add(d) : n.delete(d))); return n; });
  }

  // Discard local changes (p4 revert) for the given files — the "undo" for opened files.
  async function discard(fs: OpenedFile[]) {
    const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
    if (!dps.length) return;
    const label = dps.length === 1 ? splitPath(dps[0]).name : t("{n} files", { n: dps.length });
    if (!(await confirm({ title: t("Discard changes?"), body: t("Local changes to {name} will be lost permanently — the file returns to the server version.", { name: label }), confirm: t("Discard"), danger: true }))) return;
    setBusy(true);
    try { await p4.revert(dps); flash(t("Discarded: {name}", { name: label })); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }
  // right-click on a Changes row → file actions (operates on the selection if the row is in it)
  function fileCtx(i: number, e: ReactMouseEvent) {
    const multi = selRows.has(i) && selRows.size > 1;
    if (!selRows.has(i)) { setSelRows(new Set([i])); setSel(i); } // right-click selects the row
    const idxs = multi ? [...selRows] : [i];
    const targets = idxs.map((k) => view[k]).filter(Boolean) as OpenedFile[];
    const dp = view[i]?.depotFile || "";
    const dps = targets.map((f) => f.depotFile || "").filter(Boolean);
    const isCode = isCodeFile(splitPath(dp).name);
    openCtx(e, [
      { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), icon: I.trash, danger: true, act: () => discard(targets) },
      { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) },
      { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) },
      { label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) },
      ...(workOffline ? [{ label: t("Make writable (offline edit)"), icon: I.unlock, act: () => makeWritable(targets) }] : []),
      ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), icon: I.move, act: () => moveToCL(cl.change || "", dps) })),
      ...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []),
      ...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
      { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
      { label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
      ...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: I.hex, act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
    ]);
  }
  async function lockFiles(dps: string[], lock: boolean) {
    if (!dps.length) return;
    try { await (lock ? p4.lock(dps) : p4.unlock(dps)); flash(lock ? t("Locked {n} file(s).", { n: dps.length }) : t("Unlocked {n} file(s).", { n: dps.length })); await refresh(); }
    catch (e) { flash(String(e), true); }
  }
  // switch opened files to an exclusive-lock file type (+l) so only one person edits them
  async function setExclusiveType(dps: string[]) {
    if (!dps.length) return;
    try { await p4.reopenType(dps, "+l"); flash(t("{n} file(s) set to exclusive-lock type (+l).", { n: dps.length })); await refresh(); }
    catch (e) { flash(String(e), true); }
  }
  async function moveToCL(change: string, dps: string[]) {
    if (!change || !dps.length) return;
    try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); }
    catch (e) { flash(String(e), true); }
  }
  // toggle explicit "Work Offline" mode (P4V-style): stop polling the server;
  // edits stay local until reconciled.
  function toggleWorkOffline() {
    setWorkOffline((v) => {
      const nv = !v;
      try { localStorage.setItem("exd-workoffline", nv ? "1" : "0"); } catch {}
      if (nv) { setOffline(false); flash(t("Working offline — edits stay local. Run Reconcile Offline Work to sync.")); }
      else { flash(t("Back online.")); refresh(); }
      return nv;
    });
  }
  // right-panel view tabs: open/focus, close, and Window-menu toggle
  function focusViewTab(k: ViewTab) { setViewTabs((ts) => (ts.includes(k) ? ts : [...ts, k])); setViewTab(k); }
  function closeViewTab(k: ViewTab) {
    setViewTabs((ts) => { const nt = ts.filter((x) => x !== k); if (viewTab === k) setViewTab((nt[nt.length - 1] as ViewTab) || "viewer"); return nt; });
  }
  function toggleViewTab(k: ViewTab) { if (viewTabs.includes(k)) closeViewTab(k); else focusViewTab(k); }
  // open the File Tree view-tab focused on a given side (Depot or Workspace)
  function showInTree(side: "depot" | "workspace") { setTreeSide(side); setTreeNonce((n) => n + 1); focusViewTab("tree"); }
  // open a file picked in the File Tree: depot files preview in the File Viewer,
  // local files open in the OS file browser.
  function openTreeFile(n: FsEntry, side: "depot" | "workspace") {
    if (side === "depot") {
      if (tab !== "changes") switchTab("changes");
      setPreviewCL({ depotFile: n.path, _spec: n.path, action: "browse", rev: "", type: "", change: "" } as unknown as OpenedFile);
      focusViewTab("viewer");
    } else {
      reveal(n.path);
    }
  }
  // clear the read-only bit on selected local files so they can be edited while
  // offline (before a checkout). Reconcile picks the changes up later.
  async function makeWritable(targets: OpenedFile[]) {
    const locals = targets.map((f) => f.clientFile || "").filter(Boolean) as string[];
    if (!locals.length) { flash(t("No local path for the selection."), true); return; }
    try { await p4.setWritable(locals, true); flash(t("{n} file(s) made writable for offline editing.", { n: locals.length })); }
    catch (e) { flash(String(e), true); }
  }
  // diff a historical revision against the one before it (History preview)
  async function showDiffPrev(file: OpenedFile) {
    const dp = file.depotFile || "";
    const rev = Number(file.rev || "0");
    if (!dp || rev <= 1) { flash(t("No earlier revision to compare."), true); return; }
    try {
      const text = await p4.diff2(`${dp}#${rev - 1}`, `${dp}#${rev}`);
      setModal({ kind: "diff", title: `${splitPath(dp).name}  #${rev - 1} ↔ #${rev}`, text: text || t("(files are identical)") });
    } catch (e) { flash(String(e), true); }
  }
  const showBlame = (spec: string, name: string) => setModal({ kind: "blame", spec, name });

  async function doResolve(mode: "am" | "ay" | "at", file = "") {
    try {
      await p4.resolveFile(file, mode);
      const rest = await p4.resolveList();
      setNeedResolve(rest);
      if (!rest.length) { setResolveOpen(false); flash(t("All conflicts resolved.")); }
      await refresh();
    } catch (e) { flash(String(e), true); }
  }

  const menus: Record<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; hint?: string; act?: () => void }[]> = {
    File: [
      { label: t("Switch workspace…"), icon: I.monitor, hint: t("Change which Perforce workspace (client) you're working in."), act: openWorkspaces },
      { label: t("New workspace…"), icon: I.monitor, hint: t("Create a new workspace mapping depot folders to a local folder."), act: newWorkspace },
      { label: t("New folder / project…"), icon: I.folder, hint: t("Create a new folder in the working area (with a README placeholder) — e.g. a separate project."), act: () => newFolder(activePath) },
      ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, hint: t("Edit the current workspace spec — root folder and depot view mapping."), act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []),
      { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
      { label: t("Disconnect"), icon: I.power, hint: t("Sign out and return to the connection screen."), act: onDisconnect },
    ],
    Connection: [
      { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
      { label: t("Choose working folder…"), icon: I.folder, hint: t("Pick which depot folder the app shows and syncs."), act: () => browseTo("") },
    ],
    Actions: [
      { label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, hint: t("Scan the workspace for files changed outside the app (reconcile)."), act: rescan },
      { label: (workOffline ? "✓ " : "") + t("Work Offline"), icon: I.power, hint: t("Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V."), act: toggleWorkOffline },
      { label: t("Reconcile Offline Work…"), icon: I.scan, hint: t("Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them."), act: () => setModal({ kind: "reconcile" }) },
      { label: t("Clean workspace…"), icon: I.revert, hint: t("Make the workspace exactly match the depot — discards un-opened local changes."), act: () => setModal({ kind: "clean" }) },
      { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, hint: t("Sync the workspace to the newest revision (pull)."), act: getLatest },
      { label: t("Get Revision…"), icon: I.clock, hint: t("Sync the workspace to a specific changelist, label or date."), act: promptSyncTo },
      { label: t("Commit (local)"), icon: I.check, hint: t("Save selected files into a local pending changelist (not sent yet)."), act: doCommit },
      { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, hint: t("Send pending changelists to the server (push)."), act: pushAll },
      ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, hint: t("Resolve merge conflicts left after a sync."), act: () => setResolveOpen(true) }] : []),
      { label: t("Revert All…"), icon: I.revert, hint: t("Discard every open change and restore depot versions."), act: revertAll },
      { label: t("Refresh"), kb: "F5", icon: I.sync, hint: t("Re-read state from the server."), act: () => refresh() },
    ],
    Window: [
      { label: (viewTabs.includes("viewer") ? "✓ " : "") + t("File Viewer"), icon: FILE_GLYPH, hint: t("The preview panel for the selected file (image, 3D model, code, diff)."), act: () => toggleViewTab("viewer") },
      { label: (viewTabs.includes("tree") ? "✓ " : "") + t("File Tree"), icon: I.folder, hint: t("A folder hierarchy of the Depot and your Workspace, docked into the view panel."), act: () => toggleViewTab("tree") },
      { label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
      { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
      { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
    ],
    Tools: [
      { label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
      { label: t("Files & Sizes…"), icon: I.folder, hint: t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs."), act: () => setModal({ kind: "explorer" }) },
      { label: t("Exclusive Locks (typemap)…"), icon: I.lock, hint: t("Set which binary asset types are exclusive-checkout (+l) so only one person edits them."), act: () => setModal({ kind: "typemap" }) },
      { label: t("Labels…"), icon: I.clock, hint: t("Named snapshots of file revisions: tag files, or sync to a label."), act: () => setModal({ kind: "labels" }) },
      { label: t("Integrate / Merge / Copy…"), icon: I.branch, hint: t("Move changes between branches of the depot."), act: () => setModal({ kind: "branch" }) },
      { label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
      { label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
      { label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
      ...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
      ...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })),
      { label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
      { label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
    ],
    Help: [{ label: t("About"), icon: I.info, hint: t("Version and information about Exbyte Depot."), act: () => setModal({ kind: "about" }) }],
  };

  return (
    <div className="win" style={{ "--left-w": `${leftW}px`, "--right-zone-w": `${syncW}px` } as CSSProperties}>
      <div className="titlebar" data-tauri-drag-region>
        <div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span>— Perforce</span></div></div>
        <div className="menubar" onClick={(e) => e.stopPropagation()}>
          {Object.entries(menus).map(([name, items]) => (
            <div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
              {t(name)}
              <div className="dropdown">
                {items.map((it) => (
                  <div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()} title={it.hint}>
                    {it.icon && <span className="di-ic">{it.icon}</span>}
                    {it.label}{it.kb && <span className="kb">{it.kb}</span>}
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>
        <div className="conn-pill" title={t("Connected server")}><span className="dot" />{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</div>
        <button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
        <button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
        <WinControls />
      </div>

      <div className="toolbar">
        <div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
          onContextMenu={(e) => openCtx(e, [
            { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("workspace") },
            { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(String(info?.clientRoot || "")) },
            { label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces },
            { label: t("Copy name"), icon: I.copy, act: () => copyText(info?.clientName || "", t("Workspace name")) },
          ])}>
          <span className="ic">{I.monitor}</span>
          <span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
          <span className="chev">{I.chev}</span>
        </div>
        <div className="tgroup">
        <div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
        <div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
          onContextMenu={(e) => openCtx(e, [
            { label: t("New folder / project…"), icon: I.folder, act: () => newFolder(activePath) },
            ...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []),
            ...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
            { label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
            ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
            { label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
            { label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
            { label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
            ...(activePath ? [{ label: t("Delete folder…"), icon: I.trash, danger: true, act: () => deleteFolder(activePath) }] : []),
          ])}>
          <span className="ic">{I.branch}</span>
          <span className="tzmeta"><span className="lbl">{t("Working folder")}</span><span className="val">{activePath || t("whole workspace (//…)")}</span></span>
          <span className="chev">{I.chev}</span>
        </div>
        {pending.length > 0 ? (
          <div className="tzone push" onClick={pushAll} title={t("Submit committed to the server (push)")}>
            <span className="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 20V8M6 14l6-6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
            <span className="tzmeta"><span className="val">{t("Submit to server")}</span><span className="sub">{t("{n} changelist(s) → push", { n: pending.length })}</span></span>
          </div>
        ) : (
          <div className={"tzone sync" + (busy ? " busy" : "")} onClick={getLatest}>
            <span className="ic">{I.sync}</span>
            <span className="tzmeta"><span className="val">{t("Get Latest")}</span><span className="sub">{busy ? t("Working…") : t("pull from server")}</span></span>
          </div>
        )}
        </div>
      </div>

      {workOffline ? (
        <div className="netbar offmode">
          {I.power}{t("Working offline — edits stay on your disk. Reconcile to bring them into a changelist.")}
          <button className="netbar-btn" onClick={() => setModal({ kind: "reconcile" })}>{t("Reconcile…")}</button>
          <button className="netbar-btn" onClick={toggleWorkOffline}>{t("Go online")}</button>
        </div>
      ) : offline && (
        <div className="netbar off">
          <span className="ldr sm" />{t("Disconnected from the server — retrying…")}
          <button className="netbar-btn" onClick={toggleWorkOffline}>{t("Work offline instead")}</button>
        </div>
      )}
      {needResolve.length > 0 && tab === "changes" && (
        <div className="netbar warn" onClick={() => setResolveOpen(true)}>
          {I.hex}{t("{n} file(s) need conflict resolution after sync.", { n: needResolve.length })}
          <button className="netbar-btn">{t("Resolve…")}</button>
        </div>
      )}
      {behind && behind.count > 0 && !workOffline && (
        <div className="netbar behind" onClick={getLatest} title={behind.sample.join(", ")}>
          {I.sync}{t("Server has newer content — {n} file(s) behind.", { n: behind.count })}
          {behind.sample.length > 0 ? <span className="behind-names">{behind.sample.slice(0, 3).join(" · ")}{behind.count > 3 ? " …" : ""}</span> : null}
          <button className="netbar-btn">{t("Get Latest")}</button>
        </div>
      )}
      <div className="main">
        <div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
        <div className="left">
          <div className="tabs">
            <button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
            <button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
            <span className="tab-slider" style={{ left: tab === "changes" ? "16px" : "calc(50% + 16px)" }} />
          </div>

          {tab === "changes" ? (
            <div className="tabpane" key="changes">
              <div className="filter">
                <div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
              </div>
              {pendingCards.length > 0 && (
                <div className="pending">
                  <div className="pending-h">
                    <span className="pending-t"><span className="pushdot" />{t("Pending · ready to Submit")}</span>
                    <button className="pushall" onClick={pushAll} title={t("Submit all to the server")}>{t("Submit all")} ↑</button>
                  </div>
                  {pendingCards.map(({ cl, files: cf }) => {
                    const ch = cl.change || "";
                    const open = expandedCL.has(ch);
                    return (
                      <div key={ch} className="clcard">
                        <div className="clcard-h" onClick={() => toggleCL(ch)}>
                          <span className={"cl-chev" + (open ? " open" : "")}>{I.chev}</span>
                          <span className="cl-num">#{ch}</span>
                          <span className="cl-desc">{(cl.desc || "").trim() || t("(no description)")}</span>
                          <span className="cl-count">{t("{n} files", { n: cf.length })}</span>
                          <span className="cl-actions" onClick={(e) => e.stopPropagation()}>
                            <button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}>↑</button>
                            <button className="cl-more" onClick={(e) => openCtx(e, [
                              { label: t("Edit description"), icon: I.pencil, act: () => editDesc(cl) },
                              { label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
                              { label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
                              { label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
                              { label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
                              { label: t("Unshelve into workspace"), icon: I.down, act: () => unshelveCL(ch) },
                              { label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
                              { label: t("Delete shelved files"), icon: I.trash, danger: true, act: () => deleteShelfCL(ch) },
                              { label: t("Uncommit (back to working)"), icon: I.back, act: () => uncommit(cf) },
                              { label: t("Discard changes · {n} files", { n: cf.length }), icon: I.trash, danger: true, act: () => discard(cf) },
                            ])} title={t("More")}>⋯</button>
                          </span>
                        </div>
                        {open && (
                          <div className="cl-files">
                            {cf.length === 0 && <div className="cl-empty">{t("(files are outside the current working folder)")}</div>}
                            {cf.map((f) => {
                              const sp = splitPath(f.depotFile);
                              const st = statusOf(f.action);
                              return (
                                <div key={f.depotFile} className={"cl-file" + (previewCL?.depotFile === f.depotFile ? " sel" : "")} onClick={() => setPreviewCL(f)}>
                                  <span className={"stat " + st.cls}>{st.ch}</span>
                                  <span className="cl-fname"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
                                </div>
                              );
                            })}
                          </div>
                        )}
                      </div>
                    );
                  })}
                </div>
              )}
              {scanning && (
                <div className="scanbar">
                  <span className="ldr" />
                  <span>
                    {t("Scanning disk for changes…")}
                    {scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}
                    {scanCount === 0 && !activePath && t(" for the whole workspace this is slow — pick a project folder above")}
                  </span>
                  <button className="scan-cancel" onClick={() => { p4.scanCancel().catch(() => {}); }} title={t("Stop the scan. Files already found stay staged.")}>{t("Cancel")}</button>
                </div>
              )}
              <div className="chhead">
                <span className={"chk" + (allChecked ? "" : " off")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
                <span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
                <button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
                  {I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
                </button>
              </div>
              {view.length === 0 ? (
                <div className="empty">
                  {scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}{scanCount > 0 && <b className="scancount"> {t("found {n} files", { n: scanCount.toLocaleString() })}</b>}</> : <>
                    <svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
                    {busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
                  </>}
                </div>
              ) : (
                <FileList files={view} sel={sel} selRows={selRows} checked={checked} others={others} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
              )}
              <div className="commit">
                <div className="summrow">
                  <input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
                    onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
                  <button className={"aibtn icon" + (aiBusy ? " busy" : "")} disabled={aiBusy || checked.size === 0} onClick={genAiSummary}
                    title={t("Draft a commit message with AI from the selected files")}>
                    {aiBusy ? <span className="ldr sm" /> : I.spark}
                  </button>
                </div>
                <textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
                <button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
                  {commitProg ? (
                    <><span className="ldr sm" />{t("Committing…")} <b>{commitProg.count.toLocaleString()} / {commitProg.total.toLocaleString()}</b></>
                  ) : (
                    <>{I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b></>
                  )}
                </button>
                <div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
              </div>
            </div>
          ) : (
            <div className="tabpane" key="history">
            <HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
              onContext={(c, e) => openCtx(e, [
                { label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
                { label: t("Sync workspace to #{n}", { n: c.change || "" }), icon: I.clock, act: () => syncTo(c.change || "") },
                { label: t("Revert (undo) #{n}", { n: c.change || "" }), icon: I.revert, danger: true, act: () => revertChange(c) },
                { label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
                { label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
              ])} />
            </div>
          )}
        </div>

        <div className="viewpanel">
          {viewTabs.length > 1 && (
            <div className="vtabs">
              {viewTabs.map((k) => (
                <div key={k} className={"vtab" + (viewTab === k ? " on" : "")} onClick={() => setViewTab(k)}>
                  <span className="vtab-ic">{k === "viewer" ? FILE_GLYPH : k === "tree" ? I.folder : I.lock}</span>
                  {k === "viewer" ? t("File Viewer") : k === "tree" ? t("File Tree") : t("File Locks")}
                  <span className="vtab-x" onClick={(e) => { e.stopPropagation(); closeViewTab(k); }} title={t("Close")}>×</span>
                </div>
              ))}
            </div>
          )}
          <div className="viewbody">
            {viewTabs.length === 0 && <div className="empty">{I.folder}{t("No panel open.\nAdd File Viewer or File Tree from the Window menu.")}</div>}
            {viewTabs.includes("viewer") && (
              <div className="vpane" style={{ display: viewTab === "viewer" ? "flex" : "none" }}>
                {tab === "changes" ? (
                  <Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
                ) : (
                  <div className={"histsplit" + (histFile ? "" : " solo")}>
                    <ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
                    {histFile && (<>
                      <div className="vhandle histh" title={t("Drag to resize")}
                        onPointerDown={(e) => {
                          e.preventDefault();
                          const startX = e.clientX, start = histW;
                          document.body.classList.add("resizing");
                          const move = (ev: PointerEvent) => setHistW(Math.max(240, Math.min(start + (ev.clientX - startX), 620)));
                          const up = () => { document.removeEventListener("pointermove", move); document.removeEventListener("pointerup", up); document.removeEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.removeEventListener("mouseup", up); window.removeEventListener("blur", up); document.body.classList.remove("resizing"); };
                          document.addEventListener("pointermove", move); document.addEventListener("pointerup", up);
                          // mouse fallback in case pointer events don't fire in the webview
                          document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
                          window.addEventListener("blur", up); // released outside the window → end drag
                        }} />
                      <Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} onFilelog={(dp, nm) => setModal({ kind: "filelog", depot: dp, name: nm })} uproject={uproject} />
                    </>)}
                  </div>
                )}
              </div>
            )}
            {viewTabs.includes("tree") && (
              <div className="vpane tree" style={{ display: viewTab === "tree" ? "flex" : "none" }}>
                <FileTree scope={activePath} onReveal={reveal} onFlash={flash} onOpenFile={openTreeFile} onNewFolder={(side) => newFolder(side === "depot" ? activePath : "")} onFolderMenu={folderMenu} initialSide={treeSide} sideNonce={treeNonce} />
              </div>
            )}
            {viewTabs.includes("locks") && (
              <div className="vpane tree" style={{ display: viewTab === "locks" ? "flex" : "none" }}>
                <LocksPanel me={info?.userName || ""} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />
              </div>
            )}
          </div>
        </div>
      </div>

      {dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
        height={dockH} onResize={startDockResize}
        logs={logs} onClearLogs={() => setLogs([])}
        termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
        onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}

      <div className="statusbar">
        <span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
          <span className={"stdot" + ((busy || scanning || !!transfer) ? " busy" : "")}>{(busy || scanning || !!transfer) ? <span className="ldr sm" /> : I.check}</span>
          <span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd}  ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
        </span>
        <span className="stspace" />
        <button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
        <button className="stlog" onClick={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
        <button className="stlog" onClick={() => window.dispatchEvent(new Event("exd-check-update"))} title={t("Check for updates")}>
          <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
          <span>{t("Updates")}</span>
        </button>
        {peek && logs.length > 0 && (
          <div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
            <div className="logpeek-h">{t("Log")} · {logs.length}</div>
            {logs.slice(-40).map((l) => <LogRow key={l.id} l={l} />)}
          </div>
        )}
      </div>

      {dragging && (
        <div className="dropzone">
          <div className="dropinner">
            <svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
            <span>{t("Drop to add to Perforce")}</span>
          </div>
        </div>
      )}
      {modal && modal.kind !== "users" && modal.kind !== "settings" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
        editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
        onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
      {modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
      {modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
      {modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
      {modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
      {modal?.kind === "settings" && <SettingsModal section={modal.section} info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom} editors={editors} editorId={effEditorId} onPickEditor={chooseEditor} onReloadPlugins={reloadPlugins} onDisconnect={onDisconnect} onFlash={flash} onClose={() => setModal(null)} />}
      {pluginView && <PluginViewModal view={pluginView} onClose={() => setPluginView(null)} />}
      {modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
      {modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
      {modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
      {modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
      {modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
      {modal?.kind === "reconcile" && <ReconcileModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
      {modal?.kind === "explorer" && <ExplorerModal scope={activePath} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} />}
      {modal?.kind === "folderprops" && <FolderPropsModal path={modal.path} name={modal.name} onClose={() => setModal(null)} onReveal={reveal} onRename={() => { const p = modal.path; setModal(null); renameFolder(p); }} onDelete={() => { const p = modal.path; setModal(null); deleteFolder(p); }} onCopy={(p) => copyText(p, t("Path"))} />}
      {modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
      {modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
      {modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={async (c) => { const wasNew = modal.isNew; setModal(null); if (wasNew) { switchWorkspace(c); return; } await refresh(); if (await confirm({ title: t("Workspace mapping changed"), body: t("Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync."), confirm: t("Get Latest") })) getLatest(); }} />}
      {modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
      {modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
      {resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => setResolveOpen(false)} />}
      {ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
      {prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
      {showXfer && transfer && !xferMin && <TransferDialog transfer={transfer} onCancel={cancelTransfer} onMinimize={() => setXferMin(true)} />}
      {showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
      {buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
      {buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
      {buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
      {ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
      {toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
    </div>
  );
}

/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
  { modal: ModalState; info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  const X = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;
  const folder = <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6" /></svg>;
  const depotIc = <svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" /><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6" /></svg>;

  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker" onClick={(e) => e.stopPropagation()}>
        {modal.kind === "workspaces" && (<>
          <div className="picker-head">{folder}<h3>{t("Choose workspace")}</h3>{X}</div>
          <div className="picker-list">
            {modal.items.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 20 }}>{t("No workspaces available")}</div>}
            {modal.items.map((c) => (
              <div key={c.client} className={"picker-item" + (c.client === modal.current ? " on" : "")} onClick={() => onPickWorkspace(c.client || "")}>
                <span className="pi-ic">{folder}</span>
                <span className="pi-body"><span className="pi-name">{c.client}</span><span className="pi-sub">{(c.Root as string) || ""}</span></span>
              </div>
            ))}
          </div>
          <div className="picker-foot">{t("Active: {c}", { c: modal.current || "—" })}</div>
        </>)}

        {modal.kind === "scope" && (() => {
          const path = modal.path;
          const recents = recentScopes(info?.clientName || "");
          const parts = path.replace(/^\/+/, "").split("/").filter(Boolean); // e.g. ["depot","Proj"]
          const parentPath = parts.length ? "//" + parts.slice(0, -1).join("/") : "";
          const up = parts.length > 0;
          return (<>
            <div className="picker-head">{depotIc}<h3>{t("Working folder")}</h3>{X}</div>
            <div style={{ padding: "10px 16px", borderBottom: "1px solid var(--border-soft)" }}>
              <div className="crumb">
                <span className="seg" onClick={() => onBrowse("")}>//</span>
                {parts.map((seg, i) => (
                  <span key={i}><span className="sep">/</span><span className="seg" onClick={() => onBrowse("//" + parts.slice(0, i + 1).join("/"))}>{seg}</span></span>
                ))}
              </div>
            </div>
            <div className="picker-list">
              {recents.length > 0 && (
                <div className="pick-recent">
                  <div className="pick-recent-h">{t("Recently")}</div>
                  {recents.map((rp) => (
                    <div key={rp} className="picker-item" onClick={() => onChoose(rp)}>
                      <span className="pi-ic clock"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" /><path d="M12 8v4l2.5 2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg></span>
                      <span className="pi-body"><span className="pi-name">{leaf(rp)}</span><span className="pi-sub">{rp}</span></span>
                      <svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
                    </div>
                  ))}
                </div>
              )}
              {up && (
                <div className="picker-item" onClick={() => onBrowse(parentPath)}>
                  <span className="pi-ic up"><svg viewBox="0 0 24 24" fill="none"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
                  <span className="pi-body"><span className="pi-name up">{t(".. up")}</span></span>
                </div>
              )}
              {modal.dirs.length === 0 && <div className="viewer-msg" style={{ position: "static", padding: 18 }}>{t("No subfolders")}</div>}
              {modal.dirs.map((d) => (
                <div key={d.dir} className="picker-item" onClick={() => onBrowse(d.dir || "")}>
                  <span className="pi-ic">{folder}</span>
                  <span className="pi-body"><span className="pi-name">{leaf(d.dir)}{d.local ? <span className="local-tag">{t("local")}</span> : null}</span><span className="pi-sub">{d.dir}</span></span>
                  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" style={{ color: "var(--faint)" }}><path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
                </div>
              ))}
            </div>
            <button className="scope-choose" onClick={() => onChoose(path)}>
              {path ? t("Work in: {path}", { path }) : t("Work in the whole workspace (//…)")}
            </button>
          </>);
        })()}

        {modal.kind === "settings" && (<>
          <div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
          <div className="info-body">
            <div className="info-row"><span className="rk">{t("Language")}</span>
              <div className="langsel">
                {LANGS.map((l) => (
                  <button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
                    <span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
                  </button>
                ))}
              </div>
            </div>
            <div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
            <div className="info-row"><span className="rk">{t("Interface scale")}</span>
              <div className="zoomsel">
                <button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title="−">−</button>
                <button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
                <button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
              </div>
            </div>
            <div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
            <div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
            <div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
            <div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
            <div className="info-row"><span className="rk">{t("Code editor")}</span>
              <select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
                {editors.length === 0 && <option value="default">{t("System default")}</option>}
                {editors.map((ed) => (
                  <option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>
                ))}
              </select>
            </div>
            <div style={{ marginTop: 18 }}><button className="mbtn danger" style={{ width: "100%" }} onClick={() => { onClose(); onDisconnect(); }}>{t("Disconnect")}</button></div>
          </div>
        </>)}

        {modal.kind === "about" && (<>
          <div className="picker-head">{depotIc}<h3>Exbyte Depot</h3>{X}</div>
          <div className="info-body" style={{ textAlign: "center" }}>
            <div style={{ width: 60, height: 60, margin: "6px auto 14px", borderRadius: 16, background: "linear-gradient(145deg,var(--accent-2),var(--accent-deep))", display: "grid", placeItems: "center", boxShadow: "0 0 30px rgba(124,110,246,.5)" }}>
              <svg viewBox="0 0 24 24" width="30" height="30" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="#fff" strokeWidth="1.5" strokeLinejoin="round" /><path d="m3 7 9 5 9-5M12 12v10" stroke="#fff" strokeWidth="1.5" /></svg>
            </div>
            <p style={{ marginBottom: 8 }}>{t("Exbyte Depot — native Perforce client")}</p>
            <p style={{ fontSize: 12, color: "var(--faint)" }}>Tauri + React · three.js · v0.1.0<br />{t("Built by Exbyte Studios")}</p>
          </div>
        </>)}
      </div>
    </div>
  );
}

/* ---------------- avatar (initials, deterministic color, activity dot) ---------------- */
function Avatar({ user, name, size = 30, act }: { user: string; name?: string; size?: number; act?: "active" | "recent" | "idle" }) {
  return (
    <span className="avatar" style={{ width: size, height: size, fontSize: Math.round(size * 0.38), background: avatarColor(user || name || "?") }} title={name ? `${name} (${user})` : user}>
      {initials(name || "", user)}
      {act && <span className={"adot " + act} />}
    </span>
  );
}

/* ---------------- People & Roles: who works on the depot + group/permission assignment ---------------- */
function UsersRoles({ me, onClose, onFlash }: { me: string; onClose: () => void; onFlash: (text: string, err?: boolean) => void }) {
  const [users, setUsers] = useState<User[]>([]);
  const [groups, setGroups] = useState<string[]>([]);
  const [busy, setBusy] = useState(true);
  const [err, setErr] = useState("");
  const [sel, setSel] = useState("");              // selected user id
  const [myGroups, setMyGroups] = useState<string[]>([]); // groups of the selected user
  const [gBusy, setGBusy] = useState(false);
  const [q, setQ] = useState("");
  const selSeq = useRef(0); // drop a slow groups() response if another user was picked

  useEffect(() => {
    let live = true;
    (async () => {
      try {
        const [us, gs] = await Promise.all([p4.users(), p4.groups()]);
        if (!live) return;
        setUsers(us);
        setGroups(gs.map((g) => g.group || "").filter(Boolean));
      } catch (e) { if (live) setErr(String(e)); }
      finally { if (live) setBusy(false); }
    })();
    return () => { live = false; };
  }, []);
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  async function selectUser(u: string) {
    const seq = ++selSeq.current;
    setSel(u); setMyGroups([]); setGBusy(true);
    try { const g = await p4.groups(u); if (seq === selSeq.current) setMyGroups(g.map((x) => x.group || "").filter(Boolean)); }
    catch (e) { if (seq === selSeq.current) onFlash(String(e), true); }
    finally { if (seq === selSeq.current) setGBusy(false); }
  }
  async function toggleGroup(group: string) {
    if (!sel) return;
    const isMember = myGroups.includes(group);
    setGBusy(true);
    try {
      await p4.groupMember(group, sel, !isMember);
      setMyGroups((cur) => isMember ? cur.filter((x) => x !== group) : [...cur, group]);
      onFlash(isMember ? t("Removed {u} from “{g}”.", { u: sel, g: group }) : t("Added {u} to “{g}”.", { u: sel, g: group }));
    } catch (e) {
      const msg = String(e);
      onFlash(/permission|protect|admin|super|not allowed/i.test(msg) ? t("You need admin rights on the server to change roles.") : msg, true);
    } finally { setGBusy(false); }
  }

  const s = q.trim().toLowerCase();
  const filtered = users.filter((u) => !s || (u.User || "").toLowerCase().includes(s) || (u.FullName || "").toLowerCase().includes(s) || (u.Email || "").toLowerCase().includes(s));
  const selUser = users.find((u) => u.User === sel);
  const X = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;

  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.people}<h3>{t("People & Roles")}</h3>
          <span className="ph-sub">{t("{n} on this depot", { n: users.length })}</span>{X}</div>
        {err ? <div className="viewer-msg err" style={{ position: "static", padding: 24 }}>{err}</div>
          : busy ? <div className="viewer-msg" style={{ position: "static", padding: 28 }}><span className="ldr" />{t("Loading users…")}</div>
          : (
          <div className="ur-body">
            <div className="ur-list">
              <div className="ur-filter">{I.search}<input placeholder={t("Filter people…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
              <div className="ur-scroll">
                {filtered.map((u) => {
                  const act = activity(u.Access);
                  return (
                    <div key={u.User} className={"ur-row" + (sel === u.User ? " on" : "")} onClick={() => selectUser(u.User || "")}>
                      <Avatar user={u.User || ""} name={u.FullName} act={act} />
                      <span className="ur-meta">
                        <span className="ur-name">{u.FullName || u.User}{u.User === me && <span className="ur-you">{t("you")}</span>}</span>
                        <span className="ur-sub">{u.Email || u.User}</span>
                      </span>
                      <span className={"ur-actd " + act} title={act} />
                    </div>
                  );
                })}
                {filtered.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
              </div>
            </div>
            <div className="ur-detail">
              {!selUser ? <div className="viewer-msg" style={{ position: "static", padding: 28, flexDirection: "column" }}>{I.people}<span>{t("Pick a person to see and edit their roles.")}</span></div>
              : (<>
                <div className="ur-dhead">
                  <Avatar user={selUser.User || ""} name={selUser.FullName} size={46} act={activity(selUser.Access)} />
                  <div className="ur-dinfo">
                    <div className="ur-dname">{selUser.FullName || selUser.User}</div>
                    <div className="ur-dsub">{selUser.Email || "—"}{selUser.Access ? " · " + t("active {t}", { t: fmtTime(selUser.Access) }) : ""}</div>
                  </div>
                </div>
                <div className="ur-grouphd"><span>{t("Roles / groups")}</span>{gBusy && <span className="ldr sm" />}</div>
                <div className="ur-groups">
                  {groups.length === 0 && <div className="ur-empty">{t("No groups defined on the server.")}</div>}
                  {groups.map((g) => {
                    const on = myGroups.includes(g);
                    return (
                      <button key={g} className={"ur-chip" + (on ? " on" : "")} disabled={gBusy} onClick={() => toggleGroup(g)}>
                        <span className="urc-box">{on ? I.check : null}</span>{g}
                      </button>
                    );
                  })}
                </div>
                <div className="ai-hint">{t("Toggling a group grants or revokes that role. Requires admin rights on the server.")}</div>
              </>)}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

/* ---------------- right-click context menu ---------------- */
function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: CtxItem[]; onClose: () => void }) {
  useEffect(() => {
    const close = () => onClose();
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("click", close);
    document.addEventListener("scroll", close, true);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("click", close); document.removeEventListener("scroll", close, true); document.removeEventListener("keydown", onKey); };
  }, [onClose]);
  const style: CSSProperties = { left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - (items.length * 36 + 14)) };
  const hasIcon = items.some((it) => it.icon);
  return (
    <div className="ctxmenu" style={style} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
      {items.map((it, i) => (
        <div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>
          {hasIcon && <span className="ctx-ic">{it.icon}</span>}
          <span>{it.label}</span>
        </div>
      ))}
    </div>
  );
}

/* ---------------- Perforce-style command log ---------------- */
function logCount(l: LogEntry): string {
  return l.ok ? `{${l.count} ${t("item(s)")}}` : `— ${l.err || "error"}`;
}
function LogRow({ l }: { l: LogEntry }) {
  return (
    <div className={"logrow" + (l.ok ? "" : " err")} title={l.err || ""}>
      <span className="logst">{l.ok ? "✓" : "!"}</span>
      <span className="logcmd">{l.cmd}</span>
      <span className="logcount">{logCount(l)}</span>
    </div>
  );
}
// common p4 subcommands for the terminal's autocomplete
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];

/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
  tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
  height: number; onResize: (e: ReactMouseEvent) => void;
  logs: LogEntry[]; onClearLogs: () => void;
  termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
  onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
}) {
  const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
    { key: "log", label: t("Log"), icon: I.log },
    { key: "terminal", label: t("Terminal"), icon: I.terminal },
  ];
  return (
    <div className="dockpanel" style={{ flexBasis: height, height }}>
      <div className="dockhandle" onMouseDown={onResize} title={t("Drag to resize")} />
      <div className="dockhead">
        <div className="docktabs">
          {tabs.map((tb) => (
            <button key={tb.key} className={"docktab" + (tab === tb.key ? " on" : "")} onClick={() => setTab(tb.key)}>
              {tb.icon}<span>{tb.label}</span>
              {tb.key === "log" && logs.length > 0 && <span className="logn">{logs.length}</span>}
            </button>
          ))}
        </div>
        <span className="dockspace" />
        {(tab === "log" || tab === "terminal") && <button className="loghbtn" onClick={tab === "log" ? onClearLogs : onClearTerm}>{t("Clear")}</button>}
        <button className="loghbtn x" onClick={onClose}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
      </div>
      <div className="dockpane" key={tab}>
        {tab === "log" && <LogTab logs={logs} />}
        {tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
      </div>
    </div>
  );
}

function LogTab({ logs }: { logs: LogEntry[] }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
  return (
    <div className="logbody" ref={ref}>
      {logs.length === 0 ? <div className="logempty">{t("No commands yet.")}</div>
        : logs.map((l) => <LogRow key={l.id} l={l} />)}
    </div>
  );
}

/* p4 console with command autocomplete + ↑/↓ history */
function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
  lines: TermLine[]; input: string; setInput: (s: string) => void; busy: boolean; onRun: (cmd: string) => void; cmdHist: string[];
}) {
  const bodyRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const [hi, setHi] = useState(-1);       // history cursor (-1 = live input)
  const [sugIdx, setSugIdx] = useState(0);
  useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [lines, busy]);
  useEffect(() => { setSugIdx(0); }, [input]);

  // suggest only while typing the first bare word (the p4 subcommand)
  const firstWord = input.trim().split(/\s+/)[0] || "";
  const typing = input.trim() === firstWord && firstWord.length > 0 && !input.endsWith(" ");
  const sugs = typing ? P4_COMMANDS.filter((c) => c.startsWith(firstWord.toLowerCase()) && c !== firstWord.toLowerCase()).slice(0, 8) : [];

  function accept(cmd: string) { setInput(cmd + " "); inputRef.current?.focus(); }
  function onKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
    if (sugs.length) {
      if (e.key === "Tab") { e.preventDefault(); accept(sugs[sugIdx] || sugs[0]); return; }
      if (e.key === "ArrowDown") { e.preventDefault(); setSugIdx((i) => (i + 1) % sugs.length); return; }
      if (e.key === "ArrowUp") { e.preventDefault(); setSugIdx((i) => (i - 1 + sugs.length) % sugs.length); return; }
    }
    if (e.key === "Enter") { e.preventDefault(); if (!busy) onRun(input); setHi(-1); return; }
    if (e.key === "ArrowUp") { e.preventDefault(); const ni = hi < 0 ? cmdHist.length - 1 : Math.max(0, hi - 1); if (cmdHist[ni] != null) { setHi(ni); setInput(cmdHist[ni]); } return; }
    if (e.key === "ArrowDown") { e.preventDefault(); if (hi < 0) return; const ni = hi + 1; if (ni >= cmdHist.length) { setHi(-1); setInput(""); } else { setHi(ni); setInput(cmdHist[ni]); } return; }
  }

  return (
    <div className="term">
      <div className="termbody" ref={bodyRef}>
        {lines.length === 0 && <div className="termhint">{t("p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.")}</div>}
        {lines.map((ln) => (
          <div key={ln.id} className="termblock">
            {ln.cmd && <div className="termcmd"><span className="termprompt">p4&gt;</span> {ln.cmd}</div>}
            {ln.running ? <div className="termout run"><span className="ldr sm" /> {t("running…")}</div>
              : <pre className={"termout" + (ln.err ? " err" : "")}>{ln.text}</pre>}
          </div>
        ))}
      </div>
      <div className="terminput-wrap">
        <span className="termprompt">p4&gt;</span>
        <input ref={inputRef} className="terminput" value={input} placeholder={t("type a p4 command…")} spellCheck={false} autoFocus
          onChange={(e) => setInput(e.target.value)} onKeyDown={onKeyDown} />
        {busy && <span className="ldr sm" />}
        {sugs.length > 0 && (
          <div className="termsug">
            {sugs.map((s, i) => (
              <div key={s} className={"termsug-i" + (i === sugIdx ? " on" : "")} onMouseDown={(e) => { e.preventDefault(); accept(s); }}>
                <b>{firstWord}</b>{s.slice(firstWord.length)}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer, onCancel, onMinimize }: { transfer: Transfer; onCancel: () => void; onMinimize: () => void }) {
  const up = transfer.op === "submit";
  const total = transfer.total || 0;
  const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
  const logRef = useRef<HTMLDivElement>(null);
  useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [transfer.log.length]);
  return (
    <div className="modal-back xfer-back">
      <div className="xfer">
        <div className="xfer-head">
          <span className={"xfer-ic" + (up ? " up" : " down")}>
            {up
              ? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
              : <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
          </span>
          <div className="xfer-ttl">
            <b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
            <span>{total > 0
              ? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%`
              : t("{n} files", { n: transfer.count })}</span>
          </div>
          <div className="xfer-actions">
            {up && (
              <button className="xfer-cancel" onClick={onCancel} disabled={transfer.cancelling}
                title={t("Stop before the server commit. Files already sent are not applied; the changelist stays pending.")}>
                {transfer.cancelling ? t("Cancelling…") : t("Cancel")}
              </button>
            )}
            <button className="xfer-min" onClick={onMinimize} title={t("Minimize — the transfer keeps running in the background")}>
              <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
            </button>
          </div>
        </div>
        <div className={"xfer-bar" + (total > 0 ? " det" : "")}>
          <span style={total > 0 ? { width: pct + "%" } : undefined} />
        </div>
        {transfer.log.length > 0
          ? <div className="xfer-log" ref={logRef}>{transfer.log.map((f, i) => <div key={i}>{f}</div>)}</div>
          : <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>}
      </div>
    </div>
  );
}

/* minimized transfer indicator — small floating chip; the transfer keeps running.
   Click to expand back to the full dialog. */
function TransferChip({ transfer, onExpand, onCancel }: { transfer: Transfer; onExpand: () => void; onCancel: () => void }) {
  const up = transfer.op === "submit";
  const total = transfer.total || 0;
  const pct = total > 0 ? Math.min(100, Math.round((transfer.count / total) * 100)) : 0;
  return (
    <div className={"xferchip" + (up ? " up" : " down")} onClick={onExpand} title={t("Expand")}>
      <span className="xc-ic"><span className="ldr sm" /></span>
      <span className="xc-txt">
        <b>{up ? t("Submitting") : t("Getting latest")}</b>
        <span>{total > 0 ? `${transfer.count.toLocaleString()} / ${total.toLocaleString()} · ${pct}%` : t("{n} files", { n: transfer.count })}</span>
      </span>
      {up && (
        <button className="xc-cancel" disabled={transfer.cancelling} title={t("Cancel")}
          onClick={(e) => { e.stopPropagation(); onCancel(); }}>
          <svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
        </button>
      )}
      {total > 0 && <span className="xc-bar"><span style={{ width: pct + "%" }} /></span>}
    </div>
  );
}

/* ---------------- build output (MSBuild) ---------------- */
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
  const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
  return (
    <div className="modal-back">
      <div className="build">
        <div className="build-head">
          <span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
            {building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
          </span>
          <div className="build-ttl">
            <b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
            <span>{name}</span>
          </div>
          <button className="build-x" onClick={onMinimize} title={t("Minimize")}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
          </button>
          <button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
          </button>
        </div>
        <div className="build-body" ref={ref}>
          {lines.map((ln, i) => {
            const low = ln.toLowerCase();
            const cls = /: error |\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : "";
            return <div key={i} className={"build-ln" + cls}>{ln}</div>;
          })}
          {building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
        </div>
      </div>
    </div>
  );
}
/* build configuration picker (Unreal: Development / Shipping / …) */
function BuildPicker({ onPick, onClose }: { onPick: (config: string) => void; onClose: () => void }) {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  const opts: { config: string; name: string; desc: string }[] = [
    { config: "Development Editor", name: "Development Editor", desc: t("Editor build for working in-engine (default)") },
    { config: "Development", name: "Development", desc: t("Standalone game, dev config") },
    { config: "DebugGame", name: "DebugGame", desc: t("Game with debuggable game code") },
    { config: "Shipping", name: "Shipping", desc: t("Optimized release build") },
  ];
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.hammer}<h3>{t("Build configuration")}</h3>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="picker-list">
          {opts.map((o) => (
            <div key={o.config} className="picker-item" onClick={() => onPick(o.config)}>
              <span className="pi-ic">{I.hammer}</span>
              <span className="pi-body"><span className="pi-name">{o.name} · Win64</span><span className="pi-sub">{o.desc}</span></span>
            </div>
          ))}
        </div>
        <div className="picker-foot">{t("Builds via MSBuild → UnrealBuildTool")}</div>
      </div>
    </div>
  );
}

/* minimized build indicator — small floating chip, click to expand */
function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok: boolean | null; onExpand: () => void; onClose: () => void }) {
  return (
    <div className={"buildchip" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")} onClick={onExpand} title={t("Expand")}>
      <span className="bc-ic">{building ? <span className="ldr sm" /> : ok ? "✓" : "✗"}</span>
      <span className="bc-txt">{building ? t("Building…") : ok ? t("Build succeeded") : t("Build failed")}</span>
      <button className="bc-x" onClick={(e) => { e.stopPropagation(); onClose(); }} title={t("Close")}>
        <svg viewBox="0 0 24 24" width="12" height="12" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
      </button>
    </div>
  );
}

/* ---------------- text prompt (edit description) ---------------- */
function TextPrompt({ title, label, value, onSave, onClose }: { title: string; label: string; value: string; onSave: (v: string) => void; onClose: () => void }) {
  const [v, setV] = useState(value);
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(v); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [v]); // eslint-disable-line
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
        <h3>{title}</h3>
        <div className="prompt-label">{label}</div>
        <textarea ref={ref} className="prompt-input" value={v} onChange={(e) => setV(e.target.value)} />
        <div className="modal-actions">
          <button className="mbtn ghost" onClick={onClose}>{t("Cancel")}</button>
          <button className="mbtn primary" onClick={() => onSave(v)}>{t("Save")}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
  const btn = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    btn.current?.focus();
    // only Escape is global; confirming requires the focused button (Enter/Space
    // on it, or a click) so a stray Enter never triggers a destructive action
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <div className="modal-back" onClick={() => onClose(false)}>
      <div className="modal-card" onClick={(e) => e.stopPropagation()}>
        <div className={"modal-icon" + (danger ? " danger" : "")}>
          {danger
            ? <svg viewBox="0 0 24 24" fill="none"><path d="M12 9v4M12 17h.01M10.3 3.9 2 18a2 2 0 0 0 1.7 3h16.6a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
            : <svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>}
        </div>
        <h3>{title}</h3>
        <p>{body}</p>
        <div className="modal-actions">
          <button className="mbtn ghost" onClick={() => onClose(false)}>{t("Cancel")}</button>
          <button ref={btn} className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)}>{confirm}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- depot file search ---------------- */
function SearchModal({ scope, onClose, onOpenEditor, onReveal, onCopy }: { scope: string; onClose: () => void; onOpenEditor: (dp: string) => void; onReveal: (dp: string) => void; onCopy: (dp: string) => void }) {
  const [q, setQ] = useState("");
  const [res, setRes] = useState<{ depotFile?: string; rev?: string }[]>([]);
  const [busy, setBusy] = useState(false);
  const seq = useRef(0);
  const inp = useRef<HTMLInputElement>(null);
  useEffect(() => { inp.current?.focus(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
  useEffect(() => {
    const s = ++seq.current;
    if (q.trim().length < 2) { setRes([]); setBusy(false); return; }
    setBusy(true);
    const id = setTimeout(() => {
      p4.searchFiles(q.trim(), scope).then((r) => { if (s === seq.current) { setRes(r); setBusy(false); } }).catch(() => { if (s === seq.current) { setRes([]); setBusy(false); } });
    }, 250); // debounce
    return () => clearTimeout(id);
  }, [q, scope]);
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.search}<h3>{t("Search depot")}</h3>
          <span className="ph-sub">{scope || "//…"}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input ref={inp} placeholder={t("Type at least 2 characters…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Searching…")}</div>}
          {!busy && q.trim().length >= 2 && res.length === 0 && <div className="ur-empty">{t("No matches.")}</div>}
          {res.map((f) => {
            const dp = f.depotFile || "";
            const sp = splitPath(dp);
            return (
              <div key={dp} className="srch-row" onContextMenu={(e) => { e.preventDefault(); }}>
                <span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
                {isCodeFile(sp.name) && <button className="srch-act" title={t("Open in editor")} onClick={() => onOpenEditor(dp)}>{I.vscode}</button>}
                <button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
                <button className="srch-act" title={t("Copy path")} onClick={() => onCopy(dp)}>⧉</button>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------------- file locks viewer — who has what locked/open across the depot ---------------- */
// the File Locks list (bar + rows), reused by the modal AND the dockable panel
function LocksPanel({ me, onReveal, onFlash, onUnlock }: { me: string; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
  const [files, setFiles] = useState<OpenedFile[]>([]);
  const [busy, setBusy] = useState(true);
  const [q, setQ] = useState("");
  const [lockedOnly, setLockedOnly] = useState(true);
  const load = () => { setBusy(true); p4.openedAll("").then(setFiles).catch((e) => { onFlash(String(e), true); setFiles([]); }).finally(() => setBusy(false)); };
  useEffect(() => { load(); }, []); // eslint-disable-line
  const isLocked = (f: OpenedFile) => (f as Record<string, unknown>).ourLock !== undefined;
  const s = q.trim().toLowerCase();
  const rows = files.filter((f) => (!lockedOnly || isLocked(f)) && (!s || (f.depotFile || "").toLowerCase().includes(s) || (f.user || "").toLowerCase().includes(s)));
  return (
    <>
      <div className="rslv-bar" style={{ borderBottom: "1px solid var(--border-soft)" }}>
        <div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter by file or user…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
        <button className={"rslv-act" + (lockedOnly ? " on" : "")} onClick={() => setLockedOnly((v) => !v)}>{lockedOnly ? t("Locked only") : t("All open")}</button>
        <button className="rslv-act" onClick={load} title={t("Refresh")}>{I.sync}</button>
      </div>
      <div className="srch-list">
        {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
        {!busy && rows.length === 0 && <div className="ur-empty">{lockedOnly ? t("No locked files.") : t("No files open by anyone.")}</div>}
        {rows.map((f) => {
          const dp = f.depotFile || "";
          const sp = splitPath(dp);
          const locked = isLocked(f);
          const mine = (f.user || "").toLowerCase() === me.toLowerCase();
          return (
            <div key={dp + (f.client || "")} className="srch-row">
              <span className={"held" + (locked ? " locked" : "")} style={{ marginRight: 4 }}>{locked ? I.lock : I.unlock}</span>
              <span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
              <span className="lk-who">{f.user}{mine ? " · " + t("you") : ""}<span className="lk-client">{f.client}</span></span>
              {locked && mine && <button className="rslv-act" title={t("Unlock")} onClick={() => { onUnlock(dp); setTimeout(load, 400); }}>{t("Unlock")}</button>}
              <button className="srch-act" title={t("Open in Explorer")} onClick={() => onReveal(dp)}>{I.folder}</button>
            </div>
          );
        })}
      </div>
    </>
  );
}

function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; onClose: () => void; onReveal: (dp: string) => void; onFlash: (t: string, e?: boolean) => void; onUnlock: (dp: string) => void }) {
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.lock}<h3>{t("File Locks")}</h3>
          <button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <LocksPanel me={me} onReveal={onReveal} onFlash={onFlash} onUnlock={onUnlock} />
      </div>
    </div>
  );
}

/* ---------------- .p4ignore editor ---------------- */
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\n# (build intermediates come down read-only and break rebuilds if committed)\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\nobj/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
  const [text, setText] = useState("");
  const [busy, setBusy] = useState(true);
  const [saving, setSaving] = useState(false);
  useEffect(() => {
    let live = true;
    p4.ignoreRead().then((s) => { if (live) { setText(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, []); // eslint-disable-line
  async function save() {
    setSaving(true);
    try { await p4.ignoreWrite(text); onFlash(t(".p4ignore saved.")); onClose(); }
    catch (e) { onFlash(String(e), true); }
    finally { setSaving(false); }
  }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.gear}<h3>.p4ignore</h3><span className="ph-sub">{t("at the workspace root")}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint">{t("Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).")}
          {!busy && !text.trim() && <> <button className="ce-btn" style={{ display: "inline-flex", marginLeft: 6 }} onClick={() => setText(P4IGNORE_TEMPLATE)}>{t("Insert Unreal template")}</button></>}</div>
        {busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
          : <textarea className="code-edit" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} placeholder={"Binaries/\nIntermediate/\nSaved/\n*.tmp"} />}
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
          <button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- clean workspace (make it match the depot) ---------------- */
function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
  const [files, setFiles] = useState<OpenedFile[]>([]);
  const [busy, setBusy] = useState(true);
  const [applying, setApplying] = useState(false);
  useEffect(() => {
    let live = true;
    p4.cleanPreview(scope).then((f) => live && setFiles(f)).catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } }).finally(() => live && setBusy(false));
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [scope]); // eslint-disable-line
  async function apply() {
    setApplying(true);
    try { await p4.cleanApply(scope); onFlash(t("Workspace cleaned to match the depot.")); onDone(); }
    catch (e) { onFlash(String(e), true); }
    finally { setApplying(false); }
  }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.revert}<h3>{t("Clean workspace")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint" style={{ color: "var(--del)" }}>{t("Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.")}</div>
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning…")}</div>}
          {!busy && files.length === 0 && <div className="ur-empty">{t("Workspace already matches the depot — nothing to clean.")}</div>}
          {files.map((f) => {
            const dp = f.depotFile || f.clientFile || "";
            const sp = splitPath(dp);
            const st = statusOf(f.action);
            return (
              <div key={dp} className="srch-row">
                <span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
                <span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
                <span className="lk-who">{f.action}</span>
              </div>
            );
          })}
        </div>
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
          <button className="mbtn danger" onClick={apply} disabled={applying || busy || files.length === 0}>{applying ? <span className="ldr sm" /> : null}{t("Clean {n} file(s)", { n: files.length })}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- folder properties ---------------- */
function FolderPropsModal({ path, name, onClose, onReveal, onRename, onDelete, onCopy }: { path: string; name: string; onClose: () => void; onReveal: (p: string) => void; onRename: () => void; onDelete: () => void; onCopy: (p: string) => void }) {
  const [info, setInfo] = useState<{ fileCount: number; size: number } | null>(null);
  const isDepot = path.startsWith("//");
  useEffect(() => {
    let live = true;
    p4.folderInfo(path).then((i) => live && setInfo(i)).catch(() => live && setInfo({ fileCount: 0, size: 0 }));
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [path]); // eslint-disable-line
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.folder}<h3>{t("Folder properties")}</h3>
          <button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="fp-body">
          <div className="fp-name">{I.folder}<b>{name}</b></div>
          <div className="fp-grid">
            <span className="k">{t("Path")}</span><span className="v mono">{path}</span>
            <span className="k">{t("Files")}</span><span className="v">{info ? info.fileCount.toLocaleString() : "…"}</span>
            <span className="k">{t("Total size")}</span><span className="v">{info ? humanSize(info.size) : "…"}</span>
            <span className="k">{t("Location")}</span><span className="v">{isDepot ? t("depot") : t("local only")}</span>
          </div>
        </div>
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)", gap: 8 }}>
          <button className="mbtn ghost" onClick={() => onReveal(path)}>{t("Open in Explorer")}</button>
          <button className="mbtn ghost" onClick={() => onCopy(path)}>{t("Copy path")}</button>
          {isDepot && <button className="mbtn ghost" onClick={onRename}>{t("Rename…")}</button>}
          <button className="mbtn danger" style={{ marginLeft: "auto" }} onClick={onDelete}>{t("Delete folder…")}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- reconcile offline work ---------------- */
// P4V-style "Reconcile Offline Work": scan the workspace for changes made while
// disconnected (edits / adds / deletes) and open the picked ones into a changelist.
function ReconcileModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
  const [files, setFiles] = useState<OpenedFile[]>([]);
  const [busy, setBusy] = useState(true);
  const [applying, setApplying] = useState(false);
  const [sel, setSel] = useState<Set<string>>(new Set());
  const keyOf = (f: OpenedFile) => f.depotFile || f.clientFile || "";
  useEffect(() => {
    let live = true;
    p4.reconcilePreview(scope)
      .then((f) => { if (live) { setFiles(f); setSel(new Set(f.map(keyOf).filter(Boolean))); } })
      .catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } })
      .finally(() => live && setBusy(false));
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [scope]); // eslint-disable-line
  const toggle = (k: string) => setSel((s) => { const n = new Set(s); n.has(k) ? n.delete(k) : n.add(k); return n; });
  const allOn = files.length > 0 && sel.size === files.length;
  const toggleAll = () => setSel(allOn ? new Set() : new Set(files.map(keyOf).filter(Boolean)));
  async function apply() {
    const paths = files.map(keyOf).filter((k) => sel.has(k));
    if (!paths.length) return;
    setApplying(true);
    try {
      const opened = await p4.reconcileApply(paths);
      onFlash(t("Reconciled {n} file(s) into a changelist.", { n: opened.length || paths.length }));
      onDone();
    } catch (e) { onFlash(String(e), true); }
    finally { setApplying(false); }
  }
  // group for a readable header count
  const counts = { add: 0, edit: 0, delete: 0 } as Record<string, number>;
  for (const f of files) { const a = (f.action || "").toLowerCase(); if (a.includes("add")) counts.add++; else if (a.includes("delete")) counts.delete++; else counts.edit++; }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.scan}<h3>{t("Reconcile Offline Work")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint">{t("These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.")}</div>
        {!busy && files.length > 0 && (
          <div className="rec-summary">
            <span style={{ color: "var(--add)", fontWeight: 700 }}>+{counts.add}</span> {t("added")} · <span style={{ color: "var(--edit)", fontWeight: 700 }}>±{counts.edit}</span> {t("modified")} · <span style={{ color: "var(--del)", fontWeight: 700 }}>−{counts.delete}</span> {t("deleted")}
            <span className="rec-all" onClick={toggleAll}>{allOn ? t("Deselect all") : t("Select all")}</span>
          </div>
        )}
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning workspace for offline changes…")}</div>}
          {!busy && files.length === 0 && <div className="ur-empty">{t("Nothing to reconcile — the workspace matches what the server knows.")}</div>}
          {files.map((f) => {
            const dp = keyOf(f);
            const sp = splitPath(dp);
            const st = statusOf(f.action);
            const on = sel.has(dp);
            return (
              <div key={dp} className="srch-row rec-row" onClick={() => toggle(dp)}>
                <span className={"chk" + (on ? "" : " off")}>{on ? I.check : null}</span>
                <span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
                <span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
                <span className="lk-who">{f.action}</span>
              </div>
            );
          })}
        </div>
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
          <button className="mbtn" onClick={apply} disabled={applying || busy || sel.size === 0}>{applying ? <span className="ldr sm" /> : null}{t("Reconcile {n} file(s)", { n: sel.size })}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- Depot & Workspace file/size explorer ---------------- */
const FILE_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M6 3h8l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" stroke="currentColor" strokeWidth="1.5" /><path d="M14 3v4h4" stroke="currentColor" strokeWidth="1.5" /></svg>;
const sortBySize = (a: FsEntry[]) => [...a].sort((x, y) => (y.size - x.size) || Number(y.isDir) - Number(x.isDir) || x.name.localeCompare(y.name));

// one row in the tree; folders lazily load their children on first expand,
// files trigger onOpen (open in the File Viewer / reveal).
function ExpNode({ node, depth, load, max, onReveal, onFlash, onOpen, onMenu }: { node: FsEntry; depth: number; load: (p: string) => Promise<FsEntry[]>; max: number; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpen?: (n: FsEntry) => void; onMenu?: (n: FsEntry, e: ReactMouseEvent) => void }) {
  const [open, setOpen] = useState(false);
  const [kids, setKids] = useState<FsEntry[] | null>(null);
  const [busy, setBusy] = useState(false);
  async function activate() {
    if (!node.isDir) { onOpen?.(node); return; }
    if (!open && kids === null) {
      setBusy(true);
      try { setKids(sortBySize(await load(node.path))); }
      catch (e) { onFlash(String(e), true); setKids([]); }
      finally { setBusy(false); }
    }
    setOpen((o) => !o);
  }
  const pct = Math.max(2, Math.round((node.size / max) * 100));
  const childMax = Math.max(1, ...(kids || []).map((k) => k.size));
  return (
    <div className="exp-node">
      <div className={"exp-row" + (node.isDir || onOpen ? " dir" : "")} style={{ paddingLeft: 10 + depth * 15 }} onClick={activate} onContextMenu={(e) => { if (node.isDir && onMenu) { e.preventDefault(); e.stopPropagation(); onMenu(node, e); } }} title={node.path}>
        <span className="exp-caret">{node.isDir ? (busy ? <span className="ldr sm" /> : <span className={"chevi" + (open ? " open" : "")}>{I.chevron}</span>) : null}</span>
        <span className="exp-ic">{node.isDir ? I.folder : FILE_GLYPH}</span>
        <span className="exp-name">{node.name}</span>
        {node.isDir && node.fileCount > 0 ? <span className="exp-count">{t("{n} files", { n: node.fileCount })}</span> : null}
        <span className="exp-bar"><span style={{ width: pct + "%" }} /></span>
        <span className="exp-size">{humanSize(node.size)}</span>
        <span className="exp-open" onClick={(e) => { e.stopPropagation(); onReveal(node.path); }} title={t("Open in Explorer")}>{I.folder}</span>
      </div>
      {open && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={depth + 1} load={load} max={childMax} onReveal={onReveal} onFlash={onFlash} onOpen={onOpen} onMenu={onMenu} />)}
    </div>
  );
}

// The tree body (Depot / Workspace toggle + lazy size tree), reused by the
// Files & Sizes modal AND the embedded File Tree view-panel.
function FileTree({ scope, onReveal, onFlash, onOpenFile, onNewFolder, onFolderMenu, initialSide, sideNonce }: { scope: string; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void; onOpenFile?: (n: FsEntry, side: "depot" | "workspace") => void; onNewFolder?: (side: "depot" | "workspace") => void; onFolderMenu?: (n: FsEntry, e: ReactMouseEvent) => void; initialSide?: "depot" | "workspace"; sideNonce?: number }) {
  const [side, setSide] = useState<"depot" | "workspace">(initialSide ?? "depot");
  useEffect(() => { if (initialSide) setSide(initialSide); }, [sideNonce]); // eslint-disable-line
  const [kids, setKids] = useState<FsEntry[] | null>(null);
  const [busy, setBusy] = useState(true);
  const [err, setErr] = useState("");
  const load = (path: string) => (side === "depot" ? p4.depotLs(path) : p4.fsLs(path));
  const rootPath = side === "depot" ? scope : "";
  const rootLabel = side === "depot" ? (scope || t("Whole depot")) : t("Workspace root");
  useEffect(() => {
    let live = true;
    setBusy(true); setKids(null); setErr("");
    load(rootPath).then((k) => { if (live) setKids(sortBySize(k)); }).catch((e) => { if (live) { setErr(String(e)); setKids([]); } }).finally(() => live && setBusy(false));
    return () => { live = false; };
  }, [side, scope]); // eslint-disable-line
  const total = (kids || []).reduce((a, b) => a + b.size, 0);
  const max = Math.max(1, ...(kids || []).map((k) => k.size));
  return (
    <>
      <div className="ft-bar">
        <div className="exp-tabs">
          <button className={"exp-tab" + (side === "depot" ? " on" : "")} onClick={() => setSide("depot")}>{t("Depot")}</button>
          <button className={"exp-tab" + (side === "workspace" ? " on" : "")} onClick={() => setSide("workspace")}>{t("Workspace")}</button>
        </div>
        <span className="ft-total">{rootLabel} · <b>{humanSize(total)}</b></span>
        {onNewFolder && <button className="ft-new" title={t("New folder / project…")} onClick={() => onNewFolder(side)}>{I.folder}<span>{t("New")}</span></button>}
      </div>
      <div className="exp-tree">
        {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Weighing…")}</div>}
        {!busy && err && <div className="ur-empty">{err}</div>}
        {!busy && kids && kids.length === 0 && !err && <div className="ur-empty">{t("Empty.")}</div>}
        {!busy && kids && kids.map((k) => <ExpNode key={k.path} node={k} depth={0} load={load} max={max} onReveal={onReveal} onFlash={onFlash} onOpen={onOpenFile ? (n) => onOpenFile(n, side) : undefined} onMenu={onFolderMenu} />)}
      </div>
    </>
  );
}

function ExplorerModal({ scope, onClose, onReveal, onFlash }: { scope: string; onClose: () => void; onReveal: (t: string) => void; onFlash: (t: string, e?: boolean) => void }) {
  useEffect(() => {
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => document.removeEventListener("keydown", k);
  }, []); // eslint-disable-line
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide tall" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.folder}<h3>{t("Files & Sizes")}</h3>
          <button className="x" onClick={onClose} style={{ marginLeft: "auto" }}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint">{t("Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.")}</div>
        <FileTree scope={scope} onReveal={onReveal} onFlash={onFlash} onOpenFile={(n) => onReveal(n.path)} />
      </div>
    </div>
  );
}

/* ---------------- file history timeline (filelog) ---------------- */
function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) {
  const [revs, setRevs] = useState<FileRev[]>([]);
  const [busy, setBusy] = useState(true);
  useEffect(() => {
    let live = true;
    p4.filelog(depot).then((v) => { if (live) { setRevs(filelogRevs(v)); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [depot]); // eslint-disable-line
  async function diffPrev(rev: string) {
    const n = Number(rev);
    if (n <= 1) { onFlash(t("No earlier revision to compare."), true); return; }
    try { const text = await p4.diff2(`${depot}#${n - 1}`, `${depot}#${n}`); onShowDiff(`${name}  #${n - 1} ↔ #${n}`, text || t("(files are identical)")); }
    catch (e) { onFlash(String(e), true); }
  }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.log}<h3>{t("File history")}</h3><span className="ph-sub">{name}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="flog-body">
          {busy && <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>}
          {!busy && revs.length === 0 && <div className="ur-empty">{t("No history.")}</div>}
          {revs.map((r) => {
            const st = statusOf(r.action);
            return (
              <div key={r.rev} className="flog-row">
                <span className="flog-rail"><span className={"flog-dot " + st.cls} />{Number(r.rev) > 1 && <span className="flog-line" />}</span>
                <div className="flog-main">
                  <div className="flog-head"><span className="rev">#{r.rev}</span><span className={"stat " + st.cls}>{st.ch}</span><span className="flog-cl">#{r.change}</span><span className="flog-user">{r.user}</span><span className="flog-time">{fmtTime(r.time)}</span></div>
                  <div className="flog-desc">{(r.desc || "").trim() || t("(no description)")}</div>
                </div>
                {Number(r.rev) > 1 && <button className="rslv-act" onClick={() => diffPrev(r.rev)}>{t("Diff ↔ prev")}</button>}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------------- jobs (list / create / edit) ---------------- */
function JobsModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
  const [jobs, setJobs] = useState<{ Job?: string; Status?: string; Description?: string; User?: string }[]>([]);
  const [busy, setBusy] = useState(true);
  const [q, setQ] = useState("");
  const [edit, setEdit] = useState<null | { spec: string; isNew: boolean }>(null);
  const [saving, setSaving] = useState(false);
  const load = () => { setBusy(true); p4.jobs().then(setJobs).catch((e) => { onFlash(String(e), true); setJobs([]); }).finally(() => setBusy(false)); };
  useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && (edit ? setEdit(null) : onClose()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [edit]); // eslint-disable-line
  async function openEdit(job: string) {
    try { const spec = await p4.jobSpec(job); setEdit({ spec, isNew: !job }); }
    catch (e) { onFlash(String(e), true); }
  }
  async function saveEdit() {
    if (!edit) return;
    setSaving(true);
    try { await p4.jobSave(edit.spec); onFlash(t("Job saved.")); setEdit(null); load(); }
    catch (e) { onFlash(String(e), true); }
    finally { setSaving(false); }
  }
  const s = q.trim().toLowerCase();
  const rows = jobs.filter((j) => !s || (j.Job || "").toLowerCase().includes(s) || (j.Description || "").toLowerCase().includes(s) || (j.User || "").toLowerCase().includes(s));
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.log}<h3>{t("Jobs")}</h3><span className="ph-sub">{edit ? (edit.isNew ? t("New job") : t("Edit job")) : t("{n} jobs", { n: jobs.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        {edit ? (<>
          <div className="tm-hint">{t("Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.")}</div>
          <textarea className="code-edit" value={edit.spec} spellCheck={false} onChange={(e) => setEdit({ ...edit, spec: e.target.value })} />
          <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
            <button className="mbtn ghost" onClick={() => setEdit(null)} disabled={saving}>{t("Back")}</button>
            <button className="mbtn primary" onClick={saveEdit} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
          </div>
        </>) : (<>
          <div className="rslv-bar">
            <div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter jobs…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
            <button className="mbtn primary" onClick={() => openEdit("")}>{t("New job")}</button>
          </div>
          <div className="srch-list">
            {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
            {!busy && rows.length === 0 && <div className="ur-empty">{t("No jobs.")}</div>}
            {rows.map((j) => (
              <div key={j.Job} className="srch-row" onClick={() => openEdit(j.Job || "")} style={{ cursor: "pointer" }}>
                <span className="srch-body"><span className="n">{j.Job} <span style={{ color: "var(--faint)", fontWeight: 400 }}>· {j.Status}</span></span><span className="p">{(j.Description || "").trim().slice(0, 120)}{j.User ? " — " + j.User : ""}</span></span>
              </div>
            ))}
          </div>
        </>)}
      </div>
    </div>
  );
}

/* ---------------- streams (list / switch / merge down / copy up) ---------------- */
function StreamsModal({ current, onClose, onFlash, onSwitch, onInteg }: { current: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSwitch: (s: string) => void; onInteg: (s: string, dir: "down" | "up") => void }) {
  const [streams, setStreams] = useState<{ Stream?: string; Name?: string; Type?: string; Parent?: string }[]>([]);
  const [busy, setBusy] = useState(true);
  const [q, setQ] = useState("");
  useEffect(() => {
    let live = true;
    p4.streams().then((s) => live && setStreams(s)).catch((e) => { if (live) { onFlash(String(e), true); setStreams([]); } }).finally(() => live && setBusy(false));
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, []); // eslint-disable-line
  const s = q.trim().toLowerCase();
  const rows = streams.filter((x) => !s || (x.Stream || "").toLowerCase().includes(s) || (x.Name || "").toLowerCase().includes(s));
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.branch}<h3>{t("Streams")}</h3><span className="ph-sub">{current || t("(not on a stream)")}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        {current && (
          <div className="rslv-bar">
            <span style={{ flex: 1, fontSize: 12, color: "var(--muted)" }}>{t("Current stream integration")}</span>
            <button className="mbtn ghost" onClick={() => onInteg(current, "down")}>{t("Merge down")}</button>
            <button className="mbtn ghost" onClick={() => onInteg(current, "up")}>{t("Copy up")}</button>
          </div>
        )}
        <div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input placeholder={t("Filter streams…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
          {!busy && rows.length === 0 && <div className="ur-empty">{t("No streams (this depot may be classic, not stream-based).")}</div>}
          {rows.map((x) => {
            const st = x.Stream || "";
            const on = st === current;
            return (
              <div key={st} className={"srch-row" + (on ? " on" : "")}>
                <span className={"held" + (on ? " locked" : "")} style={{ marginRight: 4 }}>{I.branch}<span>{x.Type || ""}</span></span>
                <span className="srch-body"><span className="n">{x.Name || st}{on && " · " + t("current")}</span><span className="p">{st}{x.Parent && x.Parent !== "none" ? " ← " + x.Parent : ""}</span></span>
                {!on && <button className="rslv-act" onClick={() => onSwitch(st)}>{t("Switch")}</button>}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------------- integrate / merge / copy between branches ---------------- */
function BranchModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
  const [op, setOp] = useState<"merge" | "copy" | "integrate">("merge");
  const [source, setSource] = useState(scope ? scope + "/..." : "//depot/…/...");
  const [target, setTarget] = useState("//depot/…/...");
  const [busy, setBusy] = useState(false);
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
  const DESC: Record<string, string> = {
    merge: t("Merge changes from source into target — you resolve conflicts, then submit."),
    copy: t("Copy source over target verbatim (no merge). Target becomes identical to source."),
    integrate: t("Classic integrate — open target files for integration from source."),
  };
  async function run() {
    if (!source.trim() || !target.trim()) { onFlash(t("Source and target are required."), true); return; }
    setBusy(true);
    try {
      const out = await p4.branchOp(op, source.trim(), target.trim());
      onFlash(t("{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}", { op, out: out.split("\n")[0] }));
      onDone();
    } catch (e) { onFlash(String(e), true); }
    finally { setBusy(false); }
  }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker" style={{ width: 560, maxWidth: "94%" }} onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.branch}<h3>{t("Integrate / Merge / Copy")}</h3>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="info-body">
          <div className="langsel" style={{ marginBottom: 12 }}>
            {(["merge", "copy", "integrate"] as const).map((o) => (
              <button key={o} className={"langbtn" + (op === o ? " on" : "")} onClick={() => setOp(o)}>
                <span className="lname">{o === "merge" ? t("Merge") : o === "copy" ? t("Copy") : t("Integrate")}</span>
              </button>
            ))}
          </div>
          <div className="tm-hint" style={{ border: "none", padding: "0 0 12px" }}>{DESC[op]}</div>
          <div className="info-row"><span className="rk">{t("Source")}</span><input className="kf-in mono" value={source} onChange={(e) => setSource(e.target.value)} placeholder="//depot/dev/..." /></div>
          <div className="info-row"><span className="rk">{t("Target")}</span><input className="kf-in mono" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="//depot/main/..." /></div>
          <div className="modal-actions" style={{ marginTop: 16 }}>
            <button className="mbtn ghost" onClick={onClose} disabled={busy}>{t("Cancel")}</button>
            <button className="mbtn primary" onClick={run} disabled={busy}>{busy ? <span className="ldr sm" /> : null}{t("Run {op}", { op: op === "merge" ? t("Merge") : op === "copy" ? t("Copy") : t("Integrate") })}</button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---------------- labels (list / create / sync-to) ---------------- */
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
  const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);
  const [busy, setBusy] = useState(true);
  const [q, setQ] = useState("");
  const load = () => { setBusy(true); p4.labels().then(setLabels).catch((e) => { onFlash(String(e), true); setLabels([]); }).finally(() => setBusy(false)); };
  useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
  const [creating, setCreating] = useState(false);
  const [newName, setNewName] = useState("");
  async function create() {
    const n = newName.trim();
    if (!n) return;
    setCreating(true);
    try { await p4.labelTag(n, scope); onFlash(t("Label “{c}” tagged at current head.", { c: n })); setNewName(""); load(); }
    catch (e) { onFlash(String(e), true); }
    finally { setCreating(false); }
  }
  const s = q.trim().toLowerCase();
  const rows = labels.filter((l) => !s || (l.label || "").toLowerCase().includes(s) || (l.Description || "").toLowerCase().includes(s));
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.clock}<h3>{t("Labels")}</h3><span className="ph-sub">{t("{n} labels", { n: labels.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="rslv-bar">
          <div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Filter labels…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
          <input className="kf-in" style={{ maxWidth: 190 }} placeholder={t("New label name")} value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && create()} />
          <button className="mbtn primary" disabled={creating || !newName.trim()} onClick={create}>{creating ? <span className="ldr sm" /> : null}{t("Tag current")}</button>
        </div>
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
          {!busy && rows.length === 0 && <div className="ur-empty">{t("No labels.")}</div>}
          {rows.map((l) => (
            <div key={l.label} className="srch-row">
              <span className="held" style={{ marginRight: 4 }}>{I.clock}</span>
              <span className="srch-body"><span className="n">{l.label}</span><span className="p">{(l.Description || "").trim() || l.Owner || ""}{l.Update ? " · " + fmtTime(l.Update) : ""}</span></span>
              <button className="rslv-act" onClick={() => onSyncTo(l.label || "")}>{t("Sync to")}</button>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

/* ---------------- workspace (client) spec editor ---------------- */
/* ---------------- workspace View (client mapping) — P4V-style include/exclude tree ---------------- */
type ViewRule = { exclude: boolean; depot: string; client: string };

function clientNameOf(spec: string): string {
  const m = spec.match(/^Client:\s*(\S+)/m);
  return m ? m[1] : "";
}
// parse the View: block of a client spec into ordered mapping rules
function parseViewRules(spec: string): ViewRule[] {
  const rules: ViewRule[] = [];
  let inView = false;
  for (const ln of spec.split(/\r?\n/)) {
    if (/^View:/.test(ln)) { inView = true; continue; }
    if (!inView) continue;
    if (/^\S/.test(ln)) break; // next section starts at column 0
    const s = ln.trim();
    if (!s || s.startsWith("#")) continue;
    const toks = s.match(/"[^"]*"|\S+/g) || [];
    const [d0, c0] = toks;
    if (!d0 || !c0) continue;
    let depot = d0.replace(/^"|"$/g, "");
    const client = c0.replace(/^"|"$/g, "");
    let exclude = false;
    if (depot.startsWith("-")) { exclude = true; depot = depot.slice(1); }
    else if (depot.startsWith("+")) { depot = depot.slice(1); }
    rules.push({ exclude, depot, client });
  }
  return rules;
}
const quoteIf = (s: string) => (/\s/.test(s) ? `"${s}"` : s);
// write the given rules back into the spec's View: block (replacing the old one)
function buildViewSpec(spec: string, rules: ViewRule[]): string {
  const lines = spec.split(/\r?\n/);
  const out: string[] = [];
  const viewLines = () => rules.map((r) => `\t${(r.exclude ? "-" : "") + quoteIf(r.depot)} ${quoteIf(r.client)}`);
  let inView = false, wrote = false;
  for (const ln of lines) {
    if (/^View:/.test(ln)) { out.push("View:"); out.push(...viewLines()); inView = true; wrote = true; continue; }
    if (inView) { if (/^\S/.test(ln)) { inView = false; out.push(ln); } continue; }
    out.push(ln);
  }
  if (!wrote) { out.push("View:"); out.push(...viewLines()); }
  return out.join("\n");
}
const rulePrefix = (depot: string) => depot.replace(/\.\.\.$/, "");
// is folder `depot` currently mapped in, and does any descendant rule differ (mixed)?
function nodeState(depot: string, rules: ViewRule[]): { included: boolean; mixed: boolean } {
  const dSlash = depot.endsWith("/") ? depot : depot + "/";
  let included = false, mixed = false;
  for (const r of rules) {
    const p = rulePrefix(r.depot);
    if (dSlash.startsWith(p)) included = !r.exclude; // rule covers this whole folder
  }
  for (const r of rules) {
    const p = rulePrefix(r.depot);
    if (p.startsWith(dSlash) && p !== dSlash && !r.exclude !== included) mixed = true; // a descendant differs
  }
  return { included, mixed };
}
// include or exclude a folder: drop prior self/descendant rules, append the new one
function setNode(rules: ViewRule[], depot: string, include: boolean, client: string): ViewRule[] {
  const dSlash = depot + "/";
  const rel = depot.replace(/^\/\/[^/]+\//, "");
  const clientPat = `//${client}/${rel}/...`;
  const kept = rules.filter((r) => { const p = rulePrefix(r.depot); return !(p === dSlash || p.startsWith(dSlash)); });
  kept.push({ exclude: !include, depot: `${dSlash}...`, client: clientPat });
  return kept;
}

function ViewTreeNode({ depot, name, depth, rules, onToggle }: { depot: string; name: string; depth: number; rules: ViewRule[]; onToggle: (depot: string, include: boolean) => void }) {
  const [open, setOpen] = useState(depth === 0);
  const [kids, setKids] = useState<{ depot: string; name: string }[] | null>(null);
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    if (!open || kids !== null) return;
    setLoading(true);
    p4.dirs(depot)
      .then((ds) => setKids(ds.filter((d) => d.dir).map((d) => ({ depot: d.dir as string, name: (d.dir as string).split("/").filter(Boolean).pop() || (d.dir as string) }))))
      .catch(() => setKids([]))
      .finally(() => setLoading(false));
  }, [open]); // eslint-disable-line
  const st = nodeState(depot, rules);
  return (
    <div className="vt-node">
      <div className="vt-row" style={{ paddingLeft: 8 + depth * 15 }}>
        <span className="vt-chev" onClick={() => setOpen((o) => !o)}>
          <svg viewBox="0 0 24 24" style={{ transform: open ? "rotate(90deg)" : "none" }} fill="none"><path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </span>
        <span className={"vt-chk" + (st.mixed ? " mixed" : st.included ? " on" : "")} onClick={() => onToggle(depot, !st.included)} title={st.included ? t("Included — click to exclude (won’t be synced)") : t("Excluded — click to include (will be synced)")}>
          {st.mixed ? <svg viewBox="0 0 24 24" fill="none"><path d="M6 12h12" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" /></svg>
            : st.included ? <svg viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg> : null}
        </span>
        <span className="vt-ic">{I.folder}</span>
        <span className="vt-name">{name}</span>
      </div>
      {open && kids && kids.map((k) => <ViewTreeNode key={k.depot} depot={k.depot} name={k.name} depth={depth + 1} rules={rules} onToggle={onToggle} />)}
      {open && loading && <div className="vt-loading" style={{ paddingLeft: 8 + (depth + 1) * 15 }}><span className="ldr sm" /></div>}
      {open && kids && kids.length === 0 && !loading && <div className="vt-empty" style={{ paddingLeft: 8 + (depth + 1) * 15 }}>{t("(no subfolders)")}</div>}
    </div>
  );
}

function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) {
  const [spec, setSpec] = useState("");
  const [busy, setBusy] = useState(true);
  const [saving, setSaving] = useState(false);
  const [mode, setMode] = useState<"tree" | "text">("tree");
  useEffect(() => {
    let live = true;
    p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [name]); // eslint-disable-line
  async function save() {
    setSaving(true);
    try {
      await p4.clientSave(spec);
      onFlash(isNew ? t("Workspace “{c}” created.", { c: name }) : t("Workspace “{c}” updated.", { c: name }));
      onSaved(name);
    } catch (e) { onFlash(String(e), true); }
    finally { setSaving(false); }
  }
  const rules = mode === "tree" ? parseViewRules(spec) : [];
  const toggleNode = (depot: string, include: boolean) => setSpec((s) => buildViewSpec(s, setNode(parseViewRules(s), depot, include, clientNameOf(s) || name)));
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</span>
          <div className="vt-modes" title={t("Switch between the folder tree and the raw spec")}>
            <button className={mode === "tree" ? "on" : ""} onClick={() => setMode("tree")} title={t("Folder tree (include / exclude)")}>{I.folder}</button>
            <button className={mode === "text" ? "on" : ""} onClick={() => setMode("text")} title={t("Raw spec text")}>{I.log}</button>
          </div>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint">{mode === "tree"
          ? t("Check a folder to include it in this workspace, uncheck to exclude. Excluded folders are not synced (not downloaded). This edits the depot → workspace View mapping.")
          : t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
        {busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
          : mode === "tree"
            ? <div className="vt-tree"><ViewTreeNode depot="//depot" name="depot" depth={0} rules={rules} onToggle={toggleNode} /></div>
            : <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
          <button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- typemap / exclusive-lock rules ---------------- */
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
// only one person can check them out at a time, preventing lost binary merges.
/* ---------------- Settings — sectioned hub (General / Appearance / Plugins) ---------------- */
type SetSection = "general" | "appearance" | "plugins";
function SettingsModal(props: {
  section?: SetSection; info: P4Info | null; session: Session | null;
  light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => void;
  lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void;
  editors: Editor[]; editorId: string; onPickEditor: (id: string) => void;
  onReloadPlugins: () => Promise<{ loaded: number; errors: string[] }>;
  onDisconnect: () => void; onFlash: (t: string, e?: boolean) => void; onClose: () => void;
}) {
  const { info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onReloadPlugins, onDisconnect, onFlash, onClose } = props;
  const [sec, setSec] = useState<SetSection>(props.section || "general");
  function close() { applyTheme(resolveTheme(themeId)); onClose(); } // drop any unsaved live theme preview
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && close(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
  const NAV: { id: SetSection; label: string; icon: ReactNode }[] = [
    { id: "general", label: t("General"), icon: I.gear },
    { id: "appearance", label: t("Appearance"), icon: I.sun },
    { id: "plugins", label: t("Plugins"), icon: I.hex },
  ];
  return (
    <div className="modal-back" onClick={close}>
      <div className="settings-modal" onClick={(e) => e.stopPropagation()}>
        <div className="set-nav">
          <div className="set-nav-h"><span className="set-nav-logo">{I.gear}</span><b>{t("Settings")}</b></div>
          {NAV.map((n) => (
            <button key={n.id} className={"set-nav-item" + (sec === n.id ? " on" : "")} onClick={() => setSec(n.id)}>
              <span className="sni-ic">{n.icon}</span>{n.label}
            </button>
          ))}
          <div className="set-nav-sp" />
          <button className="set-nav-item danger" onClick={() => { close(); onDisconnect(); }}>{I.power}{t("Disconnect")}</button>
        </div>
        <div className="set-main">
          <div className="set-main-h"><h3>{NAV.find((n) => n.id === sec)?.label}</h3>
            <button className="x" onClick={close}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
          </div>
          {sec === "general" && (
            <div className="info-body" style={{ overflow: "auto" }}>
              <div className="info-row"><span className="rk">{t("Language")}</span>
                <div className="langsel">
                  {LANGS.map((l) => (
                    <button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
                      <span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
                    </button>
                  ))}
                </div>
              </div>
              <div className="info-row"><span className="rk">{t("Base theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
              <div className="info-row"><span className="rk">{t("Interface scale")}</span>
                <div className="zoomsel">
                  <button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title="−">−</button>
                  <button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
                  <button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
                </div>
              </div>
              <div className="info-row"><span className="rk">{t("Code editor")}</span>
                <select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
                  {editors.length === 0 && <option value="default">{t("System default")}</option>}
                  {editors.map((ed) => (<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>))}
                </select>
              </div>
              <div className="set-sep" />
              <div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
              <div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
              <div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
              <div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
            </div>
          )}
          {sec === "appearance" && <ThemesModal embedded activeId={themeId} onApply={setThemeId} onFlash={onFlash} />}
          {sec === "plugins" && <PluginsModal embedded onReload={onReloadPlugins} onFlash={onFlash} />}
        </div>
      </div>
    </div>
  );
}

/* ---------------- plugins: manage installed plugins ---------------- */
function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () => void; onReload: () => Promise<{ loaded: number; errors: string[] }>; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
  const [list, setList] = useState<PluginInfo[]>([]);
  const [busy, setBusy] = useState(true);
  const [dir, setDir] = useState("");
  const [reg, setReg] = useState<RegistryEntry[]>([]);
  const [installing, setInstalling] = useState<string>("");
  const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
  useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); fetchRegistry().then(setReg).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
  async function installReg(entry: RegistryEntry) {
    setInstalling(entry.id);
    try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
    catch (e) { onFlash(String(e), true); }
    finally { setInstalling(""); }
  }
  async function toggle(p: PluginInfo, enabled: boolean) {
    try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
  }
  async function remove(p: PluginInfo) {
    try { await p4.pluginRemove(p.id); load(); await onReload(); onFlash(t("Removed plugin “{n}”.", { n: p.name })); } catch (e) { onFlash(String(e), true); }
  }
  async function install() {
    try {
      const sel = await openDialog({ directory: true, title: t("Pick a plugin folder (contains plugin.json)") });
      if (!sel || Array.isArray(sel)) return;
      const id = await p4.pluginInstall(sel);
      load(); await onReload();
      onFlash(t("Installed plugin “{id}”.", { id }));
    } catch (e) { onFlash(String(e), true); }
  }
  const body = (<>
        <div className="tm-hint">{t("Plugins add features on top of the core app. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.")}</div>
        <div className="pl-list">
          {busy ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
            : list.length === 0 ? <div className="ur-empty">{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}</div>
              : list.map((p) => (
                <div key={p.id} className={"pl-card" + (p.enabled ? "" : " off")}>
                  <div className="pl-main">
                    <div className="pl-top"><b>{p.name || p.id}</b><span className="pl-ver">v{p.version}</span></div>
                    {p.description && <div className="pl-desc">{p.description}</div>}
                    <div className="pl-meta">
                      {p.author && <span className="pl-tag">{p.author}</span>}
                      {(p.permissions || []).map((perm: string) => <span key={perm} className="pl-perm">{perm}</span>)}
                    </div>
                  </div>
                  <div className="pl-actions">
                    <button className={"pl-toggle" + (p.enabled ? " on" : "")} onClick={() => toggle(p, !p.enabled)} title={p.enabled ? t("Disable") : t("Enable")}><span /></button>
                    <button className="pl-del" onClick={() => remove(p)} title={t("Remove")}>{I.trash}</button>
                  </div>
                </div>
              ))}
          {reg.filter((e) => !list.some((p) => p.id === e.id)).length > 0 && (<>
            <div className="pl-official-h">{t("Official plugins")}</div>
            {reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
              <div key={e.id} className="pl-card">
                <div className="pl-main">
                  <div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div>
                  {e.description && <div className="pl-desc">{e.description}</div>}
                  <div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div>
                </div>
                <div className="pl-actions">
                  <button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
                </div>
              </div>
            ))}
          </>)}
        </div>
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>
          <button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => onReload().then(({ loaded }) => onFlash(t("{n} plugin(s) loaded", { n: loaded })))}>{I.sync}{t("Reload")}</button>
          <button className="mbtn primary" onClick={install}>{I.open}{t("Install from folder…")}</button>
        </div>
  </>);
  if (embedded) return <div className="set-panel">{body}</div>;
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.hex}<h3>{t("Plugins")}</h3><span className="ph-sub">{t("{n} installed", { n: list.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        {body}
      </div>
    </div>
  );
}

/* a plugin-contributed view, mounted into a plain DOM container */
function PluginViewModal({ view, onClose }: { view: PluginView; onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = ref.current; if (!el) return;
    let cleanup: void | (() => void);
    try { cleanup = view.render(el); } catch (e) { el.textContent = String(e); }
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { document.removeEventListener("keydown", k); if (typeof cleanup === "function") { try { cleanup(); } catch {} } };
  }, []); // eslint-disable-line
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.hex}<h3>{view.title}</h3><span className="ph-sub">{view.pluginId}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="plugin-view" ref={ref} />
      </div>
    </div>
  );
}

/* ---------------- themes (switch + custom theme editor) ---------------- */
function ThemesModal({ activeId, onApply, onClose, onFlash, embedded }: { activeId: string; onApply: (id: string) => void; onClose?: () => void; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
  const [themes, setThemes] = useState<Theme[]>(() => allThemes());
  const [editing, setEditing] = useState<Theme | null>(null); // custom theme being edited/created
  const fileRef = useRef<HTMLInputElement>(null);
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && (editing ? setEditing(null) : onClose?.()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [editing]); // eslint-disable-line
  // live preview while editing; restore the active theme when leaving the editor
  useEffect(() => { if (editing) applyTheme(editing); else applyTheme(resolveTheme(activeId)); }, [editing]); // eslint-disable-line

  function refreshList() { setThemes(allThemes()); }
  function startNew() {
    const base = BUILTIN_THEMES[0]!;
    const vars: Record<string, string> = {};
    for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(base, tk.var);
    setEditing({ id: newCustomId(), name: t("My theme"), base: "dark", vars });
  }
  function startEdit(th: Theme) {
    const vars: Record<string, string> = {};
    for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(th, tk.var);
    setEditing({ ...th, vars });
  }
  function saveEditing() {
    if (!editing) return;
    const customs = loadCustomThemes().filter((c) => c.id !== editing.id);
    customs.push({ ...editing, builtin: false });
    saveCustomThemes(customs);
    refreshList();
    setEditing(null);
    onApply(editing.id);
    onFlash(t("Theme “{n}” saved.", { n: editing.name }));
  }
  function del(th: Theme) {
    saveCustomThemes(loadCustomThemes().filter((c) => c.id !== th.id));
    refreshList();
    if (activeId === th.id) onApply("dark");
    else applyTheme(resolveTheme(activeId));
  }
  function doExport(th: Theme) {
    const blob = new Blob([exportTheme(th)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob); a.download = `${th.name.replace(/\s+/g, "-").toLowerCase()}.exbyte-theme.json`; a.click();
    URL.revokeObjectURL(a.href);
  }
  function onImportFile(e: { target: HTMLInputElement }) {
    const f = e.target.files?.[0]; if (!f) return;
    f.text().then((txt) => { const th = importTheme(txt); const customs = loadCustomThemes(); customs.push(th); saveCustomThemes(customs); refreshList(); onApply(th.id); onFlash(t("Theme “{n}” imported.", { n: th.name })); })
      .catch((err) => onFlash(String(err), true));
  }

  const groups = Array.from(new Set(THEME_TOKENS.map((tk) => tk.group)));
  const body = (!editing ? (<>
          <div className="th-grid">
            {themes.map((th) => (
              <div key={th.id} className={"th-card" + (th.id === activeId ? " on" : "")} onClick={() => onApply(th.id)}>
                <div className="th-swatch" style={{ background: th.vars["--bg"] || (th.base === "light" ? "#eef0f5" : "#0b0b12") }}>
                  <span style={{ background: th.vars["--panel"] || (th.base === "light" ? "#fff" : "#161621") }} />
                  <span style={{ background: th.vars["--accent"] || "#7c6ef6" }} />
                  <span style={{ background: th.vars["--accent-2"] || "#9a8bf9" }} />
                </div>
                <div className="th-meta"><b>{th.name}</b><span>{th.base === "light" ? t("Light") : t("Dark")}{th.builtin ? "" : " · " + t("custom")}</span></div>
                {th.id === activeId && <span className="th-on">{I.check}</span>}
                {!th.builtin && (
                  <div className="th-actions" onClick={(e) => e.stopPropagation()}>
                    <button title={t("Edit")} onClick={() => startEdit(th)}>{I.pencil}</button>
                    <button title={t("Export")} onClick={() => doExport(th)}>{I.down}</button>
                    <button title={t("Delete")} className="danger" onClick={() => del(th)}>{I.trash}</button>
                  </div>
                )}
              </div>
            ))}
          </div>
          <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
            <input ref={fileRef} type="file" accept=".json" style={{ display: "none" }} onChange={onImportFile} />
            <button className="mbtn ghost" onClick={() => fileRef.current?.click()}>{I.open}{t("Import…")}</button>
            <button className="mbtn primary" onClick={startNew}><svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>{t("New custom theme")}</button>
          </div>
        </>) : (<>
          <div className="th-edit">
            <div className="th-edit-row">
              <label>{t("Name")}</label>
              <input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
              <div className="th-base">
                <button className={editing.base === "dark" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "dark" })}>{I.moon} {t("Dark")}</button>
                <button className={editing.base === "light" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "light" })}>{I.sun} {t("Light")}</button>
              </div>
            </div>
            {groups.map((g) => (
              <div key={g} className="th-group">
                <div className="th-group-h">{g}</div>
                <div className="th-tokens">
                  {THEME_TOKENS.filter((tk) => tk.group === g).map((tk) => (
                    <label key={tk.var} className="th-token">
                      <input type="color" value={editing.vars[tk.var] || "#000000"} onChange={(e) => setEditing({ ...editing, vars: { ...editing.vars, [tk.var]: e.target.value } })} />
                      <span>{tk.label}</span>
                    </label>
                  ))}
                </div>
              </div>
            ))}
          </div>
          <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
            <button className="mbtn ghost" onClick={() => setEditing(null)}>{t("Cancel")}</button>
            <button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => doExport(editing)}>{I.down}{t("Export")}</button>
            <button className="mbtn primary" onClick={saveEditing}>{I.check}{t("Save & apply")}</button>
          </div>
        </>));
  if (embedded) return <div className="set-panel">{body}</div>;
  return (
    <div className="modal-back" onClick={() => (editing ? setEditing(null) : onClose?.())}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.sun}<h3>{editing ? t("Custom theme") : t("Themes")}</h3>
          <span className="ph-sub">{editing ? editing.name : t("{n} themes", { n: themes.length })}</span>
          <button className="x" onClick={() => (editing ? setEditing(null) : onClose?.())}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        {body}
      </div>
    </div>
  );
}

const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
  const [entries, setEntries] = useState<string[]>([]);
  const [busy, setBusy] = useState(true);
  const [saving, setSaving] = useState(false);
  const [add, setAdd] = useState("");
  const load = () => { setBusy(true); p4.typemapGet().then(setEntries).catch((e) => { onFlash(String(e), true); setEntries([]); }).finally(() => setBusy(false)); };
  useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
  const patternOf = (e: string) => (e.split(/\s+/)[1] || "").toLowerCase();
  function addRecommended() {
    const have = new Set(entries.map(patternOf));
    const add: string[] = [];
    for (const ext of UE_LOCK_EXTS) {
      const pat = `//....${ext}`;
      if (!have.has(pat)) add.push(`binary+l ${pat}`);
    }
    if (!add.length) { onFlash(t("All recommended rules are already present."), false); return; }
    setEntries((cur) => [...cur, ...add]);
  }
  function addOne() {
    const v = add.trim();
    if (!v) return;
    // accept "binary+l //....ext" or just "//....ext" (defaults to binary+l)
    const entry = /\s/.test(v) ? v : `binary+l ${v}`;
    setEntries((cur) => [...cur, entry]);
    setAdd("");
  }
  async function save() {
    setSaving(true);
    try { await p4.typemapSet(entries); onFlash(t("Typemap saved.")); onClose(); }
    catch (e) { onFlash(String(e), true); }
    finally { setSaving(false); }
  }
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.lock}<h3>{t("Exclusive Locks (typemap)")}</h3>
          <span className="ph-sub">{t("{n} rules", { n: entries.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="tm-hint">{t("Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.")}</div>
        <div className="rslv-bar">
          <button className="mbtn primary" onClick={addRecommended}>{I.lock}{t("Add recommended Unreal rules")}</button>
          <div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Add rule, e.g. binary+l //....uasset")} value={add} onChange={(e) => setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} /></div>
        </div>
        <div className="srch-list">
          {busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
          {!busy && entries.length === 0 && <div className="ur-empty">{t("No typemap rules yet.")}</div>}
          {entries.map((e, i) => {
            const parts = e.split(/\s+/);
            const type = parts[0] || "";
            const pat = parts.slice(1).join(" ");
            const excl = /\+l/.test(type);
            return (
              <div key={i + e} className="srch-row">
                <span className={"held" + (excl ? " locked" : "")} style={{ marginRight: 4 }}>{excl ? I.lock : I.unlock}<span>{type}</span></span>
                <span className="srch-body"><span className="n" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{pat}</span></span>
                <button className="srch-act" title={t("Remove")} onClick={() => setEntries((cur) => cur.filter((_, j) => j !== i))}>✕</button>
              </div>
            );
          })}
        </div>
        <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
          <button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
          <button className="mbtn primary" onClick={save} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save typemap")}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- blame / annotate ---------------- */
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
  const [text, setText] = useState<string | null>(null);
  useEffect(() => {
    let live = true;
    p4.annotate(spec).then((s) => live && setText(s)).catch((e) => { if (live) { onFlash(String(e), true); setText(""); } });
    const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", k);
    return () => { live = false; document.removeEventListener("keydown", k); };
  }, [spec]); // eslint-disable-line
  // annotate lines look like: "12345: <user> <code>" — split the prefix for coloring
  const rows = (text || "").split("\n").map((ln, i) => {
    const m = ln.match(/^(\d+):\s*(\S+)?\s?(.*)$/);
    return m ? { n: i + 1, cl: m[1], user: m[2] || "", code: m[3] ?? "" } : { n: i + 1, cl: "", user: "", code: ln };
  });
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.people}<h3>{t("Blame")}</h3><span className="ph-sub">{name}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="blame-body">
          {text === null ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
            : rows.map((r) => (
              <div key={r.n} className="blame-row">
                <span className="bl-cl">{r.cl && "#" + r.cl}</span>
                <span className="bl-user">{r.user}</span>
                <span className="bl-code">{r.code || " "}</span>
              </div>
            ))}
        </div>
      </div>
    </div>
  );
}

/* ---------------- unified-diff viewer (diff2) ---------------- */
function DiffModal({ title, text, onClose }: { title: string; text: string; onClose: () => void }) {
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
  const lines = text.split("\n");
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.clock}<h3>{t("Diff")}</h3><span className="ph-sub">{title}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="diff-body">
          {lines.map((ln, i) => {
            const c = ln.startsWith("+") && !ln.startsWith("+++") ? "add" : ln.startsWith("-") && !ln.startsWith("---") ? "del" : ln.startsWith("@@") ? "hunk" : "";
            return <div key={i} className={"diff-line " + c}>{ln || " "}</div>;
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------------- resolve conflicts ---------------- */
function ResolveModal({ files, onResolve, onClose }: { files: OpenedFile[]; onResolve: (mode: "am" | "ay" | "at", file?: string) => void; onClose: () => void }) {
  useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
  return (
    <div className="modal-back" onClick={onClose}>
      <div className="picker wide" onClick={(e) => e.stopPropagation()}>
        <div className="picker-head">{I.hex}<h3>{t("Resolve conflicts")}</h3><span className="ph-sub">{t("{n} file(s)", { n: files.length })}</span>
          <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
        </div>
        <div className="rslv-bar">
          <button className="mbtn primary" onClick={() => onResolve("am")}>{t("Auto-merge all")}</button>
          <button className="mbtn ghost" onClick={() => onResolve("at")}>{t("Accept theirs (all)")}</button>
          <button className="mbtn ghost" onClick={() => onResolve("ay")}>{t("Accept yours (all)")}</button>
        </div>
        <div className="srch-list">
          {files.length === 0 && <div className="ur-empty">{t("Nothing to resolve.")}</div>}
          {files.map((f) => {
            const dp = f.depotFile || "";
            const sp = splitPath(dp);
            return (
              <div key={dp} className="srch-row">
                <span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
                <button className="rslv-act" title={t("Auto-merge")} onClick={() => onResolve("am", dp)}>{t("Merge")}</button>
                <button className="rslv-act" title={t("Accept theirs")} onClick={() => onResolve("at", dp)}>{t("Theirs")}</button>
                <button className="rslv-act" title={t("Accept yours")} onClick={() => onResolve("ay", dp)}>{t("Yours")}</button>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy, onOpen, selected, split, width }: { detail: Describe | null; busy?: boolean; onOpen?: (f: OpenedFile) => void; selected?: string; split?: boolean; width?: number }) {
  const ref = useRef<HTMLDivElement>(null);
  const [scroll, setScroll] = useState(0);
  const [h, setH] = useState(500);
  const fileRows = useMemo(() => (detail ? describeFiles(detail) : []), [detail]);
  useEffect(() => {
    const el = ref.current; if (!el) return;
    const onScroll = () => setScroll(el.scrollTop);
    el.addEventListener("scroll", onScroll);
    const ro = new ResizeObserver(() => setH(el.clientHeight));
    ro.observe(el); setH(el.clientHeight);
    return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
  }, [detail]);
  // jump back to top whenever a different changelist loads
  useEffect(() => { if (ref.current) ref.current.scrollTop = 0; setScroll(0); }, [detail?.change]);

  const cls = "right" + (split ? " histlist" : "");
  // width set → fixed, resizable column (direct inline style, no CSS-var indirection)
  const wstyle: CSSProperties | undefined = width != null ? { width, flex: "none" } : undefined;
  if (busy) return <div className={cls} style={wstyle}><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
  if (!detail) return <div className={cls} style={wstyle}><div className="nofile">{I.clock}<span>{t("Select a changelist in History")}</span></div></div>;

  const first = Math.max(0, Math.floor(scroll / CROW) - 6);
  const last = Math.min(fileRows.length, Math.ceil((scroll + h) / CROW) + 6);
  const rows = [];
  for (let i = first; i < last; i++) {
    const f = fileRows[i];
    const { name, dir } = splitPath(f.depotFile);
    const st = statusOf(f.action);
    const key = `${f.depotFile}#${f.rev}`;
    rows.push(
      <div key={i} className={"crow click" + (selected === key ? " sel" : "")} style={{ top: i * CROW }}
        onClick={() => onOpen?.({ depotFile: f.depotFile, action: f.action, rev: f.rev, type: "", change: detail.change, _spec: key })}>
        <span className={"stat " + st.cls}>{st.ch}</span>
        <span className="hbody"><span className="hdesc" style={{ WebkitLineClamp: 1 }}>{name}</span><span className="hmeta">{dir}#{f.rev}</span></span>
        <span className="crow-go">{I.chevron}</span>
      </div>
    );
  }
  return (
    <div className={cls} style={wstyle}>
      <div className="diffhead">
        <span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
        <span className="ttl">
          <span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
          <span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
        </span>
      </div>
      <div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
    </div>
  );
}

/* ---------------- history list ---------------- */
function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { history: Change[]; sizes?: Record<string, number>; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
  if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
  if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
  return (
    <div className="files" style={{ position: "relative" }}>
      {history.map((c) => {
        const sz = sizes?.[c.change || ""];
        // "NEW" badge on commits submitted within the last 3 hours (recomputed on
        // every history refresh, so it fades away on its own)
        const ageMs = c.time ? Date.now() - Number(c.time) * 1000 : Infinity;
        const fresh = ageMs >= 0 && ageMs < 3 * 3600 * 1000;
        return (
          <div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
            <Avatar user={c.user || "?"} size={26} />
            <span className="hbody">
              <span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
              <span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
            </span>
            {fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}
          </div>
        );
      })}
    </div>
  );
}

/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
// image formats the webview can actually decode in an <img>. Game/DCC formats
// (tga, dds, exr, psd, tiff, hdr…) can't — those get the glyph, not a broken img.
const IMG_RENDERABLE = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "bmp"]);
function Thumb({ file }: { file: OpenedFile }) {
  const dp = file.depotFile || "";
  const { name } = splitPath(dp);
  const kind = kindOf(name);
  const ext = (name.split(".").pop() || "").toLowerCase();
  const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
  useEffect(() => {
    const renderableImage = kind === "image" && IMG_RENDERABLE.has(ext);
    if (!renderableImage && kind !== "uasset") return;
    if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
    let live = true;
    // .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
    // real images → fetch the file bytes and blob them directly
    const job = kind === "uasset"
      ? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
      : p4.readDepot(dp).then((buf) =>
          URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }))
        );
    job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
      .catch(() => { thumbCache.set(dp, null); });
    return () => { live = false; };
  }, [dp]);

  // onError: some formats (or corrupt files) still fail to decode after all —
  // fall back to the glyph instead of the browser's broken-image icon
  if (url) return <img className="fthumb" src={url} alt="" onError={() => { thumbCache.set(dp, null); setUrl(null); }} />;
  const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
  return <span className={"fthumb ico k-" + kind}>{ic}</span>;
}

/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle, onContext }:
  { files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; others: Map<string, { user: string; locked: boolean }>; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void; onContext: (i: number, e: ReactMouseEvent) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  const [scroll, setScroll] = useState(0);
  const [h, setH] = useState(500);
  useEffect(() => {
    const el = ref.current!;
    const onScroll = () => setScroll(el.scrollTop);
    el.addEventListener("scroll", onScroll);
    const ro = new ResizeObserver(() => setH(el.clientHeight));
    ro.observe(el); setH(el.clientHeight);
    return () => { el.removeEventListener("scroll", onScroll); ro.disconnect(); };
  }, []);
  const first = Math.max(0, Math.floor(scroll / ROW) - 4);
  const last = Math.min(files.length, Math.ceil((scroll + h) / ROW) + 4);
  const rows = [];
  for (let i = first; i < last; i++) {
    const f = files[i];
    const dp = f.depotFile || "";
    const { name, dir } = splitPath(dp);
    const st = statusOf(f.action);
    const isCk = checked.has(dp);
    const other = others.get(dp);
    rows.push(
      <div key={dp + i} className={"file" + (selRows.has(i) ? " sel" : "") + (i === sel ? " primary" : "")} style={{ top: i * ROW }} onClick={(e) => onRowClick(i, e)} onContextMenu={(e) => onContext(i, e)}>
        <span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, dp); }}>{I.check}</span>
        <Thumb file={f} />
        <span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
        {other && <span className={"held" + (other.locked ? " locked" : "")} title={other.locked ? t("Locked by {u}", { u: other.user }) : t("Also open by {u}", { u: other.user })}>{other.locked ? I.lock : I.people}<span>{other.user}</span></span>}
        <span className={"stat " + st.cls}>{st.ch}</span>
      </div>
    );
  }
  return <div className="files" ref={ref}><div className="vspace" style={{ height: files.length * ROW }}>{rows}</div></div>;
}

/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, onFilelog, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; onFilelog?: (depot: string, name: string) => void; uproject?: string }) {
  const [explain, setExplain] = useState<string | null>(null); // AI code summary
  const [explBusy, setExplBusy] = useState(false);
  const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile
  // on file change show its cached summary (persisted; removed only via ×)
  useEffect(() => {
    explSeq.current++;
    setExplain(file ? getExplain(file._spec || file.depotFile || "") : null);
    setExplBusy(false);
  }, [file?.depotFile, file?._spec]);
  if (!file) return <div className="right"><div className="nofile">{I.cube}<span>{t("Select a file on the left")}</span></div></div>;
  const dp = file.depotFile || "";
  const spec = file._spec || "";      // set → read-only view of a submitted revision
  const hist = !!spec;
  const { name, dir } = splitPath(dp);
  const st = statusOf(file.action);
  const kind = kindOf(name);
  const codeFile = isCodeFile(name); // gate Summary/IDE buttons to real source files
  const rev = hist ? "#" + (file.rev || "?")
    : file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");

  // ask the AI for a short description of what this source file does
  async function doExplain() {
    if (!file) return;
    const seq = ++explSeq.current;
    const key = spec || dp;
    setExplBusy(true); setExplain(null);
    try {
      const buf = await fileBytes(file);
      // guard against binaries slipping through: bail if it's not decodable text
      const bytes = new Uint8Array(buf);
      if (bytes.length && bytes.slice(0, 8000).includes(0)) throw new Error(t("Not a text file."));
      const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
      const out = await aiExplainCode(name, text);
      if (seq !== explSeq.current) return; // a different file is showing now
      setExplain(out);
      saveExplain(key, out); // persist until dismissed via ×
    } catch (e) {
      if (seq === explSeq.current) onFlash(String(e), true);
    } finally { if (seq === explSeq.current) setExplBusy(false); }
  }

  return (
    <div className="right">
      <div className="diffhead">
        <span className={"stat " + st.cls} style={{ width: 26, height: 26, fontSize: 13 }}>{st.ch}</span>
        <span className="ttl">
          <span className="n">{name} <span>— {dir}</span></span>
          <span className="m">{file.action} · <span className="rev">{rev}</span>{file.type ? " · " + file.type : ""}{hist && file.change ? " · " + t("in #{n}", { n: file.change }) : ""}</span>
        </span>
        <span className="tools">
          {codeFile && (
            <span className={"gbtn ai icon" + (explBusy ? " busy" : "")} onClick={() => !explBusy && doExplain()} title={t("AI summary — what this file does")}>
              {explBusy ? <span className="ldr sm" /> : I.spark}
            </span>
          )}
          {codeFile && (
            <span className="gbtn vsc icon" title={t("Open in {editor}", { editor: editorName })}
              onClick={async () => { try { await p4.openInEditor(dp, editorId); onFlash(t("Opening in {editor}…", { editor: editorName })); } catch (e) { onFlash(String(e), true); } }}>
              {I.vscode}
            </span>
          )}
          {codeFile && onBlame && (
            <span className="gbtn icon" title={t("Blame — who changed each line")} onClick={() => onBlame(spec || dp, name)}>{I.people}</span>
          )}
          {hist && onDiffPrev && codeFile && (
            <span className="gbtn icon" title={t("Diff against the previous revision")} onClick={() => onDiffPrev(file)}>{I.clock}</span>
          )}
          {onFilelog && dp && <span className="gbtn icon" title={t("File history")} onClick={() => onFilelog(dp, name)}>{I.log}</span>}
          {!hist && <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>}
        </span>
      </div>

      {(explain || explBusy) && (
        <div className="explain">
          <span className="explain-ic">{I.spark}</span>
          <div className="explain-body">{explBusy ? <span className="explain-load"><span className="ldr sm" />{t("Reading the code…")}</span> : explain}</div>
          {!explBusy && <button className="explain-x" onClick={() => { setExplain(null); dropExplain(spec || dp); }} title={t("Dismiss")}>×</button>}
        </div>
      )}

      {kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
        : kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
          : kind === "audio" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={false} />
            : kind === "video" ? <MediaViewer depot={dp} name={name} spec={spec || undefined} video={true} />
              : kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
                : <CodeView file={file} onFlash={onFlash} />}
    </div>
  );
}
function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
  return <div className="mi"><span className="k">{k}</span><span className={"v" + (acc ? " acc" : "")}>{v}</span></div>;
}

/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?: string }) {
  const [url, setUrl] = useState<string | null>(null);
  const [err, setErr] = useState("");
  const [dim, setDim] = useState("");
  useEffect(() => {
    let u = ""; let live = true;
    (spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
      if (!live) return;
      const ext = (name.split(".").pop() || "png").toLowerCase();
      const mime = ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}`;
      u = URL.createObjectURL(new Blob([buf], { type: mime }));
      setUrl(u);
    }).catch((e) => live && setErr(String(e)));
    return () => { live = false; if (u) URL.revokeObjectURL(u); };
  }, [depot, name, spec]);
  return (
    <div className="asset">
      <div className="stage">
        <span className="lbl2"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>{t("Image")}</span>
        {err ? <div className="viewer-msg err">{err}</div>
          : url ? <img src={url} alt={name} style={{ maxWidth: "90%", maxHeight: "90%", borderRadius: 10, boxShadow: "0 20px 50px -20px #000" }}
            onLoad={(e) => setDim(`${(e.target as HTMLImageElement).naturalWidth}×${(e.target as HTMLImageElement).naturalHeight}`)} />
            : <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>}
      </div>
      <div className="assetmeta">
        <Mi k={t("File")} v={name} /><Mi k={t("Resolution")} v={dim || "—"} /><Mi k={t("Format")} v={(name.split(".").pop() || "").toUpperCase()} /><Mi k={t("Source")} v={t("working copy")} acc />
      </div>
    </div>
  );
}

/* ---------------- audio / video preview with playback (like Anchorpoint) ---------------- */
const MEDIA_MIME: Record<string, string> = {
  wav: "audio/wav", mp3: "audio/mpeg", ogg: "audio/ogg", oga: "audio/ogg", flac: "audio/flac", m4a: "audio/mp4", aac: "audio/aac", opus: "audio/opus",
  mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", m4v: "video/mp4", ogv: "video/ogg",
};
function MediaViewer({ depot, name, spec, video }: { depot: string; name: string; spec?: string; video: boolean }) {
  const [url, setUrl] = useState<string | null>(null);
  const [err, setErr] = useState("");
  const [dur, setDur] = useState("");
  useEffect(() => {
    let u = ""; let live = true;
    setUrl(null); setErr(""); setDur("");
    (spec ? p4.printDepot(spec) : p4.readDepot(depot)).then((buf) => {
      if (!live) return;
      const ext = (name.split(".").pop() || "").toLowerCase();
      u = URL.createObjectURL(new Blob([buf], { type: MEDIA_MIME[ext] || (video ? "video/mp4" : "audio/mpeg") }));
      setUrl(u);
    }).catch((e) => live && setErr(String(e)));
    return () => { live = false; if (u) URL.revokeObjectURL(u); };
  }, [depot, name, spec, video]);
  const onMeta = (e: { currentTarget: HTMLMediaElement }) => {
    const d = e.currentTarget.duration;
    if (isFinite(d)) { const m = Math.floor(d / 60), s = Math.round(d % 60); setDur(`${m}:${s.toString().padStart(2, "0")}`); }
  };
  const ext = (name.split(".").pop() || "").toUpperCase();
  return (
    <div className="asset">
      <div className="stage">
        <span className="lbl2">{video ? I.hex : SOUND_GLYPH}{video ? t("Video") : t("Audio")}</span>
        {err ? <div className="viewer-msg err">{err}</div>
          : !url ? <div className="viewer-msg"><span className="ldr" />{t("Loading…")}</div>
            : video
              ? <video className="mediaplayer vid" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
              : (
                <div className="audiobox">
                  <div className="audio-art">{SOUND_GLYPH}</div>
                  <div className="audio-name">{name}</div>
                  <audio className="mediaplayer" src={url} controls autoPlay={false} onLoadedMetadata={onMeta} />
                </div>
              )}
      </div>
      <div className="assetmeta">
        <Mi k={t("File")} v={name} /><Mi k={t("Duration")} v={dur || "—"} /><Mi k={t("Format")} v={ext} /><Mi k={t("Source")} v={spec ? t("server revision") : t("working copy")} acc />
      </div>
    </div>
  );
}
const SOUND_GLYPH = <svg viewBox="0 0 24 24" fill="none"><path d="M11 5 6 9H3v6h3l5 4V5Z" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" /><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>;

/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
  const dp = file.depotFile || "";
  const { name } = splitPath(dp);
  const hist = !!file._spec; // historical revision (3D export only works on the working copy)
  const [thumb, setThumb] = useState<string | null>(null);
  const [tried, setTried] = useState(false);
  const [model, setModel] = useState<ArrayBuffer | null>(null); // exported FBX bytes
  const [exporting, setExporting] = useState(false);
  useEffect(() => {
    let live = true; let url = "";
    setThumb(null); setTried(false); setModel(null); setExporting(false);
    // backend reads the asset and returns ONLY the embedded thumbnail PNG —
    // works for the working copy and for exact submitted revisions (history)
    const spec = file._spec || dp;
    p4.uassetThumb(spec, !!file._spec).then((buf) => {
      if (!live) return;
      url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
      setThumb(url);
      setTried(true);
    }).catch(() => live && setTried(true));
    return () => { live = false; if (url) URL.revokeObjectURL(url); };
  }, [dp, file._spec]);

  async function load3d() {
    if (!uproject) { onFlash(t("Pick the Unreal project working folder first."), true); return; }
    setExporting(true);
    try {
      const buf = await p4.exportUasset3d(dp, uproject);
      setModel(buf);
    } catch (e) { onFlash(String(e), true); }
    finally { setExporting(false); }
  }

  // real, rotatable 3D once Unreal has exported the mesh
  if (model) return <ModelViewer depot={dp} name={name} bytes={model} forceExt="fbx" />;

  const canExport = !!uproject; // exports the version currently synced on disk
  return (
    <div className="asset">
      <div className="stage">
        {thumb ? (<>
          <span className="lbl2">{I.cube}{t("Unreal · embedded thumbnail")}</span>
          <img src={thumb} alt={name} style={{ maxWidth: "82%", maxHeight: "82%", borderRadius: 12, boxShadow: "0 20px 50px -20px #000" }} />
        </>) : (
          <div className="uasset-card">
            <div className="uasset-ic">{I.cube}</div>
            <div className="uasset-name">{name}</div>
            <div className="uasset-note">{!tried ? t("Reading asset…") : t("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}</div>
          </div>
        )}
        {/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */}
        <button className="load3d" onClick={load3d} disabled={!canExport || exporting}
          title={!uproject ? t("Pick the Unreal project working folder first.") : hist ? t("Exports the version currently synced on disk.") : t("Export the mesh from Unreal and view it in 3D")}>
          {exporting ? <><span className="ldr sm" />{t("Exporting from Unreal… (first time is slow)")}</> : <>{I.cube}{t("Load 3D model")}</>}
        </button>
      </div>
      <div className="assetmeta">
        <Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
      </div>
    </div>
  );
}

