diff --git a/PLUGINS.md b/PLUGINS.md index bd91cf9..fec3243 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -75,6 +75,17 @@ host.t(key, params?) // app i18n 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 - **Menu items** and **views** appear in the **Actions** menu. - **File context items** appear when you right-click a file in Changes. diff --git a/plugins-examples/team-relay/index.js b/plugins-examples/team-relay/index.js index 70c7f6c..5734da1 100644 --- a/plugins-examples/team-relay/index.js +++ b/plugins-examples/team-relay/index.js @@ -130,7 +130,7 @@ export function activate(host) { inp.placeholder = ph || ""; inp.value = S.get(key) || ""; 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)); l.appendChild(inp); return l; @@ -205,7 +205,7 @@ export function activate(host) { el.textContent = ""; el.style.cssText = "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"); head.style.cssText = "display:flex;align-items:center;gap:8px"; @@ -229,7 +229,7 @@ export function activate(host) { const refresh = () => { 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 || ""; renderMembers(membersEl); }; diff --git a/plugins-examples/team-relay/plugin.json b/plugins-examples/team-relay/plugin.json index bac4cf1..c17d3b5 100644 --- a/plugins-examples/team-relay/plugin.json +++ b/plugins-examples/team-relay/plugin.json @@ -1,7 +1,7 @@ { "id": "team-relay", "name": "Team Relay", - "version": "1.0.0", + "version": "1.0.1", "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.", "entry": "index.js", diff --git a/src/App.css b/src/App.css index e7179ae..06c2971 100644 --- a/src/App.css +++ b/src/App.css @@ -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%; 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); - 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)} @keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}} diff --git a/src/App.tsx b/src/App.tsx index 11d047d..11774a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,7 @@ import ModelViewer from "./ModelViewer"; import CodeView from "./CodeView"; import { useUpdater } 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 { 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 { 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"; @@ -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 uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures 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([]); // code editors installed on this machine const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick) const [others, setOthers] = useState>(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"); } + // 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) { - setToast({ text, err }); + const one = (text || "").trim(); + setToast({ text: one.length > 220 ? one.slice(0, 220).trimEnd() + "…" : one, err }); setTimeout(() => setToast(null), 4500); } function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise { @@ -653,7 +658,11 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan setPluginVer((v) => v + 1); 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(() => { if (pluginsLoaded.current || !info?.clientName) return; pluginsLoaded.current = true; @@ -952,7 +961,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan async function getLatest() { 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); } } // 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 (!(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); - 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); } } 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 }); } - // 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() { - if (!uproject) return; - try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); } + const up = uprojectRef.current; + 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); } } // open the configuration picker (Development / Shipping / …) before building 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); } // 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. async function buildSln(config: string) { 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); - try { await p4.buildSln(slnPath, config, uproject); } + try { await p4.buildSln(sln, config, up); } catch { /* the overlay already shows the failure line */ } } @@ -1719,6 +1732,16 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan {logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"} + {/* 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) => ( + + ))}