0.3.6 — plugin registry, Unreal extracted to a plugin, in-app update check + fixes

- 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>
This commit is contained in:
Bonchellon
2026-07-09 15:13:02 +03:00
parent f0fc2f687a
commit 8e7908ae09
13 changed files with 283 additions and 72 deletions

View File

@ -12,7 +12,7 @@ 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 } from "./plugins";
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)
@ -378,6 +378,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
const [history, setHistory] = useState<Change[]>([]);
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
const sizesRef = useRef<Record<string, number>>({}); // mirror of changeSizes so we only fetch new changes (no flicker)
const histSizeScope = useRef<string>(""); // scope the cached sizes belong to
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
@ -390,6 +392,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
const [xferMin, setXferMin] = useState(false); // transfer dialog collapsed to a floating chip (keeps running)
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
const uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
@ -621,17 +624,32 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
info: () => info,
selectedFile: () => selFile || null,
refresh: () => { void refresh(); },
// Unreal bridge — core keeps buildSln/overlay/log; the plugin just drives them
ue: {
hasProject: () => !!uprojectRef.current,
uproject: () => uprojectRef.current,
build: () => { startBuild(); },
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
},
});
setPluginVer((v) => v + 1);
return res;
}
useEffect(() => { uprojectRef.current = uproject; }, [uproject]); // keep the ue-bridge mirror live
useEffect(() => {
if (pluginsLoaded.current || !info?.clientName) return;
pluginsLoaded.current = true;
reloadPlugins().then(({ loaded, errors }) => {
(async () => {
// auto-install official plugins (e.g. Unreal) on first run so no one loses tooling
try {
const installed = new Set((await p4.pluginList()).map((p) => p.id));
const added = await autoInstallOfficial(installed);
if (added.length) console.log("[plugins] auto-installed:", added);
} catch { /* offline — skip */ }
const { loaded, errors } = await reloadPlugins();
if (errors.length) console.warn("[plugins] load errors:", errors);
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
}).catch(() => {});
})().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
@ -894,13 +912,19 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
try {
const h = await p4.history(path);
setHistory(h);
// commit weights load in the background so the list shows instantly
// commit weights load in the background so the list shows instantly. Sizes are
// cached per changelist and only fetched for NEW changes — so a poll/refresh
// on the same scope never re-fetches (which used to make the badges flicker).
const scopeChanged = f !== histSizeScope.current;
histSizeScope.current = f;
if (scopeChanged) { sizesRef.current = {}; setChangeSizes({}); }
const nums = h.map((c) => c.change || "").filter(Boolean);
setChangeSizes({});
if (nums.length) {
p4.changeSizes(nums, f).then((rows) => {
const m: Record<string, number> = {};
const need = nums.filter((n) => sizesRef.current[n] === undefined);
if (need.length) {
p4.changeSizes(need, f).then((rows) => {
const m = { ...sizesRef.current };
for (const r of rows) m[r.change] = r.size;
sizesRef.current = m;
setChangeSizes(m);
}).catch(() => {});
}
@ -1373,7 +1397,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
],
Tools: [
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
@ -1386,7 +1409,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{ 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" }) },
],
@ -1439,7 +1461,6 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
@ -1675,7 +1696,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
height={dockH} onResize={startDockResize}
logs={logs} onClearLogs={() => setLogs([])}
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} uproject={uproject} />}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
@ -1683,9 +1704,12 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span>
<span className="stspace" />
{uproject && <button className="stlog" onClick={() => openDock("unreal")} title={t("Unreal Log")}>{I.hex}<span>{t("Unreal")}</span></button>}
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
<button className="stlog" onClick={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
<button className="stlog" onClick={() => window.dispatchEvent(new Event("exd-check-update"))} title={t("Check for updates")}>
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span>{t("Updates")}</span>
</button>
{peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<div className="logpeek-h">{t("Log")} · {logs.length}</div>
@ -2036,17 +2060,16 @@ function LogRow({ l }: { l: LogEntry }) {
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm, uproject }: {
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void;
logs: LogEntry[]; onClearLogs: () => void;
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void; uproject: string;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
}) {
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
{ key: "log", label: t("Log"), icon: I.log },
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []),
];
return (
<div className="dockpanel" style={{ flexBasis: height, height }}>
@ -2067,7 +2090,6 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
<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>
);
@ -2143,28 +2165,6 @@ function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
);
}
/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */
function UnrealTab({ uproject }: { uproject: string }) {
const [text, setText] = useState("");
const [err, setErr] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let live = true;
const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e)));
load();
const iv = window.setInterval(load, 1500);
return () => { live = false; clearInterval(iv); };
}, [uproject]);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]);
return (
<div className="termbody" ref={ref}>
{err ? <div className="termhint err">{err}</div>
: text ? <pre className="termout ue">{text}</pre>
: <div className="termhint">{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}</div>}
</div>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer, onCancel, onMinimize }: { transfer: Transfer; onCancel: () => void; onMinimize: () => void }) {
const up = transfer.op === "submit";
@ -3248,8 +3248,16 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
const [list, setList] = useState<PluginInfo[]>([]);
const [busy, setBusy] = useState(true);
const [dir, setDir] = useState("");
const [reg, setReg] = useState<RegistryEntry[]>([]);
const [installing, setInstalling] = useState<string>("");
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); fetchRegistry().then(setReg).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
async function installReg(entry: RegistryEntry) {
setInstalling(entry.id);
try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
catch (e) { onFlash(String(e), true); }
finally { setInstalling(""); }
}
async function toggle(p: PluginInfo, enabled: boolean) {
try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
}
@ -3286,6 +3294,21 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
</div>
</div>
))}
{reg.filter((e) => !list.some((p) => p.id === e.id)).length > 0 && (<>
<div className="pl-official-h">{t("Official plugins")}</div>
{reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
<div key={e.id} className="pl-card">
<div className="pl-main">
<div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div>
{e.description && <div className="pl-desc">{e.description}</div>}
<div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div>
</div>
<div className="pl-actions">
<button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
</div>
</div>
))}
</>)}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>
@ -3682,9 +3705,10 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
<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">{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
</span>
{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}
</div>
);
})}