Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597dffe92d | |||
| 30e77664d5 | |||
| 23fabe11ec | |||
| 37f5c504f7 | |||
| 20c94ef9f0 | |||
| dc88269baf | |||
| 9fc81015d7 |
11
PLUGINS.md
11
PLUGINS.md
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
243
plugins-examples/team-relay/index.js
Normal file
243
plugins-examples/team-relay/index.js
Normal 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 + ")");
|
||||||
|
}
|
||||||
12
plugins-examples/team-relay/plugin.json
Normal file
12
plugins-examples/team-relay/plugin.json
Normal 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
211
src-tauri/Cargo.lock
generated
@ -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"
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
17
src/App.css
17
src/App.css
@ -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)}}
|
||||||
|
|
||||||
|
|||||||
194
src/App.tsx
194
src/App.tsx
@ -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>
|
||||||
|
|||||||
118
src/Updater.tsx
118
src/Updater.tsx
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/i18n.ts
15
src/i18n.ts
@ -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 d’abord 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 d’abord 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 d’abord 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 l’app. Installez un dossier avec plugin.json, activez/désactivez — elles se chargent automatiquement. Elles s’exécutent dans l’app — n’installez 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 l’app. Parcourez le catalogue ci-dessous et installez ce qu’il faut — rien n’est installé automatiquement. Activez/désactivez ; les activées se chargent au démarrage. Elles s’exécutent dans l’app — n’installez 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 à l’installation", 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" },
|
||||||
|
|||||||
66
src/p4.ts
66
src/p4.ts
@ -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();
|
||||||
|
|||||||
@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user