v0.3.5 — theme system, plugin platform, sectioned Settings, tab animations

- Themes: built-in presets (Ember/Forest/Ocean/Rosé) + custom-theme editor
  (per-token color pickers, live preview, JSON export/import). src/themes.ts.
- Plugin platform: JS/ESM plugins loaded via blob-module import (CSP
  script-src blob:), Host SDK with menu / file-context / view / submit-hook
  contribution points + permission model. Backend commands to list/read/enable/
  install/remove plugins under <config>/plugins/. Example plugin + PLUGINS.md.
- Settings redesigned into a sectioned hub (General / Appearance / Plugins);
  Themes and Plugins moved in, removed from the Actions menu.
- "NEW" badge on History commits submitted < 3h ago.
- Smooth tab transitions: sliding underline + content fade on Changes/History,
  and matching fades on the view-panel and dock tab groups.
- Fix: settings close button used the default browser style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-09 13:50:45 +03:00
parent 4e4e0f215a
commit f0fc2f687a
14 changed files with 1127 additions and 37 deletions

View File

@ -2868,6 +2868,169 @@ async fn save_ai_key(key: String) -> Result<(), String> {
std::fs::write(&p, key.trim()).map_err(|e| e.to_string())
}
// ================= plugins =================
// Plugins live in <config>/plugins/<id>/ with a plugin.json manifest and a JS
// entry. The frontend reads the entry source and imports it as an ES module in a
// controlled Host-SDK scope. Enable/disable state is a small enabled.json map.
fn plugins_dir() -> Option<std::path::PathBuf> {
let app = APP.get()?;
let dir = app.path().app_config_dir().ok()?.join("plugins");
let _ = std::fs::create_dir_all(&dir);
Some(dir)
}
fn plugin_enabled_map() -> std::collections::HashMap<String, bool> {
plugins_dir()
.map(|d| d.join("enabled.json"))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_plugin_enabled_map(m: &std::collections::HashMap<String, bool>) {
if let Some(d) = plugins_dir() {
let _ = std::fs::write(d.join("enabled.json"), serde_json::to_string_pretty(m).unwrap_or_default());
}
}
fn find_plugin_dir(id: &str) -> Option<std::path::PathBuf> {
let root = plugins_dir()?;
for e in std::fs::read_dir(&root).ok()?.flatten() {
if !e.path().is_dir() { continue; }
let mp = e.path().join("plugin.json");
if let Ok(txt) = std::fs::read_to_string(&mp) {
if let Ok(m) = serde_json::from_str::<Value>(&txt) {
let mid = m.get("id").and_then(|v| v.as_str()).map(String::from)
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
if mid == id { return Some(e.path()); }
}
}
}
None
}
/// List installed plugins (manifest + enabled flag).
#[tauri::command]
async fn plugin_list() -> Result<Vec<Value>, String> {
let dir = plugins_dir().ok_or("No config dir")?;
let en = plugin_enabled_map();
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
if !e.path().is_dir() { continue; }
let Ok(txt) = std::fs::read_to_string(e.path().join("plugin.json")) else { continue };
let Ok(m) = serde_json::from_str::<Value>(&txt) else { continue };
let id = m.get("id").and_then(|v| v.as_str()).map(String::from)
.unwrap_or_else(|| e.file_name().to_string_lossy().to_string());
let enabled = *en.get(&id).unwrap_or(&true);
out.push(serde_json::json!({
"id": id,
"name": m.get("name").and_then(|v| v.as_str()).unwrap_or(""),
"version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"),
"description": m.get("description").and_then(|v| v.as_str()).unwrap_or(""),
"author": m.get("author").and_then(|v| v.as_str()).unwrap_or(""),
"permissions": m.get("permissions").cloned().unwrap_or(Value::Array(vec![])),
"manifest": m,
"enabled": enabled,
"dir": e.path().to_string_lossy(),
}));
}
}
Ok(out)
}
/// Read a plugin's JS entry source (guards against path escape).
#[tauri::command]
async fn plugin_read_entry(id: String) -> Result<String, String> {
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(pdir.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let entry = manifest.get("entry").and_then(|v| v.as_str()).unwrap_or("index.js");
if entry.contains("..") || entry.starts_with('/') || entry.starts_with('\\') {
return Err("Invalid entry path".into());
}
let full = pdir.join(entry);
// ensure the resolved path stays inside the plugin dir
let base = pdir.canonicalize().map_err(|e| e.to_string())?;
let target = full.canonicalize().map_err(|e| e.to_string())?;
if !target.starts_with(&base) {
return Err("Entry escapes plugin folder".into());
}
std::fs::read_to_string(&target).map_err(|e| e.to_string())
}
#[tauri::command]
async fn plugin_set_enabled(id: String, enabled: bool) -> Result<(), String> {
let mut m = plugin_enabled_map();
m.insert(id, enabled);
write_plugin_enabled_map(&m);
Ok(())
}
fn copy_dir_recursive(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
let e = e?;
let dest = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir_recursive(&e.path(), &dest)?;
} else {
std::fs::copy(e.path(), dest)?;
}
}
Ok(())
}
/// Install a plugin from a local folder that contains plugin.json → copies it
/// into <config>/plugins/<id>/. Returns the plugin id.
#[tauri::command]
async fn plugin_install(src: String) -> Result<String, String> {
let srcp = std::path::PathBuf::from(&src);
if !srcp.join("plugin.json").exists() {
return Err("Folder has no plugin.json".into());
}
let manifest: Value = serde_json::from_str(&std::fs::read_to_string(srcp.join("plugin.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let id = manifest.get("id").and_then(|v| v.as_str()).ok_or("plugin.json missing \"id\"")?.to_string();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
return Err("Invalid plugin id (use letters, digits, - _ .)".into());
}
let dest = plugins_dir().ok_or("No config dir")?.join(&id);
if dest.exists() {
let _ = std::fs::remove_dir_all(&dest); // reinstall / update
}
copy_dir_recursive(&srcp, &dest).map_err(|e| e.to_string())?;
Ok(id)
}
#[tauri::command]
async fn plugin_remove(id: String) -> Result<(), String> {
let pdir = find_plugin_dir(&id).ok_or("Plugin not found")?;
std::fs::remove_dir_all(&pdir).map_err(|e| e.to_string())?;
let mut m = plugin_enabled_map();
m.remove(&id);
write_plugin_enabled_map(&m);
Ok(())
}
#[tauri::command]
async fn plugins_dir_path() -> Result<String, String> {
plugins_dir().map(|d| d.to_string_lossy().to_string()).ok_or_else(|| "No config dir".into())
}
#[tauri::command]
async fn open_plugins_dir() -> Result<(), String> {
let dir = plugins_dir().ok_or("No config dir")?;
let mut c = Command::new("explorer");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.creation_flags(0x0800_0000);
}
c.arg(dir);
c.spawn().map_err(|e| e.to_string())?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default()
@ -2959,6 +3122,13 @@ pub fn run() {
p4_scan_stream,
p4_scan_cancel,
p4_transfer_cancel,
plugin_list,
plugin_read_entry,
plugin_set_enabled,
plugin_install,
plugin_remove,
plugins_dir_path,
open_plugins_dir,
p4_undo_change,
open_in_explorer,
find_uproject,