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:
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.4",
|
||||
"version": "0.3.5",
|
||||
"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 }
|
||||
}
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -966,7 +966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
107
src/App.css
107
src/App.css
@ -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)}
|
||||
|
||||
373
src/App.tsx
373
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 "./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>
|
||||
|
||||
41
src/i18n.ts
41
src/i18n.ts
@ -157,6 +157,47 @@ const D: Record<string, Tr> = {
|
||||
"Included — click to exclude (won’t 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 l’app.", 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 l’app. Installez un dossier avec plugin.json, activez/désactivez — elles se chargent automatiquement. Elles s’exécutent dans l’app — n’installez 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 d’extension (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 l’extension : {p}", es: "Del plugin: {p}" },
|
||||
"Plugin view": { ru: "Панель плагина", de: "Plugin-Ansicht", fr: "Vue d’extension", 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 l’arborescence 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" },
|
||||
|
||||
@ -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
148
src/plugins.ts
Normal 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
133
src/themes.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user