5 Commits
v0.4.0 ... main

Author SHA1 Message Date
597dffe92d 0.4.2 — re-authenticate in place when the Perforce ticket expires
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:28:45 +03:00
30e77664d5 Re-authenticate in place when the Perforce ticket expires
Perforce ends the ticket on its own schedule, so a long day outlives a login.
Every command then failed and the only way back was Exit + restart, which threw
away the scope, selection and open panels.

- p4.ts routes all ~110 commands through one invoke wrapper that recognises an
  auth failure (isAuthError) and raises onAuthLost, so no call site can miss it.
  p4_login / p4_restore / p4_clients are exempt: they report bad credentials
  inline and would otherwise loop the prompt.
- The server poll no longer treats an expired ticket as an outage. The server
  answered, it just refused us — the offline banner it used to raise never
  cleared, since polling stayed refused forever.
- ReauthModal asks for the password over the running app, keeping all state,
  and offers Sign out as the way back to the Connect screen.

The failed command is NOT retried automatically — re-auth then refresh only.
Replaying a half-finished submit or revert unattended is worse than asking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:25:14 +03:00
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 570 additions and 18 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.2",
"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.2"
dependencies = [
"encoding_rs",
"reqwest 0.12.28",

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.9"
version = "0.4.2"
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.2",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -947,11 +947,17 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.dropinner svg{width:44px;height:44px}
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
/* re-auth: who we're signing back in as */
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
/* toast */
.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, isAuthError, onAuthLost, 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,12 +401,14 @@ 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
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server
const [reauth, setReauth] = useState(false); // p4 ticket expired — prompt for the password
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
@ -483,8 +485,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 +646,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;
@ -656,6 +675,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
})().catch(() => {});
}, [info?.clientName]); // eslint-disable-line
// any p4 command that comes back "not logged in" raises the re-auth prompt
useEffect(() => { onAuthLost(() => setReauth(true)); return () => onAuthLost(null); }, []);
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
@ -778,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
checkBehind(); // a new submit landed → am I now behind?
}
}
} catch {
} catch (e) {
// an expired ticket is not an outage: the server answered, it just
// refused us. The re-auth prompt handles it — don't also claim offline.
if (isAuthError(e)) return;
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
}
};
@ -943,7 +967,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 +991,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 +1051,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 +1738,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
@ -1772,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{/* last in the tree so it sits over every other overlay — nothing else
can succeed until the session is back */}
{reauth && <ReauthModal session={session} onSignOut={onDisconnect}
onDone={(i) => { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
@ -2374,6 +2416,65 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l
);
}
/* ---------------- re-authenticate (expired ticket) ----------------
Perforce expires the login ticket on its own schedule, so a long day
outlives it and every command starts failing. The server is still there and
the workspace is untouched — only the ticket died — so we ask for the
password over the running app instead of dropping back to the Connect
screen, which would throw away the scope, selection and open panels.
Deliberately not dismissable by clicking away: nothing works until it's
resolved one way or the other. */
function ReauthModal({ session, onDone, onSignOut }: {
session: Session | null; onDone: (i: P4Info) => void; onSignOut: () => void;
}) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
async function reconnect() {
if (!session || !password || busy) return;
setBusy(true); setErr("");
try {
const i = await p4.login(session.server, session.user, password, session.client);
setPassword("");
onDone(i);
} catch (e) { setErr(String(e)); setBusy(false); }
}
return (
<div className="modal-back">
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
<h3>{t("Session expired")}</h3>
<div className="prompt-label">
{t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")}
</div>
<div className="reauth-who">
<span>{session?.user}</span>
<span>{session?.client}</span>
<span>{session?.server}</span>
</div>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6" /></svg>
<input ref={ref} type="password" value={password} disabled={busy}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && reconnect()} />
</div>
</div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<div className="modal-actions">
<button className="mbtn ghost" onClick={onSignOut} disabled={busy}>{t("Sign out")}</button>
<button className="mbtn primary" onClick={reconnect} disabled={busy || !password}>
{busy ? t("Connecting…") : t("Reconnect")}
</button>
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
const btn = useRef<HTMLButtonElement>(null);

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" },
@ -446,6 +447,11 @@ const D: Record<string, Tr> = {
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
// session expired → re-auth prompt
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },

View File

@ -1,5 +1,31 @@
// Typed wrappers around the Rust p4 commands.
import { invoke } from "@tauri-apps/api/core";
import { invoke as rawInvoke } from "@tauri-apps/api/core";
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
* as opposed to the server being unreachable. Perforce expires tickets on its
* own schedule (often 12h), so a workday can outlive a login. */
export function isAuthError(e: unknown): boolean {
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
.test(String(e));
}
// Set by the app: called when any command comes back with an auth failure, so
// the UI can ask for the password again instead of dead-ending on every action.
let authLost: (() => void) | null = null;
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
// Commands that legitimately report bad credentials — the login screen shows
// those inline, and firing the re-auth prompt from them would loop.
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
/** Every p4 command funnels through here so a lost ticket is caught in one
* place rather than at each of the ~110 call sites. */
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return rawInvoke<T>(cmd, args).catch((e) => {
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
throw e;
});
}
export interface P4Info {
serverAddress?: string;
@ -114,6 +140,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 +332,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;
}