9 Commits

Author SHA1 Message Date
23fabe11ec 0.4.1 — Unreal plugin actions, sync toast summary, plugin dock buttons
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:55:31 +03:00
37f5c504f7 Fix dead Unreal plugin actions, sync toast flood, Team Relay theming
- ue bridge: Launch / Build now read live refs. They captured startBuild /
  launchUE from the render where plugins loaded — before the async .uproject
  and .sln detection had resolved — so both saw empty paths forever. hasProject
  used the live ref, so the menu items showed up and then did nothing.
- Get Latest / Sync to revision: summarise `p4 sync` stdout (syncSummary)
  instead of dumping every depot path into the toast. flash() caps length and
  .toast caps height so no raw output can blow up the bubble again.
- Team Relay: use the real theme tokens — --txt / --add / --del, not
  --text / --ok / --err, which don't exist and silently fell back to a
  near-white hardcode, invisible on the light theme. Bumped to 1.0.1.
- Plugin dock panels get a status-bar button next to Terminal / Log; the tabs
  were otherwise only reachable from the Actions menu.
- PLUGINS.md documents the theme token names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:50:14 +03:00
20c94ef9f0 0.4.0 — Team Relay: presence + coordinator force-shelve over a generic relay
- host.team bridge + 'team' permission (SDK)
- backend: relay_http (generic HTTP pipe), p4_shelve_open, p4_protects_max
- new plugin team-relay (opt-in): Team dock, presence loop, force-shelve
- relay server (exbyte-relay/) — generic zero-dep message bus, no Perforce knowledge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:23:27 +03:00
dc88269baf 0.3.9 — fix plugin registry (backend HTTPS, CORS), fold updater into a status-bar icon
- Plugin catalog/install failed silently: the webview fetch() to Gitea raw is
  blocked by CORS (no ACAO header). Route registry reads through a new Rust
  command registry_http_get (host-restricted) — catalog + installs now work.
- Updater: removed the floating card that overlapped the Terminal/Log/Updates
  buttons. Update state now shows as a single status-bar download icon (no text):
  idle → download glyph, available → accent + dot, checking/downloading → spinner.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:46:04 +03:00
294d10eb40 0.3.7 — Unreal plugin renders native: right-click actions + dock tab, real icons
Plugin contributions gain named host icons, a working-folder context slot, and
bottom-dock panels. The Unreal plugin (1.2.0) now puts Launch / Build Solution
in the folder right-click menu with the UE + hammer icons and its log in a
bottom-dock "Unreal" tab — where they lived before extraction — instead of
emoji entries in Tools. Plugin is backward-compatible (falls back to menu/view
on 0.3.6 hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:31:49 +03:00
8e7908ae09 0.3.6 — plugin registry, Unreal extracted to a plugin, in-app update check + fixes
- Official plugin registry hosted on Gitea; client fetches registry.json over
  HTTPS and installs plugins via backend plugin_write_file.
- Auto-install autoInstall plugins on first connect; per-machine enabled state.
- Extract all Unreal tooling (Build Solution / Unreal Log) into a separate
  "unreal" plugin (defaultEnabled) driven through the new host.ue bridge;
  core UI no longer hard-wires Unreal.
- Settings → Plugins: "Official plugins" section with Install buttons.
- Status bar: "Updates" button (right of Log) triggers an update check without
  restarting the client.
- Fixes: NEW badge hard-right/centered without shifting text; History commit
  size no longer flickers (cached per change); giant icons in empty plugin/
  People lists; Get Latest hover contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:13:02 +03:00
f0fc2f687a v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations
- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor
  (per-token color pickers, live preview, JSON export/import). src/themes.ts.
- Plugin platform: JS/ESM plugins loaded via blob-module import (CSP
  script-src blob:), Host SDK with menu / file-context / view / submit-hook
  contribution points + permission model. Backend commands to list/read/enable/
  install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md.
- Settings redesigned into a sectioned hub (General / Appearance / Plugins);
  Themes and Plugins moved in, removed from the Actions menu.
- "NEW" badge on History commits submitted < 3h ago.
- Smooth tab transitions: sliding underline + content fade on Changes/History,
  and matching fades on the view-panel and dock tab groups.
- Fix: settings close button used the default browser style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:50:45 +03:00
4e4e0f215a 123 2026-07-08 23:35:13 +03:00
20 changed files with 2240 additions and 159 deletions

94
HotTODO.md Normal file
View File

@ -0,0 +1,94 @@
# 🔥 HotTODO — Exbyte Depot
Фичи «супер-удобства», которых **нет ни в P4V, ни в git** — заточены под UE-команду на бинарных ассетах. Ранжировано по «польза / усилие». Стек, на который опираемся: Tauri (Rust) backend + `p4` CLI, React фронт, встроенный OpenRouter (AI), UE-MCP (`mcp__ue-blueprint__*`), уже готовые тумбы `.uasset` / 3D-вьювер / медиа-превью / файл-локи / трей+уведомления / offline / Files & Sizes / View-дерево.
Статус: **план**, реализуем по мере выбора.
---
## 🧩 Foundation — система плагинов / аддонов
**Идея:** фичи выпускаются как **отдельные плагины**, которые докачиваются по необходимости и не засоряют основной софт. Ядро остаётся лёгким; тяжёлое/нишевое (в т.ч. фичи 18 ниже) живёт в плагинах. Это фундамент — стоит сделать рано, тогда остальное можно поставлять как плагины.
**Реалистичный дизайн под Tauri** (Rust-бэкенд скомпилирован, поэтому плагины — на фронте/сайдкаре, а не нативные .dll):
- **Тип плагина = JS/ESM-бандл** (± WASM для тяжёлого), грузится в webview в рантайме через **стабильный Host SDK**, который экспортит ядро:
- `p4.*` (whitelisted-команды), события (`p4-log`, `p4-transfer`, `p4-scan`…), `flash/notify`, чтение выбранного файла/CL, i18n, тема.
- **Contribution points** — куда плагин встраивается: пункт меню Actions, контекст-меню файла/чейнджлиста, вкладка view-панели (у нас уже есть File Viewer/Tree/Locks — тот же механизм), кнопка в тулбаре, шаг перед Submit (hook). Ядро уже построено на этих точках — надо просто открыть их как API.
- **Манифест** `plugin.json`: `name, version, entry, minAppVersion, permissions[], contributes{menus,panels,contextItems,submitHooks}`.
- **Модель разрешений:** плагин декларирует, что ему нужно (`p4:read`, `p4:write`, `fs:read`, `network:<host>`, `ui:panel`). Пользователь подтверждает при установке. JS-песочница: iframe/Web Worker с message-bridge, доступ к ядру только через Host SDK (не напрямую к `invoke`).
- **CSP:** у нас строгий CSP (внешнее запрещено), поэтому плагины **не грузятся с чужих URL** — их скачивают в локальную папку `plugins/` и подключают контролируемо; сетевой доступ плагину даётся только к явно разрешённым хостам.
- **Реестр/маркет:** индекс плагинов на Gitea (список манифестов) → скачивание **подписанного** бандла (у нас уже есть инфраструктура подписи minisign) → проверка подписи → распаковка в `plugins/`. UI «Плагины»: установлено / доступно / обновления / вкл-выкл, версии, разрешения.
- **Изоляция сбоев:** упавший плагин не роняет ядро (ловим ошибки на границе bridge), можно отключить.
- **Сайдкар-вариант** (опционально, для бэкенд-тяжёлых плагинов): плагин = отдельный исполняемый/скрипт, ядро дёргает его по stdio-JSON (как VS Code language servers). Полезно для парсеров `.uasset`, интеграций.
**Порядок реализации фундамента:**
1. Внутренний рефактор: вынести существующие contribution-точки (меню/контекст/view-табы/submit-hook) за стабильный интерфейс.
2. Host SDK + загрузчик локальных плагинов из `plugins/` + модель разрешений.
3. UI «Плагины» (вкл/выкл, разрешения).
4. Реестр на Gitea + скачивание/проверка подписи/обновления.
5. Перенести 12 фичи из списка ниже в плагины как эталон (напр. визуальный дифф или reference-guard).
**Усилие:** высокое (архитектура), но окупается: дальше фичи добавляются без раздувания ядра и обновляются независимо от основного релиза.
---
## 🥇 Топ — уникальное, максимум пользы
### 1. Reference-guard: целостность ассет-референсов перед Submit ⭐ рекомендация №1
Перед сабмитом парсить импорты `.uasset` (через UE asset registry / UE-MCP, либо чтение заголовков `.uasset`) и проверять: не сабмитишь ли ассет, который ссылается на другой ассет, которого **нет ни в депоте, ни в текущем changelist**.
- **Зачем:** причина №1 сломанных билдов в UE. Ни P4V, ни git не понимают референсы ассетов.
- **UX:** «⚠️ `M_Hero` ссылается на `T_Hero_D`, его нет на сервере и не в коммите → добавить в changelist / отменить сабмит».
- **Как:** backend-команда `p4_check_refs(files)` → карта референсов → сверка с `p4 files` + открытыми. Бонус: «submit together» — сгруппировать взаимозависимые ассеты в один атомарный CL.
- **Усилие:** среднее-высокое (парсинг референсов — ключевой кусок).
### 2. Визуальный дифф бинарников
Не «binary files differ», а реально:
- **Картинки** (png/tga/exr/bmp): сравнение свайпом / onion-skin / попиксельная подсветка разницы.
- **`.uasset`**: дифф по свойствам (какой параметр материала / LOD / настройку меняли) из встроенных данных.
- **Зачем:** P4V/git по бинарям молчат; Anchorpoint умеет частично.
- **Как:** переиспользовать существующие тумбы/`readDepot`; для двух ревизий — `p4 print` обеих версий → canvas-свайп. Свойства `.uasset` — через UE-MCP или парсер.
- **Усилие:** картинки — низкое-среднее (быстрый вин); свойства `.uasset` — среднее.
### 3. Lock handoff — запрос залоченного файла у коллеги
Бинарь эксклюзивно залочен напарником → кнопка «Попросить файл». Ему — нативное уведомление (трей уже есть); отпустил — тебе прилетает «свободно».
- **Зачем:** убирает «отпусти плиз ассет» из дискорда. Нет нигде.
- **Как:** нужен лёгкий канал сообщений между клиентами. Варианты: свой мини-эндпоинт (как waitlist-server) / Perforce как транспорт (спец. changelist-описание или counter) / общий канал. Требует продумать транспорт.
- **Усилие:** среднее (основное — транспорт уведомлений между людьми).
---
## 🥈 Осведомлённость (git/P4V не дают)
### 4. Presence — кто где работает прямо сейчас
Лёгкий поллинг `p4 opened -a` + свой heartbeat → «Mira: Content/Characters, Voss: Config». Presence как в Google Docs, но по депоту. Предупреждает о коллизии **до** открытия того же ассета.
- **Усилие:** низкое-среднее (частично уже есть `others`-индикаторы; нужен агрегированный вид + heartbeat).
### 5. Impact-preview перед Get Latest
Показать не только «сколько файлов», а что это значит для UE: «синк тронет 400 материалов → ~20 мин компиляции шейдеров», «меняется .ini упаковки», «конфликт с твоими открытыми файлами».
- **Как:** `p4 sync -n` (уже есть `p4_behind`) + классификация путей/типов; для шейдеров — эвристика по кол-ву материалов/шейдеров.
- **Усилие:** среднее.
---
## 🥉 Скорость/безопасность (кусочки уже есть)
### 6. AI-ревью changelist перед Submit
OpenRouter уже вшит (commit-месседжи) — расширить: «кладёшь 2 ГБ несжатого ассета», «забыл `.uasset`, на который ссылается материал» (пересекается с №1), «этот тип обычно лочат — взять лок?».
- **Усилие:** низкое-среднее (инфра AI готова).
### 7. Управление занятым местом на диске
Есть Files & Sizes — добавить «синкнуто 380 ГБ; вот что можно `p4 sync #none`, чтобы освободить 120 ГБ». Пересекается с View-деревом (исключил → освободил).
- **Усилие:** низкое (есть `fs_ls` / sizes).
### 8. Time-travel превью ассета
Ползунок по истории ревизии → тумба/3D на каждой версии. Есть revision graph + тумбы — соединить в визуальную ленту.
- **Усилие:** среднее.
---
## Порядок, который предлагаю
Развилка по стратегии:
- **Вариант A (плагины сначала):** сделать 🧩 Foundation, затем каждую фичу — как плагин. Ядро остаётся лёгким с самого начала, но первый результат для пользователя дольше.
- **Вариант B (ценность сначала):** сделать 12 фичи инлайн ради быстрого «вау», потом построить плагин-систему и вынести их в плагины как эталон. Быстрее польза, но потом рефактор.
Рекомендую **B → потом Foundation**: сначала №2 (визуальный дифф картинок свайпом — быстрый «вау» на готовой инфре), затем №1 (reference-guard — главная польза для билдов), **после этого 🧩 Foundation** и перенос №1/№2 в плагины как образец. Дальше по выбору: №4 presence → №6 AI-ревью → №5 impact-preview → №7 диск → №3 lock handoff → №2 `.uasset`-дифф свойств → №8 time-travel — уже как плагины.

114
PLUGINS.md Normal file
View File

@ -0,0 +1,114 @@
# Exbyte Depot — Plugins
Plugins add features on top of the core app so the base stays lean and specialised
work ships (and updates) independently.
## What language?
**JavaScript / TypeScript.** A plugin is a plain **ES module** that exports
`activate(host)`. Author in TypeScript if you like, then compile to a single JS
file (`esbuild index.ts --bundle --format=esm --outfile=index.js`). No build step
is required for plain JS.
Why JS and not native? The app's Rust backend is compiled and sealed; the extensible
surface is the webview. Plugins run in the app's webview and reach the app **only**
through the `host` object — never `invoke` directly.
## How plugins are loaded
The app reads the plugin's entry source from disk (Rust) and imports it as an ES
module from a `blob:` URL — **no `eval`**, and it stays inside the CSP
(`script-src 'self' blob:`). Disabled plugins are never imported.
## Anatomy
```
my-plugin/
plugin.json # manifest
index.js # ES module exporting activate(host)
```
### plugin.json
```json
{
"id": "my-plugin", // unique; [A-Za-z0-9-_.]
"name": "My Plugin",
"version": "1.0.0",
"author": "You",
"description": "What it does.",
"entry": "index.js", // must stay inside the plugin folder
"minAppVersion": "0.3.5",
"permissions": ["p4:read", "notify"],
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
}
```
### Permissions (declared, shown to the user)
| permission | grants |
|---|---|
| `p4:read` | read-only p4: `opened, dirs, describe, changeSizes, clientSpec` on `host.p4` |
| `p4:write` | mutating p4: `add, edit, del, sync, reconcile` on `host.p4` |
| `notify` | native notifications via `host.notify` |
Without a permission, the matching APIs are simply absent (or no-ops). `host.storage`,
`host.clipboard`, `host.flash`, `host.info`, `host.t`, `host.theme` are always available.
## The `host` SDK
```ts
host.version // Host SDK version, e.g. "1.0"
host.plugin // { id, name, dir }
host.has(perm) // boolean
host.p4.<method>(...) // Promise — only the whitelisted set your perms allow
host.info() // { userName, clientName, clientRoot } | null
host.selectedFile() // { depotFile } | null
host.refresh() // re-read the Changes view
host.theme() // current theme's CSS-var overrides
host.ui.addMenuItem(label, run) // Actions-menu action
host.ui.addFileContextItem(label, run(file)) // right-click a file
host.ui.addView({ id, title, render(el, host) })// a panel; render into a DOM element (return an optional cleanup fn)
host.ui.addSubmitHook(run(files) => {ok, message}) // block/allow a commit
host.flash(text, isError?) // toast
host.notify(title, body) // native notification (needs "notify")
host.clipboard.write(text) // Promise
host.storage.get/set/remove(key) // per-plugin localStorage namespace
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.
- **Submit hooks** run before a commit is finalised; returning `{ ok:false, message }`
prompts the user to confirm or cancel. (This is exactly where a reference-integrity
guard, size check, or lint would live.)
## Import methods
1. **Install from folder** — Actions → Plugins… → *Install from folder…* → pick a folder
that contains `plugin.json`. It's copied into the app's plugins folder.
2. **Drop-in** — Actions → Plugins… → *Open plugins folder*, drop `my-plugin/` in there,
then *Reload*.
3. **(Planned) Registry** — a signed registry (`exbyte-depot-plugins` repo, minisign-signed
bundles) for one-click install/updates, reusing the app's updater signing infra.
## Try the example
`plugins-examples/hello-exbyte/` demonstrates all four contribution points plus storage,
clipboard, info and notifications. Install it from folder, then:
- Actions → **Say hello 👋** and Actions → **Workspace Notes**
- right-click a file → **Copy depot path (plugin)**
- try to commit a file named `wip*` → the submit guard warns.
## Safety
Plugins run in-app on the main thread — install only ones you trust. The `id` is
path-validated, the entry cannot escape the plugin folder, and each plugin's storage
is namespaced. A plugin that throws is isolated and does not crash the app.

View File

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

View File

@ -0,0 +1,57 @@
// Hello Exbyte — example plugin.
// A plugin is a plain ES module that exports `activate(host)`. The host is the
// only way the plugin touches the app; what it can reach is gated by the
// "permissions" in plugin.json.
export function activate(host) {
host.log("activating", host.plugin.id, "v", host.version);
// 1) A menu action (appears under the Actions menu).
host.ui.addMenuItem("Say hello 👋", () => {
const me = host.info()?.userName || "there";
host.flash(`Hello, ${me}! (from the Hello Exbyte plugin)`);
host.notify("Hello Exbyte", "The example plugin says hi.");
});
// 2) A file context-menu item (right-click a file in Changes).
host.ui.addFileContextItem("Copy depot path (plugin)", (file) => {
if (file.depotFile) {
host.clipboard.write(file.depotFile);
host.flash("Copied: " + file.depotFile);
}
});
// 3) A view — a per-workspace notepad, persisted with host.storage.
// Opens from the Actions menu (listed by its title).
host.ui.addView({
id: "notes",
title: "Workspace Notes",
render: (el) => {
const key = "notes:" + (host.info()?.clientName || "default");
const ta = document.createElement("textarea");
ta.value = host.storage.get(key) || "";
ta.placeholder = "Jot notes for this workspace… (saved locally, per workspace)";
ta.style.cssText =
"width:100%;height:320px;box-sizing:border-box;resize:vertical;" +
"background:var(--input-bg,#111);color:var(--txt,#eee);" +
"border:1px solid var(--border,#333);border-radius:10px;padding:12px;" +
"font:13px/1.6 system-ui;outline:none";
ta.addEventListener("input", () => host.storage.set(key, ta.value));
el.appendChild(ta);
// optional cleanup returned to the host:
return () => host.storage.set(key, ta.value);
},
});
// 4) A submit guard — blocks (with a confirm) files that look like WIP.
// This is the same hook point a reference-integrity guard would use.
host.ui.addSubmitHook((files) => {
const wip = files.filter((f) => /(^|\/)(wip|temp|scratch)[^/]*$/i.test(f.depotFile || ""));
if (wip.length) {
return { ok: false, message: `${wip.length} file(s) look like work-in-progress (wip / temp / scratch).` };
}
return { ok: true };
});
host.log("activated");
}

View File

@ -0,0 +1,11 @@
{
"id": "hello-exbyte",
"name": "Hello Exbyte",
"version": "1.0.0",
"author": "Exbyte Studios",
"description": "Example plugin — a menu action, a per-workspace notes panel, a file context item, and a submit guard. Copy it as a starting point.",
"entry": "index.js",
"minAppVersion": "0.3.5",
"permissions": ["p4:read", "notify"],
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
}

View File

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

View File

@ -0,0 +1,12 @@
{
"id": "team-relay",
"name": "Team Relay",
"version": "1.0.1",
"author": "Exbyte Studios",
"description": "See who on the team is online and what they have open, over a lightweight relay on your VPN. Coordinators (admin/super) can force-shelve a teammate's open work to the server when they've stepped away — the teammate's client does the shelve; nothing is submitted. Configure the relay URL in the Team tab. Requires the Exbyte relay service.",
"entry": "index.js",
"minAppVersion": "0.4.0",
"permissions": ["team", "notify"],
"defaultEnabled": false,
"contributes": { "dock": ["team"] }
}

View 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");
}

View 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
View File

@ -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.4"
version = "0.4.1"
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"

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.4"
version = "0.4.1"
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]

View File

@ -2315,6 +2315,53 @@ async fn p4_unshelve(state: State<'_, AppState>, change: String) -> Result<Strin
run_text(&conn, &["unshelve", "-s", &change, "-f"])
}
/// Shelve everything the user has open in their DEFAULT changelist to the server
/// in one shot: create a numbered pending changelist, move the open files into
/// it, and `shelve` it. Returns `{ change, files }`. This is the primitive the
/// team-relay plugin drives so a coordinator can push a teammate's local work to
/// the server (as a shelf — nothing is submitted). The teammate's client runs
/// this; it uses ordinary client-side p4 commands and never touches the server
/// config. Files already parked in other numbered changelists are left alone.
#[tauri::command]
async fn p4_shelve_open(state: State<'_, AppState>, description: String) -> Result<Value, String> {
let conn = current(&state)?;
let opened = run_json(&conn, &["opened", "-c", "default"])?;
let files: Vec<String> = opened
.iter()
.filter_map(|v| v.get("depotFile").and_then(|d| d.as_str()).map(String::from))
.collect();
if files.is_empty() {
return Err("No open files in the default changelist".into());
}
let desc = if description.trim().is_empty() {
"Remote shelve (Team Relay)".to_string()
} else {
description
};
let cl = create_changelist(&conn, &desc)?;
// Move the open files into the new changelist, batched so a huge default
// changelist doesn't overflow the Windows command line.
let prefix_len = "reopen -c ".len() + cl.len();
for r in file_batches(&files, prefix_len) {
let mut args: Vec<&str> = vec!["reopen", "-c", &cl];
for f in &files[r] {
args.push(f.as_str());
}
run_text(&conn, &args)?;
}
run_text(&conn, &["shelve", "-c", &cl])?;
Ok(serde_json::json!({ "change": cl, "files": files.len() }))
}
/// The caller's maximum protection level on this server (`p4 protects -m`),
/// e.g. "super" / "admin" / "write". Read-only; used to gate coordinator-only
/// actions in the UI (the server still enforces the real permission).
#[tauri::command]
async fn p4_protects_max(state: State<'_, AppState>) -> Result<String, String> {
let conn = current(&state)?;
run_text(&conn, &["protects", "-m"]).map(|s| s.trim().to_string())
}
/// Delete the shelved files of a changelist.
#[tauri::command]
async fn p4_delete_shelf(state: State<'_, AppState>, change: String) -> Result<String, String> {
@ -2868,6 +2915,250 @@ async fn save_ai_key(key: String) -> Result<(), String> {
std::fs::write(&p, key.trim()).map_err(|e| e.to_string())
}
// ================= plugins =================
// Plugins live in <config>/plugins/<id>/ with a plugin.json manifest and a JS
// entry. The frontend reads the entry source and imports it as an ES module in a
// controlled Host-SDK scope. Enable/disable state is a small enabled.json map.
fn plugins_dir() -> Option<std::path::PathBuf> {
let app = APP.get()?;
let dir = app.path().app_config_dir().ok()?.join("plugins");
let _ = std::fs::create_dir_all(&dir);
Some(dir)
}
fn plugin_enabled_map() -> std::collections::HashMap<String, bool> {
plugins_dir()
.map(|d| d.join("enabled.json"))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_plugin_enabled_map(m: &std::collections::HashMap<String, bool>) {
if let Some(d) = plugins_dir() {
let _ = std::fs::write(d.join("enabled.json"), serde_json::to_string_pretty(m).unwrap_or_default());
}
}
fn find_plugin_dir(id: &str) -> Option<std::path::PathBuf> {
let root = plugins_dir()?;
for e in std::fs::read_dir(&root).ok()?.flatten() {
if !e.path().is_dir() { continue; }
let mp = e.path().join("plugin.json");
if let Ok(txt) = std::fs::read_to_string(&mp) {
if let Ok(m) = serde_json::from_str::<Value>(&txt) {
let mid = m.get("id").and_then(|v| v.as_str()).map(String::from)
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
if mid == id { return Some(e.path()); }
}
}
}
None
}
/// List installed plugins (manifest + enabled flag).
#[tauri::command]
async fn plugin_list() -> Result<Vec<Value>, String> {
let dir = plugins_dir().ok_or("No config dir")?;
let en = plugin_enabled_map();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
if !e.path().is_dir() { continue; }
let Ok(txt) = std::fs::read_to_string(e.path().join("plugin.json")) else { continue };
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());
// 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(""),
"version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"),
"description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"author": m.get("author").and_then(|v| v.as_str()).unwrap_or(""),
"permissions": m.get("permissions").cloned().unwrap_or(Value::Array(vec![])),
"manifest": m,
"enabled": enabled,
"dir": e.path().to_string_lossy(),
}));
}
}
Ok(out)
}
/// Read a plugin's JS entry source (guards against path escape).
#[tauri::command]
async fn plugin_read_entry(id: String) -> Result<String, String> {
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(pdir.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let entry = manifest.get("entry").and_then(|v| v.as_str()).unwrap_or("index.js");
if entry.contains("..") || entry.starts_with('/') || entry.starts_with('\\') {
return Err("Invalid entry path".into());
}
let full = pdir.join(entry);
// ensure the resolved path stays inside the plugin dir
let base = pdir.canonicalize().map_err(|e| e.to_string())?;
let target = full.canonicalize().map_err(|e| e.to_string())?;
if !target.starts_with(&base) {
return Err("Entry escapes plugin folder".into());
}
std::fs::read_to_string(&target).map_err(|e| e.to_string())
}
#[tauri::command]
async fn plugin_set_enabled(id: String, enabled: bool) -> Result<(), String> {
let mut m = plugin_enabled_map();
m.insert(id, enabled);
write_plugin_enabled_map(&m);
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)? {
let e = e?;
let dest = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir_recursive(&e.path(), &dest)?;
} else {
std::fs::copy(e.path(), dest)?;
}
}
Ok(())
}
/// Install a plugin from a local folder that contains plugin.json → copies it
/// into <config>/plugins/<id>/. Returns the plugin id.
#[tauri::command]
async fn plugin_install(src: String) -> Result<String, String> {
let srcp = std::path::PathBuf::from(&src);
if !srcp.join("plugin.json").exists() {
return Err("Folder has no plugin.json".into());
}
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(srcp.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let id = manifest.get("id").and_then(|v| v.as_str()).ok_or("plugin.json missing \"id\"")?.to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
return Err("Invalid plugin id (use letters, digits, - _ .)".into());
}
let dest = plugins_dir().ok_or("No config dir")?.join(&id);
if dest.exists() {
let _ = std::fs::remove_dir_all(&dest); // reinstall / update
}
copy_dir_recursive(&srcp, &dest).map_err(|e| e.to_string())?;
Ok(id)
}
#[tauri::command]
async fn plugin_remove(id: String) -> Result<(), String> {
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
std::fs::remove_dir_all(&pdir).map_err(|e| e.to_string())?;
let mut m = plugin_enabled_map();
m.remove(&id);
write_plugin_enabled_map(&m);
Ok(())
}
#[tauri::command]
async fn plugins_dir_path() -> Result<String, String> {
plugins_dir().map(|d| d.to_string_lossy().to_string()).ok_or_else(|| "No config dir".into())
}
#[tauri::command]
async fn open_plugins_dir() -> Result<(), String> {
let dir = plugins_dir().ok_or("No config dir")?;
let mut c = Command::new("explorer");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
c.arg(dir);
c.spawn().map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default()
@ -2959,6 +3250,18 @@ pub fn run() {
p4_scan_stream,
p4_scan_cancel,
p4_transfer_cancel,
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,
open_plugins_dir,
p4_undo_change,
open_in_explorer,
find_uproject,

View File

@ -2,7 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot",
"version": "0.3.4",
"version": "0.4.1",
"identifier": "com.bonchellon.exbyte-depot",
"build": {
"beforeDevCommand": "npm run dev",
@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; 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'; 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": {

View File

@ -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}
@ -116,12 +120,21 @@ body.resizing{cursor:col-resize!important;user-select:none}
.left{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0;overflow:hidden}
.right{display:flex;flex-direction:column;min-height:0}
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border)}
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border);position:relative}
.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 .15s;background:none;border:none}
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.on::after{content:"";position:absolute;bottom:-1px;left:16px;right:16px;height:2px;
background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:2px;box-shadow:0 0 10px rgba(124,110,246,.6)}
.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: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 */
.tabpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .3s cubic-bezier(.4,0,.2,1)}
@keyframes tabin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
@media (prefers-reduced-motion:reduce){.tab-slider{transition:none}.tabpane{animation:none}}
.badge{font-size:11px;font-weight:600;color:var(--muted);background:var(--panel-3);padding:1px 8px;border-radius:10px;font-variant-numeric:tabular-nums}
.tab.on .badge{color:var(--accent-2);background:rgba(124,110,246,.16)}
@ -269,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}
@ -357,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);
@ -393,6 +416,10 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.docktab:hover{color:var(--txt);background:var(--hover)}
.docktab.on{color:var(--txt);border-bottom-color:var(--accent)}
.docktab.on svg{color:var(--accent-2)}
.docktab:active{transform:scale(.96)}
/* dock content fades in when switching Log / Terminal / Unreal */
.dockpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .26s cubic-bezier(.4,0,.2,1)}
@media (prefers-reduced-motion:reduce){.vpane,.dockpane{animation:none}}
.dockspace{flex:1}
.dockpanel .loghbtn{margin-left:0}
/* terminal */
@ -674,6 +701,83 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.vt-ic svg{width:15px;height:15px}
.vt-name{font-size:13px;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vt-loading,.vt-empty{height:24px;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint)}
/* themes modal */
.th-grid{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:16px}
.th-card{position:relative;border:1.5px solid var(--border);border-radius:13px;overflow:hidden;cursor:pointer;background:var(--panel);transition:.15s}
.th-card:hover{border-color:var(--accent)}
.th-card.on{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.2)}
.th-swatch{height:64px;display:flex;align-items:center;gap:7px;padding:0 14px}
.th-swatch span{width:20px;height:20px;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.35)}
.th-meta{padding:9px 12px 11px}
.th-meta b{display:block;font-size:13px;font-weight:600}
.th-meta span{font-size:11px;color:var(--faint)}
.th-on{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center}
.th-on svg{width:13px;height:13px}
.th-actions{position:absolute;top:7px;right:7px;display:none;gap:4px}
.th-card:hover .th-actions{display:flex}
.th-actions button{width:24px;height:24px;border:none;border-radius:7px;background:var(--chip);color:var(--txt);cursor:pointer;display:grid;place-items:center;backdrop-filter:blur(4px)}
.th-actions button svg{width:13px;height:13px}
.th-actions button:hover{background:var(--accent);color:#fff}
.th-actions button.danger:hover{background:var(--del)}
.th-edit{flex:1;min-height:0;overflow:auto;padding:16px}
.th-edit-row{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}
.th-edit-row label{font-size:12px;color:var(--muted);font-weight:600}
.th-edit-row input{flex:1;min-width:160px;background:var(--input-bg);border:1px solid var(--border);border-radius:9px;padding:9px 12px;color:var(--txt);font-size:14px;outline:none}
.th-edit-row input:focus{border-color:var(--accent)}
.th-base{display:flex;gap:2px;background:var(--panel-3);border-radius:9px;padding:2px}
.th-base button{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;border:none;background:none;color:var(--muted);border-radius:7px;padding:7px 11px;cursor:pointer}
.th-base button svg{width:14px;height:14px}
.th-base button.on{background:var(--elevated);color:var(--accent-2)}
.th-group{margin-bottom:16px}
.th-group-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:600;margin-bottom:9px}
.th-tokens{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:9px}
.th-token{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--txt);cursor:pointer}
.th-token input[type=color]{width:30px;height:30px;flex:0 0 auto;border:1px solid var(--border);border-radius:8px;background:none;cursor:pointer;padding:2px}
.th-token input[type=color]::-webkit-color-swatch{border:none;border-radius:6px}
.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}
.pl-top{display:flex;align-items:baseline;gap:9px}
.pl-top b{font-size:14px;font-weight:650}
.pl-ver{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
.pl-desc{font-size:12.5px;color:var(--muted);margin-top:3px;line-height:1.45}
.pl-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
.pl-tag{font-size:11px;color:var(--muted);background:var(--panel-3);border-radius:6px;padding:2px 8px}
.pl-perm{font-size:10.5px;font-weight:600;color:var(--accent-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.25);border-radius:6px;padding:2px 8px}
.pl-actions{flex:0 0 auto;display:flex;align-items:center;gap:8px}
.pl-toggle{width:38px;height:22px;border-radius:12px;border:none;background:var(--panel-3);cursor:pointer;position:relative;transition:.18s;padding:0}
.pl-toggle span{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--faint);transition:.18s}
.pl-toggle.on{background:var(--accent)}
.pl-toggle.on span{left:19px;background:#fff}
.pl-del{width:30px;height:30px;border:none;background:none;color:var(--faint);border-radius:8px;cursor:pointer;display:grid;place-items:center}
.pl-del svg{width:15px;height:15px}
.pl-del:hover{background:rgba(242,99,126,.14);color:var(--del)}
.plugin-view{flex:1;min-height:0;min-height:340px;overflow:auto;padding:16px;color:var(--txt);font-size:13.5px}
/* settings — two-pane (left nav + content) */
.settings-modal{width:min(860px,94vw);height:min(620px,88vh);display:flex;background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .2s ease}
.set-nav{flex:0 0 210px;display:flex;flex-direction:column;gap:2px;padding:14px 10px;background:var(--panel);border-right:1px solid var(--border-soft)}
.set-nav-h{display:flex;align-items:center;gap:9px;padding:6px 10px 14px;font-size:14px;font-weight:650}
.set-nav-logo{display:grid;place-items:center;color:var(--accent-2)}
.set-nav-logo svg{width:18px;height:18px}
.set-nav-item{display:flex;align-items:center;gap:10px;padding:9px 11px;border:none;background:none;color:var(--muted);font-size:13.5px;font-weight:500;font-family:var(--font);border-radius:9px;cursor:pointer;text-align:left;transition:.12s}
.set-nav-item .sni-ic{display:grid;place-items:center;flex:0 0 auto}
.set-nav-item svg{width:16px;height:16px}
.set-nav-item:hover{background:var(--hover);color:var(--txt)}
.set-nav-item.on{background:var(--accent);color:#fff}
.set-nav-item.danger{color:var(--del)}
.set-nav-item.danger:hover{background:rgba(242,99,126,.14)}
.set-nav-sp{flex:1}
.set-main{flex:1;min-width:0;display:flex;flex-direction:column}
.set-main-h{display:flex;align-items:center;padding:16px 18px 12px;border-bottom:1px solid var(--border-soft)}
.set-main-h h3{font-size:16px;font-weight:650;flex:1}
.set-main .info-body{flex:1;min-height:0;padding:16px 20px}
.set-panel{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
.set-sep{height:1px;background:var(--border-soft);margin:14px 0}
/* rich code editor: transparent textarea over a highlighted underlay + gutter */
.code.editing{overflow:hidden}
.code.editing .code-gutter{overflow:hidden}
@ -699,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)}
@ -726,7 +830,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto;overflow-y:hidden}
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px;transition:background .22s ease,border-color .22s ease,color .18s ease}
.vtab:active{transform:scale(.97)}
.vtab:hover{color:var(--txt);background:var(--hover)}
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
.vtab-ic{display:grid;place-items:center;color:var(--accent-2)}
@ -734,7 +839,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)}
.vtab-x:hover{background:var(--del);color:#fff}
.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative}
.vpane{flex:1;min-width:0;min-height:0}
.vpane{flex:1;min-width:0;min-height:0;animation:tabin .28s cubic-bezier(.4,0,.2,1)}
.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0}
.vpane.tree{flex-direction:column}
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
@ -759,6 +864,9 @@ 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{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)}}
/* 3d viewer toolbar */
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
@ -783,8 +891,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.picker-head{padding:18px 20px 14px;border-bottom:1px solid var(--border-soft);display:flex;align-items:center;gap:10px}
.picker-head h3{font-size:15px;font-weight:700}
.picker-head > svg{width:20px;height:20px;flex:0 0 auto;color:var(--accent-2)}
.picker-head .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center}
.picker-head .x:hover{color:var(--txt)}
.picker-head .x,.set-main-h .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:.15s}
.picker-head .x:hover,.set-main-h .x:hover{color:var(--txt);background:var(--hover)}
.picker-head .x svg,.set-main-h .x svg{width:14px;height:14px}
.picker-list{overflow-y:auto;padding:8px}
.picker-item{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:11px;cursor:pointer;transition:background .12s}
.picker-item:hover{background:var(--hover)}
@ -842,7 +951,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
animation:toastin .25s ease;white-space:pre-wrap;text-align:center;
max-height:30vh;overflow-y:auto;overflow-wrap:anywhere}
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
@ -1019,7 +1129,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}

View File

@ -4,12 +4,15 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
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 } from "./p4";
import { p4, statusOf, splitPath, kindOf, isCodeFile, fmtTime, describeFiles, filelogRevs, humanSize, leaf, syncSummary, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
import { aiSummary, aiExplainCode, getExplain, saveExplain, dropExplain, initials, avatarColor, activity } from "./ai";
// native OS notification (works even when the window is hidden in the tray)
@ -79,16 +82,20 @@ 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>,
};
/* ---------------- theme hook ---------------- */
function useTheme(): [boolean, () => void] {
const [light, setLight] = useState(() => {
try { return localStorage.getItem("exd-theme") === "light"; } catch { return false; }
});
useEffect(() => {
document.body.classList.toggle("light", light);
try { localStorage.setItem("exd-theme", light ? "light" : "dark"); } catch {}
}, [light]);
return [light, () => setLight((l) => !l)];
// 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());
useEffect(() => { applyTheme(resolveTheme(themeId)); saveActiveId(themeId); }, [themeId]);
const light = resolveTheme(themeId).base === "light";
const toggleTheme = () => setThemeId((id) => (resolveTheme(id).base === "light" ? "dark" : "light"));
return [light, toggleTheme, themeId, setThemeId];
}
/* ---------------- language hook (whole tree re-renders on change) ---------------- */
@ -118,7 +125,7 @@ export default function App() {
const [phase, setPhase] = useState<"loading" | "connect" | "main">("loading");
const [info, setInfo] = useState<P4Info | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [light, toggleTheme] = useTheme();
const [light, toggleTheme, themeId, setThemeId] = useTheme();
const [lang, setLang] = useLang();
const [zoom, setZoom] = useZoom();
const saved = useMemo(() => loadSession(), []);
@ -140,11 +147,11 @@ export default function App() {
content = <Connect light={light} toggleTheme={toggleTheme} initial={saved}
onDone={(i, s) => { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />;
else
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
content = <Workbench info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
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>) ---------------- */
@ -326,7 +333,7 @@ type ViewTab = "viewer" | "tree" | "locks"; // dockable panels in the right view
type ModalState =
| { kind: "workspaces"; items: Client[]; current: string }
| { kind: "scope"; path: string; dirs: Dir[] }
| { kind: "settings" }
| { kind: "settings"; section?: "general" | "appearance" | "plugins" }
| { kind: "users" }
| { kind: "search" }
| { kind: "locks" }
@ -350,10 +357,13 @@ type LogEntry = { id: number; cmd: string; count: number; ok: boolean; err?: str
type Transfer = { op: "sync" | "submit"; count: number; total?: number; file?: string; log: string[]; cancelling?: boolean };
type TermLine = { id: number; cmd?: string; text: string; err?: boolean; running?: boolean };
function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
function Workbench({ info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, onInfo, onSession, onDisconnect }:
{ info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; onInfo: (i: P4Info) => void; onSession: (s: Session) => void; onDisconnect: () => void }) {
const [tab, setTab] = useState<Tab>("changes");
const [modal, setModal] = useState<ModalState | null>(null);
const [, setPluginVer] = useState(0); // bumped when the plugin registry changes → re-render menus
const [pluginView, setPluginView] = useState<PluginView | null>(null); // open plugin view modal
const pluginsLoaded = useRef(false);
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
const [files, setFiles] = useState<OpenedFile[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set());
@ -375,6 +385,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
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>(""); // scope the cached sizes belong to
const [detail, setDetail] = useState<Describe | null>(null);
const [selChange, setSelChange] = useState<string>(""); // highlighted History row (instant)
const [detailBusy, setDetailBusy] = useState(false); // right-panel files loading
@ -387,7 +399,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [xferMin, setXferMin] = useState(false); // transfer dialog collapsed to a floating chip (keeps running)
const [showXfer, setShowXfer] = useState(false); // delayed so fast ops don't flash a dialog
const [uproject, setUproject] = useState(""); // local .uproject path when the scope is a UE project
const uprojectRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [slnPath, setSlnPath] = useState(""); // local .sln path in the scope (for building)
const slnPathRef = useRef(""); // live mirror for the plugin ue-bridge closures
const [editors, setEditors] = useState<Editor[]>([]); // code editors installed on this machine
const [editorId, setEditorId] = useState(getEditor()); // chosen editor id ("" → auto-pick)
const [others, setOthers] = useState<Map<string, { user: string; locked: boolean }>>(new Map()); // files opened/locked by OTHER users
@ -407,9 +421,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [aiBusy, setAiBusy] = useState(false); // AI commit-summary generation in flight
const [ctx, setCtx] = useState<null | { x: number; y: number; items: CtxItem[] }>(null); // right-click menu
const [logs, setLogs] = useState<LogEntry[]>([]); // live p4 command log (P4V-style)
// bottom dock: a tabbed panel (Log / Terminal / Unreal), 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 [dockTab, setDockTab] = useState<"log" | "terminal" | "unreal">("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.
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 {}
@ -469,8 +484,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
document.body.classList.add("resizing-v");
}
// A toast is one line of feedback, never a report: cap it so a raw p4 dump or a
// long server error can't grow the bubble over the whole window. Full output
// always stays available in the Log dock.
function flash(text: string, err = false) {
setToast({ text, err });
const one = (text || "").trim();
setToast({ text: one.length > 220 ? one.slice(0, 220).trimEnd() + "…" : one, err });
setTimeout(() => setToast(null), 4500);
}
function confirm(opts: { title: string; body: string; confirm: string; danger?: boolean }): Promise<boolean> {
@ -478,7 +497,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
}
// open the bottom dock on a given tab (toggle off if already showing it)
function openDock(tab: "log" | "terminal" | "unreal") {
function openDock(tab: string) {
setDockOpen((o) => (o && dockTab === tab ? false : true));
setDockTab(tab);
}
@ -610,6 +629,52 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
// Source Control enabled in Unreal, UE auto-checks-out files, so they appear
// here on their own — no reconcile needed. Reconcile is a manual fallback.
useEffect(() => { refresh(); /* eslint-disable-next-line */ }, []);
// ---- plugins: (re)load enabled plugins; they register menu/panel/hook contributions
async function reloadPlugins() {
const res = await loadPlugins({
flash,
notify: (title, body) => { void notify(title, body); },
info: () => info,
selectedFile: () => selFile || null,
refresh: () => { void refresh(); },
// Unreal bridge — core keeps buildSln/overlay/log; the plugin just drives them
ue: {
hasProject: () => !!uprojectRef.current,
uproject: () => uprojectRef.current,
build: () => { startBuild(); },
launch: () => { void launchUE(); },
readLog: (tb?: number) => p4.ueLog(uprojectRef.current, tb ?? 200_000),
},
// Team bridge — presence + coordinator shelve tools; plugin owns the logic
team: {
whoami: () => (info ? { user: info.userName, client: info.clientName, root: info.clientRoot } : null),
maxAccess: () => p4.protectsMax().catch(() => ""),
openCount: () => p4.opened("").then((a) => a.length).catch(() => 0),
shelveOpen: (desc: string) => p4.shelveOpen(desc),
submitShelved: (change: string) => p4.submitShelved(change),
relay: (url: string, method: string, body?: string, token?: string) => p4.relayHttp(url, method, body, token),
},
});
setPluginVer((v) => v + 1);
return res;
}
// keep the ue-bridge mirrors live: the bridge closures are built once (plugins
// load as soon as we have a workspace) but the paths are detected later, per
// working folder — so the bridge must read refs, never captured state.
useEffect(() => { uprojectRef.current = uproject; }, [uproject]);
useEffect(() => { slnPathRef.current = slnPath; }, [slnPath]);
useEffect(() => {
if (pluginsLoaded.current || !info?.clientName) return;
pluginsLoaded.current = true;
(async () => {
// No auto-install: only load what the user chose to install. New plugins are
// browsed and installed by hand from Settings → Plugins (the registry catalog).
const { loaded, errors } = await reloadPlugins();
if (errors.length) console.warn("[plugins] load errors:", errors);
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
})().catch(() => {});
}, [info?.clientName]); // eslint-disable-line
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
@ -870,13 +935,19 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
try {
const h = await p4.history(path);
setHistory(h);
// commit weights load in the background so the list shows instantly
// commit weights load in the background so the list shows instantly. Sizes are
// cached per changelist and only fetched for NEW changes — so a poll/refresh
// on the same scope never re-fetches (which used to make the badges flicker).
const scopeChanged = f !== histSizeScope.current;
histSizeScope.current = f;
if (scopeChanged) { sizesRef.current = {}; setChangeSizes({}); }
const nums = h.map((c) => c.change || "").filter(Boolean);
setChangeSizes({});
if (nums.length) {
p4.changeSizes(nums, f).then((rows) => {
const m: Record<string, number> = {};
const need = nums.filter((n) => sizesRef.current[n] === undefined);
if (need.length) {
p4.changeSizes(need, f).then((rows) => {
const m = { ...sizesRef.current };
for (const r of rows) m[r.change] = r.size;
sizesRef.current = m;
setChangeSizes(m);
}).catch(() => {});
}
@ -890,7 +961,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
async function getLatest() {
setBusy(true);
try { flash(t("Get Latest: {msg}", { msg: await p4.sync(activePath) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
try { flash(t("Get Latest: {msg}", { msg: syncSummary(await p4.sync(activePath)) })); await refresh(); await loadHistory(); behindRef.current = 0; setBehind(null); } // sync may pull new submitted changelists → refresh History too
catch (e) { flash(String(e), true); setBusy(false); }
}
// check whether the server has newer content than my workspace (files behind)
@ -914,7 +985,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
if (!r) return;
if (!(await confirm({ title: t("Sync workspace to {rev}?", { rev: r }), body: t("Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.", { rev: r }), confirm: t("Sync") }))) return;
setBusy(true);
try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: await p4.syncTo(activePath, r) })); await refresh(); await loadHistory(); }
try { flash(t("Synced to {rev}: {msg}", { rev: r, msg: syncSummary(await p4.syncTo(activePath, r)) })); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
function promptSyncTo() {
@ -974,25 +1045,29 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
function openCtx(e: ReactMouseEvent, items: CtxItem[]) { e.preventDefault(); e.stopPropagation(); setCtx({ x: e.clientX, y: e.clientY, items }); }
// launch Unreal by opening the detected .uproject (file association starts UE)
// launch Unreal by opening the detected .uproject (file association starts UE).
// Reads the ref, not the state: the plugin ue-bridge calls this through a
// closure captured before the project was detected.
async function launchUE() {
if (!uproject) return;
try { await p4.launchFile(uproject); flash(t("Launching Unreal…")); }
const up = uprojectRef.current;
if (!up) { flash(t("No Unreal project in the working folder. Choose the project folder first."), true); return; }
try { await p4.launchFile(up); flash(t("Launching Unreal…")); }
catch (e) { flash(String(e), true); }
}
// open the configuration picker (Development / Shipping / …) before building
function startBuild() {
if (!slnPath && !uproject) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
if (!slnPathRef.current && !uprojectRef.current) { flash(t("No .sln found in the working folder. Choose the project folder first."), true); return; }
setBuildPick(true);
}
// build with the chosen UE configuration, live output. For a real UE project we
// pass the .uproject so the backend builds the game module via UnrealBuildTool.
async function buildSln(config: string) {
setBuildPick(false);
if (!slnPath && !uproject) return;
const sln = slnPathRef.current, up = uprojectRef.current;
if (!sln && !up) return;
setBuildLog([]); setBuildOk(null); setBuilding(true); setBuildOpen(true); setBuildMin(false);
try { await p4.buildSln(slnPath, config, uproject); }
try { await p4.buildSln(sln, config, up); }
catch { /* the overlay already shows the failure line */ }
}
@ -1028,6 +1103,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
.map((f) => f.depotFile || "")
.filter((d) => checked.has(d));
if (!list.length || !desc.trim()) return;
// plugin submit hooks can block (e.g. a reference-integrity guard)
if (getRegistry().submitHooks.length) {
const verdict = await runSubmitHooks(list.map((d) => ({ depotFile: d })));
if (!verdict.ok) {
if (!(await confirm({ title: t("A plugin flagged this commit"), body: verdict.message || t("A plugin blocked the commit."), confirm: t("Commit anyway"), danger: true }))) return;
}
}
const message = descBody.trim() ? `${desc.trim()}\n\n${descBody.trim()}` : desc.trim();
setBusy(true);
try {
@ -1231,6 +1313,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
...(isCode ? [{ label: t("Blame (annotate)"), icon: I.people, act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(dp) },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(dp, t("Path")) },
...getRegistry().fileItems.map((fi) => ({ label: fi.label, icon: pluginIcon(fi.icon), act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
]);
}
async function lockFiles(dps: string[], lock: boolean) {
@ -1341,7 +1424,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ label: (viewTabs.includes("locks") ? "✓ " : "") + t("File Locks"), icon: I.lock, hint: t("Everyone's checked-out / exclusively-locked files, docked into the view panel."), act: () => toggleViewTab("locks") },
{ label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, hint: t("Show the panel of recently run Perforce commands."), act: () => openDock("log") },
{ label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, hint: t("Open an embedded terminal for raw p4 commands."), act: () => openDock("terminal") },
...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, hint: t("Show output from the headless Unreal preview process."), act: () => openDock("unreal") }] : []),
...getRegistry().docks.map((d) => ({ label: (dockOpen && dockTab === `plugin:${d.id}` ? "✓ " : "") + d.title, icon: pluginIcon(d.icon), hint: t("From plugin: {p}", { p: d.pluginId }), act: () => openDock(`plugin:${d.id}`) })),
],
Tools: [
{ label: t("Search depot…"), icon: I.search, hint: t("Find files anywhere in the depot by name or path."), act: () => setModal({ kind: "search" }) },
@ -1352,7 +1435,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ label: t("Streams…"), icon: I.branch, hint: t("Switch stream, merge down from the parent, or copy up to it."), act: () => setModal({ kind: "streams" }) },
{ label: t("Jobs…"), icon: I.log, hint: t("Perforce's task/bug tracker — create jobs and attach them to changelists."), act: () => setModal({ kind: "jobs" }) },
{ label: t("Edit .p4ignore…"), icon: I.gear, hint: t("Edit which files reconcile / add ignore (build output, caches)."), act: () => setModal({ kind: "ignore" }) },
{ label: (slnPath || uproject) ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, hint: t("Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild."), act: startBuild },
...getRegistry().menu.map((m) => ({ label: m.label, icon: pluginIcon(m.icon), hint: t("From plugin: {p}", { p: m.pluginId }), act: () => { void Promise.resolve(m.run()).catch((e) => flash(String(e), true)); } })),
...getRegistry().views.map((v) => ({ label: v.title, icon: I.hex, hint: t("Plugin view"), act: () => setPluginView(v) })),
{ label: t("People & Roles…"), icon: I.people, hint: t("See who works on this depot and their roles."), act: () => setModal({ kind: "users" }) },
{ label: t("Settings…"), icon: I.gear, hint: t("App preferences — language, theme, editor, and more."), act: () => setModal({ kind: "settings" }) },
],
@ -1404,8 +1488,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
...(activePath ? [{ label: t("Rename…"), icon: I.pencil, act: () => renameFolder(activePath) }] : []),
...(activePath ? [{ label: t("Properties"), icon: I.info, act: () => setModal({ kind: "folderprops", path: activePath, name: splitPath(activePath).name || activePath }) }] : []),
{ label: t("Show in File Tree"), icon: I.folder, act: () => showInTree("depot") },
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
...((slnPath || uproject) ? [{ label: t("Build Solution (.sln)"), icon: I.hammer, act: startBuild }] : []),
...getRegistry().folderItems.filter((f) => !f.visible || f.visible()).map((f) => ({ label: f.label, icon: pluginIcon(f.icon), act: () => { void Promise.resolve(f.run()).catch((e) => flash(String(e), true)); } })),
{ label: t("Open in Explorer"), icon: I.reveal, act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), icon: I.folder, act: () => browseTo("") },
{ label: t("Copy path"), icon: I.copy, act: () => copyText(activePath || "//…", t("Path")) },
@ -1460,10 +1543,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="tabs">
<button className={"tab" + (tab === "changes" ? " on" : "")} onClick={() => switchTab("changes")}>{t("Changes")} <span className="badge">{uncommitted.length}</span></button>
<button className={"tab" + (tab === "history" ? " on" : "")} onClick={() => switchTab("history")}>{t("History")}</button>
<span className="tab-slider" style={{ left: tab === "changes" ? "16px" : "calc(50% + 16px)" }} />
</div>
{tab === "changes" ? (
<>
<div className="tabpane" key="changes">
<div className="filter">
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
</div>
@ -1567,8 +1651,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</button>
<div className="addhint">{t("Commit = local (revertable). Send to the server — Submit, top-right.")}</div>
</div>
</>
</div>
) : (
<div className="tabpane" key="history">
<HistoryList history={history} sizes={changeSizes} busy={busy} sel={selChange} onSelect={openChange}
onContext={(c, e) => openCtx(e, [
{ label: t("Open this changelist"), icon: I.open, act: () => openChange(c) },
@ -1577,6 +1662,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{ label: t("Copy number #{n}", { n: c.change || "" }), icon: I.copy, act: () => copyText(c.change || "", t("Number")) },
{ label: t("Copy description"), icon: I.copy, act: () => copyText((c.desc || "").trim(), t("Description")) },
])} />
</div>
)}
</div>
@ -1635,10 +1721,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
{dockOpen && <DockPanel tab={dockTab} setTab={setDockTab} onClose={() => setDockOpen(false)}
height={dockH} onResize={startDockResize}
height={dockH} onResize={startDockResize} docks={getRegistry().docks}
logs={logs} onClearLogs={() => setLogs([])}
termLines={termLines} termInput={termInput} setTermInput={setTermInput} termBusy={termBusy}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} uproject={uproject} />}
onRun={runConsole} cmdHist={cmdHist} onClearTerm={() => setTermLines([])} />}
<div className="statusbar">
<span className="stfront" onMouseEnter={showPeek} onMouseLeave={hidePeek} title={t("Recent commands")}>
@ -1646,9 +1732,33 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<span className="stlast">{logs.length ? `${logs[logs.length - 1].cmd} ${logCount(logs[logs.length - 1])}` : "p4 — ready"}</span>
</span>
<span className="stspace" />
{uproject && <button className="stlog" onClick={() => openDock("unreal")} title={t("Unreal Log")}>{I.hex}<span>{t("Unreal")}</span></button>}
{/* Plugin dock panels get a status-bar button too — a dock tab you can't
open from anywhere is just a hidden tab. Listed before the built-ins so
Terminal/Log keep their place as plugins come and go. */}
{getRegistry().docks.map((d) => (
<button key={`${d.pluginId}:${d.id}`} className="stlog"
onClick={() => openDock(`plugin:${d.id}`)}
title={t("From plugin: {p}", { p: d.pluginId })}>
{pluginIcon(d.icon)}<span>{d.title}</span>
</button>
))}
<button className="stlog" onClick={() => openDock("terminal")} title="Ctrl+`">{I.terminal}<span>{t("Terminal")}</span></button>
<button className="stlog" onClick={() => openDock("log")} title="Ctrl+L">{I.log}<span>Log</span></button>
<button
className={"stlog stupd icon" + (upd.phase === "available" ? " has" : "") + (upd.phase === "downloading" || upd.phase === "checking" ? " busy" : "")}
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>
{peek && logs.length > 0 && (
<div className="logpeek" onMouseEnter={showPeek} onMouseLeave={hidePeek}>
<div className="logpeek-h">{t("Log")} · {logs.length}</div>
@ -1665,13 +1775,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
</div>
)}
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
{modal && modal.kind !== "users" && modal.kind !== "settings" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />}
{modal?.kind === "locks" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "settings" && <SettingsModal section={modal.section} info={info} session={session} light={light} toggleTheme={toggleTheme} themeId={themeId} setThemeId={setThemeId} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom} editors={editors} editorId={effEditorId} onPickEditor={chooseEditor} onReloadPlugins={reloadPlugins} onDisconnect={onDisconnect} onFlash={flash} onClose={() => setModal(null)} />}
{pluginView && <PluginViewModal view={pluginView} onClose={() => setPluginView(null)} />}
{modal?.kind === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
@ -1996,19 +2108,20 @@ function LogRow({ l }: { l: LogEntry }) {
// common p4 subcommands for the terminal's autocomplete
const P4_COMMANDS = ["add","annotate","branch","branches","change","changes","client","clients","counter","counters","delete","depot","depots","describe","diff","dirs","edit","filelog","files","fstat","group","groups","have","info","integrate","labels","login","logout","monitor","move","opened","print","protect","reconcile","reopen","resolve","resolved","revert","reviews","shelve","sizes","status","stream","streams","submit","sync","tag","tickets","unshelve","user","users","where"];
/* ---------------- bottom dock: tabbed Log / Terminal / Unreal ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm, uproject }: {
tab: "log" | "terminal" | "unreal"; setTab: (t: "log" | "terminal" | "unreal") => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void;
/* ---------------- bottom dock: tabbed Log / Terminal + plugin dock panels ---------------- */
function DockPanel({ tab, setTab, onClose, height, onResize, docks, logs, onClearLogs, termLines, termInput, setTermInput, termBusy, onRun, cmdHist, onClearTerm }: {
tab: string; setTab: (t: string) => void; onClose: () => void;
height: number; onResize: (e: ReactMouseEvent) => void; docks: PluginDock[];
logs: LogEntry[]; onClearLogs: () => void;
termLines: TermLine[]; termInput: string; setTermInput: (s: string) => void; termBusy: boolean;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void; uproject: string;
onRun: (cmd: string) => void; cmdHist: string[]; onClearTerm: () => void;
}) {
const tabs: { key: "log" | "terminal" | "unreal"; label: string; icon: ReactNode }[] = [
const tabs: { key: string; label: string; icon: ReactNode }[] = [
{ key: "log", label: t("Log"), icon: I.log },
{ key: "terminal", label: t("Terminal"), icon: I.terminal },
...(uproject ? [{ key: "unreal" as const, label: t("Unreal"), icon: I.hex }] : []),
...docks.map((d) => ({ key: `plugin:${d.id}`, label: d.title, icon: pluginIcon(d.icon) })),
];
const activeDock = tab.startsWith("plugin:") ? docks.find((d) => `plugin:${d.id}` === tab) : undefined;
return (
<div className="dockpanel" style={{ flexBasis: height, height }}>
<div className="dockhandle" onMouseDown={onResize} title={t("Drag to resize")} />
@ -2025,13 +2138,29 @@ function DockPanel({ tab, setTab, onClose, height, onResize, logs, onClearLogs,
{(tab === "log" || tab === "terminal") && <button className="loghbtn" onClick={tab === "log" ? onClearLogs : onClearTerm}>{t("Clear")}</button>}
<button className="loghbtn x" onClick={onClose}><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>
{tab === "log" && <LogTab logs={logs} />}
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
{tab === "unreal" && <UnrealTab uproject={uproject} />}
<div className="dockpane" key={tab}>
{tab === "log" && <LogTab logs={logs} />}
{tab === "terminal" && <TerminalTab lines={termLines} input={termInput} setInput={setTermInput} busy={termBusy} onRun={onRun} cmdHist={cmdHist} />}
{activeDock && <PluginDockPanel dock={activeDock} />}
{tab.startsWith("plugin:") && !activeDock && <div className="logempty">{t("This panel's plugin is no longer active.")}</div>}
</div>
</div>
);
}
// Host for a plugin-provided dock panel: mounts the plugin's render() into a div
// and runs its cleanup on unmount / tab switch. Same pattern as PluginViewModal.
function PluginDockPanel({ dock }: { dock: PluginDock }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current; if (!el) return;
let cleanup: void | (() => void);
try { cleanup = dock.render(el); } catch (e) { el.textContent = String(e); }
return () => { try { (cleanup as (() => void) | undefined)?.(); } catch { /* ignore */ } el.innerHTML = ""; };
}, [dock]);
return <div className="dockplugin" ref={ref} style={{ height: "100%", overflow: "auto" }} />;
}
function LogTab({ logs }: { logs: LogEntry[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [logs]);
@ -2102,28 +2231,6 @@ function TerminalTab({ lines, input, setInput, busy, onRun, cmdHist }: {
);
}
/* tails the newest Unreal log (Saved/Logs/*.log) while the tab is open */
function UnrealTab({ uproject }: { uproject: string }) {
const [text, setText] = useState("");
const [err, setErr] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let live = true;
const load = () => p4.ueLog(uproject).then((s) => { if (live) { setText(s); setErr(""); } }).catch((e) => live && setErr(String(e)));
load();
const iv = window.setInterval(load, 1500);
return () => { live = false; clearInterval(iv); };
}, [uproject]);
useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [text]);
return (
<div className="termbody" ref={ref}>
{err ? <div className="termhint err">{err}</div>
: text ? <pre className="termout ue">{text}</pre>
: <div className="termhint">{t("No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.")}</div>}
</div>
);
}
/* ---------------- server transfer progress (P4V-style) ---------------- */
function TransferDialog({ transfer, onCancel, onMinimize }: { transfer: Transfer; onCancel: () => void; onMinimize: () => void }) {
const up = transfer.op === "submit";
@ -3126,6 +3233,316 @@ function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: str
/* ---------------- typemap / exclusive-lock rules ---------------- */
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
// only one person can check them out at a time, preventing lost binary merges.
/* ---------------- Settings — sectioned hub (General / Appearance / Plugins) ---------------- */
type SetSection = "general" | "appearance" | "plugins";
function SettingsModal(props: {
section?: SetSection; info: P4Info | null; session: Session | null;
light: boolean; toggleTheme: () => void; themeId: string; setThemeId: (id: string) => void;
lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void;
editors: Editor[]; editorId: string; onPickEditor: (id: string) => void;
onReloadPlugins: () => Promise<{ loaded: number; errors: string[] }>;
onDisconnect: () => void; onFlash: (t: string, e?: boolean) => void; onClose: () => void;
}) {
const { info, session, light, toggleTheme, themeId, setThemeId, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onReloadPlugins, onDisconnect, onFlash, onClose } = props;
const [sec, setSec] = useState<SetSection>(props.section || "general");
function close() { applyTheme(resolveTheme(themeId)); onClose(); } // drop any unsaved live theme preview
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && close(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line
const NAV: { id: SetSection; label: string; icon: ReactNode }[] = [
{ id: "general", label: t("General"), icon: I.gear },
{ id: "appearance", label: t("Appearance"), icon: I.sun },
{ id: "plugins", label: t("Plugins"), icon: I.hex },
];
return (
<div className="modal-back" onClick={close}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="set-nav">
<div className="set-nav-h"><span className="set-nav-logo">{I.gear}</span><b>{t("Settings")}</b></div>
{NAV.map((n) => (
<button key={n.id} className={"set-nav-item" + (sec === n.id ? " on" : "")} onClick={() => setSec(n.id)}>
<span className="sni-ic">{n.icon}</span>{n.label}
</button>
))}
<div className="set-nav-sp" />
<button className="set-nav-item danger" onClick={() => { close(); onDisconnect(); }}>{I.power}{t("Disconnect")}</button>
</div>
<div className="set-main">
<div className="set-main-h"><h3>{NAV.find((n) => n.id === sec)?.label}</h3>
<button className="x" onClick={close}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{sec === "general" && (
<div className="info-body" style={{ overflow: "auto" }}>
<div className="info-row"><span className="rk">{t("Language")}</span>
<div className="langsel">
{LANGS.map((l) => (
<button key={l.code} className={"langbtn" + (lang === l.code ? " on" : "")} onClick={() => setLang(l.code)} title={l.name}>
<span className="lflag">{l.flag}</span><span className="lname">{l.name}</span>
</button>
))}
</div>
</div>
<div className="info-row"><span className="rk">{t("Base theme")}</span><button className="mbtn ghost" style={{ flex: "none", padding: "6px 14px" }} onClick={toggleTheme}>{light ? t("Light ☀") : t("Dark ☾")}</button></div>
<div className="info-row"><span className="rk">{t("Interface scale")}</span>
<div className="zoomsel">
<button className="zbtn" onClick={() => setZoom(zoom - 0.1)} disabled={zoom <= 0.5} title=""></button>
<button className="zval" onClick={() => setZoom(1)} title={t("Reset")}>{Math.round(zoom * 100)}%</button>
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Code editor")}</span>
<select className="edsel" value={editorId} onChange={(e) => onPickEditor(e.target.value)} title={t("Open code files in this editor")}>
{editors.length === 0 && <option value="default">{t("System default")}</option>}
{editors.map((ed) => (<option key={ed.id} value={ed.id}>{ed.id === "default" ? t("System default") : ed.name}</option>))}
</select>
</div>
<div className="set-sep" />
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
</div>
)}
{sec === "appearance" && <ThemesModal embedded activeId={themeId} onApply={setThemeId} onFlash={onFlash} />}
{sec === "plugins" && <PluginsModal embedded onReload={onReloadPlugins} onFlash={onFlash} />}
</div>
</div>
</div>
);
}
/* ---------------- plugins: manage installed plugins ---------------- */
function PluginsModal({ onClose, onReload, onFlash, embedded }: { onClose?: () => void; onReload: () => Promise<{ loaded: number; errors: string[] }>; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
const [list, setList] = useState<PluginInfo[]>([]);
const [busy, setBusy] = useState(true);
const [dir, setDir] = useState("");
const [reg, setReg] = useState<RegistryEntry[]>([]);
const [regBusy, setRegBusy] = useState(true);
const [regErr, setRegErr] = useState("");
const [installing, setInstalling] = useState<string>("");
const load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
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) {
setInstalling(entry.id);
try { await installFromRegistry(entry); load(); await onReload(); onFlash(t("Installed plugin “{id}”.", { id: entry.name })); }
catch (e) { onFlash(String(e), true); }
finally { setInstalling(""); }
}
async function toggle(p: PluginInfo, enabled: boolean) {
try { await p4.pluginSetEnabled(p.id, enabled); load(); await onReload(); } catch (e) { onFlash(String(e), true); }
}
async function remove(p: PluginInfo) {
try { await p4.pluginRemove(p.id); load(); await onReload(); onFlash(t("Removed plugin “{n}”.", { n: p.name })); } catch (e) { onFlash(String(e), true); }
}
async function install() {
try {
const sel = await openDialog({ directory: true, title: t("Pick a plugin folder (contains plugin.json)") });
if (!sel || Array.isArray(sel)) return;
const id = await p4.pluginInstall(sel);
load(); await onReload();
onFlash(t("Installed plugin “{id}”.", { id }));
} catch (e) { onFlash(String(e), true); }
}
const body = (<>
<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">
{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.map((p) => (
<div key={p.id} className={"pl-card" + (p.enabled ? "" : " off")}>
<div className="pl-main">
<div className="pl-top"><b>{p.name || p.id}</b><span className="pl-ver">v{p.version}</span></div>
{p.description && <div className="pl-desc">{p.description}</div>}
<div className="pl-meta">
{p.author && <span className="pl-tag">{p.author}</span>}
{(p.permissions || []).map((perm: string) => <span key={perm} className="pl-perm">{perm}</span>)}
</div>
</div>
<div className="pl-actions">
<button className={"pl-toggle" + (p.enabled ? " on" : "")} onClick={() => toggle(p, !p.enabled)} title={p.enabled ? t("Disable") : t("Enable")}><span /></button>
<button className="pl-del" onClick={() => remove(p)} title={t("Remove")}>{I.trash}</button>
</div>
</div>
))}
<div className="pl-official-h">{t("Available to install")}<span className="pl-cat-sub">{t("from the Exbyte plugin registry")}</span></div>
{regBusy ? <div className="ur-empty"><span className="ldr" /> {t("Loading the plugin catalog…")}</div>
: 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>
: reg.filter((e) => !list.some((p) => p.id === e.id)).length === 0
? <div className="ur-empty">{I.check}{t("Everything from the catalog is already installed.")}</div>
: reg.filter((e) => !list.some((p) => p.id === e.id)).map((e) => (
<div key={e.id} className="pl-card">
<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="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" style={{ flex: "none", padding: "11px 16px" }} onClick={() => onReload().then(({ loaded }) => onFlash(t("{n} plugin(s) loaded", { n: loaded })))}>{I.sync}{t("Reload")}</button>
<button className="mbtn primary" onClick={install}>{I.open}{t("Install from folder…")}</button>
</div>
</>);
if (embedded) return <div className="set-panel">{body}</div>;
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{t("Plugins")}</h3><span className="ph-sub">{t("{n} installed", { n: list.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{body}
</div>
</div>
);
}
/* a plugin-contributed view, mounted into a plain DOM container */
function PluginViewModal({ view, onClose }: { view: PluginView; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current; if (!el) return;
let cleanup: void | (() => void);
try { cleanup = view.render(el); } catch (e) { el.textContent = String(e); }
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { document.removeEventListener("keydown", k); if (typeof cleanup === "function") { try { cleanup(); } catch {} } };
}, []); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.hex}<h3>{view.title}</h3><span className="ph-sub">{view.pluginId}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="plugin-view" ref={ref} />
</div>
</div>
);
}
/* ---------------- themes (switch + custom theme editor) ---------------- */
function ThemesModal({ activeId, onApply, onClose, onFlash, embedded }: { activeId: string; onApply: (id: string) => void; onClose?: () => void; onFlash: (t: string, e?: boolean) => void; embedded?: boolean }) {
const [themes, setThemes] = useState<Theme[]>(() => allThemes());
const [editing, setEditing] = useState<Theme | null>(null); // custom theme being edited/created
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && (editing ? setEditing(null) : onClose?.()); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [editing]); // eslint-disable-line
// live preview while editing; restore the active theme when leaving the editor
useEffect(() => { if (editing) applyTheme(editing); else applyTheme(resolveTheme(activeId)); }, [editing]); // eslint-disable-line
function refreshList() { setThemes(allThemes()); }
function startNew() {
const base = BUILTIN_THEMES[0]!;
const vars: Record<string, string> = {};
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(base, tk.var);
setEditing({ id: newCustomId(), name: t("My theme"), base: "dark", vars });
}
function startEdit(th: Theme) {
const vars: Record<string, string> = {};
for (const tk of THEME_TOKENS) vars[tk.var] = tokenValue(th, tk.var);
setEditing({ ...th, vars });
}
function saveEditing() {
if (!editing) return;
const customs = loadCustomThemes().filter((c) => c.id !== editing.id);
customs.push({ ...editing, builtin: false });
saveCustomThemes(customs);
refreshList();
setEditing(null);
onApply(editing.id);
onFlash(t("Theme “{n}” saved.", { n: editing.name }));
}
function del(th: Theme) {
saveCustomThemes(loadCustomThemes().filter((c) => c.id !== th.id));
refreshList();
if (activeId === th.id) onApply("dark");
else applyTheme(resolveTheme(activeId));
}
function doExport(th: Theme) {
const blob = new Blob([exportTheme(th)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = `${th.name.replace(/\s+/g, "-").toLowerCase()}.exbyte-theme.json`; a.click();
URL.revokeObjectURL(a.href);
}
function onImportFile(e: { target: HTMLInputElement }) {
const f = e.target.files?.[0]; if (!f) return;
f.text().then((txt) => { const th = importTheme(txt); const customs = loadCustomThemes(); customs.push(th); saveCustomThemes(customs); refreshList(); onApply(th.id); onFlash(t("Theme “{n}” imported.", { n: th.name })); })
.catch((err) => onFlash(String(err), true));
}
const groups = Array.from(new Set(THEME_TOKENS.map((tk) => tk.group)));
const body = (!editing ? (<>
<div className="th-grid">
{themes.map((th) => (
<div key={th.id} className={"th-card" + (th.id === activeId ? " on" : "")} onClick={() => onApply(th.id)}>
<div className="th-swatch" style={{ background: th.vars["--bg"] || (th.base === "light" ? "#eef0f5" : "#0b0b12") }}>
<span style={{ background: th.vars["--panel"] || (th.base === "light" ? "#fff" : "#161621") }} />
<span style={{ background: th.vars["--accent"] || "#7c6ef6" }} />
<span style={{ background: th.vars["--accent-2"] || "#9a8bf9" }} />
</div>
<div className="th-meta"><b>{th.name}</b><span>{th.base === "light" ? t("Light") : t("Dark")}{th.builtin ? "" : " · " + t("custom")}</span></div>
{th.id === activeId && <span className="th-on">{I.check}</span>}
{!th.builtin && (
<div className="th-actions" onClick={(e) => e.stopPropagation()}>
<button title={t("Edit")} onClick={() => startEdit(th)}>{I.pencil}</button>
<button title={t("Export")} onClick={() => doExport(th)}>{I.down}</button>
<button title={t("Delete")} className="danger" onClick={() => del(th)}>{I.trash}</button>
</div>
)}
</div>
))}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<input ref={fileRef} type="file" accept=".json" style={{ display: "none" }} onChange={onImportFile} />
<button className="mbtn ghost" onClick={() => fileRef.current?.click()}>{I.open}{t("Import…")}</button>
<button className="mbtn primary" onClick={startNew}><svg viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>{t("New custom theme")}</button>
</div>
</>) : (<>
<div className="th-edit">
<div className="th-edit-row">
<label>{t("Name")}</label>
<input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
<div className="th-base">
<button className={editing.base === "dark" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "dark" })}>{I.moon} {t("Dark")}</button>
<button className={editing.base === "light" ? "on" : ""} onClick={() => setEditing({ ...editing, base: "light" })}>{I.sun} {t("Light")}</button>
</div>
</div>
{groups.map((g) => (
<div key={g} className="th-group">
<div className="th-group-h">{g}</div>
<div className="th-tokens">
{THEME_TOKENS.filter((tk) => tk.group === g).map((tk) => (
<label key={tk.var} className="th-token">
<input type="color" value={editing.vars[tk.var] || "#000000"} onChange={(e) => setEditing({ ...editing, vars: { ...editing.vars, [tk.var]: e.target.value } })} />
<span>{tk.label}</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={() => setEditing(null)}>{t("Cancel")}</button>
<button className="mbtn ghost" style={{ flex: "none", padding: "11px 16px" }} onClick={() => doExport(editing)}>{I.down}{t("Export")}</button>
<button className="mbtn primary" onClick={saveEditing}>{I.check}{t("Save & apply")}</button>
</div>
</>));
if (embedded) return <div className="set-panel">{body}</div>;
return (
<div className="modal-back" onClick={() => (editing ? setEditing(null) : onClose?.())}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.sun}<h3>{editing ? t("Custom theme") : t("Themes")}</h3>
<span className="ph-sub">{editing ? editing.name : t("{n} themes", { n: themes.length })}</span>
<button className="x" onClick={() => (editing ? setEditing(null) : onClose?.())}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
{body}
</div>
</div>
);
}
const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"];
function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [entries, setEntries] = useState<string[]>([]);
@ -3351,6 +3768,10 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
<div className="files" style={{ position: "relative" }}>
{history.map((c) => {
const sz = sizes?.[c.change || ""];
// "NEW" badge on commits submitted within the last 3 hours (recomputed on
// every history refresh, so it fades away on its own)
const ageMs = c.time ? Date.now() - Number(c.time) * 1000 : Infinity;
const fresh = ageMs >= 0 && ageMs < 3 * 3600 * 1000;
return (
<div key={c.change} className={"hrow click" + (sel === c.change ? " sel" : "")} onClick={() => onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}>
<Avatar user={c.user || "?"} size={26} />
@ -3358,6 +3779,7 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hmeta">#{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? <span className="hsize" title={t("Total size of files in this changelist")}>{humanSize(sz)}</span> : null}</span>
</span>
{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}
</div>
);
})}

View File

@ -1,35 +1,44 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { t } from "./i18n";
type Phase = "idle" | "available" | "downloading" | "done" | "error";
export type UpdPhase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error";
// Non-intrusive update prompt: quietly checks the Gitea Releases endpoint a few
// seconds after launch. If a newer signed version exists, slides a small card into
// the bottom-right corner. The user chooses when to install — nothing is forced.
export default function UpdateBanner() {
export interface UpdaterState {
phase: UpdPhase;
pct: number;
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 [phase, setPhase] = useState<Phase>("idle");
const [phase, setPhase] = useState<UpdPhase>("idle");
const [pct, setPct] = useState(0);
const [err, setErr] = useState("");
const [hidden, setHidden] = useState(false);
const busyRef = useRef(false);
useEffect(() => {
let live = true;
const t = setTimeout(async () => {
try {
const u = await check();
if (live && u) { setUpdate(u); setPhase("available"); }
} catch {
// offline, or no release published yet — stay silent, never nag
}
}, 4000);
return () => { live = false; clearTimeout(t); };
}, []);
async function runCheck(manual: boolean) {
if (busyRef.current || phase === "downloading") return;
busyRef.current = true;
if (manual) setPhase("checking");
try {
const u = await check();
if (u) { setUpdate(u); setPhase("available"); }
else { setPhase(manual ? "uptodate" : "idle"); }
} catch (e) {
setErr(String(e));
if (manual) setPhase("error");
} finally { busyRef.current = false; }
}
async function install() {
if (!update) return;
if (!update) { void runCheck(true); return; }
setPhase("downloading"); setPct(0);
let total = 0, got = 0;
try {
@ -43,42 +52,20 @@ export default function UpdateBanner() {
} catch (e) { setErr(String(e)); setPhase("error"); }
}
if (hidden || phase === "idle" || !update) 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
return (
<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" || phase === "error") && (
<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>
// let the transient "checked, up to date" / "error" state fade back to idle
useEffect(() => {
if (phase !== "uptodate" && phase !== "error") return;
const id = setTimeout(() => setPhase("idle"), 3500);
return () => clearTimeout(id);
}, [phase]);
{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 === "error" && <div className="upd-notes err">{err}</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>
);
return { phase, pct, err, version: update?.version, check: () => runCheck(true), install };
}

View File

@ -73,6 +73,7 @@ const D: Record<string, Tr> = {
"Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" },
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
"No Unreal project in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет проекта Unreal. Сначала выбери папку проекта.", de: "Kein Unreal-Projekt im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucun projet Unreal dans le dossier de travail. Choisissez dabord le dossier du projet.", es: "No hay proyecto Unreal en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
"Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" },
@ -157,6 +158,61 @@ const D: Record<string, Tr> = {
"Included — click to exclude (wont be synced)": { ru: "Включено — клик, чтобы исключить (не будет качаться)", de: "Enthalten — klicken zum Ausschließen (wird nicht synchronisiert)", fr: "Inclus — cliquer pour exclure (ne sera pas synchronisé)", es: "Incluido — clic para excluir (no se sincronizará)" },
"Excluded — click to include (will be synced)": { ru: "Исключено — клик, чтобы включить (будет качаться)", de: "Ausgeschlossen — klicken zum Einschließen (wird synchronisiert)", fr: "Exclu — cliquer pour inclure (sera synchronisé)", es: "Excluido — clic para incluir (se sincronizará)" },
"(no subfolders)": { ru: "(нет подпапок)", de: "(keine Unterordner)", fr: "(aucun sous-dossier)", es: "(sin subcarpetas)" },
"Themes": { ru: "Темы", de: "Themes", fr: "Thèmes", es: "Temas" },
"Themes…": { ru: "Темы…", de: "Themes…", fr: "Thèmes…", es: "Temas…" },
"Switch the color theme or make your own custom theme.": { ru: "Сменить цветовую тему или создать свою.", de: "Farbthema wechseln oder ein eigenes erstellen.", fr: "Changer de thème ou créer le vôtre.", es: "Cambia el tema de color o crea el tuyo." },
"Plugins…": { ru: "Плагины…", de: "Plugins…", fr: "Extensions…", es: "Plugins…" },
"Install and manage plugins that add extra features on top of the core app.": { ru: "Установка и управление плагинами, которые добавляют функции поверх ядра.", de: "Plugins installieren und verwalten, die Funktionen über der Kern-App hinzufügen.", fr: "Installer et gérer des extensions qui ajoutent des fonctions au cœur de lapp.", es: "Instala y gestiona plugins que añaden funciones sobre el núcleo." },
"Custom theme": { ru: "Своя тема", de: "Eigenes Theme", fr: "Thème personnalisé", es: "Tema personalizado" },
"{n} themes": { ru: "{n} тем", de: "{n} Themes", fr: "{n} thèmes", es: "{n} temas" },
"My theme": { ru: "Моя тема", de: "Mein Theme", fr: "Mon thème", es: "Mi tema" },
"Theme “{n}” saved.": { ru: "Тема «{n}» сохранена.", de: "Theme „{n}“ gespeichert.", fr: "Thème « {n} » enregistré.", es: "Tema «{n}» guardado." },
"Theme “{n}” imported.": { ru: "Тема «{n}» импортирована.", de: "Theme „{n}“ importiert.", fr: "Thème « {n} » importé.", es: "Tema «{n}» importado." },
"Light": { ru: "Светлая", de: "Hell", fr: "Clair", es: "Claro" },
"Dark": { ru: "Тёмная", de: "Dunkel", fr: "Sombre", es: "Oscuro" },
"custom": { ru: "своя", de: "eigen", fr: "perso", es: "propio" },
"Export": { ru: "Экспорт", de: "Exportieren", fr: "Exporter", es: "Exportar" },
"Import…": { ru: "Импорт…", de: "Importieren…", fr: "Importer…", es: "Importar…" },
"New custom theme": { ru: "Новая своя тема", de: "Neues eigenes Theme", fr: "Nouveau thème perso", es: "Nuevo tema propio" },
"Save & apply": { ru: "Сохранить и применить", de: "Speichern & anwenden", fr: "Enregistrer et appliquer", es: "Guardar y aplicar" },
"Name": { ru: "Название", de: "Name", fr: "Nom", es: "Nombre" },
"NEW": { ru: "НОВОЕ", de: "NEU", fr: "NOUVEAU", es: "NUEVO" },
"Submitted less than 3 hours ago": { ru: "Засабмичено меньше 3 часов назад", de: "Vor weniger als 3 Stunden übermittelt", fr: "Soumis il y a moins de 3 heures", es: "Enviado hace menos de 3 horas" },
"Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" },
"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" },
"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" },
"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" },
"Update check failed": { ru: "Не удалось проверить обновления", de: "Update-Prüfung fehlgeschlagen", fr: "Échec de la vérification", es: "Fallo al buscar actualizaciones" },
"Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" },
"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" },
"Plugins add features on top of the core app. Browse the catalog below and install what you need — nothing is installed automatically. Toggle plugins on/off; enabled ones load on launch. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Смотри каталог ниже и ставь нужное — ничего не ставится само. Включай/выключай; включённые грузятся при запуске. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins erweitern die Kern-App. Durchsuche den Katalog unten und installiere, was du brauchst — nichts wird automatisch installiert. Plugins ein/aus schalten; aktivierte laden beim Start. Sie laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions enrichissent le cœur de lapp. Parcourez le catalogue ci-dessous et installez ce quil faut — rien nest installé automatiquement. Activez/désactivez ; les activées se chargent au démarrage. Elles sexécutent dans lapp — ninstallez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Explora el catálogo abajo e instala lo que necesites — nada se instala solo. Actívalos/desactívalos; los activos se cargan al iniciar. Se ejecutan en la app — instala solo los de confianza." },
"No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." },
"Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" },
"Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" },
"Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" },
"Available to install": { ru: "Доступно для установки", de: "Verfügbar zur Installation", fr: "Disponibles à linstallation", es: "Disponibles para instalar" },
"from the Exbyte plugin registry": { ru: "из реестра плагинов Exbyte", de: "aus der Exbyte-Plugin-Registry", fr: "depuis le registre de plugins Exbyte", es: "del registro de plugins de Exbyte" },
"Loading the plugin catalog…": { ru: "Загружаю каталог плагинов…", de: "Plugin-Katalog wird geladen…", fr: "Chargement du catalogue…", es: "Cargando el catálogo…" },
"Couldn't reach the plugin registry.": { ru: "Не удалось получить реестр плагинов.", de: "Plugin-Registry nicht erreichbar.", fr: "Registre de plugins injoignable.", es: "No se pudo acceder al registro." },
"Retry": { ru: "Повторить", de: "Erneut", fr: "Réessayer", es: "Reintentar" },
"Everything from the catalog is already installed.": { ru: "Всё из каталога уже установлено.", de: "Alles aus dem Katalog ist bereits installiert.", fr: "Tout le catalogue est déjà installé.", es: "Todo el catálogo ya está instalado." },
"Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" },
"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" },
"Install from folder…": { ru: "Установить из папки…", de: "Aus Ordner installieren…", fr: "Installer depuis un dossier…", es: "Instalar desde carpeta…" },
"Pick a plugin folder (contains plugin.json)": { ru: "Выбери папку плагина (с plugin.json)", de: "Plugin-Ordner wählen (mit plugin.json)", fr: "Choisir un dossier dextension (avec plugin.json)", es: "Elige una carpeta de plugin (con plugin.json)" },
"Installed plugin “{id}”.": { ru: "Плагин «{id}» установлен.", de: "Plugin „{id}“ installiert.", fr: "Extension « {id} » installée.", es: "Plugin «{id}» instalado." },
"Removed plugin “{n}”.": { ru: "Плагин «{n}» удалён.", de: "Plugin „{n}“ entfernt.", fr: "Extension « {n} » supprimée.", es: "Plugin «{n}» eliminado." },
"{n} plugin(s) loaded": { ru: "загружено плагинов: {n}", de: "{n} Plugin(s) geladen", fr: "{n} extension(s) chargée(s)", es: "{n} plugin(s) cargados" },
"From plugin: {p}": { ru: "Из плагина: {p}", de: "Von Plugin: {p}", fr: "De lextension : {p}", es: "Del plugin: {p}" },
"Plugin view": { ru: "Панель плагина", de: "Plugin-Ansicht", fr: "Vue dextension", es: "Vista de plugin" },
"A plugin flagged this commit": { ru: "Плагин отметил этот коммит", de: "Ein Plugin hat diesen Commit markiert", fr: "Une extension a signalé ce commit", es: "Un plugin marcó este commit" },
"A plugin blocked the commit.": { ru: "Плагин заблокировал коммит.", de: "Ein Plugin hat den Commit blockiert.", fr: "Une extension a bloqué le commit.", es: "Un plugin bloqueó el commit." },
"Commit anyway": { ru: "Всё равно закоммитить", de: "Trotzdem committen", fr: "Valider quand même", es: "Confirmar de todos modos" },
"Switch between the folder tree and the raw spec": { ru: "Переключить дерево папок / сырой спек", de: "Zwischen Ordnerbaum und Rohspezifikation wechseln", fr: "Basculer entre larborescence et la spec brute", es: "Alternar entre árbol de carpetas y spec en bruto" },
"Folder tree (include / exclude)": { ru: "Дерево папок (включить / исключить)", de: "Ordnerbaum (ein-/ausschließen)", fr: "Arborescence (inclure / exclure)", es: "Árbol de carpetas (incluir / excluir)" },
"Raw spec text": { ru: "Сырой текст спека", de: "Rohspezifikation", fr: "Spec brute", es: "Spec en bruto" },

View File

@ -108,6 +108,24 @@ export const p4 = {
scanCancel: () => invoke<void>("p4_scan_cancel"),
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
transferCancel: () => invoke<void>("p4_transfer_cancel"),
// --- plugins ---
pluginList: () => invoke<PluginInfo[]>("plugin_list"),
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
// generic HTTP pipe for relay-backed plugins (any http(s) URL, optional bearer)
relayHttp: (url: string, method: string, body?: string, token?: string) =>
invoke<string>("relay_http", { url, method, body, token }),
// shelve everything open in the default changelist → { change, files }
shelveOpen: (description: string) =>
invoke<{ change: string; files: number }>("p4_shelve_open", { description }),
// caller's max protection level on the server (super/admin/write/…)
protectsMax: () => invoke<string>("p4_protects_max"),
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
openPluginsDir: () => invoke<void>("open_plugins_dir"),
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
@ -196,6 +214,7 @@ export function getEditor(): string { try { return localStorage.getItem("exd-edi
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
export interface Dir { dir?: string; local?: boolean; [k: string]: unknown }
export interface PluginInfo { id: string; name: string; version: string; description: string; author: string; permissions: string[]; manifest: Record<string, unknown>; enabled: boolean; dir: string }
// depot path -> short leaf name (last segment)
export function leaf(path?: string): string {
if (!path) return "";
@ -287,6 +306,35 @@ export function fmtTime(t?: string): string {
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
/** Condense `p4 sync` stdout into one short line.
*
* A sync prints one line per file — `//depot/F.uasset#3 - updating d:\ws\F.uasset` —
* so a real Get Latest returns thousands of full paths. Dumping that into a toast
* buries the screen; the per-file detail already streamed into the transfer dialog.
* Short outputs ("File(s) up-to-date.") pass through unchanged. */
export function syncSummary(out: string): string {
const text = (out || "").trim();
if (!text) return "up to date";
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
// p4 says its piece in one line when nothing moved ("File(s) up-to-date.") and
// so do server errors — keep those verbatim; flash() caps the length.
if (lines.length === 1) return lines[0];
let added = 0, updated = 0, deleted = 0;
for (const l of lines) {
if (/ - (added as|refreshing) /.test(l)) added++;
else if (/ - (updating|replacing) /.test(l)) updated++;
else if (/ - deleted as /.test(l)) deleted++;
}
const moved = added + updated + deleted;
// unrecognised shape (server notices, errors) — report the line count, not the text
if (!moved) return `${lines.length.toLocaleString()} lines`;
const parts: string[] = [];
if (added) parts.push(`+${added.toLocaleString()}`);
if (updated) parts.push(`~${updated.toLocaleString()}`);
if (deleted) parts.push(`${deleted.toLocaleString()}`);
return `${moved.toLocaleString()} files (${parts.join(" ")})`;
}
// action -> single-char status + css class
export function statusOf(action?: string): { ch: string; cls: string } {
const a = (action || "").toLowerCase();

227
src/plugins.ts Normal file
View File

@ -0,0 +1,227 @@
// Plugin platform — Host SDK + loader + contribution registry.
//
// Plugins are plain JS/ESM modules (author in TS → compile to JS). Each ships a
// `plugin.json` manifest + a JS entry that exports `activate(host)`. We read the
// entry source from disk (Rust) and import it as an ES module from a blob: URL —
// no eval, works under CSP `script-src blob:`. The plugin only touches the app
// through the `host` object; capabilities are gated by declared permissions.
import { p4, PluginInfo } from "./p4";
import { t } from "./i18n";
import { resolveTheme, loadActiveId } from "./themes";
// ---- contribution shapes ----------------------------------------------------
// `icon` is a NAME from the host icon set (e.g. "ue", "hammer"); the host renders
// the real SVG. Unknown / omitted names fall back to a neutral glyph.
export interface PluginMenuItem { pluginId: string; label: string; icon?: string; run: () => void | Promise<void> }
export interface PluginFileItem { pluginId: string; label: string; icon?: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
export interface PluginFolderItem { pluginId: string; label: string; icon?: string; visible?: () => boolean; run: () => void | Promise<void> }
export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) }
export interface PluginDock { pluginId: string; id: string; title: string; icon?: string; render: (container: HTMLElement) => void | (() => void) }
export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict> }
export type SubmitVerdict = { ok: boolean; message?: string };
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; folderItems: PluginFolderItem[]; views: PluginView[]; docks: PluginDock[]; submitHooks: PluginSubmitHook[] }
const registry: Registry = { menu: [], fileItems: [], folderItems: [], views: [], docks: [], submitHooks: [] };
let version = 0;
const listeners = new Set<() => void>();
function bump() { version++; listeners.forEach((l) => l()); }
export function getRegistry(): Registry { return registry; }
export function registryVersion(): number { return version; }
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.folderItems = []; registry.views = []; registry.docks = []; registry.submitHooks = []; }
// Unreal Engine bridge — the core keeps the build machinery + overlay + log
// reader; the plugin only drives them. Gated by the "ue" permission.
export interface UeBridge {
hasProject: () => boolean;
uproject: () => string;
build: () => void;
launch: () => void;
readLog: (tailBytes?: number) => Promise<string>;
}
// Team bridge — presence + coordinator tools. The plugin drives these; the core
// just exposes the p4 primitives + a generic HTTP pipe to the relay. Gated by
// the "team" permission. Nothing here touches the Perforce server config: shelve
// is an ordinary client-side operation and `relay` talks only to the URL the
// plugin was configured with.
export interface TeamBridge {
whoami: () => { user?: string; client?: string; root?: string } | null;
maxAccess: () => Promise<string>; // "super" | "admin" | "write" | …
openCount: () => Promise<number>; // files open in this workspace
shelveOpen: (desc: string) => Promise<{ change: string; files: number }>;
submitShelved: (change: string) => Promise<string>;
// dumb HTTP pipe to the configured relay (backend does the request; no CORS)
relay: (url: string, method: string, body?: string, token?: string) => Promise<string>;
}
// ---- host context (provided by the app) -------------------------------------
export interface HostContext {
flash: (text: string, err?: boolean) => void;
notify: (title: string, body: string) => void;
info: () => { userName?: string; clientName?: string; clientRoot?: string } | null;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge;
team?: TeamBridge;
}
// ---- the Host SDK a plugin receives -----------------------------------------
export interface HostApi {
version: string;
plugin: { id: string; name: string; dir: string };
perms: string[];
has: (perm: string) => boolean;
p4: Record<string, (...a: unknown[]) => Promise<unknown>>;
ui: {
addMenuItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string }) => void;
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>, opts?: { icon?: string }) => void;
addFolderContextItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string; visible?: () => boolean }) => void;
addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
addDockPanel: (dock: { id: string; title: string; icon?: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict>) => void;
};
flash: (text: string, err?: boolean) => void;
notify: (title: string, body: string) => void;
clipboard: { write: (text: string) => Promise<void> };
storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void };
info: () => ReturnType<HostContext["info"]>;
selectedFile: () => { depotFile?: string } | null;
refresh: () => void;
ue?: UeBridge; // present only with the "ue" permission
team?: TeamBridge; // present only with the "team" permission
theme: () => Record<string, string>;
t: typeof t;
log: (...a: unknown[]) => void;
}
const HOST_VERSION = "1.0";
// p4 methods a plugin may call, split by permission. Only whitelisted, existing methods.
const P4_READ = ["opened", "dirs", "describe", "changeSizes", "clientSpec"] as const;
const P4_WRITE = ["add", "edit", "del", "sync", "reconcile"] as const;
function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
const perms = Array.isArray(info.permissions) ? info.permissions : [];
const has = (p: string) => perms.includes(p);
const p4host: Record<string, (...a: unknown[]) => Promise<unknown>> = {};
const anyP4 = p4 as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>;
if (has("p4:read")) for (const m of P4_READ) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
if (has("p4:write")) for (const m of P4_WRITE) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
const ns = `exd-plugin:${info.id}:`;
const host: HostApi = {
version: HOST_VERSION,
plugin: { id: info.id, name: info.name, dir: info.dir },
perms,
has,
p4: p4host,
ui: {
addMenuItem: (label, run, opts) => { registry.menu.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
addFileContextItem: (label, run, opts) => { registry.fileItems.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
addFolderContextItem: (label, run, opts) => { registry.folderItems.push({ pluginId: info.id, label, icon: opts?.icon, visible: opts?.visible, run }); bump(); },
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); },
addDockPanel: (dock) => { registry.docks.push({ pluginId: info.id, id: dock.id, title: dock.title, icon: dock.icon, render: (el) => dock.render(el, host) }); bump(); },
addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); },
},
flash: ctx.flash,
notify: has("notify") ? ctx.notify : () => {},
clipboard: { write: (text) => navigator.clipboard.writeText(text) },
storage: {
get: (k) => { try { return localStorage.getItem(ns + k); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(ns + k, v); } catch {} },
remove: (k) => { try { localStorage.removeItem(ns + k); } catch {} },
},
info: ctx.info,
selectedFile: ctx.selectedFile,
refresh: ctx.refresh,
theme: () => resolveTheme(loadActiveId()).vars,
t,
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
};
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
if (has("team") && ctx.team) host.team = ctx.team; // Team bridge, permission-gated
return host;
}
// import a plugin's source as an ES module from a blob: URL, then activate it
async function loadOne(info: PluginInfo, ctx: HostContext): Promise<void> {
const code = await p4.pluginReadEntry(info.id);
const blob = new Blob([code], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
try {
const mod = await import(/* @vite-ignore */ url);
const activate = mod.activate || mod.default;
if (typeof activate !== "function") throw new Error("plugin has no activate(host) export");
await activate(makeHost(info, ctx));
} finally {
URL.revokeObjectURL(url);
}
}
/** (Re)load all enabled plugins. Clears the registry first so this is idempotent. */
export async function loadPlugins(ctx: HostContext): Promise<{ loaded: number; errors: string[] }> {
clearRegistry();
const errors: string[] = [];
let loaded = 0;
let list: PluginInfo[] = [];
try { list = await p4.pluginList(); } catch (e) { return { loaded: 0, errors: [String(e)] }; }
for (const info of list) {
if (!info.enabled) continue;
try { await loadOne(info, ctx); loaded++; }
catch (e) { errors.push(`${info.name || info.id}: ${String(e)}`); }
}
bump();
return { loaded, errors };
}
/** Run all plugin submit hooks; returns the first blocking verdict, or ok. */
export async function runSubmitHooks(files: { depotFile?: string }[]): Promise<SubmitVerdict> {
for (const h of registry.submitHooks) {
try { const v = await h.run(files); if (v && !v.ok) return v; }
catch { /* a broken hook must not block submit */ }
}
return { ok: true };
}
// ---- official plugin registry (hosted on Gitea) -----------------------------
// The client fetches registry.json over HTTPS (frontend fetch — no Rust HTTP dep),
// then hands each file to the backend to write into the plugins folder.
export const REGISTRY_URL = "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/registry.json";
export interface RegistryEntry {
id: string; name: string; version: string; author?: string; description?: string;
permissions?: string[]; minAppVersion?: string; official?: boolean; autoInstall?: boolean;
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[]> {
const txt = await p4.registryHttpGet(REGISTRY_URL);
const j = JSON.parse(txt);
return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : [];
}
/** Download a registry plugin's files (backend HTTPS) and write them into the plugins folder. */
export async function installFromRegistry(entry: RegistryEntry): Promise<void> {
for (const f of entry.files) {
const text = await p4.registryHttpGet(entry.baseUrl + f);
await p4.pluginWriteFile(entry.id, f, text);
}
}
/** Install any official plugins marked autoInstall that aren't installed yet. Silent on network failure. */
export async function autoInstallOfficial(installedIds: Set<string>): Promise<string[]> {
let reg: RegistryEntry[] = [];
try { reg = await fetchRegistry(); } catch { return []; }
const done: string[] = [];
for (const e of reg) {
if (e.autoInstall && !installedIds.has(e.id)) {
try { await installFromRegistry(e); done.push(e.id); } catch { /* offline / transient — try next launch */ }
}
}
return done;
}

133
src/themes.ts Normal file
View File

@ -0,0 +1,133 @@
// Theme system — built-in + user-made custom themes.
//
// A theme = a base (dark/light) plus an overlay of CSS-variable values applied as
// inline styles on <body>, which win over the :root / body.light stylesheet vars.
// Everything is CSS-variable driven, so a theme just changes token values.
export type ThemeBase = "dark" | "light";
export interface Theme {
id: string;
name: string;
base: ThemeBase;
builtin?: boolean;
vars: Record<string, string>; // e.g. { "--accent": "#7c6ef6" }
}
// The tokens a user can edit. Grouped for the editor UI. Every one is a color.
export const THEME_TOKENS: { var: string; label: string; group: string }[] = [
{ var: "--accent", label: "Accent", group: "Brand" },
{ var: "--accent-2", label: "Accent light", group: "Brand" },
{ var: "--accent-deep", label: "Accent deep", group: "Brand" },
{ var: "--bg", label: "Background", group: "Surfaces" },
{ var: "--panel", label: "Panel", group: "Surfaces" },
{ var: "--panel-2", label: "Panel 2", group: "Surfaces" },
{ var: "--panel-3", label: "Panel 3", group: "Surfaces" },
{ var: "--elevated", label: "Elevated", group: "Surfaces" },
{ var: "--border", label: "Border", group: "Surfaces" },
{ var: "--txt", label: "Text", group: "Text" },
{ var: "--muted", label: "Muted text", group: "Text" },
{ var: "--faint", label: "Faint text", group: "Text" },
{ var: "--add", label: "Add / success", group: "Status" },
{ var: "--edit", label: "Edit / warning", group: "Status" },
{ var: "--del", label: "Delete / danger", group: "Status" },
];
export const TOKEN_VARS = THEME_TOKENS.map((t) => t.var);
// Built-in presets. "dark"/"light" carry no overlay (pure stylesheet base); the
// rest recolor a few tokens on top of a base to give a distinct look.
export const BUILTIN_THEMES: Theme[] = [
{ id: "dark", name: "Midnight (default)", base: "dark", builtin: true, vars: {} },
{ id: "light", name: "Daylight", base: "light", builtin: true, vars: {} },
{ id: "ember", name: "Ember", base: "dark", builtin: true, vars: { "--accent": "#f0883e", "--accent-2": "#f7a75f", "--accent-deep": "#c96a22", "--bg": "#0f0b07", "--panel": "#1a130c", "--panel-2": "#20180f", "--panel-3": "#281d12" } },
{ id: "forest", name: "Forest", base: "dark", builtin: true, vars: { "--accent": "#35c88a", "--accent-2": "#5fe0a5", "--accent-deep": "#22a06e", "--bg": "#07100b", "--panel": "#0e1a13", "--panel-2": "#12211a", "--panel-3": "#182a20" } },
{ id: "ocean", name: "Ocean", base: "dark", builtin: true, vars: { "--accent": "#3aa0ff", "--accent-2": "#6cbaff", "--accent-deep": "#1f7ce0", "--bg": "#060b12", "--panel": "#0c141f", "--panel-2": "#101a28", "--panel-3": "#152234" } },
{ id: "rose", name: "Rosé (light)", base: "light", builtin: true, vars: { "--accent": "#e0426a", "--accent-2": "#f06489", "--accent-deep": "#c02b52", "--bg": "#faf3f5" } },
];
const CUSTOM_KEY = "exd-themes-custom";
const ACTIVE_KEY = "exd-theme-id";
const LEGACY_KEY = "exd-theme"; // old "light"/"dark" flag
export function loadCustomThemes(): Theme[] {
try {
const raw = localStorage.getItem(CUSTOM_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? (arr as Theme[]).filter((t) => t && t.id && t.vars) : [];
} catch { return []; }
}
export function saveCustomThemes(list: Theme[]) {
try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch {}
}
export function allThemes(): Theme[] {
return [...BUILTIN_THEMES, ...loadCustomThemes()];
}
export function loadActiveId(): string {
try {
const id = localStorage.getItem(ACTIVE_KEY);
if (id) return id;
// migrate from the old dark/light flag
return localStorage.getItem(LEGACY_KEY) === "light" ? "light" : "dark";
} catch { return "dark"; }
}
export function saveActiveId(id: string) {
try {
localStorage.setItem(ACTIVE_KEY, id);
const th = allThemes().find((t) => t.id === id);
localStorage.setItem(LEGACY_KEY, th?.base === "light" ? "light" : "dark"); // keep legacy in sync
} catch {}
}
export function resolveTheme(id: string): Theme {
return allThemes().find((t) => t.id === id) || BUILTIN_THEMES[0]!;
}
// Apply a theme to the document: set the base class, then overlay its var values
// (clearing any previous overlay first).
export function applyTheme(theme: Theme) {
const body = document.body;
for (const v of TOKEN_VARS) body.style.removeProperty(v);
body.classList.toggle("light", theme.base === "light");
for (const [k, val] of Object.entries(theme.vars)) {
if (val) body.style.setProperty(k, val);
}
}
// Read the effective value of a token right now (for seeding the editor). Falls
// back to the theme's overlay or the computed stylesheet value.
export function tokenValue(theme: Theme, varName: string): string {
if (theme.vars[varName]) return theme.vars[varName]!;
const el = document.createElement("div");
el.style.color = `var(${varName})`;
// temporarily set base so computed value reflects the right stylesheet
const hadLight = document.body.classList.contains("light");
document.body.classList.toggle("light", theme.base === "light");
document.body.appendChild(el);
const rgb = getComputedStyle(el).color;
el.remove();
document.body.classList.toggle("light", hadLight);
return rgbToHex(rgb) || "#7c6ef6";
}
function rgbToHex(rgb: string): string | null {
const m = rgb.match(/rgba?\(([^)]+)\)/);
if (!m || !m[1]) return null;
const parts = m[1].split(",").map((x) => parseInt(x.trim(), 10));
const [r, g, b] = parts;
if (r == null || g == null || b == null) return null;
const h = (n: number) => n.toString(16).padStart(2, "0");
return `#${h(r)}${h(g)}${h(b)}`;
}
export function newCustomId(): string {
// no Date.now/Math.random dependency worries in the app runtime; keep it simple
return "custom-" + Math.random().toString(36).slice(2, 9);
}
export function exportTheme(theme: Theme): string {
return JSON.stringify({ name: theme.name, base: theme.base, vars: theme.vars }, null, 2);
}
export function importTheme(json: string): Theme {
const o = JSON.parse(json);
if (!o || typeof o !== "object" || !o.vars) throw new Error("Invalid theme file");
return { id: newCustomId(), name: String(o.name || "Imported theme"), base: o.base === "light" ? "light" : "dark", vars: o.vars };
}