v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations
- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor (per-token color pickers, live preview, JSON export/import). src/themes.ts. - Plugin platform: JS/ESM plugins loaded via blob-module import (CSP script-src blob:), Host SDK with menu / file-context / view / submit-hook contribution points + permission model. Backend commands to list/read/enable/ install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md. - Settings redesigned into a sectioned hub (General / Appearance / Plugins); Themes and Plugins moved in, removed from the Actions menu. - "NEW" badge on History commits submitted < 3h ago. - Smooth tab transitions: sliding underline + content fade on Changes/History, and matching fades on the view-panel and dock tab groups. - Fix: settings close button used the default browser style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
373
src/App.tsx
373
src/App.tsx
@ -4,12 +4,15 @@ 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 } from "./p4";
|
||||
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 } 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)
|
||||
@ -79,16 +82,13 @@ const I = {
|
||||
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 ---------------- */
|
||||
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)];
|
||||
/* ---------------- 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) ---------------- */
|
||||
@ -118,7 +118,7 @@ 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 [light, toggleTheme, themeId, setThemeId] = useTheme();
|
||||
const [lang, setLang] = useLang();
|
||||
const [zoom, setZoom] = useZoom();
|
||||
const saved = useMemo(() => loadSession(), []);
|
||||
@ -140,7 +140,7 @@ export default function App() {
|
||||
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}
|
||||
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"); }} />;
|
||||
|
||||
@ -326,7 +326,7 @@ type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view
|
||||
type ModalState =
|
||||
| { kind: "workspaces"; items: Client[]; current: string }
|
||||
| { kind: "scope"; path: string; dirs: Dir[] }
|
||||
| { kind: "settings" }
|
||||
| { kind: "settings"; section?: "general" | "appearance" | "plugins" }
|
||||
| { kind: "users" }
|
||||
| { kind: "search" }
|
||||
| { kind: "locks" }
|
||||
@ -350,10 +350,13 @@ type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: str
|
||||
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, 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 }) {
|
||||
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());
|
||||
@ -610,6 +613,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
// Source Control enabled in Unreal, UE auto-checks-out files, so they appear
|
||||
// here on their own — no reconcile needed. Reconcile is a manual fallback.
|
||||
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
|
||||
// ---- plugins: (re)load enabled plugins; they register menu/panel/hook contributions
|
||||
async function reloadPlugins() {
|
||||
const res = await loadPlugins({
|
||||
flash,
|
||||
notify: (title, body) => { void notify(title, body); },
|
||||
info: () => info,
|
||||
selectedFile: () => selFile || null,
|
||||
refresh: () => { void refresh(); },
|
||||
});
|
||||
setPluginVer((v) => v + 1);
|
||||
return res;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (pluginsLoaded.current || !info?.clientName) return;
|
||||
pluginsLoaded.current = true;
|
||||
reloadPlugins().then(({ loaded, errors }) => {
|
||||
if (errors.length) console.warn("[plugins] load errors:", errors);
|
||||
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
|
||||
}).catch(() => {});
|
||||
}, [info?.clientName]); // eslint-disable-line
|
||||
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
|
||||
// live Perforce command log — the Rust backend emits one event per p4 call
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined; let dead = false;
|
||||
@ -1028,6 +1052,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
.map((f) => f.depotFile || "")
|
||||
.filter((d) => checked.has(d));
|
||||
if (!list.length || !desc.trim()) return;
|
||||
// plugin submit hooks can block (e.g. a reference-integrity guard)
|
||||
if (getRegistry().submitHooks.length) {
|
||||
const verdict = await runSubmitHooks(list.map((d) => ({ depotFile: d })));
|
||||
if (!verdict.ok) {
|
||||
if (!(await confirm({ title: t("A plugin flagged this commit"), body: verdict.message || t("A plugin blocked the commit."), confirm: t("Commit anyway"), danger: true }))) return;
|
||||
}
|
||||
}
|
||||
const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim();
|
||||
setBusy(true);
|
||||
try {
|
||||
@ -1231,6 +1262,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
|
||||
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
|
||||
{ label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
|
||||
...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: I.hex, act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
|
||||
]);
|
||||
}
|
||||
async function lockFiles(dps: string[], lock: boolean) {
|
||||
@ -1352,6 +1384,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
|
||||
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
|
||||
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
|
||||
...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
|
||||
...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })),
|
||||
{ label: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), act: startBuild },
|
||||
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
|
||||
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
|
||||
@ -1460,10 +1494,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
<div className="tabs">
|
||||
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
|
||||
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
|
||||
<span className="tab-slider" style={{ left: tab === "changes" ? "16px" : "calc(50% + 16px)" }} />
|
||||
</div>
|
||||
|
||||
{tab === "changes" ? (
|
||||
<>
|
||||
<div className="tabpane" key="changes">
|
||||
<div className="filter">
|
||||
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
|
||||
</div>
|
||||
@ -1567,8 +1602,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</button>
|
||||
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tabpane" key="history">
|
||||
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
|
||||
onContext={(c, e) => openCtx(e, [
|
||||
{ label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
|
||||
@ -1577,6 +1613,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
{ label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
|
||||
{ label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
|
||||
])} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1665,13 +1702,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
{modal && modal.kind !== "users" && modal.kind !== "settings" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
|
||||
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
|
||||
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
|
||||
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
|
||||
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
|
||||
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "settings" && <SettingsModal section={modal.section} info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom} editors={editors} editorId={effEditorId} onPickEditor={chooseEditor} onReloadPlugins={reloadPlugins} onDisconnect={onDisconnect} onFlash={flash} onClose={() => setModal(null)} />}
|
||||
{pluginView && <PluginViewModal view={pluginView} onClose={() => setPluginView(null)} />}
|
||||
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
|
||||
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
|
||||
@ -2025,9 +2064,11 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
|
||||
{(tab === "log" || tab === "terminal") && <button className="loghbtn" onClick={tab === "log" ? onClearLogs : onClearTerm}>{t("Clear")}</button>}
|
||||
<button className="loghbtn x" onClick={onClose}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
{tab === "log" && <LogTab logs={logs} />}
|
||||
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
|
||||
{tab === "unreal" && <UnrealTab uproject={uproject} />}
|
||||
<div className="dockpane" key={tab}>
|
||||
{tab === "log" && <LogTab logs={logs} />}
|
||||
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
|
||||
{tab === "unreal" && <UnrealTab uproject={uproject} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3126,6 +3167,288 @@ function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: str
|
||||
/* ---------------- typemap / exclusive-lock rules ---------------- */
|
||||
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
|
||||
// only one person can check them out at a time, preventing lost binary merges.
|
||||
/* ---------------- Settings — sectioned hub (General / Appearance / Plugins) ---------------- */
|
||||
type SetSection = "general" | "appearance" | "plugins";
|
||||
function SettingsModal(props: {
|
||||
section?: SetSection; info: P4Info | null; session: Session | null;
|
||||
light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => void;
|
||||
lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void;
|
||||
editors: Editor[]; editorId: string; onPickEditor: (id: string) => void;
|
||||
onReloadPlugins: () => Promise<{ loaded: number; errors: string[] }>;
|
||||
onDisconnect: () => void; onFlash: (t: string, e?: boolean) => void; onClose: () => void;
|
||||
}) {
|
||||
const { info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onReloadPlugins, onDisconnect, onFlash, onClose } = props;
|
||||
const [sec, setSec] = useState<SetSection>(props.section || "general");
|
||||
function close() { applyTheme(resolveTheme(themeId)); onClose(); } // drop any unsaved live theme preview
|
||||
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && close(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
|
||||
const NAV: { id: SetSection; label: string; icon: ReactNode }[] = [
|
||||
{ id: "general", label: t("General"), icon: I.gear },
|
||||
{ id: "appearance", label: t("Appearance"), icon: I.sun },
|
||||
{ id: "plugins", label: t("Plugins"), icon: I.hex },
|
||||
];
|
||||
return (
|
||||
<div className="modal-back" onClick={close}>
|
||||
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="set-nav">
|
||||
<div className="set-nav-h"><span className="set-nav-logo">{I.gear}</span><b>{t("Settings")}</b></div>
|
||||
{NAV.map((n) => (
|
||||
<button key={n.id} className={"set-nav-item" + (sec === n.id ? " on" : "")} onClick={() => setSec(n.id)}>
|
||||
<span className="sni-ic">{n.icon}</span>{n.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="set-nav-sp" />
|
||||
<button className="set-nav-item danger" onClick={() => { close(); onDisconnect(); }}>{I.power}{t("Disconnect")}</button>
|
||||
</div>
|
||||
<div className="set-main">
|
||||
<div className="set-main-h"><h3>{NAV.find((n) => n.id === sec)?.label}</h3>
|
||||
<button className="x" onClick={close}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
{sec === "general" && (
|
||||
<div className="info-body" style={{ overflow: "auto" }}>
|
||||
<div className="info-row"><span className="rk">{t("Language")}</span>
|
||||
<div className="langsel">
|
||||
{LANGS.map((l) => (
|
||||
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
|
||||
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-row"><span className="rk">{t("Base theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
|
||||
<div className="info-row"><span className="rk">{t("Interface scale")}</span>
|
||||
<div className="zoomsel">
|
||||
<button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title="−">−</button>
|
||||
<button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
|
||||
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-row"><span className="rk">{t("Code editor")}</span>
|
||||
<select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
|
||||
{editors.length === 0 && <option value="default">{t("System default")}</option>}
|
||||
{editors.map((ed) => (<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="set-sep" />
|
||||
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
|
||||
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{sec === "appearance" && <ThemesModal embedded activeId={themeId} onApply={setThemeId} onFlash={onFlash} />}
|
||||
{sec === "plugins" && <PluginsModal embedded onReload={onReloadPlugins} onFlash={onFlash} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- plugins: manage installed plugins ---------------- */
|
||||
function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () => void; onReload: () => Promise<{ loaded: number; errors: string[] }>; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
|
||||
const [list, setList] = useState<PluginInfo[]>([]);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [dir, setDir] = useState("");
|
||||
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
|
||||
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
|
||||
async function toggle(p: PluginInfo, enabled: boolean) {
|
||||
try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
async function remove(p: PluginInfo) {
|
||||
try { await p4.pluginRemove(p.id); load(); await onReload(); onFlash(t("Removed plugin “{n}”.", { n: p.name })); } catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
async function install() {
|
||||
try {
|
||||
const sel = await openDialog({ directory: true, title: t("Pick a plugin folder (contains plugin.json)") });
|
||||
if (!sel || Array.isArray(sel)) return;
|
||||
const id = await p4.pluginInstall(sel);
|
||||
load(); await onReload();
|
||||
onFlash(t("Installed plugin “{id}”.", { id }));
|
||||
} catch (e) { onFlash(String(e), true); }
|
||||
}
|
||||
const body = (<>
|
||||
<div className="tm-hint">{t("Plugins add features on top of the core app. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.")}</div>
|
||||
<div className="pl-list">
|
||||
{busy ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
|
||||
: list.length === 0 ? <div className="ur-empty">{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}</div>
|
||||
: list.map((p) => (
|
||||
<div key={p.id} className={"pl-card" + (p.enabled ? "" : " off")}>
|
||||
<div className="pl-main">
|
||||
<div className="pl-top"><b>{p.name || p.id}</b><span className="pl-ver">v{p.version}</span></div>
|
||||
{p.description && <div className="pl-desc">{p.description}</div>}
|
||||
<div className="pl-meta">
|
||||
{p.author && <span className="pl-tag">{p.author}</span>}
|
||||
{(p.permissions || []).map((perm: string) => <span key={perm} className="pl-perm">{perm}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-actions">
|
||||
<button className={"pl-toggle" + (p.enabled ? " on" : "")} onClick={() => toggle(p, !p.enabled)} title={p.enabled ? t("Disable") : t("Enable")}><span /></button>
|
||||
<button className="pl-del" onClick={() => remove(p)} title={t("Remove")}>{I.trash}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>
|
||||
<button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => onReload().then(({ loaded }) => onFlash(t("{n} plugin(s) loaded", { n: loaded })))}>{I.sync}{t("Reload")}</button>
|
||||
<button className="mbtn primary" onClick={install}>{I.open}{t("Install from folder…")}</button>
|
||||
</div>
|
||||
</>);
|
||||
if (embedded) return <div className="set-panel">{body}</div>;
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.hex}<h3>{t("Plugins")}</h3><span className="ph-sub">{t("{n} installed", { n: list.length })}</span>
|
||||
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* a plugin-contributed view, mounted into a plain DOM container */
|
||||
function PluginViewModal({ view, onClose }: { view: PluginView; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current; if (!el) return;
|
||||
let cleanup: void | (() => void);
|
||||
try { cleanup = view.render(el); } catch (e) { el.textContent = String(e); }
|
||||
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", k);
|
||||
return () => { document.removeEventListener("keydown", k); if (typeof cleanup === "function") { try { cleanup(); } catch {} } };
|
||||
}, []); // eslint-disable-line
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.hex}<h3>{view.title}</h3><span className="ph-sub">{view.pluginId}</span>
|
||||
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
<div className="plugin-view" ref={ref} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- themes (switch + custom theme editor) ---------------- */
|
||||
function ThemesModal({ activeId, onApply, onClose, onFlash, embedded }: { activeId: string; onApply: (id: string) => void; onClose?: () => void; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
|
||||
const [themes, setThemes] = useState<Theme[]>(() => allThemes());
|
||||
const [editing, setEditing] = useState<Theme | null>(null); // custom theme being edited/created
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && (editing ? setEditing(null) : onClose?.()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [editing]); // eslint-disable-line
|
||||
// live preview while editing; restore the active theme when leaving the editor
|
||||
useEffect(() => { if (editing) applyTheme(editing); else applyTheme(resolveTheme(activeId)); }, [editing]); // eslint-disable-line
|
||||
|
||||
function refreshList() { setThemes(allThemes()); }
|
||||
function startNew() {
|
||||
const base = BUILTIN_THEMES[0]!;
|
||||
const vars: Record<string, string> = {};
|
||||
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(base, tk.var);
|
||||
setEditing({ id: newCustomId(), name: t("My theme"), base: "dark", vars });
|
||||
}
|
||||
function startEdit(th: Theme) {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(th, tk.var);
|
||||
setEditing({ ...th, vars });
|
||||
}
|
||||
function saveEditing() {
|
||||
if (!editing) return;
|
||||
const customs = loadCustomThemes().filter((c) => c.id !== editing.id);
|
||||
customs.push({ ...editing, builtin: false });
|
||||
saveCustomThemes(customs);
|
||||
refreshList();
|
||||
setEditing(null);
|
||||
onApply(editing.id);
|
||||
onFlash(t("Theme “{n}” saved.", { n: editing.name }));
|
||||
}
|
||||
function del(th: Theme) {
|
||||
saveCustomThemes(loadCustomThemes().filter((c) => c.id !== th.id));
|
||||
refreshList();
|
||||
if (activeId === th.id) onApply("dark");
|
||||
else applyTheme(resolveTheme(activeId));
|
||||
}
|
||||
function doExport(th: Theme) {
|
||||
const blob = new Blob([exportTheme(th)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob); a.download = `${th.name.replace(/\s+/g, "-").toLowerCase()}.exbyte-theme.json`; a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
function onImportFile(e: { target: HTMLInputElement }) {
|
||||
const f = e.target.files?.[0]; if (!f) return;
|
||||
f.text().then((txt) => { const th = importTheme(txt); const customs = loadCustomThemes(); customs.push(th); saveCustomThemes(customs); refreshList(); onApply(th.id); onFlash(t("Theme “{n}” imported.", { n: th.name })); })
|
||||
.catch((err) => onFlash(String(err), true));
|
||||
}
|
||||
|
||||
const groups = Array.from(new Set(THEME_TOKENS.map((tk) => tk.group)));
|
||||
const body = (!editing ? (<>
|
||||
<div className="th-grid">
|
||||
{themes.map((th) => (
|
||||
<div key={th.id} className={"th-card" + (th.id === activeId ? " on" : "")} onClick={() => onApply(th.id)}>
|
||||
<div className="th-swatch" style={{ background: th.vars["--bg"] || (th.base === "light" ? "#eef0f5" : "#0b0b12") }}>
|
||||
<span style={{ background: th.vars["--panel"] || (th.base === "light" ? "#fff" : "#161621") }} />
|
||||
<span style={{ background: th.vars["--accent"] || "#7c6ef6" }} />
|
||||
<span style={{ background: th.vars["--accent-2"] || "#9a8bf9" }} />
|
||||
</div>
|
||||
<div className="th-meta"><b>{th.name}</b><span>{th.base === "light" ? t("Light") : t("Dark")}{th.builtin ? "" : " · " + t("custom")}</span></div>
|
||||
{th.id === activeId && <span className="th-on">{I.check}</span>}
|
||||
{!th.builtin && (
|
||||
<div className="th-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button title={t("Edit")} onClick={() => startEdit(th)}>{I.pencil}</button>
|
||||
<button title={t("Export")} onClick={() => doExport(th)}>{I.down}</button>
|
||||
<button title={t("Delete")} className="danger" onClick={() => del(th)}>{I.trash}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<input ref={fileRef} type="file" accept=".json" style={{ display: "none" }} onChange={onImportFile} />
|
||||
<button className="mbtn ghost" onClick={() => fileRef.current?.click()}>{I.open}{t("Import…")}</button>
|
||||
<button className="mbtn primary" onClick={startNew}><svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>{t("New custom theme")}</button>
|
||||
</div>
|
||||
</>) : (<>
|
||||
<div className="th-edit">
|
||||
<div className="th-edit-row">
|
||||
<label>{t("Name")}</label>
|
||||
<input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
|
||||
<div className="th-base">
|
||||
<button className={editing.base === "dark" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "dark" })}>{I.moon} {t("Dark")}</button>
|
||||
<button className={editing.base === "light" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "light" })}>{I.sun} {t("Light")}</button>
|
||||
</div>
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<div key={g} className="th-group">
|
||||
<div className="th-group-h">{g}</div>
|
||||
<div className="th-tokens">
|
||||
{THEME_TOKENS.filter((tk) => tk.group === g).map((tk) => (
|
||||
<label key={tk.var} className="th-token">
|
||||
<input type="color" value={editing.vars[tk.var] || "#000000"} onChange={(e) => setEditing({ ...editing, vars: { ...editing.vars, [tk.var]: e.target.value } })} />
|
||||
<span>{tk.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={() => setEditing(null)}>{t("Cancel")}</button>
|
||||
<button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => doExport(editing)}>{I.down}{t("Export")}</button>
|
||||
<button className="mbtn primary" onClick={saveEditing}>{I.check}{t("Save & apply")}</button>
|
||||
</div>
|
||||
</>));
|
||||
if (embedded) return <div className="set-panel">{body}</div>;
|
||||
return (
|
||||
<div className="modal-back" onClick={() => (editing ? setEditing(null) : onClose?.())}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.sun}<h3>{editing ? t("Custom theme") : t("Themes")}</h3>
|
||||
<span className="ph-sub">{editing ? editing.name : t("{n} themes", { n: themes.length })}</span>
|
||||
<button className="x" onClick={() => (editing ? setEditing(null) : onClose?.())}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
|
||||
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
|
||||
const [entries, setEntries] = useState<string[]>([]);
|
||||
@ -3351,11 +3674,15 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
|
||||
<div className="files" style={{ position: "relative" }}>
|
||||
{history.map((c) => {
|
||||
const sz = sizes?.[c.change || ""];
|
||||
// "NEW" badge on commits submitted within the last 3 hours (recomputed on
|
||||
// every history refresh, so it fades away on its own)
|
||||
const ageMs = c.time ? Date.now() - Number(c.time) * 1000 : Infinity;
|
||||
const fresh = ageMs >= 0 && ageMs < 3 * 3600 * 1000;
|
||||
return (
|
||||
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
|
||||
<Avatar user={c.user || "?"} size={26} />
|
||||
<span className="hbody">
|
||||
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
|
||||
<span className="hdesc">{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}{(c.desc || "").trim() || t("(no description)")}</span>
|
||||
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user