Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597dffe92d | |||
| 30e77664d5 | |||
| 23fabe11ec | |||
| 37f5c504f7 | |||
| 20c94ef9f0 | |||
| dc88269baf | |||
| 9fc81015d7 | |||
| 294d10eb40 | |||
| 8e7908ae09 |
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
|
||||
```
|
||||
|
||||
## Theme tokens
|
||||
A plugin's DOM inherits the app's CSS variables — use them so the panel follows the
|
||||
active theme. The exact names (a wrong name silently falls back to your hardcoded
|
||||
default, which then breaks on the opposite theme):
|
||||
|
||||
`--accent` `--accent-2` `--accent-deep` · `--bg` `--panel` `--panel-2` `--panel-3`
|
||||
`--elevated` `--border` · `--txt` `--muted` `--faint` · `--add` (success) `--edit`
|
||||
(warning) `--del` (danger) · `--mono` (font)
|
||||
|
||||
There is no `--text`, `--ok`, `--err`, or `--sans`.
|
||||
|
||||
## Contribution points
|
||||
- **Menu items** and **views** appear in the **Actions** menu.
|
||||
- **File context items** appear when you right-click a file in Changes.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"] }
|
||||
}
|
||||
51
plugins-examples/unreal/index.js
Normal file
51
plugins-examples/unreal/index.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Unreal Engine Tools — official plugin.
|
||||
// The core app keeps the build engine (UnrealBuildTool / MSBuild), the build
|
||||
// overlay and the log reader; this plugin is just the Unreal-facing surface.
|
||||
// It renders natively: the actions sit in the working-folder right-click menu
|
||||
// with the host's Unreal + hammer icons, and the log is a bottom-dock tab —
|
||||
// exactly where these lived before, just delivered as a plugin. Reached through
|
||||
// the permission-gated `host.ue` bridge.
|
||||
|
||||
export function activate(host) {
|
||||
const ue = host.ue;
|
||||
if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; }
|
||||
|
||||
// Right-click the working folder → Launch / Build, shown only on a UE project.
|
||||
// Prefer the native folder-context slot (host ≥ 0.3.7); fall back to the top
|
||||
// menu on older hosts so the actions are always reachable.
|
||||
const addAction = (label, run, icon) => {
|
||||
if (host.ui.addFolderContextItem) host.ui.addFolderContextItem(label, run, { icon, visible: () => ue.hasProject() });
|
||||
else host.ui.addMenuItem(label, () => { if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } run(); }, { icon });
|
||||
};
|
||||
addAction("Launch Unreal Engine", () => ue.launch(), "ue");
|
||||
addAction("Build Solution (.sln)", () => ue.build(), "hammer");
|
||||
|
||||
// Bottom-dock "Unreal" tab — tails the editor log (Saved/Logs), refreshing live.
|
||||
// Native dock panel on host ≥ 0.3.7; a modal view as a fallback on older hosts.
|
||||
const mountLog = (el) => {
|
||||
el.style.cssText = "display:flex;flex-direction:column;height:100%";
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.cssText =
|
||||
"flex:1;margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap;word-break:break-word;" +
|
||||
"font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)";
|
||||
el.appendChild(pre);
|
||||
let alive = true;
|
||||
async function tick() {
|
||||
if (!alive) return;
|
||||
try {
|
||||
if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; }
|
||||
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
|
||||
const txt = await ue.readLog(200000);
|
||||
pre.textContent = txt || "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.";
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
} catch (e) { pre.textContent = String(e); }
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1500);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
};
|
||||
if (host.ui.addDockPanel) host.ui.addDockPanel({ id: "unreal", title: "Unreal", icon: "ue", render: mountLog });
|
||||
else host.ui.addView({ id: "unreal-log", title: "Unreal Log", render: mountLog });
|
||||
|
||||
host.log("Unreal Engine Tools activated");
|
||||
}
|
||||
12
plugins-examples/unreal/plugin.json
Normal file
12
plugins-examples/unreal/plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "unreal",
|
||||
"name": "Unreal Engine Tools",
|
||||
"version": "1.2.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Build the game (.sln / UnrealBuildTool), launch the editor, and tail the Unreal log. Actions live in the working-folder right-click menu; the log is a bottom-dock tab. Enable on Unreal Engine projects.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.6",
|
||||
"permissions": ["ue"],
|
||||
"defaultEnabled": true,
|
||||
"contributes": { "folderContext": true, "dock": ["unreal"] }
|
||||
}
|
||||
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"
|
||||
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]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@ -558,6 +575,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@ -966,9 +992,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.5"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -1279,8 +1306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1302,8 +1331,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1572,6 +1604,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2042,6 +2075,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
@ -2784,6 +2823,62 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@ -2812,7 +2907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"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]]
|
||||
@ -2822,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2834,6 +2940,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@ -2909,6 +3030,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@ -3046,6 +3205,7 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@ -3093,6 +3253,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -3315,6 +3481,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@ -3385,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3709,7 +3887,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3858,7 +4036,7 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3917,7 +4095,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@ -4692,6 +4870,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@ -4757,6 +4945,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.5"
|
||||
version = "0.4.2"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
@ -26,6 +26,8 @@ serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
# decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths
|
||||
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)
|
||||
[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"])
|
||||
}
|
||||
|
||||
/// Shelve everything the user has open in their DEFAULT changelist to the server
|
||||
/// in one shot: create a numbered pending changelist, move the open files into
|
||||
/// it, and `shelve` it. Returns `{ change, files }`. This is the primitive the
|
||||
/// team-relay plugin drives so a coordinator can push a teammate's local work to
|
||||
/// the server (as a shelf — nothing is submitted). The teammate's client runs
|
||||
/// this; it uses ordinary client-side p4 commands and never touches the server
|
||||
/// config. Files already parked in other numbered changelists are left alone.
|
||||
#[tauri::command]
|
||||
async fn p4_shelve_open(state: State<'_, AppState>, description: String) -> Result<Value, String> {
|
||||
let conn = current(&state)?;
|
||||
let opened = run_json(&conn, &["opened", "-c", "default"])?;
|
||||
let files: Vec<String> = opened
|
||||
.iter()
|
||||
.filter_map(|v| v.get("depotFile").and_then(|d| d.as_str()).map(String::from))
|
||||
.collect();
|
||||
if files.is_empty() {
|
||||
return Err("No open files in the default changelist".into());
|
||||
}
|
||||
let desc = if description.trim().is_empty() {
|
||||
"Remote shelve (Team Relay)".to_string()
|
||||
} else {
|
||||
description
|
||||
};
|
||||
let cl = create_changelist(&conn, &desc)?;
|
||||
// Move the open files into the new changelist, batched so a huge default
|
||||
// changelist doesn't overflow the Windows command line.
|
||||
let prefix_len = "reopen -c ".len() + cl.len();
|
||||
for r in file_batches(&files, prefix_len) {
|
||||
let mut args: Vec<&str> = vec!["reopen", "-c", &cl];
|
||||
for f in &files[r] {
|
||||
args.push(f.as_str());
|
||||
}
|
||||
run_text(&conn, &args)?;
|
||||
}
|
||||
run_text(&conn, &["shelve", "-c", &cl])?;
|
||||
Ok(serde_json::json!({ "change": cl, "files": files.len() }))
|
||||
}
|
||||
|
||||
/// The caller's maximum protection level on this server (`p4 protects -m`),
|
||||
/// e.g. "super" / "admin" / "write". Read-only; used to gate coordinator-only
|
||||
/// actions in the UI (the server still enforces the real permission).
|
||||
#[tauri::command]
|
||||
async fn p4_protects_max(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let conn = current(&state)?;
|
||||
run_text(&conn, &["protects", "-m"]).map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Delete the shelved files of a changelist.
|
||||
#[tauri::command]
|
||||
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
|
||||
@ -2923,7 +2970,10 @@ async fn plugin_list() -> Result<Vec<Value>, String> {
|
||||
let Ok(m) = serde_json::from_str::<Value>(&txt) else { continue };
|
||||
let id = m.get("id").and_then(|v| v.as_str()).map(String::from)
|
||||
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
|
||||
let enabled = *en.get(&id).unwrap_or(&true);
|
||||
// off by default unless the manifest opts in (defaultEnabled) — official
|
||||
// plugins like Unreal ship on; the user's choice persists in enabled.json
|
||||
let default_on = m.get("defaultEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let enabled = *en.get(&id).unwrap_or(&default_on);
|
||||
out.push(serde_json::json!({
|
||||
"id": id,
|
||||
"name": m.get("name").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
@ -2967,6 +3017,84 @@ async fn plugin_set_enabled(id: String, enabled: bool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write one file of a plugin into <config>/plugins/<id>/<name>. The frontend
|
||||
/// fetches the file bytes from the registry over HTTPS and hands the content here;
|
||||
/// this only touches the confined plugins folder (id + name are path-validated).
|
||||
#[tauri::command]
|
||||
async fn plugin_write_file(id: String, name: String, content: String) -> Result<(), String> {
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') || id.contains("..") {
|
||||
return Err("Invalid plugin id".into());
|
||||
}
|
||||
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err("Invalid file name".into());
|
||||
}
|
||||
let dir = plugins_dir().ok_or("No config dir")?.join(&id);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
std::fs::write(dir.join(&name), content).map_err(|e| e.to_string())?;
|
||||
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<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
for e in std::fs::read_dir(from)? {
|
||||
@ -3125,6 +3253,11 @@ pub fn run() {
|
||||
plugin_list,
|
||||
plugin_read_entry,
|
||||
plugin_set_enabled,
|
||||
plugin_write_file,
|
||||
registry_http_get,
|
||||
relay_http,
|
||||
p4_shelve_open,
|
||||
p4_protects_max,
|
||||
plugin_install,
|
||||
plugin_remove,
|
||||
plugins_dir_path,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.2",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -24,8 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
"csp": "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
38
src/App.css
38
src/App.css
@ -94,7 +94,11 @@ input,textarea,[contenteditable="true"],
|
||||
.tgroup{display:flex;min-width:0;position:relative}
|
||||
.tgroup .tzone{flex:1;min-width:0}
|
||||
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
|
||||
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
|
||||
.tzone:last-child{border-right:none}
|
||||
.tzone:hover{background:var(--hover)}
|
||||
/* keep the labels/icon high-contrast on hover in every theme (fixes greyed-out text) */
|
||||
.tzone:hover .val{color:var(--txt)}.tzone:hover .lbl,.tzone:hover .sub{color:var(--muted)}
|
||||
.sync:hover .ic{color:var(--accent-2)}.sync:hover .val{color:var(--txt)}.sync:hover .sub{color:var(--muted)}
|
||||
|
||||
/* draggable panel dividers */
|
||||
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
|
||||
@ -120,9 +124,11 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .2s;background:none;border:none}
|
||||
.tab:hover{color:var(--txt)}.tab.on{color:var(--txt)}
|
||||
.tab:focus{outline:none}
|
||||
.tab:focus-visible{outline:none;box-shadow:inset 0 0 0 2px rgba(124,110,246,.35)}
|
||||
.tab:active{transform:scale(.97)}
|
||||
/* sliding underline that glides between the two tabs */
|
||||
.tab-slider{position:absolute;bottom:-1px;height:2px;width:calc(50% - 32px);border-radius:2px 2px 0 0;pointer-events:none;
|
||||
.tab-slider{position:absolute;bottom:0;height:2px;width:calc(50% - 32px);border-radius:2px;pointer-events:none;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));box-shadow:0 0 10px rgba(124,110,246,.6);
|
||||
transition:left .34s cubic-bezier(.5,.02,.15,1)}
|
||||
/* content fades + rises slightly when the tab changes */
|
||||
@ -276,6 +282,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.upd-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 18px rgba(124,110,246,.5)}
|
||||
.upd-ic svg{width:18px;height:18px}
|
||||
.upd.sm{width:auto;max-width:340px;padding:11px 14px}
|
||||
.upd.sm .upd-ic{width:28px;height:28px}
|
||||
.upd.sm.ok{cursor:pointer}
|
||||
.upd-ic.ok{background:linear-gradient(145deg,var(--add),#1f9f6e);box-shadow:0 0 16px rgba(62,207,142,.45)}
|
||||
.upd-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.upd-ttl b{font-size:13.5px}
|
||||
.upd-ttl span{font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
@ -364,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}
|
||||
.stlog:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.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;
|
||||
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);
|
||||
@ -721,6 +737,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0}
|
||||
/* plugins manager */
|
||||
.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);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.off{opacity:.55}
|
||||
.pl-main{flex:1;min-width:0}
|
||||
@ -785,7 +803,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* history */
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft);position:relative}
|
||||
.hrow.click{cursor:pointer}
|
||||
.hrow:hover{background:var(--hover)}
|
||||
.hrow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
@ -846,7 +864,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.hmeta{font-size:11px;color:var(--faint)}
|
||||
.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.hnew{display:inline-block;vertical-align:middle;margin-right:7px;padding:1px 7px;border-radius:6px;font-size:9.5px;font-weight:800;letter-spacing:.6px;color:#fff;
|
||||
.hnew{position:absolute;top:50%;right:14px;transform:translateY(-50%);padding:3px 10px;border-radius:7px;font-size:11px;font-weight:800;letter-spacing:.7px;color:#fff;pointer-events:none;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 0 rgba(124,110,246,.5);animation:newpulse 2.2s ease-in-out infinite}
|
||||
@keyframes newpulse{0%,100%{box-shadow:0 0 0 0 rgba(124,110,246,.45)}50%{box-shadow:0 0 0 4px rgba(124,110,246,0)}}
|
||||
|
||||
@ -929,11 +947,17 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.dropinner svg{width:44px;height:44px}
|
||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||
|
||||
/* re-auth: who we're signing back in as */
|
||||
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
|
||||
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
|
||||
|
||||
/* toast */
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center;
|
||||
max-height:30vh;overflow-y:auto;overflow-wrap:anywhere}
|
||||
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
|
||||
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
||||
|
||||
@ -1110,7 +1134,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
|
||||
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
|
||||
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;white-space:pre-line}
|
||||
.ur-empty svg{width:34px;height:34px;opacity:.35;flex:0 0 auto}
|
||||
.viewer-msg svg{width:36px;height:36px;opacity:.4;flex:0 0 auto}
|
||||
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
|
||||
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
|
||||
.ur-dinfo{min-width:0}
|
||||
|
||||
296
src/App.tsx
296
src/App.tsx
@ -7,12 +7,12 @@ import { isPermissionGranted, requestPermission, sendNotification } from "@tauri
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import ModelViewer from "./ModelViewer";
|
||||
import CodeView from "./CodeView";
|
||||
import UpdateBanner from "./Updater";
|
||||
import { useUpdater } from "./Updater";
|
||||
import "./App.css";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, isAuthError, onAuthLost, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
|
||||
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
|
||||
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
|
||||
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView } 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";
|
||||
|
||||
// native OS notification (works even when the window is hidden in the tray)
|
||||
@ -82,6 +82,13 @@ const I = {
|
||||
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
// Resolve a plugin-declared icon NAME to a real SVG from the host set, so plugin
|
||||
// contributions render with native icons instead of a generic glyph.
|
||||
function pluginIcon(name?: string): ReactNode {
|
||||
if (name && Object.prototype.hasOwnProperty.call(I, name)) return (I as Record<string, ReactNode>)[name];
|
||||
return I.hex;
|
||||
}
|
||||
|
||||
/* ---------------- theme hook (built-in + custom themes) ---------------- */
|
||||
function useTheme(): [boolean, () => void, string, (id: string) => void] {
|
||||
const [themeId, setThemeId] = useState(() => loadActiveId());
|
||||
@ -144,7 +151,7 @@ export default function App() {
|
||||
onInfo={setInfo} onSession={(s) => { setSession(s); saveSession(s); }}
|
||||
onDisconnect={() => { p4.disconnect(); clearSession(); setInfo(null); setSession(null); setPhase("connect"); }} />;
|
||||
|
||||
return <>{content}<UpdateBanner /></>;
|
||||
return content;
|
||||
}
|
||||
|
||||
/* ---------------- custom styled dropdown (replaces the ugly native <select>) ---------------- */
|
||||
@ -378,6 +385,8 @@ function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lan
|
||||
const [toast, setToast] = useState<{ text: string; err?: boolean } | null>(null);
|
||||
const [history, setHistory] = useState<Change[]>([]);
|
||||
const [changeSizes, setChangeSizes] = useState<Record<string, number>>({}); // per-changelist byte weight for History
|
||||
const sizesRef = useRef<Record<string, number>>({}); // mirror of changeSizes so we only fetch new changes (no flicker)
|
||||
const histSizeScope = useRef<string>(" | ||||