Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc88269baf | |||
| 9fc81015d7 | |||
| 294d10eb40 | |||
| 8e7908ae09 | |||
| f0fc2f687a | |||
| 4e4e0f215a | |||
| 09b8f9d842 | |||
| 1c1153eee9 |
94
HotTODO.md
Normal file
94
HotTODO.md
Normal 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 — система плагинов / аддонов
|
||||
**Идея:** фичи выпускаются как **отдельные плагины**, которые докачиваются по необходимости и не засоряют основной софт. Ядро остаётся лёгким; тяжёлое/нишевое (в т.ч. фичи 1–8 ниже) живёт в плагинах. Это фундамент — стоит сделать рано, тогда остальное можно поставлять как плагины.
|
||||
|
||||
**Реалистичный дизайн под 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. Перенести 1–2 фичи из списка ниже в плагины как эталон (напр. визуальный дифф или 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 (ценность сначала):** сделать 1–2 фичи инлайн ради быстрого «вау», потом построить плагин-систему и вынести их в плагины как эталон. Быстрее польза, но потом рефактор.
|
||||
|
||||
Рекомендую **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 — уже как плагины.
|
||||
103
PLUGINS.md
Normal file
103
PLUGINS.md
Normal file
@ -0,0 +1,103 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
57
plugins-examples/hello-exbyte/index.js
Normal file
57
plugins-examples/hello-exbyte/index.js
Normal 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");
|
||||
}
|
||||
11
plugins-examples/hello-exbyte/plugin.json
Normal file
11
plugins-examples/hello-exbyte/plugin.json
Normal 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 }
|
||||
}
|
||||
51
plugins-examples/unreal/index.js
Normal file
51
plugins-examples/unreal/index.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Unreal Engine Tools — official plugin.
|
||||
// The core app keeps the build engine (UnrealBuildTool / MSBuild), the build
|
||||
// overlay and the log reader; this plugin is just the Unreal-facing surface.
|
||||
// It renders natively: the actions sit in the working-folder right-click menu
|
||||
// with the host's Unreal + hammer icons, and the log is a bottom-dock tab —
|
||||
// exactly where these lived before, just delivered as a plugin. Reached through
|
||||
// the permission-gated `host.ue` bridge.
|
||||
|
||||
export function activate(host) {
|
||||
const ue = host.ue;
|
||||
if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; }
|
||||
|
||||
// Right-click the working folder → Launch / Build, shown only on a UE project.
|
||||
// Prefer the native folder-context slot (host ≥ 0.3.7); fall back to the top
|
||||
// menu on older hosts so the actions are always reachable.
|
||||
const addAction = (label, run, icon) => {
|
||||
if (host.ui.addFolderContextItem) host.ui.addFolderContextItem(label, run, { icon, visible: () => ue.hasProject() });
|
||||
else host.ui.addMenuItem(label, () => { if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } run(); }, { icon });
|
||||
};
|
||||
addAction("Launch Unreal Engine", () => ue.launch(), "ue");
|
||||
addAction("Build Solution (.sln)", () => ue.build(), "hammer");
|
||||
|
||||
// Bottom-dock "Unreal" tab — tails the editor log (Saved/Logs), refreshing live.
|
||||
// Native dock panel on host ≥ 0.3.7; a modal view as a fallback on older hosts.
|
||||
const mountLog = (el) => {
|
||||
el.style.cssText = "display:flex;flex-direction:column;height:100%";
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.cssText =
|
||||
"flex:1;margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap;word-break:break-word;" +
|
||||
"font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)";
|
||||
el.appendChild(pre);
|
||||
let alive = true;
|
||||
async function tick() {
|
||||
if (!alive) return;
|
||||
try {
|
||||
if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; }
|
||||
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
|
||||
const txt = await ue.readLog(200000);
|
||||
pre.textContent = txt || "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.";
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
} catch (e) { pre.textContent = String(e); }
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1500);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
};
|
||||
if (host.ui.addDockPanel) host.ui.addDockPanel({ id: "unreal", title: "Unreal", icon: "ue", render: mountLog });
|
||||
else host.ui.addView({ id: "unreal-log", title: "Unreal Log", render: mountLog });
|
||||
|
||||
host.log("Unreal Engine Tools activated");
|
||||
}
|
||||
12
plugins-examples/unreal/plugin.json
Normal file
12
plugins-examples/unreal/plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "unreal",
|
||||
"name": "Unreal Engine Tools",
|
||||
"version": "1.2.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Build the game (.sln / UnrealBuildTool), launch the editor, and tail the Unreal log. Actions live in the working-folder right-click menu; the log is a bottom-dock tab. Enable on Unreal Engine projects.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.6",
|
||||
"permissions": ["ue"],
|
||||
"defaultEnabled": true,
|
||||
"contributes": { "folderContext": true, "dock": ["unreal"] }
|
||||
}
|
||||
211
src-tauri/Cargo.lock
generated
211
src-tauri/Cargo.lock
generated
@ -468,6 +468,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@ -558,6 +575,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@ -966,9 +992,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.2"
|
||||
version = "0.3.8"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -1279,8 +1306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1302,8 +1331,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1572,6 +1604,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2042,6 +2075,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
@ -2784,6 +2823,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@ -2812,7 +2907,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2822,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2834,6 +2940,21 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@ -2909,6 +3030,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@ -3046,6 +3205,7 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@ -3093,6 +3253,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -3315,6 +3481,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@ -3385,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3709,7 +3887,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3858,7 +4036,7 @@ checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3917,7 +4095,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@ -4692,6 +4870,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@ -4757,6 +4945,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.2"
|
||||
version = "0.3.9"
|
||||
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]
|
||||
|
||||
@ -2868,6 +2868,208 @@ 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())
|
||||
}
|
||||
|
||||
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 +3161,15 @@ 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,
|
||||
plugin_install,
|
||||
plugin_remove,
|
||||
plugins_dir_path,
|
||||
open_plugins_dir,
|
||||
p4_undo_change,
|
||||
open_in_explorer,
|
||||
find_uproject,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.3.2",
|
||||
"version": "0.3.9",
|
||||
"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": {
|
||||
|
||||
180
src/App.css
180
src/App.css
@ -94,7 +94,11 @@ input,textarea,[contenteditable="true"],
|
||||
.tgroup{display:flex;min-width:0;position:relative}
|
||||
.tgroup .tzone{flex:1;min-width:0}
|
||||
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
|
||||
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
|
||||
.tzone:last-child{border-right:none}
|
||||
.tzone:hover{background:var(--hover)}
|
||||
/* keep the labels/icon high-contrast on hover in every theme (fixes greyed-out text) */
|
||||
.tzone:hover .val{color:var(--txt)}.tzone:hover .lbl,.tzone:hover .sub{color:var(--muted)}
|
||||
.sync:hover .ic{color:var(--accent-2)}.sync:hover .val{color:var(--txt)}.sync:hover .sub{color:var(--muted)}
|
||||
|
||||
/* draggable panel dividers */
|
||||
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
|
||||
@ -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 */
|
||||
@ -448,10 +475,31 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.xfer-ttl b{font-size:14.5px}
|
||||
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xfer-cancel{margin-left:auto;flex:0 0 auto;align-self:flex-start;font-size:11.5px;font-weight:600;cursor:pointer;
|
||||
.xfer-actions{margin-left:auto;flex:0 0 auto;align-self:flex-start;display:flex;align-items:center;gap:8px}
|
||||
.xfer-cancel{flex:0 0 auto;font-size:11.5px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
|
||||
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||
.xfer-cancel:disabled{opacity:.55;cursor:default}
|
||||
.xfer-min{flex:0 0 auto;width:26px;height:26px;display:grid;place-items:center;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;transition:.15s}
|
||||
.xfer-min:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.xfer-min svg{width:15px;height:15px}
|
||||
/* minimized transfer chip (bottom-right; sits above the build chip if both show) */
|
||||
.xferchip{position:fixed;right:18px;bottom:18px;z-index:361;display:flex;align-items:center;gap:11px;
|
||||
padding:9px 11px 9px 12px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
|
||||
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;min-width:210px;max-width:320px;overflow:hidden}
|
||||
.xferchip.up{border-color:rgba(124,110,246,.5)}
|
||||
.xferchip.down{border-color:rgba(62,207,142,.5)}
|
||||
.xferchip:hover{filter:brightness(1.06)}
|
||||
.xc-ic{flex:0 0 auto;display:grid;place-items:center}
|
||||
.xc-txt{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.xc-txt b{font-size:12.5px;font-weight:600}
|
||||
.xc-txt span{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xc-cancel{flex:0 0 auto;width:22px;height:22px;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
|
||||
.xc-cancel:hover:not(:disabled){background:var(--danger,#e5484d);color:#fff}
|
||||
.xc-cancel:disabled{opacity:.5;cursor:default}
|
||||
.xc-bar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:transparent}
|
||||
.xc-bar span{display:block;height:100%;background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease}
|
||||
.xfer-bar{margin-top:14px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
|
||||
@ -632,6 +680,104 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
background:var(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto;
|
||||
box-shadow:inset 3px 0 0 var(--border-soft)}
|
||||
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
|
||||
/* workspace View include/exclude tree (P4V-style) */
|
||||
.vt-modes{margin-left:auto;display:flex;gap:2px;background:var(--panel-3);border:1px solid var(--border);border-radius:9px;padding:2px}
|
||||
.vt-modes button{width:28px;height:24px;display:grid;place-items:center;border:none;background:none;color:var(--faint);border-radius:7px;cursor:pointer;transition:.15s}
|
||||
.vt-modes button svg{width:15px;height:15px}
|
||||
.vt-modes button:hover{color:var(--txt)}
|
||||
.vt-modes button.on{background:var(--elevated);color:var(--accent-2)}
|
||||
.vt-tree{flex:1;min-height:0;overflow:auto;background:var(--elevated);padding:6px 4px}
|
||||
.vt-node{user-select:none}
|
||||
.vt-row{display:flex;align-items:center;gap:7px;height:26px;border-radius:7px;padding-right:8px;cursor:default}
|
||||
.vt-row:hover{background:#ffffff08}
|
||||
.vt-chev{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--faint);cursor:pointer}
|
||||
.vt-chev svg{width:13px;height:13px;transition:transform .12s ease}
|
||||
.vt-chk{width:16px;height:16px;flex:0 0 auto;border-radius:4px;border:1.5px solid var(--border);display:grid;place-items:center;cursor:pointer;transition:.12s;background:var(--panel)}
|
||||
.vt-chk:hover{border-color:var(--accent)}
|
||||
.vt-chk.on{background:var(--accent);border-color:var(--accent)}
|
||||
.vt-chk.mixed{background:var(--faint);border-color:var(--faint)}
|
||||
.vt-chk svg{width:12px;height:12px}
|
||||
.vt-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
|
||||
.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}
|
||||
@ -657,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)}
|
||||
@ -684,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)}
|
||||
@ -692,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}
|
||||
@ -717,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}
|
||||
@ -741,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)}
|
||||
@ -778,7 +929,8 @@ body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.modal-card h3{font-size:17px;font-weight:700;margin-bottom:8px}
|
||||
.modal-card p{font-size:13px;line-height:1.55;color:var(--muted);white-space:pre-wrap;margin-bottom:22px}
|
||||
.modal-actions{display:flex;gap:10px}
|
||||
.mbtn{flex:1;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn svg{width:15px;height:15px;flex:0 0 auto}
|
||||
.mbtn.ghost{background:var(--panel);border:1px solid var(--border);color:var(--txt)}
|
||||
.mbtn.ghost:hover{background:var(--hover)}
|
||||
.mbtn.primary{color:#fff;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7)}
|
||||
@ -976,7 +1128,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}
|
||||
|
||||
692
src/App.tsx
692
src/App.tsx
@ -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, 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>(" | ||||