import { useEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, CSSProperties, ReactNode } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";

/* ---------------- 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>,
  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>,
  ue: <svg viewBox="0 0 1280 1280"><path fillRule="evenodd" clipRule="evenodd" fill="currentColor" d="M640,1280c353.46,0,640-286.54,640-640S993.46,0,640,0S0,286.54,0,640S286.54,1280,640,1280z M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z"/></svg>,
};

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

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

/* ---------------- UI zoom hook (scales the whole webview) ---------------- */
function useZoom(): [number, (z: number) => void] {
  const [zoom, setZoom] = useState(() => {
    const v = Number(localStorage.getItem("exd-zoom")); return v >= 0.5 && v <= 2 ? v : 1;
  });
  useEffect(() => {
    (document.documentElement.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom);
    try { localStorage.setItem("exd-zoom", String(zoom)); } catch {}
  }, [zoom]);
  return [zoom, (z) => setZoom(Math.min(2, Math.max(0.5, Math.round(z * 100) / 100)))];
}

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

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

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

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

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

/* ---------------- 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">{I.hex}</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">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span>— Perforce</span></div></div>
        <button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
        <WinControls />
      </div>
      <div className="overlay">
        <div className="card">
          <div className="clogo">{I.cube}</div>
          <h2>{t("Connect to Perforce")}</h2>
          <div className="sub">{t("Exbyte Depot — sign in to your P4 server")}</div>

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

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

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

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

type ModalState =
  | { kind: "workspaces"; items: Client[]; current: string }
  | { kind: "scope"; path: string; dirs: Dir[] }
  | { kind: "settings" }
  | { kind: "about" };

type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: string | null };
type Transfer = { op: "sync" | "submit"; count: number; file?: string };

function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
  { info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
  const [tab, setTab] = useState<Tab>("changes");
  const [modal, setModal] = useState<ModalState | null>(null);
  const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
  const [files, setFiles] = useState<OpenedFile[]>([]);
  const [checked, setChecked] = useState<Set<string>>(new Set());
  const [sel, setSel] = useState<number | null>(null);       // primary row (preview + shift anchor)
  const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
  const [filter, setFilter] = useState("");
  const [desc, setDesc] = useState("");        // commit summary (first line)
  const [descBody, setDescBody] = useState(""); // extended description (optional)
  const [busy, setBusy] = useState(false);
  const [scanning, setScanning] = useState(false); // background reconcile in progress
  const [openMenu, setOpenMenu] = useState<string | null>(null);
  const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
  const [history, setHistory] = useState<Change[]>([]);
  const [detail, setDetail] = useState<Describe | null>(null);
  const [selChange, setSelChange] = useState<string>("");   // highlighted History row (instant)
  const [detailBusy, setDetailBusy] = useState(false);      // right-panel files loading
  const [dragging, setDragging] = useState(false);
  const [ask, setAsk] = useState<null | { title: string; body: string; confirm: string; danger?: boolean; resolve: (v: boolean) => void }>(null);
  const [pending, setPending] = useState<Change[]>([]); // committed-but-not-pushed changelists
  const [transfer, setTransfer] = useState<Transfer | null>(null); // live sync/submit file transfer
  const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
  const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
  const [slnPath, setSlnPath] = useState("");   // local .sln path in the scope (for building)
  const [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 [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)
  const [logOpen, setLogOpen] = useState(false);
  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; });
  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]);

  function startResize(e: ReactMouseEvent, kind: "left" | "sync") {
    e.preventDefault();
    const startX = e.clientX, startLeft = leftW, startSync = syncW;
    const move = (ev: MouseEvent) => {
      if (kind === "left") setLeftW(Math.max(300, Math.min(startLeft + (ev.clientX - startX), window.innerWidth - 420)));
      else setSyncW(Math.max(160, Math.min(startSync - (ev.clientX - startX), 520)));
    };
    const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("resizing"); };
    document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); document.body.classList.add("resizing");
  }

  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 }));
  }

  // load the currently-open files + pending changelists (fast — just reads p4 state)
  async function loadOpened(s: string) {
    const f = await p4.opened(s);
    setFiles(f);
    const uncommitted = f.filter((x) => (x.change || "default") === "default");
    setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
    setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
    setSelRows(new Set());
    try { setPending(await p4.changes()); } catch { setPending([]); }
  }

  // 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;
    setBusy(true);
    try { await loadOpened(s); }
    catch (e) { flash(String(e), true); }
    finally { setBusy(false); }
    if (scan) {
      setScanning(true);
      try { await p4.scan(s); await loadOpened(s); }
      catch (e) { flash(String(e), true); }
      finally { setScanning(false); }
    }
  }
  // Default = Perforce-native: just read what's checked out (p4 opened). With
  // Source Control enabled in Unreal, UE auto-checks-out files, so they appear
  // here on their own — no reconcile needed. Reconcile is a manual fallback.
  useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
  // live Perforce command log — the Rust backend emits one event per p4 call
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ cmd: string; count: number; ok: boolean; err?: string | null }>("p4-log", (e) => {
      setLogs((L) => {
        const next = [...L, { ...e.payload, id: ++logId.current }];
        return next.length > 600 ? next.slice(-600) : next;
      });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // live file-transfer progress (Get Latest / Submit) streamed from the backend
  useEffect(() => {
    let un: (() => void) | undefined; let dead = false;
    listen<{ op: "sync" | "submit"; count: number; file?: string; done: boolean }>("p4-transfer", (e) => {
      const p = e.payload;
      if (p.done) setTransfer(null);
      else setTransfer({ op: p.op, count: p.count, file: p.file });
    }).then((u) => { if (dead) u(); else un = u; });
    return () => { dead = true; un?.(); };
  }, []);
  // only surface the progress dialog once a transfer has run for a moment
  const xferActive = transfer !== null;
  useEffect(() => {
    if (!xferActive) { setShowXfer(false); return; }
    const id = setTimeout(() => setShowXfer(true), 400);
    return () => clearTimeout(id);
  }, [xferActive]);
  // detect 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]);

  // 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 || "";
    setSelChange(n);        // highlight the row immediately — no waiting for the load
    setDetail(null);
    setDetailBusy(true);
    try { setDetail(await p4.describe(n)); }
    catch (e) { flash(String(e), true); }
    finally { setDetailBusy(false); }
  }

  async function openWorkspaces() {
    if (!session) { flash(t("No session data for the workspace list"), true); return; }
    try { setModal({ kind: "workspaces", items: await p4.clients(session.server, session.user), current: info?.clientName || "" }); }
    catch (e) { flash(String(e), true); }
  }
  async function switchWorkspace(client: string) {
    setModal(null); setBusy(true);
    try {
      const i = await p4.switchClient(client);
      onInfo(i);
      if (session) onSession({ ...session, client });
      const scope = loadScope(client); // restore this workspace's saved working folder
      setActivePath(scope);
      await refresh(scope);
      flash(t("Active workspace: {c}", { c: client }));
    } catch (e) { flash(String(e), true); setBusy(false); }
  }
  async function browseTo(path: string) {
    try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); }
    catch (e) { flash(String(e), true); }
  }
  function chooseScope(path: string) {
    setModal(null);
    setActivePath(path);
    saveScope(info?.clientName || "", path); // remember the working folder per workspace
    setHistory([]); setDetail(null); setSel(null);
    refresh(path);
    if (tab === "history") loadHistory(path);
  }


  async function loadHistory(scope?: string) {
    const f = scope !== undefined ? scope : activePath;
    const path = f ? f + "/..." : "";
    setBusy(true);
    try { setHistory(await p4.history(path)); }
    catch (e) { flash(String(e), true); } finally { setBusy(false); }
  }
  function switchTab(t: Tab) {
    setTab(t);
    if (t === "history" && history.length === 0) loadHistory();
  }

  async function getLatest() {
    setBusy(true);
    try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); }
    catch (e) { flash(String(e), true); setBusy(false); }
  }

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

  // build the solution found in the working folder (MSBuild) with live output
  async function buildSln() {
    if (!slnPath) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
    setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
    try { await p4.buildSln(slnPath); }
    catch { /* the overlay already shows the failure line */ }
  }

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

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

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

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

  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]);
  const selFile = 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);
  }
  // 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 isCode = kindOf(splitPath(dp).name) === "code";
    openCtx(e, [
      { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) },
      ...(isCode ? [{ label: t("Edit in VS Code"), icon: I.vscode, act: () => { p4.openInVscode(dp).then(() => flash(t("Opening in VS Code…"))).catch((err) => flash(String(err), true)); } }] : []),
      { label: t("Open in Explorer"), act: () => reveal(dp) },
      { label: t("Copy path"), act: () => copyText(dp, t("Path")) },
    ]);
  }

  const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
    File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
    Connection: [{ label: t("Refresh"), kb: "F5", act: () => refresh() }, { label: t("Choose working folder…"), act: () => browseTo("") }],
    Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", act: getLatest }, { label: t("Commit (local)"), act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", act: pushAll }, { label: t("Revert All…"), act: revertAll }, { label: t("Refresh"), kb: "F5", act: () => refresh() }],
    Window: [{ label: (logOpen ? "✓ " : "") + t("Log"), kb: "Ctrl+L", act: () => setLogOpen((o) => !o) }],
    Tools: [{ label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", act: buildSln }, { label: t("Settings…"), act: () => setModal({ kind: "settings" }) }],
    Help: [{ label: t("About"), 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">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span>— Perforce</span></div></div>
        <div className="menubar" onClick={(e) => e.stopPropagation()}>
          {Object.entries(menus).map(([name, items]) => (
            <div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
              {t(name)}
              <div className="dropdown">
                {items.map((it) => (
                  <div key={it.label} className={"di" + (it.ext ? " ext" : "")} onClick={() => it.act?.()}>
                    {it.label}{it.kb && <span className="kb">{it.kb}</span>}
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>
        <div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
        <button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
        <WinControls />
      </div>

      <div className="toolbar">
        <div className="tzone" onClick={openWorkspaces} title={t("Switch workspace · right-click for more")}
          onContextMenu={(e) => openCtx(e, [
            { label: t("Open in Explorer"), act: () => reveal(String(info?.clientRoot || "")) },
            { label: t("Switch workspace…"), act: openWorkspaces },
            { label: t("Copy name"), act: () => copyText(info?.clientName || "", t("Workspace name")) },
          ])}>
          <span className="ic">{I.monitor}</span>
          <span className="tzmeta"><span className="lbl">{t("Workspace")}</span><span className="val">{info?.clientName || "—"}</span></span>
          <span className="chev">{I.chev}</span>
        </div>
        <div className="tgroup">
        <div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
        <div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
          onContextMenu={(e) => openCtx(e, [
            ...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
            ...(slnPath ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: buildSln }] : []),
            { label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
            { label: t("Choose another folder…"), act: () => browseTo("") },
            { label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
          ])}>
          <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>

      <div className="main">
        <div className="vhandle mainh" onMouseDown={(e) => startResize(e, "left")} title={t("Drag to resize")} />
        <div className="left">
          <div className="tabs">
            <button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
            <button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
          </div>

          {tab === "changes" ? (
            <>
              <div className="filter">
                <div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
              </div>
              {pending.length > 0 && (
                <div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
                  <span className="pushdot" />
                  <span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
                  <span className="pusharr">↑</span>
                </div>
              )}
              {scanning && (
                <div className="scanbar">
                  <span className="ldr" />
                  <span>{t("Scanning disk for changes…")}{!activePath && t(" for the whole workspace this is slow — pick a project folder above")}</span>
                </div>
              )}
              <div className="chhead">
                <span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
                <span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
                <button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
                  {I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
                </button>
              </div>
              {view.length === 0 ? (
                <div className="empty">
                  {scanning ? <><span className="ldr" />{t("Scanning disk for changes…")}</> : <>
                    <svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
                    {busy ? t("Loading…") : t("No open files yet.\nEdit assets in Unreal (Source Control → Perforce) — they show up here on their own.\n\nEdited files outside Perforce? Actions → “Rescan changes”.")}
                  </>}
                </div>
              ) : (
                <FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} onContext={fileCtx} />
              )}
              <div className="commit">
                <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(); }} />
                <textarea className="desc" placeholder={t("Description (optional)…")} value={descBody} onChange={(e) => setDescBody(e.target.value)}
                  onKeyDown={(e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) doCommit(); }} />
                <button className="submit" disabled={busy || !desc.trim() || checked.size === 0} onClick={doCommit}>
                  {I.check}{t("Commit changelist")} <b>{t("· {n} files", { n: checked.size })}</b>
                </button>
                <div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
              </div>
            </>
          ) : (
            <HistoryList history={history} busy={busy} sel={selChange} onSelect={openChange}
              onContext={(c, e) => openCtx(e, [
                { label: t("Open this changelist"), act: () => openChange(c) },
                { label: t("Revert (undo) #{n}", { n: c.change || "" }), danger: true, act: () => revertChange(c) },
                { label: t("Copy number #{n}", { n: c.change || "" }), act: () => copyText(c.change || "", t("Number")) },
                { label: t("Copy description"), act: () => copyText((c.desc || "").trim(), t("Description")) },
              ])} />
          )}
        </div>

        {tab === "changes"
          ? <Preview file={selFile} onRevert={doRevert} onFlash={flash} />
          : <ChangeDetail detail={detail} busy={detailBusy} />}
      </div>

      {logOpen && <LogPanel logs={logs} onClose={() => setLogOpen(false)} onClear={() => setLogs([])} />}

      <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={() => setLogOpen((o) => !o)} title="Ctrl+L">{I.log}<span>Log</span></button>
        {peek && logs.length > 0 && (
          <div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
            <div className="logpeek-h">{t("Log")} · {logs.length}</div>
            {logs.slice(-40).map((l) => <LogRow key={l.id} l={l} />)}
          </div>
        )}
      </div>

      {dragging && (
        <div className="dropzone">
          <div className="dropinner">
            <svg viewBox="0 0 24 24" fill="none"><path d="M12 15V3M8 7l4-4 4 4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
            <span>{t("Drop to add to Perforce")}</span>
          </div>
        </div>
      )}
      {modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
        onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
      {ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
      {showXfer && transfer && <TransferDialog transfer={transfer} />}
      {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, light, toggleTheme, lang, setLang, zoom, setZoom, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
  { modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  const X = <button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>;
  const folder = <svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" strokeWidth="1.6" /></svg>;
  const depotIc = <svg viewBox="0 0 24 24" fill="none"><ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" strokeWidth="1.6" /><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" strokeWidth="1.6" /></svg>;

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

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

        {modal.kind === "settings" && (<>
          <div className="picker-head"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" /><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.6H10.5l-.4 2.6a7 7 0 0 0-1.7 1l-2.3-1-2 3.4L6 11a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.6h3.5l.4-2.6a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.5c.06-.33.1-.66.1-1Z" stroke="currentColor" strokeWidth="1.3" /></svg><h3>{t("Settings")}</h3>{X}</div>
          <div className="info-body">
            <div className="info-row"><span className="rk">{t("Language")}</span>
              <div className="langsel">
                {LANGS.map((l) => (
                  <button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
                    <span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
                  </button>
                ))}
              </div>
            </div>
            <div className="info-row"><span className="rk">{t("Theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
            <div className="info-row"><span className="rk">{t("Interface scale")}</span>
              <div className="zoomsel">
                <button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title="−">−</button>
                <button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
                <button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
              </div>
            </div>
            <div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</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 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>
  );
}

/* ---------------- 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>
  );
}
function LogPanel({ logs, onClose, onClear }: { logs: LogEntry[]; onClose: () => void; onClear: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
  return (
    <div className="logpanel">
      <div className="loghead">
        <span className="logtitle">{I.log}<b>Log</b><span className="logn">{logs.length}</span></span>
        <button className="loghbtn" onClick={onClear}>{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="logbody" ref={ref}>
        {logs.length === 0 ? <div className="logempty">{t("No commands yet.")}</div>
          : logs.map((l) => <LogRow key={l.id} l={l} />)}
      </div>
    </div>
  );
}

/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer }: { transfer: Transfer }) {
  const up = transfer.op === "submit";
  return (
    <div className="modal-back xfer-back">
      <div className="xfer">
        <div className="xfer-head">
          <span className={"xfer-ic" + (up ? " up" : " down")}>
            {up
              ? <svg viewBox="0 0 24 24" fill="none"><path d="M12 20V6M6 12l6-6 6 6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>
              : <svg viewBox="0 0 24 24" fill="none"><path d="M12 4v14M6 12l6 6 6-6" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /></svg>}
          </span>
          <div className="xfer-ttl">
            <b>{up ? t("Submitting to server") : t("Getting latest from server")}</b>
            <span>{t("{n} files", { n: transfer.count })}</span>
          </div>
        </div>
        <div className="xfer-file">{transfer.file || (up ? t("Uploading…") : t("Downloading…"))}</div>
        <div className="xfer-bar"><span /></div>
      </div>
    </div>
  );
}

/* ---------------- build output (MSBuild) ---------------- */
function BuildOverlay({ lines, building, ok, sln, onMinimize, onClose }: { lines: string[]; building: boolean; ok: boolean | null; sln: string; onMinimize: () => void; onClose: () => void }) {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [lines]);
  const name = sln.replace(/\\/g, "/").split("/").pop() || "solution";
  return (
    <div className="modal-back">
      <div className="build">
        <div className="build-head">
          <span className={"build-ic" + (building ? " run" : ok ? " ok" : ok === false ? " fail" : "")}>
            {building ? <span className="ldr sm" /> : ok ? "✓" : ok === false ? "✗" : I.hammer}
          </span>
          <div className="build-ttl">
            <b>{building ? t("Building…") : ok ? t("Build succeeded") : ok === false ? t("Build failed") : t("Build")}</b>
            <span>{name}</span>
          </div>
          <button className="build-x" onClick={onMinimize} title={t("Minimize")}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 18h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" /></svg>
          </button>
          <button className="build-x" onClick={onClose} disabled={building} title={building ? t("Building…") : t("Close")}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
          </button>
        </div>
        <div className="build-body" ref={ref}>
          {lines.map((ln, i) => {
            const low = ln.toLowerCase();
            const cls = /: error |\bfailed\b|✗/.test(low) ? " err" : /\bwarning\b/.test(low) ? " warn" : /succeeded|✓/.test(low) ? " ok" : "";
            return <div key={i} className={"build-ln" + cls}>{ln}</div>;
          })}
          {building && lines.length === 0 && <div className="build-ln">{t("Starting MSBuild…")}</div>}
        </div>
      </div>
    </div>
  );
}
/* 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>
  );
}

/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(false); if (e.key === "Enter") onClose(true); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <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 className={"mbtn " + (danger ? "danger" : "primary")} onClick={() => onClose(true)} autoFocus>{confirm}</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- change detail (History right panel) — virtualized ---------------- */
const CROW = 52;
function ChangeDetail({ detail, busy }: { detail: Describe | null; busy?: boolean }) {
  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]);

  if (busy) return <div className="right"><div className="nofile"><span className="ldr" /><span>{t("Loading file list…")}</span></div></div>;
  if (!detail) return <div className="right"><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);
    rows.push(
      <div key={i} className="crow" style={{ top: i * CROW }}>
        <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>
      </div>
    );
  }
  return (
    <div className="right">
      <div className="diffhead">
        <span className="rev" style={{ fontSize: 15, fontWeight: 700 }}>#{detail.change}</span>
        <span className="ttl">
          <span className="n">{(detail.desc || "").trim() || t("(no description)")}</span>
          <span className="m">{t("{user} · {time} · {n} file(s)", { user: detail.user || "", time: fmtTime(detail.time), n: fileRows.length })}</span>
        </span>
      </div>
      <div className="files" ref={ref}><div className="vspace" style={{ height: fileRows.length * CROW }}>{rows}</div></div>
    </div>
  );
}

/* ---------------- history list ---------------- */
function HistoryList({ history, busy, sel, onSelect, onContext }: { history: Change[]; busy: boolean; sel?: string; onSelect: (c: Change) => void; onContext?: (c: Change, e: ReactMouseEvent) => void }) {
  if (busy && !history.length) return <div className="empty">{t("Loading history…")}</div>;
  if (!history.length) return <div className="empty">{t("No submitted changes.")}</div>;
  return (
    <div className="files" style={{ position: "relative" }}>
      {history.map((c) => (
        <div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
          <span className="hnum">#{c.change}</span>
          <span className="hbody">
            <span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
            <span className="hmeta">{c.user} · {fmtTime(c.time)}</span>
          </span>
        </div>
      ))}
    </div>
  );
}

/* ---------------- file thumbnail (lazy, cached) — like Anchorpoint's list previews ---------------- */
const thumbCache = new Map<string, string | null>(); // depotFile -> objectURL | null(no thumb)
function Thumb({ file }: { file: OpenedFile }) {
  const dp = file.depotFile || "";
  const { name } = splitPath(dp);
  const kind = kindOf(name);
  const [url, setUrl] = useState<string | null>(() => thumbCache.get(dp) ?? null);
  useEffect(() => {
    if (kind !== "image" && kind !== "uasset") return;
    if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
    let live = true;
    p4.readDepot(dp).then((buf) => {
      if (!live) return;
      let u: string | null = null;
      if (kind === "image") {
        const ext = (name.split(".").pop() || "png").toLowerCase();
        u = URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
      } else {
        const png = extractPng(new Uint8Array(buf));
        if (png) u = URL.createObjectURL(new Blob([png], { type: "image/png" }));
      }
      thumbCache.set(dp, u);
      if (live) setUrl(u);
    }).catch(() => { thumbCache.set(dp, null); });
    return () => { live = false; };
  }, [dp]);

  if (url) return <img className="fthumb" src={url} alt="" />;
  const ic = kind === "model" ? "◆" : kind === "image" || kind === "uasset" ? "▦" : "≡";
  return <span className={"fthumb ico k-" + kind}>{ic}</span>;
}

/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, selRows, checked, onRowClick, onToggle, onContext }:
  { files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; 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);
    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>
        <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 }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void }) {
  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 { name, dir } = splitPath(dp);
  const st = statusOf(file.action);
  const kind = kindOf(name);
  const rev = file.rev ? `#${file.haveRev || "?"} → #${file.rev}` : "#" + (file.rev || "?");

  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}</span>
        </span>
        <span className="tools">
          {kind === "code" && (
            <span className="gbtn vsc" onClick={async () => { try { await p4.openInVscode(dp); onFlash(t("Opening in VS Code…")); } catch (e) { onFlash(String(e), true); } }}>
              {I.vscode}{t("Edit in VS Code")}
            </span>
          )}
          <span className="gbtn" onClick={() => onRevert(file)}>{I.revert}{t("Revert")}</span>
        </span>
      </div>

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

/* ---------------- .uasset — extract embedded Unreal thumbnail (PNG) ---------------- */
function UassetPreview({ file }: { file: OpenedFile }) {
  const dp = file.depotFile || "";
  const { name } = splitPath(dp);
  const [thumb, setThumb] = useState<string | null>(null);
  const [tried, setTried] = useState(false);
  useEffect(() => {
    let live = true; let url = "";
    setThumb(null); setTried(false);
    p4.readDepot(dp).then((buf) => {
      if (!live) return;
      const png = extractPng(new Uint8Array(buf));
      if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
      setTried(true);
    }).catch(() => live && setTried(true));
    return () => { live = false; if (url) URL.revokeObjectURL(url); };
  }, [dp]);

  return (
    <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("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}</div>
          </div>
        )}
      </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>
  );
}

// scan raw bytes for an embedded PNG and return just that PNG (sig → IEND+crc)
function extractPng(b: Uint8Array): Uint8Array | null {
  const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
  for (let i = 0; i < b.length - 8; i++) {
    let ok = true;
    for (let k = 0; k < 8; k++) if (b[i + k] !== sig[k]) { ok = false; break; }
    if (!ok) continue;
    for (let j = i + 8; j < b.length - 8; j++) {
      if (b[j] === 0x49 && b[j + 1] === 0x45 && b[j + 2] === 0x4e && b[j + 3] === 0x44) return b.slice(i, j + 8);
    }
    return b.slice(i);
  }
  return null;
}

