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>
This commit is contained in:
Bonchellon
2026-07-09 13:50:45 +03:00
parent 4e4e0f215a
commit f0fc2f687a
14 changed files with 1127 additions and 37 deletions

103
PLUGINS.md Normal file
View 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.

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.3.4",
"version": "0.3.5",
"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 }
}

2
src-tauri/Cargo.lock generated
View File

@ -966,7 +966,7 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.3.4"
version = "0.3.5"
dependencies = [
"encoding_rs",
"serde",

View File

@ -1,6 +1,6 @@
[package]
name = "exbyte-depot"
version = "0.3.4"
version = "0.3.5"
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
authors = ["Exbyte Studios"]
edition = "2021"

View File

@ -2868,6 +2868,169 @@ 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());
let enabled = *en.get(&id).unwrap_or(&true);
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(())
}
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 +3122,13 @@ pub fn run() {
p4_scan_stream,
p4_scan_cancel,
p4_transfer_cancel,
plugin_list,
plugin_read_entry,
plugin_set_enabled,
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.3.5",
"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; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
}
},
"bundle": {

View File

@ -116,12 +116,19 @@ 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:active{transform:scale(.97)}
/* sliding underline that glides between the two tabs */
.tab-slider{position:absolute;bottom:-1px;height:2px;width:calc(50% - 32px);border-radius:2px 2px 0 0;pointer-events:none;
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)}
@ -393,6 +400,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 +685,81 @@ 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-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}
@ -726,7 +812,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 +821,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 +846,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{display:inline-block;vertical-align:middle;margin-right:7px;padding:1px 7px;border-radius:6px;font-size:9.5px;font-weight:800;letter-spacing:.6px;color:#fff;
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 +873,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)}

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 "./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 } 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,13 @@ const I = {
open: <svg viewBox="0 0 24 24" fill="none"><path d="M14 4h6v6M20 4l-8 8M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
/* ---------------- 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)];
/* ---------------- 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 +118,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,7 +140,7 @@ 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"); }} />;
@ -326,7 +326,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 +350,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());
@ -610,6 +613,27 @@ 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(); },
});
setPluginVer((v) => v + 1);
return res;
}
useEffect(() => {
if (pluginsLoaded.current || !info?.clientName) return;
pluginsLoaded.current = true;
reloadPlugins().then(({ loaded, errors }) => {
if (errors.length) console.warn("[plugins] load errors:", errors);
if (loaded) flash(t("{n} plugin(s) loaded", { n: loaded }));
}).catch(() => {});
}, [info?.clientName]); // eslint-disable-line
useEffect(() => subscribeRegistry(() => setPluginVer((v) => v + 1)), []);
// live Perforce command log — the Rust backend emits one event per p4 call
useEffect(() => {
let un: (() => void) | undefined; let dead = false;
@ -1028,6 +1052,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 +1262,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: I.hex, act: () => { void Promise.resolve(fi.run(view[i] as { depotFile?: string })).catch((err) => flash(String(err), true)); } })),
]);
}
async function lockFiles(dps: string[], lock: boolean) {
@ -1352,6 +1384,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" }) },
...getRegistry().menu.map((m) => ({ label: m.label, icon: I.hex, 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: (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 },
{ 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" }) },
@ -1460,10 +1494,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 +1602,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 +1613,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>
@ -1665,13 +1702,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} />}
@ -2025,9 +2064,11 @@ 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} />}
{tab === "unreal" && <UnrealTab uproject={uproject} />}
</div>
</div>
);
}
@ -3126,6 +3167,288 @@ 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 load = () => { setBusy(true); p4.pluginList().then(setList).catch((e) => onFlash(String(e), true)).finally(() => setBusy(false)); };
useEffect(() => { load(); 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 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. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.")}</div>
<div className="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>
<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,11 +3674,15 @@ 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} />
<span className="hbody">
<span className="hdesc">{(c.desc || "").trim() || t("(no description)")}</span>
<span className="hdesc">{fresh && <span className="hnew" title={t("Submitted less than 3 hours ago")}>{t("NEW")}</span>}{(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>
</div>

View File

@ -157,6 +157,47 @@ 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" },
"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. Install a folder with a plugin.json, toggle plugins on/off, then they load automatically. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Установи папку с plugin.json, включай/выключай — они грузятся автоматически. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins fügen Funktionen zur Kern-App hinzu. Installiere einen Ordner mit plugin.json, schalte Plugins ein/aus — sie laden automatisch. Plugins laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions ajoutent des fonctions au cœur de lapp. Installez un dossier avec plugin.json, activez/désactivez — elles se chargent automatiquement. Elles sexécutent dans lapp — ninstallez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Instala una carpeta con plugin.json, actívalos/desactívalos y se cargan solos. Se ejecutan en la app — instala solo los de confianza." },
"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" },
"Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" },
"Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" },
"Install from folder…": { ru: "Установить из папки…", de: "Aus Ordner installieren…", fr: "Installer depuis un dossier…", es: "Instalar desde carpeta…" },
"Pick a plugin folder (contains plugin.json)": { ru: "Выбери папку плагина (с plugin.json)", de: "Plugin-Ordner wählen (mit plugin.json)", fr: "Choisir un dossier dextension (avec plugin.json)", es: "Elige una carpeta de plugin (con plugin.json)" },
"Installed plugin “{id}”.": { ru: "Плагин «{id}» установлен.", de: "Plugin „{id}“ installiert.", fr: "Extension « {id} » installée.", es: "Plugin «{id}» instalado." },
"Removed plugin “{n}”.": { ru: "Плагин «{n}» удалён.", de: "Plugin „{n}“ entfernt.", fr: "Extension « {n} » supprimée.", es: "Plugin «{n}» eliminado." },
"{n} plugin(s) loaded": { ru: "загружено плагинов: {n}", de: "{n} Plugin(s) geladen", fr: "{n} extension(s) chargée(s)", es: "{n} plugin(s) cargados" },
"From plugin: {p}": { ru: "Из плагина: {p}", de: "Von Plugin: {p}", fr: "De lextension : {p}", es: "Del plugin: {p}" },
"Plugin view": { ru: "Панель плагина", de: "Plugin-Ansicht", fr: "Vue dextension", es: "Vista de plugin" },
"A plugin flagged this commit": { ru: "Плагин отметил этот коммит", de: "Ein Plugin hat diesen Commit markiert", fr: "Une extension a signalé ce commit", es: "Un plugin marcó este commit" },
"A plugin blocked the commit.": { ru: "Плагин заблокировал коммит.", de: "Ein Plugin hat den Commit blockiert.", fr: "Une extension a bloqué le commit.", es: "Un plugin bloqueó el commit." },
"Commit anyway": { ru: "Всё равно закоммитить", de: "Trotzdem committen", fr: "Valider quand même", es: "Confirmar de todos modos" },
"Switch between the folder tree and the raw spec": { ru: "Переключить дерево папок / сырой спек", de: "Zwischen Ordnerbaum und Rohspezifikation wechseln", fr: "Basculer entre larborescence et la spec brute", es: "Alternar entre árbol de carpetas y spec en bruto" },
"Folder tree (include / exclude)": { ru: "Дерево папок (включить / исключить)", de: "Ordnerbaum (ein-/ausschließen)", fr: "Arborescence (inclure / exclure)", es: "Árbol de carpetas (incluir / excluir)" },
"Raw spec text": { ru: "Сырой текст спека", de: "Rohspezifikation", fr: "Spec brute", es: "Spec en bruto" },

View File

@ -108,6 +108,14 @@ 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 }),
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 +204,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 "";

148
src/plugins.ts Normal file
View File

@ -0,0 +1,148 @@
// 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 ----------------------------------------------------
export interface PluginMenuItem { pluginId: string; label: string; run: () => void | Promise<void> }
export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
export interface PluginView { pluginId: string; id: string; title: 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[]; views: PluginView[]; submitHooks: PluginSubmitHook[] }
const registry: Registry = { menu: [], fileItems: [], views: [], 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.views = []; registry.submitHooks = []; }
// ---- 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;
}
// ---- 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>) => void;
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>) => void;
addView: (view: { id: string; title: 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;
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) => { registry.menu.push({ pluginId: info.id, label, run }); bump(); },
addFileContextItem: (label, run) => { registry.fileItems.push({ pluginId: info.id, label, run }); bump(); },
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.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),
};
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 };
}

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