3 Commits

Author SHA1 Message Date
23fabe11ec 0.4.1 — Unreal plugin actions, sync toast summary, plugin dock buttons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:55:31 +03:00
37f5c504f7 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>
2026-07-18 16:50:14 +03:00
20c94ef9f0 0.4.0 — Team Relay: presence + coordinator force-shelve over a generic relay
- host.team bridge + 'team' permission (SDK)
- backend: relay_http (generic HTTP pipe), p4_shelve_open, p4_protects_max
- new plugin team-relay (opt-in): Team dock, presence loop, force-shelve
- relay server (exbyte-relay/) — generic zero-dep message bus, no Perforce knowledge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:23:27 +03:00
13 changed files with 463 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
```
## 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.

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.3.9",
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -0,0 +1,243 @@
// Team Relay — presence + coordinator tools over a GENERIC relay.
//
// The relay (see exbyte-relay/) is a dumb message bus: it routes opaque JSON and
// tracks who's online, and knows nothing about Perforce. ALL the logic lives
// here and runs through the permission-gated `host.team` bridge:
// - every ~4s we announce presence (user, #open files, access level) and drain
// our inbox in one /sync round-trip;
// - a coordinator (admin/super) can ask a teammate's client to shelve its open
// work to the server — the teammate's client does the shelve, nothing is
// submitted, and their files stay checked out (shelve is non-destructive).
//
// Relay URL/token/room are configured in the Team tab and stored per-plugin —
// nothing is hardcoded into the app.
export function activate(host) {
const team = host.team;
if (!team) { host.log('no team bridge — plugin.json needs the "team" permission'); return; }
const S = host.storage;
if (!S.get("room")) S.set("room", "team");
const getCfg = () => ({
url: (S.get("relayUrl") || "").trim().replace(/\/+$/, ""),
token: (S.get("relayToken") || "").trim(),
room: (S.get("room") || "team").trim() || "team",
});
const me = team.whoami() || {};
const myId = me.client || me.user || "unknown";
let access = "";
team.maxAccess().then((a) => { access = String(a || "").toLowerCase(); updateAll(); }).catch(() => {});
const isCoordinator = () => access === "super" || access === "admin";
let members = [];
let status = "idle"; // idle | ok | error
let statusMsg = "";
const updaters = new Set();
const updateAll = () => updaters.forEach((fn) => { try { fn(); } catch (_) {} });
// ---- relay I/O (backend HTTP pipe → no CORS, any http(s) URL) --------------
async function post(path, bodyObj) {
const c = getCfg();
if (!c.url) throw new Error("Relay URL not set");
const txt = await team.relay(c.url + path, "POST", JSON.stringify(bodyObj), c.token);
return JSON.parse(txt || "{}");
}
async function send(to, type, payload) {
const c = getCfg();
await post("/send", { room: c.room, to, from: myId, type, payload: payload || {} });
}
// ---- inbound message handling ---------------------------------------------
async function handle(msg) {
const from = msg.from || "A teammate";
if (msg.type === "shelve-request") {
host.flash(from + " is shelving your open work to the server…");
host.notify("Team Relay", from + " is shelving your open work to the server…");
try {
const n = await team.openCount();
if (!n) { await send(from, "shelve-result", { ok: true, change: "", files: 0 }); return; }
const r = await team.shelveOpen("Remote shelve requested by " + from + " via Team Relay");
host.notify("Team Relay", "Your open work was shelved as CL " + r.change + " (" + r.files + " file(s)). Your files stay checked out.");
await send(from, "shelve-result", { ok: true, change: r.change, files: r.files });
} catch (e) {
host.flash("Remote shelve failed: " + e, true);
await send(from, "shelve-result", { ok: false, error: String(e) });
}
} else if (msg.type === "shelve-result") {
const p = msg.payload || {};
if (p.ok && p.change) host.flash(from + ": shelved " + p.files + " file(s) as CL " + p.change + " — find it under Pending / Shelved.");
else if (p.ok) host.flash(from + ": nothing open to shelve.");
else host.flash(from + ": shelve failed — " + p.error, true);
}
}
// ---- presence loop (one global timer, survives plugin reloads) ------------
let busy = false;
async function tick() {
if (busy) return;
busy = true;
try {
const c = getCfg();
if (!c.url) { status = "idle"; statusMsg = "Set the relay URL below."; return; }
const openCount = await team.openCount().catch(() => 0);
const res = await post("/sync", {
room: c.room, id: myId, name: me.user || myId,
meta: { client: myId, user: me.user || "", openCount, access },
});
members = Array.isArray(res.members) ? res.members : [];
status = "ok";
statusMsg = members.length + (members.length === 1 ? " online" : " online");
for (const m of (Array.isArray(res.messages) ? res.messages : [])) await handle(m);
} catch (e) {
status = "error";
statusMsg = String(e).replace(/^Error:\s*/, "");
} finally {
busy = false;
updateAll();
}
}
// Single presence loop even if the plugin is reloaded (blob module re-imported).
if (typeof window !== "undefined") {
if (window.__exbyteTeamRelayTimer) clearInterval(window.__exbyteTeamRelayTimer);
window.__exbyteTeamRelayTimer = setInterval(tick, 4000);
}
tick();
// ---- UI helpers ------------------------------------------------------------
function styleBtn(b, primary) {
b.style.cssText =
"padding:4px 10px;border-radius:6px;cursor:pointer;white-space:nowrap;font:12px var(--sans,system-ui,sans-serif);" +
(primary
? "background:var(--accent,#7c6ef6);color:#fff;border:1px solid var(--accent,#7c6ef6)"
: "background:transparent;color:var(--muted,#9a9aa8);border:1px solid var(--border,#33333d)");
if (b.disabled) { b.style.opacity = "0.5"; b.style.cursor = "default"; }
}
function buildSettings() {
const wrap = document.createElement("div");
wrap.style.cssText =
"display:flex;flex-direction:column;gap:6px;padding:9px;border:1px solid var(--border,#26262e);border-radius:8px;background:var(--panel,#17171d)";
const title = document.createElement("div");
title.textContent = "Relay settings";
title.style.cssText = "font-weight:600;margin-bottom:2px";
const field = (label, key, ph, pw) => {
const l = document.createElement("label");
l.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:11px;color:var(--muted,#9a9aa8)";
l.appendChild(document.createTextNode(label));
const inp = document.createElement("input");
inp.type = pw ? "password" : "text";
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(--txt,#e8e8ee);font:12px var(--mono,ui-monospace,monospace)";
inp.addEventListener("input", () => S.set(key, inp.value));
l.appendChild(inp);
return l;
};
const save = document.createElement("button");
save.textContent = "Connect";
styleBtn(save, true);
save.addEventListener("click", () => { host.flash("Relay settings saved."); tick(); });
wrap.append(
title,
field("Relay URL", "relayUrl", "http://26.0.0.1:8787"),
field("Token (optional)", "relayToken", "shared secret", true),
field("Room", "room", "team"),
save,
);
return wrap;
}
function renderMembers(container) {
container.textContent = "";
if (!getCfg().url) return;
if (!members.length) {
const empty = document.createElement("div");
empty.style.cssText = "color:var(--muted,#9a9aa8);padding:8px 0";
empty.textContent = status === "error" ? "Can't reach the relay — check the URL/token." : "No teammates online yet.";
container.appendChild(empty);
return;
}
for (const m of members) {
const meta = m.meta || {};
const oc = Number(meta.openCount || 0);
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--border,#26262e)";
const infoCol = document.createElement("div");
infoCol.style.flex = "1";
const name = document.createElement("div");
name.style.fontWeight = "600";
name.textContent = (m.name || m.id) + (m.id === myId ? " (you)" : "");
const sub = document.createElement("div");
sub.style.cssText = "color:var(--muted,#9a9aa8);font-size:11px";
sub.textContent = m.id + " · " + oc + " open" + (meta.access ? " · " + meta.access : "");
infoCol.append(name, sub);
row.appendChild(infoCol);
if (isCoordinator() && m.id !== myId) {
const btn = document.createElement("button");
const idle = oc > 0 ? "Force shelve" : "Nothing open";
btn.textContent = idle;
btn.disabled = oc === 0;
styleBtn(btn, oc > 0);
if (oc > 0) {
btn.title = "Ask this teammate's client to shelve their open work to the server (nothing is submitted).";
btn.addEventListener("click", async () => {
btn.disabled = true; btn.textContent = "Requesting…"; styleBtn(btn, false);
try { await send(m.id, "shelve-request", {}); host.flash("Asked " + (m.name || m.id) + " to shelve their open work…"); }
catch (e) { host.flash("Couldn't reach the relay: " + e, true); }
setTimeout(() => { btn.disabled = false; btn.textContent = idle; styleBtn(btn, true); }, 2500);
});
}
row.appendChild(btn);
}
container.appendChild(row);
}
}
// ---- dock panel ("Team") ---------------------------------------------------
host.ui.addDockPanel({
id: "team",
title: "Team",
icon: "people",
render: (el) => {
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(--txt,#e8e8ee)";
const head = document.createElement("div");
head.style.cssText = "display:flex;align-items:center;gap:8px";
const dot = document.createElement("span");
dot.style.cssText = "width:8px;height:8px;border-radius:50%;flex:0 0 auto";
const statusText = document.createElement("span");
statusText.style.cssText = "color:var(--muted,#9a9aa8);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
const gear = document.createElement("button");
gear.textContent = "Settings";
styleBtn(gear, false);
head.append(dot, statusText, gear);
const settings = buildSettings();
settings.style.display = getCfg().url ? "none" : "flex";
gear.addEventListener("click", () => {
settings.style.display = settings.style.display === "none" ? "flex" : "none";
});
const membersEl = document.createElement("div");
el.append(head, settings, membersEl);
const refresh = () => {
dot.style.background =
status === "ok" ? "var(--add,#3ecf8e)" : status === "error" ? "var(--del,#ff6b6b)" : "var(--muted,#9a9aa8)";
statusText.textContent = statusMsg || "";
renderMembers(membersEl);
};
updaters.add(refresh);
refresh();
return () => { updaters.delete(refresh); };
},
});
host.log("Team Relay activated (id " + myId + ")");
}

View File

@ -0,0 +1,12 @@
{
"id": "team-relay",
"name": "Team Relay",
"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",
"minAppVersion": "0.4.0",
"permissions": ["team", "notify"],
"defaultEnabled": false,
"contributes": { "dock": ["team"] }
}

2
src-tauri/Cargo.lock generated
View File

@ -992,7 +992,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.8"
version = "0.4.1"
dependencies = [
"encoding_rs",
"reqwest 0.12.28",

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.9"
version = "0.4.1"
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"]
edition = "2021"

View File

@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result<Strin
run_text(&conn, &["unshelve", "-s", &change, "-f"])
}
/// Shelve everything the user has open in their DEFAULT changelist to the server
/// in one shot: create a numbered pending changelist, move the open files into
/// it, and `shelve` it. Returns `{ change, files }`. This is the primitive the
/// team-relay plugin drives so a coordinator can push a teammate's local work to
/// the server (as a shelf — nothing is submitted). The teammate's client runs
/// this; it uses ordinary client-side p4 commands and never touches the server
/// config. Files already parked in other numbered changelists are left alone.
#[tauri::command]
async fn p4_shelve_open(state: State<'_, AppState>, description: String) -> Result<Value, String> {
let conn = current(&state)?;
let opened = run_json(&conn, &["opened", "-c", "default"])?;
let files: Vec<String> = opened
.iter()
.filter_map(|v| v.get("depotFile").and_then(|d| d.as_str()).map(String::from))
.collect();
if files.is_empty() {
return Err("No open files in the default changelist".into());
}
let desc = if description.trim().is_empty() {
"Remote shelve (Team Relay)".to_string()
} else {
description
};
let cl = create_changelist(&conn, &desc)?;
// Move the open files into the new changelist, batched so a huge default
// changelist doesn't overflow the Windows command line.
let prefix_len = "reopen -c ".len() + cl.len();
for r in file_batches(&files, prefix_len) {
let mut args: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files[r] {
args.push(f.as_str());
}
run_text(&conn, &args)?;
}
run_text(&conn, &["shelve", "-c", &cl])?;
Ok(serde_json::json!({ "change": cl, "files": files.len() }))
}
/// The caller's maximum protection level on this server (`p4 protects -m`),
/// e.g. "super" / "admin" / "write". Read-only; used to gate coordinator-only
/// actions in the UI (the server still enforces the real permission).
#[tauri::command]
async fn p4_protects_max(state: State<'_, AppState>) -> Result<String, String> {
let conn = current(&state)?;
run_text(&conn, &["protects", "-m"]).map(|s| s.trim().to_string())
}
/// Delete the shelved files of a changelist.
#[tauri::command]
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
@ -3006,6 +3053,48 @@ async fn registry_http_get(url: String) -> Result<String, String> {
resp.text().await.map_err(|e| e.to_string())
}
/// Generic HTTP pipe for plugins that talk to a self-hosted service (e.g. the
/// team relay on the VPN). The webview can't reach arbitrary hosts — CSP pins
/// `connect-src`, and cross-origin `fetch` hits CORS — so the plugin routes its
/// requests through here. This is a DUMB pipe: it has no idea what the service
/// is, which keeps the relay URL out of the app (the plugin holds it) and lets
/// one primitive serve any future relay-backed plugin. http/https only; an
/// optional bearer token authenticates against the caller's own service.
#[tauri::command]
async fn relay_http(
url: String,
method: String,
body: Option<String>,
token: Option<String>,
) -> Result<String, String> {
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("Relay URL must be http(s)".into());
}
let client = reqwest::Client::builder()
.user_agent("ExbyteDepot")
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|e| e.to_string())?;
let mut req = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
other => return Err(format!("Unsupported method {other}")),
};
if let Some(tok) = token.filter(|t| !t.is_empty()) {
req = req.header("authorization", format!("Bearer {tok}"));
}
if let Some(b) = body {
req = req.header("content-type", "application/json").body(b);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(format!("HTTP {}: {}", status.as_u16(), text.trim()));
}
Ok(text)
}
fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
@ -3166,6 +3255,9 @@ pub fn run() {
plugin_set_enabled,
plugin_write_file,
registry_http_get,
relay_http,
p4_shelve_open,
p4_protects_max,
plugin_install,
plugin_remove,
plugins_dir_path,

View File

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot",
"version": "0.3.9",
"version": "0.4.1",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",

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%;
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)}}

View File

@ -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<Editor[]>([]); // code editors installed on this machine
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
@ -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<boolean> {
@ -640,11 +645,24 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
launch: () => { void launchUE(); },
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
},
// Team bridge — presence + coordinator shelve tools; plugin owns the logic
team: {
whoami: () => (info ? { user: info.userName, client: info.clientName, root: info.clientRoot } : null),
maxAccess: () => p4.protectsMax().catch(() => ""),
openCount: () => p4.opened("").then((a) => a.length).catch(() => 0),
shelveOpen: (desc: string) => p4.shelveOpen(desc),
submitShelved: (change: string) => p4.submitShelved(change),
relay: (url: string, method: string, body?: string, token?: string) => p4.relayHttp(url, method, body, token),
},
});
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;
@ -943,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)
@ -967,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() {
@ -1027,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 */ }
}
@ -1710,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>
<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("log")} title="Ctrl+L">{I.log}<span>Log</span></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 — 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 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" },
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
"Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" },

View File

@ -114,6 +114,14 @@ export const p4 = {
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
// generic HTTP pipe for relay-backed plugins (any http(s) URL, optional bearer)
relayHttp: (url: string, method: string, body?: string, token?: string) =>
invoke<string>("relay_http", { url, method, body, token }),
// shelve everything open in the default changelist → { change, files }
shelveOpen: (description: string) =>
invoke<{ change: string; files: number }>("p4_shelve_open", { description }),
// caller's max protection level on the server (super/admin/write/…)
protectsMax: () => invoke<string>("p4_protects_max"),
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
@ -298,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" });
}
/** 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
export function statusOf(action?: string): { ch: string; cls: string } {
const a = (action || "").toLowerCase();

View File

@ -42,6 +42,21 @@ export interface UeBridge {
readLog: (tailBytes?: number) => Promise<string>;
}
// Team bridge — presence + coordinator tools. The plugin drives these; the core
// just exposes the p4 primitives + a generic HTTP pipe to the relay. Gated by
// the "team" permission. Nothing here touches the Perforce server config: shelve
// is an ordinary client-side operation and `relay` talks only to the URL the
// plugin was configured with.
export interface TeamBridge {
whoami: () => { user?: string; client?: string; root?: string } | null;
maxAccess: () => Promise<string>; // "super" | "admin" | "write" | …
openCount: () => Promise<number>; // files open in this workspace
shelveOpen: (desc: string) => Promise<{ change: string; files: number }>;
submitShelved: (change: string) => Promise<string>;
// dumb HTTP pipe to the configured relay (backend does the request; no CORS)
relay: (url: string, method: string, body?: string, token?: string) => Promise<string>;
}
// ---- host context (provided by the app) -------------------------------------
export interface HostContext {
flash: (text: string, err?: boolean) => void;
@ -50,6 +65,7 @@ export interface HostContext {
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge;
team?: TeamBridge;
}
// ---- the Host SDK a plugin receives -----------------------------------------
@ -75,6 +91,7 @@ export interface HostApi {
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge; // present only with the "ue" permission
team?: TeamBridge; // present only with the "team" permission
theme: () => Record<string, string>;
t: typeof t;
log: (...a: unknown[]) => void;
@ -125,6 +142,7 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
};
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
if (has("team") && ctx.team) host.team = ctx.team; // Team bridge, permission-gated
return host;
}