11 Commits
v0.3.4 ... main

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

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

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

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

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

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

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

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

View File

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

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.2",
"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)}
@ -838,11 +947,17 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.dropinner svg{width:44px;height:44px}
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
/* re-auth: who we're signing back in as */
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
/* toast */
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
animation:toastin .25s ease;white-space:pre-wrap;text-align:center;
max-height:30vh;overflow-y:auto;overflow-wrap:anywhere}
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
@ -1019,7 +1134,9 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;white-space:pre-line}
.ur-empty svg{width:34px;height:34px;opacity:.35;flex:0 0 auto}
.viewer-msg svg{width:36px;height:36px;opacity:.4;flex:0 0 auto}
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
.ur-dinfo{min-width:0}

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, isAuthError, onAuthLost, saveSession, loadSession, clearSession, saveScope, loadScope, recentScopes, pushRecentScope, fileBytes, getEditor, setEditorPref, P4Info, OpenedFile, Client, Change, Session, Describe, Dir, User, Editor, FileRev, FsEntry, PluginInfo } from "./p4";
import { t, LANG, LANGS, setLangGlobal, Lang } from "./i18n";
import { Theme, THEME_TOKENS, BUILTIN_THEMES, allThemes, loadCustomThemes, saveCustomThemes, loadActiveId, saveActiveId, resolveTheme, applyTheme, tokenValue, newCustomId, exportTheme, importTheme } from "./themes";
import { loadPlugins, getRegistry, subscribeRegistry, runSubmitHooks, PluginView, PluginDock, fetchRegistry, installFromRegistry, RegistryEntry } from "./plugins";
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,13 +399,16 @@ 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
const [needResolve, setNeedResolve] = useState<OpenedFile[]>([]); // files awaiting conflict resolution
const [resolveOpen, setResolveOpen] = useState(false);
const [offline, setOffline] = useState(false); // lost connection to the server
const [reauth, setReauth] = useState(false); // p4 ticket expired — prompt for the password
const [workOffline, setWorkOffline] = useState(() => { try { return localStorage.getItem("exd-workoffline") === "1"; } catch { return false; } }); // explicit offline-work mode
const [behind, setBehind] = useState<{ count: number; sample: string[] } | null>(null); // server has newer content than the workspace
const lastSubmit = useRef<string>(""); // newest submitted CL seen (new-submit toast)
@ -407,9 +422,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 +485,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 +498,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 +630,54 @@ 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
// any p4 command that comes back "not logged in" raises the re-auth prompt
useEffect(() => { onAuthLost(() => setReauth(true)); return () => onAuthLost(null); }, []);
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
@ -731,7 +799,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
checkBehind(); // a new submit landed → am I now behind?
}
}
} catch {
} catch (e) {
// an expired ticket is not an outage: the server answered, it just
// refused us. The re-auth prompt handles it — don't also claim offline.
if (isAuthError(e)) return;
if (alive && !offline) setOffline(true); // p4 unreachable → show offline banner
}
};
@ -870,13 +941,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 +967,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 +991,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 +1051,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 +1109,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 +1319,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 +1430,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 +1441,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 +1494,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 +1549,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 +1657,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 +1668,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 +1727,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 +1738,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 +1781,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} />}
@ -1692,6 +1810,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{showXfer && transfer && xferMin && <TransferChip transfer={transfer} onExpand={() => setXferMin(false)} onCancel={cancelTransfer} />}
{buildPick && <BuildPicker onPick={buildSln} onClose={() => setBuildPick(false)} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{/* last in the tree so it sits over every other overlay — nothing else
can succeed until the session is back */}
{reauth && <ReauthModal session={session} onSignOut={onDisconnect}
onDone={(i) => { setReauth(false); setOffline(false); onInfo(i); flash(t("Reconnected to the server.")); void refresh(); }} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
{ctx && <ContextMenu x={ctx.x} y={ctx.y} items={ctx.items} onClose={() => setCtx(null)} />}
{toast && <div className={"toast" + (toast.err ? " err" : "")}>{toast.text}</div>}
@ -1996,19 +2118,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 +2148,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 +2241,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";
@ -2299,6 +2416,65 @@ function TextPrompt({ title, label, value, onSave, onClose }: { title: string; l
);
}
/* ---------------- re-authenticate (expired ticket) ----------------
Perforce expires the login ticket on its own schedule, so a long day
outlives it and every command starts failing. The server is still there and
the workspace is untouched — only the ticket died — so we ask for the
password over the running app instead of dropping back to the Connect
screen, which would throw away the scope, selection and open panels.
Deliberately not dismissable by clicking away: nothing works until it's
resolved one way or the other. */
function ReauthModal({ session, onDone, onSignOut }: {
session: Session | null; onDone: (i: P4Info) => void; onSignOut: () => void;
}) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
async function reconnect() {
if (!session || !password || busy) return;
setBusy(true); setErr("");
try {
const i = await p4.login(session.server, session.user, password, session.client);
setPassword("");
onDone(i);
} catch (e) { setErr(String(e)); setBusy(false); }
}
return (
<div className="modal-back">
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
<h3>{t("Session expired")}</h3>
<div className="prompt-label">
{t("Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.")}
</div>
<div className="reauth-who">
<span>{session?.user}</span>
<span>{session?.client}</span>
<span>{session?.server}</span>
</div>
<div className="fld"><label>{t("Password")}</label>
<div className="inp">
<svg viewBox="0 0 24 24" fill="none"><rect x="4" y="10" width="16" height="10" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="currentColor" strokeWidth="1.6" /></svg>
<input ref={ref} type="password" value={password} disabled={busy}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && reconnect()} />
</div>
</div>
{err && <div className="err"><span></span><span>{err}</span></div>}
<div className="modal-actions">
<button className="mbtn ghost" onClick={onSignOut} disabled={busy}>{t("Sign out")}</button>
<button className="mbtn primary" onClick={reconnect} disabled={busy || !password}>
{busy ? t("Connecting…") : t("Reconnect")}
</button>
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
const btn = useRef<HTMLButtonElement>(null);
@ -3126,6 +3302,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 +3837,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 +3848,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" },
@ -391,6 +447,11 @@ const D: Record<string, Tr> = {
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
// session expired → re-auth prompt
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },

View File

@ -1,5 +1,31 @@
// Typed wrappers around the Rust p4 commands.
import { invoke } from "@tauri-apps/api/core";
import { invoke as rawInvoke } from "@tauri-apps/api/core";
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
* as opposed to the server being unreachable. Perforce expires tickets on its
* own schedule (often 12h), so a workday can outlive a login. */
export function isAuthError(e: unknown): boolean {
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
.test(String(e));
}
// Set by the app: called when any command comes back with an auth failure, so
// the UI can ask for the password again instead of dead-ending on every action.
let authLost: (() => void) | null = null;
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
// Commands that legitimately report bad credentials — the login screen shows
// those inline, and firing the re-auth prompt from them would loop.
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
/** Every p4 command funnels through here so a lost ticket is caught in one
* place rather than at each of the ~110 call sites. */
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return rawInvoke<T>(cmd, args).catch((e) => {
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
throw e;
});
}
export interface P4Info {
serverAddress?: string;
@ -108,6 +134,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 +240,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 +332,35 @@ export function fmtTime(t?: string): string {
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
/** Condense `p4 sync` stdout into one short line.
*
* A sync prints one line per file — `//depot/F.uasset#3 - updating d:\ws\F.uasset` —
* so a real Get Latest returns thousands of full paths. Dumping that into a toast
* buries the screen; the per-file detail already streamed into the transfer dialog.
* Short outputs ("File(s) up-to-date.") pass through unchanged. */
export function syncSummary(out: string): string {
const text = (out || "").trim();
if (!text) return "up to date";
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
// p4 says its piece in one line when nothing moved ("File(s) up-to-date.") and
// so do server errors — keep those verbatim; flash() caps the length.
if (lines.length === 1) return lines[0];
let added = 0, updated = 0, deleted = 0;
for (const l of lines) {
if (/ - (added as|refreshing) /.test(l)) added++;
else if (/ - (updating|replacing) /.test(l)) updated++;
else if (/ - deleted as /.test(l)) deleted++;
}
const moved = added + updated + deleted;
// unrecognised shape (server notices, errors) — report the line count, not the text
if (!moved) return `${lines.length.toLocaleString()} lines`;
const parts: string[] = [];
if (added) parts.push(`+${added.toLocaleString()}`);
if (updated) parts.push(`~${updated.toLocaleString()}`);
if (deleted) parts.push(`${deleted.toLocaleString()}`);
return `${moved.toLocaleString()} files (${parts.join(" ")})`;
}
// action -> single-char status + css class
export function statusOf(action?: string): { ch: string; cls: string } {
const a = (action || "").toLowerCase();

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 };
}