7 Commits
v0.3.7 ... 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
dc88269baf 0.3.9 — fix plugin registry (backend HTTPS, CORS), fold updater into a status-bar icon
- Plugin catalog/install failed silently: the webview fetch() to Gitea raw is
  blocked by CORS (no ACAO header). Route registry reads through a new Rust
  command registry_http_get (host-restricted) — catalog + installs now work.
- Updater: removed the floating card that overlapped the Terminal/Log/Updates
  buttons. Update state now shows as a single status-bar download icon (no text):
  idle → download glyph, available → accent + dot, checking/downloading → spinner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:59:38 +03:00
9fc81015d7 0.3.8 — plugins install from the catalog only (no auto-install)
Drop first-run auto-install. New plugins are discovered and installed by hand
from Settings → Plugins, which now shows an 'Available to install' catalog
fetched from the Gitea plugin registry (with loading / offline-retry states).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:46:04 +03:00
14 changed files with 895 additions and 144 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

@ -1,7 +1,7 @@
{ {
"name": "exbyte-depot", "name": "exbyte-depot",
"private": true, "private": true,
"version": "0.3.7", "version": "0.4.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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"] }
}

211
src-tauri/Cargo.lock generated
View File

@ -468,6 +468,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@ -558,6 +575,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@ -966,9 +992,10 @@ dependencies = [
[[package]] [[package]]
name = "exbyte-depot" name = "exbyte-depot"
version = "0.3.7" version = "0.4.2"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@ -1279,8 +1306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1302,8 +1331,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1572,6 +1604,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@ -2042,6 +2075,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "mac-notification-sys" name = "mac-notification-sys"
version = "0.6.15" version = "0.6.15"
@ -2784,6 +2823,62 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.46"
@ -2812,7 +2907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
] ]
[[package]] [[package]]
@ -2822,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.9.5",
] ]
[[package]] [[package]]
@ -2834,6 +2940,21 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@ -2909,6 +3030,44 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@ -3046,6 +3205,7 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@ -3093,6 +3253,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -3315,6 +3481,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.21.0"
@ -3385,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@ -3709,7 +3887,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@ -3858,7 +4036,7 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
"rand", "rand 0.9.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@ -3917,7 +4095,7 @@ dependencies = [
"minisign-verify", "minisign-verify",
"osakit", "osakit",
"percent-encoding", "percent-encoding",
"reqwest", "reqwest 0.13.4",
"rustls", "rustls",
"semver", "semver",
"serde", "serde",
@ -4692,6 +4870,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "web_atoms" name = "web_atoms"
version = "0.2.5" version = "0.2.5"
@ -4757,6 +4945,15 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "exbyte-depot" name = "exbyte-depot"
version = "0.3.7" version = "0.4.2"
description = "Exbyte Depot — native Perforce client by Exbyte Studios" description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"] authors = ["Exbyte Studios"]
edition = "2021" edition = "2021"
@ -26,6 +26,8 @@ serde_json = "1"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
# decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths # decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths
encoding_rs = "0.8" encoding_rs = "0.8"
# backend-side HTTPS for the plugin registry (webview fetch is blocked by CORS)
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
# Auto-updater (desktop only — checks Gitea Releases for a signed new version) # Auto-updater (desktop only — checks Gitea Releases for a signed new version)
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]

View File

@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result<Strin
run_text(&conn, &["unshelve", "-s", &change, "-f"]) 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. /// Delete the shelved files of a changelist.
#[tauri::command] #[tauri::command]
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> { async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
@ -2987,6 +3034,67 @@ async fn plugin_write_file(id: String, name: String, content: String) -> Result<
Ok(()) Ok(())
} }
/// Fetch text from the Exbyte plugin registry over HTTPS on the backend — the
/// webview `fetch()` is blocked by CORS (Gitea raw sends no ACAO header), so the
/// plugin catalog and installs go through here. Restricted to the trusted host.
#[tauri::command]
async fn registry_http_get(url: String) -> Result<String, String> {
if !url.starts_with("https://gitea.exbytestudios.com/") {
return Err("Only the Exbyte registry host is allowed".into());
}
let client = reqwest::Client::builder()
.user_agent("ExbyteDepot")
.build()
.map_err(|e| e.to_string())?;
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status().as_u16()));
}
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<()> { fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?; std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? { for e in std::fs::read_dir(from)? {
@ -3146,6 +3254,10 @@ pub fn run() {
plugin_read_entry, plugin_read_entry,
plugin_set_enabled, plugin_set_enabled,
plugin_write_file, plugin_write_file,
registry_http_get,
relay_http,
p4_shelve_open,
p4_protects_max,
plugin_install, plugin_install,
plugin_remove, plugin_remove,
plugins_dir_path, plugins_dir_path,

View File

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

View File

@ -374,6 +374,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
color:var(--muted);border-radius:7px;padding:3px 10px;cursor:pointer;font-size:11.5px} color:var(--muted);border-radius:7px;padding:3px 10px;cursor:pointer;font-size:11.5px}
.stlog:hover{color:var(--txt);border-color:var(--accent)} .stlog:hover{color:var(--txt);border-color:var(--accent)}
.stlog svg{width:13px;height:13px} .stlog svg{width:13px;height:13px}
.stlog.icon{padding:3px 7px}
.stupd{position:relative}
.stupd.has{color:var(--accent);border-color:var(--accent)}
.stupd.busy{color:var(--accent-2)}
.stupd[disabled]{cursor:default;opacity:.85}
.stupd-dot{position:absolute;top:2px;right:2px;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--panel)}
.logpeek{position:absolute;left:8px;bottom:26px;width:520px;max-width:calc(100vw - 20px);max-height:min(340px,60vh);overflow-y:auto;z-index:250; .logpeek{position:absolute;left:8px;bottom:26px;width:520px;max-width:calc(100vw - 20px);max-height:min(340px,60vh);overflow-y:auto;z-index:250;
background:var(--elevated);border:1px solid var(--border);border-radius:11px 11px 0 0;padding:5px;box-shadow:0 -14px 40px -12px rgba(0,0,0,.55)} background:var(--elevated);border:1px solid var(--border);border-radius:11px 11px 0 0;padding:5px;box-shadow:0 -14px 40px -12px rgba(0,0,0,.55)}
.logpeek-h{position:sticky;top:0;padding:5px 9px 7px;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint); .logpeek-h{position:sticky;top:0;padding:5px 9px 7px;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);
@ -731,7 +737,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0} .th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0}
/* plugins manager */ /* plugins manager */
.pl-list{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px} .pl-list{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
.pl-official-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:700;margin:8px 2px 0;padding-top:10px;border-top:1px solid var(--border-soft)} .pl-official-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:700;margin:8px 2px 0;padding-top:10px;border-top:1px solid var(--border-soft);display:flex;align-items:baseline;gap:8px}
.pl-cat-sub{text-transform:none;letter-spacing:0;font-weight:500;color:var(--faint);opacity:.8}
.pl-card{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--panel);transition:.15s} .pl-card{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--panel);transition:.15s}
.pl-card.off{opacity:.55} .pl-card.off{opacity:.55}
.pl-main{flex:1;min-width:0} .pl-main{flex:1;min-width:0}
@ -940,11 +947,17 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.dropinner svg{width:44px;height:44px} .dropinner svg{width:44px;height:44px}
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)} .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 */
.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

@ -7,12 +7,12 @@ import { isPermissionGranted, requestPermission, sendNotification } from "@tauri
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import ModelViewer from "./ModelViewer"; import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView"; import CodeView from "./CodeView";
import UpdateBanner 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, 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 { 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, autoInstallOfficial, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins"; import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai"; import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
// native OS notification (works even when the window is hidden in the tray) // native OS notification (works even when the window is hidden in the tray)
@ -151,7 +151,7 @@ export default function App() {
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }} onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />; onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
return <>{content}<UpdateBanner /></>; return content;
} }
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */ /* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
@ -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 [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
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
const [resolveOpen, setResolveOpen] = useState(false); const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server 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 [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 [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) const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
@ -423,6 +425,7 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
// bottom dock: a tabbed panel (Log / Terminal + any plugin dock panels), opened from Window menu // bottom dock: a tabbed panel (Log / Terminal + any plugin dock panels), opened from Window menu
const [dockOpen, setDockOpen] = useState(false); const [dockOpen, setDockOpen] = useState(false);
const [dockTab, setDockTab] = useState<string>("log"); const [dockTab, setDockTab] = useState<string>("log");
const upd = useUpdater(); // silent update check + state for the status-bar update icon
// right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab. // right-side view panel: tabbed (File Viewer / File Tree). Header hides at one tab.
const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => { const [viewTabs, setViewTabs] = useState<ViewTab[]>(() => {
try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {} try { const a = JSON.parse(localStorage.getItem("exd-viewtabs") || ""); if (Array.isArray(a) && a.length && a.every((x: string) => x === "viewer" || x === "tree" || x === "locks")) return a; } catch {}
@ -482,8 +485,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> {
@ -639,26 +646,37 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
launch: () => { void launchUE(); }, launch: () => { void launchUE(); },
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000), 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); 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;
(async () => { (async () => {
// auto-install official plugins (e.g. Unreal) on first run so no one loses tooling // No auto-install: only load what the user chose to install. New plugins are
try { // browsed and installed by hand from Settings → Plugins (the registry catalog).
const installed = new Set((await p4.pluginList()).map((p) => p.id));
const added = await autoInstallOfficial(installed);
if (added.length) console.log("[plugins] auto-installed:", added);
} catch { /* offline — skip */ }
const { loaded, errors } = await reloadPlugins(); const { loaded, errors } = await reloadPlugins();
if (errors.length) console.warn("[plugins] load errors:", errors); if (errors.length) console.warn("[plugins] load errors:", errors);
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded })); if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
})().catch(() => {}); })().catch(() => {});
}, [info?.clientName]); // eslint-disable-line }, [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)), []); useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call // live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => { useEffect(() => {
@ -781,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
checkBehind(); // a new submit landed → am I now behind? 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 if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
} }
}; };
@ -946,7 +967,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)
@ -970,7 +991,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() {
@ -1030,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 }); } 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 */ }
} }
@ -1713,11 +1738,32 @@ 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 className="stlog" onClick={() => window.dispatchEvent(new Event("exd-check-update"))} title={t("Check for updates")}> <button
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg> className={"stlog stupd icon" + (upd.phase === "available" ? " has" : "") + (upd.phase === "downloading" || upd.phase === "checking" ? " busy" : "")}
<span>{t("Updates")}</span> onClick={() => (upd.phase === "available" ? upd.install() : upd.check())}
disabled={upd.phase === "downloading"}
title={
upd.phase === "available" ? t("Update available — click to install")
: upd.phase === "downloading" ? t("Downloading… {pct}%", { pct: upd.pct })
: upd.phase === "done" ? t("Installed — restarting…")
: t("Check for updates")
}>
{upd.phase === "checking" || upd.phase === "downloading"
? <span className="ldr sm" />
: <svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>}
{upd.phase === "available" && <span className="stupd-dot" />}
</button> </button>
{peek && logs.length > 0 && ( {peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}> <div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
@ -1764,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />} {showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />} {buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(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)} />} {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)} />} {ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>} {toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
@ -2366,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 ---------------- */ /* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) { function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
const btn = useRef<HTMLButtonElement>(null); const btn = useRef<HTMLButtonElement>(null);
@ -3275,9 +3384,12 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
const [busy, setBusy] = useState(true); const [busy, setBusy] = useState(true);
const [dir, setDir] = useState(""); const [dir, setDir] = useState("");
const [reg, setReg] = useState<RegistryEntry[]>([]); const [reg, setReg] = useState<RegistryEntry[]>([]);
const [regBusy, setRegBusy] = useState(true);
const [regErr, setRegErr] = useState("");
const [installing, setInstalling] = useState<string>(""); const [installing, setInstalling] = useState<string>("");
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); }; const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
useEffect(() => { load(); p4.pluginsDirPath().then(setDir).catch(() => {}); fetchRegistry().then(setReg).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line const loadReg = () => { setRegBusy(true); setRegErr(""); fetchRegistry().then(setReg).catch((e) => setRegErr(String(e))).finally(() => setRegBusy(false)); };
useEffect(() => { load(); loadReg(); p4.pluginsDirPath().then(setDir).catch(() => {}); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose?.(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
async function installReg(entry: RegistryEntry) { async function installReg(entry: RegistryEntry) {
setInstalling(entry.id); setInstalling(entry.id);
try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); } try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
@ -3300,7 +3412,7 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
} catch (e) { onFlash(String(e), true); } } catch (e) { onFlash(String(e), true); }
} }
const body = (<> const body = (<>
<div className="tm-hint">{t("Plugins add features on top of the core app. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.")}</div> <div className="tm-hint">{t("Plugins add features on top of the core app. Browse the catalog below and install what you need — nothing is installed automatically. Toggle plugins on/off; enabled ones load on launch. Plugins run in-app — only install ones you trust.")}</div>
<div className="pl-list"> <div className="pl-list">
{busy ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div> {busy ? <div className="ur-empty"><span className="ldr" /> {t("Loading…")}</div>
: list.length === 0 ? <div className="ur-empty">{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}</div> : list.length === 0 ? <div className="ur-empty">{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}</div>
@ -3320,21 +3432,23 @@ function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () =
</div> </div>
</div> </div>
))} ))}
{reg.filter((e) => !list.some((p) => p.id === e.id)).length > 0 && (<> <div className="pl-official-h">{t("Available to install")}<span className="pl-cat-sub">{t("from the Exbyte plugin registry")}</span></div>
<div className="pl-official-h">{t("Official plugins")}</div> {regBusy ? <div className="ur-empty"><span className="ldr" /> {t("Loading the plugin catalog…")}</div>
{reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => ( : regErr ? <div className="ur-empty">{I.hex}{t("Couldn't reach the plugin registry.")}<button className="mbtn ghost" style={{ flex: "none", padding: "6px 12px", marginTop: 8 }} onClick={loadReg}>{I.sync}{t("Retry")}</button></div>
<div key={e.id} className="pl-card"> : reg.filter((e) => !list.some((p) => p.id === e.id)).length === 0
<div className="pl-main"> ? <div className="ur-empty">{I.check}{t("Everything from the catalog is already installed.")}</div>
<div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div> : reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
{e.description && <div className="pl-desc">{e.description}</div>} <div key={e.id} className="pl-card">
<div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div> <div className="pl-main">
<div className="pl-top"><b>{e.name}</b><span className="pl-ver">v{e.version}</span></div>
{e.description && <div className="pl-desc">{e.description}</div>}
<div className="pl-meta">{e.author && <span className="pl-tag">{e.author}</span>}{(e.permissions || []).map((perm) => <span key={perm} className="pl-perm">{perm}</span>)}</div>
</div>
<div className="pl-actions">
<button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
</div>
</div> </div>
<div className="pl-actions"> ))}
<button className="mbtn primary" style={{ flex: "none", padding: "7px 13px", gap: 6 }} disabled={installing === e.id} onClick={() => installReg(e)}>{installing === e.id ? <span className="ldr sm" /> : I.down}{t("Install")}</button>
</div>
</div>
))}
</>)}
</div> </div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}> <div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button> <button className="mbtn ghost" onClick={() => p4.openPluginsDir().catch(() => {})} title={dir}>{I.reveal}{t("Open plugins folder")}</button>

View File

@ -1,53 +1,44 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater"; import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { t } from "./i18n";
type Phase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error"; export type UpdPhase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error";
// Update prompt. Quietly checks the Gitea Releases endpoint a few seconds after export interface UpdaterState {
// launch, and again on demand when the "Updates" button fires `exd-check-update`. phase: UpdPhase;
// If a newer signed version exists, slides a small card into the bottom-right. pct: number;
export default function UpdateBanner() { err: string;
version?: string;
check: () => void; // manual check
install: () => void; // download + install the pending update
}
// Update logic as a hook. Silently checks the Gitea Releases endpoint a few
// seconds after mount, and again on demand (manual). The UI is just a single
// download icon in the status bar (see Workbench) — no floating card.
export function useUpdater(): UpdaterState {
const [update, setUpdate] = useState<Update | null>(null); const [update, setUpdate] = useState<Update | null>(null);
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<UpdPhase>("idle");
const [pct, setPct] = useState(0); const [pct, setPct] = useState(0);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [hidden, setHidden] = useState(false);
const busyRef = useRef(false); const busyRef = useRef(false);
// shared check routine. `manual` shows "checking" / "up to date" feedback.
async function runCheck(manual: boolean) { async function runCheck(manual: boolean) {
if (busyRef.current || phase === "downloading") return; if (busyRef.current || phase === "downloading") return;
busyRef.current = true; busyRef.current = true;
if (manual) { setHidden(false); setPhase("checking"); } if (manual) setPhase("checking");
try { try {
const u = await check(); const u = await check();
if (u) { setUpdate(u); setPhase("available"); setHidden(false); } if (u) { setUpdate(u); setPhase("available"); }
else if (manual) { setPhase("uptodate"); setHidden(false); } else { setPhase(manual ? "uptodate" : "idle"); }
else setPhase("idle");
} catch (e) { } catch (e) {
if (manual) { setErr(String(e)); setPhase("error"); setHidden(false); } setErr(String(e));
if (manual) setPhase("error");
} finally { busyRef.current = false; } } finally { busyRef.current = false; }
} }
// initial silent check + listen for manual "Updates" clicks
useEffect(() => {
const id = setTimeout(() => runCheck(false), 4000);
const onManual = () => runCheck(true);
window.addEventListener("exd-check-update", onManual);
return () => { clearTimeout(id); window.removeEventListener("exd-check-update", onManual); };
}, []); // eslint-disable-line
// auto-dismiss the transient "up to date" card
useEffect(() => {
if (phase !== "uptodate") return;
const id = setTimeout(() => { setHidden(true); setPhase("idle"); }, 3500);
return () => clearTimeout(id);
}, [phase]);
async function install() { async function install() {
if (!update) return; if (!update) { void runCheck(true); return; }
setPhase("downloading"); setPct(0); setPhase("downloading"); setPct(0);
let total = 0, got = 0; let total = 0, got = 0;
try { try {
@ -61,59 +52,20 @@ export default function UpdateBanner() {
} catch (e) { setErr(String(e)); setPhase("error"); } } catch (e) { setErr(String(e)); setPhase("error"); }
} }
if (hidden || phase === "idle") return null; // initial silent check + listen for manual "Updates" clicks (kept for compatibility)
useEffect(() => {
const id = setTimeout(() => runCheck(false), 4000);
const onManual = () => runCheck(true);
window.addEventListener("exd-check-update", onManual);
return () => { clearTimeout(id); window.removeEventListener("exd-check-update", onManual); };
}, []); // eslint-disable-line
// compact cards for the transient states (no `update` object yet) // let the transient "checked, up to date" / "error" state fade back to idle
if (phase === "checking") { useEffect(() => {
return <div className="upd sm"><div className="upd-head"><span className="upd-ic"><span className="ldr sm" /></span><div className="upd-ttl"><b>{t("Checking for updates…")}</b></div></div></div>; if (phase !== "uptodate" && phase !== "error") return;
} const id = setTimeout(() => setPhase("idle"), 3500);
if (phase === "uptodate") { return () => clearTimeout(id);
return <div className="upd sm ok" onClick={() => { setHidden(true); setPhase("idle"); }}> }, [phase]);
<div className="upd-head"><span className="upd-ic ok"><svg viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<div className="upd-ttl"><b>{t("You're on the latest version")}</b></div></div>
</div>;
}
if (phase === "error") {
return <div className="upd sm"><div className="upd-head"><span className="upd-ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 8v5m0 3h.01M10.3 4l-7 12A1.5 1.5 0 0 0 4.6 18h14.8a1.5 1.5 0 0 0 1.3-2L13.7 4a1.5 1.5 0 0 0-3.4 0Z" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
<div className="upd-ttl"><b>{t("Update check failed")}</b><span>{err.slice(0, 80)}</span></div>
<button className="upd-x" onClick={() => { setHidden(true); setPhase("idle"); }}><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button></div>
</div>;
}
if (!update) return null;
return ( return { phase, pct, err, version: update?.version, check: () => runCheck(true), install };
<div className="upd">
<div className="upd-head">
<span className="upd-ic">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
<div className="upd-ttl">
<b>{t("Update available")}</b>
<span>Exbyte Depot v{update.version}</span>
</div>
{phase === "available" && (
<button className="upd-x" onClick={() => setHidden(true)} title={t("Later")}>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
)}
</div>
{update.body?.trim() && phase === "available" && <div className="upd-notes">{update.body.trim()}</div>}
{phase === "downloading" && (
<div className="upd-prog">
<div className="upd-track"><div className="upd-fill" style={{ width: `${pct}%` }} /></div>
<span>{t("Downloading… {pct}%", { pct })}</span>
</div>
)}
{phase === "done" && <div className="upd-notes">{t("Installed — restarting…")}</div>}
{phase === "available" && (
<div className="upd-actions">
<button className="upd-later" onClick={() => setHidden(true)}>{t("Later")}</button>
<button className="upd-go" onClick={install}>{t("Update now")}</button>
</div>
)}
</div>
);
} }

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" },
@ -180,6 +181,7 @@ const D: Record<string, Tr> = {
"Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" }, "Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" },
"General": { ru: "Общие", de: "Allgemein", fr: "Général", es: "General" }, "General": { ru: "Общие", de: "Allgemein", fr: "Général", es: "General" },
"Check for updates": { ru: "Проверить обновления", de: "Nach Updates suchen", fr: "Vérifier les mises à jour", es: "Buscar actualizaciones" }, "Check for updates": { ru: "Проверить обновления", de: "Nach Updates suchen", fr: "Vérifier les mises à jour", es: "Buscar actualizaciones" },
"Update available — click to install": { ru: "Есть обновление — нажми, чтобы установить", de: "Update verfügbar — zum Installieren klicken", fr: "Mise à jour dispo — cliquez pour installer", es: "Actualización disponible — clic para instalar" },
"Updates": { ru: "Обновления", de: "Updates", fr: "Mises à jour", es: "Actualizaciones" }, "Updates": { ru: "Обновления", de: "Updates", fr: "Mises à jour", es: "Actualizaciones" },
"Checking for updates…": { ru: "Проверяю обновления…", de: "Suche nach Updates…", fr: "Recherche de mises à jour…", es: "Buscando actualizaciones…" }, "Checking for updates…": { ru: "Проверяю обновления…", de: "Suche nach Updates…", fr: "Recherche de mises à jour…", es: "Buscando actualizaciones…" },
"You're on the latest version": { ru: "У тебя последняя версия", de: "Du hast die neueste Version", fr: "Vous avez la dernière version", es: "Tienes la última versión" }, "You're on the latest version": { ru: "У тебя последняя версия", de: "Du hast die neueste Version", fr: "Vous avez la dernière version", es: "Tienes la última versión" },
@ -187,11 +189,17 @@ const D: Record<string, Tr> = {
"Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" }, "Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" },
"Base theme": { ru: "Базовая тема", de: "Basis-Theme", fr: "Thème de base", es: "Tema base" }, "Base theme": { ru: "Базовая тема", de: "Basis-Theme", fr: "Thème de base", es: "Tema base" },
"{n} installed": { ru: "установлено: {n}", de: "{n} installiert", fr: "{n} installés", es: "{n} instalados" }, "{n} installed": { ru: "установлено: {n}", de: "{n} installiert", fr: "{n} installés", es: "{n} instalados" },
"Plugins add features on top of the core app. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Установи папку с plugin.json, включай/выключай — они грузятся автоматически. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins fügen Funktionen zur Kern-App hinzu. Installiere einen Ordner mit plugin.json, schalte Plugins ein/aus — sie laden automatisch. Plugins laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions ajoutent des fonctions au cœur de lapp. Installez un dossier avec plugin.json, activez/désactivez — elles se chargent automatiquement. Elles sexécutent dans lapp — ninstallez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Instala una carpeta con plugin.json, actívalos/desactívalos y se cargan solos. Se ejecutan en la app — instala solo los de confianza." }, "Plugins add features on top of the core app. Browse the catalog below and install what you need — nothing is installed automatically. Toggle plugins on/off; enabled ones load on launch. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Смотри каталог ниже и ставь нужное — ничего не ставится само. Включай/выключай; включённые грузятся при запуске. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins erweitern die Kern-App. Durchsuche den Katalog unten und installiere, was du brauchst — nichts wird automatisch installiert. Plugins ein/aus schalten; aktivierte laden beim Start. Sie laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions enrichissent le cœur de lapp. Parcourez le catalogue ci-dessous et installez ce quil faut — rien nest installé automatiquement. Activez/désactivez ; les activées se chargent au démarrage. Elles sexécutent dans lapp — ninstallez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Explora el catálogo abajo e instala lo que necesites — nada se instala solo. Actívalos/desactívalos; los activos se cargan al iniciar. Se ejecutan en la app — instala solo los de confianza." },
"No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." }, "No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." },
"Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" }, "Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" },
"Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" }, "Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" },
"Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" }, "Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" },
"Available to install": { ru: "Доступно для установки", de: "Verfügbar zur Installation", fr: "Disponibles à linstallation", es: "Disponibles para instalar" },
"from the Exbyte plugin registry": { ru: "из реестра плагинов Exbyte", de: "aus der Exbyte-Plugin-Registry", fr: "depuis le registre de plugins Exbyte", es: "del registro de plugins de Exbyte" },
"Loading the plugin catalog…": { ru: "Загружаю каталог плагинов…", de: "Plugin-Katalog wird geladen…", fr: "Chargement du catalogue…", es: "Cargando el catálogo…" },
"Couldn't reach the plugin registry.": { ru: "Не удалось получить реестр плагинов.", de: "Plugin-Registry nicht erreichbar.", fr: "Registre de plugins injoignable.", es: "No se pudo acceder al registro." },
"Retry": { ru: "Повторить", de: "Erneut", fr: "Réessayer", es: "Reintentar" },
"Everything from the catalog is already installed.": { ru: "Всё из каталога уже установлено.", de: "Alles aus dem Katalog ist bereits installiert.", fr: "Tout le catalogue est déjà installé.", es: "Todo el catálogo ya está instalado." },
"Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" }, "Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" },
"Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" }, "Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" },
"Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" }, "Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" },
@ -439,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." }, "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}" }, "{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." }, "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" }, "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)." }, "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" }, "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. // 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 { export interface P4Info {
serverAddress?: string; serverAddress?: string;
@ -113,6 +139,15 @@ export const p4 = {
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }), pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }), 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 }), 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 }), pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }), pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
pluginsDirPath: () => invoke<string>("plugins_dir_path"), pluginsDirPath: () => invoke<string>("plugins_dir_path"),
@ -297,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" }); 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();

View File

@ -42,6 +42,21 @@ export interface UeBridge {
readLog: (tailBytes?: number) => Promise<string>; 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) ------------------------------------- // ---- host context (provided by the app) -------------------------------------
export interface HostContext { export interface HostContext {
flash: (text: string, err?: boolean) => void; flash: (text: string, err?: boolean) => void;
@ -50,6 +65,7 @@ export interface HostContext {
selectedFile: () => { depotFile?: string } | null; selectedFile: () => { depotFile?: string } | null;
refresh: () => void; refresh: () => void;
ue?: UeBridge; ue?: UeBridge;
team?: TeamBridge;
} }
// ---- the Host SDK a plugin receives ----------------------------------------- // ---- the Host SDK a plugin receives -----------------------------------------
@ -75,6 +91,7 @@ export interface HostApi {
selectedFile: () => { depotFile?: string } | null; selectedFile: () => { depotFile?: string } | null;
refresh: () => void; refresh: () => void;
ue?: UeBridge; // present only with the "ue" permission ue?: UeBridge; // present only with the "ue" permission
team?: TeamBridge; // present only with the "team" permission
theme: () => Record<string, string>; theme: () => Record<string, string>;
t: typeof t; t: typeof t;
log: (...a: unknown[]) => void; log: (...a: unknown[]) => void;
@ -125,6 +142,7 @@ function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
log: (...a) => console.log(`[plugin:${info.id}]`, ...a), log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
}; };
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated 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; return host;
} }
@ -179,19 +197,19 @@ export interface RegistryEntry {
files: string[]; baseUrl: string; files: string[]; baseUrl: string;
} }
// All registry HTTP goes through the Rust backend (registryHttpGet): the webview
// `fetch()` is blocked by CORS because Gitea's raw endpoint sends no ACAO header.
export async function fetchRegistry(): Promise<RegistryEntry[]> { export async function fetchRegistry(): Promise<RegistryEntry[]> {
const r = await fetch(REGISTRY_URL, { cache: "no-store" }); const txt = await p4.registryHttpGet(REGISTRY_URL);
if (!r.ok) throw new Error(`registry HTTP ${r.status}`); const j = JSON.parse(txt);
const j = await r.json();
return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : []; return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : [];
} }
/** Download a registry plugin's files over HTTPS and write them into the plugins folder. */ /** Download a registry plugin's files (backend HTTPS) and write them into the plugins folder. */
export async function installFromRegistry(entry: RegistryEntry): Promise<void> { export async function installFromRegistry(entry: RegistryEntry): Promise<void> {
for (const f of entry.files) { for (const f of entry.files) {
const res = await fetch(entry.baseUrl + f, { cache: "no-store" }); const text = await p4.registryHttpGet(entry.baseUrl + f);
if (!res.ok) throw new Error(`${f}: HTTP ${res.status}`); await p4.pluginWriteFile(entry.id, f, text);
await p4.pluginWriteFile(entry.id, f, await res.text());
} }
} }