Fix dead Unreal plugin actions, sync toast flood, Team Relay theming

- ue bridge: Launch / Build now read live refs. They captured startBuild /
  launchUE from the render where plugins loaded — before the async .uproject
  and .sln detection had resolved — so both saw empty paths forever. hasProject
  used the live ref, so the menu items showed up and then did nothing.
- Get Latest / Sync to revision: summarise `p4 sync` stdout (syncSummary)
  instead of dumping every depot path into the toast. flash() caps length and
  .toast caps height so no raw output can blow up the bubble again.
- Team Relay: use the real theme tokens — --txt / --add / --del, not
  --text / --ok / --err, which don't exist and silently fell back to a
  near-white hardcode, invisible on the light theme. Bumped to 1.0.1.
- Plugin dock panels get a status-bar button next to Terminal / Log; the tabs
  were otherwise only reachable from the Actions menu.
- PLUGINS.md documents the theme token names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-18 16:50:14 +03:00
parent 20c94ef9f0
commit 37f5c504f7
7 changed files with 81 additions and 16 deletions

View File

@ -75,6 +75,17 @@ host.t(key, params?) // app i18n
host.log(...args) // console, tagged with the plugin id host.log(...args) // console, tagged with the plugin id
``` ```
## Theme tokens
A plugin's DOM inherits the app's CSS variables — use them so the panel follows the
active theme. The exact names (a wrong name silently falls back to your hardcoded
default, which then breaks on the opposite theme):
`--accent` `--accent-2` `--accent-deep` · `--bg` `--panel` `--panel-2` `--panel-3`
`--elevated` `--border` · `--txt` `--muted` `--faint` · `--add` (success) `--edit`
(warning) `--del` (danger) · `--mono` (font)
There is no `--text`, `--ok`, `--err`, or `--sans`.
## Contribution points ## Contribution points
- **Menu items** and **views** appear in the **Actions** menu. - **Menu items** and **views** appear in the **Actions** menu.
- **File context items** appear when you right-click a file in Changes. - **File context items** appear when you right-click a file in Changes.

View File

@ -130,7 +130,7 @@ export function activate(host) {
inp.placeholder = ph || ""; inp.placeholder = ph || "";
inp.value = S.get(key) || ""; inp.value = S.get(key) || "";
inp.style.cssText = inp.style.cssText =
"padding:5px 7px;border:1px solid var(--border,#33333d);border-radius:6px;background:var(--bg,#0e0e12);color:var(--text,#e8e8ee);font:12px var(--mono,ui-monospace,monospace)"; "padding:5px 7px;border:1px solid var(--border,#33333d);border-radius:6px;background:var(--bg,#0e0e12);color:var(--txt,#e8e8ee);font:12px var(--mono,ui-monospace,monospace)";
inp.addEventListener("input", () => S.set(key, inp.value)); inp.addEventListener("input", () => S.set(key, inp.value));
l.appendChild(inp); l.appendChild(inp);
return l; return l;
@ -205,7 +205,7 @@ export function activate(host) {
el.textContent = ""; el.textContent = "";
el.style.cssText = el.style.cssText =
"display:flex;flex-direction:column;height:100%;gap:9px;padding:11px 13px;overflow:auto;" + "display:flex;flex-direction:column;height:100%;gap:9px;padding:11px 13px;overflow:auto;" +
"font:12px/1.5 var(--sans,system-ui,sans-serif);color:var(--text,#e8e8ee)"; "font:12px/1.5 var(--sans,system-ui,sans-serif);color:var(--txt,#e8e8ee)";
const head = document.createElement("div"); const head = document.createElement("div");
head.style.cssText = "display:flex;align-items:center;gap:8px"; head.style.cssText = "display:flex;align-items:center;gap:8px";
@ -229,7 +229,7 @@ export function activate(host) {
const refresh = () => { const refresh = () => {
dot.style.background = dot.style.background =
status === "ok" ? "var(--ok,#3ecf8e)" : status === "error" ? "var(--err,#ff6b6b)" : "var(--muted,#9a9aa8)"; status === "ok" ? "var(--add,#3ecf8e)" : status === "error" ? "var(--del,#ff6b6b)" : "var(--muted,#9a9aa8)";
statusText.textContent = statusMsg || ""; statusText.textContent = statusMsg || "";
renderMembers(membersEl); renderMembers(membersEl);
}; };

View File

@ -1,7 +1,7 @@
{ {
"id": "team-relay", "id": "team-relay",
"name": "Team Relay", "name": "Team Relay",
"version": "1.0.0", "version": "1.0.1",
"author": "Exbyte Studios", "author": "Exbyte Studios",
"description": "See who on the team is online and what they have open, over a lightweight relay on your VPN. Coordinators (admin/super) can force-shelve a teammate's open work to the server when they've stepped away — the teammate's client does the shelve; nothing is submitted. Configure the relay URL in the Team tab. Requires the Exbyte relay service.", "description": "See who on the team is online and what they have open, over a lightweight relay on your VPN. Coordinators (admin/super) can force-shelve a teammate's open work to the server when they've stepped away — the teammate's client does the shelve; nothing is submitted. Configure the relay URL in the Team tab. Requires the Exbyte relay service.",
"entry": "index.js", "entry": "index.js",

View File

@ -951,7 +951,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%; .toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
background:var(--elevated);border:1px solid var(--border);color:var(--txt); background:var(--elevated);border:1px solid var(--border);color:var(--txt);
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6); padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
animation:toastin .25s ease;white-space:pre-wrap;text-align:center} animation:toastin .25s ease;white-space:pre-wrap;text-align:center;
max-height:30vh;overflow-y:auto;overflow-wrap:anywhere}
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)} .toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}} @keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}

View File

@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView"; import CodeView from "./CodeView";
import { useUpdater } from "./Updater"; import { useUpdater } from "./Updater";
import "./App.css"; 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 { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, 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 { 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 { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins"; import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
@ -401,6 +401,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project 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 uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building) const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
const slnPathRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick) const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users
@ -483,8 +484,12 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
document.body.classList.add("resizing-v"); document.body.classList.add("resizing-v");
} }
// A toast is one line of feedback, never a report: cap it so a raw p4 dump or a
// long server error can't grow the bubble over the whole window. Full output
// always stays available in the Log dock.
function flash(text: string, err = false) { function flash(text: string, err = false) {
setToast({ text, err }); const one = (text || "").trim();
setToast({ text: one.length > 220 ? one.slice(0, 220).trimEnd() + "…" : one, err });
setTimeout(() => setToast(null), 4500); setTimeout(() => setToast(null), 4500);
} }
function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise<boolean> { function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise<boolean> {
@ -653,7 +658,11 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
setPluginVer((v) => v + 1); setPluginVer((v) => v + 1);
return res; return res;
} }
useEffect(() => { uprojectRef.current = uproject; }, [uproject]); // keep the ue-bridge mirror live // keep the ue-bridge mirrors live: the bridge closures are built once (plugins
// load as soon as we have a workspace) but the paths are detected later, per
// working folder — so the bridge must read refs, never captured state.
useEffect(() => { uprojectRef.current = uproject; }, [uproject]);
useEffect(() => { slnPathRef.current = slnPath; }, [slnPath]);
useEffect(() => { useEffect(() => {
if (pluginsLoaded.current || !info?.clientName) return; if (pluginsLoaded.current || !info?.clientName) return;
pluginsLoaded.current = true; pluginsLoaded.current = true;
@ -952,7 +961,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
async function getLatest() { async function getLatest() {
setBusy(true); setBusy(true);
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too try { flash(t("Get Latest: {msg}", { msg: syncSummary(await p4.sync(activePath)) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
catch (e) { flash(String(e), true); setBusy(false); } catch (e) { flash(String(e), true); setBusy(false); }
} }
// check whether the server has newer content than my workspace (files behind) // check whether the server has newer content than my workspace (files behind)
@ -976,7 +985,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
if (!r) return; if (!r) return;
if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return; if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return;
setBusy(true); setBusy(true);
try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: await p4.syncTo(activePath, r) })); await refresh(); await loadHistory(); } try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: syncSummary(await p4.syncTo(activePath, r)) })); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); } catch (e) { flash(String(e), true); setBusy(false); }
} }
function promptSyncTo() { function promptSyncTo() {
@ -1036,25 +1045,29 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); } function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }
// launch Unreal by opening the detected .uproject (file association starts UE) // launch Unreal by opening the detected .uproject (file association starts UE).
// Reads the ref, not the state: the plugin ue-bridge calls this through a
// closure captured before the project was detected.
async function launchUE() { async function launchUE() {
if (!uproject) return; const up = uprojectRef.current;
try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); } if (!up) { flash(t("No Unreal project in the working folder. Choose the project folder first."), true); return; }
try { await p4.launchFile(up); flash(t("Launching Unreal…")); }
catch (e) { flash(String(e), true); } catch (e) { flash(String(e), true); }
} }
// open the configuration picker (Development / Shipping / …) before building // open the configuration picker (Development / Shipping / …) before building
function startBuild() { function startBuild() {
if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; } if (!slnPathRef.current && !uprojectRef.current) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
setBuildPick(true); setBuildPick(true);
} }
// build with the chosen UE configuration, live output. For a real UE project we // build with the chosen UE configuration, live output. For a real UE project we
// pass the .uproject so the backend builds the game module via UnrealBuildTool. // pass the .uproject so the backend builds the game module via UnrealBuildTool.
async function buildSln(config: string) { async function buildSln(config: string) {
setBuildPick(false); setBuildPick(false);
if (!slnPath && !uproject) return; const sln = slnPathRef.current, up = uprojectRef.current;
if (!sln && !up) return;
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false); setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
try { await p4.buildSln(slnPath, config, uproject); } try { await p4.buildSln(sln, config, up); }
catch { /* the overlay already shows the failure line */ } catch { /* the overlay already shows the failure line */ }
} }
@ -1719,6 +1732,16 @@ 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 className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span> </span>
<span className="stspace" /> <span className="stspace" />
{/* Plugin dock panels get a status-bar button too — a dock tab you can't
open from anywhere is just a hidden tab. Listed before the built-ins so
Terminal/Log keep their place as plugins come and go. */}
{getRegistry().docks.map((d) => (
<button key={`${d.pluginId}:${d.id}`} className="stlog"
onClick={() => openDock(`plugin:${d.id}`)}
title={t("From plugin: {p}", { p: d.pluginId })}>
{pluginIcon(d.icon)}<span>{d.title}</span>
</button>
))}
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</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={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
<button <button

View File

@ -73,6 +73,7 @@ const D: Record<string, Tr> = {
"Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" }, "Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" },
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" }, "Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." }, "No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
"No Unreal project in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет проекта Unreal. Сначала выбери папку проекта.", de: "Kein Unreal-Projekt im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucun projet Unreal dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay proyecto Unreal en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" }, "Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" }, "AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
"Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" }, "Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" },

View File

@ -306,6 +306,35 @@ export function fmtTime(t?: string): string {
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }); return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
} }
/** Condense `p4 sync` stdout into one short line.
*
* A sync prints one line per file — `//depot/F.uasset#3 - updating d:\ws\F.uasset` —
* so a real Get Latest returns thousands of full paths. Dumping that into a toast
* buries the screen; the per-file detail already streamed into the transfer dialog.
* Short outputs ("File(s) up-to-date.") pass through unchanged. */
export function syncSummary(out: string): string {
const text = (out || "").trim();
if (!text) return "up to date";
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
// p4 says its piece in one line when nothing moved ("File(s) up-to-date.") and
// so do server errors — keep those verbatim; flash() caps the length.
if (lines.length === 1) return lines[0];
let added = 0, updated = 0, deleted = 0;
for (const l of lines) {
if (/ - (added as|refreshing) /.test(l)) added++;
else if (/ - (updating|replacing) /.test(l)) updated++;
else if (/ - deleted as /.test(l)) deleted++;
}
const moved = added + updated + deleted;
// unrecognised shape (server notices, errors) — report the line count, not the text
if (!moved) return `${lines.length.toLocaleString()} lines`;
const parts: string[] = [];
if (added) parts.push(`+${added.toLocaleString()}`);
if (updated) parts.push(`~${updated.toLocaleString()}`);
if (deleted) parts.push(`${deleted.toLocaleString()}`);
return `${moved.toLocaleString()} files (${parts.join(" ")})`;
}
// action -> single-char status + css class // action -> single-char status + css class
export function statusOf(action?: string): { ch: string; cls: string } { export function statusOf(action?: string): { ch: string; cls: string } {
const a = (action || "").toLowerCase(); const a = (action || "").toLowerCase();