- Official plugin registry hosted on Gitea; client fetches registry.json over HTTPS and installs plugins via backend plugin_write_file. - Auto-install autoInstall plugins on first connect; per-machine enabled state. - Extract all Unreal tooling (Build Solution / Unreal Log) into a separate "unreal" plugin (defaultEnabled) driven through the new host.ue bridge; core UI no longer hard-wires Unreal. - Settings → Plugins: "Official plugins" section with Install buttons. - Status bar: "Updates" button (right of Log) triggers an update check without restarting the client. - Fixes: NEW badge hard-right/centered without shifting text; History commit size no longer flickers (cached per change); giant icons in empty plugin/ People lists; Get Latest hover contrast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4032 lines
258 KiB
TypeScript
4032 lines
258 KiB
TypeScript
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 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 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 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>(" |