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 (
);
}
/* ---------------- icons ---------------- */
const I = {
hex: ,
monitor: ,
branch: ,
sync: ,
chev: ,
search: ,
check: ,
moon: ,
sun: ,
arrow: ,
spinner: ,
cube: ,
revert: ,
back: ,
chevron: ,
spark: ,
people: ,
terminal: ,
clock: ,
log: ,
vscode: ,
hammer: ,
lock: ,
unlock: ,
shelf: ,
folder: ,
power: ,
up: ,
gear: ,
info: ,
scan: ,
ue: ,
trash: ,
copy: ,
reveal: ,
move: ,
pencil: ,
down: ,
open: ,
};
/* ---------------- 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);
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(null);
const [session, setSession] = useState(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((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 = ;
else if (phase === "connect")
content = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <>{content}>;
}
/* ---------------- custom styled dropdown (replaces the ugly native