From f0fc2f687a69588034b98ffcf711ad3f6b3592fa Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Thu, 9 Jul 2026 13:50:45 +0300 Subject: [PATCH] =?UTF-8?q?v0.3.5=20=E2=80=94=20theme=20system,=20plugin?= =?UTF-8?q?=20platform,=20sectioned=20Settings,=20tab=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 /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 --- PLUGINS.md | 103 ++++++ package.json | 2 +- plugins-examples/hello-exbyte/index.js | 57 ++++ plugins-examples/hello-exbyte/plugin.json | 11 + src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 170 ++++++++++ src-tauri/tauri.conf.json | 6 +- src/App.css | 107 ++++++- src/App.tsx | 373 ++++++++++++++++++++-- src/i18n.ts | 41 +++ src/p4.ts | 9 + src/plugins.ts | 148 +++++++++ src/themes.ts | 133 ++++++++ 14 files changed, 1127 insertions(+), 37 deletions(-) create mode 100644 PLUGINS.md create mode 100644 plugins-examples/hello-exbyte/index.js create mode 100644 plugins-examples/hello-exbyte/plugin.json create mode 100644 src/plugins.ts create mode 100644 src/themes.ts diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..bd91cf9 --- /dev/null +++ b/PLUGINS.md @@ -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.(...) // 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. diff --git a/package.json b/package.json index 0da63e9..78a6276 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "exbyte-depot", "private": true, - "version": "0.3.4", + "version": "0.3.5", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins-examples/hello-exbyte/index.js b/plugins-examples/hello-exbyte/index.js new file mode 100644 index 0000000..b57b8c4 --- /dev/null +++ b/plugins-examples/hello-exbyte/index.js @@ -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"); +} diff --git a/plugins-examples/hello-exbyte/plugin.json b/plugins-examples/hello-exbyte/plugin.json new file mode 100644 index 0000000..e300b70 --- /dev/null +++ b/plugins-examples/hello-exbyte/plugin.json @@ -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 } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a6fcfb..f156413 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "exbyte-depot" -version = "0.3.4" +version = "0.3.5" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a1c4f77..6fd1e87 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e748de0..68d8fb4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 /plugins// 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 { + 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 { + 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) { + 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 { + 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::(&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, 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::(&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 { + 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 /plugins//. Returns the plugin id. +#[tauri::command] +async fn plugin_install(src: String) -> Result { + 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 { + 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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b445f48..53b137d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/App.css b/src/App.css index 5a3b186..6161d3d 100644 --- a/src/App.css +++ b/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)} diff --git a/src/App.tsx b/src/App.tsx index 941d662..02ceee2 100644 --- a/src/App.tsx +++ b/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: , }; -/* ---------------- 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(null); const [session, setSession] = useState(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 = { saveSession(s); setInfo(i); setSession(s); setPhase("main"); }} />; else - content = { 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("changes"); const [modal, setModal] = useState(null); + const [, setPluginVer] = useState(0); // bumped when the plugin registry changes → re-render menus + const [pluginView, setPluginView] = useState(null); // open plugin view modal + const pluginsLoaded = useRef(false); const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); const [files, setFiles] = useState([]); const [checked, setChecked] = useState>(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
+
{tab === "changes" ? ( - <> +
{I.search} { setFilter(e.target.value); setSelRows(new Set()); }} />
@@ -1567,8 +1602,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{t("Commit = local (revertable). Send to the server — Submit, top-right.")}
- + ) : ( +
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")) }, ])} /> +
)} @@ -1665,13 +1702,15 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set )} - {modal && modal.kind !== "users" && setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />} {modal?.kind === "users" && setModal(null)} onFlash={flash} />} {modal?.kind === "search" && 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" && setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />} {modal?.kind === "typemap" && setModal(null)} onFlash={flash} />} + {modal?.kind === "settings" && setModal(null)} />} + {pluginView && setPluginView(null)} />} {modal?.kind === "labels" && setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />} {modal?.kind === "branch" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "streams" && 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") && } - {tab === "log" && } - {tab === "terminal" && } - {tab === "unreal" && } +
+ {tab === "log" && } + {tab === "terminal" && } + {tab === "unreal" && } +
); } @@ -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(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 ( +
+
e.stopPropagation()}> +
+
{I.gear}{t("Settings")}
+ {NAV.map((n) => ( + + ))} +
+ +
+
+

{NAV.find((n) => n.id === sec)?.label}

+ +
+ {sec === "general" && ( +
+
{t("Language")} +
+ {LANGS.map((l) => ( + + ))} +
+
+
{t("Base theme")}
+
{t("Interface scale")} +
+ + + +
+
+
{t("Code editor")} + +
+
+
{t("Server")}{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}
+
{t("User")}{info?.userName || "—"}
+
{t("Workspace")}{info?.clientName || "—"}
+
{t("Server version")}{(info?.serverVersion as string || "").split("/")[2] || "—"}
+
+ )} + {sec === "appearance" && } + {sec === "plugins" && } +
+
+
+ ); +} + +/* ---------------- 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([]); + 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 = (<> +
{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.")}
+
+ {busy ?
{t("Loading…")}
+ : list.length === 0 ?
{I.hex}{t("No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.")}
+ : list.map((p) => ( +
+
+
{p.name || p.id}v{p.version}
+ {p.description &&
{p.description}
} +
+ {p.author && {p.author}} + {(p.permissions || []).map((perm: string) => {perm})} +
+
+
+ + +
+
+ ))} +
+
+ + + +
+ ); + if (embedded) return
{body}
; + return ( +
+
e.stopPropagation()}> +
{I.hex}

{t("Plugins")}

{t("{n} installed", { n: list.length })} + +
+ {body} +
+
+ ); +} + +/* a plugin-contributed view, mounted into a plain DOM container */ +function PluginViewModal({ view, onClose }: { view: PluginView; onClose: () => void }) { + const ref = useRef(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 ( +
+
e.stopPropagation()}> +
{I.hex}

{view.title}

{view.pluginId} + +
+
+
+
+ ); +} + +/* ---------------- 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(() => allThemes()); + const [editing, setEditing] = useState(null); // custom theme being edited/created + const fileRef = useRef(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 = {}; + 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 = {}; + 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 ? (<> +
+ {themes.map((th) => ( +
onApply(th.id)}> +
+ + + +
+
{th.name}{th.base === "light" ? t("Light") : t("Dark")}{th.builtin ? "" : " · " + t("custom")}
+ {th.id === activeId && {I.check}} + {!th.builtin && ( +
e.stopPropagation()}> + + + +
+ )} +
+ ))} +
+
+ + + +
+ ) : (<> +
+
+ + setEditing({ ...editing, name: e.target.value })} /> +
+ + +
+
+ {groups.map((g) => ( +
+
{g}
+
+ {THEME_TOKENS.filter((tk) => tk.group === g).map((tk) => ( + + ))} +
+
+ ))} +
+
+ + + +
+ )); + if (embedded) return
{body}
; + return ( +
(editing ? setEditing(null) : onClose?.())}> +
e.stopPropagation()}> +
{I.sun}

{editing ? t("Custom theme") : t("Themes")}

+ {editing ? editing.name : t("{n} themes", { n: themes.length })} + +
+ {body} +
+
+ ); +} + 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([]); @@ -3351,11 +3674,15 @@ function HistoryList({ history, sizes, busy, sel, onSelect, onContext }: { histo
{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 (
onSelect(c)} onContextMenu={(e) => onContext?.(c, e)}> - {(c.desc || "").trim() || t("(no description)")} + {fresh && {t("NEW")}}{(c.desc || "").trim() || t("(no description)")} #{c.change} · {c.user} · {fmtTime(c.time)}{sz != null && sz > 0 ? {humanSize(sz)} : null}
diff --git a/src/i18n.ts b/src/i18n.ts index 55e6c7b..563d942 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -157,6 +157,47 @@ const D: Record = { "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" }, diff --git a/src/p4.ts b/src/p4.ts index 6c36adb..eb6bd3d 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -108,6 +108,14 @@ export const p4 = { scanCancel: () => invoke("p4_scan_cancel"), // ask an in-progress submit/sync transfer to stop (safe pre-commit for submit) transferCancel: () => invoke("p4_transfer_cancel"), + // --- plugins --- + pluginList: () => invoke("plugin_list"), + pluginReadEntry: (id: string) => invoke("plugin_read_entry", { id }), + pluginSetEnabled: (id: string, enabled: boolean) => invoke("plugin_set_enabled", { id, enabled }), + pluginInstall: (src: string) => invoke("plugin_install", { src }), + pluginRemove: (id: string) => invoke("plugin_remove", { id }), + pluginsDirPath: () => invoke("plugins_dir_path"), + openPluginsDir: () => invoke("open_plugins_dir"), describe: (change: string) => invoke("p4_describe", { change }), undoChange: (change: string) => invoke("p4_undo_change", { change }), openInExplorer: (target: string) => invoke("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; enabled: boolean; dir: string } // depot path -> short leaf name (last segment) export function leaf(path?: string): string { if (!path) return ""; diff --git a/src/plugins.ts b/src/plugins.ts new file mode 100644 index 0000000..ba0208c --- /dev/null +++ b/src/plugins.ts @@ -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 } +export interface PluginFileItem { pluginId: string; label: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise } +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 } +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 Promise>; + ui: { + addMenuItem: (label: string, run: () => void | Promise) => void; + addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise) => void; + addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void; + addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise) => void; + }; + flash: (text: string, err?: boolean) => void; + notify: (title: string, body: string) => void; + clipboard: { write: (text: string) => Promise }; + storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void }; + info: () => ReturnType; + selectedFile: () => { depotFile?: string } | null; + refresh: () => void; + theme: () => Record; + 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 Promise> = {}; + const anyP4 = p4 as unknown as Record Promise>; + 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 { + 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 { + 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 }; +} diff --git a/src/themes.ts b/src/themes.ts new file mode 100644 index 0000000..801cf88 --- /dev/null +++ b/src/themes.ts @@ -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 , 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; // 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 }; +}