From b8a020097a6adab80099c0830da2dd9f794876ae Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 11:27:27 +0000 Subject: [PATCH 01/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 7e35d0aa..7b271750 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 7e35d0aab7c80f0df7ffff98a28117a4bbc33fd1 +Subproject commit 7b27175048d80f10f50c42dd1d60595efae3e7b6 From 807d7bdf8c6b8b3df247bf1f3e3de10a700ed036 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 12:36:24 +0000 Subject: [PATCH 02/96] Removed functions.exec --- Backend/src/plugin_bundles.rs | 72 ++++++++++++++------------- Backend/src/tauri_commands/plugins.rs | 18 +++---- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index bfb2ff2f..0af8c347 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -116,13 +116,6 @@ pub struct PluginManifestModule { pub vcs_backends: Vec, } -/// Manifest functions component declaration. -#[derive(Debug, Deserialize)] -pub struct PluginManifestFunctions { - #[serde(default)] - pub exec: Option, -} - /// Parsed plugin manifest payload. #[derive(Debug, Deserialize)] pub struct PluginManifest { @@ -138,7 +131,7 @@ pub struct PluginManifest { #[serde(default)] pub module: Option, #[serde(default)] - pub functions: Option, + pub functions: Option, } /// Returns current Unix timestamp in milliseconds. @@ -226,13 +219,6 @@ pub struct ModuleComponent { pub vcs_backends: Vec<(String, Option)>, } -/// Installed functions component metadata and resolved executable path. -#[derive(Debug, Clone)] -pub struct FunctionsComponent { - pub exec: String, - pub exec_path: PathBuf, -} - /// Active component metadata for a plugin selected by `current.json`. #[derive(Debug, Clone)] pub struct InstalledPluginComponents { @@ -242,7 +228,6 @@ pub struct InstalledPluginComponents { pub default_enabled: bool, pub requested_capabilities: Vec, pub module: Option, - pub functions: Option, } impl PluginBundleStore { @@ -528,13 +513,15 @@ impl PluginBundleStore { )); } - let (module_exec, functions_exec) = ( - normalize_exec(manifest.module.and_then(|m| m.exec)), - normalize_exec(manifest.functions.and_then(|f| f.exec)), - ); + if manifest.functions.is_some() { + return Err( + "manifest uses unsupported field 'functions'; use module.exec only".to_string(), + ); + } + + let module_exec = normalize_exec(manifest.module.and_then(|m| m.exec)); validate_entrypoint(&staging_version_dir, module_exec.as_deref(), "module")?; - validate_entrypoint(&staging_version_dir, functions_exec.as_deref(), "functions")?; // Promote staged version into place (flat layout, drop old version directory). if plugin_dir.exists() { @@ -814,6 +801,12 @@ impl PluginBundleStore { .map_err(|e| format!("read {}: {e}", manifest_path.display()))?; let manifest: PluginManifest = serde_json::from_str(&text) .map_err(|e| format!("parse {}: {e}", manifest_path.display()))?; + if manifest.functions.is_some() { + return Err(format!( + "manifest {} uses unsupported field 'functions'; use module.exec only", + manifest_path.display() + )); + } let id = manifest.id.trim().to_string(); if id.is_empty() { return Err("manifest id is empty".to_string()); @@ -876,22 +869,10 @@ impl PluginBundleStore { }) }); - let functions = manifest.functions.and_then(|f| { - let exec = f.exec?.trim().to_string(); - if exec.is_empty() { - return None; - } - let exec_path = version_dir.join("bin").join(platform_exec_name(&exec)); - Some(FunctionsComponent { exec, exec_path }) - }); - // Validate that declared entrypoints exist (defense-in-depth; installer should have ensured). if let Some(m) = &module { validate_entrypoint(&version_dir, Some(&m.exec), "module")?; } - if let Some(f) = &functions { - validate_entrypoint(&version_dir, Some(&f.exec), "functions")?; - } Ok(Some(InstalledPluginComponents { plugin_id: id, @@ -904,7 +885,6 @@ impl PluginBundleStore { requested_capabilities, module, - functions, })) } @@ -1464,6 +1444,30 @@ mod tests { assert!(err.is_err()); } + #[test] + /// Verifies installer rejects deprecated functions component manifests. + /// + /// # Returns + /// - `()`. + fn install_rejects_functions_component() { + let bundle = make_tar_xz_bundle(vec![TarEntry { + name: "test.plugin/openvcs.plugin.json".into(), + data: basic_manifest("test.plugin", ",\"functions\":{\"exec\":\"legacy.wasm\"}"), + unix_mode: None, + kind: TarEntryKind::File, + }]); + + let (_tmp, bundle_path) = write_bundle_to_temp(&bundle); + let store_root = tempdir().unwrap(); + let store = PluginBundleStore::new_at(store_root.path().to_path_buf()); + + let err = store.install_ovcsp(&bundle_path).unwrap_err(); + assert_eq!( + err, + "manifest uses unsupported field 'functions'; use module.exec only" + ); + } + #[test] /// Verifies installer accepts valid tar.xz bundles. /// diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index e58b5376..17f938c5 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -106,7 +106,7 @@ pub fn approve_plugin_capabilities( } #[tauri::command] -/// Lists callable functions exported by a plugin's functions component. +/// Lists callable functions exported by a plugin module component. /// /// # Parameters /// - `plugin_id`: Plugin id to inspect. @@ -119,8 +119,8 @@ pub fn list_plugin_functions(plugin_id: String) -> Result { let Some(components) = store.load_current_components(plugin_id.trim())? else { return Err("plugin not installed".to_string()); }; - let Some(functions) = components.functions else { - return Err("plugin has no functions component".to_string()); + let Some(module) = components.module else { + return Err("plugin has no module component".to_string()); }; let installed = store @@ -130,8 +130,8 @@ pub fn list_plugin_functions(plugin_id: String) -> Result { let rpc = StdioRpcProcess::new( SpawnConfig { plugin_id: components.plugin_id, - component_label: "functions".into(), - exec_path: functions.exec_path, + component_label: "module".into(), + exec_path: module.exec_path, args: Vec::new(), requested_capabilities: installed.requested_capabilities, approval: installed.approval, @@ -166,8 +166,8 @@ pub fn invoke_plugin_function( let Some(components) = store.load_current_components(plugin_id.trim())? else { return Err("plugin not installed".to_string()); }; - let Some(functions) = components.functions else { - return Err("plugin has no functions component".to_string()); + let Some(module) = components.module else { + return Err("plugin has no module component".to_string()); }; let installed = store @@ -177,8 +177,8 @@ pub fn invoke_plugin_function( let rpc = StdioRpcProcess::new( SpawnConfig { plugin_id: components.plugin_id, - component_label: "functions".into(), - exec_path: functions.exec_path, + component_label: "module".into(), + exec_path: module.exec_path, args: Vec::new(), requested_capabilities: installed.requested_capabilities, approval: installed.approval, From 0da05edd04b5725fe6c3ac647d3babf517f694ba Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 13:36:07 +0000 Subject: [PATCH 03/96] update --- Backend/built-in-plugins/Git | 2 +- Backend/src/lib.rs | 4 + Backend/src/plugin_bundles.rs | 2 +- Backend/src/plugin_runtime/manager.rs | 361 ++++++++++++++++++++++++ Backend/src/plugin_runtime/mod.rs | 3 + Backend/src/plugin_runtime/stdio_rpc.rs | 14 +- Backend/src/plugin_runtime/vcs_proxy.rs | 2 +- Backend/src/plugin_vcs_backends.rs | 28 +- Backend/src/settings.rs | 30 ++ Backend/src/state.rs | 12 + Backend/src/tauri_commands/backends.rs | 2 +- Backend/src/tauri_commands/plugins.rs | 147 +++------- Backend/src/tauri_commands/settings.rs | 7 +- Backend/src/tauri_commands/themes.rs | 18 +- 14 files changed, 482 insertions(+), 150 deletions(-) create mode 100644 Backend/src/plugin_runtime/manager.rs diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 7b271750..b07515b7 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 7b27175048d80f10f50c42dd1d60595efae3e7b6 +Subproject commit b07515b72a243ac8b6b5390e4e4fb3e30a4c7a64 diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index f4b2f511..a88e5113 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -144,6 +144,10 @@ pub fn run() { ); } } + let state = app.state::(); + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: failed to sync runtime on startup: {}", err); + } // On startup, optionally reopen the last repository if enabled in settings. try_reopen_last_repo(app.handle()); diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 0af8c347..a6b925ed 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -260,7 +260,7 @@ impl PluginBundleStore { /// /// # Returns /// - Store instance rooted at `root`. - fn new_at(root: PathBuf) -> Self { + pub(crate) fn new_at(root: PathBuf) -> Self { Self { root } } diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs new file mode 100644 index 00000000..23129b4b --- /dev/null +++ b/Backend/src/plugin_runtime/manager.rs @@ -0,0 +1,361 @@ +use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; +use crate::plugin_runtime::stdio_rpc::{RpcConfig, RpcError, SpawnConfig, StdioRpcProcess}; +use crate::settings::AppConfig; +use parking_lot::Mutex; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +#[derive(Clone)] +struct ModuleRuntimeSpec { + plugin_id: String, + key: String, + default_enabled: bool, + spawn: SpawnConfig, +} + +/// Owns long-lived module plugin processes and coordinates lifecycle actions. +pub struct PluginRuntimeManager { + store: PluginBundleStore, + processes: Mutex>>, +} + +impl Default for PluginRuntimeManager { + /// Creates a runtime manager backed by the default plugin bundle store. + /// + /// # Returns + /// - Default [`PluginRuntimeManager`]. + fn default() -> Self { + Self::new(PluginBundleStore::new_default()) + } +} + +impl PluginRuntimeManager { + /// Creates a runtime manager using a specific plugin bundle store. + /// + /// # Parameters + /// - `store`: Plugin bundle store used to resolve installed components. + /// + /// # Returns + /// - New runtime manager instance. + pub fn new(store: PluginBundleStore) -> Self { + Self { + store, + processes: Mutex::new(HashMap::new()), + } + } + + /// Starts a plugin module process if needed. + /// + /// This operation is idempotent. If the plugin is already running, it + /// validates readiness and returns success. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(())` when the plugin is running. + /// - `Err(String)` when plugin lookup/startup fails. + pub fn start_plugin(&self, plugin_id: &str) -> Result<(), String> { + let key = normalize_plugin_key(plugin_id)?; + if let Some(existing) = self.processes.lock().get(&key).cloned() { + return existing.ensure_running(); + } + let spec = self.resolve_module_runtime_spec(plugin_id)?; + self.start_plugin_spec(spec) + } + + /// Stops a plugin module process when present. + /// + /// This operation is idempotent. Stopping an already-stopped plugin + /// returns success. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(())` after stop/removal. + /// - `Err(String)` if the identifier is invalid. + pub fn stop_plugin(&self, plugin_id: &str) -> Result<(), String> { + let key = normalize_plugin_key(plugin_id)?; + let process = self.processes.lock().remove(&key); + if let Some(process) = process { + process.stop(); + } + Ok(()) + } + + /// Synchronizes runtime process state with current persisted plugin settings. + /// + /// # Returns + /// - `Ok(())` when sync succeeds. + /// - `Err(String)` when one or more start/stop operations fail. + pub fn sync_plugin_runtime(&self) -> Result<(), String> { + let cfg = AppConfig::load_or_default(); + self.sync_plugin_runtime_with_config(&cfg) + } + + /// Synchronizes runtime process state with an explicit configuration snapshot. + /// + /// # Parameters + /// - `cfg`: Application config used to determine enabled plugins. + /// + /// # Returns + /// - `Ok(())` when sync succeeds. + /// - `Err(String)` when one or more start/stop operations fail. + pub fn sync_plugin_runtime_with_config(&self, cfg: &AppConfig) -> Result<(), String> { + let components = self.store.list_current_components()?; + let mut desired_running = HashSet::new(); + let mut errors = Vec::new(); + + for component in components { + let plugin_id = component.plugin_id.trim(); + if plugin_id.is_empty() || component.module.is_none() { + continue; + } + + let key = plugin_id.to_ascii_lowercase(); + if cfg.is_plugin_enabled(plugin_id, component.default_enabled) { + desired_running.insert(key.clone()); + if let Err(err) = self.start_plugin(plugin_id) { + errors.push(format!("start {}: {}", plugin_id, err)); + } + } + } + + let running: Vec = self.processes.lock().keys().cloned().collect(); + for plugin_id in running { + if !desired_running.contains(&plugin_id) { + if let Err(err) = self.stop_plugin(&plugin_id) { + errors.push(format!("stop {}: {}", plugin_id, err)); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + /// Calls a module RPC method through the persistent plugin process. + /// + /// # Parameters + /// - `cfg`: App config snapshot used to enforce enabled-state checks. + /// - `plugin_id`: Plugin identifier. + /// - `method`: RPC method name. + /// - `params`: JSON method params. + /// + /// # Returns + /// - `Ok(Value)` plugin RPC result. + /// - `Err(String)` when plugin is disabled/not available or RPC fails. + pub fn call_module_method_with_config( + &self, + cfg: &AppConfig, + plugin_id: &str, + method: &str, + params: Value, + ) -> Result { + let spec = self.resolve_module_runtime_spec(plugin_id)?; + if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { + return Err(format!("plugin `{}` is disabled", spec.plugin_id)); + } + + self.start_plugin_spec(spec.clone())?; + let rpc = self + .processes + .lock() + .get(&spec.key) + .cloned() + .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id))?; + rpc.call(method, params).map_err(format_rpc_error) + } + + fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { + if let Some(existing) = self.processes.lock().get(&spec.key).cloned() { + return existing.ensure_running(); + } + + let rpc = Arc::new(StdioRpcProcess::new( + spec.spawn.clone(), + RpcConfig::default(), + )); + rpc.ensure_running()?; + + let mut lock = self.processes.lock(); + if let Some(existing) = lock.get(&spec.key).cloned() { + drop(lock); + return existing.ensure_running(); + } + lock.insert(spec.key, rpc); + Ok(()) + } + + fn resolve_module_runtime_spec(&self, plugin_id: &str) -> Result { + let requested = plugin_id.trim(); + if requested.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let components = self.find_components(requested)?; + let module = components + .module + .ok_or_else(|| "plugin has no module component".to_string())?; + let installed = self + .store + .get_current_installed(&components.plugin_id)? + .ok_or_else(|| "plugin is not installed".to_string())?; + let key = components.plugin_id.to_ascii_lowercase(); + + Ok(ModuleRuntimeSpec { + plugin_id: components.plugin_id.clone(), + key, + default_enabled: components.default_enabled, + spawn: SpawnConfig { + plugin_id: components.plugin_id, + component_label: "module".into(), + exec_path: module.exec_path, + args: Vec::new(), + requested_capabilities: installed.requested_capabilities, + approval: installed.approval, + allowed_workspace_root: None, + }, + }) + } + + fn find_components(&self, plugin_id: &str) -> Result { + self.store + .list_current_components()? + .into_iter() + .find(|components| components.plugin_id.eq_ignore_ascii_case(plugin_id)) + .ok_or_else(|| "plugin not installed".to_string()) + } +} + +fn normalize_plugin_key(plugin_id: &str) -> Result { + let plugin_id = plugin_id.trim().to_ascii_lowercase(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + Ok(plugin_id) +} + +fn format_rpc_error(err: RpcError) -> String { + format!("{}: {}", err.code, err.message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_bundles::{ApprovalState, CurrentPointer, InstalledPluginIndex}; + use crate::plugin_bundles::{InstalledPluginVersion, PluginBundleStore}; + use std::collections::BTreeMap; + use std::fs; + use tempfile::tempdir; + + const MINIMAL_WASM: &[u8] = &[ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, + 0x02, 0x01, 0x00, 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, + ]; + + #[test] + fn start_and_stop_are_idempotent() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "test.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + manager.start_plugin("test.plugin").expect("start"); + manager.start_plugin("TEST.PLUGIN").expect("start twice"); + assert_eq!(manager.processes.lock().len(), 1); + + manager.stop_plugin("test.plugin").expect("stop"); + manager.stop_plugin("test.plugin").expect("stop twice"); + assert!(manager.processes.lock().is_empty()); + } + + #[test] + fn sync_tracks_enabled_state() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "alpha.plugin", true); + write_plugin(temp.path(), "beta.plugin", false); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let mut cfg = AppConfig::default(); + cfg.plugins.disabled.clear(); + cfg.plugins.enabled.clear(); + + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("initial sync"); + assert!(manager.processes.lock().contains_key("alpha.plugin")); + assert!(!manager.processes.lock().contains_key("beta.plugin")); + + cfg.plugins.disabled = vec!["alpha.plugin".into()]; + cfg.plugins.enabled = vec!["beta.plugin".into()]; + cfg.validate(); + + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("second sync"); + assert!(!manager.processes.lock().contains_key("alpha.plugin")); + assert!(manager.processes.lock().contains_key("beta.plugin")); + } + + fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); + fs::write(plugin_dir.join("bin").join("plugin.wasm"), MINIMAL_WASM).expect("write wasm"); + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Test Plugin", + "version": "1.0.0", + "default_enabled": default_enabled, + "module": { + "exec": "plugin.wasm", + "vcs_backends": [] + } + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } +} diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 3137e468..b68c5fad 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,3 +1,6 @@ pub mod events; +pub mod manager; pub mod stdio_rpc; pub mod vcs_proxy; + +pub use manager::PluginRuntimeManager; diff --git a/Backend/src/plugin_runtime/stdio_rpc.rs b/Backend/src/plugin_runtime/stdio_rpc.rs index c4b6f587..ca3acb05 100644 --- a/Backend/src/plugin_runtime/stdio_rpc.rs +++ b/Backend/src/plugin_runtime/stdio_rpc.rs @@ -1,5 +1,7 @@ use crate::plugin_bundles::ApprovalState; -use crate::plugin_runtime::events::{register_plugin_io, PluginIoHandle, PluginStdin}; +use crate::plugin_runtime::events::{ + register_plugin_io, unregister_plugin, PluginIoHandle, PluginStdin, +}; use openvcs_core::models::VcsEvent; use openvcs_core::plugin_protocol::{PluginMessage, RpcRequest, RpcResponse}; use serde_json::Value; @@ -326,6 +328,8 @@ impl StdioRpcProcess { /// # Returns /// - `()`. fn kill_process(&self) { + unregister_plugin(&self.spawn.plugin_id); + // Drop stdin so the child sees EOF. *self.stdin.lock().unwrap() = None; @@ -358,6 +362,14 @@ impl StdioRpcProcess { } } + /// Explicitly stops the running plugin process. + /// + /// # Returns + /// - `()`. + pub fn stop(&self) { + self.kill_process(); + } + /// Spawns the WASI plugin process and wire-up IO/event threads. /// /// # Returns diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 4d582cc5..a7f19871 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -49,7 +49,7 @@ impl PluginVcsProxy { plugin_id, component_label: format!("vcs-backend-{}", backend_id.as_ref()), exec_path, - args: vec!["--backend".into(), backend_id.as_ref().to_string()], + args: Vec::new(), requested_capabilities, approval, allowed_workspace_root: Some(workdir.clone()), diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 5a6314ee..47baeb8e 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -23,34 +23,8 @@ use std::{ /// - `true` when plugin should be active. /// - `false` otherwise. fn is_plugin_enabled_in_settings(plugin_id: &str, default_enabled: bool) -> bool { - let plugin_id = plugin_id.trim().to_lowercase(); - if plugin_id.is_empty() { - return false; - } - let cfg = AppConfig::load_or_default(); - let disabled: Vec = cfg - .plugins - .disabled - .iter() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(); - if disabled.iter().any(|id| id == &plugin_id) { - return false; - } - - let enabled: Vec = cfg - .plugins - .enabled - .iter() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(); - - // `enabled` is additive (explicit opt-in), not an allowlist. - // Plugins remain active by their manifest default unless explicitly disabled. - default_enabled || enabled.iter().any(|id| id == &plugin_id) + cfg.is_plugin_enabled(plugin_id, default_enabled) } /// Metadata describing a single plugin-provided backend implementation. diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index ddc98c58..afb8db8a 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -705,6 +705,36 @@ impl AppConfig { fs::rename(tmp, p) } + /// Returns whether a plugin should be considered enabled by current settings. + /// + /// # Parameters + /// - `plugin_id`: Plugin id to evaluate. + /// - `default_enabled`: Manifest-provided default enabled flag. + /// + /// # Returns + /// - `true` when plugin should be active. + /// - `false` otherwise. + pub fn is_plugin_enabled(&self, plugin_id: &str, default_enabled: bool) -> bool { + let plugin_id = plugin_id.trim().to_ascii_lowercase(); + if plugin_id.is_empty() { + return false; + } + if self + .plugins + .disabled + .iter() + .any(|id| id.trim().eq_ignore_ascii_case(&plugin_id)) + { + return false; + } + default_enabled + || self + .plugins + .enabled + .iter() + .any(|id| id.trim().eq_ignore_ascii_case(&plugin_id)) + } + /// Future-proof migrations between schema versions. /// /// # Returns diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 39938d19..f0a4d859 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -7,6 +7,7 @@ use log::{debug, info}; use parking_lot::RwLock; use crate::output_log::OutputLogEntry; +use crate::plugin_runtime::PluginRuntimeManager; use crate::repo::Repo; use crate::repo_settings::RepoConfig; use crate::settings::AppConfig; @@ -73,6 +74,9 @@ pub struct AppState { /// MRU list for “Recents” recents: RwLock>, + + /// Long-lived plugin process runtime manager. + plugin_runtime: PluginRuntimeManager, } impl AppState { @@ -270,6 +274,14 @@ impl AppState { pub fn recents(&self) -> Vec { self.recents.read().clone() } + + /// Returns the shared plugin runtime manager. + /// + /// # Returns + /// - Plugin runtime manager reference. + pub fn plugin_runtime(&self) -> &PluginRuntimeManager { + &self.plugin_runtime + } } // ────────────────────────────────────────────────────────────────────────────── diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 6bf52d59..6e4430d5 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -217,7 +217,7 @@ pub async fn call_vcs_backend_method( plugin_id: desc_clone.plugin_id, component_label: format!("vcs-backend-{}", backend_id_clone), exec_path: desc_clone.exec_path, - args: vec!["--backend".into(), backend_id_clone.clone()], + args: Vec::new(), requested_capabilities: desc_clone.requested_capabilities, approval: desc_clone.approval, allowed_workspace_root, diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 17f938c5..482cb832 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,10 +1,11 @@ use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; use crate::plugins; +use crate::state::AppState; +use log::warn; use serde_json::Value; use tauri::Emitter; use tauri::Manager; -use tauri::{Runtime, Window}; +use tauri::{Runtime, State, Window}; #[tauri::command] /// Lists plugin summaries discovered by the backend. @@ -40,6 +41,7 @@ pub fn load_plugin(id: String) -> Result { /// - `Err(String)` when installation fails. pub async fn install_ovcsp( window: Window, + state: State<'_, AppState>, bundle_path: String, ) -> Result { let store = PluginBundleStore::new_default(); @@ -56,6 +58,10 @@ pub async fn install_ovcsp( ); } + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: runtime sync after install failed: {}", err); + } + Ok(installed) } @@ -78,8 +84,10 @@ pub fn list_installed_bundles() -> Result, String> { /// # Returns /// - `Ok(())` when removal succeeds. /// - `Err(String)` when validation/removal fails. -pub fn uninstall_plugin(plugin_id: String) -> Result<(), String> { - PluginBundleStore::new_default().uninstall_plugin(plugin_id.trim()) +pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + state.plugin_runtime().stop_plugin(&plugin_id)?; + PluginBundleStore::new_default().uninstall_plugin(&plugin_id) } #[tauri::command] @@ -94,15 +102,21 @@ pub fn uninstall_plugin(plugin_id: String) -> Result<(), String> { /// - `Ok(())` when state is updated. /// - `Err(String)` when update fails. pub fn approve_plugin_capabilities( + state: State<'_, AppState>, plugin_id: String, version: String, approved: bool, ) -> Result<(), String> { - PluginBundleStore::new_default().approve_capabilities( - plugin_id.trim(), - version.trim(), - approved, - ) + let plugin_id = plugin_id.trim().to_string(); + PluginBundleStore::new_default().approve_capabilities(&plugin_id, version.trim(), approved)?; + + if !approved { + let _ = state.plugin_runtime().stop_plugin(&plugin_id); + } else if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: runtime sync after approval failed: {}", err); + } + + Ok(()) } #[tauri::command] @@ -114,36 +128,17 @@ pub fn approve_plugin_capabilities( /// # Returns /// - `Ok(Value)` containing function descriptors. /// - `Err(String)` when plugin lookup or RPC fails. -pub fn list_plugin_functions(plugin_id: String) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(module) = components.module else { - return Err("plugin has no module component".to_string()); - }; - - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "module".into(), - exec_path: module.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); - - let v = rpc - .call("functions.list", Value::Null) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) +pub fn list_plugin_functions( + state: State<'_, AppState>, + plugin_id: String, +) -> Result { + let cfg = state.config(); + state.plugin_runtime().call_module_method_with_config( + &cfg, + plugin_id.trim(), + "functions.list", + Value::Null, + ) } #[tauri::command] @@ -158,42 +153,18 @@ pub fn list_plugin_functions(plugin_id: String) -> Result { /// - `Ok(Value)` function result payload. /// - `Err(String)` when invocation fails. pub fn invoke_plugin_function( + state: State<'_, AppState>, plugin_id: String, function_id: String, args: Value, ) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(module) = components.module else { - return Err("plugin has no module component".to_string()); - }; - - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "module".into(), - exec_path: module.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); - - let v = rpc - .call( - "functions.invoke", - serde_json::json!({ "id": function_id.trim(), "args": args }), - ) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) + let cfg = state.config(); + state.plugin_runtime().call_module_method_with_config( + &cfg, + plugin_id.trim(), + "functions.invoke", + serde_json::json!({ "id": function_id.trim(), "args": args }), + ) } #[tauri::command] @@ -208,43 +179,19 @@ pub fn invoke_plugin_function( /// - `Ok(Value)` method result payload. /// - `Err(String)` when lookup/validation/RPC fails. pub fn call_plugin_module_method( + state: State<'_, AppState>, plugin_id: String, method: String, params: Option, ) -> Result { - let store = PluginBundleStore::new_default(); - let Some(components) = store.load_current_components(plugin_id.trim())? else { - return Err("plugin not installed".to_string()); - }; - let Some(module) = components.module else { - return Err("plugin has no module component".to_string()); - }; - - let installed = store - .get_current_installed(&components.plugin_id)? - .ok_or_else(|| "plugin is not installed".to_string())?; - let method = method.trim(); if method.is_empty() { return Err("method is empty".to_string()); } - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: components.plugin_id, - component_label: "module".into(), - exec_path: module.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, - approval: installed.approval, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); - + let cfg = state.config(); let params = params.unwrap_or(Value::Null); - let v = rpc - .call(method, params) - .map_err(|e| format!("{}: {}", e.code, e.message))?; - Ok(v) + state + .plugin_runtime() + .call_module_method_with_config(&cfg, plugin_id.trim(), method, params) } diff --git a/Backend/src/tauri_commands/settings.rs b/Backend/src/tauri_commands/settings.rs index 88d681b6..3ccdf7d0 100644 --- a/Backend/src/tauri_commands/settings.rs +++ b/Backend/src/tauri_commands/settings.rs @@ -31,7 +31,12 @@ pub fn get_global_settings(state: State<'_, AppState>) -> Result, cfg: AppConfig) -> Result<(), String> { - state.set_config(cfg) + state.set_config(cfg.clone())?; + state + .plugin_runtime() + .sync_plugin_runtime_with_config(&cfg) + .map_err(|err| format!("settings saved but plugin runtime sync failed: {err}"))?; + Ok(()) } #[tauri::command] diff --git a/Backend/src/tauri_commands/themes.rs b/Backend/src/tauri_commands/themes.rs index 8fc4039f..11237be0 100644 --- a/Backend/src/tauri_commands/themes.rs +++ b/Backend/src/tauri_commands/themes.rs @@ -10,29 +10,13 @@ use tauri::State; /// # Returns /// - Lowercase set of enabled plugin ids. fn enabled_plugins(cfg: &settings::AppConfig) -> HashSet { - let disabled: HashSet = cfg - .plugins - .disabled - .iter() - .map(|s| s.trim().to_ascii_lowercase()) - .collect(); - let enabled: HashSet = cfg - .plugins - .enabled - .iter() - .map(|s| s.trim().to_ascii_lowercase()) - .collect(); - let mut out = HashSet::new(); for p in plugins::list_plugins() { let id = p.id.trim().to_ascii_lowercase(); if id.is_empty() { continue; } - if disabled.contains(&id) { - continue; - } - if enabled.contains(&id) || p.default_enabled { + if cfg.is_plugin_enabled(&id, p.default_enabled) { out.insert(id); } } From 8d6829068d781612095f8232a20e327cac180241 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 13:59:34 +0000 Subject: [PATCH 04/96] update --- .../src/plugin_runtime/component_instance.rs | 42 +++++++++++++++++ Backend/src/plugin_runtime/instance.rs | 13 ++++++ Backend/src/plugin_runtime/manager.rs | 46 ++++++++++++++----- Backend/src/plugin_runtime/mod.rs | 3 ++ Backend/src/plugin_runtime/stdio_instance.rs | 36 +++++++++++++++ Backend/src/plugin_runtime/stdio_rpc.rs | 28 ++++++++++- 6 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 Backend/src/plugin_runtime/component_instance.rs create mode 100644 Backend/src/plugin_runtime/instance.rs create mode 100644 Backend/src/plugin_runtime/stdio_instance.rs diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs new file mode 100644 index 00000000..81079982 --- /dev/null +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -0,0 +1,42 @@ +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use serde_json::Value; +use std::path::PathBuf; + +/// Component-model runtime instance placeholder. +/// +/// This is intentionally minimal in the current migration slice: runtime +/// detection and selection are wired, and component execution support is the +/// next implementation step. +pub struct ComponentPluginRuntimeInstance { + plugin_id: String, + exec_path: PathBuf, +} + +impl ComponentPluginRuntimeInstance { + /// Creates a new component runtime instance placeholder. + pub fn new(plugin_id: String, exec_path: PathBuf) -> Self { + Self { + plugin_id, + exec_path, + } + } +} + +impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { + fn ensure_running(&self) -> Result<(), String> { + Err(format!( + "component runtime not implemented yet for plugin `{}` ({})", + self.plugin_id, + self.exec_path.display() + )) + } + + fn call(&self, _method: &str, _params: Value) -> Result { + Err(format!( + "component runtime not implemented yet for plugin `{}`", + self.plugin_id + )) + } + + fn stop(&self) {} +} diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs new file mode 100644 index 00000000..32476d3d --- /dev/null +++ b/Backend/src/plugin_runtime/instance.rs @@ -0,0 +1,13 @@ +use serde_json::Value; + +/// Runtime instance abstraction used by the plugin runtime manager. +pub trait PluginRuntimeInstance: Send + Sync { + /// Ensures the underlying runtime instance is started. + fn ensure_running(&self) -> Result<(), String>; + + /// Calls a plugin method and returns JSON payload. + fn call(&self, method: &str, params: Value) -> Result; + + /// Stops the runtime instance. + fn stop(&self); +} diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 23129b4b..da2b0f9f 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -1,10 +1,16 @@ use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, RpcError, SpawnConfig, StdioRpcProcess}; +use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::stdio_instance::StdioPluginRuntimeInstance; +use crate::plugin_runtime::stdio_rpc::SpawnConfig; use crate::settings::AppConfig; use parking_lot::Mutex; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use std::path::Path; use std::sync::Arc; +use wasmtime::component::Component; +use wasmtime::Engine; #[derive(Clone)] struct ModuleRuntimeSpec { @@ -17,7 +23,7 @@ struct ModuleRuntimeSpec { /// Owns long-lived module plugin processes and coordinates lifecycle actions. pub struct PluginRuntimeManager { store: PluginBundleStore, - processes: Mutex>>, + processes: Mutex>>, } impl Default for PluginRuntimeManager { @@ -169,7 +175,7 @@ impl PluginRuntimeManager { .get(&spec.key) .cloned() .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id))?; - rpc.call(method, params).map_err(format_rpc_error) + rpc.call(method, params) } fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { @@ -177,21 +183,34 @@ impl PluginRuntimeManager { return existing.ensure_running(); } - let rpc = Arc::new(StdioRpcProcess::new( - spec.spawn.clone(), - RpcConfig::default(), - )); - rpc.ensure_running()?; + let instance = self.create_instance(&spec); + instance.ensure_running()?; let mut lock = self.processes.lock(); if let Some(existing) = lock.get(&spec.key).cloned() { drop(lock); return existing.ensure_running(); } - lock.insert(spec.key, rpc); + lock.insert(spec.key, instance); Ok(()) } + fn create_instance(&self, spec: &ModuleRuntimeSpec) -> Arc { + if is_component_module(&spec.spawn.exec_path) { + return Arc::new(ComponentPluginRuntimeInstance::new( + spec.spawn.plugin_id.clone(), + spec.spawn.exec_path.clone(), + )); + } + + log::warn!( + "plugin runtime: using deprecated stdio fallback for plugin `{}` ({})", + spec.spawn.plugin_id, + spec.spawn.exec_path.display() + ); + Arc::new(StdioPluginRuntimeInstance::new(spec.spawn.clone())) + } + fn resolve_module_runtime_spec(&self, plugin_id: &str) -> Result { let requested = plugin_id.trim(); if requested.is_empty() { @@ -241,8 +260,13 @@ fn normalize_plugin_key(plugin_id: &str) -> Result { Ok(plugin_id) } -fn format_rpc_error(err: RpcError) -> String { - format!("{}: {}", err.code, err.message) +fn is_component_module(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + let engine = Engine::default(); + Component::from_file(&engine, path).is_ok() } #[cfg(test)] diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index b68c5fad..7041db99 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,5 +1,8 @@ +pub mod component_instance; pub mod events; +pub mod instance; pub mod manager; +pub mod stdio_instance; pub mod stdio_rpc; pub mod vcs_proxy; diff --git a/Backend/src/plugin_runtime/stdio_instance.rs b/Backend/src/plugin_runtime/stdio_instance.rs new file mode 100644 index 00000000..06611bd5 --- /dev/null +++ b/Backend/src/plugin_runtime/stdio_instance.rs @@ -0,0 +1,36 @@ +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; +use serde_json::Value; + +/// Stdio-backed runtime instance implementation. +/// +/// Deprecated: this exists as a migration fallback while component runtime +/// support is being rolled out. +pub struct StdioPluginRuntimeInstance { + rpc: StdioRpcProcess, +} + +impl StdioPluginRuntimeInstance { + /// Creates a new stdio-backed runtime instance. + pub fn new(spawn: SpawnConfig) -> Self { + Self { + rpc: StdioRpcProcess::new(spawn, RpcConfig::default()), + } + } +} + +impl PluginRuntimeInstance for StdioPluginRuntimeInstance { + fn ensure_running(&self) -> Result<(), String> { + self.rpc.ensure_running() + } + + fn call(&self, method: &str, params: Value) -> Result { + self.rpc + .call(method, params) + .map_err(|e| format!("{}: {}", e.code, e.message)) + } + + fn stop(&self) { + self.rpc.stop(); + } +} diff --git a/Backend/src/plugin_runtime/stdio_rpc.rs b/Backend/src/plugin_runtime/stdio_rpc.rs index ca3acb05..79b851ca 100644 --- a/Backend/src/plugin_runtime/stdio_rpc.rs +++ b/Backend/src/plugin_runtime/stdio_rpc.rs @@ -512,9 +512,10 @@ fn read_stdout_loop( } } PluginMessage::Event { event } => { + log_plugin_event(&spawn.plugin_id, &spawn.component_label, &event); if let Ok(lock) = on_event.lock() { if let Some(cb) = lock.as_ref() { - cb(event); + cb(event.clone()); } } } @@ -563,6 +564,31 @@ fn read_stderr_loop(stderr: impl io::Read, path: PathBuf, plugin_id: String, com } } +/// Forwards plugin events into host logs even when no explicit event sink is set. +/// +/// # Parameters +/// - `plugin_id`: Plugin id for log prefix. +/// - `component`: Component label for log prefix. +/// - `event`: Event payload from plugin stdout. +/// +/// # Returns +/// - `()`. +fn log_plugin_event(plugin_id: &str, component: &str, event: &VcsEvent) { + let prefix = format!("[plugin:{plugin_id}:{component}] "); + match event { + VcsEvent::Info { msg } => log::info!("{}{}", prefix, msg), + VcsEvent::RemoteMessage { msg } => log::info!("{}{}", prefix, msg), + VcsEvent::Progress { phase, detail } => log::info!("{}{}: {}", prefix, phase, detail), + VcsEvent::Auth { method, detail } => log::info!("{}auth {}: {}", prefix, method, detail), + VcsEvent::PushStatus { refname, status } => { + let status = status.as_deref().unwrap_or("unknown"); + log::info!("{}push {} -> {}", prefix, refname, status); + } + VcsEvent::Warning { msg } => log::warn!("{}{}", prefix, msg), + VcsEvent::Error { msg } => log::error!("{}{}", prefix, msg), + } +} + /// Checks whether a file is a wasm module by extension and magic bytes. /// /// # Parameters From d691068b7a0bbf69e8274d4d73bfa2c8bab53901 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 14:36:05 +0000 Subject: [PATCH 05/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index b07515b7..ece76e46 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit b07515b72a243ac8b6b5390e4e4fb3e30a4c7a64 +Subproject commit ece76e467fc725ed8da743740acfd586f296eeda From 4a171e88758e36cc66db2a403e2c4bd6dcaf981a Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 14:36:20 +0000 Subject: [PATCH 06/96] Update --- .../src/plugin_runtime/component_instance.rs | 802 +++++++++++++++++- Backend/src/plugin_runtime/manager.rs | 5 +- Backend/src/plugin_runtime/stdio_rpc.rs | 2 +- 3 files changed, 781 insertions(+), 28 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 81079982..63d06c47 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1,42 +1,798 @@ use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::stdio_rpc::handle_host_request; +use crate::plugin_runtime::stdio_rpc::SpawnConfig; +use openvcs_core::plugin_protocol::RpcRequest; +use parking_lot::Mutex; +use serde::de::DeserializeOwned; +use serde::Serialize; use serde_json::Value; -use std::path::PathBuf; +use wasmtime::component::{Component, Linker}; +use wasmtime::{Engine, Store}; -/// Component-model runtime instance placeholder. -/// -/// This is intentionally minimal in the current migration slice: runtime -/// detection and selection are wired, and component execution support is the -/// next implementation step. +mod bindings { + wasmtime::component::bindgen!({ + path: "../../Core/wit", + world: "openvcs-plugin", + additional_derives: [serde::Serialize, serde::Deserialize], + }); +} + +use bindings::exports::openvcs::plugin::plugin_api; + +struct ComponentRuntime { + store: Store, + bindings: bindings::OpenvcsPlugin, +} + +#[derive(Clone)] +struct ComponentHostState { + spawn: SpawnConfig, +} + +impl ComponentHostState { + fn host_error( + code: impl Into, + message: impl Into, + ) -> bindings::openvcs::plugin::host_api::HostError { + bindings::openvcs::plugin::host_api::HostError { + code: code.into(), + message: message.into(), + } + } + + fn host_call( + &self, + method: &str, + params: Value, + ) -> Result { + let req = RpcRequest { + id: 0, + method: method.to_string(), + params, + }; + let rsp = handle_host_request(&self.spawn, req); + if rsp.ok { + Ok(rsp.result) + } else { + Err(Self::host_error( + rsp.error_code.unwrap_or_else(|| "host.error".to_string()), + rsp.error + .unwrap_or_else(|| "unknown host error".to_string()), + )) + } + } +} + +impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { + fn get_runtime_info( + &mut self, + ) -> Result< + bindings::openvcs::plugin::host_api::RuntimeInfo, + bindings::openvcs::plugin::host_api::HostError, + > { + let value = self.host_call("runtime.info", Value::Null)?; + Ok(bindings::openvcs::plugin::host_api::RuntimeInfo { + os: value + .get("os") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + arch: value + .get("arch") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + container: value + .get("container") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }) + } + + fn subscribe_event( + &mut self, + event_name: String, + ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + self.host_call( + "events.subscribe", + serde_json::json!({ "name": event_name }), + )?; + Ok(()) + } + + fn emit_event( + &mut self, + event_name: String, + payload: Vec, + ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + let payload_json = if payload.is_empty() { + Value::Null + } else { + serde_json::from_slice(&payload) + .map_err(|e| Self::host_error("host.invalid_payload", e.to_string()))? + }; + self.host_call( + "events.emit", + serde_json::json!({ "name": event_name, "payload": payload_json }), + )?; + Ok(()) + } + + fn ui_notify( + &mut self, + message: String, + ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + self.host_call("ui.notify", serde_json::json!({ "message": message }))?; + Ok(()) + } + + fn workspace_read_file( + &mut self, + path: String, + ) -> Result, bindings::openvcs::plugin::host_api::HostError> { + let value = self.host_call("workspace.readFile", serde_json::json!({ "path": path }))?; + let bytes = value.as_str().unwrap_or_default().as_bytes().to_vec(); + Ok(bytes) + } + + fn workspace_write_file( + &mut self, + path: String, + content: Vec, + ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + self.host_call( + "workspace.writeFile", + serde_json::json!({ + "path": path, + "content": String::from_utf8_lossy(&content), + }), + )?; + Ok(()) + } + + fn process_exec_git( + &mut self, + cwd: Option, + args: Vec, + env: Vec, + stdin: Option, + ) -> Result< + bindings::openvcs::plugin::host_api::ProcessExecOutput, + bindings::openvcs::plugin::host_api::HostError, + > { + let env_obj = env + .into_iter() + .map(|var| (var.key, Value::String(var.value))) + .collect::>(); + let value = self.host_call( + "process.exec", + serde_json::json!({ + "program": "git", + "cwd": cwd.unwrap_or_default(), + "args": args, + "env": env_obj, + "stdin": stdin.unwrap_or_default(), + }), + )?; + Ok(bindings::openvcs::plugin::host_api::ProcessExecOutput { + success: value + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + status: value.get("status").and_then(|v| v.as_i64()).unwrap_or(-1) as i32, + stdout: value + .get("stdout") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + stderr: value + .get("stderr") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + }) + } + + fn host_log( + &mut self, + level: bindings::openvcs::plugin::host_api::LogLevel, + target: String, + message: String, + ) { + let target = if target.trim().is_empty() { + format!("plugin.{}", self.spawn.plugin_id) + } else { + format!("plugin.{}.{}", self.spawn.plugin_id, target) + }; + match level { + bindings::openvcs::plugin::host_api::LogLevel::Trace => { + log::trace!(target: &target, "{message}") + } + bindings::openvcs::plugin::host_api::LogLevel::Debug => { + log::debug!(target: &target, "{message}") + } + bindings::openvcs::plugin::host_api::LogLevel::Info => { + log::info!(target: &target, "{message}") + } + bindings::openvcs::plugin::host_api::LogLevel::Warn => { + log::warn!(target: &target, "{message}") + } + bindings::openvcs::plugin::host_api::LogLevel::Error => { + log::error!(target: &target, "{message}") + } + } + } +} + +/// Component-model runtime instance. pub struct ComponentPluginRuntimeInstance { - plugin_id: String, - exec_path: PathBuf, + spawn: SpawnConfig, + runtime: Mutex>, } impl ComponentPluginRuntimeInstance { - /// Creates a new component runtime instance placeholder. - pub fn new(plugin_id: String, exec_path: PathBuf) -> Self { + /// Creates a new component runtime instance. + pub fn new(spawn: SpawnConfig) -> Self { Self { - plugin_id, - exec_path, + spawn, + runtime: Mutex::new(None), } } + + fn instantiate_runtime(&self) -> Result { + let engine = Engine::default(); + let component = Component::from_file(&engine, &self.spawn.exec_path) + .map_err(|e| format!("load component {}: {e}", self.spawn.exec_path.display()))?; + let mut linker = Linker::new(&engine); + bindings::OpenvcsPlugin::add_to_linker::< + ComponentHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state) + .map_err(|e| format!("link host imports: {e}"))?; + let mut store = Store::new( + &engine, + ComponentHostState { + spawn: self.spawn.clone(), + }, + ); + let bindings = bindings::OpenvcsPlugin::instantiate(&mut store, &component, &linker) + .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?; + + bindings + .openvcs_plugin_plugin_api() + .call_init(&mut store) + .map_err(|e| format!("component init trap for {}: {e}", self.spawn.plugin_id))? + .map_err(|e| { + format!( + "component init failed for {}: {}", + self.spawn.plugin_id, e.message + ) + })?; + + Ok(ComponentRuntime { store, bindings }) + } + + fn with_runtime( + &self, + f: impl FnOnce(&mut ComponentRuntime) -> Result, + ) -> Result { + self.ensure_running()?; + let mut lock = self.runtime.lock(); + let runtime = lock.as_mut().ok_or_else(|| { + format!( + "component runtime not running for `{}`", + self.spawn.plugin_id + ) + })?; + f(runtime) + } +} + +fn parse_method_params(method: &str, params: Value) -> Result { + serde_json::from_value(params).map_err(|e| format!("invalid params for `{method}`: {e}")) +} + +fn encode_method_result( + plugin_id: &str, + method: &str, + value: T, +) -> Result { + serde_json::to_value(value).map_err(|e| { + format!( + "serialize component result for `{}` method `{}`: {e}", + plugin_id, method + ) + }) } impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { fn ensure_running(&self) -> Result<(), String> { - Err(format!( - "component runtime not implemented yet for plugin `{}` ({})", - self.plugin_id, - self.exec_path.display() - )) + let mut lock = self.runtime.lock(); + if lock.is_some() { + return Ok(()); + } + let runtime = self.instantiate_runtime()?; + *lock = Some(runtime); + Ok(()) } - fn call(&self, _method: &str, _params: Value) -> Result { - Err(format!( - "component runtime not implemented yet for plugin `{}`", - self.plugin_id - )) + fn call(&self, method: &str, params: Value) -> Result { + self.with_runtime(|runtime| { + macro_rules! invoke { + ($method_name:literal, $call:ident $(, $arg:expr )* ) => { + runtime + .bindings + .openvcs_plugin_plugin_api() + .$call(&mut runtime.store $(, $arg )* ) + .map_err(|e| { + format!( + "component call trap for {}.{}: {e}", + self.spawn.plugin_id, $method_name + ) + })? + .map_err(|e| { + format!( + "component call failed for {}.{}: {}: {}", + self.spawn.plugin_id, $method_name, e.code, e.message + ) + }) + }; + } + + match method { + "caps" => { + let out = invoke!("caps", call_get_caps)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "open" => { + #[derive(serde::Deserialize)] + struct Params { + path: String, + #[serde(default)] + config: Value, + } + let p: Params = parse_method_params(method, params)?; + let config = serde_json::to_vec(&p.config) + .map_err(|e| format!("serialize `open` config: {e}"))?; + let out = invoke!("open", call_open, &p.path, &config)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "clone" => { + #[derive(serde::Deserialize)] + struct Params { + url: String, + dest: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("clone", call_clone_repo, &p.url, &p.dest)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "workdir" => { + let out = invoke!("workdir", call_get_workdir)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "current_branch" => { + let out = invoke!("current_branch", call_get_current_branch)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "branches" => { + let out = invoke!("branches", call_list_branches)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "local_branches" => { + let out = invoke!("local_branches", call_list_local_branches)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "create_branch" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + checkout: bool, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("create_branch", call_create_branch, &p.name, p.checkout)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "checkout_branch" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("checkout_branch", call_checkout_branch, &p.name)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "ensure_remote" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + url: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("ensure_remote", call_ensure_remote, &p.name, &p.url)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "list_remotes" => { + let out = invoke!("list_remotes", call_list_remotes)?; + let remotes = out + .into_iter() + .map(|entry| (entry.name, entry.url)) + .collect::>(); + encode_method_result(&self.spawn.plugin_id, method, remotes) + } + "remove_remote" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("remove_remote", call_remove_remote, &p.name)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "fetch" => { + #[derive(serde::Deserialize)] + struct Params { + remote: String, + refspec: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("fetch", call_fetch, &p.remote, &p.refspec)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "fetch_with_options" => { + #[derive(serde::Deserialize)] + struct Params { + remote: String, + refspec: String, + opts: plugin_api::FetchOptions, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "fetch_with_options", + call_fetch_with_options, + &p.remote, + &p.refspec, + p.opts + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "push" => { + #[derive(serde::Deserialize)] + struct Params { + remote: String, + refspec: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("push", call_push, &p.remote, &p.refspec)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "pull_ff_only" => { + #[derive(serde::Deserialize)] + struct Params { + remote: String, + branch: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("pull_ff_only", call_pull_ff_only, &p.remote, &p.branch)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "commit" => { + #[derive(serde::Deserialize)] + struct Params { + message: String, + name: String, + email: String, + paths: Vec, + } + let p: Params = parse_method_params(method, params)?; + let out = + invoke!("commit", call_commit, &p.message, &p.name, &p.email, &p.paths)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "commit_index" => { + #[derive(serde::Deserialize)] + struct Params { + message: String, + name: String, + email: String, + } + let p: Params = parse_method_params(method, params)?; + let out = + invoke!("commit_index", call_commit_index, &p.message, &p.name, &p.email)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "status_summary" => { + let out = invoke!("status_summary", call_get_status_summary)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "status_payload" => { + let out = invoke!("status_payload", call_get_status_payload)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "log_commits" => { + #[derive(serde::Deserialize)] + struct Params { + query: plugin_api::LogQuery, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("log_commits", call_list_commits, &p.query)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "diff_file" => { + #[derive(serde::Deserialize)] + struct Params { + path: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("diff_file", call_diff_file, &p.path)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "diff_commit" => { + #[derive(serde::Deserialize)] + struct Params { + rev: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("diff_commit", call_diff_commit, &p.rev)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "conflict_details" => { + #[derive(serde::Deserialize)] + struct Params { + path: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("conflict_details", call_get_conflict_details, &p.path)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "checkout_conflict_side" => { + #[derive(serde::Deserialize)] + struct Params { + path: String, + side: plugin_api::ConflictSide, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "checkout_conflict_side", + call_checkout_conflict_side, + &p.path, + p.side + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "write_merge_result" => { + #[derive(serde::Deserialize)] + struct Params { + path: String, + content: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "write_merge_result", + call_write_merge_result, + &p.path, + p.content.as_bytes() + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stage_patch" => { + #[derive(serde::Deserialize)] + struct Params { + patch: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("stage_patch", call_stage_patch, &p.patch)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "discard_paths" => { + #[derive(serde::Deserialize)] + struct Params { + paths: Vec, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("discard_paths", call_discard_paths, &p.paths)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "apply_reverse_patch" => { + #[derive(serde::Deserialize)] + struct Params { + patch: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("apply_reverse_patch", call_apply_reverse_patch, &p.patch)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "delete_branch" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + force: bool, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("delete_branch", call_delete_branch, &p.name, p.force)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "rename_branch" => { + #[derive(serde::Deserialize)] + struct Params { + old: String, + new: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("rename_branch", call_rename_branch, &p.old, &p.new)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "merge_into_current" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + #[serde(default)] + message: Option, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "merge_into_current", + call_merge_into_current, + &p.name, + p.message.as_deref() + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "merge_abort" => { + let out = invoke!("merge_abort", call_merge_abort)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "merge_continue" => { + let out = invoke!("merge_continue", call_merge_continue)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "merge_in_progress" => { + let out = invoke!("merge_in_progress", call_is_merge_in_progress)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "set_branch_upstream" => { + #[derive(serde::Deserialize)] + struct Params { + branch: String, + upstream: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "set_branch_upstream", + call_set_branch_upstream, + &p.branch, + &p.upstream + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "branch_upstream" => { + #[derive(serde::Deserialize)] + struct Params { + branch: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("branch_upstream", call_get_branch_upstream, &p.branch)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "get_identity" => { + let out = invoke!("get_identity", call_get_identity)?; + let mapped = out.map(|identity| (identity.name, identity.email)); + encode_method_result(&self.spawn.plugin_id, method, mapped) + } + "set_identity_local" => { + #[derive(serde::Deserialize)] + struct Params { + name: String, + email: String, + } + let p: Params = parse_method_params(method, params)?; + let out = + invoke!("set_identity_local", call_set_identity_local, &p.name, &p.email)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_list" => { + let out = invoke!("stash_list", call_list_stashes)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_push" => { + #[derive(serde::Deserialize)] + struct Params { + #[serde(default)] + message: Option, + #[serde(default)] + include_untracked: bool, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!( + "stash_push", + call_stash_push, + p.message.as_deref(), + p.include_untracked + )?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_apply" => { + #[derive(serde::Deserialize)] + struct Params { + selector: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("stash_apply", call_stash_apply, &p.selector)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_pop" => { + #[derive(serde::Deserialize)] + struct Params { + selector: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("stash_pop", call_stash_pop, &p.selector)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_drop" => { + #[derive(serde::Deserialize)] + struct Params { + selector: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("stash_drop", call_stash_drop, &p.selector)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "stash_show" => { + #[derive(serde::Deserialize)] + struct Params { + selector: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("stash_show", call_stash_show, &p.selector)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "cherry_pick" => { + #[derive(serde::Deserialize)] + struct Params { + #[serde(default)] + commit: Option, + #[serde(default)] + rev: Option, + } + let p: Params = parse_method_params(method, params)?; + let commit = p + .commit + .or(p.rev) + .ok_or_else(|| "missing `commit`/`rev`".to_string())?; + let out = invoke!("cherry_pick", call_cherry_pick, &commit)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "revert_commit" => { + #[derive(serde::Deserialize)] + struct Params { + #[serde(default)] + commit: Option, + #[serde(default)] + rev: Option, + } + let p: Params = parse_method_params(method, params)?; + let commit = p + .commit + .or(p.rev) + .ok_or_else(|| "missing `commit`/`rev`".to_string())?; + let out = invoke!("revert_commit", call_revert_commit, &commit)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + _ => Err(format!( + "component method `{method}` is not part of the v1 ABI contract for plugin `{}`", + self.spawn.plugin_id + )), + } + }) } - fn stop(&self) {} + fn stop(&self) { + let mut lock = self.runtime.lock(); + if let Some(runtime) = lock.as_mut() { + let _ = runtime + .bindings + .openvcs_plugin_plugin_api() + .call_deinit(&mut runtime.store); + } + *lock = None; + } } diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index da2b0f9f..8227cfdc 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -197,10 +197,7 @@ impl PluginRuntimeManager { fn create_instance(&self, spec: &ModuleRuntimeSpec) -> Arc { if is_component_module(&spec.spawn.exec_path) { - return Arc::new(ComponentPluginRuntimeInstance::new( - spec.spawn.plugin_id.clone(), - spec.spawn.exec_path.clone(), - )); + return Arc::new(ComponentPluginRuntimeInstance::new(spec.spawn.clone())); } log::warn!( diff --git a/Backend/src/plugin_runtime/stdio_rpc.rs b/Backend/src/plugin_runtime/stdio_rpc.rs index 79b851ca..4097c26e 100644 --- a/Backend/src/plugin_runtime/stdio_rpc.rs +++ b/Backend/src/plugin_runtime/stdio_rpc.rs @@ -758,7 +758,7 @@ fn run_wasi_module(cfg: RunWasiConfig) -> Result<(), String> { /// /// # Returns /// - RPC response payload. -fn handle_host_request(spawn: &SpawnConfig, req: RpcRequest) -> RpcResponse { +pub(crate) fn handle_host_request(spawn: &SpawnConfig, req: RpcRequest) -> RpcResponse { let deny = |code: &str, msg: &str| RpcResponse { id: req.id, ok: false, From 18c892131cc30a95cf1460b34959c6e899d1411a Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 18:18:42 +0000 Subject: [PATCH 07/96] Update --- .../src/plugin_runtime/component_instance.rs | 4 + Backend/src/plugin_runtime/instance.rs | 8 ++ Backend/src/plugin_runtime/manager.rs | 106 ++++++++++++++---- Backend/src/plugin_runtime/mod.rs | 1 + Backend/src/plugin_runtime/runtime_select.rs | 85 ++++++++++++++ Backend/src/plugin_runtime/stdio_instance.rs | 10 ++ Backend/src/plugin_runtime/vcs_proxy.rs | 55 ++++----- Backend/src/tauri_commands/backends.rs | 33 +++--- 8 files changed, 234 insertions(+), 68 deletions(-) create mode 100644 Backend/src/plugin_runtime/runtime_select.rs diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 63d06c47..4a36e85a 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -304,6 +304,10 @@ fn encode_method_result( } impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { + fn runtime_kind(&self) -> &'static str { + "component" + } + fn ensure_running(&self) -> Result<(), String> { let mut lock = self.runtime.lock(); if lock.is_some() { diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index 32476d3d..4fac0970 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -1,13 +1,21 @@ +use openvcs_core::models::VcsEvent; use serde_json::Value; +use std::sync::Arc; /// Runtime instance abstraction used by the plugin runtime manager. pub trait PluginRuntimeInstance: Send + Sync { + /// Returns runtime transport kind identifier (`component` or `stdio`). + fn runtime_kind(&self) -> &'static str; + /// Ensures the underlying runtime instance is started. fn ensure_running(&self) -> Result<(), String>; /// Calls a plugin method and returns JSON payload. fn call(&self, method: &str, params: Value) -> Result; + /// Installs an optional event sink for runtime-emitted events. + fn set_event_sink(&self, _sink: Option>) {} + /// Stops the runtime instance. fn stop(&self); } diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 8227cfdc..7238ea79 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -1,16 +1,12 @@ use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; -use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::stdio_instance::StdioPluginRuntimeInstance; +use crate::plugin_runtime::runtime_select::create_runtime_instance; use crate::plugin_runtime::stdio_rpc::SpawnConfig; use crate::settings::AppConfig; use parking_lot::Mutex; use serde_json::Value; use std::collections::{HashMap, HashSet}; -use std::path::Path; use std::sync::Arc; -use wasmtime::component::Component; -use wasmtime::Engine; #[derive(Clone)] struct ModuleRuntimeSpec { @@ -196,16 +192,7 @@ impl PluginRuntimeManager { } fn create_instance(&self, spec: &ModuleRuntimeSpec) -> Arc { - if is_component_module(&spec.spawn.exec_path) { - return Arc::new(ComponentPluginRuntimeInstance::new(spec.spawn.clone())); - } - - log::warn!( - "plugin runtime: using deprecated stdio fallback for plugin `{}` ({})", - spec.spawn.plugin_id, - spec.spawn.exec_path.display() - ); - Arc::new(StdioPluginRuntimeInstance::new(spec.spawn.clone())) + create_runtime_instance(spec.spawn.clone()) } fn resolve_module_runtime_spec(&self, plugin_id: &str) -> Result { @@ -257,15 +244,6 @@ fn normalize_plugin_key(plugin_id: &str) -> Result { Ok(plugin_id) } -fn is_component_module(path: &Path) -> bool { - if !path.is_file() { - return false; - } - - let engine = Engine::default(); - Component::from_file(&engine, path).is_ok() -} - #[cfg(test)] mod tests { use super::*; @@ -324,6 +302,35 @@ mod tests { assert!(manager.processes.lock().contains_key("beta.plugin")); } + #[test] + fn start_plugin_rejects_plugins_without_module_component() { + let temp = tempdir().expect("tempdir"); + write_non_runtime_plugin(temp.path(), "themes.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let err = manager + .start_plugin("themes.plugin") + .expect_err("expected missing module error"); + assert!(err.contains("plugin has no module component")); + } + + #[test] + fn sync_ignores_plugins_without_module_component() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "runtime.plugin", true); + write_non_runtime_plugin(temp.path(), "themes.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("sync succeeds"); + + let running = manager.processes.lock(); + assert!(running.contains_key("runtime.plugin")); + assert!(!running.contains_key("themes.plugin")); + } + fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { let plugin_dir = root.join(plugin_id); fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); @@ -379,4 +386,55 @@ mod tests { ) .expect("write current"); } + + fn write_non_runtime_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Theme Plugin", + "version": "1.0.0", + "default_enabled": default_enabled + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } } diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 7041db99..9fae49ba 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -2,6 +2,7 @@ pub mod component_instance; pub mod events; pub mod instance; pub mod manager; +pub mod runtime_select; pub mod stdio_instance; pub mod stdio_rpc; pub mod vcs_proxy; diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs new file mode 100644 index 00000000..584e8bc9 --- /dev/null +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -0,0 +1,85 @@ +use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::stdio_instance::StdioPluginRuntimeInstance; +use crate::plugin_runtime::stdio_rpc::SpawnConfig; +use std::path::Path; +use std::sync::Arc; +use wasmtime::component::Component; +use wasmtime::Engine; + +/// Returns whether a module path is a valid component-model artifact. +pub fn is_component_module(path: &Path) -> bool { + if !path.is_file() { + return false; + } + + let engine = Engine::default(); + Component::from_file(&engine, path).is_ok() +} + +/// Selects and creates a runtime instance for a plugin module. +pub fn create_runtime_instance(spawn: SpawnConfig) -> Arc { + let runtime: Arc = if is_component_module(&spawn.exec_path) { + Arc::new(ComponentPluginRuntimeInstance::new(spawn.clone())) + } else { + log::warn!( + "plugin runtime: using deprecated stdio fallback for plugin `{}` ({})", + spawn.plugin_id, + spawn.exec_path.display() + ); + Arc::new(StdioPluginRuntimeInstance::new(spawn.clone())) + }; + + log::info!( + "plugin runtime: selected `{}` transport for plugin `{}` ({})", + runtime.runtime_kind(), + spawn.plugin_id, + spawn.exec_path.display() + ); + runtime +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_bundles::ApprovalState; + use std::fs; + use tempfile::tempdir; + + const MINIMAL_WASM: &[u8] = &[ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, + 0x02, 0x01, 0x00, 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, + ]; + + #[test] + fn core_wasm_is_not_detected_as_component() { + let temp = tempdir().expect("tempdir"); + let wasm_path = temp.path().join("plugin.wasm"); + fs::write(&wasm_path, MINIMAL_WASM).expect("write wasm"); + + assert!(!is_component_module(&wasm_path)); + } + + #[test] + fn selection_uses_stdio_fallback_for_core_wasm() { + let temp = tempdir().expect("tempdir"); + let wasm_path = temp.path().join("plugin.wasm"); + fs::write(&wasm_path, MINIMAL_WASM).expect("write wasm"); + + let runtime = create_runtime_instance(SpawnConfig { + plugin_id: "test.plugin".to_string(), + component_label: "module".to_string(), + exec_path: wasm_path, + args: Vec::new(), + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + allowed_workspace_root: None, + }); + + assert_eq!(runtime.runtime_kind(), "stdio"); + } +} diff --git a/Backend/src/plugin_runtime/stdio_instance.rs b/Backend/src/plugin_runtime/stdio_instance.rs index 06611bd5..eb77a6fc 100644 --- a/Backend/src/plugin_runtime/stdio_instance.rs +++ b/Backend/src/plugin_runtime/stdio_instance.rs @@ -1,6 +1,8 @@ use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; +use openvcs_core::models::VcsEvent; use serde_json::Value; +use std::sync::Arc; /// Stdio-backed runtime instance implementation. /// @@ -20,6 +22,10 @@ impl StdioPluginRuntimeInstance { } impl PluginRuntimeInstance for StdioPluginRuntimeInstance { + fn runtime_kind(&self) -> &'static str { + "stdio" + } + fn ensure_running(&self) -> Result<(), String> { self.rpc.ensure_running() } @@ -30,6 +36,10 @@ impl PluginRuntimeInstance for StdioPluginRuntimeInstance { .map_err(|e| format!("{}: {}", e.code, e.message)) } + fn set_event_sink(&self, sink: Option>) { + self.rpc.set_event_sink(sink); + } + fn stop(&self) { self.rpc.stop(); } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index a7f19871..c608c824 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,5 +1,7 @@ use crate::plugin_bundles::ApprovalState; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, RpcError, SpawnConfig, StdioRpcProcess}; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::runtime_select::create_runtime_instance; +use crate::plugin_runtime::stdio_rpc::SpawnConfig; use crate::settings::AppConfig; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, @@ -14,7 +16,7 @@ use std::sync::Arc; pub struct PluginVcsProxy { backend_id: BackendId, workdir: PathBuf, - rpc: StdioRpcProcess, + runtime: Arc, } impl PluginVcsProxy { @@ -54,18 +56,20 @@ impl PluginVcsProxy { approval, allowed_workspace_root: Some(workdir.clone()), }; - let rpc = StdioRpcProcess::new(spawn, RpcConfig::default()); + let runtime = create_runtime_instance(spawn); let p = PluginVcsProxy { backend_id, workdir, - rpc, + runtime, }; - p.rpc - .call( - "open", - json!({ "path": path_to_utf8(repo_path)?, "config": cfg }), - ) - .map_err(map_rpc_err)?; + p.runtime.ensure_running().map_err(|e| VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, + })?; + p.call_unit( + "open", + json!({ "path": path_to_utf8(repo_path)?, "config": cfg }), + )?; Ok(Arc::new(p)) } @@ -79,7 +83,12 @@ impl PluginVcsProxy { /// - `Ok(Value)` RPC result payload. /// - `Err(VcsError)` on RPC failure. fn call_value(&self, method: &str, params: Value) -> Result { - self.rpc.call(method, params).map_err(map_rpc_err) + self.runtime + .call(method, params) + .map_err(|e| VcsError::Backend { + backend: self.backend_id.clone(), + msg: e, + }) } /// Calls a plugin RPC method and deserializes its JSON result. @@ -128,13 +137,19 @@ impl PluginVcsProxy { { let sink: Option> = on.map(|cb| Arc::new(move |evt| cb(evt)) as _); - self.rpc.set_event_sink(sink); + self.runtime.set_event_sink(sink); let res = f(); - self.rpc.set_event_sink(None); + self.runtime.set_event_sink(None); res } } +impl Drop for PluginVcsProxy { + fn drop(&mut self) { + self.runtime.stop(); + } +} + impl Vcs for PluginVcsProxy { /// Returns the backend identifier for this proxy. /// @@ -772,20 +787,6 @@ impl Vcs for PluginVcsProxy { } } -/// Converts an RPC error into a backend-scoped [`VcsError`]. -/// -/// # Parameters -/// - `err`: RPC error payload. -/// -/// # Returns -/// - Converted backend error. -fn map_rpc_err(err: RpcError) -> VcsError { - VcsError::Backend { - backend: BackendId::from("plugin"), - msg: format!("{}: {}", err.code, err.message), - } -} - /// Converts a filesystem path to UTF-8 text for JSON RPC transport. /// /// # Parameters diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 6e4430d5..36299e02 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -8,7 +8,8 @@ use tauri::{async_runtime, Manager, Runtime, State, Window}; use openvcs_core::BackendId; use std::collections::BTreeMap; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; +use crate::plugin_runtime::runtime_select::create_runtime_instance; +use crate::plugin_runtime::stdio_rpc::SpawnConfig; use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; @@ -212,28 +213,26 @@ pub async fn call_vcs_backend_method( let on_event = progress_bridge(window.app_handle().clone()); let call_task = async_runtime::spawn_blocking(move || { - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: desc_clone.plugin_id, - component_label: format!("vcs-backend-{}", backend_id_clone), - exec_path: desc_clone.exec_path, - args: Vec::new(), - requested_capabilities: desc_clone.requested_capabilities, - approval: desc_clone.approval, - allowed_workspace_root, - }, - RpcConfig::default(), - ); - rpc.set_event_sink(Some(on_event)); - - rpc.call(&method_clone, params_clone) + let runtime = create_runtime_instance(SpawnConfig { + plugin_id: desc_clone.plugin_id, + component_label: format!("vcs-backend-{}", backend_id_clone), + exec_path: desc_clone.exec_path, + args: Vec::new(), + requested_capabilities: desc_clone.requested_capabilities, + approval: desc_clone.approval, + allowed_workspace_root, + }); + runtime.set_event_sink(Some(on_event)); + let call = runtime.call(&method_clone, params_clone); + runtime.stop(); + call }); let call_res = call_task .await .map_err(|e| format!("call_vcs_backend_method task failed: {e}"))?; - call_res.map_err(|e| format!("{}: {}", e.code, e.message)) + call_res } /// Resolves optional backend workspace path and enforces repo-root confinement. From 94323581ad32d434d8742b93f954e25208a6eb70 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 18:41:23 +0000 Subject: [PATCH 08/96] Update component_instance.rs --- .../src/plugin_runtime/component_instance.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 4a36e85a..402509a6 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -676,6 +676,19 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { let out = invoke!("branch_upstream", call_get_branch_upstream, &p.branch)?; encode_method_result(&self.spawn.plugin_id, method, out) } + "hard_reset_head" => { + let out = invoke!("hard_reset_head", call_hard_reset_head)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } + "reset_soft_to" => { + #[derive(serde::Deserialize)] + struct Params { + rev: String, + } + let p: Params = parse_method_params(method, params)?; + let out = invoke!("reset_soft_to", call_reset_soft_to, &p.rev)?; + encode_method_result(&self.spawn.plugin_id, method, out) + } "get_identity" => { let out = invoke!("get_identity", call_get_identity)?; let mapped = out.map(|identity| (identity.name, identity.email)); @@ -772,13 +785,15 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { commit: Option, #[serde(default)] rev: Option, + #[serde(default)] + no_edit: bool, } let p: Params = parse_method_params(method, params)?; let commit = p .commit .or(p.rev) .ok_or_else(|| "missing `commit`/`rev`".to_string())?; - let out = invoke!("revert_commit", call_revert_commit, &commit)?; + let out = invoke!("revert_commit", call_revert_commit, &commit, p.no_edit)?; encode_method_result(&self.spawn.plugin_id, method, out) } _ => Err(format!( From cb206caa377fdac9fd0e69bd6d9bb36984fbf43f Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 19:34:10 +0000 Subject: [PATCH 09/96] Update --- Backend/Cargo.toml | 2 +- Backend/src/lib.rs | 2 + .../src/plugin_runtime/component_instance.rs | 224 +-- Backend/src/plugin_runtime/host_api.rs | 288 ++++ Backend/src/plugin_runtime/instance.rs | 2 +- Backend/src/plugin_runtime/manager.rs | 95 +- Backend/src/plugin_runtime/mod.rs | 4 +- Backend/src/plugin_runtime/runtime_select.rs | 38 +- Backend/src/plugin_runtime/spawn.rs | 13 + Backend/src/plugin_runtime/stdio_instance.rs | 46 - Backend/src/plugin_runtime/stdio_rpc.rs | 1355 ----------------- Backend/src/plugin_runtime/vcs_proxy.rs | 7 +- Backend/src/tauri_commands/backends.rs | 44 +- Cargo.lock | 14 +- 14 files changed, 545 insertions(+), 1589 deletions(-) create mode 100644 Backend/src/plugin_runtime/host_api.rs create mode 100644 Backend/src/plugin_runtime/spawn.rs delete mode 100644 Backend/src/plugin_runtime/stdio_instance.rs delete mode 100644 Backend/src/plugin_runtime/stdio_rpc.rs diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index ad77422d..caedd682 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -25,7 +25,7 @@ serde_json = "1.0" default = [] [dependencies] -openvcs-core = { version = "0.1", features = ["plugin-protocol", "vcs"] } +openvcs-core = { path = "../../Core", features = ["plugin-protocol", "vcs"] } tauri = { version = "2.9", features = [] } tauri-plugin-opener = "2.5" diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index a88e5113..494a6437 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -176,6 +176,8 @@ pub fn run() { // If the main window is closed, exit the app even if auxiliary windows are open. if window.label() == "main" { if let WindowEvent::CloseRequested { .. } = event { + let state = window.app_handle().state::(); + state.plugin_runtime().stop_all_plugins(); window.app_handle().exit(0); } } diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 402509a6..0c260fd8 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1,7 +1,10 @@ use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::stdio_rpc::handle_host_request; -use crate::plugin_runtime::stdio_rpc::SpawnConfig; -use openvcs_core::plugin_protocol::RpcRequest; +use crate::plugin_runtime::host_api::{ + host_emit_event, host_process_exec_git, host_runtime_info, host_subscribe_event, + host_ui_notify, host_workspace_read_file, host_workspace_write_file, +}; +use crate::plugin_runtime::spawn::SpawnConfig; +use openvcs_core::app_api::Host as AppHostApi; use parking_lot::Mutex; use serde::de::DeserializeOwned; use serde::Serialize; @@ -30,36 +33,88 @@ struct ComponentHostState { } impl ComponentHostState { - fn host_error( - code: impl Into, - message: impl Into, + fn map_host_error( + err: openvcs_core::app_api::ComponentError, ) -> bindings::openvcs::plugin::host_api::HostError { bindings::openvcs::plugin::host_api::HostError { - code: code.into(), - message: message.into(), + code: err.code, + message: err.message, } } +} - fn host_call( - &self, - method: &str, - params: Value, - ) -> Result { - let req = RpcRequest { - id: 0, - method: method.to_string(), - params, - }; - let rsp = handle_host_request(&self.spawn, req); - if rsp.ok { - Ok(rsp.result) +impl AppHostApi for ComponentHostState { + fn get_runtime_info(&mut self) -> Result { + Ok(host_runtime_info()) + } + + fn subscribe_event( + &mut self, + event_name: &str, + ) -> Result<(), openvcs_core::app_api::ComponentError> { + host_subscribe_event(&self.spawn, event_name) + } + + fn emit_event( + &mut self, + event_name: &str, + payload: &[u8], + ) -> Result<(), openvcs_core::app_api::ComponentError> { + host_emit_event(&self.spawn, event_name, payload) + } + + fn ui_notify(&mut self, message: &str) -> Result<(), openvcs_core::app_api::ComponentError> { + host_ui_notify(&self.spawn, message) + } + + fn workspace_read_file( + &mut self, + path: &str, + ) -> Result, openvcs_core::app_api::ComponentError> { + host_workspace_read_file(&self.spawn, path) + } + + fn workspace_write_file( + &mut self, + path: &str, + content: &[u8], + ) -> Result<(), openvcs_core::app_api::ComponentError> { + host_workspace_write_file(&self.spawn, path, content) + } + + fn process_exec_git( + &mut self, + cwd: Option<&str>, + args: &[String], + env: &[(String, String)], + stdin: Option<&str>, + ) -> Result { + host_process_exec_git(&self.spawn, cwd, args, env, stdin) + } + + fn host_log(&mut self, level: openvcs_core::app_api::ComponentLogLevel, target: &str, message: &str) { + let target = if target.trim().is_empty() { + format!("plugin.{}", self.spawn.plugin_id) } else { - Err(Self::host_error( - rsp.error_code.unwrap_or_else(|| "host.error".to_string()), - rsp.error - .unwrap_or_else(|| "unknown host error".to_string()), - )) - } + format!("plugin.{}.{}", self.spawn.plugin_id, target) + }; + match level { + openvcs_core::app_api::ComponentLogLevel::Trace => { + log::trace!(target: &target, "{message}") + } + openvcs_core::app_api::ComponentLogLevel::Debug => { + log::debug!(target: &target, "{message}") + } + openvcs_core::app_api::ComponentLogLevel::Info => { + log::info!(target: &target, "{message}") + } + openvcs_core::app_api::ComponentLogLevel::Warn => { + log::warn!(target: &target, "{message}") + } + openvcs_core::app_api::ComponentLogLevel::Error => { + log::error!(target: &target, "{message}") + } + }; } } @@ -70,20 +125,11 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { bindings::openvcs::plugin::host_api::RuntimeInfo, bindings::openvcs::plugin::host_api::HostError, > { - let value = self.host_call("runtime.info", Value::Null)?; + let value = AppHostApi::get_runtime_info(self).map_err(ComponentHostState::map_host_error)?; Ok(bindings::openvcs::plugin::host_api::RuntimeInfo { - os: value - .get("os") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - arch: value - .get("arch") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - container: value - .get("container") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), + os: value.os, + arch: value.arch, + container: value.container, }) } @@ -91,11 +137,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, event_name: String, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - self.host_call( - "events.subscribe", - serde_json::json!({ "name": event_name }), - )?; - Ok(()) + AppHostApi::subscribe_event(self, &event_name).map_err(ComponentHostState::map_host_error) } fn emit_event( @@ -103,34 +145,21 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { event_name: String, payload: Vec, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - let payload_json = if payload.is_empty() { - Value::Null - } else { - serde_json::from_slice(&payload) - .map_err(|e| Self::host_error("host.invalid_payload", e.to_string()))? - }; - self.host_call( - "events.emit", - serde_json::json!({ "name": event_name, "payload": payload_json }), - )?; - Ok(()) + AppHostApi::emit_event(self, &event_name, &payload).map_err(ComponentHostState::map_host_error) } fn ui_notify( &mut self, message: String, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - self.host_call("ui.notify", serde_json::json!({ "message": message }))?; - Ok(()) + AppHostApi::ui_notify(self, &message).map_err(ComponentHostState::map_host_error) } fn workspace_read_file( &mut self, path: String, ) -> Result, bindings::openvcs::plugin::host_api::HostError> { - let value = self.host_call("workspace.readFile", serde_json::json!({ "path": path }))?; - let bytes = value.as_str().unwrap_or_default().as_bytes().to_vec(); - Ok(bytes) + AppHostApi::workspace_read_file(self, &path).map_err(ComponentHostState::map_host_error) } fn workspace_write_file( @@ -138,14 +167,8 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { path: String, content: Vec, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - self.host_call( - "workspace.writeFile", - serde_json::json!({ - "path": path, - "content": String::from_utf8_lossy(&content), - }), - )?; - Ok(()) + AppHostApi::workspace_write_file(self, &path, &content) + .map_err(ComponentHostState::map_host_error) } fn process_exec_git( @@ -158,36 +181,23 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { bindings::openvcs::plugin::host_api::ProcessExecOutput, bindings::openvcs::plugin::host_api::HostError, > { - let env_obj = env + let env = env .into_iter() - .map(|var| (var.key, Value::String(var.value))) - .collect::>(); - let value = self.host_call( - "process.exec", - serde_json::json!({ - "program": "git", - "cwd": cwd.unwrap_or_default(), - "args": args, - "env": env_obj, - "stdin": stdin.unwrap_or_default(), - }), - )?; + .map(|var| (var.key, var.value)) + .collect::>(); + let value = AppHostApi::process_exec_git( + self, + cwd.as_deref(), + &args, + &env, + stdin.as_deref(), + ) + .map_err(ComponentHostState::map_host_error)?; Ok(bindings::openvcs::plugin::host_api::ProcessExecOutput { - success: value - .get("success") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - status: value.get("status").and_then(|v| v.as_i64()).unwrap_or(-1) as i32, - stdout: value - .get("stdout") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - stderr: value - .get("stderr") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), + success: value.success, + status: value.status, + stdout: value.stdout, + stderr: value.stderr, }) } @@ -197,28 +207,24 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { target: String, message: String, ) { - let target = if target.trim().is_empty() { - format!("plugin.{}", self.spawn.plugin_id) - } else { - format!("plugin.{}.{}", self.spawn.plugin_id, target) - }; - match level { + let level = match level { bindings::openvcs::plugin::host_api::LogLevel::Trace => { - log::trace!(target: &target, "{message}") + openvcs_core::app_api::ComponentLogLevel::Trace } bindings::openvcs::plugin::host_api::LogLevel::Debug => { - log::debug!(target: &target, "{message}") + openvcs_core::app_api::ComponentLogLevel::Debug } bindings::openvcs::plugin::host_api::LogLevel::Info => { - log::info!(target: &target, "{message}") + openvcs_core::app_api::ComponentLogLevel::Info } bindings::openvcs::plugin::host_api::LogLevel::Warn => { - log::warn!(target: &target, "{message}") + openvcs_core::app_api::ComponentLogLevel::Warn } bindings::openvcs::plugin::host_api::LogLevel::Error => { - log::error!(target: &target, "{message}") + openvcs_core::app_api::ComponentLogLevel::Error } - } + }; + AppHostApi::host_log(self, level, &target, &message); } } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs new file mode 100644 index 00000000..65f0a9e4 --- /dev/null +++ b/Backend/src/plugin_runtime/host_api.rs @@ -0,0 +1,288 @@ +use crate::plugin_runtime::spawn::SpawnConfig; +use openvcs_core::app_api::{ComponentError, ProcessExecOutput}; +use serde_json::Value; +use std::collections::HashSet; +use std::ffi::OsString; +use std::fs; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; + +// Whitelisted environment variables that are forwarded to child Git processes. +const SANITIZED_ENV_KEYS: &[&str] = &[ + "HOME", + "USER", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GIT_SSH_COMMAND", + "OPENVCS_SSH_MODE", + "OPENVCS_SSH", +]; + +#[cfg(unix)] +const DEFAULT_PATH_UNIX: &str = "/usr/bin:/bin"; +#[cfg(windows)] +const DEFAULT_PATH_WINDOWS_SUFFIX: &str = "\\System32"; + +fn runtime_container_kind() -> &'static str { + if matches!( + std::env::var("OPENVCS_FLATPAK").as_deref(), + Ok("1") | Ok("true") | Ok("yes") | Ok("on") + ) { + "flatpak" + } else if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { + "appimage" + } else { + "native" + } +} + +fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option) { + let approved_caps = match &spawn.approval { + crate::plugin_bundles::ApprovalState::Approved { capabilities, .. } => { + capabilities.iter().cloned().collect::>() + } + _ => HashSet::new(), + }; + (approved_caps, spawn.allowed_workspace_root.clone()) +} + +fn host_error(code: &str, message: impl Into) -> ComponentError { + ComponentError { + code: code.to_string(), + message: message.into(), + } +} + +fn resolve_under_root(root: &Path, path: &str) -> Result { + if path.contains('\0') { + return Err("path contains NUL".to_string()); + } + + let p = Path::new(path); + if p.is_absolute() { + let root = root + .canonicalize() + .map_err(|e| format!("canonicalize root {}: {e}", root.display()))?; + let p = p + .canonicalize() + .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; + if p.starts_with(&root) { + return Ok(p); + } + return Err("path escapes workspace root".to_string()); + } + + let mut clean = PathBuf::new(); + for comp in p.components() { + match comp { + Component::Normal(c) => clean.push(c), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err("path must be relative and not contain '..'".to_string()) + } + } + } + Ok(root.join(clean)) +} + +fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { + let path = resolve_under_root(root, rel)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) +} + +fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { + let path = resolve_under_root(root, rel)?; + fs::read(&path).map_err(|e| format!("read {}: {e}", path.display())) +} + +fn sanitized_env() -> Vec<(OsString, OsString)> { + let mut out: Vec<(OsString, OsString)> = Vec::new(); + + for &k in SANITIZED_ENV_KEYS { + if let Ok(v) = std::env::var(k) { + out.push((k.into(), v.into())); + } + } + + #[cfg(unix)] + { + out.push(("PATH".into(), DEFAULT_PATH_UNIX.into())); + } + #[cfg(windows)] + { + if let Ok(sysroot) = std::env::var("SystemRoot") { + out.push(( + "PATH".into(), + format!("{sysroot}{}", DEFAULT_PATH_WINDOWS_SUFFIX).into(), + )); + } + } + + out +} + +pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { + openvcs_core::RuntimeInfo { + os: Some(std::env::consts::OS.to_string()), + arch: Some(std::env::consts::ARCH.to_string()), + container: Some(runtime_container_kind().to_string()), + } +} + +pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> Result<(), ComponentError> { + let name = event_name.trim(); + if name.is_empty() { + return Err(host_error("host.invalid_event_name", "event name is empty")); + } + crate::plugin_runtime::events::subscribe(&spawn.plugin_id, name); + Ok(()) +} + +pub fn host_emit_event( + spawn: &SpawnConfig, + event_name: &str, + payload: &[u8], +) -> Result<(), ComponentError> { + let name = event_name.trim(); + if name.is_empty() { + return Err(host_error("host.invalid_event_name", "event name is empty")); + } + let payload_json = if payload.is_empty() { + Value::Null + } else { + serde_json::from_slice(payload).map_err(|err| { + host_error( + "host.invalid_payload", + format!("payload is not valid JSON: {err}"), + ) + })? + }; + crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, name, payload_json); + Ok(()) +} + +pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> Result<(), ComponentError> { + let (caps, _) = approved_caps_and_workspace(spawn); + if !caps.contains("ui.notifications") { + return Err(host_error( + "capability.denied", + "missing capability: ui.notifications", + )); + } + let message = message.trim(); + if !message.is_empty() { + log::info!("plugin[{}] notify: {}", spawn.plugin_id, message); + } + Ok(()) +} + +pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> Result, ComponentError> { + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("workspace.read") && !caps.contains("workspace.write") { + return Err(host_error( + "capability.denied", + "missing capability: workspace.read (or workspace.write)", + )); + } + let Some(root) = workspace_root.as_ref() else { + return Err(host_error("workspace.denied", "no workspace context")); + }; + read_file_under_root(root, path).map_err(|err| host_error("workspace.error", err)) +} + +pub fn host_workspace_write_file( + spawn: &SpawnConfig, + path: &str, + content: &[u8], +) -> Result<(), ComponentError> { + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("workspace.write") { + return Err(host_error( + "capability.denied", + "missing capability: workspace.write", + )); + } + let Some(root) = workspace_root.as_ref() else { + return Err(host_error("workspace.denied", "no workspace context")); + }; + write_file_under_root(root, path, content).map_err(|err| host_error("workspace.error", err)) +} + +pub fn host_process_exec_git( + spawn: &SpawnConfig, + cwd: Option<&str>, + args: &[String], + env: &[(String, String)], + stdin: Option<&str>, +) -> Result { + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("process.exec") { + return Err(host_error( + "capability.denied", + "missing capability: process.exec", + )); + } + + let cwd = match cwd { + None => workspace_root, + Some(raw) if raw.trim().is_empty() => workspace_root, + Some(raw) => { + let Some(root) = spawn.allowed_workspace_root.as_ref() else { + return Err(host_error("workspace.denied", "no workspace context")); + }; + Some(resolve_under_root(root, raw).map_err(|e| host_error("workspace.denied", e))?) + } + }; + + let mut cmd = Command::new("git"); + if let Some(cwd) = cwd.as_ref() { + cmd.current_dir(cwd); + } + cmd.args(args); + cmd.env_clear(); + for (k, v) in sanitized_env() { + cmd.env(k, v); + } + for (k, v) in env { + if matches!(k.as_str(), "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT") { + cmd.env(k, v); + } + } + + let stdin_text = stdin.unwrap_or_default(); + let out = if stdin_text.is_empty() { + cmd.output() + .map_err(|e| host_error("process.error", format!("spawn git: {e}")))? + } else { + cmd.stdin(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| host_error("process.error", format!("spawn git: {e}")))?; + if let Some(mut child_stdin) = child.stdin.take() { + if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { + let _ = child.kill(); + return Err(host_error("process.error", format!("write stdin: {e}"))); + } + } + child + .wait_with_output() + .map_err(|e| host_error("process.error", format!("wait: {e}")))? + }; + + Ok(ProcessExecOutput { + success: out.status.success(), + status: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).to_string(), + stderr: String::from_utf8_lossy(&out.stderr).to_string(), + }) +} diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index 4fac0970..b3f09845 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -4,7 +4,7 @@ use std::sync::Arc; /// Runtime instance abstraction used by the plugin runtime manager. pub trait PluginRuntimeInstance: Send + Sync { - /// Returns runtime transport kind identifier (`component` or `stdio`). + /// Returns runtime transport kind identifier (`component`). fn runtime_kind(&self) -> &'static str; /// Ensures the underlying runtime instance is started. diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 7238ea79..7b843a09 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -1,11 +1,12 @@ use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_runtime_instance; -use crate::plugin_runtime::stdio_rpc::SpawnConfig; +use crate::plugin_runtime::spawn::SpawnConfig; use crate::settings::AppConfig; use parking_lot::Mutex; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::Arc; #[derive(Clone)] @@ -19,7 +20,12 @@ struct ModuleRuntimeSpec { /// Owns long-lived module plugin processes and coordinates lifecycle actions. pub struct PluginRuntimeManager { store: PluginBundleStore, - processes: Mutex>>, + processes: Mutex>, +} + +struct RunningPlugin { + runtime: Arc, + workspace_root: Option, } impl Default for PluginRuntimeManager { @@ -60,10 +66,10 @@ impl PluginRuntimeManager { /// - `Err(String)` when plugin lookup/startup fails. pub fn start_plugin(&self, plugin_id: &str) -> Result<(), String> { let key = normalize_plugin_key(plugin_id)?; - if let Some(existing) = self.processes.lock().get(&key).cloned() { - return existing.ensure_running(); + if let Some(existing) = self.processes.lock().get(&key) { + return existing.runtime.ensure_running(); } - let spec = self.resolve_module_runtime_spec(plugin_id)?; + let spec = self.resolve_module_runtime_spec(plugin_id, None)?; self.start_plugin_spec(spec) } @@ -82,11 +88,19 @@ impl PluginRuntimeManager { let key = normalize_plugin_key(plugin_id)?; let process = self.processes.lock().remove(&key); if let Some(process) = process { - process.stop(); + process.runtime.stop(); } Ok(()) } + /// Stops all running plugins. + pub fn stop_all_plugins(&self) { + let running = std::mem::take(&mut *self.processes.lock()); + for (_, process) in running { + process.runtime.stop(); + } + } + /// Synchronizes runtime process state with current persisted plugin settings. /// /// # Returns @@ -159,7 +173,20 @@ impl PluginRuntimeManager { method: &str, params: Value, ) -> Result { - let spec = self.resolve_module_runtime_spec(plugin_id)?; + self.call_module_method_for_workspace_with_config(cfg, plugin_id, method, params, None) + } + + /// Calls a module RPC method through the persistent plugin process with an + /// optional workspace-root confinement. + pub fn call_module_method_for_workspace_with_config( + &self, + cfg: &AppConfig, + plugin_id: &str, + method: &str, + params: Value, + allowed_workspace_root: Option, + ) -> Result { + let spec = self.resolve_module_runtime_spec(plugin_id, allowed_workspace_root)?; if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { return Err(format!("plugin `{}` is disabled", spec.plugin_id)); } @@ -169,33 +196,56 @@ impl PluginRuntimeManager { .processes .lock() .get(&spec.key) - .cloned() + .map(|p| Arc::clone(&p.runtime)) .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id))?; rpc.call(method, params) } fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { - if let Some(existing) = self.processes.lock().get(&spec.key).cloned() { - return existing.ensure_running(); + if let Some(existing) = self.processes.lock().get(&spec.key) { + if existing.workspace_root == spec.spawn.allowed_workspace_root { + return existing.runtime.ensure_running(); + } } - let instance = self.create_instance(&spec); + let instance = self.create_instance(&spec)?; instance.ensure_running()?; let mut lock = self.processes.lock(); - if let Some(existing) = lock.get(&spec.key).cloned() { - drop(lock); - return existing.ensure_running(); + if let Some(existing) = lock.get(&spec.key) { + if existing.workspace_root == spec.spawn.allowed_workspace_root { + let runtime = Arc::clone(&existing.runtime); + drop(lock); + return runtime.ensure_running(); + } + } + let runtime_to_stop = lock.get(&spec.key).map(|existing| Arc::clone(&existing.runtime)); + if let Some(runtime) = runtime_to_stop { + runtime.stop(); + lock.remove(&spec.key); } - lock.insert(spec.key, instance); + lock.insert( + spec.key, + RunningPlugin { + runtime: instance, + workspace_root: spec.spawn.allowed_workspace_root.clone(), + }, + ); Ok(()) } - fn create_instance(&self, spec: &ModuleRuntimeSpec) -> Arc { + fn create_instance( + &self, + spec: &ModuleRuntimeSpec, + ) -> Result, String> { create_runtime_instance(spec.spawn.clone()) } - fn resolve_module_runtime_spec(&self, plugin_id: &str) -> Result { + fn resolve_module_runtime_spec( + &self, + plugin_id: &str, + allowed_workspace_root: Option, + ) -> Result { let requested = plugin_id.trim(); if requested.is_empty() { return Err("plugin id is empty".to_string()); @@ -222,7 +272,7 @@ impl PluginRuntimeManager { args: Vec::new(), requested_capabilities: installed.requested_capabilities, approval: installed.approval, - allowed_workspace_root: None, + allowed_workspace_root, }, }) } @@ -236,6 +286,15 @@ impl PluginRuntimeManager { } } +impl Drop for PluginRuntimeManager { + fn drop(&mut self) { + let running = std::mem::take(&mut *self.processes.get_mut()); + for (_, process) in running { + process.runtime.stop(); + } + } +} + fn normalize_plugin_key(plugin_id: &str) -> Result { let plugin_id = plugin_id.trim().to_ascii_lowercase(); if plugin_id.is_empty() { diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 9fae49ba..3a010a1b 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,10 +1,10 @@ pub mod component_instance; pub mod events; +pub mod host_api; pub mod instance; pub mod manager; pub mod runtime_select; -pub mod stdio_instance; -pub mod stdio_rpc; +pub mod spawn; pub mod vcs_proxy; pub use manager::PluginRuntimeManager; diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 584e8bc9..fcff032b 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -1,7 +1,6 @@ use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::stdio_instance::StdioPluginRuntimeInstance; -use crate::plugin_runtime::stdio_rpc::SpawnConfig; +use crate::plugin_runtime::spawn::SpawnConfig; use std::path::Path; use std::sync::Arc; use wasmtime::component::Component; @@ -18,25 +17,22 @@ pub fn is_component_module(path: &Path) -> bool { } /// Selects and creates a runtime instance for a plugin module. -pub fn create_runtime_instance(spawn: SpawnConfig) -> Arc { - let runtime: Arc = if is_component_module(&spawn.exec_path) { - Arc::new(ComponentPluginRuntimeInstance::new(spawn.clone())) - } else { - log::warn!( - "plugin runtime: using deprecated stdio fallback for plugin `{}` ({})", - spawn.plugin_id, +pub fn create_runtime_instance(spawn: SpawnConfig) -> Result, String> { + if !is_component_module(&spawn.exec_path) { + return Err(format!( + "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", spawn.exec_path.display() - ); - Arc::new(StdioPluginRuntimeInstance::new(spawn.clone())) - }; + )); + } + let runtime: Arc = + Arc::new(ComponentPluginRuntimeInstance::new(spawn.clone())); log::info!( - "plugin runtime: selected `{}` transport for plugin `{}` ({})", - runtime.runtime_kind(), + "plugin runtime: selected `component` transport for plugin `{}` ({})", spawn.plugin_id, spawn.exec_path.display() ); - runtime + Ok(runtime) } #[cfg(test)] @@ -62,12 +58,12 @@ mod tests { } #[test] - fn selection_uses_stdio_fallback_for_core_wasm() { + fn selection_rejects_core_wasm() { let temp = tempdir().expect("tempdir"); let wasm_path = temp.path().join("plugin.wasm"); fs::write(&wasm_path, MINIMAL_WASM).expect("write wasm"); - let runtime = create_runtime_instance(SpawnConfig { + let err = match create_runtime_instance(SpawnConfig { plugin_id: "test.plugin".to_string(), component_label: "module".to_string(), exec_path: wasm_path, @@ -78,8 +74,10 @@ mod tests { approved_at_unix_ms: 0, }, allowed_workspace_root: None, - }); - - assert_eq!(runtime.runtime_kind(), "stdio"); + }) { + Ok(_) => panic!("expected non-component runtime rejection"), + Err(err) => err, + }; + assert!(err.contains("stdio runtime removed")); } } diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs new file mode 100644 index 00000000..ec96563b --- /dev/null +++ b/Backend/src/plugin_runtime/spawn.rs @@ -0,0 +1,13 @@ +use crate::plugin_bundles::ApprovalState; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct SpawnConfig { + pub plugin_id: String, + pub component_label: String, + pub exec_path: PathBuf, + pub args: Vec, + pub requested_capabilities: Vec, + pub approval: ApprovalState, + pub allowed_workspace_root: Option, +} diff --git a/Backend/src/plugin_runtime/stdio_instance.rs b/Backend/src/plugin_runtime/stdio_instance.rs deleted file mode 100644 index eb77a6fc..00000000 --- a/Backend/src/plugin_runtime/stdio_instance.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::stdio_rpc::{RpcConfig, SpawnConfig, StdioRpcProcess}; -use openvcs_core::models::VcsEvent; -use serde_json::Value; -use std::sync::Arc; - -/// Stdio-backed runtime instance implementation. -/// -/// Deprecated: this exists as a migration fallback while component runtime -/// support is being rolled out. -pub struct StdioPluginRuntimeInstance { - rpc: StdioRpcProcess, -} - -impl StdioPluginRuntimeInstance { - /// Creates a new stdio-backed runtime instance. - pub fn new(spawn: SpawnConfig) -> Self { - Self { - rpc: StdioRpcProcess::new(spawn, RpcConfig::default()), - } - } -} - -impl PluginRuntimeInstance for StdioPluginRuntimeInstance { - fn runtime_kind(&self) -> &'static str { - "stdio" - } - - fn ensure_running(&self) -> Result<(), String> { - self.rpc.ensure_running() - } - - fn call(&self, method: &str, params: Value) -> Result { - self.rpc - .call(method, params) - .map_err(|e| format!("{}: {}", e.code, e.message)) - } - - fn set_event_sink(&self, sink: Option>) { - self.rpc.set_event_sink(sink); - } - - fn stop(&self) { - self.rpc.stop(); - } -} diff --git a/Backend/src/plugin_runtime/stdio_rpc.rs b/Backend/src/plugin_runtime/stdio_rpc.rs deleted file mode 100644 index 4097c26e..00000000 --- a/Backend/src/plugin_runtime/stdio_rpc.rs +++ /dev/null @@ -1,1355 +0,0 @@ -use crate::plugin_bundles::ApprovalState; -use crate::plugin_runtime::events::{ - register_plugin_io, unregister_plugin, PluginIoHandle, PluginStdin, -}; -use openvcs_core::models::VcsEvent; -use openvcs_core::plugin_protocol::{PluginMessage, RpcRequest, RpcResponse}; -use serde_json::Value; -use std::collections::HashMap; -use std::ffi::OsString; -use std::fs; -use std::io::{self, BufRead, BufReader, LineWriter, Read, Write}; -#[cfg(unix)] -use std::os::fd::{FromRawFd, IntoRawFd}; -#[cfg(windows)] -use std::os::windows::io::{FromRawHandle, IntoRawHandle}; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -type EventSink = Arc>>>; - -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); -const BACKOFF_MS: u64 = 250; -const MAX_BACKOFF_MS: u64 = 30_000; -// Whitelisted environment variables that are forwarded to plugin processes. -// Centralized here to make adding/removing entries easier. -const SANITIZED_ENV_KEYS: &[&str] = &[ - "HOME", - "USER", - "USERPROFILE", - "TMPDIR", - "TEMP", - "TMP", - "LANG", - "LC_ALL", - // SSH / Git authentication - "SSH_AUTH_SOCK", - "SSH_AGENT_PID", - "GIT_SSH_COMMAND", - "OPENVCS_SSH_MODE", - "OPENVCS_SSH", -]; - -#[cfg(unix)] -const DEFAULT_PATH_UNIX: &str = "/usr/bin:/bin"; -#[cfg(windows)] -const DEFAULT_PATH_WINDOWS_SUFFIX: &str = "\\System32"; -const STDERR_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024; -const STDERR_LOG_MAX_FILES: usize = 5; -const MAX_PENDING: usize = 1024; -const MAX_CRASHES: u32 = 5; - -/// Detects the runtime container type for diagnostics/feature gating. -/// -/// # Returns -/// - Container label (`flatpak`, `appimage`, or `native`). -fn runtime_container_kind() -> &'static str { - if matches!( - std::env::var("OPENVCS_FLATPAK").as_deref(), - Ok("1") | Ok("true") | Ok("yes") | Ok("on") - ) { - "flatpak" - } else if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { - "appimage" - } else { - "native" - } -} - -#[derive(Debug, Clone)] -pub struct SpawnConfig { - pub plugin_id: String, - pub component_label: String, - pub exec_path: PathBuf, - pub args: Vec, - pub requested_capabilities: Vec, - pub approval: ApprovalState, - pub allowed_workspace_root: Option, -} - -#[derive(Debug, Clone)] -pub struct RpcConfig { - pub timeout: Duration, -} - -impl Default for RpcConfig { - /// Returns default RPC configuration values. - /// - /// # Returns - /// - Default [`RpcConfig`] with standard timeout. - fn default() -> Self { - Self { - timeout: DEFAULT_TIMEOUT, - } - } -} - -#[derive(Debug)] -pub struct RpcError { - pub code: String, - pub message: String, -} - -struct PendingMap { - next_id: u64, - pending: HashMap>, -} - -pub struct StdioRpcProcess { - spawn: SpawnConfig, - cfg: RpcConfig, - child: Mutex>, - stdin: PluginStdin, - pending: Arc>, - on_event: EventSink, - crash_count: Mutex, - backoff_ms: Mutex, - disabled: Mutex, -} - -struct ProcessHandle { - #[allow(dead_code)] - join: std::thread::JoinHandle<()>, - #[allow(dead_code)] - stdin_writer: os_pipe::PipeWriter, -} - -impl StdioRpcProcess { - /// Creates a new lazily-started stdio RPC process wrapper. - /// - /// # Parameters - /// - `spawn`: Process spawn configuration and capability policy. - /// - `cfg`: RPC behavior configuration (timeouts, etc.). - /// - /// # Returns - /// - A new [`StdioRpcProcess`] instance. - pub fn new(spawn: SpawnConfig, cfg: RpcConfig) -> Self { - Self { - spawn, - cfg, - child: Mutex::new(None), - stdin: Arc::new(Mutex::new(None)), - pending: Arc::new(Mutex::new(PendingMap { - next_id: 1, - pending: HashMap::new(), - })), - on_event: Arc::new(Mutex::new(None)), - crash_count: Mutex::new(0), - backoff_ms: Mutex::new(BACKOFF_MS), - disabled: Mutex::new(false), - } - } - - /// Sets or clears the event sink used for plugin-emitted `VcsEvent` values. - /// - /// # Parameters - /// - `sink`: Optional callback invoked for plugin events. - /// - /// # Returns - /// - `()`. - pub fn set_event_sink(&self, sink: Option>) { - if let Ok(mut lock) = self.on_event.lock() { - *lock = sink; - } - } - - /// Ensures the backing plugin process is running and ready for RPC calls. - /// - /// # Returns - /// - `Ok(())` when the process is running. - /// - `Err(String)` if startup/validation fails or the plugin is disabled. - pub fn ensure_running(&self) -> Result<(), String> { - if *self.disabled.lock().unwrap() { - return Err(format!( - "plugin {} is disabled after repeated crashes", - self.spawn.plugin_id - )); - } - - if self.child.lock().ok().map(|c| c.is_some()).unwrap_or(false) { - return Ok(()); - } - - if !self.spawn.exec_path.is_file() { - return Err(format!( - "plugin executable not found: {}", - self.spawn.exec_path.display() - )); - } - - if !is_wasm_module(&self.spawn.exec_path) { - return Err(format!( - "invalid plugin entrypoint (expected .wasm module): {}", - self.spawn.exec_path.display() - )); - } - - // Exponential backoff between respawns after failures. - let delay = *self.backoff_ms.lock().unwrap(); - let crashes = *self.crash_count.lock().unwrap(); - if crashes > 0 && delay > 0 { - std::thread::sleep(Duration::from_millis(delay)); - } - - if !matches!(self.spawn.approval, ApprovalState::Approved { .. }) - && !self.spawn.requested_capabilities.is_empty() - { - return Err(format!( - "plugin {} requires user approval for capabilities before execution", - self.spawn.plugin_id - )); - } - - self.spawn_wasm() - } - - /// Sends a JSON-RPC style request to the plugin and waits for a response. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON parameters payload. - /// - /// # Returns - /// - `Ok(Value)` with the response result payload. - /// - `Err(RpcError)` when the plugin is unavailable, times out, or returns an error. - pub fn call(&self, method: &str, params: Value) -> Result { - if let Err(e) = self.ensure_running() { - return Err(RpcError { - code: "plugin.not_ready".into(), - message: e, - }); - } - - let (id, rx) = { - let (tx, rx) = std::sync::mpsc::channel::(); - let mut lock = self.pending.lock().unwrap(); - if lock.pending.len() >= MAX_PENDING { - return Err(RpcError { - code: "plugin.busy".into(), - message: "too many pending plugin requests".into(), - }); - } - let id = lock.next_id; - lock.next_id = lock.next_id.saturating_add(1); - lock.pending.insert(id, tx); - (id, rx) - }; - - let req = RpcRequest { - id, - method: method.to_string(), - params, - }; - - self.write_message(&PluginMessage::Request(req)) - .map_err(|e| RpcError { - code: "plugin.io".into(), - message: e, - })?; - - let resp = rx.recv_timeout(self.cfg.timeout).map_err(|_| { - self.record_crash(); - self.kill_process(); - RpcError { - code: "plugin.timeout".into(), - message: "plugin request timed out".into(), - } - })?; - - if resp.ok { - Ok(resp.result) - } else { - Err(RpcError { - code: resp.error_code.unwrap_or_else(|| "plugin.error".into()), - message: resp.error.unwrap_or_else(|| "error".into()), - }) - } - } - - /// Serializes and writes a plugin message to child stdin. - /// - /// # Parameters - /// - `msg`: Message to send. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(String)` when serialization/write/flush fails. - fn write_message(&self, msg: &PluginMessage) -> Result<(), String> { - let line = serde_json::to_string(msg).map_err(|e| format!("serialize: {e}"))?; - let mut lock = self.stdin.lock().unwrap(); - let Some(stdin) = lock.as_mut() else { - return Err("plugin stdin not available".to_string()); - }; - let stdin: &mut LineWriter> = stdin; - if let Err(e) = writeln!(stdin, "{line}") { - drop(lock); - self.record_crash(); - self.kill_process(); - return Err(format!("write stdin: {e}")); - } - if let Err(e) = stdin.flush() { - drop(lock); - self.record_crash(); - self.kill_process(); - return Err(format!("flush stdin: {e}")); - } - Ok(()) - } - - /// Increments crash counters and updates disable/backoff policy. - /// - /// # Returns - /// - `()`. - fn record_crash(&self) { - let mut crashes = self.crash_count.lock().unwrap(); - *crashes = crashes.saturating_add(1); - if *crashes >= MAX_CRASHES { - *self.disabled.lock().unwrap() = true; - } else { - let mut backoff = self.backoff_ms.lock().unwrap(); - *backoff = (*backoff).saturating_mul(2).min(MAX_BACKOFF_MS); - } - } - - /// Tears down the running plugin process and clears pending requests. - /// - /// # Returns - /// - `()`. - fn kill_process(&self) { - unregister_plugin(&self.spawn.plugin_id); - - // Drop stdin so the child sees EOF. - *self.stdin.lock().unwrap() = None; - - // Clear pending so callers don't leak memory (timeouts still handle the callsite). - if let Ok(mut lock) = self.pending.lock() { - lock.pending.clear(); - } - - let mut child_lock = self.child.lock().unwrap(); - if let Some(ProcessHandle { - join, - mut stdin_writer, - }) = child_lock.take() - { - // Try to flush any buffered data (ignore errors) and drop the - // write end so the plugin's stdin reader sees EOF. - let _ = stdin_writer.flush(); - drop(stdin_writer); - - // If the thread has already finished, join synchronously (cheap). - // Otherwise detach a background joiner so shutdown remains - // non-blocking while ensuring the thread is eventually reaped. - if join.is_finished() { - let _ = join.join(); - } else { - std::thread::spawn(move || { - let _ = join.join(); - }); - } - } - } - - /// Explicitly stops the running plugin process. - /// - /// # Returns - /// - `()`. - pub fn stop(&self) { - self.kill_process(); - } - - /// Spawns the WASI plugin process and wire-up IO/event threads. - /// - /// # Returns - /// - `Ok(())` when spawn succeeds. - /// - `Err(String)` when pipe/setup operations fail. - fn spawn_wasm(&self) -> Result<(), String> { - let (stdin_reader, stdin_writer) = - os_pipe::pipe().map_err(|e| format!("create stdin pipe: {e}"))?; - let (stdout_reader, stdout_writer) = - os_pipe::pipe().map_err(|e| format!("create stdout pipe: {e}"))?; - let (stderr_reader, stderr_writer) = - os_pipe::pipe().map_err(|e| format!("create stderr pipe: {e}"))?; - - let wasm_path = self.spawn.exec_path.clone(); - let plugin_id = self.spawn.plugin_id.clone(); - let args = self.spawn.args.clone(); - let host_timeout = self.cfg.timeout; - let (approved_caps, allowed_workspace_root) = approved_caps_and_workspace(&self.spawn); - - let join = std::thread::spawn(move || { - let cfg = RunWasiConfig { - wasm_path, - plugin_id, - args, - host_timeout, - approved_caps, - allowed_workspace_root, - stdin: stdin_reader, - stdout: stdout_writer, - stderr: stderr_writer, - }; - if let Err(e) = run_wasi_module(cfg) { - log::error!("wasi plugin crashed: {}", e); - } - }); - - let stdin: Box = Box::new( - stdin_writer - .try_clone() - .map_err(|e| format!("clone stdin pipe: {e}"))?, - ); - let stdin = LineWriter::new(stdin); - *self.stdin.lock().unwrap() = Some(stdin); - - register_plugin_io( - &self.spawn.plugin_id, - PluginIoHandle { - stdin: Arc::clone(&self.stdin), - }, - ); - - let pending = Arc::clone(&self.pending); - let spawn = self.spawn.clone(); - let stdin_for_responses = Arc::clone(&self.stdin); - let on_event = Arc::clone(&self.on_event); - let stdout_log_path = - plugin_stdout_log_path(&self.spawn.plugin_id, &self.spawn.component_label); - - std::thread::spawn(move || { - read_stdout_loop( - stdout_reader, - spawn, - pending, - stdin_for_responses, - on_event, - stdout_log_path, - ) - }); - - let stderr_path = - plugin_stderr_log_path(&self.spawn.plugin_id, &self.spawn.component_label); - let stderr_plugin_id = self.spawn.plugin_id.clone(); - let stderr_component = self.spawn.component_label.clone(); - std::thread::spawn(move || { - read_stderr_loop( - stderr_reader, - stderr_path, - stderr_plugin_id, - stderr_component, - ) - }); - - *self.child.lock().unwrap() = Some(ProcessHandle { join, stdin_writer }); - Ok(()) - } -} - -impl Drop for StdioRpcProcess { - /// Ensures the child process is cleaned up during drop. - /// - /// # Returns - /// - `()`. - fn drop(&mut self) { - // Best-effort cleanup; ignore errors. - self.kill_process(); - } -} - -/// Reads plugin stdout lines and dispatches responses/events/host requests. -/// -/// # Parameters -/// - `stdout`: Readable stdout stream. -/// - `spawn`: Spawn metadata used for host request handling. -/// - `pending`: Pending-response map. -/// - `stdin_for_responses`: Writable stdin for sending host responses. -/// - `on_event`: Optional event callback sink. -/// - `stdout_log_path`: Path used for logging unparsable lines. -/// -/// # Returns -/// - `()`. -fn read_stdout_loop( - stdout: impl io::Read, - spawn: SpawnConfig, - pending: Arc>, - stdin_for_responses: PluginStdin, - on_event: EventSink, - stdout_log_path: PathBuf, -) { - let reader = BufReader::new(stdout); - for line in reader.lines().map_while(Result::ok) { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let msg: PluginMessage = match serde_json::from_str(trimmed) { - Ok(m) => m, - Err(_) => { - let _ = append_log_line(&stdout_log_path, trimmed); - continue; - } - }; - match msg { - PluginMessage::Response(resp) => { - let tx = pending - .lock() - .ok() - .and_then(|mut p| p.pending.remove(&resp.id)); - if let Some(tx) = tx { - let _ = tx.send(resp); - } - } - PluginMessage::Event { event } => { - log_plugin_event(&spawn.plugin_id, &spawn.component_label, &event); - if let Ok(lock) = on_event.lock() { - if let Some(cb) = lock.as_ref() { - cb(event.clone()); - } - } - } - PluginMessage::Request(req) => { - let resp = handle_host_request(&spawn, req); - if let Ok(mut lock) = stdin_for_responses.lock() { - if let Some(stdin) = lock.as_mut() { - let stdin: &mut LineWriter> = stdin; - if let Ok(line) = serde_json::to_string(&PluginMessage::Response(resp)) { - let _ = writeln!(stdin, "{line}"); - let _ = stdin.flush(); - } - } - } - } - } - } -} - -/// Reads plugin stderr, persists logs, and forwards messages to host logging. -/// -/// # Parameters -/// - `stderr`: Readable stderr stream. -/// - `path`: Destination stderr log path. -/// - `plugin_id`: Plugin id for log prefixes. -/// - `component`: Component label for log prefixes. -/// -/// # Returns -/// - `()`. -fn read_stderr_loop(stderr: impl io::Read, path: PathBuf, plugin_id: String, component: String) { - let reader = BufReader::new(stderr); - for line in reader.lines().map_while(Result::ok) { - let _ = append_log_line(&path, &line); - - // Also forward plugin stderr into the main OpenVCS-Client logs. - // - // openvcs-core's plugin logger prints: - // [INFO] some::target: message - // Parse the level prefix when present; otherwise treat it as INFO. - let prefix = format!("[plugin:{plugin_id}:{component}] "); - if let Some((lvl, rest)) = parse_plugin_stderr_level(&line) { - log::log!(lvl, "{}{}", prefix, rest); - } else { - log::info!("{}{}", prefix, line); - } - } -} - -/// Forwards plugin events into host logs even when no explicit event sink is set. -/// -/// # Parameters -/// - `plugin_id`: Plugin id for log prefix. -/// - `component`: Component label for log prefix. -/// - `event`: Event payload from plugin stdout. -/// -/// # Returns -/// - `()`. -fn log_plugin_event(plugin_id: &str, component: &str, event: &VcsEvent) { - let prefix = format!("[plugin:{plugin_id}:{component}] "); - match event { - VcsEvent::Info { msg } => log::info!("{}{}", prefix, msg), - VcsEvent::RemoteMessage { msg } => log::info!("{}{}", prefix, msg), - VcsEvent::Progress { phase, detail } => log::info!("{}{}: {}", prefix, phase, detail), - VcsEvent::Auth { method, detail } => log::info!("{}auth {}: {}", prefix, method, detail), - VcsEvent::PushStatus { refname, status } => { - let status = status.as_deref().unwrap_or("unknown"); - log::info!("{}push {} -> {}", prefix, refname, status); - } - VcsEvent::Warning { msg } => log::warn!("{}{}", prefix, msg), - VcsEvent::Error { msg } => log::error!("{}{}", prefix, msg), - } -} - -/// Checks whether a file is a wasm module by extension and magic bytes. -/// -/// # Parameters -/// - `path`: Candidate executable path. -/// -/// # Returns -/// - `true` when file looks like wasm. -/// - `false` otherwise. -fn is_wasm_module(path: &Path) -> bool { - if path.extension().and_then(|s| s.to_str()) != Some("wasm") { - return false; - } - let mut f = match fs::File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - let mut magic = [0u8; 4]; - matches!( - f.read(&mut magic), - Ok(n) if n == magic.len() && magic == [0x00, 0x61, 0x73, 0x6d] - ) -} - -/// Parses `[LEVEL]` prefixes emitted by plugin stderr logging. -/// -/// # Parameters -/// - `line`: Raw stderr line. -/// -/// # Returns -/// - `Some((Level, &str))` parsed level and message tail. -/// - `None` when no recognized level prefix exists. -fn parse_plugin_stderr_level(line: &str) -> Option<(log::Level, &str)> { - let line = line.trim(); - if !line.starts_with('[') { - return None; - } - let end = line.find(']')?; - let level = &line[1..end]; - let level = match level { - "ERROR" => log::Level::Error, - "WARN" | "WARNING" => log::Level::Warn, - "INFO" => log::Level::Info, - "DEBUG" => log::Level::Debug, - "TRACE" => log::Level::Trace, - _ => return None, - }; - - let rest = line[end + 1..].trim_start(); - Some((level, rest)) -} - -/// Returns approved capabilities and workspace root from spawn config. -/// -/// # Parameters -/// - `spawn`: Spawn config. -/// -/// # Returns -/// - Tuple of approved capability ids and optional workspace root. -fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (Vec, Option) { - let approved_caps = match &spawn.approval { - ApprovalState::Approved { capabilities, .. } => capabilities.clone(), - _ => Vec::new(), - }; - (approved_caps, spawn.allowed_workspace_root.clone()) -} - -pub struct RunWasiConfig { - pub wasm_path: PathBuf, - pub plugin_id: String, - pub args: Vec, - pub host_timeout: Duration, - pub approved_caps: Vec, - pub allowed_workspace_root: Option, - pub stdin: os_pipe::PipeReader, - pub stdout: os_pipe::PipeWriter, - pub stderr: os_pipe::PipeWriter, -} - -/// Executes a plugin wasm module inside a WASI runtime. -/// -/// # Parameters -/// - `cfg`: WASI execution configuration and IO handles. -/// -/// # Returns -/// - `Ok(())` when module exits successfully. -/// - `Err(String)` when module load/instantiate/call fails. -fn run_wasi_module(cfg: RunWasiConfig) -> Result<(), String> { - let RunWasiConfig { - wasm_path, - plugin_id, - args, - host_timeout, - approved_caps, - allowed_workspace_root, - stdin, - stdout, - stderr, - } = cfg; - let allowed_workspace_root = allowed_workspace_root.as_deref(); - use wasmtime::{Engine, Module, Store}; - use wasmtime_wasi::cli::{AsyncStdinStream, OutputFile}; - use wasmtime_wasi::WasiCtxBuilder; - - // Convert os_pipe readers/writers into std::fs::File using - // the platform-specific helpers defined below. - let stdin_file = into_file_from_reader(stdin); - let stdout_file = into_file_from_writer(stdout); - let stderr_file = into_file_from_writer(stderr); - - let engine = Engine::default(); - let module = Module::from_file(&engine, &wasm_path).map_err(|e| format!("load module: {e}"))?; - - let mut linker = wasmtime::Linker::new(&engine); - wasmtime_wasi::p1::add_to_linker_sync(&mut linker, |cx| cx).map_err(|e| format!("{e}"))?; - - let stdin_tokio = tokio::fs::File::from_std(stdin_file); - let stdin_stream = AsyncStdinStream::new(stdin_tokio); - let stdout_stream = OutputFile::new(stdout_file); - let stderr_stream = OutputFile::new(stderr_file); - - // Override args: keep deterministic while still allowing component args. - let mut argv = Vec::with_capacity(1 + args.len()); - argv.push( - wasm_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("plugin.wasm") - .to_string(), - ); - argv.extend(args.iter().cloned()); - - let mut builder = WasiCtxBuilder::new(); - builder.stdin(stdin_stream); - builder.stdout(stdout_stream); - builder.stderr(stderr_stream); - builder.env("OPENVCS_PLUGIN_ID", plugin_id.as_str()); - builder.env( - "OPENVCS_PLUGIN_HOST_TIMEOUT_MS", - host_timeout.as_millis().to_string(), - ); - builder.args(&argv); - - // Do not preopen the host filesystem into WASI. All file I/O must go through - // host RPCs which are scoped to `allowed_workspace_root` and capability-gated. - let _ = (approved_caps.as_slice(), allowed_workspace_root); - - let mut store = Store::new(&engine, builder.build_p1()); - - let instance = linker - .instantiate(&mut store, &module) - .map_err(|e| format!("instantiate: {e}"))?; - let start = instance - .get_typed_func::<(), ()>(&mut store, "_start") - .map_err(|e| format!("missing _start: {e}"))?; - start - .call(&mut store, ()) - .map_err(|e| format!("wasi trap: {e}"))?; - - Ok(()) -} - -/// Handles host-side RPC methods requested by plugin code. -/// -/// # Parameters -/// - `spawn`: Plugin spawn/config metadata. -/// - `req`: Incoming RPC request. -/// -/// # Returns -/// - RPC response payload. -pub(crate) fn handle_host_request(spawn: &SpawnConfig, req: RpcRequest) -> RpcResponse { - let deny = |code: &str, msg: &str| RpcResponse { - id: req.id, - ok: false, - result: Value::Null, - error: Some(msg.to_string()), - error_code: Some(code.to_string()), - error_data: None, - }; - - let (approved_caps, _) = approved_caps_and_workspace(spawn); - let caps = approved_caps - .into_iter() - .collect::>(); - - match req.method.as_str() { - "runtime.info" => RpcResponse { - id: req.id, - ok: true, - result: serde_json::json!({ - "os": std::env::consts::OS, - "arch": std::env::consts::ARCH, - "container": runtime_container_kind(), - }), - error: None, - error_code: None, - error_data: None, - }, - "events.subscribe" => { - let name = req - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return deny("invalid.params", "missing params.name"); - } - crate::plugin_runtime::events::subscribe(&spawn.plugin_id, &name); - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "events.emit" => { - let name = req - .params - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if name.is_empty() { - return deny("invalid.params", "missing params.name"); - } - let payload = req.params.get("payload").cloned().unwrap_or(Value::Null); - crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, &name, payload); - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "ui.notify" => { - if !caps.contains("ui.notifications") { - return deny("capability.denied", "missing capability: ui.notifications"); - } - let msg = req - .params - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if !msg.is_empty() { - log::info!("plugin[{}] notify: {}", spawn.plugin_id, msg); - } - RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - } - } - "workspace.readFile" => { - if !caps.contains("workspace.read") && !caps.contains("workspace.write") { - return deny( - "capability.denied", - "missing capability: workspace.read (or workspace.write)", - ); - } - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - let rel = req - .params - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - match read_file_under_root(root, &rel) { - Ok(bytes) => RpcResponse { - id: req.id, - ok: true, - result: Value::String(String::from_utf8_lossy(&bytes).to_string()), - error: None, - error_code: None, - error_data: None, - }, - Err(e) => deny("workspace.error", &e), - } - } - "workspace.writeFile" => { - if !caps.contains("workspace.write") { - return deny("capability.denied", "missing capability: workspace.write"); - } - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - let rel = req - .params - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let data = req - .params - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - match write_file_under_root(root, &rel, data.as_bytes()) { - Ok(()) => RpcResponse { - id: req.id, - ok: true, - result: Value::Null, - error: None, - error_code: None, - error_data: None, - }, - Err(e) => deny("workspace.error", &e), - } - } - "process.exec" => { - if !caps.contains("process.exec") { - return deny("capability.denied", "missing capability: process.exec"); - } - let program = req - .params - .get("program") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if program != "git" { - return deny("process.denied", "only 'git' is allowed"); - } - let argv = req - .params - .get("args") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>(); - - let cwd_param = req.params.get("cwd").and_then(|v| v.as_str()).unwrap_or(""); - let cwd = if cwd_param.trim().is_empty() { - spawn.allowed_workspace_root.clone() - } else { - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - return deny("workspace.denied", "no workspace context"); - }; - match resolve_under_root(root, cwd_param) { - Ok(p) => Some(p), - Err(e) => return deny("workspace.denied", &e), - } - }; - - let mut cmd = Command::new("git"); - if let Some(cwd) = cwd.as_ref() { - cmd.current_dir(cwd); - } - cmd.args(argv); - cmd.env_clear(); - for (k, v) in sanitized_env() { - cmd.env(k, v); - } - if let Some(env) = req.params.get("env").and_then(|v| v.as_object()) { - for (k, v) in env { - let Some(val) = v.as_str() else { continue }; - if matches!(k.as_str(), "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT") { - cmd.env(k, val); - } - } - } - - let stdin_text = req - .params - .get("stdin") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let out = if stdin_text.is_empty() { - match cmd.output() { - Ok(o) => o, - Err(e) => return deny("process.error", &format!("spawn git: {e}")), - } - } else { - cmd.stdin(Stdio::piped()); - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(e) => return deny("process.error", &format!("spawn git: {e}")), - }; - if let Some(mut stdin) = child.stdin.take() { - if let Err(e) = stdin.write_all(stdin_text.as_bytes()) { - let _ = child.kill(); - return deny("process.error", &format!("write stdin: {e}")); - } - } - match child.wait_with_output() { - Ok(o) => o, - Err(e) => return deny("process.error", &format!("wait: {e}")), - } - }; - - RpcResponse { - id: req.id, - ok: true, - result: serde_json::json!({ - "status": out.status.code().unwrap_or(-1), - "success": out.status.success(), - "stdout": String::from_utf8_lossy(&out.stdout), - "stderr": String::from_utf8_lossy(&out.stderr), - }), - error: None, - error_code: None, - error_data: None, - } - } - _ => deny("method.not_found", "unknown host method"), - } -} - -/// Resolves a path under a workspace root and blocks escapes. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `path`: Relative or absolute candidate path. -/// -/// # Returns -/// - `Ok(PathBuf)` resolved safe path. -/// - `Err(String)` when invalid or outside root. -fn resolve_under_root(root: &Path, path: &str) -> Result { - if path.contains('\0') { - return Err("path contains NUL".to_string()); - } - - let p = Path::new(path); - - // Allow absolute paths if they are within the allowed workspace root. - if p.is_absolute() { - let root = root - .canonicalize() - .map_err(|e| format!("canonicalize root {}: {e}", root.display()))?; - let p = p - .canonicalize() - .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; - if p.starts_with(&root) { - return Ok(p); - } - return Err("path escapes workspace root".to_string()); - } - - // Otherwise require a clean, relative path with no `..`. - let mut clean = PathBuf::new(); - for comp in p.components() { - match comp { - Component::Normal(c) => clean.push(c), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err("path must be relative and not contain '..'".to_string()) - } - } - } - Ok(root.join(clean)) -} - -/// Writes bytes to a file constrained to workspace root. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `rel`: Relative file path. -/// - `bytes`: File bytes to write. -/// -/// # Returns -/// - `Ok(())` when write succeeds. -/// - `Err(String)` when resolution/IO fails. -fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { - let path = resolve_under_root(root, rel)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; - } - fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) -} - -// Move helper functions above the test module to avoid `items_after_test_module` warnings. -/// Reads bytes from a file constrained to workspace root. -/// -/// # Parameters -/// - `root`: Allowed workspace root. -/// - `rel`: Relative file path. -/// -/// # Returns -/// - `Ok(Vec)` file bytes. -/// - `Err(String)` when resolution/IO fails. -fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { - let joined = resolve_under_root(root, rel)?; - fs::read(&joined).map_err(|e| format!("read {}: {e}", joined.display())) -} - -/// Builds a sanitized environment map for child process execution. -/// -/// # Returns -/// - Whitelisted environment key/value pairs. -fn sanitized_env() -> Vec<(OsString, OsString)> { - let mut out: Vec<(OsString, OsString)> = Vec::new(); - - for &k in SANITIZED_ENV_KEYS { - if let Ok(v) = std::env::var(k) { - out.push((k.into(), v.into())); - } - } - - #[cfg(unix)] - { - out.push(("PATH".into(), DEFAULT_PATH_UNIX.into())); - } - #[cfg(windows)] - { - if let Ok(sysroot) = std::env::var("SystemRoot") { - out.push(( - "PATH".into(), - format!("{sysroot}{}", DEFAULT_PATH_WINDOWS_SUFFIX).into(), - )); - } - } - - out -} - -/// Returns per-plugin stderr log path. -/// -/// # Parameters -/// - `plugin_id`: Plugin identifier. -/// - `component`: Component label. -/// -/// # Returns -/// - Log file path. -fn plugin_stderr_log_path(plugin_id: &str, component: &str) -> PathBuf { - crate::plugin_bundles::PluginBundleStore::new_default() - .plugin_root_dir(plugin_id) - .join("logs") - .join(format!("{component}.stderr.log")) -} - -/// Returns per-plugin stdout log path. -/// -/// # Parameters -/// - `plugin_id`: Plugin identifier. -/// - `component`: Component label. -/// -/// # Returns -/// - Log file path. -fn plugin_stdout_log_path(plugin_id: &str, component: &str) -> PathBuf { - crate::plugin_bundles::PluginBundleStore::new_default() - .plugin_root_dir(plugin_id) - .join("logs") - .join(format!("{component}.stdout.log")) -} - -// Platform-specific conversions from os_pipe types into `std::fs::File`. -// Implemented as separate functions per-platform to keep unsafe blocks small -// and clearly documented. -#[cfg(unix)] -/// Converts a unix pipe reader into an owned `File`. -/// -/// # Parameters -/// - `r`: Pipe reader handle. -/// -/// # Returns -/// - Owned file descriptor wrapper. -fn into_file_from_reader(r: os_pipe::PipeReader) -> std::fs::File { - // Safety: we consume the PipeReader and immediately create a File which - // becomes the sole owner of the underlying fd. - unsafe { std::fs::File::from_raw_fd(r.into_raw_fd()) } -} - -#[cfg(unix)] -/// Converts a unix pipe writer into an owned `File`. -/// -/// # Parameters -/// - `w`: Pipe writer handle. -/// -/// # Returns -/// - Owned file descriptor wrapper. -fn into_file_from_writer(w: os_pipe::PipeWriter) -> std::fs::File { - // Safety: we consume the PipeWriter and immediately create a File which - // becomes the sole owner of the underlying fd. - unsafe { std::fs::File::from_raw_fd(w.into_raw_fd()) } -} - -#[cfg(windows)] -/// Converts a windows pipe reader into an owned `File`. -/// -/// # Parameters -/// - `r`: Pipe reader handle. -/// -/// # Returns -/// - Owned file handle wrapper. -fn into_file_from_reader(r: os_pipe::PipeReader) -> std::fs::File { - // Safety: we consume the PipeReader and immediately create a File which - // becomes the sole owner of the underlying handle. - unsafe { std::fs::File::from_raw_handle(r.into_raw_handle()) } -} - -#[cfg(windows)] -/// Converts a windows pipe writer into an owned `File`. -/// -/// # Parameters -/// - `w`: Pipe writer handle. -/// -/// # Returns -/// - Owned file handle wrapper. -fn into_file_from_writer(w: os_pipe::PipeWriter) -> std::fs::File { - // Safety: we consume the PipeWriter and immediately create a File which - // becomes the sole owner of the underlying handle. - unsafe { std::fs::File::from_raw_handle(w.into_raw_handle()) } -} - -/// Appends a timestamped line to a plugin log file. -/// -/// # Parameters -/// - `path`: Log file path. -/// - `line`: Log line text. -/// -/// # Returns -/// - `Ok(())` when append succeeds. -/// - `Err(io::Error)` on IO failure. -fn append_log_line(path: &Path, line: &str) -> io::Result<()> { - if let Some(parent) = path.parent() { - let _ = fs::create_dir_all(parent); - } - rotate_if_needed(path)?; - let mut f = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - writeln!(f, "[{ts}] {line}")?; - Ok(()) -} - -/// Rotates a plugin log file when it exceeds configured size. -/// -/// # Parameters -/// - `path`: Log file path. -/// -/// # Returns -/// - `Ok(())` on success. -/// - `Err(io::Error)` on metadata/IO failure. -fn rotate_if_needed(path: &Path) -> io::Result<()> { - let meta = match fs::metadata(path) { - Ok(m) => m, - Err(_) => return Ok(()), - }; - if meta.len() < STDERR_LOG_MAX_BYTES { - return Ok(()); - } - - let dir = path.parent().unwrap_or_else(|| Path::new(".")); - let stem = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("plugin.stderr.log"); - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let rotated = dir.join(format!("{stem}.{ts}.log")); - let _ = fs::rename(path, rotated); - - // Best-effort prune old logs. - let mut logs: Vec = fs::read_dir(dir) - .map(|rd| rd.flatten().map(|e| e.path()).collect()) - .unwrap_or_default(); - logs.sort(); - if logs.len() > STDERR_LOG_MAX_FILES { - let excess = logs.len() - STDERR_LOG_MAX_FILES; - for p in logs.into_iter().take(excess) { - let _ = fs::remove_file(p); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - /// Verifies a minimal wasm plugin can be started. - /// - /// # Returns - /// - `()`. - fn runs_minimal_wasm_module() { - // Minimal wasm module exporting an empty `_start`. - // (module (func (export "_start"))) - let wasm: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // header - 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type section - 0x03, 0x02, 0x01, 0x00, // func section - 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, // export - 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, // code - ]; - - let dir = tempfile::tempdir().expect("tempdir"); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, wasm).expect("write wasm"); - - let rpc = StdioRpcProcess::new( - SpawnConfig { - plugin_id: "test".into(), - component_label: "functions".into(), - exec_path: wasm_path, - args: Vec::new(), - requested_capabilities: Vec::new(), - approval: ApprovalState::Approved { - capabilities: Vec::new(), - approved_at_unix_ms: 0, - }, - allowed_workspace_root: None, - }, - RpcConfig::default(), - ); - - rpc.ensure_running().expect("ensure_running"); - } - - #[test] - /// Verifies absolute paths under root are accepted by resolver. - /// - /// # Returns - /// - `()`. - fn resolve_under_root_allows_absolute_under_root() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(&root).expect("mkdir root"); - let child = root.join("child"); - std::fs::create_dir_all(&child).expect("mkdir child"); - - let resolved = - resolve_under_root(&root, child.to_string_lossy().as_ref()).expect("resolve"); - assert!(resolved.starts_with(&root)); - } - - #[test] - /// Verifies parent-directory escapes are rejected for writes. - /// - /// # Returns - /// - `()`. - fn write_file_under_root_rejects_parent_dir_escape() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path().join("root"); - std::fs::create_dir_all(&root).expect("mkdir root"); - - let err = write_file_under_root(&root, "../escape.txt", b"nope").unwrap_err(); - assert!(err.contains("relative") || err.contains("escape") || err.contains("..")); - } -} - -// Duplicate helper definitions removed (handled earlier in the file). diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index c608c824..f7ac54a8 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,7 +1,7 @@ use crate::plugin_bundles::ApprovalState; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_runtime_instance; -use crate::plugin_runtime::stdio_rpc::SpawnConfig; +use crate::plugin_runtime::spawn::SpawnConfig; use crate::settings::AppConfig; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, @@ -56,7 +56,10 @@ impl PluginVcsProxy { approval, allowed_workspace_root: Some(workdir.clone()), }; - let runtime = create_runtime_instance(spawn); + let runtime = create_runtime_instance(spawn).map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: e, + })?; let p = PluginVcsProxy { backend_id, workdir, diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 36299e02..e86c2af5 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -3,17 +3,14 @@ use std::sync::Arc; use log::{error, info, warn}; use serde_json::Value; -use tauri::{async_runtime, Manager, Runtime, State, Window}; +use tauri::{async_runtime, Runtime, State, Window}; use openvcs_core::BackendId; use std::collections::BTreeMap; -use crate::plugin_runtime::runtime_select::create_runtime_instance; -use crate::plugin_runtime::stdio_rpc::SpawnConfig; use crate::plugin_vcs_backends; use crate::repo::Repo; use crate::state::AppState; -use crate::tauri_commands::shared::progress_bridge; #[tauri::command] /// Lists VCS backends currently available from plugins. @@ -183,7 +180,7 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S /// - `Err(String)` when validation, backend resolution, or RPC execution fails. #[tauri::command] pub async fn call_vcs_backend_method( - window: Window, + _window: Window, state: State<'_, AppState>, backend_id: BackendId, method: String, @@ -204,35 +201,16 @@ pub async fn call_vcs_backend_method( .ok_or_else(|| "No repository selected".to_string())?; let allowed_workspace_root = resolve_allowed_workspace_root(&repo_root, ¶ms)?; - // Run the backend RPC on a blocking thread so the Tauri main thread and - // webview are not blocked by long-running operations (e.g. LFS transfers). - let backend_id_clone = backend_id_str.clone(); - let method_clone = method.clone(); - let params_clone = params.clone(); - let desc_clone = desc.clone(); - let on_event = progress_bridge(window.app_handle().clone()); - - let call_task = async_runtime::spawn_blocking(move || { - let runtime = create_runtime_instance(SpawnConfig { - plugin_id: desc_clone.plugin_id, - component_label: format!("vcs-backend-{}", backend_id_clone), - exec_path: desc_clone.exec_path, - args: Vec::new(), - requested_capabilities: desc_clone.requested_capabilities, - approval: desc_clone.approval, + let cfg = state.config(); + state + .plugin_runtime() + .call_module_method_for_workspace_with_config( + &cfg, + &desc.plugin_id, + &method, + params, allowed_workspace_root, - }); - runtime.set_event_sink(Some(on_event)); - let call = runtime.call(&method_clone, params_clone); - runtime.stop(); - call - }); - - let call_res = call_task - .await - .map_err(|e| format!("call_vcs_backend_method task failed: {e}"))?; - - call_res + ) } /// Resolves optional backend workspace path and enforces repo-root confinement. diff --git a/Cargo.lock b/Cargo.lock index c2965778..4d083724 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3281,7 +3281,7 @@ dependencies = [ "env_logger", "hex", "log", - "openvcs-core", + "openvcs-core 0.1.8", "os_pipe", "parking_lot", "regex", @@ -3305,6 +3305,16 @@ dependencies = [ "zip 7.4.0", ] +[[package]] +name = "openvcs-core" +version = "0.1.8" +dependencies = [ + "log", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "openvcs-core" version = "0.1.8" @@ -3324,7 +3334,7 @@ dependencies = [ "git2", "linkme", "log", - "openvcs-core", + "openvcs-core 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_json", "thiserror 2.0.18", From 07abed8b9a202fba861b4cc6012488004d24941a Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 19:41:50 +0000 Subject: [PATCH 10/96] Update --- Backend/built-in-plugins/Git | 2 +- Cargo.lock | 204 +++++++++++++++++++++++++++++++---- 2 files changed, 182 insertions(+), 24 deletions(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index ece76e46..4476eb50 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit ece76e467fc725ed8da743740acfd586f296eeda +Subproject commit 4476eb500fc2b4c4f444b1b9ced187eafff5c132 diff --git a/Cargo.lock b/Cargo.lock index 4d083724..dd22cc86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,6 +298,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auditable-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1532,6 +1544,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -3281,7 +3294,7 @@ dependencies = [ "env_logger", "hex", "log", - "openvcs-core 0.1.8", + "openvcs-core", "os_pipe", "parking_lot", "regex", @@ -3315,18 +3328,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "openvcs-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706c4aa8b72515997a4b518b6e5372bd39213a841774a6675123ad19d3c99d1d" -dependencies = [ - "log", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "openvcs-plugin-git" version = "0.2.0" @@ -3334,11 +3335,12 @@ dependencies = [ "git2", "linkme", "log", - "openvcs-core 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", + "openvcs-core", "serde", "serde_json", "thiserror 2.0.18", "time", + "wit-bindgen 0.41.0", ] [[package]] @@ -4732,6 +4734,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5509,6 +5520,12 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "tower" version = "0.5.3" @@ -5849,7 +5866,7 @@ version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -5858,7 +5875,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -5941,6 +5958,16 @@ dependencies = [ "wat", ] +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser 0.227.1", +] + [[package]] name = "wasm-encoder" version = "0.243.0" @@ -5971,6 +5998,25 @@ dependencies = [ "wasmparser 0.245.0", ] +[[package]] +name = "wasm-metadata" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap 2.13.0", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder 0.227.1", + "wasmparser 0.227.1", +] + [[package]] name = "wasm-metadata" version = "0.244.0" @@ -5996,6 +6042,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wasmparser" version = "0.243.0" @@ -7009,13 +7067,34 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro 0.41.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.51.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser 0.227.1", ] [[package]] @@ -7029,6 +7108,33 @@ dependencies = [ "wit-parser 0.244.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +dependencies = [ + "bitflags 2.10.0", + "futures", + "once_cell", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata 0.227.1", + "wit-bindgen-core 0.41.0", + "wit-component 0.227.1", +] + [[package]] name = "wit-bindgen-rust" version = "0.51.0" @@ -7040,9 +7146,24 @@ dependencies = [ "indexmap 2.13.0", "prettyplease", "syn 2.0.114", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core 0.41.0", + "wit-bindgen-rust 0.41.0", ] [[package]] @@ -7056,8 +7177,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-component" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.227.1", + "wasm-metadata 0.227.1", + "wasmparser 0.227.1", + "wit-parser 0.227.1", ] [[package]] @@ -7074,11 +7214,29 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.244.0", - "wasm-metadata", + "wasm-metadata 0.244.0", "wasmparser 0.244.0", "wit-parser 0.244.0", ] +[[package]] +name = "wit-parser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.227.1", +] + [[package]] name = "wit-parser" version = "0.243.0" From 2a20681a7eeec11a22b52774d0254b98c321b8b0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 20:00:18 +0000 Subject: [PATCH 11/96] Update --- Backend/src/plugin_bundles.rs | 11 ----------- Backend/src/plugin_runtime/component_instance.rs | 4 ---- Backend/src/plugin_runtime/events.rs | 16 ---------------- Backend/src/plugin_runtime/instance.rs | 3 --- Backend/src/plugin_runtime/manager.rs | 3 --- Backend/src/plugin_runtime/runtime_select.rs | 3 --- Backend/src/plugin_runtime/spawn.rs | 3 --- Backend/src/plugin_runtime/vcs_proxy.rs | 5 ----- Backend/src/plugin_vcs_backends.rs | 5 ----- Cargo.lock | 1 + 10 files changed, 1 insertion(+), 53 deletions(-) diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index a6b925ed..7d34a38a 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -241,17 +241,6 @@ impl PluginBundleStore { Self { root } } - /// Returns the root directory for a specific plugin id. - /// - /// # Parameters - /// - `plugin_id`: Plugin identifier used as a directory name under the store root. - /// - /// # Returns - /// - Path to the plugin root directory. - pub fn plugin_root_dir(&self, plugin_id: &str) -> PathBuf { - self.root.join(plugin_id.trim()) - } - #[cfg(test)] /// Creates a store rooted at an explicit test directory. /// diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 0c260fd8..d4a0b22a 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -310,10 +310,6 @@ fn encode_method_result( } impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { - fn runtime_kind(&self) -> &'static str { - "component" - } - fn ensure_running(&self) -> Result<(), String> { let mut lock = self.runtime.lock(); if lock.is_some() { diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index cd99fd8c..f0fc2b75 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -34,22 +34,6 @@ fn registry() -> &'static Mutex { }) } -/// Registers a plugin's stdin handle for outbound host->plugin messages. -/// -/// # Parameters -/// - `plugin_id`: Plugin id to register. -/// - `stdin`: IO handle containing the writable plugin stdin channel. -/// -/// # Returns -/// - `()`. -pub fn register_plugin_io(plugin_id: &str, stdin: PluginIoHandle) { - if let Ok(mut lock) = registry().lock() { - lock.io.insert(plugin_id.to_string(), stdin); - lock.next_id.entry(plugin_id.to_string()).or_insert(1); - lock.subs.entry(plugin_id.to_string()).or_default(); - } -} - #[allow(dead_code)] /// Removes a plugin from the runtime event registry. /// diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index b3f09845..f4c8652d 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -4,9 +4,6 @@ use std::sync::Arc; /// Runtime instance abstraction used by the plugin runtime manager. pub trait PluginRuntimeInstance: Send + Sync { - /// Returns runtime transport kind identifier (`component`). - fn runtime_kind(&self) -> &'static str; - /// Ensures the underlying runtime instance is started. fn ensure_running(&self) -> Result<(), String>; diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 7b843a09..a5240de4 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -267,10 +267,7 @@ impl PluginRuntimeManager { default_enabled: components.default_enabled, spawn: SpawnConfig { plugin_id: components.plugin_id, - component_label: "module".into(), exec_path: module.exec_path, - args: Vec::new(), - requested_capabilities: installed.requested_capabilities, approval: installed.approval, allowed_workspace_root, }, diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index fcff032b..472b5d7b 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -65,10 +65,7 @@ mod tests { let err = match create_runtime_instance(SpawnConfig { plugin_id: "test.plugin".to_string(), - component_label: "module".to_string(), exec_path: wasm_path, - args: Vec::new(), - requested_capabilities: Vec::new(), approval: ApprovalState::Approved { capabilities: Vec::new(), approved_at_unix_ms: 0, diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs index ec96563b..c67b39ae 100644 --- a/Backend/src/plugin_runtime/spawn.rs +++ b/Backend/src/plugin_runtime/spawn.rs @@ -4,10 +4,7 @@ use std::path::PathBuf; #[derive(Debug, Clone)] pub struct SpawnConfig { pub plugin_id: String, - pub component_label: String, pub exec_path: PathBuf, - pub args: Vec, - pub requested_capabilities: Vec, pub approval: ApprovalState, pub allowed_workspace_root: Option, } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index f7ac54a8..51667425 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -27,7 +27,6 @@ impl PluginVcsProxy { /// - `backend_id`: Backend id exposed by the plugin. /// - `exec_path`: Path to the plugin wasm/module executable. /// - `approval`: Capability approval state for the plugin version. - /// - `requested_capabilities`: Capabilities requested by the plugin. /// - `repo_path`: Repository working-tree path to open. /// /// # Returns @@ -38,7 +37,6 @@ impl PluginVcsProxy { backend_id: BackendId, exec_path: PathBuf, approval: ApprovalState, - requested_capabilities: Vec, repo_path: &Path, ) -> Result, VcsError> { let workdir = repo_path.to_path_buf(); @@ -49,10 +47,7 @@ impl PluginVcsProxy { })?; let spawn = SpawnConfig { plugin_id, - component_label: format!("vcs-backend-{}", backend_id.as_ref()), exec_path, - args: Vec::new(), - requested_capabilities, approval, allowed_workspace_root: Some(workdir.clone()), }; diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 47baeb8e..848edf25 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -40,8 +40,6 @@ pub struct PluginBackendDescriptor { pub plugin_name: Option, /// Executable used to proxy backend operations. pub exec_path: std::path::PathBuf, - /// Capabilities requested by the plugin version providing this backend. - pub requested_capabilities: Vec, /// Current capability approval state for the plugin version. pub approval: ApprovalState, } @@ -139,7 +137,6 @@ pub fn list_plugin_vcs_backends() -> Result, String plugin_id: p.plugin_id.clone(), plugin_name: p.name.clone(), exec_path: module.exec_path.clone(), - requested_capabilities: installed.requested_capabilities.clone(), approval: installed.approval.clone(), }; let key = backend_id.as_ref().to_string(); @@ -194,7 +191,6 @@ pub fn list_plugin_vcs_backends() -> Result, String plugin_id: plugin_id.to_string(), plugin_name: plugin_name.clone(), exec_path: exec_path.clone(), - requested_capabilities: requested_capabilities.clone(), approval: ApprovalState::Approved { capabilities: approval_caps.clone(), approved_at_unix_ms: 0, @@ -260,7 +256,6 @@ pub fn open_repo_via_plugin_vcs_backend( backend_id, desc.exec_path, desc.approval, - desc.requested_capabilities, path, ) } diff --git a/Cargo.lock b/Cargo.lock index dd22cc86..2cb34729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3326,6 +3326,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "wit-bindgen 0.41.0", ] [[package]] From 67cf723870f4cf5a0cb4919dc13b075e1be559f8 Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 20:04:05 +0000 Subject: [PATCH 12/96] Update --- Backend/built-in-plugins/Git | 2 +- Cargo.lock | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 4476eb50..db875096 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 4476eb500fc2b4c4f444b1b9ced187eafff5c132 +Subproject commit db8750961c69d0f05e666b95ed55b7780048f69b diff --git a/Cargo.lock b/Cargo.lock index 2cb34729..f1e0523c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3341,7 +3341,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "time", - "wit-bindgen 0.41.0", ] [[package]] From 9190fa7907af56da80009a448160649bda166e8f Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 20:14:19 +0000 Subject: [PATCH 13/96] Update --- AGENTS.md | 85 +++++++++++++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 61d6e464..40b64ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,44 @@ # Repository Guidelines -## Project Structure & Module Organization -- `Backend/`: Rust + Tauri application code (`src/`), commands (`src/tauri_commands/`), and plugin runtime. -- `Backend/built-in-plugins/`: git submodules for bundled plugins; initialize/update with `git submodule update --init --recursive`. -- `Frontend/`: TypeScript + Vite UI (`src/scripts/`, `src/styles/`, `src/modals/`), with Vitest tests colocated as `*.test.ts`. -- `docs/`: architecture and plugin docs plus UI assets. -- `packaging/flatpak/`: Flatpak manifests and packaging notes. -- Root files: workspace `Cargo.toml`, `Justfile`, and project docs (`README.md`, `ARCHITECTURE.md`, `SECURITY.md`). - -## Build, Test, and Development Commands -- `just build` (or `just build client|plugins`): build frontend, backend, and plugin bundles. -- `git submodule update --init --recursive`: fetch submodule content (required before plugin builds/tests). -- `just test`: workspace Rust tests + frontend TypeScript check + Vitest run. -- `just fix`: run `cargo fmt`, `cargo clippy --fix`, and frontend typecheck. -- `cargo tauri dev`: run the desktop app in development mode. -- `npm --prefix Frontend run dev`: run frontend-only Vite dev server. -- `just tauri-build`: production Tauri build wrapper. - -## Coding Style & Naming Conventions -- Rust: format with `cargo fmt --all`; keep clippy-clean (`cargo clippy --all-targets -- -D warnings`). -- TypeScript: 2-space indentation, ES modules, and small feature-focused files under `Frontend/src/scripts/features/`. -- Tests: name frontend tests `*.test.ts` near the implementation (example: `Frontend/src/scripts/lib/dom.test.ts`). -- Naming: use `snake_case` for Rust modules/functions and `camelCase` for TypeScript variables/functions. - -# ExecPlans - -When writing complex features or significant refactors, use an ExecPlan (as described in .agent/PLANS.md) from design to implementation. - -## Testing Guidelines -- Run full checks before opening a PR: `just test`. -- For frontend-only work, run `cd Frontend && npm test` and `npm exec tsc -- -p tsconfig.json --noEmit`. -- Add or update tests for behavior changes; prefer focused unit tests over broad snapshots. - -## Commit & Pull Request Guidelines -- Follow existing commit style: short imperative subject, optional scope prefix (examples: `backend: fix tauri precommands`, `ci: add wasm32-wasip1 target`, `chore(deps): bump @types/node`). -- Keep commits logically scoped; avoid mixing frontend/backend refactors unless required. -- Do not directly modify plugin code under `Backend/built-in-plugins/`; only update submodule pointers in this repository when explicitly requested. -- PRs should include: summary of behavior changes, linked issue(s), test evidence (command output), and screenshots for UI changes. -- Target the `Dev` branch for normal development work. - -## Security & Configuration Tips -- Review `SECURITY.md` before changing update, plugin, or network-related code paths. -- Do not commit secrets; keep local overrides in files like `.env.tauri.local`. -- Do not directly edit code inside git submodules (including `Backend/built-in-plugins/*`) unless the task explicitly requires a submodule update; treat submodule changes as pointer-only updates in this repo. +## Project structure & module organization +- `Backend/`: Rust + Tauri backend (`src/`), commands (`src/tauri_commands/`), plugin runtime (`src/plugin_runtime/`), and bundled plugin support (`src/plugin_runtime`, `scripts/`). +- `Backend/built-in-plugins/`: local copies of bundled plugins (do not edit their code unless explicitly requested; update submodule pointers instead). +- `Frontend/`: TypeScript + Vite UI code (`src/scripts/`, `src/styles/`, `src/modals/`), with Vitest tests colocated as `*.test.ts` files. +- `docs/`: UX docs, plugin architecture notes, and plugin/theme packaging guides referenced by contributors. +- `packaging/flatpak/`: Flatpak manifests and Flatpak-specific build notes. +- Supporting files at the repo root include the workspace `Cargo.toml`, `Justfile`, `README.md`, `ARCHITECTURE.md`, `SECURITY.md`, and installer scripts. + +## Build, test, and development commands +- `just build` (or `just build client|plugins`): builds the backend, frontend, and plugin bundles from the workspace Justfile. +- `just test`: runs workspace Rust tests plus frontend type-check + Vitest via the Justfile. +- `just fix`: formatter/lint quick fixes (runs `cargo fmt`, `cargo clippy --fix`, frontend type-check, and bundle verification). +- `cargo tauri dev`: run the desktop app in dev mode (`Backend/` directory). +- `npm --prefix Frontend run dev`: run the frontend-only Vite dev server; use `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` for TS checks and `npm --prefix Frontend test` for Vitest when needed. +- `just tauri-build`: production Tauri build wrapper (AppImage/Flatpak). `git submodule update --init --recursive` is required before building bundled plugins. + +## Plugin runtime & host expectations +- Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). +- The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. The canonical host/plugin contract is defined in `Core/wit/openvcs-core.wit` (see `openvcs_core::app_api`). +- When changing host APIs, capability strings, or runtime behavior, update `Core/wit/openvcs-core.wit`, the generated bindings, and the runtime logic in `Backend/src/plugin_runtime`. + +## Coding style & conventions +- Rust: run `cargo fmt --all`, keep `cargo clippy --all-targets -- -D warnings` clean, prefer `snake_case` for modules/functions and `PascalCase` for structs/enums. +- TypeScript: 2-space indentation, ES modules, small feature-focused files under `Frontend/src/scripts/features/`. Tests should be alongside the code (`*.test.ts`). +- Keep plugin UI contributions (e.g., `Backend/built-in-plugins/Git/entry.js`) concise and prefer host APIs (`OpenVCS.invoke`, settings/actions) documented in `docs/`. + +## ExecPlans +- For multi-component features or refactors, create/update an ExecPlan (`Client/PLANS.md`). Outline design, component impacts, and how the plugin runtime is exercised. + +## Testing guidelines +- Run `just test` before PRs; frontend-only work should at least cover `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` and `npm --prefix Frontend test`. +- Use `cargo tauri dev` to verify runtime plugin interactions (especially when touching `Backend/src/plugin_runtime/`), and make sure `docs/plugin architecture.md` stays aligned with behavior. + +## Commit & PR guidelines +- Use short, imperative commit subjects (optionally scoped, e.g., `backend: refresh plugin runtime config`). Keep changelist focused; avoid mixing UI and backend refactors unless necessary. +- PRs should target the `Dev` branch, include a summary, issue links, commands/tests run, and highlight architecture implications (host API changes, plugin capability updates, security decisions). +- Do not modify plugin code inside submodules unless explicitly asked; treat submodule updates as pointer bumps after upstream changes. +- Keep this AGENTS (and other module-level copies you rely on) current whenever workflows, tooling, or responsibilities change so future contributors can find accurate guidance. + +## Security & configuration notes +- Review `SECURITY.md` before making plugin, plugin-install, or network-related changes. +- Keep secrets out of the repo; use `.env.tauri.local` for local overrides and do not check them in. If new config flags are introduced, document them in `docs/` and update relevant settings screens/logs. From 7d7051eb7ffc513709032ec291447e4d9fe6717c Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 13 Feb 2026 20:22:00 +0000 Subject: [PATCH 14/96] Update AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 40b64ba5..d909769e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). - The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. The canonical host/plugin contract is defined in `Core/wit/openvcs-core.wit` (see `openvcs_core::app_api`). - When changing host APIs, capability strings, or runtime behavior, update `Core/wit/openvcs-core.wit`, the generated bindings, and the runtime logic in `Backend/src/plugin_runtime`. +- JavaScript-based plugin UI contributions (e.g., `entry.js`) are deprecated: route new UI work through the host/app APIs rather than embedding JS so bundles remain Wasm-only. ## Coding style & conventions - Rust: run `cargo fmt --all`, keep `cargo clippy --all-targets -- -D warnings` clean, prefer `snake_case` for modules/functions and `PascalCase` for structs/enums. From 4747723f3bee3c3e8dc4b5c8189c9e818b6bce97 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 14 Feb 2026 00:35:38 +0000 Subject: [PATCH 15/96] Update --- ARCHITECTURE.md | 4 ++-- DESIGN.md | 4 ++-- docs/plugin architecture.md | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9cd2ba06..766e6278 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,7 +33,7 @@ Backend: - `Backend/src/repo.rs`: repository handle wrapper around `Arc`. - `Backend/src/plugin_vcs_backends.rs`: backend discovery and open logic. - `Backend/src/plugin_bundles.rs`: `.ovcsp` install/index/component resolution. -- `Backend/src/plugin_runtime/stdio_rpc.rs`: plugin process spawn, RPC transport, restarts/timeouts. +- `Backend/src/plugin_runtime/component_instance.rs`: plugin component instantiation and typed ABI calls. - `Backend/src/plugin_runtime/vcs_proxy.rs`: `Vcs` trait proxy over plugin RPC. - `Backend/src/plugins.rs`: plugin discovery/manifest summarization for UI. @@ -44,7 +44,7 @@ Backend: - Command boundary: Feature-facing backend API lives under `Backend/src/tauri_commands/`. - Backend/plugin boundary: - Backend communicates with plugin components over stdio JSON-RPC, not in-process APIs. + Backend communicates with plugin components over the component-model ABI defined in `Core/wit/openvcs-core.wit`, not in-process APIs. - Settings boundary: Backend persists/loads app configuration and mediates environment application. diff --git a/DESIGN.md b/DESIGN.md index 36c403b1..ac43e7e2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -48,7 +48,7 @@ UI code under `Frontend/src/scripts/features/` delegates repository operations t ### 3) Backend to plugin communication is process-isolated -Plugin backend/function components communicate over stdio JSON-RPC (`Backend/src/plugin_runtime/stdio_rpc.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. +Plugin backend/function components communicate over the component-model ABI (`Backend/src/plugin_runtime/component_instance.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. ### 4) Safety checks are centralized @@ -134,7 +134,7 @@ When adding or changing behavior: - `Backend/src/lib.rs` - `Backend/src/tauri_commands/mod.rs` - `Backend/src/tauri_commands/shared.rs` -- `Backend/src/plugin_runtime/stdio_rpc.rs` +- `Backend/src/plugin_runtime/component_instance.rs` - `Backend/src/plugin_runtime/vcs_proxy.rs` - `Backend/src/plugin_vcs_backends.rs` - `Frontend/src/scripts/lib/tauri.ts` diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 63e8a180..51e64602 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -5,7 +5,7 @@ This document describes OpenVCS’s **secure, out-of-process** plugin system for ## Goals (non-negotiables) - The OpenVCS-Client process **never loads third-party dynamic libraries** and **never runs third-party plugin code in-process**. -- Every plugin component executes **out-of-process** and communicates over **JSON-RPC (line-delimited JSON) via stdio**. +- Every plugin component executes **out-of-process** and communicates over the **component-model WIT ABI**. - Plugins are **installed (unpacked) before execution**; nothing executes directly from inside a tar.xz archive. - The bundle manifest uses the existing `openvcs.plugin.json` format and extends it minimally. @@ -61,8 +61,8 @@ Example: - **Module component** (`module`): a plugin-executed WASI module. It is spawned with: - module: `bin/` (must end in `.wasm`) - arguments: `--backend ` for each id in `module.vcs_backends` - - protocol: stdio JSON-RPC using `openvcs_core::plugin_protocol` message types -- **Function component** (`functions`): a WASI module exposing callable functions/hooks/commands over the same stdio JSON-RPC transport. + - protocol: component-model host/plugin interfaces from `Core/wit/openvcs-core.wit` +- **Function component** (`functions`): a WASI module exposing callable functions/hooks/commands over the same component-model transport. ## Installation locations and layout From 0c6a5c724ba410a56e3d54bb3f720fd99a27e95b Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 14 Feb 2026 00:38:25 +0000 Subject: [PATCH 16/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index db875096..146048fd 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit db8750961c69d0f05e666b95ed55b7780048f69b +Subproject commit 146048fd8de38b81cde759b989e98578a890cea6 From 140227e730525ab36654344e9a251618bddf725e Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 14 Feb 2026 01:16:16 +0000 Subject: [PATCH 17/96] Update component_instance.rs --- .../src/plugin_runtime/component_instance.rs | 76 ++++++++++++++----- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index d4a0b22a..cb12f472 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1,16 +1,17 @@ -use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::host_api::{ host_emit_event, host_process_exec_git, host_runtime_info, host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, }; +use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; use openvcs_core::app_api::Host as AppHostApi; use parking_lot::Mutex; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; -use wasmtime::component::{Component, Linker}; +use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::{Engine, Store}; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; mod bindings { wasmtime::component::bindgen!({ @@ -27,9 +28,10 @@ struct ComponentRuntime { bindings: bindings::OpenvcsPlugin, } -#[derive(Clone)] struct ComponentHostState { spawn: SpawnConfig, + table: ResourceTable, + wasi: WasiCtx, } impl ComponentHostState { @@ -44,7 +46,9 @@ impl ComponentHostState { } impl AppHostApi for ComponentHostState { - fn get_runtime_info(&mut self) -> Result { + fn get_runtime_info( + &mut self, + ) -> Result { Ok(host_runtime_info()) } @@ -88,11 +92,17 @@ impl AppHostApi for ComponentHostState { args: &[String], env: &[(String, String)], stdin: Option<&str>, - ) -> Result { + ) -> Result + { host_process_exec_git(&self.spawn, cwd, args, env, stdin) } - fn host_log(&mut self, level: openvcs_core::app_api::ComponentLogLevel, target: &str, message: &str) { + fn host_log( + &mut self, + level: openvcs_core::app_api::ComponentLogLevel, + target: &str, + message: &str, + ) { let target = if target.trim().is_empty() { format!("plugin.{}", self.spawn.plugin_id) } else { @@ -118,6 +128,15 @@ impl AppHostApi for ComponentHostState { } } +impl WasiView for ComponentHostState { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { fn get_runtime_info( &mut self, @@ -125,7 +144,8 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { bindings::openvcs::plugin::host_api::RuntimeInfo, bindings::openvcs::plugin::host_api::HostError, > { - let value = AppHostApi::get_runtime_info(self).map_err(ComponentHostState::map_host_error)?; + let value = + AppHostApi::get_runtime_info(self).map_err(ComponentHostState::map_host_error)?; Ok(bindings::openvcs::plugin::host_api::RuntimeInfo { os: value.os, arch: value.arch, @@ -145,7 +165,8 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { event_name: String, payload: Vec, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::emit_event(self, &event_name, &payload).map_err(ComponentHostState::map_host_error) + AppHostApi::emit_event(self, &event_name, &payload) + .map_err(ComponentHostState::map_host_error) } fn ui_notify( @@ -185,14 +206,9 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { .into_iter() .map(|var| (var.key, var.value)) .collect::>(); - let value = AppHostApi::process_exec_git( - self, - cwd.as_deref(), - &args, - &env, - stdin.as_deref(), - ) - .map_err(ComponentHostState::map_host_error)?; + let value = + AppHostApi::process_exec_git(self, cwd.as_deref(), &args, &env, stdin.as_deref()) + .map_err(ComponentHostState::map_host_error)?; Ok(bindings::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, status: value.status, @@ -248,6 +264,8 @@ impl ComponentPluginRuntimeInstance { let component = Component::from_file(&engine, &self.spawn.exec_path) .map_err(|e| format!("load component {}: {e}", self.spawn.exec_path.display()))?; let mut linker = Linker::new(&engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker) + .map_err(|e| format!("link wasi imports: {e}"))?; bindings::OpenvcsPlugin::add_to_linker::< ComponentHostState, wasmtime::component::HasSelf, @@ -257,6 +275,8 @@ impl ComponentPluginRuntimeInstance { &engine, ComponentHostState { spawn: self.spawn.clone(), + table: ResourceTable::new(), + wasi: WasiCtx::builder().build(), }, ); let bindings = bindings::OpenvcsPlugin::instantiate(&mut store, &component, &linker) @@ -381,7 +401,29 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { } "branches" => { let out = invoke!("branches", call_list_branches)?; - encode_method_result(&self.spawn.plugin_id, method, out) + let normalized = out + .into_iter() + .map(|item| { + let kind = match item.kind { + plugin_api::BranchKind::Local => { + serde_json::json!({ "type": "Local" }) + } + plugin_api::BranchKind::Remote(remote) => { + serde_json::json!({ "type": "Remote", "remote": remote }) + } + plugin_api::BranchKind::Unknown => { + serde_json::json!({ "type": "Unknown" }) + } + }; + serde_json::json!({ + "name": item.name, + "full_ref": item.full_ref, + "kind": kind, + "current": item.current, + }) + }) + .collect::>(); + encode_method_result(&self.spawn.plugin_id, method, normalized) } "local_branches" => { let out = invoke!("local_branches", call_list_local_branches)?; From cd5ef2ace700cae902bd7eedd3bbe8dc37b90952 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 14 Feb 2026 08:45:31 +0000 Subject: [PATCH 18/96] Update --- Backend/Cargo.toml | 1 + Backend/src/lib.rs | 3 + Backend/src/plugin_bundles.rs | 21 -- Backend/src/plugin_paths.rs | 29 +- Backend/src/plugin_runtime/host_api.rs | 5 +- Backend/src/plugin_runtime/manager.rs | 24 +- Backend/src/plugin_runtime/runtime_select.rs | 4 +- Backend/src/plugin_runtime/vcs_proxy.rs | 37 +-- Backend/src/plugin_vcs_backends.rs | 58 ++-- Backend/src/plugins.rs | 267 ++++++++++++------- Backend/src/state.rs | 6 +- Backend/src/tauri_commands/backends.rs | 18 +- Backend/src/tauri_commands/general.rs | 4 + Cargo.lock | 149 ++++++++++- 14 files changed, 417 insertions(+), 209 deletions(-) diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index caedd682..a4967b17 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -30,6 +30,7 @@ openvcs-core = { path = "../../Core", features = ["plugin-protocol", "vcs"] } tauri = { version = "2.9", features = [] } tauri-plugin-opener = "2.5" serde = { version = "1", features = ["derive"] } +notify = "6" tauri-plugin-dialog = "2.5" tauri-plugin-updater = "2.9" tokio = { version = "1.49", features = ["io-util", "process", "rt", "sync", "time"] } diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 494a6437..4b001fbc 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -80,7 +80,10 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { let path_str = path.to_string_lossy().to_string(); if crate::plugin_vcs_backends::has_plugin_vcs_backend(&backend) { + let runtime_manager = state.plugin_runtime(); match crate::plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &app_config, backend, Path::new(&path), ) { diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 7d34a38a..412541d9 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -224,9 +224,7 @@ pub struct ModuleComponent { pub struct InstalledPluginComponents { pub plugin_id: String, pub name: Option, - pub version: String, pub default_enabled: bool, - pub requested_capabilities: Vec, pub module: Option, } @@ -808,22 +806,6 @@ impl PluginBundleStore { )); } - let version = manifest - .version - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - version_dir - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - }); - - let requested_capabilities = normalize_capabilities(manifest.capabilities.clone()); - let module = manifest.module.and_then(|m| { let exec = m.exec?.trim().to_string(); if exec.is_empty() { @@ -869,10 +851,7 @@ impl PluginBundleStore { .name .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), - version, default_enabled: manifest.default_enabled, - - requested_capabilities, module, })) } diff --git a/Backend/src/plugin_paths.rs b/Backend/src/plugin_paths.rs index 62ae72a2..eed76834 100644 --- a/Backend/src/plugin_paths.rs +++ b/Backend/src/plugin_paths.rs @@ -5,7 +5,10 @@ use log::{info, warn}; use std::{ env, path::{Path, PathBuf}, - sync::OnceLock, + sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, + }, }; /// File name expected for plugin manifests. @@ -17,6 +20,7 @@ pub const BUILT_IN_PLUGINS_DIR_NAME: &str = "built-in-plugins"; // it here so plugin discovery can include resources embedded in the // application bundle. static RESOURCE_DIR: OnceLock = OnceLock::new(); +static LOGGED_BUILTIN_DIRS: AtomicBool = AtomicBool::new(false); /// Returns the user-writable plugin installation directory. /// @@ -102,15 +106,20 @@ pub fn built_in_plugin_dirs() -> Vec { }) .collect(); - if result.is_empty() { - info!("plugins: no built-in plugin directories found"); - } else { - let joined = result - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", "); - info!("plugins: checked built-in plugin directories: {}", joined); + if LOGGED_BUILTIN_DIRS + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + if result.is_empty() { + info!("plugins: no built-in plugin directories found"); + } else { + let joined = result + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + info!("plugins: checked built-in plugin directories: {}", joined); + } } result diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 65f0a9e4..8b2f7e46 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -186,7 +186,10 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> Result<(), Componen Ok(()) } -pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> Result, ComponentError> { +pub fn host_workspace_read_file( + spawn: &SpawnConfig, + path: &str, +) -> Result, ComponentError> { let (caps, workspace_root) = approved_caps_and_workspace(spawn); if !caps.contains("workspace.read") && !caps.contains("workspace.write") { return Err(host_error( diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index a5240de4..24809968 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -201,6 +201,26 @@ impl PluginRuntimeManager { rpc.call(method, params) } + /// Returns the persistent runtime instance for a plugin workspace. + pub fn runtime_for_workspace_with_config( + &self, + cfg: &AppConfig, + plugin_id: &str, + allowed_workspace_root: Option, + ) -> Result, String> { + let spec = self.resolve_module_runtime_spec(plugin_id, allowed_workspace_root)?; + if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { + return Err(format!("plugin `{}` is disabled", spec.plugin_id)); + } + + self.start_plugin_spec(spec.clone())?; + self.processes + .lock() + .get(&spec.key) + .map(|p| Arc::clone(&p.runtime)) + .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id)) + } + fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { if let Some(existing) = self.processes.lock().get(&spec.key) { if existing.workspace_root == spec.spawn.allowed_workspace_root { @@ -219,7 +239,9 @@ impl PluginRuntimeManager { return runtime.ensure_running(); } } - let runtime_to_stop = lock.get(&spec.key).map(|existing| Arc::clone(&existing.runtime)); + let runtime_to_stop = lock + .get(&spec.key) + .map(|existing| Arc::clone(&existing.runtime)); if let Some(runtime) = runtime_to_stop { runtime.stop(); lock.remove(&spec.key); diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 472b5d7b..6ebc44f1 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -17,7 +17,9 @@ pub fn is_component_module(path: &Path) -> bool { } /// Selects and creates a runtime instance for a plugin module. -pub fn create_runtime_instance(spawn: SpawnConfig) -> Result, String> { +pub fn create_runtime_instance( + spawn: SpawnConfig, +) -> Result, String> { if !is_component_module(&spawn.exec_path) { return Err(format!( "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 51667425..a2f7d5bf 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,8 +1,4 @@ -use crate::plugin_bundles::ApprovalState; use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::runtime_select::create_runtime_instance; -use crate::plugin_runtime::spawn::SpawnConfig; -use crate::settings::AppConfig; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, StatusSummary, VcsEvent, @@ -20,41 +16,24 @@ pub struct PluginVcsProxy { } impl PluginVcsProxy { - /// Opens a repository through a plugin module process and returns a VCS trait object. + /// Opens a repository through a previously started plugin module runtime. /// /// # Parameters - /// - `plugin_id`: Owning plugin identifier. /// - `backend_id`: Backend id exposed by the plugin. - /// - `exec_path`: Path to the plugin wasm/module executable. - /// - `approval`: Capability approval state for the plugin version. + /// - `runtime`: Persistent plugin runtime instance. /// - `repo_path`: Repository working-tree path to open. + /// - `cfg`: Serialized config payload forwarded to the plugin. /// /// # Returns /// - `Ok(Arc)` when the plugin backend is opened successfully. /// - `Err(VcsError)` when startup or open RPC fails. pub fn open_with_process( - plugin_id: String, backend_id: BackendId, - exec_path: PathBuf, - approval: ApprovalState, + runtime: Arc, repo_path: &Path, + cfg: serde_json::Value, ) -> Result, VcsError> { let workdir = repo_path.to_path_buf(); - let cfg = AppConfig::load_or_default(); - let cfg = serde_json::to_value(cfg).map_err(|e| VcsError::Backend { - backend: backend_id.clone(), - msg: format!("serialize config: {e}"), - })?; - let spawn = SpawnConfig { - plugin_id, - exec_path, - approval, - allowed_workspace_root: Some(workdir.clone()), - }; - let runtime = create_runtime_instance(spawn).map_err(|e| VcsError::Backend { - backend: backend_id.clone(), - msg: e, - })?; let p = PluginVcsProxy { backend_id, workdir, @@ -142,12 +121,6 @@ impl PluginVcsProxy { } } -impl Drop for PluginVcsProxy { - fn drop(&mut self) { - self.runtime.stop(); - } -} - impl Vcs for PluginVcsProxy { /// Returns the backend identifier for this proxy. /// diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 848edf25..d18f7737 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -1,8 +1,8 @@ //! Discovery and opening logic for plugin-provided VCS backends. -use crate::plugin_bundles::{ApprovalState, PluginBundleStore, PluginManifest, VcsBackendProvide}; +use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; -use crate::plugin_runtime::vcs_proxy::PluginVcsProxy; +use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; use log::warn; use openvcs_core::{BackendId, Result as VcsResult, Vcs, VcsError}; @@ -38,10 +38,6 @@ pub struct PluginBackendDescriptor { pub plugin_id: String, /// Optional human-readable plugin name. pub plugin_name: Option, - /// Executable used to proxy backend operations. - pub exec_path: std::path::PathBuf, - /// Current capability approval state for the plugin version. - pub approval: ApprovalState, } /// Normalizes capability ids (trim/sort/dedup). @@ -51,16 +47,6 @@ pub struct PluginBackendDescriptor { /// /// # Returns /// - Normalized capability list. -fn normalize_capabilities(mut caps: Vec) -> Vec { - for cap in &mut caps { - *cap = cap.trim().to_string(); - } - caps.retain(|cap| !cap.is_empty()); - caps.sort(); - caps.dedup(); - caps -} - /// Reads a plugin manifest from a plugin directory. /// /// # Parameters @@ -119,15 +105,6 @@ pub fn list_plugin_vcs_backends() -> Result, String let Some(module) = p.module else { continue; }; - let installed = store - .get_current_installed(&p.plugin_id)? - .unwrap_or_else(|| crate::plugin_bundles::InstalledPluginVersion { - version: p.version.clone(), - bundle_sha256: String::new(), - installed_at_unix_ms: 0, - requested_capabilities: p.requested_capabilities.clone(), - approval: ApprovalState::Pending, - }); for (id, name) in module.vcs_backends { let backend_id = BackendId::from(id.as_str()); @@ -136,8 +113,6 @@ pub fn list_plugin_vcs_backends() -> Result, String backend_name: name, plugin_id: p.plugin_id.clone(), plugin_name: p.name.clone(), - exec_path: module.exec_path.clone(), - approval: installed.approval.clone(), }; let key = backend_id.as_ref().to_string(); map.insert(key, candidate); @@ -172,8 +147,6 @@ pub fn list_plugin_vcs_backends() -> Result, String ); continue; } - let requested_capabilities = normalize_capabilities(manifest.capabilities.clone()); - let approval_caps = requested_capabilities.clone(); let plugin_name = manifest.name.clone(); for provide in &module.vcs_backends { let (id, label) = match provide { @@ -190,11 +163,6 @@ pub fn list_plugin_vcs_backends() -> Result, String backend_name: label, plugin_id: plugin_id.to_string(), plugin_name: plugin_name.clone(), - exec_path: exec_path.clone(), - approval: ApprovalState::Approved { - capabilities: approval_caps.clone(), - approved_at_unix_ms: 0, - }, }; map.insert(key, candidate); } @@ -245,17 +213,25 @@ pub fn plugin_vcs_backend_descriptor( /// - `Ok(Arc)` with an opened backend proxy. /// - `Err(VcsError)` when descriptor resolution or backend startup fails. pub fn open_repo_via_plugin_vcs_backend( + runtime_manager: &PluginRuntimeManager, + cfg: &AppConfig, backend_id: BackendId, path: &Path, ) -> VcsResult> { let desc = plugin_vcs_backend_descriptor(&backend_id) .map_err(|_| VcsError::Unsupported(backend_id.clone()))?; - PluginVcsProxy::open_with_process( - desc.plugin_id, - backend_id, - desc.exec_path, - desc.approval, - path, - ) + let cfg_value = serde_json::to_value(cfg).map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: format!("serialize config: {e}"), + })?; + + let runtime = runtime_manager + .runtime_for_workspace_with_config(cfg, &desc.plugin_id, Some(path.to_path_buf())) + .map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: e, + })?; + + PluginVcsProxy::open_with_process(backend_id, runtime, path, cfg_value) } diff --git a/Backend/src/plugins.rs b/Backend/src/plugins.rs index 6b282a17..d43419c0 100644 --- a/Backend/src/plugins.rs +++ b/Backend/src/plugins.rs @@ -1,10 +1,15 @@ use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; use log::warn; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, fs, path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, RwLock, + }, }; const PLUGIN_THEMES_DIR_NAME: &str = "themes"; @@ -54,7 +59,7 @@ pub struct PluginThemeDir { pub path: PathBuf, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct RawPluginManifest { id: String, name: String, @@ -146,6 +151,145 @@ impl PluginOrigin { } } +#[derive(Clone)] +struct CachedPlugin { + resolved: PathBuf, + manifest: RawPluginManifest, + origin: PluginOrigin, +} + +#[derive(Default)] +struct CacheData { + list: Vec, + entries: HashMap, + loaded: bool, +} + +struct PluginCache { + data: RwLock, + dirty: AtomicBool, + watcher: Mutex>, +} + +impl PluginCache { + fn initialize() -> Arc { + let cache = Arc::new(Self { + data: RwLock::new(CacheData::default()), + dirty: AtomicBool::new(true), + watcher: Mutex::new(None), + }); + cache.ensure_fresh(); + cache.watch_directories(); + cache + } + + fn list(&self) -> Vec { + self.ensure_fresh(); + self.data.read().unwrap().list.clone() + } + + fn load_cached_plugin(&self, id: &str) -> Option { + self.ensure_fresh(); + self.data.read().unwrap().entries.get(id).cloned() + } + + fn mark_dirty(&self) { + self.dirty.store(true, Ordering::SeqCst); + } + + fn ensure_fresh(&self) { + let needs_reload = self.dirty.swap(false, Ordering::SeqCst) || { + let data = self.data.read().unwrap(); + !data.loaded + }; + if needs_reload { + self.reload(); + } + } + + fn reload(&self) { + let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); + let mut seen = HashSet::new(); + let mut summaries: Vec = Vec::new(); + let mut entries: HashMap = HashMap::new(); + + for (root, origin) in plugin_roots() { + match fs::read_dir(&root) { + Ok(iter) => { + for entry in iter.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if let Ok((resolved, manifest)) = read_manifest_from_directory(&path) { + let norm = manifest.id.trim().to_ascii_lowercase(); + if !seen.insert(norm.clone()) { + continue; + } + let is_built_in = built_in_ids.contains(&norm); + let effective_origin = if is_built_in { + PluginOrigin::BuiltIn + } else { + origin + }; + let summary = + manifest_to_summary(&resolved, manifest.clone(), effective_origin); + summaries.push(summary); + entries.insert( + norm, + CachedPlugin { + resolved: resolved.clone(), + manifest, + origin: effective_origin, + }, + ); + } + } + } + Err(err) => warn!("plugins: failed to list {}: {}", root.display(), err), + } + } + + summaries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + let mut data = self.data.write().unwrap(); + data.list = summaries; + data.entries = entries; + data.loaded = true; + } + + fn watch_directories(self: &Arc) { + let cache = Arc::clone(self); + let mut watcher = match RecommendedWatcher::new( + move |res: notify::Result| match res { + Ok(_) => cache.mark_dirty(), + Err(err) => warn!("plugins: watcher error: {}", err), + }, + Config::default(), + ) { + Ok(w) => w, + Err(err) => { + warn!("plugins: failed to start directory watcher: {}", err); + return; + } + }; + + for (root, _) in plugin_roots() { + if let Err(err) = watcher.watch(&root, RecursiveMode::Recursive) { + warn!("plugins: failed to watch {}: {}", root.display(), err); + } + } + + let mut guard = self.watcher.lock().unwrap(); + *guard = Some(watcher); + } +} + +static PLUGIN_CACHE: OnceLock> = OnceLock::new(); + +fn plugin_cache() -> &'static Arc { + PLUGIN_CACHE.get_or_init(|| PluginCache::initialize()) +} + /// Resolves plugin root directories (user + built-in). /// /// # Returns @@ -504,46 +648,9 @@ fn discover_theme_dirs_recursive(dir: &Path, depth: usize, out: &mut Vec Vec { - let mut out: Vec = Vec::new(); - let mut seen = HashSet::new(); - let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); - - let roots = plugin_roots(); - - for (root, origin) in roots { - match fs::read_dir(&root) { - Ok(entries) => { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - if let Ok((resolved, manifest)) = read_manifest_from_directory(&path) { - let norm = manifest.id.trim().to_ascii_lowercase(); - let is_built_in = built_in_ids.contains(&norm); - if !seen.insert(norm) { - continue; - } - let effective_origin = if is_built_in { - PluginOrigin::BuiltIn - } else { - origin - }; - out.push(manifest_to_summary(&resolved, manifest, effective_origin)); - } - } - } - Err(err) => warn!("plugins: failed to list {}: {}", root.display(), err), - } - } - - out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - out + plugin_cache().list() } /// Loads plugin metadata and optional entry script text by plugin id. @@ -559,65 +666,33 @@ pub fn load_plugin(id: &str) -> Result { if requested.is_empty() { return Err("plugin id is empty".to_string()); } - let requested_lower = requested.to_ascii_lowercase(); - let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); - - let roots = plugin_roots(); - - for (root, origin) in roots { - let entries = match fs::read_dir(&root) { - Ok(entries) => entries, + let normalized = requested.to_ascii_lowercase(); + let cached = plugin_cache() + .load_cached_plugin(&normalized) + .ok_or_else(|| format!("plugin `{}` not found", requested))?; + + let summary = manifest_to_summary(&cached.resolved, cached.manifest.clone(), cached.origin); + let entry_path = clean_opt(cached.manifest.entry.clone()); + let entry_code = entry_path.and_then(|entry| { + let target = cached.resolved.join(entry.trim()); + match fs::read_to_string(&target) { + Ok(text) => Some(text), Err(err) => { - warn!("plugins: failed to list {}: {}", root.display(), err); - continue; - } - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let (resolved, manifest) = match read_manifest_from_directory(&path) { - Ok(m) => m, - Err(_) => continue, - }; - let entry_path = clean_opt(manifest.entry.clone()); - if manifest.id.trim().to_ascii_lowercase() != requested_lower { - continue; + warn!( + "plugins: failed to read entry {} for {}: {}", + target.display(), + summary.id, + err + ); + None } - - let effective_origin = if built_in_ids.contains(&requested_lower) { - PluginOrigin::BuiltIn - } else { - origin - }; - let summary = manifest_to_summary(&resolved, manifest, effective_origin); - let entry_code = entry_path.and_then(|entry| { - let target = resolved.join(entry.trim()); - match fs::read_to_string(&target) { - Ok(text) => Some(text), - Err(err) => { - warn!( - "plugins: failed to read entry {} for {}: {}", - target.display(), - summary.id, - err - ); - None - } - } - }); - - return Ok(PluginPayload { - summary, - // Plugin code does not execute in-process; the UI runtime uses out-of-process components. - entry: entry_code, - }); } - } + }); - Err(format!("plugin `{}` not found", requested)) + Ok(PluginPayload { + summary, + entry: entry_code, + }) } /// Lists all discovered theme directories grouped by plugin id. diff --git a/Backend/src/state.rs b/Backend/src/state.rs index f0a4d859..f83b4e75 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -76,7 +76,7 @@ pub struct AppState { recents: RwLock>, /// Long-lived plugin process runtime manager. - plugin_runtime: PluginRuntimeManager, + plugin_runtime: Arc, } impl AppState { @@ -279,8 +279,8 @@ impl AppState { /// /// # Returns /// - Plugin runtime manager reference. - pub fn plugin_runtime(&self) -> &PluginRuntimeManager { - &self.plugin_runtime + pub fn plugin_runtime(&self) -> Arc { + Arc::clone(&self.plugin_runtime) } } diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index e86c2af5..14c5ee6c 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -100,8 +100,15 @@ pub async fn set_vcs_backend_cmd( let open_path = path.clone(); let backend_label = backend_id.as_ref().to_string(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { - plugin_vcs_backends::open_repo_via_plugin_vcs_backend(backend_id, Path::new(&open_path)) + plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, + backend_id, + Path::new(&open_path), + ) }) .await .map_err(|e| format!("set_vcs_backend_cmd task failed: {e}"))? @@ -151,8 +158,15 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S let backend_label = backend_id.as_ref().to_string(); let open_path = path.clone(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { - plugin_vcs_backends::open_repo_via_plugin_vcs_backend(backend_id, Path::new(&open_path)) + plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, + backend_id, + Path::new(&open_path), + ) }) .await .map_err(|e| format!("reopen_current_repo_cmd task failed: {e}"))? diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index f6f6aefb..ad066365 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -171,8 +171,12 @@ pub async fn add_repo_internal( let open_path = path.clone(); let backend_label = backend_id.as_ref().to_string(); let backend_id_for_task = backend_id.clone(); + let cfg = state.config(); + let runtime_manager = state.plugin_runtime(); let handle = async_runtime::spawn_blocking(move || { plugin_vcs_backends::open_repo_via_plugin_vcs_backend( + runtime_manager.as_ref(), + &cfg, backend_id_for_task, Path::new(&open_path), ) diff --git a/Cargo.lock b/Cargo.lock index f1e0523c..917aa057 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1526,6 +1526,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2353,6 +2362,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2576,6 +2605,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2881,6 +2930,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2955,6 +3016,25 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3294,6 +3374,7 @@ dependencies = [ "env_logger", "hex", "log", + "notify", "openvcs-core", "os_pipe", "parking_lot", @@ -5394,7 +5475,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "pin-project-lite", "signal-hook-registry", "socket2", @@ -6789,6 +6870,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6840,6 +6930,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6897,6 +7002,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6915,6 +7026,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6933,6 +7050,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6963,6 +7086,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6981,6 +7110,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6999,6 +7134,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7017,6 +7158,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" From 29ae880941e5dceef70b3466e466e1d5f1ae0377 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 14 Feb 2026 14:45:26 +0000 Subject: [PATCH 19/96] Add ScreenShots --- docs/images/CreateNewBranch.png | 3 +++ docs/images/CreateStash.png | 3 +++ docs/images/CyberpunkTheme.png | 3 +++ docs/images/LightModeTheme.png | 3 +++ docs/images/OpenVCS_Default.png | 3 +++ docs/images/OpenVCS_theme_showreel.png | 3 +++ docs/images/PluginList.png | 3 +++ docs/images/ShowHistory.png | 3 +++ docs/images/Theme1.png | 3 +++ docs/images/Theme2.png | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 docs/images/CreateNewBranch.png create mode 100644 docs/images/CreateStash.png create mode 100644 docs/images/CyberpunkTheme.png create mode 100644 docs/images/LightModeTheme.png create mode 100644 docs/images/OpenVCS_Default.png create mode 100644 docs/images/OpenVCS_theme_showreel.png create mode 100644 docs/images/PluginList.png create mode 100644 docs/images/ShowHistory.png create mode 100644 docs/images/Theme1.png create mode 100644 docs/images/Theme2.png diff --git a/docs/images/CreateNewBranch.png b/docs/images/CreateNewBranch.png new file mode 100644 index 00000000..546c9ece --- /dev/null +++ b/docs/images/CreateNewBranch.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f177aae4c7cd3cdc603463155cf3405b8d9b3bc677e734f15b9aa67222fdf17 +size 66909 diff --git a/docs/images/CreateStash.png b/docs/images/CreateStash.png new file mode 100644 index 00000000..40e92203 --- /dev/null +++ b/docs/images/CreateStash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9a42318ca4c535ce031bb42a53147f69709021db7942747c8d432b48e6041b7 +size 62656 diff --git a/docs/images/CyberpunkTheme.png b/docs/images/CyberpunkTheme.png new file mode 100644 index 00000000..42b7bd21 --- /dev/null +++ b/docs/images/CyberpunkTheme.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d093e7baaf63e36336a7b55381ea840c4951e3c9a6f320f186cac47ed5a2e29 +size 80914 diff --git a/docs/images/LightModeTheme.png b/docs/images/LightModeTheme.png new file mode 100644 index 00000000..7d1aa761 --- /dev/null +++ b/docs/images/LightModeTheme.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4515b4a9b1b0a935f00b76fcb20b8b2d156b77d384348fb5034854be81872148 +size 76454 diff --git a/docs/images/OpenVCS_Default.png b/docs/images/OpenVCS_Default.png new file mode 100644 index 00000000..38559666 --- /dev/null +++ b/docs/images/OpenVCS_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ac0153c03cca1c2c9971116ca6c711fedcb7b1e0eee59260686cff612e94a3e +size 78040 diff --git a/docs/images/OpenVCS_theme_showreel.png b/docs/images/OpenVCS_theme_showreel.png new file mode 100644 index 00000000..77765124 --- /dev/null +++ b/docs/images/OpenVCS_theme_showreel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c8490ed1da0add73d92474d7718cd90c777ed16d5da3f3ce0b6e123b64b98e7 +size 86845 diff --git a/docs/images/PluginList.png b/docs/images/PluginList.png new file mode 100644 index 00000000..6a1d2bff --- /dev/null +++ b/docs/images/PluginList.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30e5e94880152fdc5e63f1148c05926f9c94078121b0af73d62e6c4bba3c4789 +size 103766 diff --git a/docs/images/ShowHistory.png b/docs/images/ShowHistory.png new file mode 100644 index 00000000..c0fe9fa8 --- /dev/null +++ b/docs/images/ShowHistory.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b004bcf5bc859b26e42cd3a86f8939a2682d4f61c333294b87773a5407f7b172 +size 137034 diff --git a/docs/images/Theme1.png b/docs/images/Theme1.png new file mode 100644 index 00000000..d3b78488 --- /dev/null +++ b/docs/images/Theme1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bee959e88bc17ad45bac35a3c272fcba7c726d3bc07fa13c7fc6f4b5e8b2de82 +size 85733 diff --git a/docs/images/Theme2.png b/docs/images/Theme2.png new file mode 100644 index 00000000..ce5c7411 --- /dev/null +++ b/docs/images/Theme2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86ea7adecd0f8f8e006ae7f19071d64f55d4d8a951364db13be9cc052afc2580 +size 81499 From ebf5a15fcdcffe9d6e940cf9172ad2483858385f Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 10:34:48 +0000 Subject: [PATCH 20/96] Update AGENTS.md --- AGENTS.md | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d909769e..1a5a90fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Repository Guidelines ## Project structure & module organization + - `Backend/`: Rust + Tauri backend (`src/`), commands (`src/tauri_commands/`), plugin runtime (`src/plugin_runtime/`), and bundled plugin support (`src/plugin_runtime`, `scripts/`). - `Backend/built-in-plugins/`: local copies of bundled plugins (do not edit their code unless explicitly requested; update submodule pointers instead). - `Frontend/`: TypeScript + Vite UI code (`src/scripts/`, `src/styles/`, `src/modals/`), with Vitest tests colocated as `*.test.ts` files. @@ -9,37 +10,149 @@ - Supporting files at the repo root include the workspace `Cargo.toml`, `Justfile`, `README.md`, `ARCHITECTURE.md`, `SECURITY.md`, and installer scripts. ## Build, test, and development commands + +### Full builds - `just build` (or `just build client|plugins`): builds the backend, frontend, and plugin bundles from the workspace Justfile. +- `just tauri-build`: production Tauri build wrapper (AppImage/Flatpak). `git submodule update --init --recursive` is required before building bundled plugins. + +### Running tests + +**All tests:** - `just test`: runs workspace Rust tests plus frontend type-check + Vitest via the Justfile. + +**Frontend (Vitest):** +- `npm --prefix Frontend test`: run all tests +- `npm --prefix Frontend test run`: run tests once (non-watch mode) +- `npm --prefix Frontend test -- --run`: explicit non-watch mode +- `npm --prefix Frontend test -- src/scripts/lib/dom.test.ts`: run single test file +- `npm --prefix Frontend test -- --run -t "qs and qsa"`: run single test by name pattern +- `npm --prefix Frontend test -- --watch`: watch mode for development + +**Backend (Rust):** +- `cargo test --workspace`: run all Rust tests +- `cargo test`: run tests for current crate +- `cargo test --package openvcs_lib`: test specific crate +- `cargo test --lib`: run only library tests (not integration tests) +- `cargo test --lib -- branch`: run tests matching "branch" in name +- `cargo test --lib -- --test-threads=1`: run tests sequentially (for flaky tests) + +### Linting and formatting + - `just fix`: formatter/lint quick fixes (runs `cargo fmt`, `cargo clippy --fix`, frontend type-check, and bundle verification). +- `cargo fmt --all`: format Rust code +- `cargo clippy --all-targets -- -D warnings`: check Rust for issues +- `cargo clippy --fix --all-targets --allow-dirty`: auto-fix clippy issues +- `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit`: TypeScript type-check + +### Development servers + - `cargo tauri dev`: run the desktop app in dev mode (`Backend/` directory). -- `npm --prefix Frontend run dev`: run the frontend-only Vite dev server; use `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` for TS checks and `npm --prefix Frontend test` for Vitest when needed. -- `just tauri-build`: production Tauri build wrapper (AppImage/Flatpak). `git submodule update --init --recursive` is required before building bundled plugins. +- `npm --prefix Frontend run dev`: run the frontend-only Vite dev server. ## Plugin runtime & host expectations + - Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). - The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. The canonical host/plugin contract is defined in `Core/wit/openvcs-core.wit` (see `openvcs_core::app_api`). - When changing host APIs, capability strings, or runtime behavior, update `Core/wit/openvcs-core.wit`, the generated bindings, and the runtime logic in `Backend/src/plugin_runtime`. - JavaScript-based plugin UI contributions (e.g., `entry.js`) are deprecated: route new UI work through the host/app APIs rather than embedding JS so bundles remain Wasm-only. ## Coding style & conventions -- Rust: run `cargo fmt --all`, keep `cargo clippy --all-targets -- -D warnings` clean, prefer `snake_case` for modules/functions and `PascalCase` for structs/enums. -- TypeScript: 2-space indentation, ES modules, small feature-focused files under `Frontend/src/scripts/features/`. Tests should be alongside the code (`*.test.ts`). -- Keep plugin UI contributions (e.g., `Backend/built-in-plugins/Git/entry.js`) concise and prefer host APIs (`OpenVCS.invoke`, settings/actions) documented in `docs/`. + +### Rust + +**Formatting:** +- Run `cargo fmt --all` before committing +- Use default Rust formatting (4-space indentation, etc.) +- Keep lines under ~100 characters when reasonable + +**Naming:** +- `snake_case` for modules, functions, and variables +- `PascalCase` for structs, enums, and trait names +- Prefix private fields with underscore (e.g., `self._field`) +- Use descriptive names; avoid single letters except in tight loops + +**Imports:** +- Group imports: std library first, then external crates, then local modules +- Use `use` statements for frequently used items +- Prefer absolute paths from crate root (`crate::module::Item`) + +**Error handling:** +- Use `Result` for fallible operations; avoid `panic!` in library code +- Use `?` operator for propagating errors +- Include context in error messages: `some_func().context("failed to load config")?` +- Log warnings for recoverable errors with `log::warn` +- Use `anyhow` for application code (commands, main) when context is needed + +**Types & patterns:** +- Prefer `Arc` for shared ownership across threads +- Use `tokio` async runtime for I/O-bound async operations +- Use `parking_lot` mutexes (faster than std) +- Derive `Clone`, `Debug`, `Serialize`, `Deserialize` as needed + +### TypeScript + +**Formatting:** +- 2-space indentation (no tabs) +- Use ES modules (`import`/`export`) +- Keep lines under ~100 characters when reasonable +- Use semicolons at statement ends + +**Naming:** +- `camelCase` for variables, functions, and methods +- `PascalCase` for classes, types, and interfaces +- Prefix private class members with underscore (e.g., `this._field`) +- Use descriptive names; avoid abbreviations except common ones (e.g., `btn`, `cfg`) + +**Imports:** +- Use absolute imports from project root when possible +- Group imports: external libs, then relative `./` paths, then `../` paths +- Prefer named imports: `import { foo, bar } from './module'` +- Use type-only imports (`import type { Foo }`) when only using types + +**Types:** +- Use explicit types for function parameters and return values +- Prefer interfaces for object shapes, types for unions/intersections +- Use `null` for "intentionally empty", `undefined` for "not yet set" +- Avoid `any`; use `unknown` when type is truly unknown + +**Error handling:** +- Use try/catch for async operations; always handle or re-throw +- Use `Promise.catch(() => {})` for fire-and-forget async calls +- Show user-facing errors via `notify()` from `lib/notify` +- Log internal errors with console for debugging + +**UI patterns:** +- Use `qs('#id')` for single element queries from `lib/dom` +- Use `qsa('.class')` for multiple elements +- Use event delegation for list items +- Keep feature modules focused and small (<200 lines when possible) +- Colocate tests as `*.test.ts` next to the source file + +### Testing + +- Run `just test` before PRs +- Frontend-only work: run `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` and `npm --prefix Frontend test` +- Use Vitest's `describe`/`it`/`expect` for frontend tests +- Use descriptive test names: `it('finds elements by selector')` +- Use `beforeEach` to reset DOM state in DOM tests ## ExecPlans + - For multi-component features or refactors, create/update an ExecPlan (`Client/PLANS.md`). Outline design, component impacts, and how the plugin runtime is exercised. ## Testing guidelines + - Run `just test` before PRs; frontend-only work should at least cover `npm --prefix Frontend exec tsc -- -p tsconfig.json --noEmit` and `npm --prefix Frontend test`. - Use `cargo tauri dev` to verify runtime plugin interactions (especially when touching `Backend/src/plugin_runtime/`), and make sure `docs/plugin architecture.md` stays aligned with behavior. ## Commit & PR guidelines + - Use short, imperative commit subjects (optionally scoped, e.g., `backend: refresh plugin runtime config`). Keep changelist focused; avoid mixing UI and backend refactors unless necessary. - PRs should target the `Dev` branch, include a summary, issue links, commands/tests run, and highlight architecture implications (host API changes, plugin capability updates, security decisions). - Do not modify plugin code inside submodules unless explicitly asked; treat submodule updates as pointer bumps after upstream changes. - Keep this AGENTS (and other module-level copies you rely on) current whenever workflows, tooling, or responsibilities change so future contributors can find accurate guidance. ## Security & configuration notes + - Review `SECURITY.md` before making plugin, plugin-install, or network-related changes. - Keep secrets out of the repo; use `.env.tauri.local` for local overrides and do not check them in. If new config flags are introduced, document them in `docs/` and update relevant settings screens/logs. From edd7d882c1e624d698900acb59f31ecbb42cec36 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 14:12:01 +0000 Subject: [PATCH 21/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 146048fd..c43b9a71 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 146048fd8de38b81cde759b989e98578a890cea6 +Subproject commit c43b9a71b50243971035529a70ce533c18b099a2 From 8043085a444bda7199d8444b00f47f00339b8558 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 14:12:10 +0000 Subject: [PATCH 22/96] Update plugin architecture.md --- docs/plugin architecture.md | 261 ++++++++++++++++++++++-------------- 1 file changed, 159 insertions(+), 102 deletions(-) diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 51e64602..35fb7d9b 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -1,147 +1,204 @@ -# OpenVCS Plugin Bundles (.ovcsp) — Architecture +# OpenVCS Plugin Architecture -This document describes OpenVCS’s **secure, out-of-process** plugin system for distributing plugins as **`.ovcsp`** bundles. +This document describes OpenVCS's plugin system. -## Goals (non-negotiables) +## Architecture -- The OpenVCS-Client process **never loads third-party dynamic libraries** and **never runs third-party plugin code in-process**. -- Every plugin component executes **out-of-process** and communicates over the **component-model WIT ABI**. -- Plugins are **installed (unpacked) before execution**; nothing executes directly from inside a tar.xz archive. -- The bundle manifest uses the existing `openvcs.plugin.json` format and extends it minimally. +``` +Client <---> Core <---> Plugin +``` + +No translation layers. Just **WIT and Rust**. + +- **Core** is the glue - provides WIT bindings, host implementation, plugin runtime +- **Plugins** are pure WASM components with no boilerplate +- **Client** loads and communicates with plugins via WIT -## Bundle format +## Plugin Types -An `.ovcsp` is a tar.xz archive containing exactly one top-level plugin folder named by plugin id: +| Type | Code | Required Functions | +|------|------|-------------------| +| **Theme** | No | None | +| **Code** | Yes | `init`, `deinit` | +| **Code + VCS** | Yes | `init`, `deinit` + all VCS functions | + +VCS is optional - plugins can implement it if they provide a VCS backend. + +## Plugin Structure + +### Theme Plugin ``` / openvcs.plugin.json - bin/ - .wasm - assets/... (optional) - themes/... (optional; existing `theme.json` packs) + themes/ + / + theme.json + theme.css + ... ``` -Notes: +No Rust code. Theme plugins ship UI assets only. -- Bundles are **WASM-only**; OpenVCS will reject native binaries. -- Bundle entry paths must be relative and use `/` separators (the installer normalizes and validates). +### Code Plugin -## Manifest (`openvcs.plugin.json`) +``` +/ + openvcs.plugin.json + src/ + lib.rs # Rust library with #[openvcs_plugin] marked functions + Cargo.toml +``` -Existing fields like `id`, `name`, `version`, etc. remain unchanged. +Plugin author writes: -This system uses the `module` section (used by `OpenVCS-Plugin-Git`) and adds: +```rust +// src/lib.rs +use openvcs_core::plugin_api::*; -- `capabilities`: string array of requested capabilities. -- `functions`: optional function component descriptor. +#[openvcs_plugin] +pub fn init() -> Result<(), PluginError> { Ok(()) } -Example: +#[openvcs_plugin] +pub fn deinit() -> Result<(), PluginError> { Ok(()) } +``` -```json -{ - "id": "openvcs.git", - "name": "Git", - "version": "0.1.0", - "capabilities": ["workspace.read", "vcs.read", "vcs.write"], - "module": { - "exec": "openvcs-git-plugin.wasm", - "vcs_backends": [ - { "id": "git", "name": "Git" } - ] - }, - "functions": { - "exec": "openvcs-hello-functions.wasm" - } -} +### VCS Plugin + +Same as Code Plugin, but implements VCS functions: + +```rust +// src/lib.rs +use openvcs_core::vcs_api::*; + +#[openvcs_plugin] +pub fn init() -> Result<(), PluginError> { Ok(()) } + +#[openvcs_plugin] +pub fn deinit() -> Result<(), PluginError> { Ok(()) } + +#[openvcs_plugin] +pub fn get_caps() -> Result { ... } + +#[openvcs_plugin] +pub fn list_branches() -> Result, PluginError> { ... } +// ... all VCS functions required ``` -### Component types +## WIT Interfaces -- **Module component** (`module`): a plugin-executed WASI module. It is spawned with: - - module: `bin/` (must end in `.wasm`) - - arguments: `--backend ` for each id in `module.vcs_backends` - - protocol: component-model host/plugin interfaces from `Core/wit/openvcs-core.wit` -- **Function component** (`functions`): a WASI module exposing callable functions/hooks/commands over the same component-model transport. +### plugin-api (Required) -## Installation locations and layout +Required for all code plugins: -OpenVCS installs bundles into the user config directory (via `directories::ProjectDirs`): +```wit +interface plugin-api { + init: func() -> result<_, plugin-error> + deinit: func() -> result<_, plugin-error> +} +``` -- Config root: - - Linux: `$XDG_CONFIG_HOME/OpenVCS` (or `~/.config/OpenVCS`) - - Windows: `%APPDATA%\\OpenVCS` - - macOS: `~/Library/Application Support/OpenVCS` +### vcs-api (Optional) -Installed bundle layout: +For VCS backend plugins: +```wit +interface vcs-api { + get-caps: func() -> result + open: func(path: string, config: list) -> result<_, plugin-error> + list-branches: func() -> result list + commit: func(message: string, name: string, email: string, paths: list) -> result + // ... all VCS functions +} ``` -plugins/ - / - index.json (metadata, including SHA-256 + approvals) - current.json (pointer: {"version": "..."}; used instead of symlinks) - / - openvcs.plugin.json - bin/... - ... + +### Custom WIT (Optional) + +Plugins can define their own WIT interfaces for **plugin-to-plugin** communication. + +Example: A GitHub plugin exports a `github-api` interface that other plugins can call. + +## Plugin Dependencies + +Plugins can declare dependencies on other plugins: + +```json +{ + "id": "my-plugin", + "dependencies": { + "openvcs.github": { + "required": false + }, + "openvcs.ai": { + "required": true + } + } +} ``` -The runtime discovers plugins **only** from this installed directory and resolves `/current.json` to a concrete version folder. +- Required dependencies: plugin fails to load if missing +- Optional dependencies: plugin loads without them (can check at runtime) -## Security model +## The `#[openvcs_plugin]` Macro -### Secure ZIP extraction (install-time) +Every ABI function must be marked with `#[openvcs_plugin]`: -The installer enforces: +```rust +use openvcs_core::vcs_api::*; -- **ZipSlip/path traversal prevention** - - reject absolute paths and Windows drive prefixes - - normalize separators and reject any `..` components - - canonicalize and ensure every extracted path stays within the install directory -- **Symlink rejection** - - reject any archive entries that are symlinks (Unix mode `0120000`) -- **Resource limits** - - cap total uncompressed size per bundle - - cap per-file size - - cap file count - - reject suspicious compression ratios (zip-bomb heuristics) -- **Required file validation** - - `openvcs.plugin.json` must exist at `/openvcs.plugin.json` - - declared component entrypoints must exist under `bin/` after extraction and be valid `.wasm` modules -- **Integrity** - - compute and store SHA-256 of the `.ovcsp` bundle in `/index.json` +#[openvcs_plugin] +pub fn init() -> Result<(), PluginError> { ... } -### Trust + capabilities +#[openvcs_plugin] +pub fn get_caps() -> Result { ... } +``` -Plugins are **untrusted by default**. +The macro: +1. Marks functions as WIT exports +2. Generates the WIT Guest impl +3. Handles error conversion -- Capabilities are declared in the manifest (`capabilities`). -- Capabilities must be **approved by the user** at install-time (or on first run). -- The host enforces capabilities for **plugin → host** JSON-RPC calls; denied calls return structured errors. +## Building Plugins -Capability strings: +SDK builds plugins with: -- `workspace.read`, `workspace.write` -- `vcs.read`, `vcs.write` -- `network.http` -- `credentials.request` -- `ui.commands`, `ui.notifications` +```bash +cargo build --lib --target wasm32-wasip1 +``` -### Process isolation (best-effort) +No shim generation. No code generation. Just compile the library to WASM. -Each plugin component is spawned with: +## Bundle Format (.ovcsp) -- sanitized environment (allowlist) -- controlled working directory -- restricted `PATH` and no implicit shell execution +An `.ovcsp` is a tar.xz archive: -Runtime hardening: +``` +/ + openvcs.plugin.json + bin/ + .wasm + assets/... (optional) + themes/... (optional, theme plugins only) +``` + +## Manifest (`openvcs.plugin.json`) -- per-request timeouts and cancellation best-effort -- stdout/stderr captured into per-plugin logs (rotation + size limits) -- crash restart with exponential backoff; auto-disable after repeated crashes +```json +{ + "id": "openvcs.git", + "name": "Git", + "version": "0.1.0", + "author": "OpenVCS Team", + "description": "Git VCS backend", + "default_enabled": true, + "dependencies": {} +} +``` -OS-level sandboxing is optional and best-effort: +## Security -- Linux: supports wrappers (e.g. `bwrap`) if configured; otherwise runs unprivileged. -- Windows/macOS: no large dependencies; relies on install validation + capability gating + process isolation. +- Plugins run **out-of-process** in WebAssembly +- Client never loads third-party dynamic libraries +- No native code execution from plugins +- Capabilities declared in manifest, approved by user +- Process isolation + resource limits From 48e39382126e27608481c31c128af96bd1215ec4 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 14:16:45 +0000 Subject: [PATCH 23/96] Update plugin architecture.md --- docs/plugin architecture.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 35fb7d9b..7d822dc7 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -16,11 +16,11 @@ No translation layers. Just **WIT and Rust**. ## Plugin Types -| Type | Code | Required Functions | -|------|------|-------------------| -| **Theme** | No | None | -| **Code** | Yes | `init`, `deinit` | -| **Code + VCS** | Yes | `init`, `deinit` + all VCS functions | +| Type | Code | Required Functions | +| -------------- | ---- | ------------------------------------ | +| **Theme** | No | None | +| **Code** | Yes | `init`, `deinit` | +| **Code + VCS** | Yes | `init`, `deinit` + all VCS functions | VCS is optional - plugins can implement it if they provide a VCS backend. @@ -154,6 +154,7 @@ pub fn get_caps() -> Result { ... } ``` The macro: + 1. Marks functions as WIT exports 2. Generates the WIT Guest impl 3. Handles error conversion From 15490a325e3506e853d6321c2471cf58b0236cf0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 15:01:28 +0000 Subject: [PATCH 24/96] Update plugin architecture.md --- docs/plugin architecture.md | 91 ++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 7d822dc7..c1572ded 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -46,7 +46,7 @@ No Rust code. Theme plugins ship UI assets only. / openvcs.plugin.json src/ - lib.rs # Rust library with #[openvcs_plugin] marked functions + lib.rs # Rust library Cargo.toml ``` @@ -56,11 +56,20 @@ Plugin author writes: // src/lib.rs use openvcs_core::plugin_api::*; -#[openvcs_plugin] -pub fn init() -> Result<(), PluginError> { Ok(()) } +// Internal helpers - NOT ABI +fn helper() -> ... { ... } +// Plugin ABI - functions in mod plugin are exported #[openvcs_plugin] -pub fn deinit() -> Result<(), PluginError> { Ok(()) } +mod plugin { + use super::*; + + pub fn init() -> Result<(), PluginError> { Ok(()) } + pub fn deinit() -> Result<(), PluginError> { Ok(()) } +} + +// Generate WIT Guest impl +openvcs_core::export_plugin!(plugin); ``` ### VCS Plugin @@ -71,23 +80,37 @@ Same as Code Plugin, but implements VCS functions: // src/lib.rs use openvcs_core::vcs_api::*; -#[openvcs_plugin] -pub fn init() -> Result<(), PluginError> { Ok(()) } +// Internal helpers - NOT ABI +fn helper() -> ... { ... } +// Plugin ABI - functions in mod plugin are exported #[openvcs_plugin] -pub fn deinit() -> Result<(), PluginError> { Ok(()) } - -#[openvcs_plugin] -pub fn get_caps() -> Result { ... } +mod plugin { + use super::*; + + pub fn init() -> Result<(), PluginError> { Ok(()) } + pub fn deinit() -> Result<(), PluginError> { Ok(()) } + pub fn get_caps() -> Result { ... } + pub fn list_branches() -> Result, PluginError> { ... } + // ... all VCS functions required +} -#[openvcs_plugin] -pub fn list_branches() -> Result, PluginError> { ... } -// ... all VCS functions required +// Generate WIT Guest impl +openvcs_core::export_plugin!(plugin); ``` +## Why This Structure? + +The `mod plugin` approach provides clear separation: + +- **Outside `mod plugin`** - Internal helpers, not exported to WIT +- **Inside `mod plugin`** - ABI functions, exported to WIT + +This is more explicit than marking every function, while keeping the plugin code organized. + ## WIT Interfaces -### plugin-api (Required) +### plugin.wit (Required) Required for all code plugins: @@ -96,9 +119,14 @@ interface plugin-api { init: func() -> result<_, plugin-error> deinit: func() -> result<_, plugin-error> } + +world plugin { + import host-api; + export plugin-api; +} ``` -### vcs-api (Optional) +### vcs.wit (Optional) For VCS backend plugins: @@ -110,6 +138,11 @@ interface vcs-api { commit: func(message: string, name: string, email: string, paths: list) -> result // ... all VCS functions } + +world vcs { + import host-api; + export vcs-api; +} ``` ### Custom WIT (Optional) @@ -139,25 +172,29 @@ Plugins can declare dependencies on other plugins: - Required dependencies: plugin fails to load if missing - Optional dependencies: plugin loads without them (can check at runtime) -## The `#[openvcs_plugin]` Macro +## The Macros -Every ABI function must be marked with `#[openvcs_plugin]`: +### `#[openvcs_plugin]` -```rust -use openvcs_core::vcs_api::*; - -#[openvcs_plugin] -pub fn init() -> Result<(), PluginError> { ... } +Marks a module as containing plugin ABI functions: +```rust #[openvcs_plugin] -pub fn get_caps() -> Result { ... } +mod plugin { + pub fn init() -> ... { } + pub fn deinit() -> ... { } +} ``` -The macro: +### `export_plugin!` + +Generates the WIT Guest impl: + +```rust +openvcs_core::export_plugin!(plugin); +``` -1. Marks functions as WIT exports -2. Generates the WIT Guest impl -3. Handles error conversion +This must be called after the `#[openvcs_plugin]` mod is defined. ## Building Plugins From 4d56250e9d6265c8f0ed5ca7645f876721e815d6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 15:51:39 +0000 Subject: [PATCH 25/96] Update Cargo.toml --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 24fbd624..1c7a1663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "Backend", - "Backend/built-in-plugins/Git", ] resolver = "2" From c1a8e7462781267beb958a6f8d715d6104ce9fb5 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Feb 2026 16:08:59 +0000 Subject: [PATCH 26/96] Update Cargo.lock --- Cargo.lock | 117 ++++------------------------------------------------- 1 file changed, 7 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 917aa057..ccde5199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1871,21 +1871,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libgit2-sys", - "log", - "openssl-probe 0.1.6", - "openssl-sys", - "url", -] - [[package]] name = "glib" version = "0.18.5" @@ -2691,20 +2676,6 @@ version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.7.4" @@ -2732,52 +2703,6 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "libssh2-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" -dependencies = [ - "cc", - "libc", - "libz-sys", - "openssl-sys", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linkme" -version = "0.3.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" -dependencies = [ - "linkme-impl", -] - -[[package]] -name = "linkme-impl" -version = "0.3.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3341,30 +3266,12 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "openvcs" version = "0.1.1" @@ -3404,6 +3311,8 @@ name = "openvcs-core" version = "0.1.8" dependencies = [ "log", + "openvcs-core-derive", + "proc-macro2", "serde", "serde_json", "thiserror 2.0.18", @@ -3411,17 +3320,11 @@ dependencies = [ ] [[package]] -name = "openvcs-plugin-git" -version = "0.2.0" +name = "openvcs-core-derive" +version = "0.1.0" dependencies = [ - "git2", - "linkme", - "log", - "openvcs-core", - "serde", - "serde_json", - "thiserror 2.0.18", - "time", + "proc-macro2", + "quote", ] [[package]] @@ -4314,7 +4217,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", "security-framework", @@ -5872,12 +5775,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" From 16112d004f6051a3b346dc5501868033c82b02db Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 00:07:00 +0000 Subject: [PATCH 27/96] Update AGENTS.md --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1a5a90fd..bc89d3ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,26 @@ - Use `parking_lot` mutexes (faster than std) - Derive `Clone`, `Debug`, `Serialize`, `Deserialize` as needed +### Documentation + +- **ALL code must be documented**, not just public APIs. This includes: + - Rust: Use doc comments (`///` for items, `//!` for modules) for all functions, structs, enums, traits, and fields. + - TypeScript: Use JSDoc comments (`/** ... */`) for all functions, classes, interfaces, and types. +- Include usage examples for complex functions. +- Keep README files in sync with code changes. +- Document configuration options and environment variables. +- All new files must include the following copyright header: + +```rust +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +``` + +```typescript +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +``` + ### TypeScript **Formatting:** From a7bfbb57a757196f880e309870c64fed2cab615f Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 00:46:21 +0000 Subject: [PATCH 28/96] Update documentation --- Backend/build.rs | 2 + Backend/src/lib.rs | 2 + Backend/src/logging.rs | 2 + Backend/src/main.rs | 3 ++ Backend/src/output_log.rs | 2 + Backend/src/plugin_bundles.rs | 2 + Backend/src/plugin_paths.rs | 2 + .../src/plugin_runtime/component_instance.rs | 2 + Backend/src/plugin_runtime/events.rs | 2 + Backend/src/plugin_runtime/host_api.rs | 2 + Backend/src/plugin_runtime/instance.rs | 2 + Backend/src/plugin_runtime/manager.rs | 2 + Backend/src/plugin_runtime/mod.rs | 2 + Backend/src/plugin_runtime/runtime_select.rs | 2 + Backend/src/plugin_runtime/spawn.rs | 2 + Backend/src/plugin_runtime/vcs_proxy.rs | 2 + Backend/src/plugin_vcs_backends.rs | 2 + Backend/src/plugins.rs | 2 + Backend/src/repo.rs | 2 + Backend/src/repo_settings.rs | 2 + Backend/src/settings.rs | 2 + Backend/src/state.rs | 2 + Backend/src/tauri_commands/backends.rs | 2 + Backend/src/tauri_commands/branches.rs | 2 + Backend/src/tauri_commands/commit.rs | 2 + Backend/src/tauri_commands/conflicts.rs | 2 + Backend/src/tauri_commands/general.rs | 2 + Backend/src/tauri_commands/mod.rs | 2 + Backend/src/tauri_commands/output_log.rs | 2 + Backend/src/tauri_commands/plugins.rs | 2 + Backend/src/tauri_commands/remotes.rs | 2 + Backend/src/tauri_commands/repo_files.rs | 2 + Backend/src/tauri_commands/settings.rs | 2 + Backend/src/tauri_commands/shared.rs | 2 + Backend/src/tauri_commands/ssh.rs | 2 + Backend/src/tauri_commands/stash.rs | 2 + Backend/src/tauri_commands/status.rs | 2 + Backend/src/tauri_commands/themes.rs | 2 + Backend/src/tauri_commands/updater.rs | 2 + Backend/src/themes.rs | 2 + Backend/src/utilities/inner.rs | 2 + Backend/src/utilities/mod.rs | 2 + Backend/src/utilities/utilities.rs | 2 + Backend/src/validate.rs | 2 + Backend/src/workarounds.rs | 2 + Frontend/src/scripts/features/about.ts | 2 + Frontend/src/scripts/features/branches.ts | 2 + Frontend/src/scripts/features/cherryPick.ts | 2 + Frontend/src/scripts/features/commandSheet.ts | 2 + Frontend/src/scripts/features/conflicts.ts | 2 + .../scripts/features/deleteBranchConfirm.ts | 2 + Frontend/src/scripts/features/diff.ts | 2 + Frontend/src/scripts/features/newBranch.ts | 2 + Frontend/src/scripts/features/outputLog.ts | 2 + Frontend/src/scripts/features/renameBranch.ts | 2 + Frontend/src/scripts/features/repo/commit.ts | 2 + Frontend/src/scripts/features/repo/context.ts | 2 + .../src/scripts/features/repo/diffView.ts | 2 + Frontend/src/scripts/features/repo/filter.ts | 2 + .../src/scripts/features/repo/history.test.ts | 2 + Frontend/src/scripts/features/repo/history.ts | 2 + Frontend/src/scripts/features/repo/hotkeys.ts | 2 + Frontend/src/scripts/features/repo/hydrate.ts | 2 + Frontend/src/scripts/features/repo/index.ts | 2 + .../src/scripts/features/repo/interactions.ts | 2 + Frontend/src/scripts/features/repo/list.ts | 2 + .../scripts/features/repo/selectionState.ts | 2 + Frontend/src/scripts/features/repo/stash.ts | 2 + .../src/scripts/features/repoSelection.ts | 2 + Frontend/src/scripts/features/repoSettings.ts | 2 + .../src/scripts/features/repoSwitchDrawer.ts | 2 + Frontend/src/scripts/features/setUpstream.ts | 2 + Frontend/src/scripts/features/settings.ts | 2 + Frontend/src/scripts/features/sshAuth.ts | 2 + Frontend/src/scripts/features/sshHostkey.ts | 2 + Frontend/src/scripts/features/sshKeys.ts | 2 + Frontend/src/scripts/features/stashConfirm.ts | 2 + Frontend/src/scripts/features/update.ts | 2 + Frontend/src/scripts/lib/dom.test.ts | 2 + Frontend/src/scripts/lib/dom.ts | 45 +++++++++++++++++++ Frontend/src/scripts/lib/menu.ts | 2 + Frontend/src/scripts/lib/notify.ts | 6 +++ Frontend/src/scripts/lib/scrollbars.ts | 14 ++++++ Frontend/src/scripts/lib/tauri.ts | 8 ++++ Frontend/src/scripts/main.ts | 2 + Frontend/src/scripts/plugins.ts | 2 + Frontend/src/scripts/state/state.ts | 16 +++++++ Frontend/src/scripts/themes.ts | 2 + Frontend/src/scripts/types.d.ts | 2 + Frontend/src/scripts/ui/layout.ts | 2 + Frontend/src/scripts/ui/menubar.ts | 2 + Frontend/src/scripts/ui/modals.ts | 2 + Frontend/src/setupTests.ts | 2 + 93 files changed, 266 insertions(+) diff --git a/Backend/build.rs b/Backend/build.rs index 43eb7180..6765e604 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::{env, fs, path::PathBuf, process::Command}; fn is_flatpak_build() -> bool { diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 4b001fbc..5d0a5f31 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! OpenVCS backend application crate. //! //! This crate wires together Tauri command handlers, runtime state, diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index a9266ac5..a44527c1 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::settings::{AppConfig, LogLevel}; use std::fs::{self, OpenOptions}; use std::io::{Seek, SeekFrom, Write}; diff --git a/Backend/src/main.rs b/Backend/src/main.rs index edc984fd..6bfb9186 100644 --- a/Backend/src/main.rs +++ b/Backend/src/main.rs @@ -1,6 +1,9 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + /// Binary entrypoint that launches the backend runtime. /// /// # Returns diff --git a/Backend/src/output_log.rs b/Backend/src/output_log.rs index 8b5dda1a..2b833f49 100644 --- a/Backend/src/output_log.rs +++ b/Backend/src/output_log.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Shared output log types used by backend command execution and UI display. use serde::{Deserialize, Serialize}; diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 412541d9..a2954d27 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Plugin bundle installation, indexing, and component discovery. use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; diff --git a/Backend/src/plugin_paths.rs b/Backend/src/plugin_paths.rs index eed76834..a69642ac 100644 --- a/Backend/src/plugin_paths.rs +++ b/Backend/src/plugin_paths.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Path resolution helpers for installed and built-in plugins. use directories::ProjectDirs; diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index cb12f472..1987a7e3 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_runtime::host_api::{ host_emit_event, host_process_exec_git, host_runtime_info, host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index f0fc2b75..0b8a0e27 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use openvcs_core::plugin_protocol::PluginMessage; use openvcs_core::plugin_protocol::RpcRequest; use serde_json::Value; diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 8b2f7e46..74adfa55 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_runtime::spawn::SpawnConfig; use openvcs_core::app_api::{ComponentError, ProcessExecOutput}; use serde_json::Value; diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index f4c8652d..01c329e8 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use openvcs_core::models::VcsEvent; use serde_json::Value; use std::sync::Arc; diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 24809968..4912c357 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_bundles::{InstalledPluginComponents, PluginBundleStore}; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_runtime_instance; diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 3a010a1b..ea560c8b 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later pub mod component_instance; pub mod events; pub mod host_api; diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 6ebc44f1..1c12fe52 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs index c67b39ae..f1997117 100644 --- a/Backend/src/plugin_runtime/spawn.rs +++ b/Backend/src/plugin_runtime/spawn.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_bundles::ApprovalState; use std::path::PathBuf; diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index a2f7d5bf..56c95195 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_runtime::instance::PluginRuntimeInstance; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index d18f7737..fa4f8d5c 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Discovery and opening logic for plugin-provided VCS backends. use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; diff --git a/Backend/src/plugins.rs b/Backend/src/plugins.rs index d43419c0..3bc17f7c 100644 --- a/Backend/src/plugins.rs +++ b/Backend/src/plugins.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; use log::warn; use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; diff --git a/Backend/src/repo.rs b/Backend/src/repo.rs index 4a18c0c2..2eea4f21 100644 --- a/Backend/src/repo.rs +++ b/Backend/src/repo.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Thin wrapper around an opened backend repository handle. use openvcs_core::{BackendId, Vcs}; diff --git a/Backend/src/repo_settings.rs b/Backend/src/repo_settings.rs index e2c2143b..c04012e4 100644 --- a/Backend/src/repo_settings.rs +++ b/Backend/src/repo_settings.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Repository-local settings payloads exchanged with the frontend. use serde::{Deserialize, Serialize}; diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index afb8db8a..ecc101e1 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Global application configuration types and persistence helpers. use directories::ProjectDirs; diff --git a/Backend/src/state.rs b/Backend/src/state.rs index f83b4e75..9740f7d5 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Application-level mutable state and persistence helpers. use std::{fs, io}; diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index 14c5ee6c..d1f247f0 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 9894d89e..931b202b 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::collections::HashSet; use log::{debug, error, info, warn}; diff --git a/Backend/src/tauri_commands/commit.rs b/Backend/src/tauri_commands/commit.rs index a0136f9e..2fdc73c6 100644 --- a/Backend/src/tauri_commands/commit.rs +++ b/Backend/src/tauri_commands/commit.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{error, info}; diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index 1939b632..f0a14932 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use std::process::Command; diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index ad066365..1cae78b2 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/Backend/src/tauri_commands/mod.rs b/Backend/src/tauri_commands/mod.rs index 7af3588a..47f2f3a9 100644 --- a/Backend/src/tauri_commands/mod.rs +++ b/Backend/src/tauri_commands/mod.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later //! Aggregates the backend's Tauri command modules. //! //! Each submodule defines command handlers grouped by feature area, and this diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index 7df5b585..5e408441 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder, Window}; use crate::output_log::OutputLogEntry; diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 482cb832..b414bf4f 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; use crate::plugins; use crate::state::AppState; diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 5fc03491..4ae716a8 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use log::{error, info, warn}; use tauri::{Emitter, Manager, Runtime, State, Window}; diff --git a/Backend/src/tauri_commands/repo_files.rs b/Backend/src/tauri_commands/repo_files.rs index 8d06b9bd..4aa4cbd2 100644 --- a/Backend/src/tauri_commands/repo_files.rs +++ b/Backend/src/tauri_commands/repo_files.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::collections::HashSet; use std::path::{Component, PathBuf}; diff --git a/Backend/src/tauri_commands/settings.rs b/Backend/src/tauri_commands/settings.rs index 3ccdf7d0..cf4e401d 100644 --- a/Backend/src/tauri_commands/settings.rs +++ b/Backend/src/tauri_commands/settings.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use log::warn; use std::collections::{HashMap, HashSet}; use tauri::State; diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index 001a0505..667f16d1 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::sync::Arc; use openvcs_core::models::VcsEvent; diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 3ca8019b..f6b828ee 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::{fs, path::PathBuf, process::Command}; use serde::Serialize; diff --git a/Backend/src/tauri_commands/stash.rs b/Backend/src/tauri_commands/stash.rs index fd3bd217..73374a22 100644 --- a/Backend/src/tauri_commands/stash.rs +++ b/Backend/src/tauri_commands/stash.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{error, info}; diff --git a/Backend/src/tauri_commands/status.rs b/Backend/src/tauri_commands/status.rs index b83567d3..87af2897 100644 --- a/Backend/src/tauri_commands/status.rs +++ b/Backend/src/tauri_commands/status.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::PathBuf; use log::{debug, error, info}; diff --git a/Backend/src/tauri_commands/themes.rs b/Backend/src/tauri_commands/themes.rs index 11237be0..2d85fe5e 100644 --- a/Backend/src/tauri_commands/themes.rs +++ b/Backend/src/tauri_commands/themes.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use crate::{plugins, settings, state::AppState, themes}; use std::collections::HashSet; use tauri::State; diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 0e1fa7e0..0808df0f 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; diff --git a/Backend/src/themes.rs b/Backend/src/themes.rs index c11cb563..deb0ddf8 100644 --- a/Backend/src/themes.rs +++ b/Backend/src/themes.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use log::warn; use serde::{Deserialize, Serialize}; use std::{collections::HashSet, fs, path::Path}; diff --git a/Backend/src/utilities/inner.rs b/Backend/src/utilities/inner.rs index beac64d9..eda4570c 100644 --- a/Backend/src/utilities/inner.rs +++ b/Backend/src/utilities/inner.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use serde::Serialize; #[derive(Serialize)] diff --git a/Backend/src/utilities/mod.rs b/Backend/src/utilities/mod.rs index b73c13df..a9a0f69c 100644 --- a/Backend/src/utilities/mod.rs +++ b/Backend/src/utilities/mod.rs @@ -1,2 +1,4 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later pub mod inner; pub use inner as utilities; diff --git a/Backend/src/utilities/utilities.rs b/Backend/src/utilities/utilities.rs index beac64d9..eda4570c 100644 --- a/Backend/src/utilities/utilities.rs +++ b/Backend/src/utilities/utilities.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use serde::Serialize; #[derive(Serialize)] diff --git a/Backend/src/validate.rs b/Backend/src/validate.rs index e4a17c11..32863a20 100644 --- a/Backend/src/validate.rs +++ b/Backend/src/validate.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later use std::path::Path; #[derive(serde::Serialize)] diff --git a/Backend/src/workarounds.rs b/Backend/src/workarounds.rs index 554dceee..47afe18e 100644 --- a/Backend/src/workarounds.rs +++ b/Backend/src/workarounds.rs @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later #[cfg(target_os = "linux")] /// Applies a runtime workaround for NVIDIA + Wayland rendering issues. /// diff --git a/Frontend/src/scripts/features/about.ts b/Frontend/src/scripts/features/about.ts index 97250486..c0d8f26f 100644 --- a/Frontend/src/scripts/features/about.ts +++ b/Frontend/src/scripts/features/about.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/about.ts import { openModal } from "@scripts/ui/modals"; import { TAURI } from "../lib/tauri"; diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index 3ef2b7e0..c1935f18 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/branches.ts import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; diff --git a/Frontend/src/scripts/features/cherryPick.ts b/Frontend/src/scripts/features/cherryPick.ts index eae62dd2..9c315de1 100644 --- a/Frontend/src/scripts/features/cherryPick.ts +++ b/Frontend/src/scripts/features/cherryPick.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { closeModal, hydrate, openModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/commandSheet.ts b/Frontend/src/scripts/features/commandSheet.ts index c5f9806d..8b5f0378 100644 --- a/Frontend/src/scripts/features/commandSheet.ts +++ b/Frontend/src/scripts/features/commandSheet.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/commandSheet.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; diff --git a/Frontend/src/scripts/features/conflicts.ts b/Frontend/src/scripts/features/conflicts.ts index df9c999a..484b0ea7 100644 --- a/Frontend/src/scripts/features/conflicts.ts +++ b/Frontend/src/scripts/features/conflicts.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { hydrate, openModal, closeModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/deleteBranchConfirm.ts b/Frontend/src/scripts/features/deleteBranchConfirm.ts index 3506c445..ed4caf1d 100644 --- a/Frontend/src/scripts/features/deleteBranchConfirm.ts +++ b/Frontend/src/scripts/features/deleteBranchConfirm.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/deleteBranchConfirm.ts import { closeModal, hydrate, openModal } from "../ui/modals"; diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index c6207cb9..ad313bc1 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/newBranch.ts b/Frontend/src/scripts/features/newBranch.ts index 59b329b1..8bebe4e2 100644 --- a/Frontend/src/scripts/features/newBranch.ts +++ b/Frontend/src/scripts/features/newBranch.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/newBranch.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; diff --git a/Frontend/src/scripts/features/outputLog.ts b/Frontend/src/scripts/features/outputLog.ts index 3a1b37f0..fad10af6 100644 --- a/Frontend/src/scripts/features/outputLog.ts +++ b/Frontend/src/scripts/features/outputLog.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from '../lib/scrollbars'; diff --git a/Frontend/src/scripts/features/renameBranch.ts b/Frontend/src/scripts/features/renameBranch.ts index 7ceb07fa..b86dd0be 100644 --- a/Frontend/src/scripts/features/renameBranch.ts +++ b/Frontend/src/scripts/features/renameBranch.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/renameBranch.ts import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; diff --git a/Frontend/src/scripts/features/repo/commit.ts b/Frontend/src/scripts/features/repo/commit.ts index 24e1defa..c73b84fb 100644 --- a/Frontend/src/scripts/features/repo/commit.ts +++ b/Frontend/src/scripts/features/repo/commit.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { state } from '../../state/state'; export function updateCommitButton() { diff --git a/Frontend/src/scripts/features/repo/context.ts b/Frontend/src/scripts/features/repo/context.ts index 6fdf0a11..04dcfcb2 100644 --- a/Frontend/src/scripts/features/repo/context.ts +++ b/Frontend/src/scripts/features/repo/context.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs } from '../../lib/dom'; export const filterInput = qs('#filter'); diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index fda8587c..1b0bbcf6 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; diff --git a/Frontend/src/scripts/features/repo/filter.ts b/Frontend/src/scripts/features/repo/filter.ts index d0cd5309..9879bb06 100644 --- a/Frontend/src/scripts/features/repo/filter.ts +++ b/Frontend/src/scripts/features/repo/filter.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { state, prefs, disableDefaultSelectAll } from '../../state/state'; import { filterInput, selectAllBox } from './context'; import { renderList } from './list'; diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 2e1ecb19..68edf65b 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { describe, it, expect } from 'vitest' // Provide matchMedia to avoid jsdom environment errors in modules that access it diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index 62f02f91..315a82e9 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; diff --git a/Frontend/src/scripts/features/repo/hotkeys.ts b/Frontend/src/scripts/features/repo/hotkeys.ts index dec1d06d..c76c0c0b 100644 --- a/Frontend/src/scripts/features/repo/hotkeys.ts +++ b/Frontend/src/scripts/features/repo/hotkeys.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { filterInput } from './context'; import { disableDefaultSelectAll, prefs, state } from '../../state/state'; import { getVisibleFiles } from './selectionState'; diff --git a/Frontend/src/scripts/features/repo/hydrate.ts b/Frontend/src/scripts/features/repo/hydrate.ts index 7316da94..096baca1 100644 --- a/Frontend/src/scripts/features/repo/hydrate.ts +++ b/Frontend/src/scripts/features/repo/hydrate.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../../lib/tauri'; import { state, prefs } from '../../state/state'; import { renderList } from './list'; diff --git a/Frontend/src/scripts/features/repo/index.ts b/Frontend/src/scripts/features/repo/index.ts index 29f84127..38391e5a 100644 --- a/Frontend/src/scripts/features/repo/index.ts +++ b/Frontend/src/scripts/features/repo/index.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later export { bindRepoHotkeys } from './hotkeys'; export { bindFilter } from './filter'; export { renderList, wireRenderListCallbacks } from './list'; diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 45054fdf..0026ec0d 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { notify } from '../../lib/notify'; import { TAURI } from '../../lib/tauri'; diff --git a/Frontend/src/scripts/features/repo/list.ts b/Frontend/src/scripts/features/repo/list.ts index 94b7ef03..d0adfc19 100644 --- a/Frontend/src/scripts/features/repo/list.ts +++ b/Frontend/src/scripts/features/repo/list.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { state, prefs, statusClass, statusLabel } from '../../state/state'; import { refreshRepoActions } from '../../ui/layout'; diff --git a/Frontend/src/scripts/features/repo/selectionState.ts b/Frontend/src/scripts/features/repo/selectionState.ts index a20158ac..83111b7c 100644 --- a/Frontend/src/scripts/features/repo/selectionState.ts +++ b/Frontend/src/scripts/features/repo/selectionState.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { prefs, state } from '../../state/state'; import { filterInput, selectAllBox } from './context'; import type { FileStatus } from '../../types'; diff --git a/Frontend/src/scripts/features/repo/stash.ts b/Frontend/src/scripts/features/repo/stash.ts index edb11343..1fe1fbc2 100644 --- a/Frontend/src/scripts/features/repo/stash.ts +++ b/Frontend/src/scripts/features/repo/stash.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; diff --git a/Frontend/src/scripts/features/repoSelection.ts b/Frontend/src/scripts/features/repoSelection.ts index 9157c1a0..1d51dd72 100644 --- a/Frontend/src/scripts/features/repoSelection.ts +++ b/Frontend/src/scripts/features/repoSelection.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/repoSelection.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/repoSettings.ts b/Frontend/src/scripts/features/repoSettings.ts index 65f95aa2..1c0ca2ff 100644 --- a/Frontend/src/scripts/features/repoSettings.ts +++ b/Frontend/src/scripts/features/repoSettings.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/repoSwitchDrawer.ts b/Frontend/src/scripts/features/repoSwitchDrawer.ts index 4cf8236d..63877a6c 100644 --- a/Frontend/src/scripts/features/repoSwitchDrawer.ts +++ b/Frontend/src/scripts/features/repoSwitchDrawer.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/repoSwitchDrawer.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/setUpstream.ts b/Frontend/src/scripts/features/setUpstream.ts index 9ffef405..8d845d7b 100644 --- a/Frontend/src/scripts/features/setUpstream.ts +++ b/Frontend/src/scripts/features/setUpstream.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from "../lib/tauri"; import { notify } from "../lib/notify"; import { closeModal, hydrate, openModal } from "../ui/modals"; diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 43db9de5..75259ad2 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { toKebab } from '../lib/dom'; diff --git a/Frontend/src/scripts/features/sshAuth.ts b/Frontend/src/scripts/features/sshAuth.ts index 5cd1ec05..bd198bb4 100644 --- a/Frontend/src/scripts/features/sshAuth.ts +++ b/Frontend/src/scripts/features/sshAuth.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal, closeModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/sshHostkey.ts b/Frontend/src/scripts/features/sshHostkey.ts index a38cc965..f32c2a25 100644 --- a/Frontend/src/scripts/features/sshHostkey.ts +++ b/Frontend/src/scripts/features/sshHostkey.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal, closeModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/sshKeys.ts b/Frontend/src/scripts/features/sshKeys.ts index 11f66af3..a29428f5 100644 --- a/Frontend/src/scripts/features/sshKeys.ts +++ b/Frontend/src/scripts/features/sshKeys.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; import { openModal } from '../ui/modals'; diff --git a/Frontend/src/scripts/features/stashConfirm.ts b/Frontend/src/scripts/features/stashConfirm.ts index 17de644f..4d54cc29 100644 --- a/Frontend/src/scripts/features/stashConfirm.ts +++ b/Frontend/src/scripts/features/stashConfirm.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/features/stashConfirm.ts import { TAURI } from '../lib/tauri'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/features/update.ts b/Frontend/src/scripts/features/update.ts index 57967e2c..9a1f69b8 100644 --- a/Frontend/src/scripts/features/update.ts +++ b/Frontend/src/scripts/features/update.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { notify } from '../lib/notify'; diff --git a/Frontend/src/scripts/lib/dom.test.ts b/Frontend/src/scripts/lib/dom.test.ts index 16bc4cab..5150f37f 100644 --- a/Frontend/src/scripts/lib/dom.test.ts +++ b/Frontend/src/scripts/lib/dom.test.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { describe, it, expect, beforeEach } from 'vitest' import { qs, qsa, setText, setValue, setChecked, escapeHtml, toKebab } from './dom' diff --git a/Frontend/src/scripts/lib/dom.ts b/Frontend/src/scripts/lib/dom.ts index f1ea4ee9..5f7c0307 100644 --- a/Frontend/src/scripts/lib/dom.ts +++ b/Frontend/src/scripts/lib/dom.ts @@ -1,21 +1,66 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * Query a single element by CSS selector. + * @param sel - CSS selector string + * @param root - Root element to search within (defaults to document) + * @returns First matching element or null + */ export const qs = (sel: string, root: Document | Element = document): T | null => root.querySelector(sel) as T | null; +/** + * Query all elements matching a CSS selector. + * @param sel - CSS selector string + * @param root - Root element to search within (defaults to document) + * @returns Array of matching elements + */ export const qsa = (sel: string, root: Document | Element = document): T[] => Array.from(root.querySelectorAll(sel)) as T[]; +/** + * Set the text content of an element. + * @param el - Element to modify + * @param text - Text content to set + */ export const setText = (el: Element | null | undefined, text: string) => { if (el) (el as HTMLElement).textContent = text; }; +/** + * Set the value of an input or select element. + * @param el - Form element to modify + * @param value - Value to set + */ export const setValue = (el: HTMLInputElement | HTMLSelectElement | null | undefined, value: string | number) => { if (!el) return; (el as HTMLInputElement | HTMLSelectElement).value = String(value); }; +/** + * Set the checked state of a checkbox or radio input. + * @param el - Input element to modify + * @param on - Whether to check the element + */ export const setChecked = (el: HTMLInputElement | null | undefined, on: boolean) => { if (el) el.checked = on; }; +/** + * Escape HTML special characters in a string. + * @param s - Value to escape + * @returns HTML-escaped string + */ export const escapeHtml = (s: any) => String(s) .replace(/&/g,'&') .replace(/(target: Document | HTMLElement | Window, type: K, fn: (ev: DocumentEventMap[K]) => any) => target.addEventListener(type, fn as any); +/** + * Convert a value to kebab-case. + * @param v - Value to convert + * @returns Kebab-case string + */ export const toKebab = (v: unknown) => String(v ?? '').toLowerCase().replace(/_/g, '-'); diff --git a/Frontend/src/scripts/lib/menu.ts b/Frontend/src/scripts/lib/menu.ts index 41356cef..08f6ef4c 100644 --- a/Frontend/src/scripts/lib/menu.ts +++ b/Frontend/src/scripts/lib/menu.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later export type CtxItem = { label: string; action?: () => void | Promise }; /** diff --git a/Frontend/src/scripts/lib/notify.ts b/Frontend/src/scripts/lib/notify.ts index f01eb2cf..4fcd001e 100644 --- a/Frontend/src/scripts/lib/notify.ts +++ b/Frontend/src/scripts/lib/notify.ts @@ -1,7 +1,13 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs, setText } from './dom'; const statusEl = qs('#status'); +/** + * Display a notification message in the status bar. + * @param text - Message to display + */ export function notify(text: string) { if (!statusEl) return; setText(statusEl, text); diff --git a/Frontend/src/scripts/lib/scrollbars.ts b/Frontend/src/scripts/lib/scrollbars.ts index d766131e..af5919b4 100644 --- a/Frontend/src/scripts/lib/scrollbars.ts +++ b/Frontend/src/scripts/lib/scrollbars.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { OverlayScrollbars } from 'overlayscrollbars'; // Use the official attribute name so OverlayScrollbars can hide native scrollbars @@ -115,14 +117,26 @@ function queryScrollableElements(root: ParentNode, includeHidden = false): HTMLE return includeHidden ? all : all.filter(isVisibleForInit); } +/** + * Initialize OverlayScrollbars for scrollable elements within a root. + * @param root - Root element to search for scrollable elements (defaults to document) + */ export function initOverlayScrollbarsFor(root: ParentNode = document) { queryScrollableElements(root).forEach(initOne); } +/** + * Refresh OverlayScrollbars for scrollable elements within a root. + * @param root - Root element to search for scrollable elements (defaults to document) + */ export function refreshOverlayScrollbarsFor(root: ParentNode = document) { queryScrollableElements(root).forEach(refreshOne); } +/** + * Destroy OverlayScrollbars instances for matching elements. + * @param target - CSS selector string or root element + */ export function destroyOverlayScrollbarsFor(target: string | ParentNode = document) { if (typeof target === 'string') { let els: HTMLElement[] = []; diff --git a/Frontend/src/scripts/lib/tauri.ts b/Frontend/src/scripts/lib/tauri.ts index 831614bb..7041e1c2 100644 --- a/Frontend/src/scripts/lib/tauri.ts +++ b/Frontend/src/scripts/lib/tauri.ts @@ -1,12 +1,16 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/lib/tauri.ts import type { Json } from "../types"; type Unlisten = () => void; type Listener = (evt: { payload: T }) => void; +/** Interface for Tauri core functionality. */ interface TauriCore { invoke(cmd: string, args?: Json): Promise; } +/** Interface for Tauri event system. */ interface TauriEvent { listen(event: string, cb: Listener): Promise<{ unlisten: Unlisten }>; } @@ -20,11 +24,15 @@ declare global { const core: TauriCore | null = typeof window !== "undefined" && window.__TAURI__?.core ? window.__TAURI__.core : null; const tEvent: TauriEvent | null = typeof window !== "undefined" && window.__TAURI__?.event ? window.__TAURI__.event : null; +/** Tauri API wrapper providing invoke and event listening capabilities. */ export const TAURI = { + /** Whether Tauri runtime is available. */ has: !!core, + /** Invoke a Tauri command. */ invoke(cmd: string, args?: Json): Promise { return core ? core.invoke(cmd, args) : Promise.resolve(undefined as unknown as T); }, + /** Listen for Tauri events. */ listen(event: string, cb: Listener): Promise<{ unlisten: Unlisten }> { return tEvent ? tEvent.listen(event, cb) : Promise.resolve({ unlisten() {} }); }, diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index fe5186de..cbfec236 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index 8d00324a..32db698a 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from './lib/tauri'; import { notify } from './lib/notify'; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index b4cd193a..f0b9c437 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -1,6 +1,9 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/state/state.ts import type { AppPrefs, Branch, CommitItem, FileStatus, StashItem } from '../types'; +/** Default application preferences. */ export const defaultPrefs: AppPrefs = { theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', leftW: 0, @@ -8,11 +11,17 @@ export const defaultPrefs: AppPrefs = { }; // In-memory-only UI prefs. Persisted preferences now live in native Rust config. +/** Current application preferences. */ export let prefs: AppPrefs = { ...defaultPrefs }; +/** + * Save preferences to storage. + * @deprecated Web prefs are not persisted; native settings handle persistence + */ export function savePrefs() { // no-op: web prefs are not persisted; native settings handle persistence } +/** Metadata for diff view rendering. */ export type DiffMeta = { offset: number; rest: string[]; @@ -21,12 +30,14 @@ export type DiffMeta = { totalHunks: number; }; +/** References to DOM elements for a hunk. */ export type HunkNodeRefs = { hunkEl: HTMLElement; hunkCheckbox: HTMLInputElement | null; lineCheckboxes: Record; }; +/** Global application state. */ export const state = { hasRepo: false, // backend truth (set after open/clone/add) branch: '' as string, // current branch name @@ -67,6 +78,11 @@ export const hasRepo = (): boolean => Boolean(state.hasRepo && state.branch); export const hasChanges = (): boolean => Array.isArray(state.files) && state.files.length > 0; +/** + * Get display label for a file status code. + * @param s - Status code character + * @returns Human-readable status label + */ export const statusLabel = (s: string) => s === 'A' ? 'Added' : s === '?' ? 'Untracked' : diff --git a/Frontend/src/scripts/themes.ts b/Frontend/src/scripts/themes.ts index b3939253..14e0347f 100644 --- a/Frontend/src/scripts/themes.ts +++ b/Frontend/src/scripts/themes.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from './lib/tauri'; import { notify } from './lib/notify'; import { getRegisteredThemePayload, getRegisteredThemeSummaries } from './plugins'; diff --git a/Frontend/src/scripts/types.d.ts b/Frontend/src/scripts/types.d.ts index 0ad38ac0..f9142cf9 100644 --- a/Frontend/src/scripts/types.d.ts +++ b/Frontend/src/scripts/types.d.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later export type Json = Record; export interface BranchKind { diff --git a/Frontend/src/scripts/ui/layout.ts b/Frontend/src/scripts/ui/layout.ts index 6be2427f..ffd9db54 100644 --- a/Frontend/src/scripts/ui/layout.ts +++ b/Frontend/src/scripts/ui/layout.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later import { qs, qsa, setText } from '../lib/dom'; import { prefs, savePrefs, state, hasRepo, hasChanges } from '../state/state'; import { TAURI } from '../lib/tauri'; diff --git a/Frontend/src/scripts/ui/menubar.ts b/Frontend/src/scripts/ui/menubar.ts index 3b4c8cc5..d2aebee2 100644 --- a/Frontend/src/scripts/ui/menubar.ts +++ b/Frontend/src/scripts/ui/menubar.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later type MenuAction = (id: string) => void | Promise; const MENU_CLOSE_MS = 130; diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index e08752c0..3f8a9a60 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // src/scripts/ui/modals.ts import { qs } from "@scripts/lib/dom"; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from "../lib/scrollbars"; diff --git a/Frontend/src/setupTests.ts b/Frontend/src/setupTests.ts index 9d29c1e9..acebced6 100644 --- a/Frontend/src/setupTests.ts +++ b/Frontend/src/setupTests.ts @@ -1,3 +1,5 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later // Test setup: provide browser shims used by frontend modules (globalThis as any).matchMedia = (query: string) => ({ matches: false, From a79162c29e30126ec408290578b29b59ce5fe3f4 Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 18:32:30 +0000 Subject: [PATCH 29/96] Update compiles --- .../src/plugin_runtime/component_instance.rs | 145 ++++-------------- Backend/src/plugin_runtime/events.rs | 60 +------- Backend/src/plugin_runtime/host_api.rs | 37 +++-- Backend/src/plugin_vcs_backends.rs | 7 - Backend/src/plugins.rs | 2 +- 5 files changed, 61 insertions(+), 190 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 1987a7e3..051c4cae 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -6,7 +6,6 @@ use crate::plugin_runtime::host_api::{ }; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; -use openvcs_core::app_api::Host as AppHostApi; use parking_lot::Mutex; use serde::de::DeserializeOwned; use serde::Serialize; @@ -18,16 +17,16 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; mod bindings { wasmtime::component::bindgen!({ path: "../../Core/wit", - world: "openvcs-plugin", + world: "vcs", additional_derives: [serde::Serialize, serde::Deserialize], }); } -use bindings::exports::openvcs::plugin::plugin_api; +use bindings::exports::openvcs::plugin::vcs_api; struct ComponentRuntime { store: Store, - bindings: bindings::OpenvcsPlugin, + bindings: bindings::Vcs, } struct ComponentHostState { @@ -38,7 +37,7 @@ struct ComponentHostState { impl ComponentHostState { fn map_host_error( - err: openvcs_core::app_api::ComponentError, + err: openvcs_core::app_api::PluginError, ) -> bindings::openvcs::plugin::host_api::HostError { bindings::openvcs::plugin::host_api::HostError { code: err.code, @@ -47,89 +46,6 @@ impl ComponentHostState { } } -impl AppHostApi for ComponentHostState { - fn get_runtime_info( - &mut self, - ) -> Result { - Ok(host_runtime_info()) - } - - fn subscribe_event( - &mut self, - event_name: &str, - ) -> Result<(), openvcs_core::app_api::ComponentError> { - host_subscribe_event(&self.spawn, event_name) - } - - fn emit_event( - &mut self, - event_name: &str, - payload: &[u8], - ) -> Result<(), openvcs_core::app_api::ComponentError> { - host_emit_event(&self.spawn, event_name, payload) - } - - fn ui_notify(&mut self, message: &str) -> Result<(), openvcs_core::app_api::ComponentError> { - host_ui_notify(&self.spawn, message) - } - - fn workspace_read_file( - &mut self, - path: &str, - ) -> Result, openvcs_core::app_api::ComponentError> { - host_workspace_read_file(&self.spawn, path) - } - - fn workspace_write_file( - &mut self, - path: &str, - content: &[u8], - ) -> Result<(), openvcs_core::app_api::ComponentError> { - host_workspace_write_file(&self.spawn, path, content) - } - - fn process_exec_git( - &mut self, - cwd: Option<&str>, - args: &[String], - env: &[(String, String)], - stdin: Option<&str>, - ) -> Result - { - host_process_exec_git(&self.spawn, cwd, args, env, stdin) - } - - fn host_log( - &mut self, - level: openvcs_core::app_api::ComponentLogLevel, - target: &str, - message: &str, - ) { - let target = if target.trim().is_empty() { - format!("plugin.{}", self.spawn.plugin_id) - } else { - format!("plugin.{}.{}", self.spawn.plugin_id, target) - }; - match level { - openvcs_core::app_api::ComponentLogLevel::Trace => { - log::trace!(target: &target, "{message}") - } - openvcs_core::app_api::ComponentLogLevel::Debug => { - log::debug!(target: &target, "{message}") - } - openvcs_core::app_api::ComponentLogLevel::Info => { - log::info!(target: &target, "{message}") - } - openvcs_core::app_api::ComponentLogLevel::Warn => { - log::warn!(target: &target, "{message}") - } - openvcs_core::app_api::ComponentLogLevel::Error => { - log::error!(target: &target, "{message}") - } - }; - } -} - impl WasiView for ComponentHostState { fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { @@ -146,8 +62,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { bindings::openvcs::plugin::host_api::RuntimeInfo, bindings::openvcs::plugin::host_api::HostError, > { - let value = - AppHostApi::get_runtime_info(self).map_err(ComponentHostState::map_host_error)?; + let value = host_runtime_info(); Ok(bindings::openvcs::plugin::host_api::RuntimeInfo { os: value.os, arch: value.arch, @@ -159,7 +74,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, event_name: String, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::subscribe_event(self, &event_name).map_err(ComponentHostState::map_host_error) + host_subscribe_event(&self.spawn, &event_name).map_err(ComponentHostState::map_host_error) } fn emit_event( @@ -167,7 +82,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { event_name: String, payload: Vec, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::emit_event(self, &event_name, &payload) + host_emit_event(&self.spawn, &event_name, &payload) .map_err(ComponentHostState::map_host_error) } @@ -175,14 +90,14 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, message: String, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::ui_notify(self, &message).map_err(ComponentHostState::map_host_error) + host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error) } fn workspace_read_file( &mut self, path: String, ) -> Result, bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::workspace_read_file(self, &path).map_err(ComponentHostState::map_host_error) + host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error) } fn workspace_write_file( @@ -190,7 +105,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { path: String, content: Vec, ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - AppHostApi::workspace_write_file(self, &path, &content) + host_workspace_write_file(&self.spawn, &path, &content) .map_err(ComponentHostState::map_host_error) } @@ -209,7 +124,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { .map(|var| (var.key, var.value)) .collect::>(); let value = - AppHostApi::process_exec_git(self, cwd.as_deref(), &args, &env, stdin.as_deref()) + host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) .map_err(ComponentHostState::map_host_error)?; Ok(bindings::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, @@ -225,24 +140,29 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { target: String, message: String, ) { - let level = match level { + let target = if target.trim().is_empty() { + format!("plugin.{}", self.spawn.plugin_id) + } else { + format!("plugin.{}.{}", self.spawn.plugin_id, target) + }; + + match level { bindings::openvcs::plugin::host_api::LogLevel::Trace => { - openvcs_core::app_api::ComponentLogLevel::Trace + log::trace!(target: &target, "{message}") } bindings::openvcs::plugin::host_api::LogLevel::Debug => { - openvcs_core::app_api::ComponentLogLevel::Debug + log::debug!(target: &target, "{message}") } bindings::openvcs::plugin::host_api::LogLevel::Info => { - openvcs_core::app_api::ComponentLogLevel::Info + log::info!(target: &target, "{message}") } bindings::openvcs::plugin::host_api::LogLevel::Warn => { - openvcs_core::app_api::ComponentLogLevel::Warn + log::warn!(target: &target, "{message}") } bindings::openvcs::plugin::host_api::LogLevel::Error => { - openvcs_core::app_api::ComponentLogLevel::Error + log::error!(target: &target, "{message}") } }; - AppHostApi::host_log(self, level, &target, &message); } } @@ -268,7 +188,7 @@ impl ComponentPluginRuntimeInstance { let mut linker = Linker::new(&engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker) .map_err(|e| format!("link wasi imports: {e}"))?; - bindings::OpenvcsPlugin::add_to_linker::< + bindings::Vcs::add_to_linker::< ComponentHostState, wasmtime::component::HasSelf, >(&mut linker, |state| state) @@ -281,7 +201,7 @@ impl ComponentPluginRuntimeInstance { wasi: WasiCtx::builder().build(), }, ); - let bindings = bindings::OpenvcsPlugin::instantiate(&mut store, &component, &linker) + let bindings = bindings::Vcs::instantiate(&mut store, &component, &linker) .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?; bindings @@ -342,13 +262,14 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { Ok(()) } + #[allow(clippy::let_unit_value)] fn call(&self, method: &str, params: Value) -> Result { self.with_runtime(|runtime| { macro_rules! invoke { ($method_name:literal, $call:ident $(, $arg:expr )* ) => { runtime .bindings - .openvcs_plugin_plugin_api() + .openvcs_plugin_vcs_api() .$call(&mut runtime.store $(, $arg )* ) .map_err(|e| { format!( @@ -407,13 +328,13 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { .into_iter() .map(|item| { let kind = match item.kind { - plugin_api::BranchKind::Local => { + vcs_api::BranchKind::Local => { serde_json::json!({ "type": "Local" }) } - plugin_api::BranchKind::Remote(remote) => { + vcs_api::BranchKind::Remote(remote) => { serde_json::json!({ "type": "Remote", "remote": remote }) } - plugin_api::BranchKind::Unknown => { + vcs_api::BranchKind::Unknown => { serde_json::json!({ "type": "Unknown" }) } }; @@ -492,7 +413,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { struct Params { remote: String, refspec: String, - opts: plugin_api::FetchOptions, + opts: vcs_api::FetchOptions, } let p: Params = parse_method_params(method, params)?; let out = invoke!( @@ -560,7 +481,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { "log_commits" => { #[derive(serde::Deserialize)] struct Params { - query: plugin_api::LogQuery, + query: vcs_api::LogQuery, } let p: Params = parse_method_params(method, params)?; let out = invoke!("log_commits", call_list_commits, &p.query)?; @@ -597,7 +518,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { #[derive(serde::Deserialize)] struct Params { path: String, - side: plugin_api::ConflictSide, + side: vcs_api::ConflictSide, } let p: Params = parse_method_params(method, params)?; let out = invoke!( diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index 0b8a0e27..11718a16 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -1,22 +1,11 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use openvcs_core::plugin_protocol::PluginMessage; -use openvcs_core::plugin_protocol::RpcRequest; + use serde_json::Value; use std::collections::{HashMap, HashSet}; -use std::io::Write; -use std::sync::{Arc, Mutex, OnceLock}; - -pub type PluginStdin = Arc>>>>; - -#[derive(Clone)] -pub struct PluginIoHandle { - pub stdin: PluginStdin, -} +use std::sync::{Mutex, OnceLock}; struct Registry { - next_id: HashMap, - io: HashMap, subs: HashMap>, // plugin_id -> event names } @@ -29,8 +18,6 @@ static REGISTRY: OnceLock> = OnceLock::new(); fn registry() -> &'static Mutex { REGISTRY.get_or_init(|| { Mutex::new(Registry { - next_id: HashMap::new(), - io: HashMap::new(), subs: HashMap::new(), }) }) @@ -46,9 +33,7 @@ fn registry() -> &'static Mutex { /// - `()`. pub fn unregister_plugin(plugin_id: &str) { if let Ok(mut lock) = registry().lock() { - lock.io.remove(plugin_id); lock.subs.remove(plugin_id); - lock.next_id.remove(plugin_id); } } @@ -79,8 +64,8 @@ pub fn subscribe(plugin_id: &str, event: &str) { /// # Returns /// - `()`. pub fn emit_from_plugin(plugin_id: &str, name: &str, payload: Value) { - // For now this just fans out to other plugin subscribers. - // Host-side internal listeners can be added later. + // Current component runtime transport is in-process only. + // Keep the subscription graph updated, but there is no cross-plugin delivery channel yet. emit_to_plugins(Some(plugin_id), name, payload); } @@ -94,12 +79,9 @@ pub fn emit_from_plugin(plugin_id: &str, name: &str, payload: Value) { /// # Returns /// - `()`. pub fn emit_to_plugins(origin_plugin_id: Option<&str>, name: &str, payload: Value) { - let targets: Vec<(String, PluginIoHandle, u64)> = { - let Ok(mut lock) = registry().lock() else { - return; - }; - - let matching: Vec = lock + let _ = payload; + if let Ok(lock) = registry().lock() { + let _targets: Vec = lock .subs .iter() .filter_map(|(plugin_id, events)| { @@ -112,33 +94,5 @@ pub fn emit_to_plugins(origin_plugin_id: Option<&str>, name: &str, payload: Valu Some(plugin_id.clone()) }) .collect(); - - let mut out = Vec::new(); - for plugin_id in matching { - let Some(io) = lock.io.get(&plugin_id).cloned() else { - continue; - }; - let id = lock.next_id.entry(plugin_id.clone()).or_insert(1); - let req_id = *id; - *id = id.saturating_add(1); - out.push((plugin_id, io, req_id)); - } - out - }; - - for (_plugin_id, io, req_id) in targets { - if let Ok(mut lock) = io.stdin.lock() { - if let Some(stdin) = lock.as_mut() { - let req = RpcRequest { - id: req_id, - method: "event.dispatch".to_string(), - params: serde_json::json!({ "name": name, "payload": payload }), - }; - if let Ok(line) = serde_json::to_string(&PluginMessage::Request(req)) { - let _ = writeln!(stdin, "{line}"); - let _ = stdin.flush(); - } - } - } } } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 74adfa55..e01d3ea3 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -1,7 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_runtime::spawn::SpawnConfig; -use openvcs_core::app_api::{ComponentError, ProcessExecOutput}; +use openvcs_core::app_api::PluginError; use serde_json::Value; use std::collections::HashSet; use std::ffi::OsString; @@ -55,8 +55,18 @@ fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option< (approved_caps, spawn.allowed_workspace_root.clone()) } -fn host_error(code: &str, message: impl Into) -> ComponentError { - ComponentError { +pub(crate) type HostResult = Result; + +/// Host-side result for `process-exec-git` mapped into WIT bindings by runtime glue. +pub(crate) struct HostProcessExecOutput { + pub success: bool, + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +fn host_error(code: &str, message: impl Into) -> PluginError { + PluginError { code: code.to_string(), message: message.into(), } @@ -141,7 +151,7 @@ pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { } } -pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> Result<(), ComponentError> { +pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult<()> { let name = event_name.trim(); if name.is_empty() { return Err(host_error("host.invalid_event_name", "event name is empty")); @@ -150,11 +160,7 @@ pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> Result<(), Ok(()) } -pub fn host_emit_event( - spawn: &SpawnConfig, - event_name: &str, - payload: &[u8], -) -> Result<(), ComponentError> { +pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> HostResult<()> { let name = event_name.trim(); if name.is_empty() { return Err(host_error("host.invalid_event_name", "event name is empty")); @@ -173,7 +179,7 @@ pub fn host_emit_event( Ok(()) } -pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> Result<(), ComponentError> { +pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); if !caps.contains("ui.notifications") { return Err(host_error( @@ -188,10 +194,7 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> Result<(), Componen Ok(()) } -pub fn host_workspace_read_file( - spawn: &SpawnConfig, - path: &str, -) -> Result, ComponentError> { +pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { let (caps, workspace_root) = approved_caps_and_workspace(spawn); if !caps.contains("workspace.read") && !caps.contains("workspace.write") { return Err(host_error( @@ -209,7 +212,7 @@ pub fn host_workspace_write_file( spawn: &SpawnConfig, path: &str, content: &[u8], -) -> Result<(), ComponentError> { +) -> HostResult<()> { let (caps, workspace_root) = approved_caps_and_workspace(spawn); if !caps.contains("workspace.write") { return Err(host_error( @@ -229,7 +232,7 @@ pub fn host_process_exec_git( args: &[String], env: &[(String, String)], stdin: Option<&str>, -) -> Result { +) -> HostResult { let (caps, workspace_root) = approved_caps_and_workspace(spawn); if !caps.contains("process.exec") { return Err(host_error( @@ -284,7 +287,7 @@ pub fn host_process_exec_git( .map_err(|e| host_error("process.error", format!("wait: {e}")))? }; - Ok(ProcessExecOutput { + Ok(HostProcessExecOutput { success: out.status.success(), status: out.status.code().unwrap_or(-1), stdout: String::from_utf8_lossy(&out.stdout).to_string(), diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index fa4f8d5c..2664a3e3 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -42,13 +42,6 @@ pub struct PluginBackendDescriptor { pub plugin_name: Option, } -/// Normalizes capability ids (trim/sort/dedup). -/// -/// # Parameters -/// - `caps`: Raw capability list. -/// -/// # Returns -/// - Normalized capability list. /// Reads a plugin manifest from a plugin directory. /// /// # Parameters diff --git a/Backend/src/plugins.rs b/Backend/src/plugins.rs index 3bc17f7c..d4db68c0 100644 --- a/Backend/src/plugins.rs +++ b/Backend/src/plugins.rs @@ -289,7 +289,7 @@ impl PluginCache { static PLUGIN_CACHE: OnceLock> = OnceLock::new(); fn plugin_cache() -> &'static Arc { - PLUGIN_CACHE.get_or_init(|| PluginCache::initialize()) + PLUGIN_CACHE.get_or_init(PluginCache::initialize) } /// Resolves plugin root directories (user + built-in). From b1da418b0603fe7ec3054786bbe6c4e30fe70b30 Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 19:09:53 +0000 Subject: [PATCH 30/96] Added code comments --- Backend/build.rs | 10 +++++ Backend/src/plugin_bundles.rs | 8 ++++ .../src/plugin_runtime/component_instance.rs | 26 +++++++++++ Backend/src/plugin_runtime/events.rs | 2 + Backend/src/plugin_runtime/host_api.rs | 19 ++++++++ Backend/src/plugin_runtime/manager.rs | 42 ++++++++++++++++++ Backend/src/plugin_runtime/mod.rs | 14 ++++++ Backend/src/plugin_runtime/runtime_select.rs | 2 + Backend/src/plugin_runtime/spawn.rs | 5 +++ Backend/src/plugin_runtime/vcs_proxy.rs | 4 ++ Backend/src/repo_settings.rs | 2 + Backend/src/state.rs | 1 + Backend/src/tauri_commands/branches.rs | 10 +++++ Backend/src/tauri_commands/general.rs | 6 +++ Backend/src/tauri_commands/remotes.rs | 14 ++++++ Backend/src/tauri_commands/shared.rs | 2 + Backend/src/tauri_commands/ssh.rs | 7 +++ Backend/src/utilities/mod.rs | 4 ++ .../src/scripts/features/repo/diffView.ts | 34 ++++++++++++++ .../src/scripts/features/repo/history.test.ts | 10 +++-- Frontend/src/scripts/features/repo/history.ts | 26 +++++++++-- .../src/scripts/features/repo/interactions.ts | 14 ++++++ Frontend/src/scripts/features/repo/stash.ts | 21 ++++++++- Frontend/src/scripts/plugins.ts | 44 +++++++++++++++++++ Frontend/src/scripts/state/state.ts | 12 ++++- Frontend/src/scripts/themes.ts | 27 ++++++++++++ Frontend/src/scripts/types.d.ts | 12 +++++ Frontend/src/scripts/vite-raw.d.ts | 2 + Frontend/src/setupTests.ts | 8 +++- 29 files changed, 377 insertions(+), 11 deletions(-) diff --git a/Backend/build.rs b/Backend/build.rs index 6765e604..15e239be 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later use std::{env, fs, path::PathBuf, process::Command}; +/// Returns whether the build is running for Flatpak packaging. fn is_flatpak_build() -> bool { matches!( env::var("OPENVCS_FLATPAK").as_deref(), @@ -9,10 +10,12 @@ fn is_flatpak_build() -> bool { ) } +/// Returns whether the build is running under `cargo tauri dev`. fn is_tauri_dev() -> bool { matches!(env::var("DEP_TAURI_DEV").as_deref(), Ok("true")) } +/// Returns whether an environment variable is set to a truthy value. fn is_truthy_env(key: &str) -> bool { matches!( env::var(key).as_deref(), @@ -20,6 +23,7 @@ fn is_truthy_env(key: &str) -> bool { ) } +/// Runs `git` with arguments and returns trimmed stdout on success. fn run_git(args: &[&str]) -> Option { let out = Command::new("git").args(args).output().ok()?; if !out.status.success() { @@ -29,6 +33,7 @@ fn run_git(args: &[&str]) -> Option { (!s.is_empty()).then_some(s) } +/// Resolves the current branch name from CI environment or local Git. fn git_branch() -> Option { if let Ok(v) = env::var("GITHUB_REF_NAME") { let v = v.trim().to_string(); @@ -53,15 +58,18 @@ fn git_branch() -> Option { } } +/// Returns the current commit short hash. fn git_short_hash() -> Option { run_git(&["rev-parse", "--short=8", "HEAD"]) } +/// Returns whether the Git worktree has uncommitted changes. fn git_is_dirty() -> Option { let s = run_git(&["status", "--porcelain"])?; Some(!s.trim().is_empty()) } +/// Sanitizes arbitrary text into a semver-safe build metadata identifier. fn sanitize_semver_ident(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut last_was_dash = false; @@ -88,6 +96,7 @@ fn sanitize_semver_ident(s: &str) -> String { } } +/// Ensures the generated built-in plugin resource directory exists. fn ensure_generated_builtins_resource_dir(manifest_dir: &std::path::Path) { // Keep `bundle.resources` valid for plain `cargo build` runs even before // plugin bundles are generated. @@ -101,6 +110,7 @@ fn ensure_generated_builtins_resource_dir(manifest_dir: &std::path::Path) { } } +/// Generates Tauri build config and exports build-time metadata env vars. fn main() { // Base config path (in the Backend crate) let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index a2954d27..c26f56a5 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -1171,15 +1171,23 @@ mod tests { use tempfile::tempdir; use xz2::write::XzEncoder; + /// Synthetic tar entry kind used by bundle-construction helpers. enum TarEntryKind { + /// Regular file entry. File, + /// Symbolic-link entry targeting `target`. Symlink { target: String }, } + /// Synthetic tar entry used to build fixture bundles in tests. struct TarEntry { + /// Path written into the tar header. name: String, + /// Raw entry payload bytes. data: Vec, + /// Optional Unix mode to set in the tar header. unix_mode: Option, + /// Tar entry type selector. kind: TarEntryKind, } diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 051c4cae..5fb1b14f 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -24,18 +24,26 @@ mod bindings { use bindings::exports::openvcs::plugin::vcs_api; +/// Live component instance plus generated bindings handle. struct ComponentRuntime { + /// Wasmtime store containing component state and host context. store: Store, + /// Generated typed binding entrypoints for the plugin world. bindings: bindings::Vcs, } +/// Host state stored inside the Wasmtime store for host imports. struct ComponentHostState { + /// Spawn-time plugin metadata and capability context. spawn: SpawnConfig, + /// Component resource table used by WASI/component model. table: ResourceTable, + /// WASI context exposed to the component. wasi: WasiCtx, } impl ComponentHostState { + /// Converts core host errors into generated WIT host error type. fn map_host_error( err: openvcs_core::app_api::PluginError, ) -> bindings::openvcs::plugin::host_api::HostError { @@ -47,6 +55,7 @@ impl ComponentHostState { } impl WasiView for ComponentHostState { + /// Returns mutable WASI context and resource table view. fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.wasi, @@ -56,6 +65,7 @@ impl WasiView for ComponentHostState { } impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { + /// Returns runtime metadata for the current host process. fn get_runtime_info( &mut self, ) -> Result< @@ -70,6 +80,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { }) } + /// Registers an event subscription for this plugin. fn subscribe_event( &mut self, event_name: String, @@ -77,6 +88,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { host_subscribe_event(&self.spawn, &event_name).map_err(ComponentHostState::map_host_error) } + /// Emits a plugin-originated event through the host event bus. fn emit_event( &mut self, event_name: String, @@ -86,6 +98,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error) } + /// Forwards plugin notifications to host-side UI notification handling. fn ui_notify( &mut self, message: String, @@ -93,6 +106,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error) } + /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, path: String, @@ -100,6 +114,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error) } + /// Writes a workspace file under capability and path constraints. fn workspace_write_file( &mut self, path: String, @@ -109,6 +124,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error) } + /// Executes `git` in a constrained host environment. fn process_exec_git( &mut self, cwd: Option, @@ -134,6 +150,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { }) } + /// Logs plugin-emitted messages through the host logger. fn host_log( &mut self, level: bindings::openvcs::plugin::host_api::LogLevel, @@ -168,7 +185,9 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { /// Component-model runtime instance. pub struct ComponentPluginRuntimeInstance { + /// Spawn configuration used to instantiate and identify the component. spawn: SpawnConfig, + /// Lazily initialized runtime state. runtime: Mutex>, } @@ -181,6 +200,7 @@ impl ComponentPluginRuntimeInstance { } } + /// Instantiates the component runtime and executes plugin initialization. fn instantiate_runtime(&self) -> Result { let engine = Engine::default(); let component = Component::from_file(&engine, &self.spawn.exec_path) @@ -218,6 +238,7 @@ impl ComponentPluginRuntimeInstance { Ok(ComponentRuntime { store, bindings }) } + /// Ensures a runtime exists and executes a closure with mutable access. fn with_runtime( &self, f: impl FnOnce(&mut ComponentRuntime) -> Result, @@ -234,10 +255,12 @@ impl ComponentPluginRuntimeInstance { } } +/// Deserializes JSON RPC parameters for a named method. fn parse_method_params(method: &str, params: Value) -> Result { serde_json::from_value(params).map_err(|e| format!("invalid params for `{method}`: {e}")) } +/// Serializes a method result to JSON with contextual error reporting. fn encode_method_result( plugin_id: &str, method: &str, @@ -252,6 +275,7 @@ fn encode_method_result( } impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { + /// Starts the component runtime when not already running. fn ensure_running(&self) -> Result<(), String> { let mut lock = self.runtime.lock(); if lock.is_some() { @@ -263,6 +287,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { } #[allow(clippy::let_unit_value)] + /// Invokes a v1 ABI method exported by the plugin component. fn call(&self, method: &str, params: Value) -> Result { self.with_runtime(|runtime| { macro_rules! invoke { @@ -771,6 +796,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { }) } + /// Deinitializes and drops the running component runtime. fn stop(&self) { let mut lock = self.runtime.lock(); if let Some(runtime) = lock.as_mut() { diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index 11718a16..80516d8b 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -5,7 +5,9 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; +/// In-memory mapping of plugin subscriptions by plugin id. struct Registry { + /// Event names subscribed by each plugin id. subs: HashMap>, // plugin_id -> event names } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index e01d3ea3..c3240265 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -32,6 +32,7 @@ const DEFAULT_PATH_UNIX: &str = "/usr/bin:/bin"; #[cfg(windows)] const DEFAULT_PATH_WINDOWS_SUFFIX: &str = "\\System32"; +/// Detects the runtime container kind for diagnostics. fn runtime_container_kind() -> &'static str { if matches!( std::env::var("OPENVCS_FLATPAK").as_deref(), @@ -45,6 +46,7 @@ fn runtime_container_kind() -> &'static str { } } +/// Extracts approved capabilities and optional workspace root from spawn config. fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option) { let approved_caps = match &spawn.approval { crate::plugin_bundles::ApprovalState::Approved { capabilities, .. } => { @@ -55,16 +57,22 @@ fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option< (approved_caps, spawn.allowed_workspace_root.clone()) } +/// Host API result type alias for plugin-facing operations. pub(crate) type HostResult = Result; /// Host-side result for `process-exec-git` mapped into WIT bindings by runtime glue. pub(crate) struct HostProcessExecOutput { + /// Whether the process exited successfully. pub success: bool, + /// Numeric process exit status code. pub status: i32, + /// Captured standard output text. pub stdout: String, + /// Captured standard error text. pub stderr: String, } +/// Creates a structured plugin host error. fn host_error(code: &str, message: impl Into) -> PluginError { PluginError { code: code.to_string(), @@ -72,6 +80,7 @@ fn host_error(code: &str, message: impl Into) -> PluginError { } } +/// Resolves a plugin-supplied path under an allowed workspace root. fn resolve_under_root(root: &Path, path: &str) -> Result { if path.contains('\0') { return Err("path contains NUL".to_string()); @@ -104,6 +113,7 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { Ok(root.join(clean)) } +/// Writes bytes to a relative path constrained to the workspace root. fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { let path = resolve_under_root(root, rel)?; if let Some(parent) = path.parent() { @@ -112,11 +122,13 @@ fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), Str fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) } +/// Reads bytes from a relative path constrained to the workspace root. fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { let path = resolve_under_root(root, rel)?; fs::read(&path).map_err(|e| format!("read {}: {e}", path.display())) } +/// Builds a sanitized child-process environment for Git execution. fn sanitized_env() -> Vec<(OsString, OsString)> { let mut out: Vec<(OsString, OsString)> = Vec::new(); @@ -143,6 +155,7 @@ fn sanitized_env() -> Vec<(OsString, OsString)> { out } +/// Returns runtime metadata exposed to plugins. pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { openvcs_core::RuntimeInfo { os: Some(std::env::consts::OS.to_string()), @@ -151,6 +164,7 @@ pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { } } +/// Registers a plugin subscription for a named host event. pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult<()> { let name = event_name.trim(); if name.is_empty() { @@ -160,6 +174,7 @@ pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult Ok(()) } +/// Emits a plugin-originated event with JSON payload validation. pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> HostResult<()> { let name = event_name.trim(); if name.is_empty() { @@ -179,6 +194,7 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> Ok(()) } +/// Handles plugin notification requests gated by `ui.notifications` capability. pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); if !caps.contains("ui.notifications") { @@ -194,6 +210,7 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { Ok(()) } +/// Reads a workspace file when the plugin has workspace read access. pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { let (caps, workspace_root) = approved_caps_and_workspace(spawn); if !caps.contains("workspace.read") && !caps.contains("workspace.write") { @@ -208,6 +225,7 @@ pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult, diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 4912c357..2328c09c 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -12,21 +12,31 @@ use std::path::PathBuf; use std::sync::Arc; #[derive(Clone)] +/// Fully resolved runtime spec for a module-capable plugin. struct ModuleRuntimeSpec { + /// Canonical plugin identifier. plugin_id: String, + /// Normalized lowercase process map key. key: String, + /// Manifest default-enabled value for this plugin. default_enabled: bool, + /// Spawn configuration used to instantiate runtime transport. spawn: SpawnConfig, } /// Owns long-lived module plugin processes and coordinates lifecycle actions. pub struct PluginRuntimeManager { + /// Bundle store used to resolve installed plugin metadata. store: PluginBundleStore, + /// Running plugin runtime instances keyed by normalized plugin id. processes: Mutex>, } +/// Runtime handle tracked for a running plugin. struct RunningPlugin { + /// Runtime instance for dispatching plugin RPC calls. runtime: Arc, + /// Workspace confinement root associated with the runtime instance. workspace_root: Option, } @@ -180,6 +190,17 @@ impl PluginRuntimeManager { /// Calls a module RPC method through the persistent plugin process with an /// optional workspace-root confinement. + /// + /// # Parameters + /// - `cfg`: App config snapshot used for enabled-state checks. + /// - `plugin_id`: Plugin identifier. + /// - `method`: RPC method name. + /// - `params`: JSON RPC parameters. + /// - `allowed_workspace_root`: Optional workspace root for host capability confinement. + /// + /// # Returns + /// - `Ok(Value)` plugin RPC response payload. + /// - `Err(String)` when plugin state validation or RPC dispatch fails. pub fn call_module_method_for_workspace_with_config( &self, cfg: &AppConfig, @@ -204,6 +225,15 @@ impl PluginRuntimeManager { } /// Returns the persistent runtime instance for a plugin workspace. + /// + /// # Parameters + /// - `cfg`: App config snapshot used for enabled-state checks. + /// - `plugin_id`: Plugin identifier. + /// - `allowed_workspace_root`: Optional workspace root for host capability confinement. + /// + /// # Returns + /// - `Ok(Arc)` running runtime instance. + /// - `Err(String)` when plugin state validation or startup fails. pub fn runtime_for_workspace_with_config( &self, cfg: &AppConfig, @@ -223,6 +253,7 @@ impl PluginRuntimeManager { .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id)) } + /// Starts or reuses a runtime for a resolved plugin runtime spec. fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { if let Some(existing) = self.processes.lock().get(&spec.key) { if existing.workspace_root == spec.spawn.allowed_workspace_root { @@ -258,6 +289,7 @@ impl PluginRuntimeManager { Ok(()) } + /// Creates a runtime instance for a resolved plugin spec. fn create_instance( &self, spec: &ModuleRuntimeSpec, @@ -265,6 +297,7 @@ impl PluginRuntimeManager { create_runtime_instance(spec.spawn.clone()) } + /// Resolves a plugin id into a module runtime specification. fn resolve_module_runtime_spec( &self, plugin_id: &str, @@ -298,6 +331,7 @@ impl PluginRuntimeManager { }) } + /// Finds installed plugin components by plugin id (case-insensitive). fn find_components(&self, plugin_id: &str) -> Result { self.store .list_current_components()? @@ -308,6 +342,7 @@ impl PluginRuntimeManager { } impl Drop for PluginRuntimeManager { + /// Stops all runtimes when the manager is dropped. fn drop(&mut self) { let running = std::mem::take(&mut *self.processes.get_mut()); for (_, process) in running { @@ -316,6 +351,7 @@ impl Drop for PluginRuntimeManager { } } +/// Normalizes plugin ids to process map keys. fn normalize_plugin_key(plugin_id: &str) -> Result { let plugin_id = plugin_id.trim().to_ascii_lowercase(); if plugin_id.is_empty() { @@ -340,6 +376,7 @@ mod tests { ]; #[test] + /// Verifies repeated start/stop calls keep runtime state stable. fn start_and_stop_are_idempotent() { let temp = tempdir().expect("tempdir"); write_plugin(temp.path(), "test.plugin", true); @@ -355,6 +392,7 @@ mod tests { } #[test] + /// Verifies sync starts and stops plugins according to config toggles. fn sync_tracks_enabled_state() { let temp = tempdir().expect("tempdir"); write_plugin(temp.path(), "alpha.plugin", true); @@ -383,6 +421,7 @@ mod tests { } #[test] + /// Verifies runtime start rejects plugins without module components. fn start_plugin_rejects_plugins_without_module_component() { let temp = tempdir().expect("tempdir"); write_non_runtime_plugin(temp.path(), "themes.plugin", true); @@ -395,6 +434,7 @@ mod tests { } #[test] + /// Verifies sync ignores non-runtime plugins without module components. fn sync_ignores_plugins_without_module_component() { let temp = tempdir().expect("tempdir"); write_plugin(temp.path(), "runtime.plugin", true); @@ -411,6 +451,7 @@ mod tests { assert!(!running.contains_key("themes.plugin")); } + /// Writes a minimal module-capable plugin layout into a temp store. fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { let plugin_dir = root.join(plugin_id); fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); @@ -467,6 +508,7 @@ mod tests { .expect("write current"); } + /// Writes a plugin layout with manifest/index but no runtime module. fn write_non_runtime_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { let plugin_dir = root.join(plugin_id); fs::create_dir_all(&plugin_dir).expect("create plugin dir"); diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index ea560c8b..a3223abe 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -1,12 +1,26 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +//! Plugin runtime subsystem modules. +//! +//! These modules provide plugin process/component lifecycle management, +//! host API bridging, and backend proxy adapters. + +/// Component-model runtime implementation and ABI dispatch. pub mod component_instance; +/// In-memory plugin event subscription registry. pub mod events; +/// Host functions exposed to plugin modules. pub mod host_api; +/// Runtime instance trait used by the manager. pub mod instance; +/// Long-lived plugin runtime lifecycle manager. pub mod manager; +/// Runtime transport selection and factory helpers. pub mod runtime_select; +/// Runtime spawn configuration types. pub mod spawn; +/// `Vcs` trait adapter backed by plugin runtime RPC. pub mod vcs_proxy; +/// Re-exported runtime manager type used by application state. pub use manager::PluginRuntimeManager; diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 1c12fe52..d2d2f4c9 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -53,6 +53,7 @@ mod tests { ]; #[test] + /// Verifies that a core Wasm module is rejected as a component. fn core_wasm_is_not_detected_as_component() { let temp = tempdir().expect("tempdir"); let wasm_path = temp.path().join("plugin.wasm"); @@ -62,6 +63,7 @@ mod tests { } #[test] + /// Verifies runtime selection returns a clear non-component error. fn selection_rejects_core_wasm() { let temp = tempdir().expect("tempdir"); let wasm_path = temp.path().join("plugin.wasm"); diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs index f1997117..07895e2e 100644 --- a/Backend/src/plugin_runtime/spawn.rs +++ b/Backend/src/plugin_runtime/spawn.rs @@ -3,10 +3,15 @@ use crate::plugin_bundles::ApprovalState; use std::path::PathBuf; +/// Runtime launch configuration for a plugin module instance. #[derive(Debug, Clone)] pub struct SpawnConfig { + /// Canonical plugin identifier used for logging and routing. pub plugin_id: String, + /// Path to the plugin component/module executable. pub exec_path: PathBuf, + /// Persisted capability approval state for this plugin install. pub approval: ApprovalState, + /// Optional workspace root constraining file/process host operations. pub allowed_workspace_root: Option, } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 56c95195..d1b8b9f4 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -11,9 +11,13 @@ use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// [`Vcs`] implementation that forwards operations to a plugin runtime. pub struct PluginVcsProxy { + /// Backend identifier represented by this proxy instance. backend_id: BackendId, + /// Repository worktree path associated with this backend session. workdir: PathBuf, + /// Started plugin runtime used for RPC calls. runtime: Arc, } diff --git a/Backend/src/repo_settings.rs b/Backend/src/repo_settings.rs index c04012e4..62136cdb 100644 --- a/Backend/src/repo_settings.rs +++ b/Backend/src/repo_settings.rs @@ -7,7 +7,9 @@ use serde::{Deserialize, Serialize}; /// Name/URL pair for a configured Git remote. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemoteConfig { + /// Remote name (for example `origin`). pub name: String, + /// Remote fetch/push URL. pub url: String, } diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 9740f7d5..2ddc6738 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -293,6 +293,7 @@ impl AppState { #[derive(Debug, Clone, Serialize, Deserialize)] struct RecentFileEntry { + /// Stored repository path string. path: String, } diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 931b202b..3d9daef1 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -215,9 +215,13 @@ pub async fn git_list_branches(state: State<'_, AppState>) -> Result, + /// Current HEAD commit id when available. pub commit: Option, } @@ -418,7 +422,9 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul } #[derive(serde::Serialize)] +/// Merge-state payload consumed by the UI. pub struct MergeContext { + /// Whether an in-progress merge is detected in the repository. pub in_progress: bool, } @@ -560,9 +566,13 @@ pub async fn git_create_branch( } #[derive(serde::Serialize)] +/// Compact repository snapshot used by quick status views. pub struct RepoSummary { + /// Absolute repository worktree path. path: String, + /// Current branch name, or `HEAD` when detached. current_branch: String, + /// Normalized local/remote branch list. branches: Vec, } diff --git a/Backend/src/tauri_commands/general.rs b/Backend/src/tauri_commands/general.rs index 1cae78b2..cd7a8ec4 100644 --- a/Backend/src/tauri_commands/general.rs +++ b/Backend/src/tauri_commands/general.rs @@ -22,8 +22,11 @@ use super::progress_bridge; const WIKI_URL: &str = "https://github.com/jordonbc/OpenVCS/wiki"; #[derive(serde::Serialize)] +/// Event payload emitted after selecting/opening a repository. struct RepoSelectedPayload { + /// Selected repository path. path: String, + /// Backend identifier that opened the repository. backend: String, } @@ -322,8 +325,11 @@ pub fn current_repo_path(state: State<'_, AppState>) -> Option { } #[derive(serde::Serialize)] +/// Serializable recent-repository item for frontend rendering. pub struct RecentRepoDto { + /// Absolute repository path. path: String, + /// Last path segment used as a display name when available. name: Option, } diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 4ae716a8..0649d51d 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -155,18 +155,28 @@ fn emit_ssh_prompt(app: &tauri::AppHandle, remote: &str, url: &st } #[derive(Clone, serde::Serialize)] +/// UI event payload requesting unknown-host-key confirmation. struct SshHostKeyPrompt { + /// Remote host name requiring trust confirmation. host: String, + /// Remote alias involved in the failed operation. remote: String, + /// Remote URL associated with the host. url: String, + /// Raw backend error message. message: String, } #[derive(Clone, serde::Serialize)] +/// UI event payload requesting SSH authentication troubleshooting. struct SshAuthPrompt { + /// Remote host that rejected authentication. host: String, + /// Remote alias involved in the failed operation. remote: String, + /// Remote URL associated with the host. url: String, + /// Raw backend error message. message: String, } @@ -477,9 +487,13 @@ pub async fn git_pull( } #[derive(serde::Serialize)] +/// Pull execution result returned to the frontend. pub struct PullResult { + /// Whether a pull operation ran and updated local refs. pub pulled: bool, + /// Branch name evaluated for the pull. pub branch: String, + /// Skip/failure context when no pull was performed. pub reason: Option, } diff --git a/Backend/src/tauri_commands/shared.rs b/Backend/src/tauri_commands/shared.rs index 667f16d1..b72737dd 100644 --- a/Backend/src/tauri_commands/shared.rs +++ b/Backend/src/tauri_commands/shared.rs @@ -12,7 +12,9 @@ use crate::repo::Repo; use crate::state::AppState; #[derive(serde::Serialize, Clone)] +/// Generic progress event payload sent to the UI. pub struct ProgressPayload { + /// Human-readable progress message. pub message: String, } diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index f6b828ee..66079de0 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -38,9 +38,13 @@ fn ensure_ssh_dir() -> Result { } #[derive(Clone, Serialize)] +/// Process output captured from SSH-related shell commands. pub struct SshCommandOutput { + /// Process exit code, or `-1` when unavailable. pub code: i32, + /// UTF-8-decoded standard output. pub stdout: String, + /// UTF-8-decoded standard error. pub stderr: String, } @@ -162,8 +166,11 @@ pub fn ssh_agent_list_keys() -> Result { } #[derive(Clone, Serialize)] +/// Candidate private-key file discovered in `~/.ssh`. pub struct SshKeyCandidate { + /// Absolute path to the candidate key file. pub path: String, + /// File name shown in the UI. pub name: String, } diff --git a/Backend/src/utilities/mod.rs b/Backend/src/utilities/mod.rs index a9a0f69c..6bfab2d3 100644 --- a/Backend/src/utilities/mod.rs +++ b/Backend/src/utilities/mod.rs @@ -1,4 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +//! Utility helpers exposed by the backend crate. + +/// Internal utility helper module. pub mod inner; +/// Back-compat re-export of utility helper module. pub use inner as utilities; diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index 1b0bbcf6..02e4d18a 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -13,6 +13,7 @@ import { hydrateStatus } from './hydrate'; import { getVisibleFiles, updateSelectAllState } from './selectionState'; import { openMergeModal, hasExternalMergeTool, launchExternalMergeTool } from '../conflicts'; +/** Scrolls the current diff viewport back to the origin. */ function scrollDiffToTop() { if (!diffEl) return; const host = diffEl.closest('.diff-scroll') as HTMLElement | null; @@ -23,12 +24,14 @@ function scrollDiffToTop() { } } +/** Regex markers that identify non-textual Git patches. */ const BINARY_DIFF_INDICATORS = [ /^binary files /i, /^git binary patch/i, /^literal /i, ]; +/** Returns true when the diff payload should be treated as binary. */ function detectBinaryDiff(lines: string[] = []) { if (!Array.isArray(lines) || lines.length === 0) { return true; @@ -42,11 +45,13 @@ function detectBinaryDiff(lines: string[] = []) { ); } +/** Renders a placeholder hunk for binary or unsupported file types. */ function renderBinaryDiffPlaceholder(path?: string) { const label = path ? ` (${escapeHtml(path)})` : ''; return `
Diff not supported on this file type${label}.
`; } +/** Builds a synthetic unified diff for an untracked text file. */ function buildUntrackedTextPatch(path: string, text: string): string[] { const normalized = String(text || '').replace(/\r\n/g, '\n'); const body = normalized.length ? normalized.split('\n') : []; @@ -62,11 +67,13 @@ function buildUntrackedTextPatch(path: string, text: string): string[] { return out; } +/** Highlights a row in the left list for the current tab. */ export function highlightRow(index: number) { const rows = qsa((prefs.tab === 'history' ? '.row.commit' : '.row'), listEl || (undefined as any)); rows.forEach((el, i) => el.classList.toggle('active', i === index)); } +/** Loads and renders the selected file diff with selection state restored. */ export async function selectFile(file: FileStatus, index: number) { if (!diffHeadPath || !diffEl) return; if (!state.diffDirty && state.currentFile === file.path) { @@ -235,6 +242,7 @@ export async function selectFile(file: FileStatus, index: number) { } } +/** Loads and renders a stash diff in read-only mode. */ export async function selectStashDiff(selector: string) { if (!diffHeadPath || !diffEl) return; diffEl.innerHTML = '
Loading…
'; @@ -254,6 +262,7 @@ export async function selectStashDiff(selector: string) { } } +/** Renders diffs for multiple files in a single combined view. */ export async function renderCombinedDiff(paths: string[]) { if (!diffHeadPath || !diffEl) return; clearActiveRows(); @@ -280,6 +289,7 @@ export async function renderCombinedDiff(paths: string[]) { scrollDiffToTop(); } +/** Clears multi-file diff selection state from the list. */ export function clearDiffSelection() { if (!listEl) return; if (state.diffSelectedFiles && state.diffSelectedFiles.size > 0) { @@ -289,12 +299,14 @@ export function clearDiffSelection() { } } +/** Removes active styling from all rows in the file list. */ export function clearActiveRows() { if (!listEl) return; const rows = listEl.querySelectorAll('li.row.active'); rows.forEach((r) => r.classList.remove('active')); } +/** Loads and renders conflict details and resolution actions. */ async function renderConflictView(file: FileStatus) { if (!diffEl) return; state.currentFile = file.path; @@ -322,6 +334,7 @@ async function renderConflictView(file: FileStatus) { } } +/** Builds conflict view markup for text or binary conflicts. */ function renderConflictMarkup(details: ConflictDetails) { const binary = !!details.binary; const header = `
Merge conflict
${renderConflictActions(binary)}
`; @@ -330,6 +343,7 @@ function renderConflictMarkup(details: ConflictDetails) { return `
${header}${body}
`; } +/** Renders conflict action buttons based on conflict content type. */ function renderConflictActions(binary: boolean) { const mergeBtn = binary ? '' : ''; return `
@@ -339,11 +353,13 @@ function renderConflictActions(binary: boolean) {
`; } +/** Renders a compact binary-conflict explanation panel. */ function renderBinaryConflictBody(details: ConflictDetails) { const note = 'This file is binary. Choose which version to keep.'; return `
${escapeHtml(note)}
`; } +/** Renders side-by-side panes for textual conflict content. */ function renderTextConflictBody(details: ConflictDetails) { return `
${renderConflictPane('Mine', details.ours)} @@ -351,6 +367,7 @@ function renderTextConflictBody(details: ConflictDetails) {
`; } +/** Renders one labeled conflict pane section. */ function renderConflictPane(label: string, value?: string | null) { const safeLabel = escapeHtml(label); const hasText = typeof value === 'string' && value.length > 0; @@ -360,6 +377,7 @@ function renderConflictPane(label: string, value?: string | null) { return `
${safeLabel}
${body}
`; } +/** Wires conflict action buttons to backend commands and UI refreshes. */ function bindConflictActions(root: HTMLElement, file: FileStatus, details: ConflictDetails) { const container = root.querySelector('.conflict-view') as HTMLElement | null; if (!container) return; @@ -404,6 +422,7 @@ function bindConflictActions(root: HTMLElement, file: FileStatus, details: Confl } } +/** Clears picked styling and checkboxes for all visible rows. */ function clearAllFileSelections() { if (!listEl) return; const rows = listEl.querySelectorAll('li.row'); @@ -414,6 +433,7 @@ function clearAllFileSelections() { }); } +/** Toggles commit inclusion for a file and syncs current hunk selection. */ export function toggleFilePick(path: string, on: boolean) { if (!path) return; disableDefaultSelectAll(); @@ -457,6 +477,7 @@ export function toggleFilePick(path: string, on: boolean) { updateCommitButton(); } +/** Reconciles hunk and line checkbox UI with in-memory selection state. */ export function updateHunkCheckboxes() { const nodes = state.currentDiffHunkNodes; if (!nodes || nodes.size === 0) return; @@ -490,14 +511,17 @@ export function updateHunkCheckboxes() { }); } +/** Tracks whether delegated diff checkbox handlers are already bound. */ let diffToggleHandlerBound = false; +/** Binds delegated change handling for hunk and line toggles once. */ function bindHunkToggles(root: HTMLElement) { if (!root || diffToggleHandlerBound) return; root.addEventListener('change', handleDiffInputChange); diffToggleHandlerBound = true; } +/** Routes checkbox changes to hunk-level or line-level handlers. */ function handleDiffInputChange(ev: Event) { const target = ev.target as HTMLInputElement | null; if (!target || !(target instanceof HTMLInputElement)) return; @@ -508,6 +532,7 @@ function handleDiffInputChange(ev: Event) { } } +/** Applies selection updates for a single hunk toggle interaction. */ function handleHunkToggle(input: HTMLInputElement) { const idx = Number(input.dataset.hunk || -1); if (!state.currentFile || idx < 0) return; @@ -546,6 +571,7 @@ function handleHunkToggle(input: HTMLInputElement) { updateCommitButton(); } +/** Applies selection updates for a single changed line toggle. */ function handleLineToggle(input: HTMLInputElement) { const hunk = Number(input.dataset.hunk || -1); const line = Number(input.dataset.line || -1); @@ -587,6 +613,7 @@ function handleLineToggle(input: HTMLInputElement) { updateCommitButton(); } +/** Syncs the file-level checkbox to current hunk and line selections. */ function syncFileCheckboxWithHunks() { if (!state.currentFile) return; if (state.currentDiffBinary) { @@ -622,6 +649,7 @@ function syncFileCheckboxWithHunks() { } } +/** Returns contiguous hunk indices derived from unified diff lines. */ export function allHunkIndices(lines: string[]) { const meta = state.currentDiffMeta; if (meta && meta.totalHunks > 0) { @@ -635,6 +663,7 @@ export function allHunkIndices(lines: string[]) { return starts.map((_, i) => i); } +/** Parses diff lines into reusable metadata for hunk rendering. */ function buildDiffMeta(lines: string[]): DiffMeta { const idx = lines.findIndex((l) => (l || '').startsWith('@@')); const rest = idx >= 0 ? lines.slice(idx) : []; @@ -668,6 +697,7 @@ function buildDiffMeta(lines: string[]): DiffMeta { }; } +/** Builds a DOM fragment for diff hunks and caches node references. */ function buildDiffFragment(lines: string[]): DocumentFragment { const meta = buildDiffMeta(lines); state.currentDiffMeta = meta; @@ -759,6 +789,7 @@ function buildDiffFragment(lines: string[]): DocumentFragment { return fragment; } +/** Renders diff hunks as HTML with selectable hunk and line checkboxes. */ export function renderHunksWithSelection(lines: string[]) { if (!lines || !lines.length) return ''; let idx = lines.findIndex((l) => l.startsWith('@@')); @@ -787,6 +818,7 @@ export function renderHunksWithSelection(lines: string[]) { return html; } +/** Renders diff hunks as static, read-only HTML. */ export function renderHunksReadonly(lines: string[]) { if (!lines || !lines.length) return ''; let idx = lines.findIndex((l) => l.startsWith('@@')); @@ -808,12 +840,14 @@ export function renderHunksReadonly(lines: string[]) { return html; } +/** Renders one read-only diff line row. */ function hline(ln: string, n: number) { const first = (typeof ln === 'string' ? ln[0] : ' ') || ' '; const t = first === '+' ? 'add' : first === '-' ? 'del' : ''; return `
${n}
${escapeHtml(String(ln))}
`; } +/** Updates a list row checkbox for a specific file path. */ export function updateListCheckboxForPath(path: string, checked: boolean, indeterminate: boolean) { if (!listEl || !path) return; const selector = `li.row[data-path="${path.replace(/(["\\])/g, '\\$1')}"] input.pick`; diff --git a/Frontend/src/scripts/features/repo/history.test.ts b/Frontend/src/scripts/features/repo/history.test.ts index 68edf65b..b4dc0919 100644 --- a/Frontend/src/scripts/features/repo/history.test.ts +++ b/Frontend/src/scripts/features/repo/history.test.ts @@ -2,9 +2,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later import { describe, it, expect } from 'vitest' -// Provide matchMedia to avoid jsdom environment errors in modules that access it -// Set it on global before importing modules that may use it. -(globalThis as any).matchMedia = (query: string) => ({ matches: false, media: query, addListener: () => {}, removeListener: () => {} }) +/** Provides a minimal `matchMedia` test shim used by history imports. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, removeListener: () => {} } +} + +// Set matchMedia before importing modules that touch browser media APIs. +(globalThis as any).matchMedia = createMatchMediaMock import { parseCommitDiffByFile, formatTimeAgo } from './history' diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index 315a82e9..158d23c4 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -12,8 +12,22 @@ import { hydrateStatus, hydrateCommits } from './hydrate'; import { updateCommitButton } from './commit'; import { openCherryPick } from '../cherryPick'; +/** Optional toolbar button that opens the selected commit actions menu. */ const historyActionsBtn = document.getElementById('history-actions-btn') as HTMLButtonElement | null; +/** Optional flags that customize commit actions menu contents. */ +type CommitActionsMenuOptions = { + isAhead?: boolean; +}; + +/** Parsed commit diff block grouped by file path. */ +export type CommitDiffFile = { + path: string; + status: string; + lines: string[]; +}; + +/** Toggles visibility of commit actions UI in history mode. */ function updateHistoryActionsVisibility() { if (!historyActionsBtn) return; const on = prefs.tab === 'history' && !!(state as any)?.selectedCommit?.id; @@ -21,7 +35,8 @@ function updateHistoryActionsVisibility() { historyActionsBtn.disabled = !on; } -async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: { isAhead?: boolean }) { +/** Builds and shows the commit context menu at screen coordinates. */ +async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: CommitActionsMenuOptions) { const items: CtxItem[] = []; items.push({ label: 'Copy hash', action: async () => { @@ -106,6 +121,7 @@ if (historyActionsBtn && !(historyActionsBtn as any).__wired) { window.addEventListener('app:tab-changed', () => updateHistoryActionsVisibility()); } +/** Renders commit rows filtered by search text and selects the first entry. */ export function renderHistoryList(query: string): boolean { const list = listEl; const count = countEl; @@ -181,6 +197,7 @@ export function renderHistoryList(query: string): boolean { return true; } +/** Loads metadata and per-file diff details for the selected commit. */ export async function selectHistory(commit: any, index: number) { if (!diffHeadPath || !diffEl) return; (state as any).selectedCommit = commit || null; @@ -236,6 +253,7 @@ export async function selectHistory(commit: any, index: number) { const sideEl = diffEl.querySelector('.commit-files'); const contentEl = diffEl.querySelector('.commit-content'); if (sideEl && contentEl) { + /** Switches the right panel to the selected file diff block. */ const selectCommitFile = (idx: number) => { if (idx < 0 || idx >= files.length) return; sideEl.querySelectorAll('.row').forEach((r) => r.classList.remove('active')); @@ -311,9 +329,10 @@ export async function selectHistory(commit: any, index: number) { } } -export function parseCommitDiffByFile(lines: string[]): { path: string; status: string; lines: string[] }[] { +/** Splits a full commit diff payload into file-scoped diff blocks. */ +export function parseCommitDiffByFile(lines: string[]): CommitDiffFile[] { if (!Array.isArray(lines) || lines.length === 0) return []; - const files: { path: string; status: string; lines: string[] }[] = []; + const files: CommitDiffFile[] = []; let i = 0; while (i < lines.length) { const l = lines[i] || ''; @@ -340,6 +359,7 @@ export function parseCommitDiffByFile(lines: string[]): { path: string; status: return files; } +/** Formats a timestamp-like value into a compact relative time string. */ export function formatTimeAgo(isoMaybe: string): string { try { const d = new Date(String(isoMaybe || '').trim()); diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 0026ec0d..0a07de03 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -13,6 +13,7 @@ import { selectFile, renderCombinedDiff, clearDiffSelection, clearActiveRows, to import { hydrateStatus, hydrateStash } from './hydrate'; import { getVisibleFiles, updateSelectAllState } from './selectionState'; +/** Handles click selection behavior for a file row. */ export function onFileClick(e: MouseEvent, file: FileStatus, index: number, visible: FileStatus[]) { if (dragState.suppressNextClick) { dragState.suppressNextClick = false; @@ -63,6 +64,7 @@ export function onFileClick(e: MouseEvent, file: FileStatus, index: number, visi updateCommitButton(); } +/** Starts drag-selection for diff or commit selection gestures. */ export function onFileMouseDown(e: MouseEvent, file: FileStatus, index: number, visible: FileStatus[], li: HTMLElement) { if (e.button !== 0) return; const mode = e.shiftKey ? 'diff' : (e.ctrlKey || e.metaKey) ? 'commit' : null; @@ -126,6 +128,7 @@ export function onFileMouseDown(e: MouseEvent, file: FileStatus, index: number, document.addEventListener('mouseup', onUp, { once: true }); } +/** Applies one row selection change for the active drag mode. */ export function applySelect(path: string, on: boolean, rowEl: HTMLElement | null, visible: FileStatus[], mode: 'diff' | 'commit') { disableDefaultSelectAll(); if (mode === 'commit') { @@ -140,6 +143,7 @@ export function applySelect(path: string, on: boolean, rowEl: HTMLElement | null } } +/** Recomputes drag selection state for the current cursor range. */ export function updateDragRange(visible: FileStatus[]) { if (!dragState.isDragSelecting || dragState.dragMode === null) return; const list = listEl; @@ -186,6 +190,7 @@ export function updateDragRange(visible: FileStatus[]) { } } +/** Toggles commit selection for all visible files. */ export function toggleSelectAll(on: boolean, visible: FileStatus[]) { if (on) { visible.forEach((f) => { if (f.path) toggleFilePick(f.path, true); }); @@ -194,6 +199,7 @@ export function toggleSelectAll(on: boolean, visible: FileStatus[]) { } } +/** Opens the context menu for one file row and current selection. */ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { ev.preventDefault(); const x = ev.clientX, y = ev.clientY; @@ -208,6 +214,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { selectedPaths.length === 1 && !state.selectionImplicitAll; const items: CtxItem[] = []; + /** Opens the stash modal pre-filled for the provided paths. */ const openStashForPaths = (paths: string[], defaultMessage: string) => { if (!paths.length) return; openStashConfirm({ @@ -301,19 +308,25 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { buildCtxMenu(items, x, y); } +/** Optional callback that re-renders the left list. */ let renderListCallback: (() => void) | null = null; + +/** Registers a callback used after operations that refresh list state. */ export function setRenderListCallback(fn: () => void) { renderListCallback = fn; } +/** Returns true while drag selection is currently active. */ export function isDragSelecting() { return dragState.isDragSelecting; } +/** Stores the latest drag cursor index for range updates. */ export function setDragCurrentIndex(index: number) { dragState.dragCurrentIndex = index; } +/** Re-renders list state after shift-range toggling and reselects target row. */ function renderListAfterRangeSelect(file: FileStatus) { renderListCallback?.(); const refreshed = getVisibleFiles(); @@ -322,6 +335,7 @@ function renderListAfterRangeSelect(file: FileStatus) { updateCommitButton(); } +/** Applies active-row styling by index in the current list. */ function highlightRow(index: number) { const rows = listEl?.querySelectorAll('li.row'); rows?.forEach((el, i) => el.classList.toggle('active', i === index)); diff --git a/Frontend/src/scripts/features/repo/stash.ts b/Frontend/src/scripts/features/repo/stash.ts index 1fe1fbc2..57f16fdc 100644 --- a/Frontend/src/scripts/features/repo/stash.ts +++ b/Frontend/src/scripts/features/repo/stash.ts @@ -10,14 +10,26 @@ import { diffEl, diffHeadPath, listEl, countEl, leftFootEl, undoLeftBtn } from ' import { highlightRow, selectStashDiff } from './diffView'; import { hydrateStatus, hydrateStash } from './hydrate'; +/** Lazily created footer container for stash actions. */ let stashFootEl: HTMLElement | null = null; +/** True once stash footer button handlers are wired. */ let stashFootBound = false; +/** Optional callback used to refresh the stash list. */ let renderListRef: (() => void) | null = null; +/** Minimal stash list item shape used by selection helpers. */ +type StashListItem = { + selector: string; + msg?: string; + meta?: string; +}; + +/** Registers a list render callback used after stash mutations. */ export function setRenderListRef(fn: () => void) { renderListRef = fn; } +/** Renders stash entries filtered by the provided query. */ export function renderStashList(query: string): boolean { const list = listEl; const count = countEl; @@ -29,6 +41,7 @@ export function renderStashList(query: string): boolean { const items = stash.filter((s) => !query || (s.msg || '').toLowerCase().includes(query) || (s.selector || '').includes(query)); count.textContent = `${items.length} stash${items.length === 1 ? '' : 'es'}`; + /** Enables or disables footer action buttons for stash operations. */ const enableActionButtons = (enabled: boolean) => { const a = document.querySelector('#stash-apply-btn'); if (a) a.disabled = !enabled; const p = document.querySelector('#stash-pop-btn'); if (p) p.disabled = !enabled; @@ -90,7 +103,8 @@ export function renderStashList(query: string): boolean { return true; } -export async function selectStash(item: { selector: string; msg?: string; meta?: string }, index: number) { +/** Selects a stash entry and loads its diff preview. */ +export async function selectStash(item: StashListItem, index: number) { if (!diffHeadPath || !diffEl) return; highlightRow(index); state.currentStash = item.selector; @@ -108,6 +122,7 @@ export async function selectStash(item: { selector: string; msg?: string; meta?: } } +/** Shows the stash-specific footer controls and hides undo UI. */ export function showStashFooter() { if (!leftFootEl) return; const foot = ensureStashFooterControls(); @@ -118,6 +133,7 @@ export function showStashFooter() { foot.classList.add('show'); } +/** Hides stash footer controls and restores default footer state. */ export function hideStashFooter() { if (!leftFootEl) return; if (leftFootEl.dataset.mode === 'stash') { @@ -128,6 +144,7 @@ export function hideStashFooter() { if (stashFootEl) stashFootEl.classList.remove('show'); } +/** Returns the currently active stash selector from state or list row. */ export function getActiveStashSelector(): string { if (state.currentStash) return state.currentStash; const active = listEl?.querySelector('li.row.commit.active'); @@ -136,6 +153,7 @@ export function getActiveStashSelector(): string { return sel; } +/** Creates stash footer controls on demand and wires handlers once. */ function ensureStashFooterControls(): HTMLElement | null { if (!leftFootEl) return null; if (!stashFootEl) { @@ -157,6 +175,7 @@ function ensureStashFooterControls(): HTMLElement | null { return stashFootEl; } +/** Binds click handlers for create/apply/pop/drop stash actions. */ function wireStashFooterButtons(container: HTMLElement) { const createBtn = container.querySelector('#stash-create-btn'); createBtn?.addEventListener('click', () => { diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index 32db698a..08d27080 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -5,6 +5,7 @@ import { notify } from './lib/notify'; import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import type { GlobalSettings, Json, ThemePayload, ThemeSummary } from './types'; +/** Describes plugin metadata returned by discovery endpoints. */ export interface PluginSummary { id: string; name: string; @@ -20,11 +21,13 @@ export interface PluginSummary { icon_data_url?: string; } +/** Holds a plugin manifest summary with optional UI module code. */ export interface PluginPayload { summary: PluginSummary; entry?: string | null; } +/** Enumerates supported lifecycle hook names for plugin callbacks. */ export type HookName = | 'preCommit' | 'onCommit' | 'postCommit' | 'prePush' | 'onPush' | 'postPush' @@ -32,6 +35,7 @@ export type HookName = | 'preBranchCreate' | 'onBranchCreate' | 'postBranchCreate' | 'preBranchDelete' | 'onBranchDelete' | 'postBranchDelete'; +/** Carries hook execution data and cancellation controls. */ export interface HookContext { name: HookName; data: T; @@ -40,21 +44,26 @@ export interface HookContext { reason?: string; } +/** Defines a hook callback signature used by plugin registrations. */ export type HookHandler = (ctx: HookContext) => void | Promise; +/** Defines a generic plugin action callback signature. */ export type PluginAction = (payload?: unknown) => void | Promise; +/** Represents a plugin-provided menu entry. */ export interface PluginMenuItem { label: string; action: string; title?: string; } +/** Represents a plugin-provided titlebar action button. */ export interface PluginTitleButton { label: string; action: string; title?: string; } +/** Represents a plugin-provided settings section descriptor. */ export interface PluginSettingsSection { id: string; label: string; @@ -64,6 +73,7 @@ export interface PluginSettingsSection { onMount?: (ctx: { modal: HTMLElement; panel: HTMLElement }) => void; } +/** Represents a plugin-provided menubar menu contribution. */ export interface PluginMenubarMenu { id: string; html: string; @@ -71,20 +81,24 @@ export interface PluginMenubarMenu { after?: string; } +/** Lists context menu targets supported by plugin contributions. */ export type PluginContextMenuTarget = 'files' | 'commits' | 'branches'; +/** Represents a single context menu command contributed by a plugin. */ export interface PluginContextMenuItem { label: string; action: string; title?: string; } +/** Groups context menu contributions by target surface. */ export interface PluginContextMenus { files?: PluginContextMenuItem[]; commits?: PluginContextMenuItem[]; branches?: PluginContextMenuItem[]; } +/** Defines the full plugin registration payload accepted by the host. */ export interface PluginRegistration { id?: string; name?: string; @@ -135,10 +149,12 @@ let initialized = false; let disabledPlugins = new Set(); let enabledPlugins = new Set(); +/** Normalizes ids for case-insensitive map keys. */ function normalizeId(value: string): string { return String(value || '').trim().toLowerCase(); } +/** Checks whether a plugin is enabled after overrides are applied. */ function isPluginEnabled(summary: PluginSummary): boolean { const id = normalizeId(summary?.id || ''); if (!id) return false; @@ -147,6 +163,7 @@ function isPluginEnabled(summary: PluginSummary): boolean { return !!summary?.default_enabled; } +/** Removes all injected plugin script nodes. */ function clearPluginScripts() { while (PLUGIN_SCRIPT_NODES.length) { const node = PLUGIN_SCRIPT_NODES.pop(); @@ -154,6 +171,7 @@ function clearPluginScripts() { } } +/** Clears all plugin runtime registries and injected UI. */ function resetPluginRuntime() { clearPluginScripts(); @@ -176,6 +194,7 @@ function resetPluginRuntime() { if (host) host.replaceChildren(); } +/** Injects a plugin module into the document head. */ function injectPluginModule(code: string, pluginId: string) { const head = document.head; if (!head) return; @@ -190,6 +209,7 @@ function injectPluginModule(code: string, pluginId: string) { PLUGIN_SCRIPT_NODES.push(script); } +/** Removes tracked UI nodes that belong to a plugin. */ function clearPluginUi(pluginId: string) { const nodes = pluginUiNodes.get(pluginId) || []; for (const node of nodes) { @@ -198,20 +218,24 @@ function clearPluginUi(pluginId: string) { pluginUiNodes.delete(pluginId); } +/** Tracks host-inserted UI nodes for plugin cleanup. */ function trackUiNode(pluginId: string, node: HTMLElement) { const list = pluginUiNodes.get(pluginId) || []; list.push(node); pluginUiNodes.set(pluginId, list); } +/** Returns the plugins menu list element. */ function pluginsMenuList(): HTMLElement | null { return document.getElementById('plugins-menu-list'); } +/** Returns the titlebar plugin action host element. */ function pluginActionsHost(): HTMLElement | null { return document.getElementById('plugin-title-actions'); } +/** Ensures a disabled placeholder row exists when no plugin menu items exist. */ function ensurePluginsMenuPlaceholder() { const list = pluginsMenuList(); if (!list) return; @@ -227,24 +251,28 @@ function ensurePluginsMenuPlaceholder() { list.appendChild(btn); } +/** Removes the plugin menu placeholder row. */ function removePluginsMenuPlaceholder() { pluginsMenuList() ?.querySelector('[data-openvcs-plugin-placeholder="true"]') ?.remove(); } +/** Registers a plugin action handler by id. */ function registerAction(id: string, handler: PluginAction) { const key = String(id || '').trim(); if (!key) return; actionHandlers.set(key, handler); } +/** Registers a lifecycle hook handler for a plugin. */ function registerHook(pluginId: string, name: HookName, handler: HookHandler) { const list = hookHandlers.get(name) || []; list.push({ pluginId, handler }); hookHandlers.set(name, list); } +/** Registers a theme payload exposed by a plugin. */ function registerTheme(theme: ThemePayload) { const id = normalizeId(theme?.summary?.id || ''); if (!id) return; @@ -254,12 +282,14 @@ function registerTheme(theme: ThemePayload) { } } +/** Registers theme summary metadata exposed by a plugin. */ function registerThemeSummary(summary: ThemeSummary) { const id = normalizeId(summary?.id || ''); if (!id) return; registeredThemeSummaries.set(id, summary); } +/** Adds a plugin item to the plugins menu list. */ function addMenuItem(pluginId: string, item: PluginMenuItem) { const list = pluginsMenuList(); if (!list) return; @@ -279,6 +309,7 @@ function addMenuItem(pluginId: string, item: PluginMenuItem) { trackUiNode(pluginId, btn); } +/** Adds a plugin action button to the titlebar host. */ function addTitlebarButton(pluginId: string, btn: PluginTitleButton) { const host = pluginActionsHost(); if (!host) return; @@ -295,6 +326,7 @@ function addTitlebarButton(pluginId: string, btn: PluginTitleButton) { trackUiNode(pluginId, el); } +/** Inserts or updates a plugin-provided settings section. */ function upsertSettingsSection(pluginId: string, section: PluginSettingsSection) { const id = String(section?.id || '').trim(); const label = String(section?.label || '').trim(); @@ -311,6 +343,7 @@ function upsertSettingsSection(pluginId: string, section: PluginSettingsSection) if (modal) applyPluginSettingsSections(modal); } +/** Inserts or updates a plugin-provided menubar menu. */ function applyMenubarMenu(pluginId: string, menu: PluginMenubarMenu) { const id = String(menu?.id || '').trim(); const html = String(menu?.html || ''); @@ -341,6 +374,7 @@ function applyMenubarMenu(pluginId: string, menu: PluginMenubarMenu) { trackUiNode(pluginId, node); } +/** Resolves plugin id during global API registration callbacks. */ function currentPluginIdForRegistration(explicit?: string): string | null { const id = String(explicit || '').trim(); if (id) return id; @@ -348,6 +382,7 @@ function currentPluginIdForRegistration(explicit?: string): string | null { return ctxId || null; } +/** Registers plugin hooks, actions, menus, and theme contributions. */ function registerPlugin(reg: PluginRegistration) { const pluginId = currentPluginIdForRegistration(reg?.id) || null; if (!pluginId) return; @@ -426,6 +461,7 @@ function registerPlugin(reg: PluginRegistration) { } } +/** Installs the `window.OpenVCS` plugin registration API once. */ function installGlobalApi() { if (window.OpenVCS) return; const callPluginMethod = ( @@ -479,6 +515,7 @@ function installGlobalApi() { } } +/** Renders plugin-provided settings sections inside the settings modal. */ export function applyPluginSettingsSections(modal?: HTMLElement | null): void { const m = modal || (document.getElementById('settings-modal') as HTMLElement | null); if (!m) return; @@ -563,15 +600,18 @@ export function applyPluginSettingsSections(modal?: HTMLElement | null): void { } } +/** Returns registered theme summaries from loaded plugins. */ export function getRegisteredThemeSummaries(): ThemeSummary[] { return Array.from(registeredThemeSummaries.values()); } +/** Returns a registered theme payload by id, if available. */ export function getRegisteredThemePayload(id: string): ThemePayload | null { const key = normalizeId(id); return registeredThemePayloads.get(key) || null; } +/** Executes all handlers registered for a lifecycle hook. */ export async function runHook(name: HookName, data: T): Promise> { const ctx: HookContext = { name, @@ -598,6 +638,7 @@ export async function runHook(name: HookName, data: T): Promise { const id = String(actionId || '').trim(); if (!id) return false; @@ -612,12 +653,14 @@ export async function runPluginAction(actionId: string, payload?: unknown): Prom return true; } +/** Returns plugin-contributed context menu items for a target surface. */ export function getPluginContextMenuItems( target: PluginContextMenuTarget, ): PluginContextMenuItem[] { return (contextMenuItems.get(target) || []).slice(); } +/** Loads plugin manifests and installs plugin UI/runtime state. */ export async function initPlugins(): Promise { if (initialized) return; initialized = true; @@ -668,6 +711,7 @@ export async function initPlugins(): Promise { ensurePluginsMenuPlaceholder(); } +/** Reloads plugins by resetting and reinitializing the plugin runtime. */ export async function reloadPlugins(): Promise { installGlobalApi(); if (!TAURI.has) return; diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index f0b9c437..23c3284d 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -94,6 +94,11 @@ export const statusLabel = (s: string) => s === 'M' ? 'Modified' : s === 'D' ? 'Deleted' : 'Changed'; +/** + * Get CSS class token for a file status code. + * @param s - Status code character + * @returns CSS class suffix used by status badges + */ export const statusClass = (s: string) => s === 'A' ? 'add' : s === '?' ? 'untracked' : @@ -105,8 +110,11 @@ export const statusClass = (s: string) => s === 'M' ? 'mod' : s === 'D' ? 'del' : 'mod'; -// Disable the implicit "select all" mode. When clearImplicit is true, drop the -// auto-filled selection set so later logic only sees explicit user picks. +/** + * Disables implicit select-all behavior. + * @param clearImplicit - Whether to clear auto-filled file selections + * @returns True when implicit selections were cleared + */ export function disableDefaultSelectAll(clearImplicit = false): boolean { const hadImplicit = state.defaultSelectAll && state.selectionImplicitAll; if (clearImplicit && hadImplicit) { diff --git a/Frontend/src/scripts/themes.ts b/Frontend/src/scripts/themes.ts index 14e0347f..b9357162 100644 --- a/Frontend/src/scripts/themes.ts +++ b/Frontend/src/scripts/themes.ts @@ -28,10 +28,12 @@ let activeScripts: string[] = []; let currentMode: 'system' | 'light' | 'dark' = 'system'; let systemListenerInstalled = false; +/** Resolves the current system appearance mode from media query state. */ function effectiveSystemMode(): 'light' | 'dark' { return SYSTEM_DARK_MQ.matches ? 'dark' : 'light'; } +/** Checks whether an id refers to one of the built-in defaults. */ function isBuiltInDefaultThemeId(id: string): boolean { const desired = String(id ?? '').trim().toLowerCase(); return ( @@ -41,22 +43,26 @@ function isBuiltInDefaultThemeId(id: string): boolean { ); } +/** Resolves the built-in default theme id for a display mode. */ function defaultThemeIdForMode(mode: 'system' | 'light' | 'dark'): string { const target = mode === 'system' ? effectiveSystemMode() : mode; return target === 'dark' ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID; } +/** Normalizes theme appearance metadata from external sources. */ function normalizeAppearance(value: unknown): 'light' | 'dark' | 'both' | null { const raw = String(value ?? '').trim().toLowerCase(); if (raw === 'light' || raw === 'dark' || raw === 'both') return raw; return null; } +/** Finds a loaded theme summary by id. */ function getThemeSummary(id: string): ThemeSummary | null { const desired = String(id || DEFAULT_THEME_ID).trim().toLowerCase() || DEFAULT_THEME_ID; return availableThemes.find((t) => (t.id || '').toLowerCase() === desired) ?? null; } +/** Returns a paired theme id when switching between light and dark variants. */ function resolvePairedThemeId(id: string): string | null { const desired = String(id || DEFAULT_THEME_ID).trim() || DEFAULT_THEME_ID; const summary = getThemeSummary(desired); @@ -84,6 +90,7 @@ function resolvePairedThemeId(id: string): string | null { return null; } +/** Broadcasts a theme-pack change event to the UI. */ function dispatchThemeChanged() { try { window.dispatchEvent(new CustomEvent('openvcs:theme-pack-changed', { detail: { id: activeThemeId } })); @@ -92,6 +99,7 @@ function dispatchThemeChanged() { } } +/** Installs a system color-scheme listener once. */ function ensureSystemListener() { if (systemListenerInstalled) return; systemListenerInstalled = true; @@ -106,6 +114,7 @@ function ensureSystemListener() { }); } +/** Builds the fallback generic default theme summary. */ function defaultSummary(): ThemeSummary { return { id: DEFAULT_THEME_ID, @@ -115,6 +124,7 @@ function defaultSummary(): ThemeSummary { }; } +/** Builds the fallback built-in light theme summary. */ function defaultLightSummary(): ThemeSummary { return { id: DEFAULT_LIGHT_THEME_ID, @@ -126,6 +136,7 @@ function defaultLightSummary(): ThemeSummary { }; } +/** Builds the fallback built-in dark theme summary. */ function defaultDarkSummary(): ThemeSummary { return { id: DEFAULT_DARK_THEME_ID, @@ -137,6 +148,7 @@ function defaultDarkSummary(): ThemeSummary { }; } +/** Sanitizes externally provided theme summary fields. */ function sanitizeSummary(raw: ThemeSummary): ThemeSummary { const id = String(raw?.id ?? '').trim() || DEFAULT_THEME_ID; const base: ThemeSummary = { @@ -152,6 +164,7 @@ function sanitizeSummary(raw: ThemeSummary): ThemeSummary { return base; } +/** Creates, updates, or removes a style tag by id. */ function setStyleContent(id: string, css: string | null | undefined) { const existing = document.getElementById(id) as HTMLStyleElement | null; const text = typeof css === 'string' ? css : ''; @@ -172,6 +185,7 @@ function setStyleContent(id: string, css: string | null | undefined) { target.textContent = text; } +/** Syncs the active theme-pack id onto the document root attribute. */ function syncThemePackAttr() { const root = document.documentElement; if (!root) return; @@ -187,6 +201,7 @@ function syncThemePackAttr() { root.setAttribute(THEME_PACK_ATTR, current); } +/** Applies active markup snippets to head and body. */ function applyMarkupNodes() { const markup = activeMarkup ?? null; const headHtml = markup?.head ?? null; @@ -195,6 +210,7 @@ function applyMarkupNodes() { setMarkupForTarget(document.body, BODY_MARKUP_NODES, bodyHtml); } +/** Replaces tracked markup nodes for a target container. */ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: string | null | undefined) { const parent = target ?? null; if (!parent) return; @@ -210,6 +226,7 @@ function setMarkupForTarget(target: ParentNode | null, store: ChildNode[], html: store.push(...nodes); } +/** Rebuilds theme-provided script nodes from the active payload. */ function applyScriptNodes() { while (THEME_SCRIPT_NODES.length) { const node = THEME_SCRIPT_NODES.pop(); @@ -233,6 +250,7 @@ function applyScriptNodes() { }); } +/** Removes tracked DOM nodes from the document. */ function clearNodes(store: ChildNode[]) { while (store.length) { const node = store.pop(); @@ -240,6 +258,7 @@ function clearNodes(store: ChildNode[]) { } } +/** Applies currently selected theme assets for the requested appearance mode. */ function applyModeStyles(mode: 'system' | 'light' | 'dark') { currentMode = mode; ensureSystemListener(); @@ -252,6 +271,7 @@ function applyModeStyles(mode: 'system' | 'light' | 'dark') { dispatchThemeChanged(); } +/** Resolves the id written to the root theme-pack attribute. */ function resolveThemePackAttrId(summary: ThemeSummary | null | undefined, themeId: string): string { const rawId = String(summary?.id ?? themeId ?? DEFAULT_THEME_ID).trim() || DEFAULT_THEME_ID; const pluginId = String(summary?.plugin_id ?? '').trim(); @@ -263,18 +283,22 @@ function resolveThemePackAttrId(summary: ThemeSummary | null | undefined, themeI return rawId; } +/** Returns the current list of available theme summaries. */ export function getAvailableThemes(): ThemeSummary[] { return [...availableThemes]; } +/** Returns the currently active theme id. */ export function getActiveThemeId(): string { return activeThemeId; } +/** Returns the current appearance mode used by theme rendering. */ export function getCurrentMode(): 'system' | 'light' | 'dark' { return currentMode; } +/** Refreshes available themes from backend and plugin registries. */ export async function refreshAvailableThemes(): Promise { const pluginSummaries = getRegisteredThemeSummaries(); if (!TAURI.has) { @@ -340,6 +364,7 @@ export async function refreshAvailableThemes(): Promise { return availableThemes; } +/** Ensures theme metadata has been loaded at least once. */ export async function ensureThemesLoaded(force?: boolean): Promise { if (!fetchedThemes || force) { return refreshAvailableThemes(); @@ -347,6 +372,7 @@ export async function ensureThemesLoaded(force?: boolean): Promise; +/** Represents branch kind metadata reported by the backend. */ export interface BranchKind { type?: 'Local' | 'Remote' | string; remote?: string; } +/** Represents a Git branch entry used in branch pickers. */ export interface Branch { name: string; full_ref?: string; @@ -13,6 +16,7 @@ export interface Branch { kind?: BranchKind; } +/** Represents a file status row in the Changes view. */ export interface FileStatus { path: string; old_path?: string; @@ -22,6 +26,7 @@ export interface FileStatus { hunks?: string[]; } +/** Represents merge conflict payload data for a file. */ export interface ConflictDetails { path: string; ours?: string | null; @@ -30,6 +35,7 @@ export interface ConflictDetails { binary?: boolean; } +/** Represents a commit list item for History. */ export interface CommitItem { id: string; msg?: string; @@ -39,18 +45,21 @@ export interface CommitItem { remoteRef?: string; } +/** Represents a stash list item for the Stash tab. */ export interface StashItem { selector: string; // e.g., "stash@{0}" msg?: string; meta?: string; // date string } +/** Represents persisted local UI preferences. */ export interface AppPrefs { theme: 'dark' | 'light'; leftW: number; // px tab: 'changes' | 'history' | 'stash'; } +/** Represents global settings loaded from the backend. */ export interface GlobalSettings { general?: { theme?: 'system'|'dark'|'light'; @@ -113,6 +122,7 @@ export interface GlobalSettings { }; } +/** Represents theme metadata shown in settings. */ export interface ThemeSummary { id: string; name: string; @@ -125,6 +135,7 @@ export interface ThemeSummary { plugin_id?: string; } +/** Represents the full theme package payload. */ export interface ThemePayload { summary: ThemeSummary; styles?: string | null; @@ -135,6 +146,7 @@ export interface ThemePayload { scripts?: string[]; } +/** Represents repository-local identity and remote settings. */ export interface RepoSettings { user_name?: string; user_email?: string; diff --git a/Frontend/src/scripts/vite-raw.d.ts b/Frontend/src/scripts/vite-raw.d.ts index 8e0ff041..4d53f9c5 100644 --- a/Frontend/src/scripts/vite-raw.d.ts +++ b/Frontend/src/scripts/vite-raw.d.ts @@ -1,4 +1,6 @@ +/** Declares Vite raw-loader imports for modal HTML templates. */ declare module '*.html?raw' { + /** Contains raw file contents as a string. */ const content: string; export default content; } diff --git a/Frontend/src/setupTests.ts b/Frontend/src/setupTests.ts index acebced6..d63222ad 100644 --- a/Frontend/src/setupTests.ts +++ b/Frontend/src/setupTests.ts @@ -1,7 +1,9 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later // Test setup: provide browser shims used by frontend modules -(globalThis as any).matchMedia = (query: string) => ({ +/** Provides a deterministic `matchMedia` mock for Vitest. */ +function createMatchMediaMock(query: string) { + return { matches: false, media: query, addListener: () => {}, @@ -10,5 +12,7 @@ addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => false, -}) + } +} +(globalThis as any).matchMedia = createMatchMediaMock From 1b46bc0f095906b8ad897c55d3f07e35a3b1d26c Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 19:13:22 +0000 Subject: [PATCH 31/96] Added code comments --- Frontend/src/scripts/features/commandSheet.ts | 4 ++++ Frontend/src/scripts/main.ts | 3 +++ Frontend/src/scripts/ui/layout.ts | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/Frontend/src/scripts/features/commandSheet.ts b/Frontend/src/scripts/features/commandSheet.ts index 8b5f0378..2376beb8 100644 --- a/Frontend/src/scripts/features/commandSheet.ts +++ b/Frontend/src/scripts/features/commandSheet.ts @@ -6,6 +6,7 @@ import { notify } from "../lib/notify"; import { openModal, closeModal, hydrate } from "../ui/modals"; import { refreshRepoSummary } from "./repoSelection"; +/** Available command-sheet tabs. */ type Which = "clone" | "add"; // Elements inside the modal @@ -107,6 +108,7 @@ function setSheet(which: Which) { positionIndicator(); } +/** Opens the command sheet and selects an initial tab. */ export function openSheet(which: Which = "clone") { openModal("command-modal"); if (!root) bindCommandSheet(); @@ -120,10 +122,12 @@ export function openSheet(which: Which = "clone") { requestAnimationFrame(positionIndicator); } +/** Closes the command sheet modal. */ export function closeSheet() { closeModal("command-modal"); } +/** Wires command-sheet controls and action handlers once. */ export function bindCommandSheet() { // Inject the fragment if not already present hydrate("command-modal"); diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index cbfec236..ddf86441 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -41,6 +41,7 @@ const undoLeftBtn = qs('#undo-left-btn'); let fetchCloseTimer: number | null = null; const FETCH_CLOSE_MS = 130; +/** Closes the Fetch/Pull popover, optionally with a short close animation. */ function closeFetchPopover() { if (!fetchPop || !fetchCaret) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -58,6 +59,7 @@ function closeFetchPopover() { }, FETCH_CLOSE_MS); } +/** Closes transient UI surfaces before a repo switch or hard refresh. */ function forceCloseTransientUi() { closeAllModals(); closeSheet(); @@ -76,6 +78,7 @@ function forceCloseTransientUi() { window.dispatchEvent(new CustomEvent('app:repo-will-switch')); } +/** Boots the frontend shell, wires handlers, and hydrates initial state. */ async function boot() { // If launched as the Output Log window, render that view and skip the main app UI. if (await initOutputLogViewIfRequested()) return; diff --git a/Frontend/src/scripts/ui/layout.ts b/Frontend/src/scripts/ui/layout.ts index ffd9db54..8452bede 100644 --- a/Frontend/src/scripts/ui/layout.ts +++ b/Frontend/src/scripts/ui/layout.ts @@ -12,6 +12,7 @@ const SYSTEM_DARK_MQ = matchMedia('(prefers-color-scheme: dark)'); let systemSyncActive = false; let systemSyncWired = false; +/** Wires a single listener that keeps system-theme mode in sync. */ function ensureSystemSyncListener() { if (systemSyncWired) return; systemSyncWired = true; @@ -34,6 +35,7 @@ const repoTitleEl = qs('#repo-title'); const repoBranchEl = qs('#repo-branch'); const aheadBehindEl = qs('#ahead-behind'); +/** Applies a requested appearance mode to the document and persisted prefs. */ export function setTheme(theme: 'dark'|'light'|'system') { const root = document.documentElement; ensureSystemSyncListener(); @@ -52,6 +54,7 @@ export function setTheme(theme: 'dark'|'light'|'system') { savePrefs(); } +/** Toggles between light and dark appearance modes. */ export function toggleTheme() { const next = (prefs.theme === 'dark' ? 'light' : 'dark'); // Persist to native settings when available, then apply to UI @@ -71,6 +74,7 @@ export function toggleTheme() { } } +/** Switches the active center tab and updates related UI state. */ export function setTab(tab: 'changes'|'history'|'stash') { const prevTab = prefs.tab; prefs.tab = tab; savePrefs(); @@ -112,10 +116,12 @@ export function setTab(tab: 'changes'|'history'|'stash') { window.dispatchEvent(new CustomEvent('app:tab-changed', { detail: tab })); } +/** Binds tab button clicks to an external change handler. */ export function bindTabs(onChange: (t: 'changes'|'history'|'stash') => void) { tabs.forEach(btn => btn.addEventListener('click', () => onChange((btn.dataset.tab as any) ?? 'changes'))); } +/** Enables drag-resizing for the work grid split view. */ export function initResizer() { if (!workGrid || !resizer) return; @@ -174,6 +180,7 @@ export function initResizer() { }); } +/** Recomputes enablement and labels for repo-scoped UI actions. */ export function refreshRepoActions() { const repoOn = hasRepo(); const changesOn = hasChanges(); @@ -240,6 +247,7 @@ export function refreshRepoActions() { } } +/** Binds layout action refresh handlers to app lifecycle events. */ export function bindLayoutActionState() { // Recompute on repo selection, status refresh, branch changes, and typing (when enabled) window.addEventListener('app:repo-selected', refreshRepoActions); From 33433a7394c24a97eb844be29b9d18214b0a821a Mon Sep 17 00:00:00 2001 From: Jordon Date: Mon, 16 Feb 2026 19:40:50 +0000 Subject: [PATCH 32/96] Update --- Backend/src/lib.rs | 1 + .../src/plugin_runtime/component_instance.rs | 24 ++++++++++++++---- Backend/src/plugin_runtime/manager.rs | 24 ++++++++++++++++++ Backend/src/tauri_commands/plugins.rs | 25 +++++++++++++++++++ Frontend/src/scripts/features/settings.ts | 17 ++++++++++++- 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 5d0a5f31..16889584 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -271,6 +271,7 @@ fn build_invoke_handler( tauri_commands::install_ovcsp, tauri_commands::list_installed_bundles, tauri_commands::uninstall_plugin, + tauri_commands::set_plugin_enabled, tauri_commands::approve_plugin_capabilities, tauri_commands::list_plugin_functions, tauri_commands::invoke_plugin_function, diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 5fb1b14f..f978cd96 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1,5 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use std::sync::OnceLock; + use crate::plugin_runtime::host_api::{ host_emit_event, host_process_exec_git, host_runtime_info, host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, @@ -11,9 +13,21 @@ use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; use wasmtime::component::{Component, Linker, ResourceTable}; -use wasmtime::{Engine, Store}; +use wasmtime::{Cache, CacheConfig, Config, Engine, Store}; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; +static WASMTIME_ENGINE: OnceLock = OnceLock::new(); + +fn get_wasmtime_engine() -> &'static Engine { + WASMTIME_ENGINE.get_or_init(|| { + let cache_config = CacheConfig::new(); + let cache = Cache::new(cache_config).expect("create wasmtime cache"); + let mut config = Config::default(); + config.cache(Some(cache)); + Engine::new(&config).expect("create wasmtime engine") + }) +} + mod bindings { wasmtime::component::bindgen!({ path: "../../Core/wit", @@ -202,10 +216,10 @@ impl ComponentPluginRuntimeInstance { /// Instantiates the component runtime and executes plugin initialization. fn instantiate_runtime(&self) -> Result { - let engine = Engine::default(); - let component = Component::from_file(&engine, &self.spawn.exec_path) + let engine = get_wasmtime_engine(); + let component = Component::from_file(engine, &self.spawn.exec_path) .map_err(|e| format!("load component {}: {e}", self.spawn.exec_path.display()))?; - let mut linker = Linker::new(&engine); + let mut linker = Linker::new(engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker) .map_err(|e| format!("link wasi imports: {e}"))?; bindings::Vcs::add_to_linker::< @@ -214,7 +228,7 @@ impl ComponentPluginRuntimeInstance { >(&mut linker, |state| state) .map_err(|e| format!("link host imports: {e}"))?; let mut store = Store::new( - &engine, + engine, ComponentHostState { spawn: self.spawn.clone(), table: ResourceTable::new(), diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 2328c09c..16fe8508 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -113,6 +113,30 @@ impl PluginRuntimeManager { } } + /// Ensures a plugin is running or stopped based on enabled state. + /// + /// This is more efficient than sync_plugin_runtime_with_config when + /// only one plugin's state has changed. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// - `enabled`: Whether the plugin should be running. + /// + /// # Returns + /// - `Ok(())` when the operation succeeds. + /// - `Err(String)` when the operation fails. + pub fn set_plugin_enabled(&self, plugin_id: &str, enabled: bool) -> Result<(), String> { + let key = normalize_plugin_key(plugin_id)?; + let is_running = self.processes.lock().contains_key(&key); + + if enabled && !is_running { + self.start_plugin(plugin_id)?; + } else if !enabled && is_running { + self.stop_plugin(plugin_id)?; + } + Ok(()) + } + /// Synchronizes runtime process state with current persisted plugin settings. /// /// # Returns diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index b414bf4f..4a498aa4 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -92,6 +92,31 @@ pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result PluginBundleStore::new_default().uninstall_plugin(&plugin_id) } +#[tauri::command] +/// Enables or disables a plugin without triggering a full runtime sync. +/// +/// This is more efficient than set_global_settings when only toggling +/// a single plugin's enabled state. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id to toggle. +/// - `enabled`: Whether the plugin should be enabled. +/// +/// # Returns +/// - `Ok(())` when the operation succeeds. +/// - `Err(String)` when the operation fails. +pub fn set_plugin_enabled( + state: State<'_, AppState>, + plugin_id: String, + enabled: bool, +) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + state + .plugin_runtime() + .set_plugin_enabled(&plugin_id, enabled) +} + #[tauri::command] /// Approves or denies requested capabilities for a plugin version. /// diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 75259ad2..3581fd70 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1288,6 +1288,20 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } }; + const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { + if (!TAURI.has) return; + try { + await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); + await reloadPlugins(); + await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); + try { + await refreshAvailableThemes(); + } catch {} + } catch { + notify('Failed to toggle plugin'); + } + }; + if (!(pane as any).__wired) { (pane as any).__wired = true; @@ -1476,6 +1490,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { if (!el || el.type !== 'checkbox' || !el.dataset.pluginId) return; const id = String(el.dataset.pluginId).trim().toLowerCase(); if (!id) return; + const wasEnabled = state.enabled.has(id); if (el.checked) { state.disabled.delete(id); state.enabled.add(id); @@ -1485,7 +1500,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } updateCounts(); renderDetails(getFiltered()); - persistPluginsDisabled().catch(() => {}); + persistSinglePluginToggle(id, el.checked).catch(() => {}); }); searchEl.addEventListener('input', () => { From d5d0c61d894166d2719b03f385160f8eedf3f4b8 Mon Sep 17 00:00:00 2001 From: Jordon Date: Tue, 17 Feb 2026 15:31:19 +0000 Subject: [PATCH 33/96] Update --- Backend/src/lib.rs | 1 + Backend/src/tauri_commands/output_log.rs | 42 +++++++++++++++++- Frontend/src/scripts/features/settings.ts | 1 + Frontend/src/scripts/lib/logger.ts | 52 +++++++++++++++++++++++ Frontend/src/scripts/main.ts | 23 +++++----- Frontend/src/scripts/ui/menubar.ts | 4 ++ 6 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 Frontend/src/scripts/lib/logger.ts diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 16889584..26b730ab 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -290,6 +290,7 @@ fn build_invoke_handler( tauri_commands::open_output_log_window, tauri_commands::get_output_log, tauri_commands::clear_output_log, + tauri_commands::log_frontend_message, tauri_commands::tail_app_log, tauri_commands::clear_app_log, tauri_commands::exit_app, diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index 5e408441..f244628b 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -2,8 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder, Window}; -use crate::output_log::OutputLogEntry; +use crate::output_log::{OutputLevel, OutputLogEntry}; use crate::state::AppState; +use log; /// Reads up to the last `max_lines` lines from a log file efficiently. /// @@ -76,6 +77,45 @@ pub fn clear_output_log(state: tauri::State<'_, AppState>) { state.clear_output_log(); } +#[tauri::command] +/// Handles log messages from the frontend, forwarding them to the output log. +/// +/// # Parameters +/// - `state`: Shared application state. +/// - `level`: Log severity level ("debug", "info", "warn", "error"). +/// - `source`: Source subsystem (e.g., "ui", "plugin"). +/// - `message`: Log message text. +/// +/// # Returns +/// - `()`. +pub fn log_frontend_message( + state: tauri::State<'_, AppState>, + level: String, + source: String, + message: String, +) { + let (output_level, log_level) = match level.to_lowercase().as_str() { + "debug" | "trace" => (OutputLevel::Info, log::Level::Trace), + "info" => (OutputLevel::Info, log::Level::Info), + "warn" | "warning" => (OutputLevel::Warn, log::Level::Warn), + "error" | "err" => (OutputLevel::Error, log::Level::Error), + _ => (OutputLevel::Info, log::Level::Info), + }; + + log::log!(log_level, "[{}] {}", source, message); + + let entry = OutputLogEntry::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0), + output_level, + source, + message, + ); + state.push_output_log(entry); +} + #[tauri::command] /// Reads and returns recent lines from `logs/openvcs.log`. /// diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 3581fd70..22e1e4c9 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -79,6 +79,7 @@ async function rebuildThemePackOptions( } export function openSettings(section?: string){ + console.log('Opening settings modal', section ? `section: ${section}` : ''); openModal('settings-modal'); const modal = document.getElementById('settings-modal') as HTMLElement | null; if (!modal) return; diff --git a/Frontend/src/scripts/lib/logger.ts b/Frontend/src/scripts/lib/logger.ts new file mode 100644 index 00000000..e5e390ff --- /dev/null +++ b/Frontend/src/scripts/lib/logger.ts @@ -0,0 +1,52 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { TAURI } from "./tauri"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +function formatMessage(...args: unknown[]): string { + return args + .map((a) => { + if (a instanceof Error) return a.message; + if (typeof a === "object") { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + } + return String(a); + }) + .join(" "); +} + +function sendToBackend(level: LogLevel, source: string, message: string): void { + if (TAURI.has) { + TAURI.invoke("log_frontend_message", { level, source, message }).catch(() => {}); + } +} + +function installFrontendLogger(): void { + console.debug = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("debug", "ui", msg); + }; + + console.log = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("debug", "ui", msg); + }; + + console.warn = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("warn", "ui", msg); + }; + + console.error = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("error", "ui", msg); + }; +} + +installFrontendLogger(); diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index ddf86441..85feb0a0 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -1,5 +1,6 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +import './lib/logger'; import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; @@ -332,23 +333,25 @@ async function boot() { async function runMenuAction(id?: string | null) { switch (id) { - case 'clone_repo': openSheet('clone'); break; - case 'add_repo': openSheet('add'); break; - case 'open_repo': openSwitchDrawer(); break; - case 'fetch': await defaultFetchAction(); break; - case 'push': await pushChanges(); break; - case 'commit': commitBtn?.click(); break; - case 'docs': await openDocs(); break; + case 'clone_repo': console.log('Action: clone_repo'); openSheet('clone'); break; + case 'add_repo': console.log('Action: add_repo'); openSheet('add'); break; + case 'open_repo': console.log('Action: open_repo'); openSwitchDrawer(); break; + case 'fetch': console.log('Action: fetch'); await defaultFetchAction(); break; + case 'push': console.log('Action: push'); await pushChanges(); break; + case 'commit': console.log('Action: commit'); commitBtn?.click(); break; + case 'docs': console.log('Action: docs'); await openDocs(); break; case 'show-output-log': + console.log('Action: show-output-log'); if (!TAURI.has) { notify('Output Log is available in the desktop app'); break; } try { await TAURI.invoke('open_output_log_window', {}); } catch { notify('Failed to open Output Log'); } break; - case 'about': openAbout(); break; - case 'settings': openSettings(); break; - case 'repo-settings': openRepoSettings(); break; + case 'about': console.log('Action: about'); openAbout(); break; + case 'settings': console.log('Action: settings'); openSettings(); break; + case 'repo-settings': console.log('Action: repo-settings'); openRepoSettings(); break; case 'repo-edit-gitignore': case 'repo-edit-gitattributes': { + console.log('Action:', id); if (!TAURI.has) { notify('Open this in the desktop app to edit repository files'); break; } const name = id === 'repo-edit-gitignore' ? '.gitignore' : '.gitattributes'; try { await TAURI.invoke('open_repo_dotfile', { name }); } diff --git a/Frontend/src/scripts/ui/menubar.ts b/Frontend/src/scripts/ui/menubar.ts index d2aebee2..6be704d7 100644 --- a/Frontend/src/scripts/ui/menubar.ts +++ b/Frontend/src/scripts/ui/menubar.ts @@ -60,9 +60,13 @@ export function initMenubar(onAction: MenuAction) { if (!list || !trigger) return; const isOpen = !list.hasAttribute('hidden'); if (isOpen) { + const menuName = trigger.textContent || 'menu'; + console.log(`UI: Close ${menuName} menu`); closeMenus(); return; } + const menuName = trigger.textContent || 'menu'; + console.log(`UI: Open ${menuName} menu`); list.classList.remove('is-closing'); list.removeAttribute('hidden'); trigger.setAttribute('aria-expanded', 'true'); From 20a40c3809fa9bb83ba91ba3e0cdbcec956b09d8 Mon Sep 17 00:00:00 2001 From: Jordon Date: Tue, 17 Feb 2026 15:48:51 +0000 Subject: [PATCH 34/96] Improve logging --- Backend/src/logging.rs | 44 ++++++++++++++++++++++-- Backend/src/tauri_commands/output_log.rs | 25 +++++++++----- Frontend/src/scripts/lib/logger.ts | 12 +++---- 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index a44527c1..162f0243 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -27,6 +27,26 @@ pub fn clear_active_log_file() -> Result<(), String> { Ok(()) } +/// Writes a line directly to the active log file. +/// +/// # Parameters +/// - `line`: The line to write (without trailing newline). +/// +/// # Returns +/// - `Ok(())` if the line was written. +/// - `Err(String)` if writing fails or log file not initialized. +pub fn write_to_log(line: &str) -> Result<(), String> { + let Some(file) = ACTIVE_LOG_FILE.get() else { + return Ok(()); + }; + let mut f = file + .lock() + .map_err(|_| "log file lock poisoned".to_string())?; + writeln!(f, "{}", line).map_err(|e| e.to_string())?; + f.flush().map_err(|e| e.to_string())?; + Ok(()) +} + /// Initialize logging: console (env_logger) + append to `./logs/openvcs.log`. /// Respects `RUST_LOG` for filtering; sets a sensible default if missing. /// @@ -80,9 +100,29 @@ pub fn init() { } } - // Build console logger (with timestamps) and then mirror to a file if possible. + // Build console logger with custom format let mut builder = env_logger::Builder::from_default_env(); - builder.format_timestamp_millis(); + builder.format(|buf, record| { + use std::io::Write; + let ts = record.level(); + let target = record.target(); + let args = record.args(); + + // Format: [YYYY-MM-DD] [HH:MM:SS] LEVEL [SOURCE]: message + let now = time::OffsetDateTime::now_utc(); + let date = format!( + "{:04}-{:02}-{:02}", + now.year(), + now.month() as u8, + now.day() + ); + let time = format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()); + + // Extract source from target (e.g., "openvcs_lib::tauri_commands::output_log" -> "output_log") + let source = target.split("::").last().unwrap_or(target).to_uppercase(); + + writeln!(buf, "[{}] [{}] {:5} [{}]: {}", date, time, ts, source, args) + }); // Wasmtime/Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. // Keep these at WARN+ even if the user enables a global TRACE filter. diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index f244628b..5a236369 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -88,12 +88,7 @@ pub fn clear_output_log(state: tauri::State<'_, AppState>) { /// /// # Returns /// - `()`. -pub fn log_frontend_message( - state: tauri::State<'_, AppState>, - level: String, - source: String, - message: String, -) { +pub fn log_frontend_message(state: tauri::State<'_, AppState>, level: String, message: String) { let (output_level, log_level) = match level.to_lowercase().as_str() { "debug" | "trace" => (OutputLevel::Info, log::Level::Trace), "info" => (OutputLevel::Info, log::Level::Info), @@ -102,7 +97,21 @@ pub fn log_frontend_message( _ => (OutputLevel::Info, log::Level::Info), }; - log::log!(log_level, "[{}] {}", source, message); + // Write directly to stderr with [FRONTEND] tag + let now = time::OffsetDateTime::now_utc(); + let timestamp = format!( + "[{}] [{}]", + format!( + "{:04}-{:02}-{:02}", + now.year(), + now.month() as u8, + now.day() + ), + format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()) + ); + let log_line = format!("{} {:5} [FRONTEND]: {}", timestamp, log_level, message); + eprintln!("{}", log_line); + let _ = crate::logging::write_to_log(&log_line); let entry = OutputLogEntry::new( std::time::SystemTime::now() @@ -110,7 +119,7 @@ pub fn log_frontend_message( .map(|d| d.as_millis() as i64) .unwrap_or(0), output_level, - source, + "frontend", message, ); state.push_output_log(entry); diff --git a/Frontend/src/scripts/lib/logger.ts b/Frontend/src/scripts/lib/logger.ts index e5e390ff..5cec6dd7 100644 --- a/Frontend/src/scripts/lib/logger.ts +++ b/Frontend/src/scripts/lib/logger.ts @@ -21,31 +21,31 @@ function formatMessage(...args: unknown[]): string { .join(" "); } -function sendToBackend(level: LogLevel, source: string, message: string): void { +function sendToBackend(level: LogLevel, message: string): void { if (TAURI.has) { - TAURI.invoke("log_frontend_message", { level, source, message }).catch(() => {}); + TAURI.invoke("log_frontend_message", { level, message }).catch(() => {}); } } function installFrontendLogger(): void { console.debug = (...args: unknown[]) => { const msg = formatMessage(...args); - sendToBackend("debug", "ui", msg); + sendToBackend("debug", msg); }; console.log = (...args: unknown[]) => { const msg = formatMessage(...args); - sendToBackend("debug", "ui", msg); + sendToBackend("debug", msg); }; console.warn = (...args: unknown[]) => { const msg = formatMessage(...args); - sendToBackend("warn", "ui", msg); + sendToBackend("warn", msg); }; console.error = (...args: unknown[]) => { const msg = formatMessage(...args); - sendToBackend("error", "ui", msg); + sendToBackend("error", msg); }; } From 4e22ebbbf1d2f57f95e5cd099da748486358c857 Mon Sep 17 00:00:00 2001 From: Jordon Date: Tue, 17 Feb 2026 17:03:48 +0000 Subject: [PATCH 35/96] Update logging.rs --- Backend/src/logging.rs | 120 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index 162f0243..cabd8b51 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -121,7 +121,125 @@ pub fn init() { // Extract source from target (e.g., "openvcs_lib::tauri_commands::output_log" -> "output_log") let source = target.split("::").last().unwrap_or(target).to_uppercase(); - writeln!(buf, "[{}] [{}] {:5} [{}]: {}", date, time, ts, source, args) + // For Debug/Trace level, try to pretty-print JSON-like content in messages + let msg = args.to_string(); + + let format_braced = |body: &str| { + let mut out = String::with_capacity(body.len() + 64); + let mut indent: usize = 0; + let mut in_string = false; + let mut escaped = false; + + for ch in body.chars() { + if in_string { + out.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => { + in_string = true; + out.push(ch); + } + '{' => { + out.push('{'); + indent += 1; + out.push('\n'); + out.push_str(&" ".repeat(indent)); + } + '}' => { + indent = indent.saturating_sub(1); + out.push('\n'); + out.push_str(&" ".repeat(indent)); + out.push('}'); + } + ',' => { + out.push(','); + out.push('\n'); + out.push_str(&" ".repeat(indent)); + } + _ => out.push(ch), + } + } + + out + }; + + let clean_label = |prefix: &str| { + let mut label = prefix.trim().trim_end_matches(':').trim().to_string(); + for suffix in ["Object", "RemoteRelease"] { + if let Some(stripped) = label.strip_suffix(suffix) { + label = stripped.trim().to_string(); + } + } + if label.is_empty() { + "payload".to_string() + } else { + label + } + }; + + // Check if message contains JSON-like structure that can be extracted and formatted. + if msg.len() > 100 && (ts == log::Level::Debug || ts == log::Level::Trace) { + if let (Some(start), Some(end)) = (msg.find('{'), msg.rfind('}')) { + let json_part = &msg[start..=end]; + + let regex = regex::Regex::new(r#"String\("([^"]*)"\)"#).ok(); + + // Strategy 1: strict conversion of common Rust debug wrappers. + let mut attempts: Vec = Vec::with_capacity(2); + let mut cleaned = json_part.replace("Object ", ""); + if let Some(re) = ®ex { + cleaned = re.replace_all(&cleaned, r#""$1""#).to_string(); + } + attempts.push(cleaned); + + // Strategy 2: aggressive conversion fallback for odd wrapper nesting. + let aggressive = json_part + .replace("Object ", "") + .replace("String(\"", "\"") + .replace("\")", "\""); + attempts.push(aggressive); + + for json_clean in attempts { + if let Ok(value) = serde_json::from_str::(&json_clean) { + if let Ok(pretty) = serde_json::to_string_pretty(&value) { + let label = clean_label(&msg[..start]); + let header = + format!("[{}] [{}] {:5} [{}]: {} ", date, time, ts, source, label); + let lines: Vec<&str> = pretty.lines().collect(); + return writeln!( + buf, + "{}{}", + header, + lines.join(&format!("\n{}", " ".repeat(header.len()))) + ); + } + } + } + + // Fallback: non-JSON Rust debug structs (e.g., RemoteRelease { ... }) + let label = clean_label(&msg[..start]); + let pretty = format_braced(json_part); + let header = format!("[{}] [{}] {:5} [{}]: {} ", date, time, ts, source, label); + let lines: Vec<&str> = pretty.lines().collect(); + return writeln!( + buf, + "{}{}", + header, + lines.join(&format!("\n{}", " ".repeat(header.len()))) + ); + } + } + + writeln!(buf, "[{}] [{}] {:5} [{}]: {}", date, time, ts, source, msg) }); // Wasmtime/Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. From 2c4fd20c299f122750220c83c33497d818d4aea6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Tue, 17 Feb 2026 17:23:19 +0000 Subject: [PATCH 36/96] Update logging --- Backend/src/plugin_runtime/manager.rs | 54 ++++++++++++++++++++-- Backend/src/settings.rs | 28 ++++++------ Backend/src/tauri_commands/output_log.rs | 16 +++---- Backend/src/tauri_commands/plugins.rs | 26 +++++++++-- Backend/src/tauri_commands/settings.rs | 57 +++++++++++++++++++++++- 5 files changed, 150 insertions(+), 31 deletions(-) diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 16fe8508..19a82b3d 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -5,6 +5,7 @@ use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_runtime_instance; use crate::plugin_runtime::spawn::SpawnConfig; use crate::settings::AppConfig; +use log::{info, warn}; use parking_lot::Mutex; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -82,7 +83,9 @@ impl PluginRuntimeManager { return existing.runtime.ensure_running(); } let spec = self.resolve_module_runtime_spec(plugin_id, None)?; - self.start_plugin_spec(spec) + self.start_plugin_spec(spec)?; + info!("plugin: started '{}'", plugin_id); + Ok(()) } /// Stops a plugin module process when present. @@ -101,15 +104,24 @@ impl PluginRuntimeManager { let process = self.processes.lock().remove(&key); if let Some(process) = process { process.runtime.stop(); + info!("plugin: stopped '{}'", plugin_id); } Ok(()) } /// Stops all running plugins. pub fn stop_all_plugins(&self) { - let running = std::mem::take(&mut *self.processes.lock()); - for (_, process) in running { - process.runtime.stop(); + let running: Vec = { + let mut processes = self.processes.lock(); + let keys: Vec = processes.keys().cloned().collect(); + for (_, process) in processes.iter_mut() { + process.runtime.stop(); + } + processes.clear(); + keys + }; + if !running.is_empty() { + info!("plugin: stopped all ({} plugins)", running.len()); } } @@ -131,8 +143,10 @@ impl PluginRuntimeManager { if enabled && !is_running { self.start_plugin(plugin_id)?; + info!("plugin: enabled '{}'", plugin_id); } else if !enabled && is_running { self.stop_plugin(plugin_id)?; + info!("plugin: disabled '{}'", plugin_id); } Ok(()) } @@ -160,6 +174,8 @@ impl PluginRuntimeManager { let mut desired_running = HashSet::new(); let mut errors = Vec::new(); + let before: Vec = self.processes.lock().keys().cloned().collect(); + for component in components { let plugin_id = component.plugin_id.trim(); if plugin_id.is_empty() || component.module.is_none() { @@ -184,9 +200,39 @@ impl PluginRuntimeManager { } } + let after: Vec = self.processes.lock().keys().cloned().collect(); + let started: Vec<&String> = after.iter().filter(|p| !before.contains(p)).collect(); + let stopped: Vec<&String> = before.iter().filter(|p| !after.contains(p)).collect(); + + if !started.is_empty() || !stopped.is_empty() { + let mut parts = Vec::new(); + if !started.is_empty() { + parts.push(format!( + "started: {}", + started + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + if !stopped.is_empty() { + parts.push(format!( + "stopped: {}", + stopped + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + info!("plugin: sync complete - {}", parts.join("; ")); + } + if errors.is_empty() { Ok(()) } else { + warn!("plugin: sync completed with errors: {}", errors.join("; ")); Err(errors.join("; ")) } } diff --git a/Backend/src/settings.rs b/Backend/src/settings.rs index ecc101e1..2dd721a6 100644 --- a/Backend/src/settings.rs +++ b/Backend/src/settings.rs @@ -70,7 +70,7 @@ impl Default for AppConfig { } /// Settings for app-wide behavior and startup UX. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct General { #[serde(default)] pub theme: Theme, @@ -120,7 +120,7 @@ fn default_theme_pack() -> String { } /// Settings that control Git backend behavior. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Git { #[serde(default)] pub backend: String, @@ -173,7 +173,7 @@ impl Default for Git { } /// Settings for authentication and signing tools. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Credentials { #[serde(default)] pub helper: CredentialHelper, @@ -207,7 +207,7 @@ impl Default for Credentials { } /// Settings that control diff rendering and external tools. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Diff { #[serde(default)] pub tab_width: u8, @@ -247,7 +247,7 @@ impl Default for Diff { } /// Git LFS behavior settings. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Lfs { #[serde(default)] pub enabled: bool, @@ -274,7 +274,7 @@ impl Default for Lfs { } /// Performance and animation tuning options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Performance { #[serde(default)] pub progressive_render: bool, @@ -298,7 +298,7 @@ impl Default for Performance { } /// Integrations with editors and issue providers. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Integrations { #[serde(default)] pub default_editor: EditorChoice, @@ -323,7 +323,7 @@ impl Default for Integrations { } /// Plugin enable/disable overrides. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Plugins { /// Plugin ids that are installed but disabled. /// @@ -338,7 +338,7 @@ pub struct Plugins { } /// User interface and accessibility options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Ux { #[serde(default)] pub ui_scale: f32, @@ -369,7 +369,7 @@ impl Default for Ux { } /// Advanced networking and force-push safety options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Advanced { #[serde(default)] pub confirm_force_push: ForcePushPolicy, @@ -393,7 +393,7 @@ impl Default for Advanced { } /// Experimental features that may change between releases. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Experimental { #[serde(default)] pub parallel_history_scan: bool, @@ -404,7 +404,7 @@ pub struct Experimental { } /// Logging verbosity and retention options. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Logging { #[serde(default)] pub level: LogLevel, @@ -527,7 +527,7 @@ pub enum WhitespaceMode { } /// Executable and arguments for an optional external diff/merge tool. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ExternalTool { #[serde(default)] pub enabled: bool, @@ -609,7 +609,7 @@ pub enum ForcePushPolicy { } /// HTTP proxy configuration used for network operations. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Proxy { #[serde(default)] pub mode: ProxyMode, diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index 5a236369..54e23532 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -4,7 +4,6 @@ use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder, Window}; use crate::output_log::{OutputLevel, OutputLogEntry}; use crate::state::AppState; -use log; /// Reads up to the last `max_lines` lines from a log file efficiently. /// @@ -100,14 +99,13 @@ pub fn log_frontend_message(state: tauri::State<'_, AppState>, level: String, me // Write directly to stderr with [FRONTEND] tag let now = time::OffsetDateTime::now_utc(); let timestamp = format!( - "[{}] [{}]", - format!( - "{:04}-{:02}-{:02}", - now.year(), - now.month() as u8, - now.day() - ), - format!("{:02}:{:02}:{:02}", now.hour(), now.minute(), now.second()) + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + now.year(), + now.month() as u8, + now.day(), + now.hour(), + now.minute(), + now.second() ); let log_line = format!("{} {:5} [FRONTEND]: {}", timestamp, log_level, message); eprintln!("{}", log_line); diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 4a498aa4..89e7660c 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -3,7 +3,7 @@ use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; use crate::plugins; use crate::state::AppState; -use log::warn; +use log::{info, warn}; use serde_json::Value; use tauri::Emitter; use tauri::Manager; @@ -49,6 +49,11 @@ pub async fn install_ovcsp( let store = PluginBundleStore::new_default(); let installed = store.install_ovcsp(std::path::Path::new(bundle_path.trim()))?; + info!( + "plugin: installed '{}' v{}", + installed.plugin_id, installed.version + ); + if !installed.requested_capabilities.is_empty() { let _ = window.app_handle().emit( "plugins:capabilities-requested", @@ -89,7 +94,9 @@ pub fn list_installed_bundles() -> Result, String> { pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result<(), String> { let plugin_id = plugin_id.trim().to_string(); state.plugin_runtime().stop_plugin(&plugin_id)?; - PluginBundleStore::new_default().uninstall_plugin(&plugin_id) + PluginBundleStore::new_default().uninstall_plugin(&plugin_id)?; + info!("plugin: uninstalled '{}'", plugin_id); + Ok(()) } #[tauri::command] @@ -135,7 +142,20 @@ pub fn approve_plugin_capabilities( approved: bool, ) -> Result<(), String> { let plugin_id = plugin_id.trim().to_string(); - PluginBundleStore::new_default().approve_capabilities(&plugin_id, version.trim(), approved)?; + let version = version.trim(); + PluginBundleStore::new_default().approve_capabilities(&plugin_id, version, approved)?; + + if approved { + info!( + "plugin: capabilities approved for '{}' v{}", + plugin_id, version + ); + } else { + info!( + "plugin: capabilities denied for '{}' v{}", + plugin_id, version + ); + } if !approved { let _ = state.plugin_runtime().stop_plugin(&plugin_id); diff --git a/Backend/src/tauri_commands/settings.rs b/Backend/src/tauri_commands/settings.rs index cf4e401d..86c7c012 100644 --- a/Backend/src/tauri_commands/settings.rs +++ b/Backend/src/tauri_commands/settings.rs @@ -1,6 +1,6 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use log::warn; +use log::{info, warn}; use std::collections::{HashMap, HashSet}; use tauri::State; @@ -10,6 +10,49 @@ use crate::state::AppState; use super::run_repo_task; +fn diff_configs(old_cfg: &AppConfig, new_cfg: &AppConfig) -> Vec { + let mut changes = Vec::new(); + + if old_cfg.general != new_cfg.general { + changes.push("general".to_string()); + } + if old_cfg.git != new_cfg.git { + changes.push("git".to_string()); + } + if old_cfg.credentials != new_cfg.credentials { + changes.push("credentials".to_string()); + } + if old_cfg.diff != new_cfg.diff { + changes.push("diff".to_string()); + } + if old_cfg.lfs != new_cfg.lfs { + changes.push("lfs".to_string()); + } + if old_cfg.performance != new_cfg.performance { + changes.push("performance".to_string()); + } + if old_cfg.integrations != new_cfg.integrations { + changes.push("integrations".to_string()); + } + if old_cfg.plugins != new_cfg.plugins { + changes.push("plugins".to_string()); + } + if old_cfg.ux != new_cfg.ux { + changes.push("ux".to_string()); + } + if old_cfg.advanced != new_cfg.advanced { + changes.push("advanced".to_string()); + } + if old_cfg.experimental != new_cfg.experimental { + changes.push("experimental".to_string()); + } + if old_cfg.logging != new_cfg.logging { + changes.push("logging".to_string()); + } + + changes +} + #[tauri::command] /// Returns global application settings. /// @@ -33,11 +76,22 @@ pub fn get_global_settings(state: State<'_, AppState>) -> Result, cfg: AppConfig) -> Result<(), String> { + let old_cfg = state.config(); state.set_config(cfg.clone())?; state .plugin_runtime() .sync_plugin_runtime_with_config(&cfg) .map_err(|err| format!("settings saved but plugin runtime sync failed: {err}"))?; + + let changes = diff_configs(&old_cfg, &cfg); + if changes.is_empty() { + info!("settings: global config saved (no changes detected)"); + } else { + info!( + "settings: global config updated - changed: {}", + changes.join(", ") + ); + } Ok(()) } @@ -163,5 +217,6 @@ pub async fn set_repo_settings(state: State<'_, AppState>, cfg: RepoConfig) -> R }) .await?; } + info!("settings: repository config updated"); Ok(()) } From a9794869a771acf58c3627a0ff451b31afaff8d2 Mon Sep 17 00:00:00 2001 From: Jordon Date: Tue, 17 Feb 2026 23:54:07 +0000 Subject: [PATCH 37/96] Added more logging --- Backend/src/logging.rs | 85 +++ Backend/src/plugin_bundles.rs | 215 +++++- Backend/src/plugin_runtime/host_api.rs | 328 ++++++++- Backend/src/plugin_runtime/vcs_proxy.rs | 881 +++++++++++++++++++++-- Backend/src/plugin_vcs_backends.rs | 265 ++++++- Backend/src/tauri_commands/conflicts.rs | 145 +++- Backend/src/tauri_commands/output_log.rs | 3 +- Backend/src/tauri_commands/ssh.rs | 200 ++++- Backend/src/tauri_commands/updater.rs | 72 +- Backend/src/utilities/utilities.rs | 59 +- Frontend/src/scripts/lib/logger.ts | 130 +++- 11 files changed, 2172 insertions(+), 211 deletions(-) diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index cabd8b51..2bec4a38 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -4,11 +4,96 @@ use crate::settings::{AppConfig, LogLevel}; use std::fs::{self, OpenOptions}; use std::io::{Seek, SeekFrom, Write}; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use time::{OffsetDateTime, UtcOffset}; use zip::{write::FileOptions, CompressionMethod, ZipWriter}; static ACTIVE_LOG_FILE: OnceLock>> = OnceLock::new(); +/// RAII timer that logs operation duration on drop. +/// +/// Use this to measure and log timing for long-running operations. +/// The duration is logged at trace level when the timer goes out of scope. +pub struct LogTimer { + start: Instant, + operation: &'static str, + module: &'static str, +} + +impl LogTimer { + /// Creates a new timer for the given operation. + /// + /// # Parameters + /// - `module`: Module/component name (e.g., "vcs_proxy", "ssh"). + /// - `operation`: Operation name (e.g., "fetch", "push"). + /// + /// # Returns + /// - A new `LogTimer` instance. + pub fn new(module: &'static str, operation: &'static str) -> Self { + Self { + start: Instant::now(), + operation, + module, + } + } + + /// Returns elapsed time in milliseconds. + /// + /// # Returns + /// - Elapsed time in milliseconds. + #[allow(dead_code)] + pub fn elapsed_ms(&self) -> u64 { + self.start.elapsed().as_millis() as u64 + } +} + +impl Drop for LogTimer { + fn drop(&mut self) { + let elapsed = self.start.elapsed(); + let ms = elapsed.as_millis(); + let us = elapsed.as_micros() - (ms * 1000); + log::trace!( + "[{}] {} completed in {}.{:03}ms", + self.module, + self.operation, + ms, + us + ); + } +} + +/// Logs an operation entry at info level. +/// +/// # Parameters +/// - `module`: Module/component name. +/// - `operation`: Operation name. +/// - `details`: Additional details string. +#[macro_export] +macro_rules! log_op_enter { + ($module:expr, $operation:expr) => { + log::info!("[{}] {}: starting", $module, $operation) + }; + ($module:expr, $operation:expr, $($arg:tt)*) => { + log::info!("[{}] {}: {}", $module, $operation, format!($($arg)*)) + }; +} + +/// Logs an operation exit at info level. +/// +/// # Parameters +/// - `module`: Module/component name. +/// - `operation`: Operation name. +/// - `details`: Additional details string. +#[macro_export] +macro_rules! log_op_exit { + ($module:expr, $operation:expr) => { + log::info!("[{}] {}: completed", $module, $operation) + }; + ($module:expr, $operation:expr, $($arg:tt)*) => { + log::info!("[{}] {}: {}", $module, $operation, format!($($arg)*)) + }; +} + /// Truncates the currently active `logs/openvcs.log` file in place. /// /// # Returns diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index c26f56a5..0b292a27 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -2,8 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Plugin bundle installation, indexing, and component discovery. +use crate::logging::LogTimer; use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; -use log::warn; +use log::{debug, error, info, trace, warn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -13,6 +14,8 @@ use std::path::{Component, Path, PathBuf}; use std::sync::OnceLock; use xz2::read::XzDecoder; +const MODULE: &str = "plugin_bundles"; + /// Safety and resource limits enforced during bundle extraction. #[derive(Debug, Clone, Copy)] pub struct InstallerLimits { @@ -238,6 +241,11 @@ impl PluginBundleStore { pub fn new_default() -> Self { let root = plugins_dir(); ensure_dir(&root); + trace!( + "[{}] PluginBundleStore::new_default: root={}", + MODULE, + root.display() + ); Self { root } } @@ -279,24 +287,70 @@ impl PluginBundleStore { bundle_path: &Path, limits: InstallerLimits, ) -> Result { + let _timer = LogTimer::new(MODULE, "install_ovcsp_with_limits"); + let start = std::time::Instant::now(); + info!( + "[{}] install_ovcsp_with_limits: bundle={}", + MODULE, + bundle_path.display() + ); + debug!( + "[{}] install_ovcsp_with_limits: limits={{max_files={}, max_file_bytes={}, max_total_bytes={}}}", + MODULE, limits.max_files, limits.max_file_bytes, limits.max_total_bytes + ); + if !bundle_path.is_file() { + error!( + "[{}] install_ovcsp_with_limits: bundle is not a file: {}", + MODULE, + bundle_path.display() + ); return Err(format!("bundle is not a file: {}", bundle_path.display())); } - fs::create_dir_all(&self.root) - .map_err(|e| format!("create {}: {e}", self.root.display()))?; + fs::create_dir_all(&self.root).map_err(|e| { + error!( + "[{}] install_ovcsp_with_limits: failed to create {}: {}", + MODULE, + self.root.display(), + e + ); + format!("create {}: {e}", self.root.display()) + })?; let bundle_sha256 = sha256_hex_file(bundle_path)?; let bundle_compressed_bytes = fs::metadata(bundle_path) - .map_err(|e| format!("metadata {}: {e}", bundle_path.display()))? + .map_err(|e| { + error!( + "[{}] install_ovcsp_with_limits: failed to get metadata: {}", + MODULE, e + ); + format!("metadata {}: {e}", bundle_path.display()) + })? .len(); + debug!( + "[{}] install_ovcsp_with_limits: bundle size={} bytes, sha256={}", + MODULE, + bundle_compressed_bytes, + &bundle_sha256[..12] + ); + let (manifest_bundle_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim().to_string(); if plugin_id.is_empty() { + error!( + "[{}] install_ovcsp_with_limits: manifest id is empty", + MODULE + ); return Err("manifest id is empty".to_string()); } + debug!( + "[{}] install_ovcsp_with_limits: plugin_id={}, version={:?}", + MODULE, plugin_id, manifest.version + ); + // Enforce that the top-level directory name matches the manifest id. let bundle_root = manifest_bundle_path .parent() @@ -305,6 +359,10 @@ impl PluginBundleStore { .unwrap_or_default() .to_string(); if bundle_root != plugin_id { + error!( + "[{}] install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", + MODULE, bundle_root, plugin_id + ); return Err(format!( "bundle root folder '{}' does not match manifest id '{}'", bundle_root, plugin_id @@ -324,6 +382,14 @@ impl PluginBundleStore { } fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?; let staging_version_dir = staging.join(&version); + + trace!( + "[{}] install_ovcsp_with_limits: staging_dir={}, plugin_dir={}", + MODULE, + staging_version_dir.display(), + plugin_dir.display() + ); + fs::create_dir_all(&staging_version_dir) .map_err(|e| format!("create {}: {e}", staging_version_dir.display()))?; @@ -496,6 +562,11 @@ impl PluginBundleStore { // Validate required files. let extracted_manifest = staging_version_dir.join(PLUGIN_MANIFEST_NAME); if !extracted_manifest.is_file() { + error!( + "[{}] install_ovcsp_with_limits: missing manifest at {}", + MODULE, + extracted_manifest.display() + ); return Err(format!( "installed bundle is missing {}", extracted_manifest.display() @@ -503,6 +574,10 @@ impl PluginBundleStore { } if manifest.functions.is_some() { + error!( + "[{}] install_ovcsp_with_limits: manifest uses deprecated 'functions' field", + MODULE + ); return Err( "manifest uses unsupported field 'functions'; use module.exec only".to_string(), ); @@ -512,12 +587,26 @@ impl PluginBundleStore { validate_entrypoint(&staging_version_dir, module_exec.as_deref(), "module")?; + debug!( + "[{}] install_ovcsp_with_limits: extracted {} files, promoting to final location", + MODULE, total_files + ); + // Promote staged version into place (flat layout, drop old version directory). if plugin_dir.exists() { + trace!( + "[{}] install_ovcsp_with_limits: removing existing plugin dir {}", + MODULE, + plugin_dir.display() + ); fs::remove_dir_all(&plugin_dir) .map_err(|e| format!("remove {}: {e}", plugin_dir.display()))?; } fs::rename(&staging_version_dir, &plugin_dir).map_err(|e| { + error!( + "[{}] install_ovcsp_with_limits: failed to move plugin into place: {}", + MODULE, e + ); format!( "move installed plugin into place {} -> {}: {e}", staging_version_dir.display(), @@ -554,6 +643,12 @@ impl PluginBundleStore { }, )?; + let elapsed = start.elapsed(); + info!( + "[{}] install_ovcsp_with_limits: installed plugin {} v{} in {:?}", + MODULE, plugin_id, version, elapsed + ); + Ok(InstalledPlugin { plugin_id, version, @@ -570,17 +665,43 @@ impl PluginBundleStore { /// - `Ok(())` when all built-in bundles are synchronized. /// - `Err(String)` when one or more bundles fail to sync. pub fn sync_built_in_plugins(&self) -> Result<(), String> { + let _timer = LogTimer::new(MODULE, "sync_built_in_plugins"); + let bundles = builtin_bundle_paths(); + info!( + "[{}] sync_built_in_plugins: syncing {} built-in bundles", + MODULE, + bundles.len() + ); + let mut errors = Vec::new(); - for bundle in builtin_bundle_paths() { - if let Err(err) = self.ensure_built_in_bundle(&bundle) { + for bundle in &bundles { + debug!( + "[{}] sync_built_in_plugins: checking {}", + MODULE, + bundle.display() + ); + if let Err(err) = self.ensure_built_in_bundle(bundle) { let msg = format!("{}: {}", bundle.display(), err); - warn!("plugins: failed to sync built-in bundle: {}", msg); + warn!( + "[{}] sync_built_in_plugins: failed to sync: {}", + MODULE, msg + ); errors.push(msg); } } + if errors.is_empty() { + debug!( + "[{}] sync_built_in_plugins: all bundles synced successfully", + MODULE + ); Ok(()) } else { + error!( + "[{}] sync_built_in_plugins: {} bundles failed", + MODULE, + errors.len() + ); Err(errors.join("; ")) } } @@ -594,24 +715,55 @@ impl PluginBundleStore { /// - `Ok(())` when bundle is already current or installed successfully. /// - `Err(String)` on install/validation failures. fn ensure_built_in_bundle(&self, bundle_path: &Path) -> Result<(), String> { + trace!( + "[{}] ensure_built_in_bundle: {}", + MODULE, + bundle_path.display() + ); + let bundle_sha256 = sha256_hex_file(bundle_path)?; let (_manifest_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { + error!( + "[{}] ensure_built_in_bundle: bundle manifest id is empty", + MODULE + ); return Err("bundle manifest id is empty".to_string()); } let plugin_id = plugin_id.to_string(); let version = derive_install_version(&manifest, &bundle_sha256); + if let Some(installed) = self.get_current_installed(&plugin_id)? { if installed.bundle_sha256 == bundle_sha256 && installed.version == version { + trace!( + "[{}] ensure_built_in_bundle: {} already installed and current", + MODULE, + plugin_id + ); return Ok(()); } + debug!( + "[{}] ensure_built_in_bundle: {} needs update (installed={}, new={})", + MODULE, plugin_id, installed.version, version + ); } + + debug!( + "[{}] ensure_built_in_bundle: installing {}", + MODULE, plugin_id + ); self.install_ovcsp_with_limits(bundle_path, InstallerLimits::default())?; + if let Err(err) = self.approve_capabilities(&plugin_id, &version, true) { warn!( - "plugins: failed to auto-approve built-in {} ({}): {}", - plugin_id, version, err + "[{}] ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", + MODULE, plugin_id, version, err + ); + } else { + debug!( + "[{}] ensure_built_in_bundle: auto-approved built-in {} ({})", + MODULE, plugin_id, version ); } Ok(()) @@ -626,19 +778,38 @@ impl PluginBundleStore { /// - `Ok(())` when the plugin is removed or not installed. /// - `Err(String)` if the id is invalid, built-in, or removal fails. pub fn uninstall_plugin(&self, plugin_id: &str) -> Result<(), String> { + let _timer = LogTimer::new(MODULE, "uninstall_plugin"); let id = plugin_id.trim(); + info!("[{}] uninstall_plugin: plugin={}", MODULE, id); + if id.is_empty() { + warn!("[{}] uninstall_plugin: empty plugin id", MODULE); return Err("plugin id is empty".to_string()); } let lower = id.to_ascii_lowercase(); if built_in_plugin_ids().contains(&lower) { + warn!( + "[{}] uninstall_plugin: cannot uninstall built-in plugin {}", + MODULE, id + ); return Err("built-in plugins cannot be removed".to_string()); } let dir = self.root.join(id); if !dir.exists() { + debug!("[{}] uninstall_plugin: plugin {} not installed", MODULE, id); return Ok(()); } - fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display())) + fs::remove_dir_all(&dir).map_err(|e| { + error!( + "[{}] uninstall_plugin: failed to remove {}: {}", + MODULE, + dir.display(), + e + ); + format!("remove {}: {e}", dir.display()) + })?; + debug!("[{}] uninstall_plugin: plugin {} removed", MODULE, id); + Ok(()) } /// Lists installed plugin indices discovered from the plugin store root. @@ -647,12 +818,27 @@ impl PluginBundleStore { /// - `Ok(Vec)` sorted by plugin id. /// - `Err(String)` when the plugin root cannot be read. pub fn list_installed(&self) -> Result, String> { + let _timer = LogTimer::new(MODULE, "list_installed"); + trace!( + "[{}] list_installed: scanning {}", + MODULE, + self.root.display() + ); + if !self.root.is_dir() { + debug!("[{}] list_installed: root does not exist", MODULE); return Ok(Vec::new()); } let mut out = Vec::new(); - let entries = - fs::read_dir(&self.root).map_err(|e| format!("read {}: {e}", self.root.display()))?; + let entries = fs::read_dir(&self.root).map_err(|e| { + error!( + "[{}] list_installed: failed to read {}: {}", + MODULE, + self.root.display(), + e + ); + format!("read {}: {e}", self.root.display()) + })?; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { @@ -669,6 +855,11 @@ impl PluginBundleStore { } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + debug!( + "[{}] list_installed: found {} installed plugins", + MODULE, + out.len() + ); Ok(out) } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index c3240265..2e3753c1 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -1,6 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use crate::logging::LogTimer; use crate::plugin_runtime::spawn::SpawnConfig; +use log::{debug, error, info, trace, warn}; use openvcs_core::app_api::PluginError; use serde_json::Value; use std::collections::HashSet; @@ -10,6 +12,8 @@ use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; +const MODULE: &str = "host_api"; + // Whitelisted environment variables that are forwarded to child Git processes. const SANITIZED_ENV_KEYS: &[&str] = &[ "HOME", @@ -82,7 +86,14 @@ fn host_error(code: &str, message: impl Into) -> PluginError { /// Resolves a plugin-supplied path under an allowed workspace root. fn resolve_under_root(root: &Path, path: &str) -> Result { + trace!( + "[{}] resolve_under_root: root={}, path={}", + MODULE, + root.display(), + path + ); if path.contains('\0') { + warn!("[{}] resolve_under_root: path contains NUL", MODULE); return Err("path contains NUL".to_string()); } @@ -95,8 +106,16 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { .canonicalize() .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; if p.starts_with(&root) { + trace!( + "[{}] resolve_under_root: resolved absolute path within root", + MODULE + ); return Ok(p); } + warn!( + "[{}] resolve_under_root: path escapes workspace root", + MODULE + ); return Err("path escapes workspace root".to_string()); } @@ -106,26 +125,72 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { Component::Normal(c) => clean.push(c), Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - return Err("path must be relative and not contain '..'".to_string()) + warn!( + "[{}] resolve_under_root: invalid path component in '{}'", + MODULE, path + ); + return Err("path must be relative and not contain '..'".to_string()); } } } - Ok(root.join(clean)) + let resolved = root.join(clean); + trace!( + "[{}] resolve_under_root: resolved to {}", + MODULE, + resolved.display() + ); + Ok(resolved) } /// Writes bytes to a relative path constrained to the workspace root. fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { + trace!( + "[{}] write_file_under_root: root={}, rel={}, len={}", + MODULE, + root.display(), + rel, + bytes.len() + ); let path = resolve_under_root(root, rel)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; } - fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display())) + fs::write(&path, bytes).map_err(|e| { + error!( + "[{}] write_file_under_root: failed to write {}: {}", + MODULE, + path.display(), + e + ); + format!("write {}: {e}", path.display()) + }) } /// Reads bytes from a relative path constrained to the workspace root. fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { + trace!( + "[{}] read_file_under_root: root={}, rel={}", + MODULE, + root.display(), + rel + ); let path = resolve_under_root(root, rel)?; - fs::read(&path).map_err(|e| format!("read {}: {e}", path.display())) + let result = fs::read(&path).map_err(|e| { + error!( + "[{}] read_file_under_root: failed to read {}: {}", + MODULE, + path.display(), + e + ); + format!("read {}: {e}", path.display()) + })?; + debug!( + "[{}] read_file_under_root: read {} bytes from {}", + MODULE, + result.len(), + rel + ); + Ok(result) } /// Builds a sanitized child-process environment for Git execution. @@ -157,72 +222,171 @@ fn sanitized_env() -> Vec<(OsString, OsString)> { /// Returns runtime metadata exposed to plugins. pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { - openvcs_core::RuntimeInfo { + trace!("[{}] host_runtime_info: gathering runtime info", MODULE); + let kind = runtime_container_kind(); + let info = openvcs_core::RuntimeInfo { os: Some(std::env::consts::OS.to_string()), arch: Some(std::env::consts::ARCH.to_string()), - container: Some(runtime_container_kind().to_string()), - } + container: Some(kind.to_string()), + }; + debug!( + "[{}] host_runtime_info: os={}, arch={}, container={}", + MODULE, + info.os.as_deref().unwrap_or("unknown"), + info.arch.as_deref().unwrap_or("unknown"), + kind + ); + info } /// Registers a plugin subscription for a named host event. pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult<()> { + let _timer = LogTimer::new(MODULE, "host_subscribe_event"); let name = event_name.trim(); + trace!( + "[{}] host_subscribe_event: plugin={}, event='{}'", + MODULE, + spawn.plugin_id, + name + ); + if name.is_empty() { + warn!( + "[{}] host_subscribe_event: empty event name from plugin {}", + MODULE, spawn.plugin_id + ); return Err(host_error("host.invalid_event_name", "event name is empty")); } + crate::plugin_runtime::events::subscribe(&spawn.plugin_id, name); + debug!( + "[{}] host_subscribe_event: plugin {} subscribed to '{}'", + MODULE, spawn.plugin_id, name + ); Ok(()) } /// Emits a plugin-originated event with JSON payload validation. pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> HostResult<()> { + let _timer = LogTimer::new(MODULE, "host_emit_event"); let name = event_name.trim(); + trace!( + "[{}] host_emit_event: plugin={}, event='{}', payload_len={}", + MODULE, + spawn.plugin_id, + name, + payload.len() + ); + if name.is_empty() { + warn!( + "[{}] host_emit_event: empty event name from plugin {}", + MODULE, spawn.plugin_id + ); return Err(host_error("host.invalid_event_name", "event name is empty")); } + let payload_json = if payload.is_empty() { Value::Null } else { serde_json::from_slice(payload).map_err(|err| { + error!( + "[{}] host_emit_event: invalid JSON payload from plugin {}: {}", + MODULE, spawn.plugin_id, err + ); host_error( "host.invalid_payload", format!("payload is not valid JSON: {err}"), ) })? }; + crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, name, payload_json); + debug!( + "[{}] host_emit_event: plugin {} emitted '{}'", + MODULE, spawn.plugin_id, name + ); Ok(()) } /// Handles plugin notification requests gated by `ui.notifications` capability. pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); + trace!( + "[{}] host_ui_notify: plugin={}, message_len={}", + MODULE, + spawn.plugin_id, + message.len() + ); + if !caps.contains("ui.notifications") { + warn!( + "[{}] host_ui_notify: capability denied for plugin {} (missing ui.notifications)", + MODULE, spawn.plugin_id + ); return Err(host_error( "capability.denied", "missing capability: ui.notifications", )); } + let message = message.trim(); if !message.is_empty() { - log::info!("plugin[{}] notify: {}", spawn.plugin_id, message); + info!( + "[{}] host_ui_notify: plugin[{}] notify: {}", + MODULE, spawn.plugin_id, message + ); } Ok(()) } /// Reads a workspace file when the plugin has workspace read access. pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { + let _timer = LogTimer::new(MODULE, "host_workspace_read_file"); + trace!( + "[{}] host_workspace_read_file: plugin={}, path='{}'", + MODULE, + spawn.plugin_id, + path + ); + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("workspace.read") && !caps.contains("workspace.write") { + warn!( + "[{}] host_workspace_read_file: capability denied for plugin {} (missing workspace.read)", + MODULE, spawn.plugin_id + ); return Err(host_error( "capability.denied", "missing capability: workspace.read (or workspace.write)", )); } + let Some(root) = workspace_root.as_ref() else { + warn!( + "[{}] host_workspace_read_file: no workspace context for plugin {}", + MODULE, spawn.plugin_id + ); return Err(host_error("workspace.denied", "no workspace context")); }; - read_file_under_root(root, path).map_err(|err| host_error("workspace.error", err)) + + let result = read_file_under_root(root, path).map_err(|err| { + error!( + "[{}] host_workspace_read_file: failed for plugin {}: {}", + MODULE, spawn.plugin_id, err + ); + host_error("workspace.error", err) + })?; + + debug!( + "[{}] host_workspace_read_file: plugin {} read {} bytes from '{}'", + MODULE, + spawn.plugin_id, + result.len(), + path + ); + Ok(result) } /// Writes a workspace file when the plugin has workspace write access. @@ -231,17 +395,52 @@ pub fn host_workspace_write_file( path: &str, content: &[u8], ) -> HostResult<()> { + let _timer = LogTimer::new(MODULE, "host_workspace_write_file"); + trace!( + "[{}] host_workspace_write_file: plugin={}, path='{}', len={}", + MODULE, + spawn.plugin_id, + path, + content.len() + ); + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("workspace.write") { + warn!( + "[{}] host_workspace_write_file: capability denied for plugin {} (missing workspace.write)", + MODULE, spawn.plugin_id + ); return Err(host_error( "capability.denied", "missing capability: workspace.write", )); } + let Some(root) = workspace_root.as_ref() else { + warn!( + "[{}] host_workspace_write_file: no workspace context for plugin {}", + MODULE, spawn.plugin_id + ); return Err(host_error("workspace.denied", "no workspace context")); }; - write_file_under_root(root, path, content).map_err(|err| host_error("workspace.error", err)) + + write_file_under_root(root, path, content).map_err(|err| { + error!( + "[{}] host_workspace_write_file: failed for plugin {}: {}", + MODULE, spawn.plugin_id, err + ); + host_error("workspace.error", err) + })?; + + debug!( + "[{}] host_workspace_write_file: plugin {} wrote {} bytes to '{}'", + MODULE, + spawn.plugin_id, + content.len(), + path + ); + Ok(()) } /// Executes `git` with sanitized environment and capability checks. @@ -252,8 +451,32 @@ pub fn host_process_exec_git( env: &[(String, String)], stdin: Option<&str>, ) -> HostResult { + let _timer = LogTimer::new(MODULE, "host_process_exec_git"); + info!( + "[{}] host_process_exec_git: plugin={}, args={:?}", + MODULE, spawn.plugin_id, args + ); + debug!( + "[{}] host_process_exec_git: cwd={:?}, env_count={}, has_stdin={}", + MODULE, + cwd, + env.len(), + stdin.is_some() + ); + trace!( + "[{}] host_process_exec_git: env={:?}, stdin_len={}", + MODULE, + env, + stdin.map(|s| s.len()).unwrap_or(0) + ); + let (caps, workspace_root) = approved_caps_and_workspace(spawn); + if !caps.contains("process.exec") { + warn!( + "[{}] host_process_exec_git: capability denied for plugin {} (missing process.exec)", + MODULE, spawn.plugin_id + ); return Err(host_error( "capability.denied", "missing capability: process.exec", @@ -265,12 +488,30 @@ pub fn host_process_exec_git( Some(raw) if raw.trim().is_empty() => workspace_root, Some(raw) => { let Some(root) = spawn.allowed_workspace_root.as_ref() else { + warn!( + "[{}] host_process_exec_git: no workspace context for plugin {}", + MODULE, spawn.plugin_id + ); return Err(host_error("workspace.denied", "no workspace context")); }; - Some(resolve_under_root(root, raw).map_err(|e| host_error("workspace.denied", e))?) + Some(resolve_under_root(root, raw).map_err(|e| { + warn!( + "[{}] host_process_exec_git: invalid cwd for plugin {}: {}", + MODULE, spawn.plugin_id, e + ); + host_error("workspace.denied", e) + })?) } }; + debug!( + "[{}] host_process_exec_git: executing git with cwd={:?}", + MODULE, + cwd.as_ref().map(|p| p.display()) + ); + + let start = std::time::Instant::now(); + let mut cmd = Command::new("git"); if let Some(cwd) = cwd.as_ref() { cmd.current_dir(cwd); @@ -288,28 +529,73 @@ pub fn host_process_exec_git( let stdin_text = stdin.unwrap_or_default(); let out = if stdin_text.is_empty() { - cmd.output() - .map_err(|e| host_error("process.error", format!("spawn git: {e}")))? + cmd.output().map_err(|e| { + error!( + "[{}] host_process_exec_git: failed to spawn git: {}", + MODULE, e + ); + host_error("process.error", format!("spawn git: {e}")) + })? } else { cmd.stdin(Stdio::piped()); - let mut child = cmd - .spawn() - .map_err(|e| host_error("process.error", format!("spawn git: {e}")))?; + let mut child = cmd.spawn().map_err(|e| { + error!( + "[{}] host_process_exec_git: failed to spawn git: {}", + MODULE, e + ); + host_error("process.error", format!("spawn git: {e}")) + })?; if let Some(mut child_stdin) = child.stdin.take() { if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { let _ = child.kill(); + error!( + "[{}] host_process_exec_git: failed to write stdin: {}", + MODULE, e + ); return Err(host_error("process.error", format!("write stdin: {e}"))); } } - child - .wait_with_output() - .map_err(|e| host_error("process.error", format!("wait: {e}")))? + child.wait_with_output().map_err(|e| { + error!( + "[{}] host_process_exec_git: failed to wait for process: {}", + MODULE, e + ); + host_error("process.error", format!("wait: {e}")) + })? }; - Ok(HostProcessExecOutput { + let elapsed = start.elapsed(); + let result = HostProcessExecOutput { success: out.status.success(), status: out.status.code().unwrap_or(-1), stdout: String::from_utf8_lossy(&out.stdout).to_string(), stderr: String::from_utf8_lossy(&out.stderr).to_string(), - }) + }; + + if result.success { + debug!( + "[{}] host_process_exec_git: git {:?} succeeded in {:?} (code={})", + MODULE, + args.first(), + elapsed, + result.status + ); + trace!( + "[{}] host_process_exec_git: stdout_len={}, stderr_len={}", + MODULE, + result.stdout.len(), + result.stderr.len() + ); + } else { + warn!( + "[{}] host_process_exec_git: git {:?} failed in {:?} (code={}): {}", + MODULE, + args.first(), + elapsed, + result.status, + result.stderr.lines().next().unwrap_or("") + ); + } + + Ok(result) } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index d1b8b9f4..1d65664f 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,6 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use crate::logging::LogTimer; use crate::plugin_runtime::instance::PluginRuntimeInstance; +use log::{debug, error, info, trace, warn}; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, StatusSummary, VcsEvent, @@ -11,6 +13,8 @@ use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::Arc; +const MODULE: &str = "vcs_proxy"; + /// [`Vcs`] implementation that forwards operations to a plugin runtime. pub struct PluginVcsProxy { /// Backend identifier represented by this proxy instance. @@ -39,20 +43,52 @@ impl PluginVcsProxy { repo_path: &Path, cfg: serde_json::Value, ) -> Result, VcsError> { + let _timer = LogTimer::new(MODULE, "open_with_process"); + let path_str = repo_path.to_string_lossy(); + info!( + "[{}] open_with_process: backend={}, path={}", + MODULE, backend_id, path_str + ); + debug!( + "[{}] open_with_process: config keys={:?}", + MODULE, + cfg.as_object().map(|o| o.keys().collect::>()) + ); + let workdir = repo_path.to_path_buf(); let p = PluginVcsProxy { - backend_id, - workdir, + backend_id: backend_id.clone(), + workdir: workdir.clone(), runtime, }; - p.runtime.ensure_running().map_err(|e| VcsError::Backend { - backend: p.backend_id.clone(), - msg: e, + + trace!( + "[{}] open_with_process: ensuring runtime is running", + MODULE + ); + p.runtime.ensure_running().map_err(|e| { + error!( + "[{}] open_with_process: failed to ensure runtime running: {}", + MODULE, e + ); + VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, + } })?; - p.call_unit( - "open", - json!({ "path": path_to_utf8(repo_path)?, "config": cfg }), - )?; + debug!("[{}] open_with_process: runtime confirmed running", MODULE); + + let params = json!({ "path": path_to_utf8(repo_path)?, "config": cfg }); + trace!("[{}] open_with_process: calling open RPC", MODULE); + p.call_unit("open", params.clone()).map_err(|e| { + error!("[{}] open_with_process: open RPC failed: {}", MODULE, e); + e + })?; + + info!( + "[{}] open_with_process: opened backend {} for {}", + MODULE, backend_id, path_str + ); Ok(Arc::new(p)) } @@ -66,12 +102,31 @@ impl PluginVcsProxy { /// - `Ok(Value)` RPC result payload. /// - `Err(VcsError)` on RPC failure. fn call_value(&self, method: &str, params: Value) -> Result { - self.runtime - .call(method, params) - .map_err(|e| VcsError::Backend { + trace!( + "[{}] call_value: method={}, params_len={}", + MODULE, + method, + params.to_string().len() + ); + let result = self.runtime.call(method, params).map_err(|e| { + error!( + "[{}] call_value: RPC call '{}' failed: {}", + MODULE, method, e + ); + VcsError::Backend { backend: self.backend_id.clone(), msg: e, - }) + } + }); + if let Ok(ref v) = result { + trace!( + "[{}] call_value: method={} returned {} bytes", + MODULE, + method, + v.to_string().len() + ); + } + result } /// Calls a plugin RPC method and deserializes its JSON result. @@ -84,10 +139,17 @@ impl PluginVcsProxy { /// - `Ok(T)` deserialized result. /// - `Err(VcsError)` on RPC or decode failure. fn call_json(&self, method: &str, params: Value) -> Result { + trace!("[{}] call_json: method={}", MODULE, method); let v = self.call_value(method, params)?; - serde_json::from_value(v).map_err(|e| VcsError::Backend { - backend: self.backend_id.clone(), - msg: format!("invalid plugin response for {method}: {e}"), + serde_json::from_value(v).map_err(|e| { + error!( + "[{}] call_json: failed to deserialize response for '{}': {}", + MODULE, method, e + ); + VcsError::Backend { + backend: self.backend_id.clone(), + msg: format!("invalid plugin response for {method}: {e}"), + } }) } @@ -101,6 +163,7 @@ impl PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on RPC failure. fn call_unit(&self, method: &str, params: Value) -> Result<(), VcsError> { + trace!("[{}] call_unit: method={}", MODULE, method); let _ = self.call_value(method, params)?; Ok(()) } @@ -118,11 +181,17 @@ impl PluginVcsProxy { where F: FnOnce() -> Result, { + if on.is_some() { + debug!("[{}] with_events: installing event callback", MODULE); + } let sink: Option> = on.map(|cb| Arc::new(move |evt| cb(evt)) as _); self.runtime.set_event_sink(sink); let res = f(); self.runtime.set_event_sink(None); + if res.is_err() { + warn!("[{}] with_events: operation failed", MODULE); + } res } } @@ -133,6 +202,7 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Backend id value. fn id(&self) -> BackendId { + trace!("[{}] id: returning backend_id={}", MODULE, self.backend_id); self.backend_id.clone() } @@ -141,7 +211,21 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Capability set; defaults on decode failure. fn caps(&self) -> Capabilities { - self.call_json("caps", Value::Null).unwrap_or_default() + trace!("[{}] caps: querying plugin capabilities", MODULE); + let result = self.call_json("caps", Value::Null); + match result { + Ok(caps) => { + debug!("[{}] caps: received capabilities from plugin", MODULE); + caps + } + Err(e) => { + warn!( + "[{}] caps: failed to get capabilities, using defaults: {}", + MODULE, e + ); + Capabilities::default() + } + } } /// Unsupported direct constructor for this proxy. @@ -155,6 +239,10 @@ impl Vcs for PluginVcsProxy { where Self: Sized, { + warn!( + "[{}] open: direct constructor not supported, use host runtime", + MODULE + ); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::open must be constructed via the host runtime".into(), @@ -174,6 +262,10 @@ impl Vcs for PluginVcsProxy { where Self: Sized, { + warn!( + "[{}] clone: direct constructor not supported, use host runtime", + MODULE + ); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::clone must be constructed via the host runtime".into(), @@ -185,6 +277,7 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Workdir path reference. fn workdir(&self) -> &Path { + trace!("[{}] workdir: returning {}", MODULE, self.workdir.display()); &self.workdir } @@ -195,7 +288,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(None)` on detached HEAD. /// - `Err(VcsError)` on backend failure. fn current_branch(&self) -> VcsResult> { - self.call_json("current_branch", Value::Null) + let _timer = LogTimer::new(MODULE, "current_branch"); + trace!("[{}] current_branch: querying current branch", MODULE); + let result = self.call_json("current_branch", Value::Null); + match &result { + Ok(Some(branch)) => { + debug!("[{}] current_branch: on branch '{}'", MODULE, branch); + } + Ok(None) => { + debug!("[{}] current_branch: detached HEAD", MODULE); + } + Err(e) => { + error!("[{}] current_branch: failed: {}", MODULE, e); + } + } + result } /// Returns local/remote branch records. @@ -204,7 +311,19 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` branch list. /// - `Err(VcsError)` on backend failure. fn branches(&self) -> VcsResult> { - self.call_json("branches", Value::Null) + let _timer = LogTimer::new(MODULE, "branches"); + trace!("[{}] branches: querying all branches", MODULE); + let result: VcsResult> = + self.call_json("branches", Value::Null); + match &result { + Ok(branches) => { + debug!("[{}] branches: found {} branches", MODULE, branches.len()); + } + Err(e) => { + error!("[{}] branches: failed: {}", MODULE, e); + } + } + result } /// Returns local branch names. @@ -213,7 +332,22 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` local branch names. /// - `Err(VcsError)` on backend failure. fn local_branches(&self) -> VcsResult> { - self.call_json("local_branches", Value::Null) + let _timer = LogTimer::new(MODULE, "local_branches"); + trace!("[{}] local_branches: querying local branches", MODULE); + let result: VcsResult> = self.call_json("local_branches", Value::Null); + match &result { + Ok(branches) => { + debug!( + "[{}] local_branches: found {} local branches", + MODULE, + branches.len() + ); + } + Err(e) => { + error!("[{}] local_branches: failed: {}", MODULE, e); + } + } + result } /// Creates a branch and optionally checks it out. @@ -226,10 +360,27 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn create_branch(&self, name: &str, checkout: bool) -> VcsResult<()> { - self.call_unit( + let _timer = LogTimer::new(MODULE, "create_branch"); + info!( + "[{}] create_branch: name={}, checkout={}", + MODULE, name, checkout + ); + let result = self.call_unit( "create_branch", json!({ "name": name, "checkout": checkout }), - ) + ); + match &result { + Ok(()) => { + debug!("[{}] create_branch: branch '{}' created", MODULE, name); + } + Err(e) => { + error!( + "[{}] create_branch: failed to create '{}': {}", + MODULE, name, e + ); + } + } + result } /// Checks out an existing branch. @@ -241,7 +392,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn checkout_branch(&self, name: &str) -> VcsResult<()> { - self.call_unit("checkout_branch", json!({ "name": name })) + let _timer = LogTimer::new(MODULE, "checkout_branch"); + info!("[{}] checkout_branch: name={}", MODULE, name); + let result = self.call_unit("checkout_branch", json!({ "name": name })); + match &result { + Ok(()) => { + debug!("[{}] checkout_branch: switched to '{}'", MODULE, name); + } + Err(e) => { + error!( + "[{}] checkout_branch: failed to switch to '{}': {}", + MODULE, name, e + ); + } + } + result } /// Creates or updates a remote URL. @@ -254,7 +419,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn ensure_remote(&self, name: &str, url: &str) -> VcsResult<()> { - self.call_unit("ensure_remote", json!({ "name": name, "url": url })) + let _timer = LogTimer::new(MODULE, "ensure_remote"); + info!("[{}] ensure_remote: name={}, url={}", MODULE, name, url); + let result = self.call_unit("ensure_remote", json!({ "name": name, "url": url })); + match &result { + Ok(()) => { + debug!("[{}] ensure_remote: remote '{}' configured", MODULE, name); + } + Err(e) => { + error!("[{}] ensure_remote: failed for '{}': {}", MODULE, name, e); + } + } + result } /// Lists configured remotes. @@ -263,7 +439,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec<(String, String)>)` name/url pairs. /// - `Err(VcsError)` on backend failure. fn list_remotes(&self) -> VcsResult> { - self.call_json("list_remotes", Value::Null) + let _timer = LogTimer::new(MODULE, "list_remotes"); + trace!("[{}] list_remotes: querying remotes", MODULE); + let result: VcsResult> = self.call_json("list_remotes", Value::Null); + match &result { + Ok(remotes) => { + debug!("[{}] list_remotes: found {} remotes", MODULE, remotes.len()); + for (name, url) in remotes { + trace!("[{}] list_remotes: remote '{}' -> '{}'", MODULE, name, url); + } + } + Err(e) => { + error!("[{}] list_remotes: failed: {}", MODULE, e); + } + } + result } /// Removes a configured remote. @@ -275,7 +465,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn remove_remote(&self, name: &str) -> VcsResult<()> { - self.call_unit("remove_remote", json!({ "name": name })) + let _timer = LogTimer::new(MODULE, "remove_remote"); + info!("[{}] remove_remote: name={}", MODULE, name); + let result = self.call_unit("remove_remote", json!({ "name": name })); + match &result { + Ok(()) => { + debug!("[{}] remove_remote: remote '{}' removed", MODULE, name); + } + Err(e) => { + error!( + "[{}] remove_remote: failed to remove '{}': {}", + MODULE, name, e + ); + } + } + result } /// Fetches a refspec from a remote. @@ -289,9 +493,20 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn fetch(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { - self.with_events(on, || { + let _timer = LogTimer::new(MODULE, "fetch"); + info!("[{}] fetch: remote={}, refspec={}", MODULE, remote, refspec); + let result = self.with_events(on, || { self.call_unit("fetch", json!({ "remote": remote, "refspec": refspec })) - }) + }); + match &result { + Ok(()) => { + debug!("[{}] fetch: completed successfully", MODULE); + } + Err(e) => { + error!("[{}] fetch: failed: {}", MODULE, e); + } + } + result } /// Fetches using explicit options payload. @@ -312,12 +527,26 @@ impl Vcs for PluginVcsProxy { opts: FetchOptions, on: Option, ) -> VcsResult<()> { - self.with_events(on, || { + let _timer = LogTimer::new(MODULE, "fetch_with_options"); + info!( + "[{}] fetch_with_options: remote={}, refspec={}, opts={:?}", + MODULE, remote, refspec, opts + ); + let result = self.with_events(on, || { self.call_unit( "fetch_with_options", json!({ "remote": remote, "refspec": refspec, "opts": opts }), ) - }) + }); + match &result { + Ok(()) => { + debug!("[{}] fetch_with_options: completed successfully", MODULE); + } + Err(e) => { + error!("[{}] fetch_with_options: failed: {}", MODULE, e); + } + } + result } /// Pushes a refspec to a remote. @@ -331,9 +560,20 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn push(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { - self.with_events(on, || { + let _timer = LogTimer::new(MODULE, "push"); + info!("[{}] push: remote={}, refspec={}", MODULE, remote, refspec); + let result = self.with_events(on, || { self.call_unit("push", json!({ "remote": remote, "refspec": refspec })) - }) + }); + match &result { + Ok(()) => { + debug!("[{}] push: completed successfully", MODULE); + } + Err(e) => { + error!("[{}] push: failed: {}", MODULE, e); + } + } + result } /// Pulls from upstream using fast-forward-only strategy. @@ -347,12 +587,26 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn pull_ff_only(&self, remote: &str, branch: &str, on: Option) -> VcsResult<()> { - self.with_events(on, || { + let _timer = LogTimer::new(MODULE, "pull_ff_only"); + info!( + "[{}] pull_ff_only: remote={}, branch={}", + MODULE, remote, branch + ); + let result = self.with_events(on, || { self.call_unit( "pull_ff_only", json!({ "remote": remote, "branch": branch }), ) - }) + }); + match &result { + Ok(()) => { + debug!("[{}] pull_ff_only: completed successfully", MODULE); + } + Err(e) => { + error!("[{}] pull_ff_only: failed: {}", MODULE, e); + } + } + result } /// Creates a commit from selected paths. @@ -373,14 +627,38 @@ impl Vcs for PluginVcsProxy { email: &str, paths: &[PathBuf], ) -> VcsResult { + let _timer = LogTimer::new(MODULE, "commit"); let paths: Vec = paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); - self.call_json( + info!( + "[{}] commit: author={} <{}>, paths={}, message_len={}", + MODULE, + name, + email, + paths.len(), + message.len() + ); + debug!( + "[{}] commit: message='{}'", + MODULE, + message.lines().next().unwrap_or("") + ); + trace!("[{}] commit: paths={:?}", MODULE, paths); + let result = self.call_json( "commit", json!({ "message": message, "name": name, "email": email, "paths": paths }), - ) + ); + match &result { + Ok(commit_id) => { + debug!("[{}] commit: created commit {}", MODULE, commit_id); + } + Err(e) => { + error!("[{}] commit: failed: {}", MODULE, e); + } + } + result } /// Creates a commit from the index. @@ -394,10 +672,32 @@ impl Vcs for PluginVcsProxy { /// - `Ok(String)` commit id. /// - `Err(VcsError)` on backend failure. fn commit_index(&self, message: &str, name: &str, email: &str) -> VcsResult { - self.call_json( + let _timer = LogTimer::new(MODULE, "commit_index"); + info!( + "[{}] commit_index: author={} <{}>, message_len={}", + MODULE, + name, + email, + message.len() + ); + debug!( + "[{}] commit_index: message='{}'", + MODULE, + message.lines().next().unwrap_or("") + ); + let result = self.call_json( "commit_index", json!({ "message": message, "name": name, "email": email }), - ) + ); + match &result { + Ok(commit_id) => { + debug!("[{}] commit_index: created commit {}", MODULE, commit_id); + } + Err(e) => { + error!("[{}] commit_index: failed: {}", MODULE, e); + } + } + result } /// Returns summarized status information. @@ -406,7 +706,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(StatusSummary)` summary payload. /// - `Err(VcsError)` on backend failure. fn status_summary(&self) -> VcsResult { - self.call_json("status_summary", Value::Null) + let _timer = LogTimer::new(MODULE, "status_summary"); + trace!("[{}] status_summary: querying status summary", MODULE); + let result: VcsResult = self.call_json("status_summary", Value::Null); + match &result { + Ok(summary) => { + debug!( + "[{}] status_summary: {} staged, {} modified, {} untracked, {} conflicted", + MODULE, summary.staged, summary.modified, summary.untracked, summary.conflicted + ); + } + Err(e) => { + error!("[{}] status_summary: failed: {}", MODULE, e); + } + } + result } /// Returns full status payload. @@ -415,7 +729,24 @@ impl Vcs for PluginVcsProxy { /// - `Ok(StatusPayload)` status payload. /// - `Err(VcsError)` on backend failure. fn status_payload(&self) -> VcsResult { - self.call_json("status_payload", Value::Null) + let _timer = LogTimer::new(MODULE, "status_payload"); + trace!("[{}] status_payload: querying full status", MODULE); + let result: VcsResult = self.call_json("status_payload", Value::Null); + match &result { + Ok(payload) => { + debug!( + "[{}] status_payload: {} files, {} ahead, {} behind", + MODULE, + payload.files.len(), + payload.ahead, + payload.behind + ); + } + Err(e) => { + error!("[{}] status_payload: failed: {}", MODULE, e); + } + } + result } /// Returns commit log entries for a query. @@ -427,7 +758,28 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` commit entries. /// - `Err(VcsError)` on backend failure. fn log_commits(&self, query: &LogQuery) -> VcsResult> { - self.call_json("log_commits", json!({ "query": query })) + let _timer = LogTimer::new(MODULE, "log_commits"); + trace!( + "[{}] log_commits: querying commits (limit={:?}, skip={:?})", + MODULE, + query.limit, + query.skip + ); + let result: VcsResult> = + self.call_json("log_commits", json!({ "query": query })); + match &result { + Ok(commits) => { + debug!( + "[{}] log_commits: {} commits returned", + MODULE, + commits.len() + ); + } + Err(e) => { + error!("[{}] log_commits: failed: {}", MODULE, e); + } + } + result } /// Returns diff lines for a file path. @@ -439,7 +791,25 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` diff lines. /// - `Err(VcsError)` on backend failure. fn diff_file(&self, path: &Path) -> VcsResult> { - self.call_json("diff_file", json!({ "path": path_to_utf8(path)? })) + let _timer = LogTimer::new(MODULE, "diff_file"); + let path_str = path_to_utf8(path)?; + trace!("[{}] diff_file: path={}", MODULE, path_str); + let result: VcsResult> = + self.call_json("diff_file", json!({ "path": path_str.clone() })); + match &result { + Ok(lines) => { + debug!( + "[{}] diff_file: {} lines for {}", + MODULE, + lines.len(), + path_str + ); + } + Err(e) => { + error!("[{}] diff_file: failed for '{}': {}", MODULE, path_str, e); + } + } + result } /// Returns diff lines for a commit/revision. @@ -451,7 +821,23 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` diff lines. /// - `Err(VcsError)` on backend failure. fn diff_commit(&self, rev: &str) -> VcsResult> { - self.call_json("diff_commit", json!({ "rev": rev })) + let _timer = LogTimer::new(MODULE, "diff_commit"); + trace!("[{}] diff_commit: rev={}", MODULE, rev); + let result: VcsResult> = self.call_json("diff_commit", json!({ "rev": rev })); + match &result { + Ok(lines) => { + debug!( + "[{}] diff_commit: {} lines for {}", + MODULE, + lines.len(), + rev + ); + } + Err(e) => { + error!("[{}] diff_commit: failed for '{}': {}", MODULE, rev, e); + } + } + result } /// Returns merge-conflict details for a file. @@ -463,7 +849,26 @@ impl Vcs for PluginVcsProxy { /// - `Ok(ConflictDetails)` conflict payload. /// - `Err(VcsError)` on backend failure. fn conflict_details(&self, path: &Path) -> VcsResult { - self.call_json("conflict_details", json!({ "path": path_to_utf8(path)? })) + let _timer = LogTimer::new(MODULE, "conflict_details"); + let path_str = path_to_utf8(path)?; + info!("[{}] conflict_details: path={}", MODULE, path_str); + let result: VcsResult = + self.call_json("conflict_details", json!({ "path": path_str.clone() })); + match &result { + Ok(details) => { + debug!( + "[{}] conflict_details: got details for {} (binary={})", + MODULE, path_str, details.binary + ); + } + Err(e) => { + error!( + "[{}] conflict_details: failed for '{}': {}", + MODULE, path_str, e + ); + } + } + result } /// Checks out a specific conflict side for a file. @@ -476,10 +881,31 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn checkout_conflict_side(&self, path: &Path, side: ConflictSide) -> VcsResult<()> { - self.call_unit( + let _timer = LogTimer::new(MODULE, "checkout_conflict_side"); + let path_str = path_to_utf8(path)?; + info!( + "[{}] checkout_conflict_side: path={}, side={:?}", + MODULE, path_str, side + ); + let result = self.call_unit( "checkout_conflict_side", - json!({ "path": path_to_utf8(path)?, "side": side }), - ) + json!({ "path": path_str.clone(), "side": side }), + ); + match &result { + Ok(()) => { + debug!( + "[{}] checkout_conflict_side: resolved {} with {:?}", + MODULE, path_str, side + ); + } + Err(e) => { + error!( + "[{}] checkout_conflict_side: failed for '{}': {}", + MODULE, path_str, e + ); + } + } + result } /// Writes merged file content for a conflict path. @@ -492,11 +918,34 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn write_merge_result(&self, path: &Path, content: &[u8]) -> VcsResult<()> { - let content = String::from_utf8_lossy(content).to_string(); - self.call_unit( + let _timer = LogTimer::new(MODULE, "write_merge_result"); + let path_str = path_to_utf8(path)?; + let content_str = String::from_utf8_lossy(content).to_string(); + info!( + "[{}] write_merge_result: path={}, content_len={}", + MODULE, + path_str, + content_str.len() + ); + let result = self.call_unit( "write_merge_result", - json!({ "path": path_to_utf8(path)?, "content": content }), - ) + json!({ "path": path_str.clone(), "content": content_str }), + ); + match &result { + Ok(()) => { + debug!( + "[{}] write_merge_result: wrote resolved content to {}", + MODULE, path_str + ); + } + Err(e) => { + error!( + "[{}] write_merge_result: failed for '{}': {}", + MODULE, path_str, e + ); + } + } + result } /// Stages a patch in the index. @@ -508,7 +957,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn stage_patch(&self, patch: &str) -> VcsResult<()> { - self.call_unit("stage_patch", json!({ "patch": patch })) + let _timer = LogTimer::new(MODULE, "stage_patch"); + info!("[{}] stage_patch: patch_len={}", MODULE, patch.len()); + let result = self.call_unit("stage_patch", json!({ "patch": patch })); + match &result { + Ok(()) => { + debug!("[{}] stage_patch: patch staged successfully", MODULE); + } + Err(e) => { + error!("[{}] stage_patch: failed: {}", MODULE, e); + } + } + result } /// Discards changes for explicit paths. @@ -520,11 +980,23 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn discard_paths(&self, paths: &[PathBuf]) -> VcsResult<()> { + let _timer = LogTimer::new(MODULE, "discard_paths"); let paths: Vec = paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); - self.call_unit("discard_paths", json!({ "paths": paths })) + info!("[{}] discard_paths: count={}", MODULE, paths.len()); + trace!("[{}] discard_paths: paths={:?}", MODULE, paths); + let result = self.call_unit("discard_paths", json!({ "paths": paths })); + match &result { + Ok(()) => { + debug!("[{}] discard_paths: changes discarded", MODULE); + } + Err(e) => { + error!("[{}] discard_paths: failed: {}", MODULE, e); + } + } + result } /// Applies a patch in reverse to discard hunks. @@ -536,7 +1008,22 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn apply_reverse_patch(&self, patch: &str) -> VcsResult<()> { - self.call_unit("apply_reverse_patch", json!({ "patch": patch })) + let _timer = LogTimer::new(MODULE, "apply_reverse_patch"); + info!( + "[{}] apply_reverse_patch: patch_len={}", + MODULE, + patch.len() + ); + let result = self.call_unit("apply_reverse_patch", json!({ "patch": patch })); + match &result { + Ok(()) => { + debug!("[{}] apply_reverse_patch: patch applied in reverse", MODULE); + } + Err(e) => { + error!("[{}] apply_reverse_patch: failed: {}", MODULE, e); + } + } + result } /// Deletes a branch. @@ -549,7 +1036,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn delete_branch(&self, name: &str, force: bool) -> VcsResult<()> { - self.call_unit("delete_branch", json!({ "name": name, "force": force })) + let _timer = LogTimer::new(MODULE, "delete_branch"); + info!("[{}] delete_branch: name={}, force={}", MODULE, name, force); + let result = self.call_unit("delete_branch", json!({ "name": name, "force": force })); + match &result { + Ok(()) => { + debug!("[{}] delete_branch: branch '{}' deleted", MODULE, name); + } + Err(e) => { + error!( + "[{}] delete_branch: failed to delete '{}': {}", + MODULE, name, e + ); + } + } + result } /// Renames a branch. @@ -562,7 +1063,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn rename_branch(&self, old: &str, new: &str) -> VcsResult<()> { - self.call_unit("rename_branch", json!({ "old": old, "new": new })) + let _timer = LogTimer::new(MODULE, "rename_branch"); + info!("[{}] rename_branch: old='{}' -> new='{}'", MODULE, old, new); + let result = self.call_unit("rename_branch", json!({ "old": old, "new": new })); + match &result { + Ok(()) => { + debug!("[{}] rename_branch: branch renamed", MODULE); + } + Err(e) => { + error!("[{}] rename_branch: failed: {}", MODULE, e); + } + } + result } /// Merges a branch into the current branch. @@ -574,7 +1086,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn merge_into_current(&self, name: &str) -> VcsResult<()> { - self.call_unit("merge_into_current", json!({ "name": name })) + let _timer = LogTimer::new(MODULE, "merge_into_current"); + info!("[{}] merge_into_current: source='{}'", MODULE, name); + let result = self.call_unit("merge_into_current", json!({ "name": name })); + match &result { + Ok(()) => { + debug!("[{}] merge_into_current: merge completed", MODULE); + } + Err(e) => { + warn!( + "[{}] merge_into_current: merge may have conflicts: {}", + MODULE, e + ); + } + } + result } /// Aborts an in-progress merge. @@ -583,7 +1109,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn merge_abort(&self) -> VcsResult<()> { - self.call_unit("merge_abort", Value::Null) + let _timer = LogTimer::new(MODULE, "merge_abort"); + info!("[{}] merge_abort: aborting merge", MODULE); + let result = self.call_unit("merge_abort", Value::Null); + match &result { + Ok(()) => { + debug!("[{}] merge_abort: merge aborted", MODULE); + } + Err(e) => { + error!("[{}] merge_abort: failed: {}", MODULE, e); + } + } + result } /// Continues an in-progress merge. @@ -592,7 +1129,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn merge_continue(&self) -> VcsResult<()> { - self.call_unit("merge_continue", Value::Null) + let _timer = LogTimer::new(MODULE, "merge_continue"); + info!("[{}] merge_continue: continuing merge", MODULE); + let result = self.call_unit("merge_continue", Value::Null); + match &result { + Ok(()) => { + debug!("[{}] merge_continue: merge continued", MODULE); + } + Err(e) => { + error!("[{}] merge_continue: failed: {}", MODULE, e); + } + } + result } /// Returns whether a merge is currently in progress. @@ -601,7 +1149,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(bool)` merge state. /// - `Err(VcsError)` on backend failure. fn merge_in_progress(&self) -> VcsResult { - self.call_json("merge_in_progress", Value::Null) + let _timer = LogTimer::new(MODULE, "merge_in_progress"); + trace!("[{}] merge_in_progress: checking merge state", MODULE); + let result = self.call_json("merge_in_progress", Value::Null); + match &result { + Ok(true) => { + debug!("[{}] merge_in_progress: merge is in progress", MODULE); + } + Ok(false) => { + debug!("[{}] merge_in_progress: no merge in progress", MODULE); + } + Err(e) => { + error!("[{}] merge_in_progress: failed: {}", MODULE, e); + } + } + result } /// Sets upstream tracking branch for a local branch. @@ -614,10 +1176,24 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn set_branch_upstream(&self, branch: &str, upstream: &str) -> VcsResult<()> { - self.call_unit( + let _timer = LogTimer::new(MODULE, "set_branch_upstream"); + info!( + "[{}] set_branch_upstream: branch='{}' -> upstream='{}'", + MODULE, branch, upstream + ); + let result = self.call_unit( "set_branch_upstream", json!({ "branch": branch, "upstream": upstream }), - ) + ); + match &result { + Ok(()) => { + debug!("[{}] set_branch_upstream: upstream set", MODULE); + } + Err(e) => { + error!("[{}] set_branch_upstream: failed: {}", MODULE, e); + } + } + result } /// Returns upstream ref for a local branch. @@ -630,7 +1206,24 @@ impl Vcs for PluginVcsProxy { /// - `Ok(None)` when unset. /// - `Err(VcsError)` on backend failure. fn branch_upstream(&self, branch: &str) -> VcsResult> { - self.call_json("branch_upstream", json!({ "branch": branch })) + let _timer = LogTimer::new(MODULE, "branch_upstream"); + trace!("[{}] branch_upstream: branch='{}'", MODULE, branch); + let result = self.call_json("branch_upstream", json!({ "branch": branch })); + match &result { + Ok(Some(upstream)) => { + debug!( + "[{}] branch_upstream: '{}' tracks '{}'", + MODULE, branch, upstream + ); + } + Ok(None) => { + debug!("[{}] branch_upstream: '{}' has no upstream", MODULE, branch); + } + Err(e) => { + error!("[{}] branch_upstream: failed: {}", MODULE, e); + } + } + result } /// Performs a hard reset of HEAD/worktree. @@ -639,7 +1232,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn hard_reset_head(&self) -> VcsResult<()> { - self.call_unit("hard_reset_head", Value::Null) + let _timer = LogTimer::new(MODULE, "hard_reset_head"); + warn!("[{}] hard_reset_head: performing hard reset", MODULE); + let result = self.call_unit("hard_reset_head", Value::Null); + match &result { + Ok(()) => { + debug!("[{}] hard_reset_head: reset completed", MODULE); + } + Err(e) => { + error!("[{}] hard_reset_head: failed: {}", MODULE, e); + } + } + result } /// Performs a soft reset to a revision. @@ -651,7 +1255,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn reset_soft_to(&self, rev: &str) -> VcsResult<()> { - self.call_unit("reset_soft_to", json!({ "rev": rev })) + let _timer = LogTimer::new(MODULE, "reset_soft_to"); + info!("[{}] reset_soft_to: rev={}", MODULE, rev); + let result = self.call_unit("reset_soft_to", json!({ "rev": rev })); + match &result { + Ok(()) => { + debug!("[{}] reset_soft_to: reset to '{}'", MODULE, rev); + } + Err(e) => { + error!("[{}] reset_soft_to: failed: {}", MODULE, e); + } + } + result } /// Returns configured repository identity if available. @@ -661,7 +1276,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(None)` when unset. /// - `Err(VcsError)` on backend failure. fn get_identity(&self) -> VcsResult> { - self.call_json("get_identity", Value::Null) + let _timer = LogTimer::new(MODULE, "get_identity"); + trace!("[{}] get_identity: querying repository identity", MODULE); + let result = self.call_json("get_identity", Value::Null); + match &result { + Ok(Some((name, email))) => { + debug!("[{}] get_identity: {} <{}>", MODULE, name, email); + } + Ok(None) => { + debug!("[{}] get_identity: no identity configured", MODULE); + } + Err(e) => { + error!("[{}] get_identity: failed: {}", MODULE, e); + } + } + result } /// Sets repository-local identity. @@ -674,10 +1303,21 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn set_identity_local(&self, name: &str, email: &str) -> VcsResult<()> { - self.call_unit( + let _timer = LogTimer::new(MODULE, "set_identity_local"); + info!("[{}] set_identity_local: {} <{}>", MODULE, name, email); + let result = self.call_unit( "set_identity_local", json!({ "name": name, "email": email }), - ) + ); + match &result { + Ok(()) => { + debug!("[{}] set_identity_local: identity set", MODULE); + } + Err(e) => { + error!("[{}] set_identity_local: failed: {}", MODULE, e); + } + } + result } /// Returns stash entries. @@ -686,7 +1326,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` stash list. /// - `Err(VcsError)` on backend failure. fn stash_list(&self) -> VcsResult> { - self.call_json("stash_list", Value::Null) + let _timer = LogTimer::new(MODULE, "stash_list"); + trace!("[{}] stash_list: querying stash entries", MODULE); + let result: VcsResult> = self.call_json("stash_list", Value::Null); + match &result { + Ok(stashes) => { + debug!("[{}] stash_list: {} stash entries", MODULE, stashes.len()); + } + Err(e) => { + error!("[{}] stash_list: failed: {}", MODULE, e); + } + } + result } /// Creates a stash entry. @@ -705,14 +1356,31 @@ impl Vcs for PluginVcsProxy { include_untracked: bool, paths: &[PathBuf], ) -> VcsResult<()> { + let _timer = LogTimer::new(MODULE, "stash_push"); let paths: Vec = paths .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); - self.call_unit( + info!( + "[{}] stash_push: message='{}', include_untracked={}, paths={}", + MODULE, + message.lines().next().unwrap_or(""), + include_untracked, + paths.len() + ); + let result = self.call_unit( "stash_push", json!({ "message": message, "include_untracked": include_untracked, "paths": paths }), - ) + ); + match &result { + Ok(()) => { + debug!("[{}] stash_push: stash created", MODULE); + } + Err(e) => { + error!("[{}] stash_push: failed: {}", MODULE, e); + } + } + result } /// Applies a stash entry. @@ -724,7 +1392,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn stash_apply(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_apply", json!({ "selector": selector })) + let _timer = LogTimer::new(MODULE, "stash_apply"); + info!("[{}] stash_apply: selector={}", MODULE, selector); + let result = self.call_unit("stash_apply", json!({ "selector": selector })); + match &result { + Ok(()) => { + debug!("[{}] stash_apply: stash applied", MODULE); + } + Err(e) => { + error!("[{}] stash_apply: failed: {}", MODULE, e); + } + } + result } /// Pops a stash entry. @@ -736,7 +1415,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn stash_pop(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_pop", json!({ "selector": selector })) + let _timer = LogTimer::new(MODULE, "stash_pop"); + info!("[{}] stash_pop: selector={}", MODULE, selector); + let result = self.call_unit("stash_pop", json!({ "selector": selector })); + match &result { + Ok(()) => { + debug!("[{}] stash_pop: stash popped", MODULE); + } + Err(e) => { + error!("[{}] stash_pop: failed: {}", MODULE, e); + } + } + result } /// Drops a stash entry. @@ -748,7 +1438,18 @@ impl Vcs for PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on backend failure. fn stash_drop(&self, selector: &str) -> VcsResult<()> { - self.call_unit("stash_drop", json!({ "selector": selector })) + let _timer = LogTimer::new(MODULE, "stash_drop"); + info!("[{}] stash_drop: selector={}", MODULE, selector); + let result = self.call_unit("stash_drop", json!({ "selector": selector })); + match &result { + Ok(()) => { + debug!("[{}] stash_drop: stash dropped", MODULE); + } + Err(e) => { + error!("[{}] stash_drop: failed: {}", MODULE, e); + } + } + result } /// Returns patch lines for a stash entry. @@ -760,7 +1461,24 @@ impl Vcs for PluginVcsProxy { /// - `Ok(Vec)` stash diff lines. /// - `Err(VcsError)` on backend failure. fn stash_show(&self, selector: &str) -> VcsResult> { - self.call_json("stash_show", json!({ "selector": selector })) + let _timer = LogTimer::new(MODULE, "stash_show"); + trace!("[{}] stash_show: selector={}", MODULE, selector); + let result: VcsResult> = + self.call_json("stash_show", json!({ "selector": selector })); + match &result { + Ok(lines) => { + debug!( + "[{}] stash_show: {} lines for {}", + MODULE, + lines.len(), + selector + ); + } + Err(e) => { + error!("[{}] stash_show: failed: {}", MODULE, e); + } + } + result } } @@ -773,10 +1491,15 @@ impl Vcs for PluginVcsProxy { /// - `Ok(String)` UTF-8 path. /// - `Err(VcsError)` when path is non-UTF8. fn path_to_utf8(path: &Path) -> Result { - path.to_str() - .map(|s| s.to_string()) - .ok_or_else(|| VcsError::Backend { + path.to_str().map(|s| s.to_string()).ok_or_else(|| { + warn!( + "[{}] path_to_utf8: non-UTF8 path: {}", + MODULE, + path.display() + ); + VcsError::Backend { backend: BackendId::from("plugin"), msg: "non-utf8 path".into(), - }) + } + }) } diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 2664a3e3..9cf8d960 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -2,11 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Discovery and opening logic for plugin-provided VCS backends. +use crate::logging::LogTimer; use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; -use log::warn; +use log::{debug, error, info, trace, warn}; use openvcs_core::{BackendId, Result as VcsResult, Vcs, VcsError}; use std::{ collections::BTreeMap, @@ -15,6 +16,8 @@ use std::{ sync::Arc, }; +const MODULE: &str = "plugin_vcs_backends"; + /// Determines whether a plugin is enabled considering config overrides. /// /// # Parameters @@ -26,7 +29,15 @@ use std::{ /// - `false` otherwise. fn is_plugin_enabled_in_settings(plugin_id: &str, default_enabled: bool) -> bool { let cfg = AppConfig::load_or_default(); - cfg.is_plugin_enabled(plugin_id, default_enabled) + let enabled = cfg.is_plugin_enabled(plugin_id, default_enabled); + trace!( + "[{}] is_plugin_enabled_in_settings: plugin={}, default={}, result={}", + MODULE, + plugin_id, + default_enabled, + enabled + ); + enabled } /// Metadata describing a single plugin-provided backend implementation. @@ -52,8 +63,20 @@ pub struct PluginBackendDescriptor { /// - `None` on read/parse failure. fn load_manifest_from_dir(plugin_dir: &Path) -> Option { let manifest_path = plugin_dir.join(PLUGIN_MANIFEST_NAME); + trace!( + "[{}] load_manifest_from_dir: loading from {}", + MODULE, + manifest_path.display() + ); + let text = fs::read_to_string(&manifest_path).ok()?; - serde_json::from_str(&text).ok() + let manifest: PluginManifest = serde_json::from_str(&text).ok()?; + + debug!( + "[{}] load_manifest_from_dir: loaded manifest for plugin '{}'", + MODULE, manifest.id + ); + Some(manifest) } /// Lists manifests from built-in plugin directories. @@ -61,14 +84,40 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { /// # Returns /// - Directory/manifest pairs for readable built-in plugins. fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { + let _timer = LogTimer::new(MODULE, "builtin_plugin_manifests"); + trace!( + "[{}] builtin_plugin_manifests: scanning built-in plugin dirs", + MODULE + ); + let mut out = Vec::new(); - for root in built_in_plugin_dirs() { + let dirs = built_in_plugin_dirs(); + debug!( + "[{}] builtin_plugin_manifests: found {} built-in plugin directories", + MODULE, + dirs.len() + ); + + for root in dirs { if !root.is_dir() { + trace!( + "[{}] builtin_plugin_manifests: {} is not a directory", + MODULE, + root.display() + ); continue; } let entries = match fs::read_dir(&root) { Ok(entries) => entries, - Err(_) => continue, + Err(e) => { + warn!( + "[{}] builtin_plugin_manifests: failed to read {}: {}", + MODULE, + root.display(), + e + ); + continue; + } }; for entry in entries.flatten() { let plugin_dir = entry.path(); @@ -80,6 +129,12 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { } } } + + debug!( + "[{}] builtin_plugin_manifests: found {} built-in manifests", + MODULE, + out.len() + ); out } @@ -89,20 +144,52 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { /// - `Ok(Vec)` containing discovered backend descriptors. /// - `Err(String)` if installed plugin components cannot be loaded. pub fn list_plugin_vcs_backends() -> Result, String> { + let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends"); + info!( + "[{}] list_plugin_vcs_backends: discovering VCS backends", + MODULE + ); + let store = PluginBundleStore::new_default(); - let plugins = store.list_current_components()?; + let plugins = store.list_current_components().map_err(|e| { + error!( + "[{}] list_plugin_vcs_backends: failed to list components: {}", + MODULE, e + ); + e + })?; + + debug!( + "[{}] list_plugin_vcs_backends: found {} installed plugins", + MODULE, + plugins.len() + ); let mut map: BTreeMap = BTreeMap::new(); for p in plugins { if !is_plugin_enabled_in_settings(&p.plugin_id, p.default_enabled) { + trace!( + "[{}] list_plugin_vcs_backends: plugin {} is disabled", + MODULE, + p.plugin_id + ); continue; } let Some(module) = p.module else { + trace!( + "[{}] list_plugin_vcs_backends: plugin {} has no module", + MODULE, + p.plugin_id + ); continue; }; for (id, name) in module.vcs_backends { let backend_id = BackendId::from(id.as_str()); + debug!( + "[{}] list_plugin_vcs_backends: found backend '{}' from plugin '{}'", + MODULE, backend_id, p.plugin_id + ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), backend_name: name, @@ -117,12 +204,27 @@ pub fn list_plugin_vcs_backends() -> Result, String for (plugin_dir, manifest) in builtin_plugin_manifests() { let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { + warn!( + "[{}] list_plugin_vcs_backends: manifest has empty id at {}", + MODULE, + plugin_dir.display() + ); continue; } if !is_plugin_enabled_in_settings(plugin_id, manifest.default_enabled) { + trace!( + "[{}] list_plugin_vcs_backends: built-in plugin {} is disabled", + MODULE, + plugin_id + ); continue; } let Some(module) = &manifest.module else { + trace!( + "[{}] list_plugin_vcs_backends: built-in plugin {} has no module", + MODULE, + plugin_id + ); continue; }; let Some(exec_name) = module @@ -131,17 +233,31 @@ pub fn list_plugin_vcs_backends() -> Result, String .map(str::trim) .filter(|s| !s.is_empty()) else { + trace!( + "[{}] list_plugin_vcs_backends: built-in plugin {} has no exec", + MODULE, + plugin_id + ); continue; }; let exec_path = plugin_dir.join("bin").join(exec_name); if !exec_path.is_file() { warn!( - "plugin_vcs_backends: built-in plugin {} is missing module exec {}", + "[{}] list_plugin_vcs_backends: built-in plugin {} is missing module exec {}", + MODULE, plugin_id, exec_path.display() ); continue; } + + debug!( + "[{}] list_plugin_vcs_backends: processing built-in plugin {} at {}", + MODULE, + plugin_id, + plugin_dir.display() + ); + let plugin_name = manifest.name.clone(); for provide in &module.vcs_backends { let (id, label) = match provide { @@ -151,8 +267,17 @@ pub fn list_plugin_vcs_backends() -> Result, String let backend_id = BackendId::from(id.as_str()); let key = backend_id.as_ref().to_string(); if map.contains_key(&key) { + trace!( + "[{}] list_plugin_vcs_backends: backend {} already registered", + MODULE, + backend_id + ); continue; } + debug!( + "[{}] list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", + MODULE, backend_id, plugin_id + ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), backend_name: label, @@ -163,7 +288,13 @@ pub fn list_plugin_vcs_backends() -> Result, String } } - Ok(map.into_values().collect()) + let result: Vec<_> = map.into_values().collect(); + info!( + "[{}] list_plugin_vcs_backends: discovered {} VCS backends", + MODULE, + result.len() + ); + Ok(result) } /// Returns whether a plugin-provided backend exists for the given backend id. @@ -175,10 +306,20 @@ pub fn list_plugin_vcs_backends() -> Result, String /// - `true` when a matching plugin backend is available. /// - `false` otherwise. pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { - list_plugin_vcs_backends().ok().is_some_and(|v| { + trace!( + "[{}] has_plugin_vcs_backend: checking for {}", + MODULE, + backend_id + ); + let result = list_plugin_vcs_backends().ok().is_some_and(|v| { v.iter() .any(|b| b.backend_id.as_ref() == backend_id.as_ref()) - }) + }); + debug!( + "[{}] has_plugin_vcs_backend: {} -> {}", + MODULE, backend_id, result + ); + result } /// Resolves the descriptor for a specific plugin-provided backend id. @@ -192,10 +333,29 @@ pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { pub fn plugin_vcs_backend_descriptor( backend_id: &BackendId, ) -> Result { - list_plugin_vcs_backends()? + trace!( + "[{}] plugin_vcs_backend_descriptor: resolving {}", + MODULE, + backend_id + ); + + let backends = list_plugin_vcs_backends()?; + let result = backends .into_iter() .find(|d| d.backend_id.as_ref() == backend_id.as_ref()) - .ok_or_else(|| format!("Unknown VCS backend: {backend_id}")) + .ok_or_else(|| { + warn!( + "[{}] plugin_vcs_backend_descriptor: unknown backend {}", + MODULE, backend_id + ); + format!("Unknown VCS backend: {backend_id}") + })?; + + debug!( + "[{}] plugin_vcs_backend_descriptor: found {} from plugin {}", + MODULE, backend_id, result.plugin_id + ); + Ok(result) } /// Opens a repository through a plugin backend process. @@ -213,20 +373,83 @@ pub fn open_repo_via_plugin_vcs_backend( backend_id: BackendId, path: &Path, ) -> VcsResult> { - let desc = plugin_vcs_backend_descriptor(&backend_id) - .map_err(|_| VcsError::Unsupported(backend_id.clone()))?; + let _timer = LogTimer::new(MODULE, "open_repo_via_plugin_vcs_backend"); + info!( + "[{}] open_repo_via_plugin_vcs_backend: backend={}, path={}", + MODULE, + backend_id, + path.display() + ); - let cfg_value = serde_json::to_value(cfg).map_err(|e| VcsError::Backend { - backend: backend_id.clone(), - msg: format!("serialize config: {e}"), + let desc = plugin_vcs_backend_descriptor(&backend_id).map_err(|e| { + error!( + "[{}] open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", + MODULE, backend_id, e + ); + VcsError::Unsupported(backend_id.clone()) })?; + debug!( + "[{}] open_repo_via_plugin_vcs_backend: resolved to plugin {}", + MODULE, desc.plugin_id + ); + + let cfg_value = serde_json::to_value(cfg).map_err(|e| { + error!( + "[{}] open_repo_via_plugin_vcs_backend: failed to serialize config: {}", + MODULE, e + ); + VcsError::Backend { + backend: backend_id.clone(), + msg: format!("serialize config: {e}"), + } + })?; + + trace!( + "[{}] open_repo_via_plugin_vcs_backend: getting runtime for plugin {}", + MODULE, + desc.plugin_id + ); + let runtime = runtime_manager .runtime_for_workspace_with_config(cfg, &desc.plugin_id, Some(path.to_path_buf())) - .map_err(|e| VcsError::Backend { - backend: backend_id.clone(), - msg: e, + .map_err(|e| { + error!( + "[{}] open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", + MODULE, desc.plugin_id, e + ); + VcsError::Backend { + backend: backend_id.clone(), + msg: e, + } })?; - PluginVcsProxy::open_with_process(backend_id, runtime, path, cfg_value) + debug!( + "[{}] open_repo_via_plugin_vcs_backend: opening via plugin proxy", + MODULE + ); + + let result = PluginVcsProxy::open_with_process(backend_id.clone(), runtime, path, cfg_value); + + match &result { + Ok(_) => { + info!( + "[{}] open_repo_via_plugin_vcs_backend: successfully opened {} via {}", + MODULE, + path.display(), + backend_id + ); + } + Err(e) => { + error!( + "[{}] open_repo_via_plugin_vcs_backend: failed to open {} via {}: {}", + MODULE, + path.display(), + backend_id, + e + ); + } + } + + result } diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index f0a14932..4343e47d 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::process::Command; -use log::{debug, warn}; +use log::{debug, error, info, trace, warn}; use openvcs_core::models::{ConflictDetails, ConflictSide}; use shlex::split; use tauri::State; @@ -13,6 +13,8 @@ use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; +const MODULE: &str = "conflicts"; + #[tauri::command] /// Returns conflict details for a repository file. /// @@ -27,13 +29,35 @@ pub async fn git_conflict_details( state: State<'_, AppState>, path: String, ) -> Result { + let start = std::time::Instant::now(); + info!("[{}] git_conflict_details: path='{}'", MODULE, path); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_conflict_details", repo, move |repo| { + let path_clone = path.clone(); + let result = run_repo_task("git_conflict_details", repo, move |repo| { repo.inner() .conflict_details(&PathBuf::from(&path)) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!("[{}] git_conflict_details: failed for '{}': {}", MODULE, path, e); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(details) => { + debug!( + "[{}] git_conflict_details: found conflict details for '{}' ({:?})", + MODULE, path_clone, start.elapsed() + ); + trace!("[{}] git_conflict_details: binary={}", MODULE, details.binary); + } + Err(e) => { + error!("[{}] git_conflict_details: failed: {}", MODULE, e); + } + } + + result } #[tauri::command] @@ -52,20 +76,46 @@ pub async fn git_resolve_conflict_side( path: String, side: String, ) -> Result<(), String> { + let start = std::time::Instant::now(); + info!("[{}] git_resolve_conflict_side: path='{}', side='{}'", MODULE, path, side); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_resolve_conflict_side", repo, move |repo| { + let path_clone = path.clone(); + let side_clone = side.clone(); + let result = run_repo_task("git_resolve_conflict_side", repo, move |repo| { let which = match side.to_lowercase().as_str() { "ours" => ConflictSide::Ours, "theirs" => ConflictSide::Theirs, other => { + warn!("[{}] git_resolve_conflict_side: invalid side '{}'", MODULE, other); return Err(format!("invalid conflict side '{other}'")); } }; repo.inner() .checkout_conflict_side(&PathBuf::from(&path), which) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!( + "[{}] git_resolve_conflict_side: failed for '{}': {}", + MODULE, path, e + ); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(()) => { + debug!( + "[{}] git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", + MODULE, path_clone, side_clone, start.elapsed() + ); + } + Err(e) => { + error!("[{}] git_resolve_conflict_side: failed: {}", MODULE, e); + } + } + + result } #[tauri::command] @@ -84,13 +134,40 @@ pub async fn git_save_merge_result( path: String, content: String, ) -> Result<(), String> { + let start = std::time::Instant::now(); + info!( + "[{}] git_save_merge_result: path='{}', content_len={}", + MODULE, path, content.len() + ); + let repo = current_repo_or_err(&state)?; - run_repo_task("git_save_merge_result", repo, move |repo| { + let path_clone = path.clone(); + let result = run_repo_task("git_save_merge_result", repo, move |repo| { repo.inner() .write_merge_result(&PathBuf::from(&path), content.as_bytes()) - .map_err(|e| e.to_string()) + .map_err(|e| { + error!( + "[{}] git_save_merge_result: failed for '{}': {}", + MODULE, path, e + ); + e.to_string() + }) }) - .await + .await; + + match &result { + Ok(()) => { + debug!( + "[{}] git_save_merge_result: saved '{}' ({:?})", + MODULE, path_clone, start.elapsed() + ); + } + Err(e) => { + error!("[{}] git_save_merge_result: failed: {}", MODULE, e); + } + } + + result } /// Splits external tool config into executable path and args. @@ -106,6 +183,7 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { .unwrap_or_default() .into_iter() .collect::>(); + trace!("[{}] tool_args: path='{}', args={:?}", MODULE, path, args); (path, args) } @@ -120,18 +198,34 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { /// - `Ok(())` when the tool process is started. /// - `Err(String)` when tool config is missing or spawn fails. pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> Result<(), String> { + let start = std::time::Instant::now(); + info!("[{}] git_launch_merge_tool: path='{}'", MODULE, path); + let cfg = state.config(); let tool = cfg.diff.external_merge.clone(); - if !tool.enabled || tool.path.trim().is_empty() { + + if !tool.enabled { + warn!("[{}] git_launch_merge_tool: external merge tool is disabled", MODULE); + return Err("no external merge tool configured".into()); + } + + if tool.path.trim().is_empty() { + warn!("[{}] git_launch_merge_tool: no tool path configured", MODULE); return Err("no external merge tool configured".into()); } + debug!( + "[{}] git_launch_merge_tool: tool='{}', args='{}'", + MODULE, tool.path, tool.args + ); + let repo = current_repo_or_err(&state)?; let (tool_path, args_template) = tool_args(&tool); let has_args = !tool.args.trim().is_empty(); let includes_placeholder = args_template.iter().any(|arg| arg.contains("{path}")); + let path_for_log = path.clone(); - run_repo_task("git_launch_merge_tool", repo, move |repo| { + let result = run_repo_task("git_launch_merge_tool", repo, move |repo| { let repo_root = repo.inner().workdir().to_path_buf(); let rel = PathBuf::from(&path); let abs = if rel.is_absolute() { @@ -140,6 +234,11 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> repo_root.join(&rel) }; + trace!( + "[{}] git_launch_merge_tool: repo_root='{}', abs_path='{}'", + MODULE, repo_root.display(), abs.display() + ); + let mut cmd = Command::new(&tool_path); cmd.current_dir(&repo_root); @@ -165,14 +264,28 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> } debug!( - "git_launch_merge_tool: spawning {} with args {:?}", - tool_path, expanded + "[{}] git_launch_merge_tool: spawning '{}' with args {:?}", + MODULE, tool_path, expanded ); cmd.spawn().map(|_| ()).map_err(|e| { - warn!("git_launch_merge_tool: failed to spawn merge tool: {e}"); + error!("[{}] git_launch_merge_tool: failed to spawn '{}': {}", MODULE, tool_path, e); e.to_string() }) }) - .await + .await; + + match &result { + Ok(()) => { + info!( + "[{}] git_launch_merge_tool: launched tool for '{}' ({:?})", + MODULE, path_for_log, start.elapsed() + ); + } + Err(e) => { + error!("[{}] git_launch_merge_tool: failed: {}", MODULE, e); + } + } + + result } diff --git a/Backend/src/tauri_commands/output_log.rs b/Backend/src/tauri_commands/output_log.rs index 54e23532..d04cebb8 100644 --- a/Backend/src/tauri_commands/output_log.rs +++ b/Backend/src/tauri_commands/output_log.rs @@ -89,7 +89,8 @@ pub fn clear_output_log(state: tauri::State<'_, AppState>) { /// - `()`. pub fn log_frontend_message(state: tauri::State<'_, AppState>, level: String, message: String) { let (output_level, log_level) = match level.to_lowercase().as_str() { - "debug" | "trace" => (OutputLevel::Info, log::Level::Trace), + "trace" => (OutputLevel::Info, log::Level::Trace), + "debug" => (OutputLevel::Info, log::Level::Debug), "info" => (OutputLevel::Info, log::Level::Info), "warn" | "warning" => (OutputLevel::Warn, log::Level::Warn), "error" | "err" => (OutputLevel::Error, log::Level::Error), diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 66079de0..b6386147 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -2,17 +2,28 @@ // SPDX-License-Identifier: GPL-3.0-or-later use std::{fs, path::PathBuf, process::Command}; +use log::{debug, error, info, trace, warn}; use serde::Serialize; use tauri::command; +const MODULE: &str = "ssh"; + /// Returns `~/.ssh/known_hosts` path. /// /// # Returns /// - `Ok(PathBuf)` known-hosts path. /// - `Err(String)` when home directory cannot be resolved. fn known_hosts_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; - Ok(home.join(".ssh").join("known_hosts")) + let home = dirs::home_dir().ok_or_else(|| { + error!( + "[{}] known_hosts_path: could not determine home directory", + MODULE + ); + "Could not determine home directory".to_string() + })?; + let path = home.join(".ssh").join("known_hosts"); + trace!("[{}] known_hosts_path: {}", MODULE, path.display()); + Ok(path) } /// Returns `~/.ssh` directory path. @@ -21,8 +32,16 @@ fn known_hosts_path() -> Result { /// - `Ok(PathBuf)` ssh directory path. /// - `Err(String)` when home directory cannot be resolved. fn ssh_dir_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; - Ok(home.join(".ssh")) + let home = dirs::home_dir().ok_or_else(|| { + error!( + "[{}] ssh_dir_path: could not determine home directory", + MODULE + ); + "Could not determine home directory".to_string() + })?; + let path = home.join(".ssh"); + trace!("[{}] ssh_dir_path: {}", MODULE, path.display()); + Ok(path) } /// Ensures `~/.ssh` directory exists. @@ -31,9 +50,28 @@ fn ssh_dir_path() -> Result { /// - `Ok(PathBuf)` created/existing ssh directory path. /// - `Err(String)` on resolution or create failure. fn ensure_ssh_dir() -> Result { - let home = dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())?; + let home = dirs::home_dir().ok_or_else(|| { + error!( + "[{}] ensure_ssh_dir: could not determine home directory", + MODULE + ); + "Could not determine home directory".to_string() + })?; let dir = home.join(".ssh"); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create ~/.ssh: {e}"))?; + fs::create_dir_all(&dir).map_err(|e| { + error!( + "[{}] ensure_ssh_dir: failed to create {}: {}", + MODULE, + dir.display(), + e + ); + format!("Failed to create ~/.ssh: {e}") + })?; + debug!( + "[{}] ensure_ssh_dir: ssh directory ready at {}", + MODULE, + dir.display() + ); Ok(dir) } @@ -58,16 +96,35 @@ pub struct SshCommandOutput { /// - `Ok(SshCommandOutput)` command output. /// - `Err(String)` on spawn failure. fn run_command(cmd: &str, args: &[&str]) -> Result { - let out = Command::new(cmd) - .args(args) - .output() - .map_err(|e| format!("Failed to run {cmd}: {e}"))?; + trace!("[{}] run_command: {} {:?}", MODULE, cmd, args); + let start = std::time::Instant::now(); - Ok(SshCommandOutput { + let out = Command::new(cmd).args(args).output().map_err(|e| { + error!("[{}] run_command: failed to spawn {}: {}", MODULE, cmd, e); + format!("Failed to run {cmd}: {e}") + })?; + + let elapsed = start.elapsed(); + let result = SshCommandOutput { code: out.status.code().unwrap_or(-1), stdout: String::from_utf8_lossy(&out.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(), - }) + }; + + if out.status.success() { + debug!( + "[{}] run_command: {} succeeded in {:?} (code={})", + MODULE, cmd, elapsed, result.code + ); + trace!("[{}] run_command: stdout='{}'", MODULE, result.stdout); + } else { + warn!( + "[{}] run_command: {} failed in {:?} (code={}): {}", + MODULE, cmd, elapsed, result.code, result.stderr + ); + } + + Ok(result) } #[cfg(not(target_os = "windows"))] @@ -80,24 +137,49 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { /// - `Ok(String)` scanned key lines. /// - `Err(String)` on command failure. fn keyscan(host: &str) -> Result { + trace!("[{}] keyscan: scanning host '{}'", MODULE, host); + let start = std::time::Instant::now(); + let out = Command::new("ssh-keyscan") .arg("-H") .arg(host) .output() - .map_err(|e| format!("Failed to run ssh-keyscan: {e}"))?; + .map_err(|e| { + error!("[{}] keyscan: failed to run ssh-keyscan: {}", MODULE, e); + format!("Failed to run ssh-keyscan: {e}") + })?; + + let elapsed = start.elapsed(); + if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + error!( + "[{}] keyscan: failed for '{}' in {:?}: {}", + MODULE, host, elapsed, err + ); return Err(if err.is_empty() { format!("ssh-keyscan exited with {}", out.status) } else { err }); } + let s = String::from_utf8_lossy(&out.stdout).to_string(); let s = s.trim().to_string(); if s.is_empty() { + warn!("[{}] keyscan: no host keys returned for '{}'", MODULE, host); return Err("ssh-keyscan returned no host keys".to_string()); } + + debug!( + "[{}] keyscan: got keys for '{}' in {:?}", + MODULE, host, elapsed + ); + trace!( + "[{}] keyscan: keys='{}'", + MODULE, + s.lines().take(3).collect::>().join("\\n") + ); Ok(s) } @@ -110,6 +192,7 @@ fn keyscan(host: &str) -> Result { /// # Returns /// - Always `Err(String)` until implemented. fn keyscan(_host: &str) -> Result { + warn!("[{}] keyscan: not implemented for Windows", MODULE); Err("SSH host key scanning is not implemented for Windows yet".to_string()) } @@ -124,16 +207,29 @@ fn keyscan(_host: &str) -> Result { /// - `Err(String)` when validation, scanning, or file write fails. pub fn ssh_trust_host(host: String) -> Result<(), String> { let host = host.trim(); + let start = std::time::Instant::now(); + info!("[{}] ssh_trust_host: host='{}'", MODULE, host); + if host.is_empty() { + warn!("[{}] ssh_trust_host: empty host provided", MODULE); return Err("Host cannot be empty".to_string()); } ensure_ssh_dir()?; let known_hosts = known_hosts_path()?; + debug!( + "[{}] ssh_trust_host: known_hosts path={}", + MODULE, + known_hosts.display() + ); // Avoid duplicating entries if the host is already present. if let Ok(existing) = fs::read_to_string(&known_hosts) { if existing.lines().any(|l| l.contains(host)) { + debug!( + "[{}] ssh_trust_host: host '{}' already in known_hosts", + MODULE, host + ); return Ok(()); } } @@ -148,8 +244,21 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { .append(true) .open(&known_hosts) .and_then(|mut f| std::io::Write::write_all(&mut f, to_append.as_bytes())) - .map_err(|e| format!("Failed to update {}: {e}", known_hosts.display()))?; + .map_err(|e| { + error!( + "[{}] ssh_trust_host: failed to update {}: {}", + MODULE, + known_hosts.display(), + e + ); + format!("Failed to update {}: {e}", known_hosts.display()) + })?; + let elapsed = start.elapsed(); + info!( + "[{}] ssh_trust_host: host '{}' trusted in {:?}", + MODULE, host, elapsed + ); Ok(()) } @@ -160,9 +269,29 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { /// - `Ok(SshCommandOutput)` with command exit/status output. /// - `Err(String)` when command execution fails. pub fn ssh_agent_list_keys() -> Result { - // Exit codes: - // 0 = keys listed, 1 = agent has no keys, 2 = agent not running/unreachable (platform dependent). - run_command("ssh-add", &["-l"]) + info!("[{}] ssh_agent_list_keys: listing SSH agent keys", MODULE); + let result = run_command("ssh-add", &["-l"])?; + + match result.code { + 0 => { + debug!( + "[{}] ssh_agent_list_keys: agent has {} keys", + MODULE, + result.stdout.lines().count() + ); + } + 1 => { + warn!("[{}] ssh_agent_list_keys: agent has no identities", MODULE); + } + code => { + warn!( + "[{}] ssh_agent_list_keys: agent returned code {}", + MODULE, code + ); + } + } + + Ok(result) } #[derive(Clone, Serialize)] @@ -181,8 +310,17 @@ pub struct SshKeyCandidate { /// - `Ok(Vec)` sorted candidate list. /// - `Err(String)` when home/ssh directory resolution fails. pub fn ssh_key_candidates() -> Result, String> { + info!( + "[{}] ssh_key_candidates: scanning for SSH key candidates", + MODULE + ); let dir = ssh_dir_path()?; + let Ok(read_dir) = fs::read_dir(&dir) else { + debug!( + "[{}] ssh_key_candidates: ssh directory does not exist or is not readable", + MODULE + ); return Ok(vec![]); }; @@ -203,6 +341,11 @@ pub fn ssh_key_candidates() -> Result, String> { || name.ends_with(".log") || name.ends_with(".old") { + trace!( + "[{}] ssh_key_candidates: skipping non-key file: {}", + MODULE, + name + ); continue; } @@ -217,6 +360,7 @@ pub fn ssh_key_candidates() -> Result, String> { continue; } + trace!("[{}] ssh_key_candidates: found candidate: {}", MODULE, name); keys.push(SshKeyCandidate { path: path.display().to_string(), name: name.to_string(), @@ -224,6 +368,11 @@ pub fn ssh_key_candidates() -> Result, String> { } keys.sort_by(|a, b| a.name.cmp(&b.name)); + debug!( + "[{}] ssh_key_candidates: found {} candidate keys", + MODULE, + keys.len() + ); Ok(keys) } @@ -238,8 +387,23 @@ pub fn ssh_key_candidates() -> Result, String> { /// - `Err(String)` when validation or command execution fails. pub fn ssh_add_key(path: String) -> Result { let p = path.trim(); + info!("[{}] ssh_add_key: path='{}'", MODULE, p); + if p.is_empty() { + warn!("[{}] ssh_add_key: empty path provided", MODULE); return Err("Path cannot be empty".to_string()); } - run_command("ssh-add", &[p]) + + let result = run_command("ssh-add", &[p])?; + + if result.code == 0 { + debug!("[{}] ssh_add_key: key added successfully", MODULE); + } else { + warn!( + "[{}] ssh_add_key: failed to add key: {}", + MODULE, result.stderr + ); + } + + Ok(result) } diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 0808df0f..33fe0ede 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -1,9 +1,12 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, error, info, trace}; use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; +const MODULE: &str = "updater"; + #[tauri::command] /// Downloads and installs an available application update. /// @@ -14,22 +17,65 @@ use tauri_plugin_updater::UpdaterExt; /// - `Ok(())` when no update exists or installation succeeds. /// - `Err(String)` when updater operations fail. pub async fn updater_install_now(window: Window) -> Result<(), String> { + let start = std::time::Instant::now(); + info!("[{}] updater_install_now: starting update check", MODULE); + let app = window.app_handle(); - let updater = app.updater().map_err(|e| e.to_string())?; - match updater.check().await.map_err(|e| e.to_string())? { + let updater = app.updater().map_err(|e| { + error!("[{}] updater_install_now: failed to get updater: {}", MODULE, e); + e.to_string() + })?; + + debug!("[{}] updater_install_now: checking for updates", MODULE); + let check_result = updater.check().await.map_err(|e| { + error!("[{}] updater_install_now: update check failed: {}", MODULE, e); + e.to_string() + })?; + + match check_result { Some(update) => { + let version = &update.version; + let current_version = &update.current_version; + info!( + "[{}] updater_install_now: update available: {} -> {}", + MODULE, current_version, version + ); + debug!( + "[{}] updater_install_now: update date={:?}, body_len={}", + MODULE, + update.date, + update.body.as_ref().map(|b| b.len()).unwrap_or(0) + ); + let app2 = app.clone(); + let download_start = std::time::Instant::now(); + update .download_and_install( |received, total| { + let total_val = total.unwrap_or(0); + let percent = if total_val > 0 { + (received as f64 / total_val as f64 * 100.0) as u32 + } else { + 0 + }; + trace!( + "[{}] updater_install_now: download progress {}/{} bytes ({}%)", + MODULE, received, total_val, percent + ); let payload = serde_json::json!({ "kind": "progress", "received": received, - "total": total + "total": total_val }); let _ = app2.emit("update:progress", payload); }, || { + let download_elapsed = download_start.elapsed(); + info!( + "[{}] updater_install_now: download completed in {:?}", + MODULE, download_elapsed + ); let _ = app2.emit( "update:progress", serde_json::json!({ "kind": "downloaded" }), @@ -37,9 +83,25 @@ pub async fn updater_install_now(window: Window) -> Result<(), St }, ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| { + error!("[{}] updater_install_now: download/install failed: {}", MODULE, e); + e.to_string() + })?; + + let elapsed = start.elapsed(); + info!( + "[{}] updater_install_now: update installed successfully in {:?}", + MODULE, elapsed + ); + Ok(()) + } + None => { + let elapsed = start.elapsed(); + debug!( + "[{}] updater_install_now: no update available (checked in {:?})", + MODULE, elapsed + ); Ok(()) } - None => Ok(()), } } diff --git a/Backend/src/utilities/utilities.rs b/Backend/src/utilities/utilities.rs index eda4570c..074631e7 100644 --- a/Backend/src/utilities/utilities.rs +++ b/Backend/src/utilities/utilities.rs @@ -1,7 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, info, trace, warn}; use serde::Serialize; +const MODULE: &str = "utilities"; + #[derive(Serialize)] pub struct AboutInfo { pub name: String, @@ -21,6 +24,8 @@ impl AboutInfo { /// # Returns /// - A populated [`AboutInfo`] record. pub fn gather() -> Self { + trace!("[{}] AboutInfo::gather: collecting application metadata", MODULE); + // Compile-time package metadata from Cargo let name = env!("CARGO_PKG_NAME").to_string(); let version = env!("OPENVCS_VERSION").to_string(); @@ -38,6 +43,11 @@ impl AboutInfo { let os = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); + debug!( + "[{}] AboutInfo::gather: {} v{} on {}-{}", + MODULE, name, version, os, arch + ); + Self { name, version, @@ -65,6 +75,9 @@ pub async fn browse_directory_async( app: tauri::AppHandle, title: &str, ) -> Option { + let start = std::time::Instant::now(); + info!("[{}] browse_directory_async: opening folder picker (title='{}')", MODULE, title); + let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); // OWNED Dialog let (tx, rx) = tokio::sync::oneshot::channel::>(); @@ -74,7 +87,25 @@ pub async fn browse_directory_async( let _ = tx.send(res.map(|p| p.to_string())); }); - rx.await.unwrap_or(None) + let result = rx.await.unwrap_or(None); + let elapsed = start.elapsed(); + + match &result { + Some(path) => { + debug!( + "[{}] browse_directory_async: selected '{}' in {:?}", + MODULE, path, elapsed + ); + } + None => { + debug!( + "[{}] browse_directory_async: canceled in {:?}", + MODULE, elapsed + ); + } + } + + result } /// Opens a native file picker and returns the selected file path. @@ -92,6 +123,12 @@ pub async fn browse_file_async( title: &str, extensions: &[&str], ) -> Option { + let start = std::time::Instant::now(); + info!( + "[{}] browse_file_async: opening file picker (title='{}', extensions={:?})", + MODULE, title, extensions + ); + let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); let (tx, rx) = tokio::sync::oneshot::channel::>(); @@ -103,5 +140,23 @@ pub async fn browse_file_async( let _ = tx.send(res.map(|p| p.to_string())); }); - rx.await.unwrap_or(None) + let result = rx.await.unwrap_or(None); + let elapsed = start.elapsed(); + + match &result { + Some(path) => { + debug!( + "[{}] browse_file_async: selected '{}' in {:?}", + MODULE, path, elapsed + ); + } + None => { + debug!( + "[{}] browse_file_async: canceled in {:?}", + MODULE, elapsed + ); + } + } + + result } diff --git a/Frontend/src/scripts/lib/logger.ts b/Frontend/src/scripts/lib/logger.ts index 5cec6dd7..36bac15e 100644 --- a/Frontend/src/scripts/lib/logger.ts +++ b/Frontend/src/scripts/lib/logger.ts @@ -3,50 +3,108 @@ import { TAURI } from "./tauri"; -type LogLevel = "debug" | "info" | "warn" | "error"; +type LogLevel = "trace" | "debug" | "info" | "warn" | "error"; + +interface Logger { + trace: (...args: unknown[]) => void; + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} function formatMessage(...args: unknown[]): string { - return args - .map((a) => { - if (a instanceof Error) return a.message; - if (typeof a === "object") { - try { - return JSON.stringify(a); - } catch { - return String(a); - } - } - return String(a); - }) - .join(" "); + return args + .map((a) => { + if (a instanceof Error) { + return `${a.name}: ${a.message}${a.stack ? `\n${a.stack}` : ""}`; + } + if (typeof a === "object") { + try { + return JSON.stringify(a, null, 2); + } catch { + return String(a); + } + } + return String(a); + }) + .join(" "); } function sendToBackend(level: LogLevel, message: string): void { - if (TAURI.has) { - TAURI.invoke("log_frontend_message", { level, message }).catch(() => {}); - } + if (TAURI.has) { + TAURI.invoke("log_frontend_message", { level, message }).catch(() => {}); + } +} + +function createLogger(module: string): Logger { + const prefix = `[${module}]`; + + return { + trace: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("trace", msg); + }, + debug: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("debug", msg); + }, + info: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("info", msg); + }, + warn: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("warn", msg); + }, + error: (...args: unknown[]) => { + const msg = `${prefix} ${formatMessage(...args)}`; + sendToBackend("error", msg); + }, + }; } function installFrontendLogger(): void { - console.debug = (...args: unknown[]) => { - const msg = formatMessage(...args); - sendToBackend("debug", msg); - }; - - console.log = (...args: unknown[]) => { - const msg = formatMessage(...args); - sendToBackend("debug", msg); - }; - - console.warn = (...args: unknown[]) => { - const msg = formatMessage(...args); - sendToBackend("warn", msg); - }; - - console.error = (...args: unknown[]) => { - const msg = formatMessage(...args); - sendToBackend("error", msg); - }; + const originalConsole = { + debug: console.debug, + log: console.log, + warn: console.warn, + error: console.error, + }; + + console.debug = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("debug", msg); + }; + + console.log = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("info", msg); + }; + + console.warn = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("warn", msg); + }; + + console.error = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("error", msg); + }; + + console.trace = (...args: unknown[]) => { + const msg = formatMessage(...args); + sendToBackend("trace", msg); + }; } installFrontendLogger(); + +export const logger = { + create: createLogger, + trace: (...args: unknown[]) => sendToBackend("trace", formatMessage(...args)), + debug: (...args: unknown[]) => sendToBackend("debug", formatMessage(...args)), + info: (...args: unknown[]) => sendToBackend("info", formatMessage(...args)), + warn: (...args: unknown[]) => sendToBackend("warn", formatMessage(...args)), + error: (...args: unknown[]) => sendToBackend("error", formatMessage(...args)), +}; From 942997cd7d9eeb217ca351119e142b65b770714f Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 00:03:50 +0000 Subject: [PATCH 38/96] Update logging.rs --- Backend/src/logging.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index 2bec4a38..80990dd4 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -17,23 +17,21 @@ static ACTIVE_LOG_FILE: OnceLock>> = OnceLock::new(); pub struct LogTimer { start: Instant, operation: &'static str, - module: &'static str, } impl LogTimer { /// Creates a new timer for the given operation. /// /// # Parameters - /// - `module`: Module/component name (e.g., "vcs_proxy", "ssh"). + /// - `_module`: Module/component name (unused, kept for API compatibility). /// - `operation`: Operation name (e.g., "fetch", "push"). /// /// # Returns /// - A new `LogTimer` instance. - pub fn new(module: &'static str, operation: &'static str) -> Self { + pub fn new(_module: &'static str, operation: &'static str) -> Self { Self { start: Instant::now(), operation, - module, } } @@ -52,13 +50,7 @@ impl Drop for LogTimer { let elapsed = self.start.elapsed(); let ms = elapsed.as_millis(); let us = elapsed.as_micros() - (ms * 1000); - log::trace!( - "[{}] {} completed in {}.{:03}ms", - self.module, - self.operation, - ms, - us - ); + log::trace!("{} completed in {}.{:03}ms", self.operation, ms, us); } } From 07d9656aa3873b5792b6c9592dfb128f86a0d8cb Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 00:06:40 +0000 Subject: [PATCH 39/96] Update --- Backend/src/plugin_bundles.rs | 100 +++---- Backend/src/plugin_runtime/host_api.rs | 133 +++------ Backend/src/plugin_runtime/vcs_proxy.rs | 360 +++++++++++------------- Backend/src/plugin_vcs_backends.rs | 102 +++---- Backend/src/tauri_commands/conflicts.rs | 58 ++-- Backend/src/tauri_commands/ssh.rs | 82 +++--- Backend/src/tauri_commands/updater.rs | 28 +- Backend/src/utilities/utilities.rs | 22 +- 8 files changed, 349 insertions(+), 536 deletions(-) diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 0b292a27..3e69462e 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -242,8 +242,7 @@ impl PluginBundleStore { let root = plugins_dir(); ensure_dir(&root); trace!( - "[{}] PluginBundleStore::new_default: root={}", - MODULE, + "PluginBundleStore::new_default: root={}", root.display() ); Self { root } @@ -290,19 +289,16 @@ impl PluginBundleStore { let _timer = LogTimer::new(MODULE, "install_ovcsp_with_limits"); let start = std::time::Instant::now(); info!( - "[{}] install_ovcsp_with_limits: bundle={}", - MODULE, + "install_ovcsp_with_limits: bundle={}", bundle_path.display() ); debug!( - "[{}] install_ovcsp_with_limits: limits={{max_files={}, max_file_bytes={}, max_total_bytes={}}}", - MODULE, limits.max_files, limits.max_file_bytes, limits.max_total_bytes + "install_ovcsp_with_limits: limits={{max_files={}, max_file_bytes={}, max_total_bytes={}}}", limits.max_files, limits.max_file_bytes, limits.max_total_bytes ); if !bundle_path.is_file() { error!( - "[{}] install_ovcsp_with_limits: bundle is not a file: {}", - MODULE, + "install_ovcsp_with_limits: bundle is not a file: {}", bundle_path.display() ); return Err(format!("bundle is not a file: {}", bundle_path.display())); @@ -310,8 +306,7 @@ impl PluginBundleStore { fs::create_dir_all(&self.root).map_err(|e| { error!( - "[{}] install_ovcsp_with_limits: failed to create {}: {}", - MODULE, + "install_ovcsp_with_limits: failed to create {}: {}", self.root.display(), e ); @@ -322,16 +317,14 @@ impl PluginBundleStore { let bundle_compressed_bytes = fs::metadata(bundle_path) .map_err(|e| { error!( - "[{}] install_ovcsp_with_limits: failed to get metadata: {}", - MODULE, e + "install_ovcsp_with_limits: failed to get metadata: {}", e ); format!("metadata {}: {e}", bundle_path.display()) })? .len(); debug!( - "[{}] install_ovcsp_with_limits: bundle size={} bytes, sha256={}", - MODULE, + "install_ovcsp_with_limits: bundle size={} bytes, sha256={}", bundle_compressed_bytes, &bundle_sha256[..12] ); @@ -347,8 +340,7 @@ impl PluginBundleStore { } debug!( - "[{}] install_ovcsp_with_limits: plugin_id={}, version={:?}", - MODULE, plugin_id, manifest.version + "install_ovcsp_with_limits: plugin_id={}, version={:?}", plugin_id, manifest.version ); // Enforce that the top-level directory name matches the manifest id. @@ -360,8 +352,7 @@ impl PluginBundleStore { .to_string(); if bundle_root != plugin_id { error!( - "[{}] install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", - MODULE, bundle_root, plugin_id + "install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", bundle_root, plugin_id ); return Err(format!( "bundle root folder '{}' does not match manifest id '{}'", @@ -384,8 +375,7 @@ impl PluginBundleStore { let staging_version_dir = staging.join(&version); trace!( - "[{}] install_ovcsp_with_limits: staging_dir={}, plugin_dir={}", - MODULE, + "install_ovcsp_with_limits: staging_dir={}, plugin_dir={}", staging_version_dir.display(), plugin_dir.display() ); @@ -563,8 +553,7 @@ impl PluginBundleStore { let extracted_manifest = staging_version_dir.join(PLUGIN_MANIFEST_NAME); if !extracted_manifest.is_file() { error!( - "[{}] install_ovcsp_with_limits: missing manifest at {}", - MODULE, + "install_ovcsp_with_limits: missing manifest at {}", extracted_manifest.display() ); return Err(format!( @@ -588,15 +577,13 @@ impl PluginBundleStore { validate_entrypoint(&staging_version_dir, module_exec.as_deref(), "module")?; debug!( - "[{}] install_ovcsp_with_limits: extracted {} files, promoting to final location", - MODULE, total_files + "install_ovcsp_with_limits: extracted {} files, promoting to final location", total_files ); // Promote staged version into place (flat layout, drop old version directory). if plugin_dir.exists() { trace!( - "[{}] install_ovcsp_with_limits: removing existing plugin dir {}", - MODULE, + "install_ovcsp_with_limits: removing existing plugin dir {}", plugin_dir.display() ); fs::remove_dir_all(&plugin_dir) @@ -604,8 +591,7 @@ impl PluginBundleStore { } fs::rename(&staging_version_dir, &plugin_dir).map_err(|e| { error!( - "[{}] install_ovcsp_with_limits: failed to move plugin into place: {}", - MODULE, e + "install_ovcsp_with_limits: failed to move plugin into place: {}", e ); format!( "move installed plugin into place {} -> {}: {e}", @@ -645,8 +631,7 @@ impl PluginBundleStore { let elapsed = start.elapsed(); info!( - "[{}] install_ovcsp_with_limits: installed plugin {} v{} in {:?}", - MODULE, plugin_id, version, elapsed + "install_ovcsp_with_limits: installed plugin {} v{} in {:?}", plugin_id, version, elapsed ); Ok(InstalledPlugin { @@ -668,23 +653,20 @@ impl PluginBundleStore { let _timer = LogTimer::new(MODULE, "sync_built_in_plugins"); let bundles = builtin_bundle_paths(); info!( - "[{}] sync_built_in_plugins: syncing {} built-in bundles", - MODULE, + "sync_built_in_plugins: syncing {} built-in bundles", bundles.len() ); let mut errors = Vec::new(); for bundle in &bundles { debug!( - "[{}] sync_built_in_plugins: checking {}", - MODULE, + "sync_built_in_plugins: checking {}", bundle.display() ); if let Err(err) = self.ensure_built_in_bundle(bundle) { let msg = format!("{}: {}", bundle.display(), err); warn!( - "[{}] sync_built_in_plugins: failed to sync: {}", - MODULE, msg + "sync_built_in_plugins: failed to sync: {}", msg ); errors.push(msg); } @@ -698,8 +680,7 @@ impl PluginBundleStore { Ok(()) } else { error!( - "[{}] sync_built_in_plugins: {} bundles failed", - MODULE, + "sync_built_in_plugins: {} bundles failed", errors.len() ); Err(errors.join("; ")) @@ -716,8 +697,7 @@ impl PluginBundleStore { /// - `Err(String)` on install/validation failures. fn ensure_built_in_bundle(&self, bundle_path: &Path) -> Result<(), String> { trace!( - "[{}] ensure_built_in_bundle: {}", - MODULE, + "ensure_built_in_bundle: {}", bundle_path.display() ); @@ -737,33 +717,28 @@ impl PluginBundleStore { if let Some(installed) = self.get_current_installed(&plugin_id)? { if installed.bundle_sha256 == bundle_sha256 && installed.version == version { trace!( - "[{}] ensure_built_in_bundle: {} already installed and current", - MODULE, + "ensure_built_in_bundle: {} already installed and current", plugin_id ); return Ok(()); } debug!( - "[{}] ensure_built_in_bundle: {} needs update (installed={}, new={})", - MODULE, plugin_id, installed.version, version + "ensure_built_in_bundle: {} needs update (installed={}, new={})", plugin_id, installed.version, version ); } debug!( - "[{}] ensure_built_in_bundle: installing {}", - MODULE, plugin_id + "ensure_built_in_bundle: installing {}", plugin_id ); self.install_ovcsp_with_limits(bundle_path, InstallerLimits::default())?; if let Err(err) = self.approve_capabilities(&plugin_id, &version, true) { warn!( - "[{}] ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", - MODULE, plugin_id, version, err + "ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", plugin_id, version, err ); } else { debug!( - "[{}] ensure_built_in_bundle: auto-approved built-in {} ({})", - MODULE, plugin_id, version + "ensure_built_in_bundle: auto-approved built-in {} ({})", plugin_id, version ); } Ok(()) @@ -780,35 +755,33 @@ impl PluginBundleStore { pub fn uninstall_plugin(&self, plugin_id: &str) -> Result<(), String> { let _timer = LogTimer::new(MODULE, "uninstall_plugin"); let id = plugin_id.trim(); - info!("[{}] uninstall_plugin: plugin={}", MODULE, id); + info!("uninstall_plugin: plugin={}", id); if id.is_empty() { - warn!("[{}] uninstall_plugin: empty plugin id", MODULE); + warn!("uninstall_plugin: empty plugin id"); return Err("plugin id is empty".to_string()); } let lower = id.to_ascii_lowercase(); if built_in_plugin_ids().contains(&lower) { warn!( - "[{}] uninstall_plugin: cannot uninstall built-in plugin {}", - MODULE, id + "uninstall_plugin: cannot uninstall built-in plugin {}", id ); return Err("built-in plugins cannot be removed".to_string()); } let dir = self.root.join(id); if !dir.exists() { - debug!("[{}] uninstall_plugin: plugin {} not installed", MODULE, id); + debug!("uninstall_plugin: plugin {} not installed", id); return Ok(()); } fs::remove_dir_all(&dir).map_err(|e| { error!( - "[{}] uninstall_plugin: failed to remove {}: {}", - MODULE, + "uninstall_plugin: failed to remove {}: {}", dir.display(), e ); format!("remove {}: {e}", dir.display()) })?; - debug!("[{}] uninstall_plugin: plugin {} removed", MODULE, id); + debug!("uninstall_plugin: plugin {} removed", id); Ok(()) } @@ -820,20 +793,18 @@ impl PluginBundleStore { pub fn list_installed(&self) -> Result, String> { let _timer = LogTimer::new(MODULE, "list_installed"); trace!( - "[{}] list_installed: scanning {}", - MODULE, + "list_installed: scanning {}", self.root.display() ); if !self.root.is_dir() { - debug!("[{}] list_installed: root does not exist", MODULE); + debug!("list_installed: root does not exist"); return Ok(Vec::new()); } let mut out = Vec::new(); let entries = fs::read_dir(&self.root).map_err(|e| { error!( - "[{}] list_installed: failed to read {}: {}", - MODULE, + "list_installed: failed to read {}: {}", self.root.display(), e ); @@ -856,8 +827,7 @@ impl PluginBundleStore { } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); debug!( - "[{}] list_installed: found {} installed plugins", - MODULE, + "list_installed: found {} installed plugins", out.len() ); Ok(out) diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 2e3753c1..013367c0 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -87,13 +87,12 @@ fn host_error(code: &str, message: impl Into) -> PluginError { /// Resolves a plugin-supplied path under an allowed workspace root. fn resolve_under_root(root: &Path, path: &str) -> Result { trace!( - "[{}] resolve_under_root: root={}, path={}", - MODULE, + "resolve_under_root: root={}, path={}", root.display(), path ); if path.contains('\0') { - warn!("[{}] resolve_under_root: path contains NUL", MODULE); + warn!("resolve_under_root: path contains NUL"); return Err("path contains NUL".to_string()); } @@ -126,8 +125,7 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => { warn!( - "[{}] resolve_under_root: invalid path component in '{}'", - MODULE, path + "resolve_under_root: invalid path component in '{}'", path ); return Err("path must be relative and not contain '..'".to_string()); } @@ -135,8 +133,7 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { } let resolved = root.join(clean); trace!( - "[{}] resolve_under_root: resolved to {}", - MODULE, + "resolve_under_root: resolved to {}", resolved.display() ); Ok(resolved) @@ -145,8 +142,7 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { /// Writes bytes to a relative path constrained to the workspace root. fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { trace!( - "[{}] write_file_under_root: root={}, rel={}, len={}", - MODULE, + "write_file_under_root: root={}, rel={}, len={}", root.display(), rel, bytes.len() @@ -157,8 +153,7 @@ fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), Str } fs::write(&path, bytes).map_err(|e| { error!( - "[{}] write_file_under_root: failed to write {}: {}", - MODULE, + "write_file_under_root: failed to write {}: {}", path.display(), e ); @@ -169,24 +164,21 @@ fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), Str /// Reads bytes from a relative path constrained to the workspace root. fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { trace!( - "[{}] read_file_under_root: root={}, rel={}", - MODULE, + "read_file_under_root: root={}, rel={}", root.display(), rel ); let path = resolve_under_root(root, rel)?; let result = fs::read(&path).map_err(|e| { error!( - "[{}] read_file_under_root: failed to read {}: {}", - MODULE, + "read_file_under_root: failed to read {}: {}", path.display(), e ); format!("read {}: {e}", path.display()) })?; debug!( - "[{}] read_file_under_root: read {} bytes from {}", - MODULE, + "read_file_under_root: read {} bytes from {}", result.len(), rel ); @@ -222,7 +214,7 @@ fn sanitized_env() -> Vec<(OsString, OsString)> { /// Returns runtime metadata exposed to plugins. pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { - trace!("[{}] host_runtime_info: gathering runtime info", MODULE); + trace!("host_runtime_info: gathering runtime info"); let kind = runtime_container_kind(); let info = openvcs_core::RuntimeInfo { os: Some(std::env::consts::OS.to_string()), @@ -230,8 +222,7 @@ pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { container: Some(kind.to_string()), }; debug!( - "[{}] host_runtime_info: os={}, arch={}, container={}", - MODULE, + "host_runtime_info: os={}, arch={}, container={}", info.os.as_deref().unwrap_or("unknown"), info.arch.as_deref().unwrap_or("unknown"), kind @@ -244,24 +235,21 @@ pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult let _timer = LogTimer::new(MODULE, "host_subscribe_event"); let name = event_name.trim(); trace!( - "[{}] host_subscribe_event: plugin={}, event='{}'", - MODULE, + "host_subscribe_event: plugin={}, event='{}'", spawn.plugin_id, name ); if name.is_empty() { warn!( - "[{}] host_subscribe_event: empty event name from plugin {}", - MODULE, spawn.plugin_id + "host_subscribe_event: empty event name from plugin {}", spawn.plugin_id ); return Err(host_error("host.invalid_event_name", "event name is empty")); } crate::plugin_runtime::events::subscribe(&spawn.plugin_id, name); debug!( - "[{}] host_subscribe_event: plugin {} subscribed to '{}'", - MODULE, spawn.plugin_id, name + "host_subscribe_event: plugin {} subscribed to '{}'", spawn.plugin_id, name ); Ok(()) } @@ -271,8 +259,7 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> let _timer = LogTimer::new(MODULE, "host_emit_event"); let name = event_name.trim(); trace!( - "[{}] host_emit_event: plugin={}, event='{}', payload_len={}", - MODULE, + "host_emit_event: plugin={}, event='{}', payload_len={}", spawn.plugin_id, name, payload.len() @@ -280,8 +267,7 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> if name.is_empty() { warn!( - "[{}] host_emit_event: empty event name from plugin {}", - MODULE, spawn.plugin_id + "host_emit_event: empty event name from plugin {}", spawn.plugin_id ); return Err(host_error("host.invalid_event_name", "event name is empty")); } @@ -291,8 +277,7 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> } else { serde_json::from_slice(payload).map_err(|err| { error!( - "[{}] host_emit_event: invalid JSON payload from plugin {}: {}", - MODULE, spawn.plugin_id, err + "host_emit_event: invalid JSON payload from plugin {}: {}", spawn.plugin_id, err ); host_error( "host.invalid_payload", @@ -303,8 +288,7 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, name, payload_json); debug!( - "[{}] host_emit_event: plugin {} emitted '{}'", - MODULE, spawn.plugin_id, name + "host_emit_event: plugin {} emitted '{}'", spawn.plugin_id, name ); Ok(()) } @@ -313,16 +297,14 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); trace!( - "[{}] host_ui_notify: plugin={}, message_len={}", - MODULE, + "host_ui_notify: plugin={}, message_len={}", spawn.plugin_id, message.len() ); if !caps.contains("ui.notifications") { warn!( - "[{}] host_ui_notify: capability denied for plugin {} (missing ui.notifications)", - MODULE, spawn.plugin_id + "host_ui_notify: capability denied for plugin {} (missing ui.notifications)", spawn.plugin_id ); return Err(host_error( "capability.denied", @@ -333,8 +315,7 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let message = message.trim(); if !message.is_empty() { info!( - "[{}] host_ui_notify: plugin[{}] notify: {}", - MODULE, spawn.plugin_id, message + "host_ui_notify: plugin[{}] notify: {}", spawn.plugin_id, message ); } Ok(()) @@ -344,8 +325,7 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { let _timer = LogTimer::new(MODULE, "host_workspace_read_file"); trace!( - "[{}] host_workspace_read_file: plugin={}, path='{}'", - MODULE, + "host_workspace_read_file: plugin={}, path='{}'", spawn.plugin_id, path ); @@ -354,8 +334,7 @@ pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult HostResult HostResult<()> { let _timer = LogTimer::new(MODULE, "host_workspace_write_file"); trace!( - "[{}] host_workspace_write_file: plugin={}, path='{}', len={}", - MODULE, + "host_workspace_write_file: plugin={}, path='{}', len={}", spawn.plugin_id, path, content.len() @@ -408,8 +383,7 @@ pub fn host_workspace_write_file( if !caps.contains("workspace.write") { warn!( - "[{}] host_workspace_write_file: capability denied for plugin {} (missing workspace.write)", - MODULE, spawn.plugin_id + "host_workspace_write_file: capability denied for plugin {} (missing workspace.write)", spawn.plugin_id ); return Err(host_error( "capability.denied", @@ -419,23 +393,20 @@ pub fn host_workspace_write_file( let Some(root) = workspace_root.as_ref() else { warn!( - "[{}] host_workspace_write_file: no workspace context for plugin {}", - MODULE, spawn.plugin_id + "host_workspace_write_file: no workspace context for plugin {}", spawn.plugin_id ); return Err(host_error("workspace.denied", "no workspace context")); }; write_file_under_root(root, path, content).map_err(|err| { error!( - "[{}] host_workspace_write_file: failed for plugin {}: {}", - MODULE, spawn.plugin_id, err + "host_workspace_write_file: failed for plugin {}: {}", spawn.plugin_id, err ); host_error("workspace.error", err) })?; debug!( - "[{}] host_workspace_write_file: plugin {} wrote {} bytes to '{}'", - MODULE, + "host_workspace_write_file: plugin {} wrote {} bytes to '{}'", spawn.plugin_id, content.len(), path @@ -453,19 +424,16 @@ pub fn host_process_exec_git( ) -> HostResult { let _timer = LogTimer::new(MODULE, "host_process_exec_git"); info!( - "[{}] host_process_exec_git: plugin={}, args={:?}", - MODULE, spawn.plugin_id, args + "host_process_exec_git: plugin={}, args={:?}", spawn.plugin_id, args ); debug!( - "[{}] host_process_exec_git: cwd={:?}, env_count={}, has_stdin={}", - MODULE, + "host_process_exec_git: cwd={:?}, env_count={}, has_stdin={}", cwd, env.len(), stdin.is_some() ); trace!( - "[{}] host_process_exec_git: env={:?}, stdin_len={}", - MODULE, + "host_process_exec_git: env={:?}, stdin_len={}", env, stdin.map(|s| s.len()).unwrap_or(0) ); @@ -474,8 +442,7 @@ pub fn host_process_exec_git( if !caps.contains("process.exec") { warn!( - "[{}] host_process_exec_git: capability denied for plugin {} (missing process.exec)", - MODULE, spawn.plugin_id + "host_process_exec_git: capability denied for plugin {} (missing process.exec)", spawn.plugin_id ); return Err(host_error( "capability.denied", @@ -489,15 +456,13 @@ pub fn host_process_exec_git( Some(raw) => { let Some(root) = spawn.allowed_workspace_root.as_ref() else { warn!( - "[{}] host_process_exec_git: no workspace context for plugin {}", - MODULE, spawn.plugin_id + "host_process_exec_git: no workspace context for plugin {}", spawn.plugin_id ); return Err(host_error("workspace.denied", "no workspace context")); }; Some(resolve_under_root(root, raw).map_err(|e| { warn!( - "[{}] host_process_exec_git: invalid cwd for plugin {}: {}", - MODULE, spawn.plugin_id, e + "host_process_exec_git: invalid cwd for plugin {}: {}", spawn.plugin_id, e ); host_error("workspace.denied", e) })?) @@ -505,8 +470,7 @@ pub fn host_process_exec_git( }; debug!( - "[{}] host_process_exec_git: executing git with cwd={:?}", - MODULE, + "host_process_exec_git: executing git with cwd={:?}", cwd.as_ref().map(|p| p.display()) ); @@ -531,8 +495,7 @@ pub fn host_process_exec_git( let out = if stdin_text.is_empty() { cmd.output().map_err(|e| { error!( - "[{}] host_process_exec_git: failed to spawn git: {}", - MODULE, e + "host_process_exec_git: failed to spawn git: {}", e ); host_error("process.error", format!("spawn git: {e}")) })? @@ -540,8 +503,7 @@ pub fn host_process_exec_git( cmd.stdin(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| { error!( - "[{}] host_process_exec_git: failed to spawn git: {}", - MODULE, e + "host_process_exec_git: failed to spawn git: {}", e ); host_error("process.error", format!("spawn git: {e}")) })?; @@ -549,16 +511,14 @@ pub fn host_process_exec_git( if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { let _ = child.kill(); error!( - "[{}] host_process_exec_git: failed to write stdin: {}", - MODULE, e + "host_process_exec_git: failed to write stdin: {}", e ); return Err(host_error("process.error", format!("write stdin: {e}"))); } } child.wait_with_output().map_err(|e| { error!( - "[{}] host_process_exec_git: failed to wait for process: {}", - MODULE, e + "host_process_exec_git: failed to wait for process: {}", e ); host_error("process.error", format!("wait: {e}")) })? @@ -574,22 +534,19 @@ pub fn host_process_exec_git( if result.success { debug!( - "[{}] host_process_exec_git: git {:?} succeeded in {:?} (code={})", - MODULE, + "host_process_exec_git: git {:?} succeeded in {:?} (code={})", args.first(), elapsed, result.status ); trace!( - "[{}] host_process_exec_git: stdout_len={}, stderr_len={}", - MODULE, + "host_process_exec_git: stdout_len={}, stderr_len={}", result.stdout.len(), result.stderr.len() ); } else { warn!( - "[{}] host_process_exec_git: git {:?} failed in {:?} (code={}): {}", - MODULE, + "host_process_exec_git: git {:?} failed in {:?} (code={}): {}", args.first(), elapsed, result.status, diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 1d65664f..c8378b93 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -46,12 +46,10 @@ impl PluginVcsProxy { let _timer = LogTimer::new(MODULE, "open_with_process"); let path_str = repo_path.to_string_lossy(); info!( - "[{}] open_with_process: backend={}, path={}", - MODULE, backend_id, path_str + "open_with_process: backend={}, path={}", backend_id, path_str ); debug!( - "[{}] open_with_process: config keys={:?}", - MODULE, + "open_with_process: config keys={:?}", cfg.as_object().map(|o| o.keys().collect::>()) ); @@ -68,26 +66,24 @@ impl PluginVcsProxy { ); p.runtime.ensure_running().map_err(|e| { error!( - "[{}] open_with_process: failed to ensure runtime running: {}", - MODULE, e + "open_with_process: failed to ensure runtime running: {}", e ); VcsError::Backend { backend: p.backend_id.clone(), msg: e, } })?; - debug!("[{}] open_with_process: runtime confirmed running", MODULE); + debug!("open_with_process: runtime confirmed running"); let params = json!({ "path": path_to_utf8(repo_path)?, "config": cfg }); - trace!("[{}] open_with_process: calling open RPC", MODULE); + trace!("open_with_process: calling open RPC"); p.call_unit("open", params.clone()).map_err(|e| { - error!("[{}] open_with_process: open RPC failed: {}", MODULE, e); + error!("open_with_process: open RPC failed: {}", e); e })?; info!( - "[{}] open_with_process: opened backend {} for {}", - MODULE, backend_id, path_str + "open_with_process: opened backend {} for {}", backend_id, path_str ); Ok(Arc::new(p)) } @@ -103,15 +99,13 @@ impl PluginVcsProxy { /// - `Err(VcsError)` on RPC failure. fn call_value(&self, method: &str, params: Value) -> Result { trace!( - "[{}] call_value: method={}, params_len={}", - MODULE, + "call_value: method={}, params_len={}", method, params.to_string().len() ); let result = self.runtime.call(method, params).map_err(|e| { error!( - "[{}] call_value: RPC call '{}' failed: {}", - MODULE, method, e + "call_value: RPC call '{}' failed: {}", method, e ); VcsError::Backend { backend: self.backend_id.clone(), @@ -120,8 +114,7 @@ impl PluginVcsProxy { }); if let Ok(ref v) = result { trace!( - "[{}] call_value: method={} returned {} bytes", - MODULE, + "call_value: method={} returned {} bytes", method, v.to_string().len() ); @@ -139,12 +132,11 @@ impl PluginVcsProxy { /// - `Ok(T)` deserialized result. /// - `Err(VcsError)` on RPC or decode failure. fn call_json(&self, method: &str, params: Value) -> Result { - trace!("[{}] call_json: method={}", MODULE, method); + trace!("call_json: method={}", method); let v = self.call_value(method, params)?; serde_json::from_value(v).map_err(|e| { error!( - "[{}] call_json: failed to deserialize response for '{}': {}", - MODULE, method, e + "call_json: failed to deserialize response for '{}': {}", method, e ); VcsError::Backend { backend: self.backend_id.clone(), @@ -163,7 +155,7 @@ impl PluginVcsProxy { /// - `Ok(())` on success. /// - `Err(VcsError)` on RPC failure. fn call_unit(&self, method: &str, params: Value) -> Result<(), VcsError> { - trace!("[{}] call_unit: method={}", MODULE, method); + trace!("call_unit: method={}", method); let _ = self.call_value(method, params)?; Ok(()) } @@ -182,7 +174,7 @@ impl PluginVcsProxy { F: FnOnce() -> Result, { if on.is_some() { - debug!("[{}] with_events: installing event callback", MODULE); + debug!("with_events: installing event callback"); } let sink: Option> = on.map(|cb| Arc::new(move |evt| cb(evt)) as _); @@ -190,7 +182,7 @@ impl PluginVcsProxy { let res = f(); self.runtime.set_event_sink(None); if res.is_err() { - warn!("[{}] with_events: operation failed", MODULE); + warn!("with_events: operation failed"); } res } @@ -202,7 +194,7 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Backend id value. fn id(&self) -> BackendId { - trace!("[{}] id: returning backend_id={}", MODULE, self.backend_id); + trace!("id: returning backend_id={}", self.backend_id); self.backend_id.clone() } @@ -211,17 +203,16 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Capability set; defaults on decode failure. fn caps(&self) -> Capabilities { - trace!("[{}] caps: querying plugin capabilities", MODULE); + trace!("caps: querying plugin capabilities"); let result = self.call_json("caps", Value::Null); match result { Ok(caps) => { - debug!("[{}] caps: received capabilities from plugin", MODULE); + debug!("caps: received capabilities from plugin"); caps } Err(e) => { warn!( - "[{}] caps: failed to get capabilities, using defaults: {}", - MODULE, e + "caps: failed to get capabilities, using defaults: {}", e ); Capabilities::default() } @@ -277,7 +268,7 @@ impl Vcs for PluginVcsProxy { /// # Returns /// - Workdir path reference. fn workdir(&self) -> &Path { - trace!("[{}] workdir: returning {}", MODULE, self.workdir.display()); + trace!("workdir: returning {}", self.workdir.display()); &self.workdir } @@ -289,17 +280,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn current_branch(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "current_branch"); - trace!("[{}] current_branch: querying current branch", MODULE); + trace!("current_branch: querying current branch"); let result = self.call_json("current_branch", Value::Null); match &result { Ok(Some(branch)) => { - debug!("[{}] current_branch: on branch '{}'", MODULE, branch); + debug!("current_branch: on branch '{}'", branch); } Ok(None) => { - debug!("[{}] current_branch: detached HEAD", MODULE); + debug!("current_branch: detached HEAD"); } Err(e) => { - error!("[{}] current_branch: failed: {}", MODULE, e); + error!("current_branch: failed: {}", e); } } result @@ -312,15 +303,15 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn branches(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "branches"); - trace!("[{}] branches: querying all branches", MODULE); + trace!("branches: querying all branches"); let result: VcsResult> = self.call_json("branches", Value::Null); match &result { Ok(branches) => { - debug!("[{}] branches: found {} branches", MODULE, branches.len()); + debug!("branches: found {} branches", branches.len()); } Err(e) => { - error!("[{}] branches: failed: {}", MODULE, e); + error!("branches: failed: {}", e); } } result @@ -333,18 +324,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn local_branches(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "local_branches"); - trace!("[{}] local_branches: querying local branches", MODULE); + trace!("local_branches: querying local branches"); let result: VcsResult> = self.call_json("local_branches", Value::Null); match &result { Ok(branches) => { debug!( - "[{}] local_branches: found {} local branches", - MODULE, + "local_branches: found {} local branches", branches.len() ); } Err(e) => { - error!("[{}] local_branches: failed: {}", MODULE, e); + error!("local_branches: failed: {}", e); } } result @@ -362,8 +352,7 @@ impl Vcs for PluginVcsProxy { fn create_branch(&self, name: &str, checkout: bool) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "create_branch"); info!( - "[{}] create_branch: name={}, checkout={}", - MODULE, name, checkout + "create_branch: name={}, checkout={}", name, checkout ); let result = self.call_unit( "create_branch", @@ -371,12 +360,11 @@ impl Vcs for PluginVcsProxy { ); match &result { Ok(()) => { - debug!("[{}] create_branch: branch '{}' created", MODULE, name); + debug!("create_branch: branch '{}' created", name); } Err(e) => { error!( - "[{}] create_branch: failed to create '{}': {}", - MODULE, name, e + "create_branch: failed to create '{}': {}", name, e ); } } @@ -393,16 +381,15 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn checkout_branch(&self, name: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "checkout_branch"); - info!("[{}] checkout_branch: name={}", MODULE, name); + info!("checkout_branch: name={}", name); let result = self.call_unit("checkout_branch", json!({ "name": name })); match &result { Ok(()) => { - debug!("[{}] checkout_branch: switched to '{}'", MODULE, name); + debug!("checkout_branch: switched to '{}'", name); } Err(e) => { error!( - "[{}] checkout_branch: failed to switch to '{}': {}", - MODULE, name, e + "checkout_branch: failed to switch to '{}': {}", name, e ); } } @@ -420,14 +407,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn ensure_remote(&self, name: &str, url: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "ensure_remote"); - info!("[{}] ensure_remote: name={}, url={}", MODULE, name, url); + info!("ensure_remote: name={}, url={}", name, url); let result = self.call_unit("ensure_remote", json!({ "name": name, "url": url })); match &result { Ok(()) => { - debug!("[{}] ensure_remote: remote '{}' configured", MODULE, name); + debug!("ensure_remote: remote '{}' configured", name); } Err(e) => { - error!("[{}] ensure_remote: failed for '{}': {}", MODULE, name, e); + error!("ensure_remote: failed for '{}': {}", name, e); } } result @@ -440,17 +427,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn list_remotes(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "list_remotes"); - trace!("[{}] list_remotes: querying remotes", MODULE); + trace!("list_remotes: querying remotes"); let result: VcsResult> = self.call_json("list_remotes", Value::Null); match &result { Ok(remotes) => { - debug!("[{}] list_remotes: found {} remotes", MODULE, remotes.len()); + debug!("list_remotes: found {} remotes", remotes.len()); for (name, url) in remotes { - trace!("[{}] list_remotes: remote '{}' -> '{}'", MODULE, name, url); + trace!("list_remotes: remote '{}' -> '{}'", name, url); } } Err(e) => { - error!("[{}] list_remotes: failed: {}", MODULE, e); + error!("list_remotes: failed: {}", e); } } result @@ -466,16 +453,15 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn remove_remote(&self, name: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "remove_remote"); - info!("[{}] remove_remote: name={}", MODULE, name); + info!("remove_remote: name={}", name); let result = self.call_unit("remove_remote", json!({ "name": name })); match &result { Ok(()) => { - debug!("[{}] remove_remote: remote '{}' removed", MODULE, name); + debug!("remove_remote: remote '{}' removed", name); } Err(e) => { error!( - "[{}] remove_remote: failed to remove '{}': {}", - MODULE, name, e + "remove_remote: failed to remove '{}': {}", name, e ); } } @@ -494,16 +480,16 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn fetch(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "fetch"); - info!("[{}] fetch: remote={}, refspec={}", MODULE, remote, refspec); + info!("fetch: remote={}, refspec={}", remote, refspec); let result = self.with_events(on, || { self.call_unit("fetch", json!({ "remote": remote, "refspec": refspec })) }); match &result { Ok(()) => { - debug!("[{}] fetch: completed successfully", MODULE); + debug!("fetch: completed successfully"); } Err(e) => { - error!("[{}] fetch: failed: {}", MODULE, e); + error!("fetch: failed: {}", e); } } result @@ -529,8 +515,7 @@ impl Vcs for PluginVcsProxy { ) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "fetch_with_options"); info!( - "[{}] fetch_with_options: remote={}, refspec={}, opts={:?}", - MODULE, remote, refspec, opts + "fetch_with_options: remote={}, refspec={}, opts={:?}", remote, refspec, opts ); let result = self.with_events(on, || { self.call_unit( @@ -540,10 +525,10 @@ impl Vcs for PluginVcsProxy { }); match &result { Ok(()) => { - debug!("[{}] fetch_with_options: completed successfully", MODULE); + debug!("fetch_with_options: completed successfully"); } Err(e) => { - error!("[{}] fetch_with_options: failed: {}", MODULE, e); + error!("fetch_with_options: failed: {}", e); } } result @@ -561,16 +546,16 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn push(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "push"); - info!("[{}] push: remote={}, refspec={}", MODULE, remote, refspec); + info!("push: remote={}, refspec={}", remote, refspec); let result = self.with_events(on, || { self.call_unit("push", json!({ "remote": remote, "refspec": refspec })) }); match &result { Ok(()) => { - debug!("[{}] push: completed successfully", MODULE); + debug!("push: completed successfully"); } Err(e) => { - error!("[{}] push: failed: {}", MODULE, e); + error!("push: failed: {}", e); } } result @@ -589,8 +574,7 @@ impl Vcs for PluginVcsProxy { fn pull_ff_only(&self, remote: &str, branch: &str, on: Option) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "pull_ff_only"); info!( - "[{}] pull_ff_only: remote={}, branch={}", - MODULE, remote, branch + "pull_ff_only: remote={}, branch={}", remote, branch ); let result = self.with_events(on, || { self.call_unit( @@ -600,10 +584,10 @@ impl Vcs for PluginVcsProxy { }); match &result { Ok(()) => { - debug!("[{}] pull_ff_only: completed successfully", MODULE); + debug!("pull_ff_only: completed successfully"); } Err(e) => { - error!("[{}] pull_ff_only: failed: {}", MODULE, e); + error!("pull_ff_only: failed: {}", e); } } result @@ -633,29 +617,27 @@ impl Vcs for PluginVcsProxy { .map(|p| p.to_string_lossy().to_string()) .collect(); info!( - "[{}] commit: author={} <{}>, paths={}, message_len={}", - MODULE, + "commit: author={} <{}>, paths={}, message_len={}", name, email, paths.len(), message.len() ); debug!( - "[{}] commit: message='{}'", - MODULE, + "commit: message='{}'", message.lines().next().unwrap_or("") ); - trace!("[{}] commit: paths={:?}", MODULE, paths); + trace!("commit: paths={:?}", paths); let result = self.call_json( "commit", json!({ "message": message, "name": name, "email": email, "paths": paths }), ); match &result { Ok(commit_id) => { - debug!("[{}] commit: created commit {}", MODULE, commit_id); + debug!("commit: created commit {}", commit_id); } Err(e) => { - error!("[{}] commit: failed: {}", MODULE, e); + error!("commit: failed: {}", e); } } result @@ -674,15 +656,13 @@ impl Vcs for PluginVcsProxy { fn commit_index(&self, message: &str, name: &str, email: &str) -> VcsResult { let _timer = LogTimer::new(MODULE, "commit_index"); info!( - "[{}] commit_index: author={} <{}>, message_len={}", - MODULE, + "commit_index: author={} <{}>, message_len={}", name, email, message.len() ); debug!( - "[{}] commit_index: message='{}'", - MODULE, + "commit_index: message='{}'", message.lines().next().unwrap_or("") ); let result = self.call_json( @@ -691,10 +671,10 @@ impl Vcs for PluginVcsProxy { ); match &result { Ok(commit_id) => { - debug!("[{}] commit_index: created commit {}", MODULE, commit_id); + debug!("commit_index: created commit {}", commit_id); } Err(e) => { - error!("[{}] commit_index: failed: {}", MODULE, e); + error!("commit_index: failed: {}", e); } } result @@ -707,17 +687,16 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn status_summary(&self) -> VcsResult { let _timer = LogTimer::new(MODULE, "status_summary"); - trace!("[{}] status_summary: querying status summary", MODULE); + trace!("status_summary: querying status summary"); let result: VcsResult = self.call_json("status_summary", Value::Null); match &result { Ok(summary) => { debug!( - "[{}] status_summary: {} staged, {} modified, {} untracked, {} conflicted", - MODULE, summary.staged, summary.modified, summary.untracked, summary.conflicted + "status_summary: {} staged, {} modified, {} untracked, {} conflicted", summary.staged, summary.modified, summary.untracked, summary.conflicted ); } Err(e) => { - error!("[{}] status_summary: failed: {}", MODULE, e); + error!("status_summary: failed: {}", e); } } result @@ -730,20 +709,19 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn status_payload(&self) -> VcsResult { let _timer = LogTimer::new(MODULE, "status_payload"); - trace!("[{}] status_payload: querying full status", MODULE); + trace!("status_payload: querying full status"); let result: VcsResult = self.call_json("status_payload", Value::Null); match &result { Ok(payload) => { debug!( - "[{}] status_payload: {} files, {} ahead, {} behind", - MODULE, + "status_payload: {} files, {} ahead, {} behind", payload.files.len(), payload.ahead, payload.behind ); } Err(e) => { - error!("[{}] status_payload: failed: {}", MODULE, e); + error!("status_payload: failed: {}", e); } } result @@ -760,8 +738,7 @@ impl Vcs for PluginVcsProxy { fn log_commits(&self, query: &LogQuery) -> VcsResult> { let _timer = LogTimer::new(MODULE, "log_commits"); trace!( - "[{}] log_commits: querying commits (limit={:?}, skip={:?})", - MODULE, + "log_commits: querying commits (limit={:?}, skip={:?})", query.limit, query.skip ); @@ -770,13 +747,12 @@ impl Vcs for PluginVcsProxy { match &result { Ok(commits) => { debug!( - "[{}] log_commits: {} commits returned", - MODULE, + "log_commits: {} commits returned", commits.len() ); } Err(e) => { - error!("[{}] log_commits: failed: {}", MODULE, e); + error!("log_commits: failed: {}", e); } } result @@ -793,20 +769,19 @@ impl Vcs for PluginVcsProxy { fn diff_file(&self, path: &Path) -> VcsResult> { let _timer = LogTimer::new(MODULE, "diff_file"); let path_str = path_to_utf8(path)?; - trace!("[{}] diff_file: path={}", MODULE, path_str); + trace!("diff_file: path={}", path_str); let result: VcsResult> = self.call_json("diff_file", json!({ "path": path_str.clone() })); match &result { Ok(lines) => { debug!( - "[{}] diff_file: {} lines for {}", - MODULE, + "diff_file: {} lines for {}", lines.len(), path_str ); } Err(e) => { - error!("[{}] diff_file: failed for '{}': {}", MODULE, path_str, e); + error!("diff_file: failed for '{}': {}", path_str, e); } } result @@ -822,19 +797,18 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn diff_commit(&self, rev: &str) -> VcsResult> { let _timer = LogTimer::new(MODULE, "diff_commit"); - trace!("[{}] diff_commit: rev={}", MODULE, rev); + trace!("diff_commit: rev={}", rev); let result: VcsResult> = self.call_json("diff_commit", json!({ "rev": rev })); match &result { Ok(lines) => { debug!( - "[{}] diff_commit: {} lines for {}", - MODULE, + "diff_commit: {} lines for {}", lines.len(), rev ); } Err(e) => { - error!("[{}] diff_commit: failed for '{}': {}", MODULE, rev, e); + error!("diff_commit: failed for '{}': {}", rev, e); } } result @@ -851,20 +825,18 @@ impl Vcs for PluginVcsProxy { fn conflict_details(&self, path: &Path) -> VcsResult { let _timer = LogTimer::new(MODULE, "conflict_details"); let path_str = path_to_utf8(path)?; - info!("[{}] conflict_details: path={}", MODULE, path_str); + info!("conflict_details: path={}", path_str); let result: VcsResult = self.call_json("conflict_details", json!({ "path": path_str.clone() })); match &result { Ok(details) => { debug!( - "[{}] conflict_details: got details for {} (binary={})", - MODULE, path_str, details.binary + "conflict_details: got details for {} (binary={})", path_str, details.binary ); } Err(e) => { error!( - "[{}] conflict_details: failed for '{}': {}", - MODULE, path_str, e + "conflict_details: failed for '{}': {}", path_str, e ); } } @@ -884,8 +856,7 @@ impl Vcs for PluginVcsProxy { let _timer = LogTimer::new(MODULE, "checkout_conflict_side"); let path_str = path_to_utf8(path)?; info!( - "[{}] checkout_conflict_side: path={}, side={:?}", - MODULE, path_str, side + "checkout_conflict_side: path={}, side={:?}", path_str, side ); let result = self.call_unit( "checkout_conflict_side", @@ -894,14 +865,12 @@ impl Vcs for PluginVcsProxy { match &result { Ok(()) => { debug!( - "[{}] checkout_conflict_side: resolved {} with {:?}", - MODULE, path_str, side + "checkout_conflict_side: resolved {} with {:?}", path_str, side ); } Err(e) => { error!( - "[{}] checkout_conflict_side: failed for '{}': {}", - MODULE, path_str, e + "checkout_conflict_side: failed for '{}': {}", path_str, e ); } } @@ -922,8 +891,7 @@ impl Vcs for PluginVcsProxy { let path_str = path_to_utf8(path)?; let content_str = String::from_utf8_lossy(content).to_string(); info!( - "[{}] write_merge_result: path={}, content_len={}", - MODULE, + "write_merge_result: path={}, content_len={}", path_str, content_str.len() ); @@ -934,14 +902,12 @@ impl Vcs for PluginVcsProxy { match &result { Ok(()) => { debug!( - "[{}] write_merge_result: wrote resolved content to {}", - MODULE, path_str + "write_merge_result: wrote resolved content to {}", path_str ); } Err(e) => { error!( - "[{}] write_merge_result: failed for '{}': {}", - MODULE, path_str, e + "write_merge_result: failed for '{}': {}", path_str, e ); } } @@ -958,14 +924,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stage_patch(&self, patch: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "stage_patch"); - info!("[{}] stage_patch: patch_len={}", MODULE, patch.len()); + info!("stage_patch: patch_len={}", patch.len()); let result = self.call_unit("stage_patch", json!({ "patch": patch })); match &result { Ok(()) => { - debug!("[{}] stage_patch: patch staged successfully", MODULE); + debug!("stage_patch: patch staged successfully"); } Err(e) => { - error!("[{}] stage_patch: failed: {}", MODULE, e); + error!("stage_patch: failed: {}", e); } } result @@ -985,15 +951,15 @@ impl Vcs for PluginVcsProxy { .iter() .map(|p| p.to_string_lossy().to_string()) .collect(); - info!("[{}] discard_paths: count={}", MODULE, paths.len()); - trace!("[{}] discard_paths: paths={:?}", MODULE, paths); + info!("discard_paths: count={}", paths.len()); + trace!("discard_paths: paths={:?}", paths); let result = self.call_unit("discard_paths", json!({ "paths": paths })); match &result { Ok(()) => { - debug!("[{}] discard_paths: changes discarded", MODULE); + debug!("discard_paths: changes discarded"); } Err(e) => { - error!("[{}] discard_paths: failed: {}", MODULE, e); + error!("discard_paths: failed: {}", e); } } result @@ -1010,17 +976,16 @@ impl Vcs for PluginVcsProxy { fn apply_reverse_patch(&self, patch: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "apply_reverse_patch"); info!( - "[{}] apply_reverse_patch: patch_len={}", - MODULE, + "apply_reverse_patch: patch_len={}", patch.len() ); let result = self.call_unit("apply_reverse_patch", json!({ "patch": patch })); match &result { Ok(()) => { - debug!("[{}] apply_reverse_patch: patch applied in reverse", MODULE); + debug!("apply_reverse_patch: patch applied in reverse"); } Err(e) => { - error!("[{}] apply_reverse_patch: failed: {}", MODULE, e); + error!("apply_reverse_patch: failed: {}", e); } } result @@ -1037,16 +1002,15 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn delete_branch(&self, name: &str, force: bool) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "delete_branch"); - info!("[{}] delete_branch: name={}, force={}", MODULE, name, force); + info!("delete_branch: name={}, force={}", name, force); let result = self.call_unit("delete_branch", json!({ "name": name, "force": force })); match &result { Ok(()) => { - debug!("[{}] delete_branch: branch '{}' deleted", MODULE, name); + debug!("delete_branch: branch '{}' deleted", name); } Err(e) => { error!( - "[{}] delete_branch: failed to delete '{}': {}", - MODULE, name, e + "delete_branch: failed to delete '{}': {}", name, e ); } } @@ -1064,14 +1028,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn rename_branch(&self, old: &str, new: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "rename_branch"); - info!("[{}] rename_branch: old='{}' -> new='{}'", MODULE, old, new); + info!("rename_branch: old='{}' -> new='{}'", old, new); let result = self.call_unit("rename_branch", json!({ "old": old, "new": new })); match &result { Ok(()) => { - debug!("[{}] rename_branch: branch renamed", MODULE); + debug!("rename_branch: branch renamed"); } Err(e) => { - error!("[{}] rename_branch: failed: {}", MODULE, e); + error!("rename_branch: failed: {}", e); } } result @@ -1087,16 +1051,15 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn merge_into_current(&self, name: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "merge_into_current"); - info!("[{}] merge_into_current: source='{}'", MODULE, name); + info!("merge_into_current: source='{}'", name); let result = self.call_unit("merge_into_current", json!({ "name": name })); match &result { Ok(()) => { - debug!("[{}] merge_into_current: merge completed", MODULE); + debug!("merge_into_current: merge completed"); } Err(e) => { warn!( - "[{}] merge_into_current: merge may have conflicts: {}", - MODULE, e + "merge_into_current: merge may have conflicts: {}", e ); } } @@ -1110,14 +1073,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn merge_abort(&self) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "merge_abort"); - info!("[{}] merge_abort: aborting merge", MODULE); + info!("merge_abort: aborting merge"); let result = self.call_unit("merge_abort", Value::Null); match &result { Ok(()) => { - debug!("[{}] merge_abort: merge aborted", MODULE); + debug!("merge_abort: merge aborted"); } Err(e) => { - error!("[{}] merge_abort: failed: {}", MODULE, e); + error!("merge_abort: failed: {}", e); } } result @@ -1130,14 +1093,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn merge_continue(&self) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "merge_continue"); - info!("[{}] merge_continue: continuing merge", MODULE); + info!("merge_continue: continuing merge"); let result = self.call_unit("merge_continue", Value::Null); match &result { Ok(()) => { - debug!("[{}] merge_continue: merge continued", MODULE); + debug!("merge_continue: merge continued"); } Err(e) => { - error!("[{}] merge_continue: failed: {}", MODULE, e); + error!("merge_continue: failed: {}", e); } } result @@ -1150,17 +1113,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn merge_in_progress(&self) -> VcsResult { let _timer = LogTimer::new(MODULE, "merge_in_progress"); - trace!("[{}] merge_in_progress: checking merge state", MODULE); + trace!("merge_in_progress: checking merge state"); let result = self.call_json("merge_in_progress", Value::Null); match &result { Ok(true) => { - debug!("[{}] merge_in_progress: merge is in progress", MODULE); + debug!("merge_in_progress: merge is in progress"); } Ok(false) => { - debug!("[{}] merge_in_progress: no merge in progress", MODULE); + debug!("merge_in_progress: no merge in progress"); } Err(e) => { - error!("[{}] merge_in_progress: failed: {}", MODULE, e); + error!("merge_in_progress: failed: {}", e); } } result @@ -1178,8 +1141,7 @@ impl Vcs for PluginVcsProxy { fn set_branch_upstream(&self, branch: &str, upstream: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "set_branch_upstream"); info!( - "[{}] set_branch_upstream: branch='{}' -> upstream='{}'", - MODULE, branch, upstream + "set_branch_upstream: branch='{}' -> upstream='{}'", branch, upstream ); let result = self.call_unit( "set_branch_upstream", @@ -1187,10 +1149,10 @@ impl Vcs for PluginVcsProxy { ); match &result { Ok(()) => { - debug!("[{}] set_branch_upstream: upstream set", MODULE); + debug!("set_branch_upstream: upstream set"); } Err(e) => { - error!("[{}] set_branch_upstream: failed: {}", MODULE, e); + error!("set_branch_upstream: failed: {}", e); } } result @@ -1207,20 +1169,19 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn branch_upstream(&self, branch: &str) -> VcsResult> { let _timer = LogTimer::new(MODULE, "branch_upstream"); - trace!("[{}] branch_upstream: branch='{}'", MODULE, branch); + trace!("branch_upstream: branch='{}'", branch); let result = self.call_json("branch_upstream", json!({ "branch": branch })); match &result { Ok(Some(upstream)) => { debug!( - "[{}] branch_upstream: '{}' tracks '{}'", - MODULE, branch, upstream + "branch_upstream: '{}' tracks '{}'", branch, upstream ); } Ok(None) => { - debug!("[{}] branch_upstream: '{}' has no upstream", MODULE, branch); + debug!("branch_upstream: '{}' has no upstream", branch); } Err(e) => { - error!("[{}] branch_upstream: failed: {}", MODULE, e); + error!("branch_upstream: failed: {}", e); } } result @@ -1233,14 +1194,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn hard_reset_head(&self) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "hard_reset_head"); - warn!("[{}] hard_reset_head: performing hard reset", MODULE); + warn!("hard_reset_head: performing hard reset"); let result = self.call_unit("hard_reset_head", Value::Null); match &result { Ok(()) => { - debug!("[{}] hard_reset_head: reset completed", MODULE); + debug!("hard_reset_head: reset completed"); } Err(e) => { - error!("[{}] hard_reset_head: failed: {}", MODULE, e); + error!("hard_reset_head: failed: {}", e); } } result @@ -1256,14 +1217,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn reset_soft_to(&self, rev: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "reset_soft_to"); - info!("[{}] reset_soft_to: rev={}", MODULE, rev); + info!("reset_soft_to: rev={}", rev); let result = self.call_unit("reset_soft_to", json!({ "rev": rev })); match &result { Ok(()) => { - debug!("[{}] reset_soft_to: reset to '{}'", MODULE, rev); + debug!("reset_soft_to: reset to '{}'", rev); } Err(e) => { - error!("[{}] reset_soft_to: failed: {}", MODULE, e); + error!("reset_soft_to: failed: {}", e); } } result @@ -1277,17 +1238,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn get_identity(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "get_identity"); - trace!("[{}] get_identity: querying repository identity", MODULE); + trace!("get_identity: querying repository identity"); let result = self.call_json("get_identity", Value::Null); match &result { Ok(Some((name, email))) => { - debug!("[{}] get_identity: {} <{}>", MODULE, name, email); + debug!("get_identity: {} <{}>", name, email); } Ok(None) => { - debug!("[{}] get_identity: no identity configured", MODULE); + debug!("get_identity: no identity configured"); } Err(e) => { - error!("[{}] get_identity: failed: {}", MODULE, e); + error!("get_identity: failed: {}", e); } } result @@ -1304,17 +1265,17 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn set_identity_local(&self, name: &str, email: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "set_identity_local"); - info!("[{}] set_identity_local: {} <{}>", MODULE, name, email); + info!("set_identity_local: {} <{}>", name, email); let result = self.call_unit( "set_identity_local", json!({ "name": name, "email": email }), ); match &result { Ok(()) => { - debug!("[{}] set_identity_local: identity set", MODULE); + debug!("set_identity_local: identity set"); } Err(e) => { - error!("[{}] set_identity_local: failed: {}", MODULE, e); + error!("set_identity_local: failed: {}", e); } } result @@ -1327,14 +1288,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stash_list(&self) -> VcsResult> { let _timer = LogTimer::new(MODULE, "stash_list"); - trace!("[{}] stash_list: querying stash entries", MODULE); + trace!("stash_list: querying stash entries"); let result: VcsResult> = self.call_json("stash_list", Value::Null); match &result { Ok(stashes) => { - debug!("[{}] stash_list: {} stash entries", MODULE, stashes.len()); + debug!("stash_list: {} stash entries", stashes.len()); } Err(e) => { - error!("[{}] stash_list: failed: {}", MODULE, e); + error!("stash_list: failed: {}", e); } } result @@ -1362,8 +1323,7 @@ impl Vcs for PluginVcsProxy { .map(|p| p.to_string_lossy().to_string()) .collect(); info!( - "[{}] stash_push: message='{}', include_untracked={}, paths={}", - MODULE, + "stash_push: message='{}', include_untracked={}, paths={}", message.lines().next().unwrap_or(""), include_untracked, paths.len() @@ -1374,10 +1334,10 @@ impl Vcs for PluginVcsProxy { ); match &result { Ok(()) => { - debug!("[{}] stash_push: stash created", MODULE); + debug!("stash_push: stash created"); } Err(e) => { - error!("[{}] stash_push: failed: {}", MODULE, e); + error!("stash_push: failed: {}", e); } } result @@ -1393,14 +1353,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stash_apply(&self, selector: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "stash_apply"); - info!("[{}] stash_apply: selector={}", MODULE, selector); + info!("stash_apply: selector={}", selector); let result = self.call_unit("stash_apply", json!({ "selector": selector })); match &result { Ok(()) => { - debug!("[{}] stash_apply: stash applied", MODULE); + debug!("stash_apply: stash applied"); } Err(e) => { - error!("[{}] stash_apply: failed: {}", MODULE, e); + error!("stash_apply: failed: {}", e); } } result @@ -1416,14 +1376,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stash_pop(&self, selector: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "stash_pop"); - info!("[{}] stash_pop: selector={}", MODULE, selector); + info!("stash_pop: selector={}", selector); let result = self.call_unit("stash_pop", json!({ "selector": selector })); match &result { Ok(()) => { - debug!("[{}] stash_pop: stash popped", MODULE); + debug!("stash_pop: stash popped"); } Err(e) => { - error!("[{}] stash_pop: failed: {}", MODULE, e); + error!("stash_pop: failed: {}", e); } } result @@ -1439,14 +1399,14 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stash_drop(&self, selector: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "stash_drop"); - info!("[{}] stash_drop: selector={}", MODULE, selector); + info!("stash_drop: selector={}", selector); let result = self.call_unit("stash_drop", json!({ "selector": selector })); match &result { Ok(()) => { - debug!("[{}] stash_drop: stash dropped", MODULE); + debug!("stash_drop: stash dropped"); } Err(e) => { - error!("[{}] stash_drop: failed: {}", MODULE, e); + error!("stash_drop: failed: {}", e); } } result @@ -1462,20 +1422,19 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn stash_show(&self, selector: &str) -> VcsResult> { let _timer = LogTimer::new(MODULE, "stash_show"); - trace!("[{}] stash_show: selector={}", MODULE, selector); + trace!("stash_show: selector={}", selector); let result: VcsResult> = self.call_json("stash_show", json!({ "selector": selector })); match &result { Ok(lines) => { debug!( - "[{}] stash_show: {} lines for {}", - MODULE, + "stash_show: {} lines for {}", lines.len(), selector ); } Err(e) => { - error!("[{}] stash_show: failed: {}", MODULE, e); + error!("stash_show: failed: {}", e); } } result @@ -1493,8 +1452,7 @@ impl Vcs for PluginVcsProxy { fn path_to_utf8(path: &Path) -> Result { path.to_str().map(|s| s.to_string()).ok_or_else(|| { warn!( - "[{}] path_to_utf8: non-UTF8 path: {}", - MODULE, + "path_to_utf8: non-UTF8 path: {}", path.display() ); VcsError::Backend { diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 9cf8d960..98a655a6 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -31,8 +31,7 @@ fn is_plugin_enabled_in_settings(plugin_id: &str, default_enabled: bool) -> bool let cfg = AppConfig::load_or_default(); let enabled = cfg.is_plugin_enabled(plugin_id, default_enabled); trace!( - "[{}] is_plugin_enabled_in_settings: plugin={}, default={}, result={}", - MODULE, + "is_plugin_enabled_in_settings: plugin={}, default={}, result={}", plugin_id, default_enabled, enabled @@ -64,8 +63,7 @@ pub struct PluginBackendDescriptor { fn load_manifest_from_dir(plugin_dir: &Path) -> Option { let manifest_path = plugin_dir.join(PLUGIN_MANIFEST_NAME); trace!( - "[{}] load_manifest_from_dir: loading from {}", - MODULE, + "load_manifest_from_dir: loading from {}", manifest_path.display() ); @@ -73,8 +71,7 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { let manifest: PluginManifest = serde_json::from_str(&text).ok()?; debug!( - "[{}] load_manifest_from_dir: loaded manifest for plugin '{}'", - MODULE, manifest.id + "load_manifest_from_dir: loaded manifest for plugin '{}'", manifest.id ); Some(manifest) } @@ -93,16 +90,14 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { let mut out = Vec::new(); let dirs = built_in_plugin_dirs(); debug!( - "[{}] builtin_plugin_manifests: found {} built-in plugin directories", - MODULE, + "builtin_plugin_manifests: found {} built-in plugin directories", dirs.len() ); for root in dirs { if !root.is_dir() { trace!( - "[{}] builtin_plugin_manifests: {} is not a directory", - MODULE, + "builtin_plugin_manifests: {} is not a directory", root.display() ); continue; @@ -111,8 +106,7 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { Ok(entries) => entries, Err(e) => { warn!( - "[{}] builtin_plugin_manifests: failed to read {}: {}", - MODULE, + "builtin_plugin_manifests: failed to read {}: {}", root.display(), e ); @@ -131,8 +125,7 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { } debug!( - "[{}] builtin_plugin_manifests: found {} built-in manifests", - MODULE, + "builtin_plugin_manifests: found {} built-in manifests", out.len() ); out @@ -153,15 +146,13 @@ pub fn list_plugin_vcs_backends() -> Result, String let store = PluginBundleStore::new_default(); let plugins = store.list_current_components().map_err(|e| { error!( - "[{}] list_plugin_vcs_backends: failed to list components: {}", - MODULE, e + "list_plugin_vcs_backends: failed to list components: {}", e ); e })?; debug!( - "[{}] list_plugin_vcs_backends: found {} installed plugins", - MODULE, + "list_plugin_vcs_backends: found {} installed plugins", plugins.len() ); let mut map: BTreeMap = BTreeMap::new(); @@ -169,16 +160,14 @@ pub fn list_plugin_vcs_backends() -> Result, String for p in plugins { if !is_plugin_enabled_in_settings(&p.plugin_id, p.default_enabled) { trace!( - "[{}] list_plugin_vcs_backends: plugin {} is disabled", - MODULE, + "list_plugin_vcs_backends: plugin {} is disabled", p.plugin_id ); continue; } let Some(module) = p.module else { trace!( - "[{}] list_plugin_vcs_backends: plugin {} has no module", - MODULE, + "list_plugin_vcs_backends: plugin {} has no module", p.plugin_id ); continue; @@ -187,8 +176,7 @@ pub fn list_plugin_vcs_backends() -> Result, String for (id, name) in module.vcs_backends { let backend_id = BackendId::from(id.as_str()); debug!( - "[{}] list_plugin_vcs_backends: found backend '{}' from plugin '{}'", - MODULE, backend_id, p.plugin_id + "list_plugin_vcs_backends: found backend '{}' from plugin '{}'", backend_id, p.plugin_id ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), @@ -205,24 +193,21 @@ pub fn list_plugin_vcs_backends() -> Result, String let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { warn!( - "[{}] list_plugin_vcs_backends: manifest has empty id at {}", - MODULE, + "list_plugin_vcs_backends: manifest has empty id at {}", plugin_dir.display() ); continue; } if !is_plugin_enabled_in_settings(plugin_id, manifest.default_enabled) { trace!( - "[{}] list_plugin_vcs_backends: built-in plugin {} is disabled", - MODULE, + "list_plugin_vcs_backends: built-in plugin {} is disabled", plugin_id ); continue; } let Some(module) = &manifest.module else { trace!( - "[{}] list_plugin_vcs_backends: built-in plugin {} has no module", - MODULE, + "list_plugin_vcs_backends: built-in plugin {} has no module", plugin_id ); continue; @@ -234,8 +219,7 @@ pub fn list_plugin_vcs_backends() -> Result, String .filter(|s| !s.is_empty()) else { trace!( - "[{}] list_plugin_vcs_backends: built-in plugin {} has no exec", - MODULE, + "list_plugin_vcs_backends: built-in plugin {} has no exec", plugin_id ); continue; @@ -243,8 +227,7 @@ pub fn list_plugin_vcs_backends() -> Result, String let exec_path = plugin_dir.join("bin").join(exec_name); if !exec_path.is_file() { warn!( - "[{}] list_plugin_vcs_backends: built-in plugin {} is missing module exec {}", - MODULE, + "list_plugin_vcs_backends: built-in plugin {} is missing module exec {}", plugin_id, exec_path.display() ); @@ -252,8 +235,7 @@ pub fn list_plugin_vcs_backends() -> Result, String } debug!( - "[{}] list_plugin_vcs_backends: processing built-in plugin {} at {}", - MODULE, + "list_plugin_vcs_backends: processing built-in plugin {} at {}", plugin_id, plugin_dir.display() ); @@ -268,15 +250,13 @@ pub fn list_plugin_vcs_backends() -> Result, String let key = backend_id.as_ref().to_string(); if map.contains_key(&key) { trace!( - "[{}] list_plugin_vcs_backends: backend {} already registered", - MODULE, + "list_plugin_vcs_backends: backend {} already registered", backend_id ); continue; } debug!( - "[{}] list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", - MODULE, backend_id, plugin_id + "list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", backend_id, plugin_id ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), @@ -290,8 +270,7 @@ pub fn list_plugin_vcs_backends() -> Result, String let result: Vec<_> = map.into_values().collect(); info!( - "[{}] list_plugin_vcs_backends: discovered {} VCS backends", - MODULE, + "list_plugin_vcs_backends: discovered {} VCS backends", result.len() ); Ok(result) @@ -307,8 +286,7 @@ pub fn list_plugin_vcs_backends() -> Result, String /// - `false` otherwise. pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { trace!( - "[{}] has_plugin_vcs_backend: checking for {}", - MODULE, + "has_plugin_vcs_backend: checking for {}", backend_id ); let result = list_plugin_vcs_backends().ok().is_some_and(|v| { @@ -316,8 +294,7 @@ pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { .any(|b| b.backend_id.as_ref() == backend_id.as_ref()) }); debug!( - "[{}] has_plugin_vcs_backend: {} -> {}", - MODULE, backend_id, result + "has_plugin_vcs_backend: {} -> {}", backend_id, result ); result } @@ -334,8 +311,7 @@ pub fn plugin_vcs_backend_descriptor( backend_id: &BackendId, ) -> Result { trace!( - "[{}] plugin_vcs_backend_descriptor: resolving {}", - MODULE, + "plugin_vcs_backend_descriptor: resolving {}", backend_id ); @@ -345,15 +321,13 @@ pub fn plugin_vcs_backend_descriptor( .find(|d| d.backend_id.as_ref() == backend_id.as_ref()) .ok_or_else(|| { warn!( - "[{}] plugin_vcs_backend_descriptor: unknown backend {}", - MODULE, backend_id + "plugin_vcs_backend_descriptor: unknown backend {}", backend_id ); format!("Unknown VCS backend: {backend_id}") })?; debug!( - "[{}] plugin_vcs_backend_descriptor: found {} from plugin {}", - MODULE, backend_id, result.plugin_id + "plugin_vcs_backend_descriptor: found {} from plugin {}", backend_id, result.plugin_id ); Ok(result) } @@ -375,29 +349,25 @@ pub fn open_repo_via_plugin_vcs_backend( ) -> VcsResult> { let _timer = LogTimer::new(MODULE, "open_repo_via_plugin_vcs_backend"); info!( - "[{}] open_repo_via_plugin_vcs_backend: backend={}, path={}", - MODULE, + "open_repo_via_plugin_vcs_backend: backend={}, path={}", backend_id, path.display() ); let desc = plugin_vcs_backend_descriptor(&backend_id).map_err(|e| { error!( - "[{}] open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", - MODULE, backend_id, e + "open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", backend_id, e ); VcsError::Unsupported(backend_id.clone()) })?; debug!( - "[{}] open_repo_via_plugin_vcs_backend: resolved to plugin {}", - MODULE, desc.plugin_id + "open_repo_via_plugin_vcs_backend: resolved to plugin {}", desc.plugin_id ); let cfg_value = serde_json::to_value(cfg).map_err(|e| { error!( - "[{}] open_repo_via_plugin_vcs_backend: failed to serialize config: {}", - MODULE, e + "open_repo_via_plugin_vcs_backend: failed to serialize config: {}", e ); VcsError::Backend { backend: backend_id.clone(), @@ -406,8 +376,7 @@ pub fn open_repo_via_plugin_vcs_backend( })?; trace!( - "[{}] open_repo_via_plugin_vcs_backend: getting runtime for plugin {}", - MODULE, + "open_repo_via_plugin_vcs_backend: getting runtime for plugin {}", desc.plugin_id ); @@ -415,8 +384,7 @@ pub fn open_repo_via_plugin_vcs_backend( .runtime_for_workspace_with_config(cfg, &desc.plugin_id, Some(path.to_path_buf())) .map_err(|e| { error!( - "[{}] open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", - MODULE, desc.plugin_id, e + "open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", desc.plugin_id, e ); VcsError::Backend { backend: backend_id.clone(), @@ -434,16 +402,14 @@ pub fn open_repo_via_plugin_vcs_backend( match &result { Ok(_) => { info!( - "[{}] open_repo_via_plugin_vcs_backend: successfully opened {} via {}", - MODULE, + "open_repo_via_plugin_vcs_backend: successfully opened {} via {}", path.display(), backend_id ); } Err(e) => { error!( - "[{}] open_repo_via_plugin_vcs_backend: failed to open {} via {}: {}", - MODULE, + "open_repo_via_plugin_vcs_backend: failed to open {} via {}: {}", path.display(), backend_id, e diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index 4343e47d..cc3f23ef 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -30,7 +30,7 @@ pub async fn git_conflict_details( path: String, ) -> Result { let start = std::time::Instant::now(); - info!("[{}] git_conflict_details: path='{}'", MODULE, path); + info!("git_conflict_details: path='{}'", path); let repo = current_repo_or_err(&state)?; let path_clone = path.clone(); @@ -38,7 +38,7 @@ pub async fn git_conflict_details( repo.inner() .conflict_details(&PathBuf::from(&path)) .map_err(|e| { - error!("[{}] git_conflict_details: failed for '{}': {}", MODULE, path, e); + error!("git_conflict_details: failed for '{}': {}", path, e); e.to_string() }) }) @@ -47,13 +47,12 @@ pub async fn git_conflict_details( match &result { Ok(details) => { debug!( - "[{}] git_conflict_details: found conflict details for '{}' ({:?})", - MODULE, path_clone, start.elapsed() + "git_conflict_details: found conflict details for '{}' ({:?})", path_clone, start.elapsed() ); - trace!("[{}] git_conflict_details: binary={}", MODULE, details.binary); + trace!("git_conflict_details: binary={}", details.binary); } Err(e) => { - error!("[{}] git_conflict_details: failed: {}", MODULE, e); + error!("git_conflict_details: failed: {}", e); } } @@ -77,7 +76,7 @@ pub async fn git_resolve_conflict_side( side: String, ) -> Result<(), String> { let start = std::time::Instant::now(); - info!("[{}] git_resolve_conflict_side: path='{}', side='{}'", MODULE, path, side); + info!("git_resolve_conflict_side: path='{}', side='{}'", path, side); let repo = current_repo_or_err(&state)?; let path_clone = path.clone(); @@ -87,7 +86,7 @@ pub async fn git_resolve_conflict_side( "ours" => ConflictSide::Ours, "theirs" => ConflictSide::Theirs, other => { - warn!("[{}] git_resolve_conflict_side: invalid side '{}'", MODULE, other); + warn!("git_resolve_conflict_side: invalid side '{}'", other); return Err(format!("invalid conflict side '{other}'")); } }; @@ -95,8 +94,7 @@ pub async fn git_resolve_conflict_side( .checkout_conflict_side(&PathBuf::from(&path), which) .map_err(|e| { error!( - "[{}] git_resolve_conflict_side: failed for '{}': {}", - MODULE, path, e + "git_resolve_conflict_side: failed for '{}': {}", path, e ); e.to_string() }) @@ -106,12 +104,11 @@ pub async fn git_resolve_conflict_side( match &result { Ok(()) => { debug!( - "[{}] git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", - MODULE, path_clone, side_clone, start.elapsed() + "git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", path_clone, side_clone, start.elapsed() ); } Err(e) => { - error!("[{}] git_resolve_conflict_side: failed: {}", MODULE, e); + error!("git_resolve_conflict_side: failed: {}", e); } } @@ -136,8 +133,7 @@ pub async fn git_save_merge_result( ) -> Result<(), String> { let start = std::time::Instant::now(); info!( - "[{}] git_save_merge_result: path='{}', content_len={}", - MODULE, path, content.len() + "git_save_merge_result: path='{}', content_len={}", path, content.len() ); let repo = current_repo_or_err(&state)?; @@ -147,8 +143,7 @@ pub async fn git_save_merge_result( .write_merge_result(&PathBuf::from(&path), content.as_bytes()) .map_err(|e| { error!( - "[{}] git_save_merge_result: failed for '{}': {}", - MODULE, path, e + "git_save_merge_result: failed for '{}': {}", path, e ); e.to_string() }) @@ -158,12 +153,11 @@ pub async fn git_save_merge_result( match &result { Ok(()) => { debug!( - "[{}] git_save_merge_result: saved '{}' ({:?})", - MODULE, path_clone, start.elapsed() + "git_save_merge_result: saved '{}' ({:?})", path_clone, start.elapsed() ); } Err(e) => { - error!("[{}] git_save_merge_result: failed: {}", MODULE, e); + error!("git_save_merge_result: failed: {}", e); } } @@ -183,7 +177,7 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { .unwrap_or_default() .into_iter() .collect::>(); - trace!("[{}] tool_args: path='{}', args={:?}", MODULE, path, args); + trace!("tool_args: path='{}', args={:?}", path, args); (path, args) } @@ -199,24 +193,23 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { /// - `Err(String)` when tool config is missing or spawn fails. pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> Result<(), String> { let start = std::time::Instant::now(); - info!("[{}] git_launch_merge_tool: path='{}'", MODULE, path); + info!("git_launch_merge_tool: path='{}'", path); let cfg = state.config(); let tool = cfg.diff.external_merge.clone(); if !tool.enabled { - warn!("[{}] git_launch_merge_tool: external merge tool is disabled", MODULE); + warn!("git_launch_merge_tool: external merge tool is disabled"); return Err("no external merge tool configured".into()); } if tool.path.trim().is_empty() { - warn!("[{}] git_launch_merge_tool: no tool path configured", MODULE); + warn!("git_launch_merge_tool: no tool path configured"); return Err("no external merge tool configured".into()); } debug!( - "[{}] git_launch_merge_tool: tool='{}', args='{}'", - MODULE, tool.path, tool.args + "git_launch_merge_tool: tool='{}', args='{}'", tool.path, tool.args ); let repo = current_repo_or_err(&state)?; @@ -235,8 +228,7 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> }; trace!( - "[{}] git_launch_merge_tool: repo_root='{}', abs_path='{}'", - MODULE, repo_root.display(), abs.display() + "git_launch_merge_tool: repo_root='{}', abs_path='{}'", repo_root.display(), abs.display() ); let mut cmd = Command::new(&tool_path); @@ -264,12 +256,11 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> } debug!( - "[{}] git_launch_merge_tool: spawning '{}' with args {:?}", - MODULE, tool_path, expanded + "git_launch_merge_tool: spawning '{}' with args {:?}", tool_path, expanded ); cmd.spawn().map(|_| ()).map_err(|e| { - error!("[{}] git_launch_merge_tool: failed to spawn '{}': {}", MODULE, tool_path, e); + error!("git_launch_merge_tool: failed to spawn '{}': {}", tool_path, e); e.to_string() }) }) @@ -278,12 +269,11 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> match &result { Ok(()) => { info!( - "[{}] git_launch_merge_tool: launched tool for '{}' ({:?})", - MODULE, path_for_log, start.elapsed() + "git_launch_merge_tool: launched tool for '{}' ({:?})", path_for_log, start.elapsed() ); } Err(e) => { - error!("[{}] git_launch_merge_tool: failed: {}", MODULE, e); + error!("git_launch_merge_tool: failed: {}", e); } } diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index b6386147..0b9396a2 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -22,7 +22,7 @@ fn known_hosts_path() -> Result { "Could not determine home directory".to_string() })?; let path = home.join(".ssh").join("known_hosts"); - trace!("[{}] known_hosts_path: {}", MODULE, path.display()); + trace!("known_hosts_path: {}", path.display()); Ok(path) } @@ -40,7 +40,7 @@ fn ssh_dir_path() -> Result { "Could not determine home directory".to_string() })?; let path = home.join(".ssh"); - trace!("[{}] ssh_dir_path: {}", MODULE, path.display()); + trace!("ssh_dir_path: {}", path.display()); Ok(path) } @@ -60,16 +60,14 @@ fn ensure_ssh_dir() -> Result { let dir = home.join(".ssh"); fs::create_dir_all(&dir).map_err(|e| { error!( - "[{}] ensure_ssh_dir: failed to create {}: {}", - MODULE, + "ensure_ssh_dir: failed to create {}: {}", dir.display(), e ); format!("Failed to create ~/.ssh: {e}") })?; debug!( - "[{}] ensure_ssh_dir: ssh directory ready at {}", - MODULE, + "ensure_ssh_dir: ssh directory ready at {}", dir.display() ); Ok(dir) @@ -96,11 +94,11 @@ pub struct SshCommandOutput { /// - `Ok(SshCommandOutput)` command output. /// - `Err(String)` on spawn failure. fn run_command(cmd: &str, args: &[&str]) -> Result { - trace!("[{}] run_command: {} {:?}", MODULE, cmd, args); + trace!("run_command: {} {:?}", cmd, args); let start = std::time::Instant::now(); let out = Command::new(cmd).args(args).output().map_err(|e| { - error!("[{}] run_command: failed to spawn {}: {}", MODULE, cmd, e); + error!("run_command: failed to spawn {}: {}", cmd, e); format!("Failed to run {cmd}: {e}") })?; @@ -113,14 +111,12 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { if out.status.success() { debug!( - "[{}] run_command: {} succeeded in {:?} (code={})", - MODULE, cmd, elapsed, result.code + "run_command: {} succeeded in {:?} (code={})", cmd, elapsed, result.code ); - trace!("[{}] run_command: stdout='{}'", MODULE, result.stdout); + trace!("run_command: stdout='{}'", result.stdout); } else { warn!( - "[{}] run_command: {} failed in {:?} (code={}): {}", - MODULE, cmd, elapsed, result.code, result.stderr + "run_command: {} failed in {:?} (code={}): {}", cmd, elapsed, result.code, result.stderr ); } @@ -137,7 +133,7 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { /// - `Ok(String)` scanned key lines. /// - `Err(String)` on command failure. fn keyscan(host: &str) -> Result { - trace!("[{}] keyscan: scanning host '{}'", MODULE, host); + trace!("keyscan: scanning host '{}'", host); let start = std::time::Instant::now(); let out = Command::new("ssh-keyscan") @@ -145,7 +141,7 @@ fn keyscan(host: &str) -> Result { .arg(host) .output() .map_err(|e| { - error!("[{}] keyscan: failed to run ssh-keyscan: {}", MODULE, e); + error!("keyscan: failed to run ssh-keyscan: {}", e); format!("Failed to run ssh-keyscan: {e}") })?; @@ -154,8 +150,7 @@ fn keyscan(host: &str) -> Result { if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); error!( - "[{}] keyscan: failed for '{}' in {:?}: {}", - MODULE, host, elapsed, err + "keyscan: failed for '{}' in {:?}: {}", host, elapsed, err ); return Err(if err.is_empty() { format!("ssh-keyscan exited with {}", out.status) @@ -167,17 +162,15 @@ fn keyscan(host: &str) -> Result { let s = String::from_utf8_lossy(&out.stdout).to_string(); let s = s.trim().to_string(); if s.is_empty() { - warn!("[{}] keyscan: no host keys returned for '{}'", MODULE, host); + warn!("keyscan: no host keys returned for '{}'", host); return Err("ssh-keyscan returned no host keys".to_string()); } debug!( - "[{}] keyscan: got keys for '{}' in {:?}", - MODULE, host, elapsed + "keyscan: got keys for '{}' in {:?}", host, elapsed ); trace!( - "[{}] keyscan: keys='{}'", - MODULE, + "keyscan: keys='{}'", s.lines().take(3).collect::>().join("\\n") ); Ok(s) @@ -192,7 +185,7 @@ fn keyscan(host: &str) -> Result { /// # Returns /// - Always `Err(String)` until implemented. fn keyscan(_host: &str) -> Result { - warn!("[{}] keyscan: not implemented for Windows", MODULE); + warn!("keyscan: not implemented for Windows"); Err("SSH host key scanning is not implemented for Windows yet".to_string()) } @@ -208,18 +201,17 @@ fn keyscan(_host: &str) -> Result { pub fn ssh_trust_host(host: String) -> Result<(), String> { let host = host.trim(); let start = std::time::Instant::now(); - info!("[{}] ssh_trust_host: host='{}'", MODULE, host); + info!("ssh_trust_host: host='{}'", host); if host.is_empty() { - warn!("[{}] ssh_trust_host: empty host provided", MODULE); + warn!("ssh_trust_host: empty host provided"); return Err("Host cannot be empty".to_string()); } ensure_ssh_dir()?; let known_hosts = known_hosts_path()?; debug!( - "[{}] ssh_trust_host: known_hosts path={}", - MODULE, + "ssh_trust_host: known_hosts path={}", known_hosts.display() ); @@ -227,8 +219,7 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { if let Ok(existing) = fs::read_to_string(&known_hosts) { if existing.lines().any(|l| l.contains(host)) { debug!( - "[{}] ssh_trust_host: host '{}' already in known_hosts", - MODULE, host + "ssh_trust_host: host '{}' already in known_hosts", host ); return Ok(()); } @@ -246,8 +237,7 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { .and_then(|mut f| std::io::Write::write_all(&mut f, to_append.as_bytes())) .map_err(|e| { error!( - "[{}] ssh_trust_host: failed to update {}: {}", - MODULE, + "ssh_trust_host: failed to update {}: {}", known_hosts.display(), e ); @@ -256,8 +246,7 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { let elapsed = start.elapsed(); info!( - "[{}] ssh_trust_host: host '{}' trusted in {:?}", - MODULE, host, elapsed + "ssh_trust_host: host '{}' trusted in {:?}", host, elapsed ); Ok(()) } @@ -269,24 +258,22 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { /// - `Ok(SshCommandOutput)` with command exit/status output. /// - `Err(String)` when command execution fails. pub fn ssh_agent_list_keys() -> Result { - info!("[{}] ssh_agent_list_keys: listing SSH agent keys", MODULE); + info!("ssh_agent_list_keys: listing SSH agent keys"); let result = run_command("ssh-add", &["-l"])?; match result.code { 0 => { debug!( - "[{}] ssh_agent_list_keys: agent has {} keys", - MODULE, + "ssh_agent_list_keys: agent has {} keys", result.stdout.lines().count() ); } 1 => { - warn!("[{}] ssh_agent_list_keys: agent has no identities", MODULE); + warn!("ssh_agent_list_keys: agent has no identities"); } code => { warn!( - "[{}] ssh_agent_list_keys: agent returned code {}", - MODULE, code + "ssh_agent_list_keys: agent returned code {}", code ); } } @@ -342,8 +329,7 @@ pub fn ssh_key_candidates() -> Result, String> { || name.ends_with(".old") { trace!( - "[{}] ssh_key_candidates: skipping non-key file: {}", - MODULE, + "ssh_key_candidates: skipping non-key file: {}", name ); continue; @@ -360,7 +346,7 @@ pub fn ssh_key_candidates() -> Result, String> { continue; } - trace!("[{}] ssh_key_candidates: found candidate: {}", MODULE, name); + trace!("ssh_key_candidates: found candidate: {}", name); keys.push(SshKeyCandidate { path: path.display().to_string(), name: name.to_string(), @@ -369,8 +355,7 @@ pub fn ssh_key_candidates() -> Result, String> { keys.sort_by(|a, b| a.name.cmp(&b.name)); debug!( - "[{}] ssh_key_candidates: found {} candidate keys", - MODULE, + "ssh_key_candidates: found {} candidate keys", keys.len() ); Ok(keys) @@ -387,21 +372,20 @@ pub fn ssh_key_candidates() -> Result, String> { /// - `Err(String)` when validation or command execution fails. pub fn ssh_add_key(path: String) -> Result { let p = path.trim(); - info!("[{}] ssh_add_key: path='{}'", MODULE, p); + info!("ssh_add_key: path='{}'", p); if p.is_empty() { - warn!("[{}] ssh_add_key: empty path provided", MODULE); + warn!("ssh_add_key: empty path provided"); return Err("Path cannot be empty".to_string()); } let result = run_command("ssh-add", &[p])?; if result.code == 0 { - debug!("[{}] ssh_add_key: key added successfully", MODULE); + debug!("ssh_add_key: key added successfully"); } else { warn!( - "[{}] ssh_add_key: failed to add key: {}", - MODULE, result.stderr + "ssh_add_key: failed to add key: {}", result.stderr ); } diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 33fe0ede..5321fe79 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -18,17 +18,17 @@ const MODULE: &str = "updater"; /// - `Err(String)` when updater operations fail. pub async fn updater_install_now(window: Window) -> Result<(), String> { let start = std::time::Instant::now(); - info!("[{}] updater_install_now: starting update check", MODULE); + info!("updater_install_now: starting update check"); let app = window.app_handle(); let updater = app.updater().map_err(|e| { - error!("[{}] updater_install_now: failed to get updater: {}", MODULE, e); + error!("updater_install_now: failed to get updater: {}", e); e.to_string() })?; - debug!("[{}] updater_install_now: checking for updates", MODULE); + debug!("updater_install_now: checking for updates"); let check_result = updater.check().await.map_err(|e| { - error!("[{}] updater_install_now: update check failed: {}", MODULE, e); + error!("updater_install_now: update check failed: {}", e); e.to_string() })?; @@ -37,12 +37,10 @@ pub async fn updater_install_now(window: Window) -> Result<(), St let version = &update.version; let current_version = &update.current_version; info!( - "[{}] updater_install_now: update available: {} -> {}", - MODULE, current_version, version + "updater_install_now: update available: {} -> {}", current_version, version ); debug!( - "[{}] updater_install_now: update date={:?}, body_len={}", - MODULE, + "updater_install_now: update date={:?}, body_len={}", update.date, update.body.as_ref().map(|b| b.len()).unwrap_or(0) ); @@ -60,8 +58,7 @@ pub async fn updater_install_now(window: Window) -> Result<(), St 0 }; trace!( - "[{}] updater_install_now: download progress {}/{} bytes ({}%)", - MODULE, received, total_val, percent + "updater_install_now: download progress {}/{} bytes ({}%)", received, total_val, percent ); let payload = serde_json::json!({ "kind": "progress", @@ -73,8 +70,7 @@ pub async fn updater_install_now(window: Window) -> Result<(), St || { let download_elapsed = download_start.elapsed(); info!( - "[{}] updater_install_now: download completed in {:?}", - MODULE, download_elapsed + "updater_install_now: download completed in {:?}", download_elapsed ); let _ = app2.emit( "update:progress", @@ -84,22 +80,20 @@ pub async fn updater_install_now(window: Window) -> Result<(), St ) .await .map_err(|e| { - error!("[{}] updater_install_now: download/install failed: {}", MODULE, e); + error!("updater_install_now: download/install failed: {}", e); e.to_string() })?; let elapsed = start.elapsed(); info!( - "[{}] updater_install_now: update installed successfully in {:?}", - MODULE, elapsed + "updater_install_now: update installed successfully in {:?}", elapsed ); Ok(()) } None => { let elapsed = start.elapsed(); debug!( - "[{}] updater_install_now: no update available (checked in {:?})", - MODULE, elapsed + "updater_install_now: no update available (checked in {:?})", elapsed ); Ok(()) } diff --git a/Backend/src/utilities/utilities.rs b/Backend/src/utilities/utilities.rs index 074631e7..6997facb 100644 --- a/Backend/src/utilities/utilities.rs +++ b/Backend/src/utilities/utilities.rs @@ -24,7 +24,7 @@ impl AboutInfo { /// # Returns /// - A populated [`AboutInfo`] record. pub fn gather() -> Self { - trace!("[{}] AboutInfo::gather: collecting application metadata", MODULE); + trace!("AboutInfo::gather: collecting application metadata"); // Compile-time package metadata from Cargo let name = env!("CARGO_PKG_NAME").to_string(); @@ -44,8 +44,7 @@ impl AboutInfo { let arch = std::env::consts::ARCH.to_string(); debug!( - "[{}] AboutInfo::gather: {} v{} on {}-{}", - MODULE, name, version, os, arch + "AboutInfo::gather: {} v{} on {}-{}", name, version, os, arch ); Self { @@ -76,7 +75,7 @@ pub async fn browse_directory_async( title: &str, ) -> Option { let start = std::time::Instant::now(); - info!("[{}] browse_directory_async: opening folder picker (title='{}')", MODULE, title); + info!("browse_directory_async: opening folder picker (title='{}')", title); let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); // OWNED Dialog @@ -93,14 +92,12 @@ pub async fn browse_directory_async( match &result { Some(path) => { debug!( - "[{}] browse_directory_async: selected '{}' in {:?}", - MODULE, path, elapsed + "browse_directory_async: selected '{}' in {:?}", path, elapsed ); } None => { debug!( - "[{}] browse_directory_async: canceled in {:?}", - MODULE, elapsed + "browse_directory_async: canceled in {:?}", elapsed ); } } @@ -125,8 +122,7 @@ pub async fn browse_file_async( ) -> Option { let start = std::time::Instant::now(); info!( - "[{}] browse_file_async: opening file picker (title='{}', extensions={:?})", - MODULE, title, extensions + "browse_file_async: opening file picker (title='{}', extensions={:?})", title, extensions ); let dialog = tauri_plugin_dialog::DialogExt::dialog(&app).clone(); @@ -146,14 +142,12 @@ pub async fn browse_file_async( match &result { Some(path) => { debug!( - "[{}] browse_file_async: selected '{}' in {:?}", - MODULE, path, elapsed + "browse_file_async: selected '{}' in {:?}", path, elapsed ); } None => { debug!( - "[{}] browse_file_async: canceled in {:?}", - MODULE, elapsed + "browse_file_async: canceled in {:?}", elapsed ); } } From f1733261fb2e372b6f0b772bbe55b43ab683fc31 Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 00:12:30 +0000 Subject: [PATCH 40/96] Update --- Backend/src/plugin_bundles.rs | 12 ++++-------- Backend/src/plugin_runtime/host_api.rs | 6 ++---- Backend/src/plugin_runtime/vcs_proxy.rs | 9 +++------ Backend/src/plugin_vcs_backends.rs | 9 +++------ Backend/src/tauri_commands/conflicts.rs | 1 - Backend/src/tauri_commands/ssh.rs | 16 +++++----------- Backend/src/tauri_commands/updater.rs | 1 - Backend/src/utilities/utilities.rs | 1 - 8 files changed, 17 insertions(+), 38 deletions(-) diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 3e69462e..8cd9b0ec 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -333,8 +333,7 @@ impl PluginBundleStore { let plugin_id = manifest.id.trim().to_string(); if plugin_id.is_empty() { error!( - "[{}] install_ovcsp_with_limits: manifest id is empty", - MODULE + "install_ovcsp_with_limits: manifest id is empty", ); return Err("manifest id is empty".to_string()); } @@ -564,8 +563,7 @@ impl PluginBundleStore { if manifest.functions.is_some() { error!( - "[{}] install_ovcsp_with_limits: manifest uses deprecated 'functions' field", - MODULE + "install_ovcsp_with_limits: manifest uses deprecated 'functions' field", ); return Err( "manifest uses unsupported field 'functions'; use module.exec only".to_string(), @@ -674,8 +672,7 @@ impl PluginBundleStore { if errors.is_empty() { debug!( - "[{}] sync_built_in_plugins: all bundles synced successfully", - MODULE + "sync_built_in_plugins: all bundles synced successfully", ); Ok(()) } else { @@ -706,8 +703,7 @@ impl PluginBundleStore { let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { error!( - "[{}] ensure_built_in_bundle: bundle manifest id is empty", - MODULE + "ensure_built_in_bundle: bundle manifest id is empty", ); return Err("bundle manifest id is empty".to_string()); } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 013367c0..667a5168 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -106,14 +106,12 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; if p.starts_with(&root) { trace!( - "[{}] resolve_under_root: resolved absolute path within root", - MODULE + "resolve_under_root: resolved absolute path within root", ); return Ok(p); } warn!( - "[{}] resolve_under_root: path escapes workspace root", - MODULE + "resolve_under_root: path escapes workspace root", ); return Err("path escapes workspace root".to_string()); } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index c8378b93..a2ee07b0 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -61,8 +61,7 @@ impl PluginVcsProxy { }; trace!( - "[{}] open_with_process: ensuring runtime is running", - MODULE + "open_with_process: ensuring runtime is running", ); p.runtime.ensure_running().map_err(|e| { error!( @@ -231,8 +230,7 @@ impl Vcs for PluginVcsProxy { Self: Sized, { warn!( - "[{}] open: direct constructor not supported, use host runtime", - MODULE + "open: direct constructor not supported, use host runtime", ); Err(VcsError::Backend { backend: BackendId::from("plugin"), @@ -254,8 +252,7 @@ impl Vcs for PluginVcsProxy { Self: Sized, { warn!( - "[{}] clone: direct constructor not supported, use host runtime", - MODULE + "clone: direct constructor not supported, use host runtime", ); Err(VcsError::Backend { backend: BackendId::from("plugin"), diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 98a655a6..e1cea9b9 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -83,8 +83,7 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { let _timer = LogTimer::new(MODULE, "builtin_plugin_manifests"); trace!( - "[{}] builtin_plugin_manifests: scanning built-in plugin dirs", - MODULE + "builtin_plugin_manifests: scanning built-in plugin dirs", ); let mut out = Vec::new(); @@ -139,8 +138,7 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { pub fn list_plugin_vcs_backends() -> Result, String> { let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends"); info!( - "[{}] list_plugin_vcs_backends: discovering VCS backends", - MODULE + "list_plugin_vcs_backends: discovering VCS backends", ); let store = PluginBundleStore::new_default(); @@ -393,8 +391,7 @@ pub fn open_repo_via_plugin_vcs_backend( })?; debug!( - "[{}] open_repo_via_plugin_vcs_backend: opening via plugin proxy", - MODULE + "open_repo_via_plugin_vcs_backend: opening via plugin proxy", ); let result = PluginVcsProxy::open_with_process(backend_id.clone(), runtime, path, cfg_value); diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index cc3f23ef..809ee2bc 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -13,7 +13,6 @@ use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; -const MODULE: &str = "conflicts"; #[tauri::command] /// Returns conflict details for a repository file. diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 0b9396a2..5e5e7b44 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -6,7 +6,6 @@ use log::{debug, error, info, trace, warn}; use serde::Serialize; use tauri::command; -const MODULE: &str = "ssh"; /// Returns `~/.ssh/known_hosts` path. /// @@ -16,8 +15,7 @@ const MODULE: &str = "ssh"; fn known_hosts_path() -> Result { let home = dirs::home_dir().ok_or_else(|| { error!( - "[{}] known_hosts_path: could not determine home directory", - MODULE + "known_hosts_path: could not determine home directory", ); "Could not determine home directory".to_string() })?; @@ -34,8 +32,7 @@ fn known_hosts_path() -> Result { fn ssh_dir_path() -> Result { let home = dirs::home_dir().ok_or_else(|| { error!( - "[{}] ssh_dir_path: could not determine home directory", - MODULE + "ssh_dir_path: could not determine home directory", ); "Could not determine home directory".to_string() })?; @@ -52,8 +49,7 @@ fn ssh_dir_path() -> Result { fn ensure_ssh_dir() -> Result { let home = dirs::home_dir().ok_or_else(|| { error!( - "[{}] ensure_ssh_dir: could not determine home directory", - MODULE + "ensure_ssh_dir: could not determine home directory", ); "Could not determine home directory".to_string() })?; @@ -298,15 +294,13 @@ pub struct SshKeyCandidate { /// - `Err(String)` when home/ssh directory resolution fails. pub fn ssh_key_candidates() -> Result, String> { info!( - "[{}] ssh_key_candidates: scanning for SSH key candidates", - MODULE + "ssh_key_candidates: scanning for SSH key candidates", ); let dir = ssh_dir_path()?; let Ok(read_dir) = fs::read_dir(&dir) else { debug!( - "[{}] ssh_key_candidates: ssh directory does not exist or is not readable", - MODULE + "ssh_key_candidates: ssh directory does not exist or is not readable", ); return Ok(vec![]); }; diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index 5321fe79..b964fb31 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -5,7 +5,6 @@ use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; -const MODULE: &str = "updater"; #[tauri::command] /// Downloads and installs an available application update. diff --git a/Backend/src/utilities/utilities.rs b/Backend/src/utilities/utilities.rs index 6997facb..43835d31 100644 --- a/Backend/src/utilities/utilities.rs +++ b/Backend/src/utilities/utilities.rs @@ -3,7 +3,6 @@ use log::{debug, info, trace, warn}; use serde::Serialize; -const MODULE: &str = "utilities"; #[derive(Serialize)] pub struct AboutInfo { From e2619321c2200e16aa9119c7715a5769641e4f04 Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 18:27:59 +0000 Subject: [PATCH 41/96] Update --- Frontend/src/scripts/features/branches.ts | 2 +- Frontend/src/scripts/features/diff.ts | 2 +- Frontend/src/scripts/features/repo/diffView.ts | 6 +++--- Frontend/src/scripts/features/repo/history.ts | 2 +- Frontend/src/scripts/features/repo/interactions.ts | 4 ++-- Frontend/src/scripts/features/repo/stash.ts | 10 +++++----- Frontend/src/scripts/features/settings.ts | 4 ++-- Frontend/src/scripts/lib/notify.ts | 1 + Frontend/src/scripts/main.ts | 12 ++++++------ 9 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index c1935f18..a836b942 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -284,7 +284,7 @@ export function bindBranchUI() { notify(`Force-deleted '${name}'`); await loadBranches(); await runHook('postBranchDelete', hookData); - } catch { notify('Force delete failed'); } + } catch (e) { console.error('Force delete failed:', e); notify('Force delete failed'); } } }}); } diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index ad313bc1..82739b5c 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -99,7 +99,7 @@ export function bindCommit() { await Promise.allSettled([hydrateStatus(), hydrateCommits()]); await runHook('postCommit', { summary, description, branch: state.branch, files: fullFiles, partialFiles }); clearBusy('Ready'); - } catch { notify('Commit failed'); } + } catch (e) { console.error('Commit failed:', e); notify('Commit failed'); } finally { clearBusy('Ready'); } diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index 02e4d18a..ded9cc28 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -150,7 +150,7 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); const selected = (state as any).selectedHunksByFile?.[file.path] as number[] | undefined; if (Array.isArray(selected) && selected.length > 0) { @@ -164,7 +164,7 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } const hunksMap: Record = (state as any).selectedHunksByFile || {}; @@ -186,7 +186,7 @@ export async function selectFile(file: FileStatus, index: number) { await TAURI.invoke('git_discard_patch', { patch }); await Promise.allSettled([hydrateStatus()]); } - } catch { notify('Discard failed'); } + } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } buildCtxMenu(items, x, y); diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index 158d23c4..c14c33c7 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -88,7 +88,7 @@ async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: C try { await TAURI.invoke('git_undo_to_commit', { id: commit.id }); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - } catch { notify('Undo failed'); } + } catch (e) { console.error('Undo failed:', e); notify('Undo failed'); } }, }); } diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 0a07de03..40953d5a 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -281,7 +281,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { const ok = window.confirm(`Discard all changes in ${paths.length} selected file(s)? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths }); await Promise.allSettled([hydrateStatus()]); } - catch { notify('Discard failed'); } + catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); } items.push({ label: 'Discard changes', action: async () => { @@ -289,7 +289,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { const ok = window.confirm(`Discard all changes in \n${f.path}? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths: [f.path] }); await Promise.allSettled([hydrateStatus()]); } - catch { notify('Discard failed'); } + catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } }}); const pluginTargets = (explicitMultiSelection ? selectedPaths.slice() : [singleTarget]).filter(Boolean); diff --git a/Frontend/src/scripts/features/repo/stash.ts b/Frontend/src/scripts/features/repo/stash.ts index 57f16fdc..c04c65cb 100644 --- a/Frontend/src/scripts/features/repo/stash.ts +++ b/Frontend/src/scripts/features/repo/stash.ts @@ -81,7 +81,7 @@ export function renderStashList(query: string): boolean { notify('Applied stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch { notify('Failed to apply stash'); } + } catch (e) { console.error('Failed to apply stash:', e); notify('Failed to apply stash'); } }}); items.push({ label: 'Delete stash', action: async () => { const ok = window.confirm(`Delete ${target}? This cannot be undone.`); @@ -93,7 +93,7 @@ export function renderStashList(query: string): boolean { if (state.currentStash === target) state.currentStash = ''; await Promise.allSettled([hydrateStash()]); renderListRef?.(); - } catch { notify('Failed to delete stash'); } + } catch (e) { console.error('Failed to delete stash:', e); notify('Failed to delete stash'); } }}); buildCtxMenu(items, x, y); }); @@ -197,7 +197,7 @@ function wireStashFooterButtons(container: HTMLElement) { notify('Applied stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_apply failed', e); notify('Failed to apply stash'); } + } catch (e) { console.error('git_stash_apply failed:', e); notify('Failed to apply stash'); } }); const popBtn = container.querySelector('#stash-pop-btn'); @@ -210,7 +210,7 @@ function wireStashFooterButtons(container: HTMLElement) { notify('Popped stash'); await Promise.allSettled([hydrateStatus(), hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_pop failed', e); notify('Failed to pop stash'); } + } catch (e) { console.error('git_stash_pop failed:', e); notify('Failed to pop stash'); } }); const dropBtn = container.querySelector('#stash-drop-btn'); @@ -226,6 +226,6 @@ function wireStashFooterButtons(container: HTMLElement) { state.currentStash = ''; await Promise.allSettled([hydrateStash()]); renderListRef?.(); - } catch (e) { console.warn('git_stash_drop failed', e); notify('Failed to drop stash'); } + } catch (e) { console.error('git_stash_drop failed:', e); notify('Failed to drop stash'); } }); } diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 22e1e4c9..30e9368f 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -293,7 +293,7 @@ export function wireSettings() { notify('Settings saved'); closeModal('settings-modal'); - } catch { notify('Failed to save settings'); } + } catch (e) { console.error('Failed to save settings:', e); notify('Failed to save settings'); } }); settingsReset?.addEventListener('click', async () => { @@ -326,7 +326,7 @@ export function wireSettings() { setTheme('system'); try { await selectThemePack(DEFAULT_LIGHT_THEME_ID, { silent: true, mode: 'system' }); } catch {} notify('Defaults restored'); - } catch { notify('Failed to restore defaults'); } + } catch (e) { console.error('Failed to restore defaults:', e); notify('Failed to restore defaults'); } }); // Settings are loaded by `openSettings()` on open. diff --git a/Frontend/src/scripts/lib/notify.ts b/Frontend/src/scripts/lib/notify.ts index 4fcd001e..d6c2e661 100644 --- a/Frontend/src/scripts/lib/notify.ts +++ b/Frontend/src/scripts/lib/notify.ts @@ -9,6 +9,7 @@ const statusEl = qs('#status'); * @param text - Message to display */ export function notify(text: string) { + console.log(`[notify] ${text}`); if (!statusEl) return; setText(statusEl, text); setTimeout(() => { diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 85feb0a0..7be8d831 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -321,14 +321,14 @@ async function boot() { notify('Pushed'); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); await runHook('postPush', hookData); - } catch { notify('Push failed'); } finally { clearBusy(); } + } catch (e) { console.error('Push failed:', e); notify('Push failed'); } finally { clearBusy(); } } async function openDocs() { if (TAURI.has) { try { await TAURI.invoke('open_docs', {}); return; } catch { /* fall back */ } } - try { window.open(WIKI_URL, '_blank', 'noopener'); } catch { notify('Unable to open docs'); } + try { window.open(WIKI_URL, '_blank', 'noopener'); } catch (e) { console.error('Unable to open docs:', e); notify('Unable to open docs'); } } async function runMenuAction(id?: string | null) { @@ -344,7 +344,7 @@ async function boot() { console.log('Action: show-output-log'); if (!TAURI.has) { notify('Output Log is available in the desktop app'); break; } try { await TAURI.invoke('open_output_log_window', {}); } - catch { notify('Failed to open Output Log'); } + catch (e) { console.error('Failed to open Output Log:', e); notify('Failed to open Output Log'); } break; case 'about': console.log('Action: about'); openAbout(); break; case 'settings': console.log('Action: settings'); openSettings(); break; @@ -355,7 +355,7 @@ async function boot() { if (!TAURI.has) { notify('Open this in the desktop app to edit repository files'); break; } const name = id === 'repo-edit-gitignore' ? '.gitignore' : '.gitattributes'; try { await TAURI.invoke('open_repo_dotfile', { name }); } - catch { notify(`Could not open ${name}`); } + catch (e) { console.error(`Could not open ${name}:`, e); notify(`Could not open ${name}`); } break; } case 'lfs-settings': openSettings('lfs'); break; @@ -364,7 +364,7 @@ async function boot() { try { const hasUpdate = await TAURI.invoke('check_for_updates', {}); if (!hasUpdate) notify('Already up to date'); - } catch { notify('Update check failed'); } + } catch (e) { console.error('Update check failed:', e); notify('Update check failed'); } break; case 'exit': if (TAURI.has) { TAURI.invoke('exit_app', {}).catch(() => {}); } break; default: { @@ -406,7 +406,7 @@ async function boot() { await TAURI.invoke('git_undo_since_push', {}); notify('Undid unpushed commits'); await Promise.allSettled([hydrateStatus(), hydrateCommits()]); - } catch { notify('Undo failed'); } finally { clearBusy(); } + } catch (e) { console.error('Undo failed:', e); notify('Undo failed'); } finally { clearBusy(); } }); From c21c4ebbdab4a52e53b390637548f1d796986f9c Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 18:36:40 +0000 Subject: [PATCH 42/96] Update settings.ts --- Frontend/src/scripts/features/settings.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 30e9368f..c91c4394 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1284,23 +1284,20 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } } } catch {} - } catch { - notify('Failed to update plugins'); - } + } catch (e) { console.error('Failed to update plugins:', e); notify('Failed to update plugins'); } }; const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { if (!TAURI.has) return; try { await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); + console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); await reloadPlugins(); await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); try { await refreshAvailableThemes(); - } catch {} - } catch { - notify('Failed to toggle plugin'); - } + } catch (e) { console.warn('refreshAvailableThemes failed:', e); } + } catch (e) { console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } }; if (!(pane as any).__wired) { From 3528177a46b8e28d1294bacd8ccf7bf01551d6df Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 18:44:29 +0000 Subject: [PATCH 43/96] Update settings.ts --- Frontend/src/scripts/features/settings.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index c91c4394..faa16e04 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1288,7 +1288,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { }; const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { - if (!TAURI.has) return; + console.log(`[persistSinglePluginToggle] ${pluginId} -> ${enabled}`); + if (!TAURI.has) { console.warn('[persistSinglePluginToggle] TAURI not available'); return; } try { await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); From 49f959938c1d096c02cc0ec7c7b865c114a32eb6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 18:59:56 +0000 Subject: [PATCH 44/96] Added logging to backend --- Backend/src/plugin_bundles.rs | 151 +++++++++++-------- Backend/src/plugin_runtime/manager.rs | 135 +++++++++++++++-- Backend/src/plugin_runtime/runtime_select.rs | 33 +++- Backend/src/tauri_commands/plugins.rs | 12 +- Frontend/src/scripts/features/settings.ts | 5 +- 5 files changed, 263 insertions(+), 73 deletions(-) diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 8cd9b0ec..dc030990 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -241,10 +241,7 @@ impl PluginBundleStore { pub fn new_default() -> Self { let root = plugins_dir(); ensure_dir(&root); - trace!( - "PluginBundleStore::new_default: root={}", - root.display() - ); + trace!("PluginBundleStore::new_default: root={}", root.display()); Self { root } } @@ -316,9 +313,7 @@ impl PluginBundleStore { let bundle_sha256 = sha256_hex_file(bundle_path)?; let bundle_compressed_bytes = fs::metadata(bundle_path) .map_err(|e| { - error!( - "install_ovcsp_with_limits: failed to get metadata: {}", e - ); + error!("install_ovcsp_with_limits: failed to get metadata: {}", e); format!("metadata {}: {e}", bundle_path.display()) })? .len(); @@ -332,14 +327,13 @@ impl PluginBundleStore { let (manifest_bundle_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim().to_string(); if plugin_id.is_empty() { - error!( - "install_ovcsp_with_limits: manifest id is empty", - ); + error!("install_ovcsp_with_limits: manifest id is empty",); return Err("manifest id is empty".to_string()); } debug!( - "install_ovcsp_with_limits: plugin_id={}, version={:?}", plugin_id, manifest.version + "install_ovcsp_with_limits: plugin_id={}, version={:?}", + plugin_id, manifest.version ); // Enforce that the top-level directory name matches the manifest id. @@ -351,7 +345,8 @@ impl PluginBundleStore { .to_string(); if bundle_root != plugin_id { error!( - "install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", bundle_root, plugin_id + "install_ovcsp_with_limits: bundle root '{}' does not match manifest id '{}'", + bundle_root, plugin_id ); return Err(format!( "bundle root folder '{}' does not match manifest id '{}'", @@ -562,9 +557,7 @@ impl PluginBundleStore { } if manifest.functions.is_some() { - error!( - "install_ovcsp_with_limits: manifest uses deprecated 'functions' field", - ); + error!("install_ovcsp_with_limits: manifest uses deprecated 'functions' field",); return Err( "manifest uses unsupported field 'functions'; use module.exec only".to_string(), ); @@ -575,7 +568,8 @@ impl PluginBundleStore { validate_entrypoint(&staging_version_dir, module_exec.as_deref(), "module")?; debug!( - "install_ovcsp_with_limits: extracted {} files, promoting to final location", total_files + "install_ovcsp_with_limits: extracted {} files, promoting to final location", + total_files ); // Promote staged version into place (flat layout, drop old version directory). @@ -589,7 +583,8 @@ impl PluginBundleStore { } fs::rename(&staging_version_dir, &plugin_dir).map_err(|e| { error!( - "install_ovcsp_with_limits: failed to move plugin into place: {}", e + "install_ovcsp_with_limits: failed to move plugin into place: {}", + e ); format!( "move installed plugin into place {} -> {}: {e}", @@ -629,7 +624,8 @@ impl PluginBundleStore { let elapsed = start.elapsed(); info!( - "install_ovcsp_with_limits: installed plugin {} v{} in {:?}", plugin_id, version, elapsed + "install_ovcsp_with_limits: installed plugin {} v{} in {:?}", + plugin_id, version, elapsed ); Ok(InstalledPlugin { @@ -657,29 +653,19 @@ impl PluginBundleStore { let mut errors = Vec::new(); for bundle in &bundles { - debug!( - "sync_built_in_plugins: checking {}", - bundle.display() - ); + debug!("sync_built_in_plugins: checking {}", bundle.display()); if let Err(err) = self.ensure_built_in_bundle(bundle) { let msg = format!("{}: {}", bundle.display(), err); - warn!( - "sync_built_in_plugins: failed to sync: {}", msg - ); + warn!("sync_built_in_plugins: failed to sync: {}", msg); errors.push(msg); } } if errors.is_empty() { - debug!( - "sync_built_in_plugins: all bundles synced successfully", - ); + debug!("sync_built_in_plugins: all bundles synced successfully",); Ok(()) } else { - error!( - "sync_built_in_plugins: {} bundles failed", - errors.len() - ); + error!("sync_built_in_plugins: {} bundles failed", errors.len()); Err(errors.join("; ")) } } @@ -693,18 +679,13 @@ impl PluginBundleStore { /// - `Ok(())` when bundle is already current or installed successfully. /// - `Err(String)` on install/validation failures. fn ensure_built_in_bundle(&self, bundle_path: &Path) -> Result<(), String> { - trace!( - "ensure_built_in_bundle: {}", - bundle_path.display() - ); + trace!("ensure_built_in_bundle: {}", bundle_path.display()); let bundle_sha256 = sha256_hex_file(bundle_path)?; let (_manifest_path, manifest) = locate_manifest_tar_xz(bundle_path)?; let plugin_id = manifest.id.trim(); if plugin_id.is_empty() { - error!( - "ensure_built_in_bundle: bundle manifest id is empty", - ); + error!("ensure_built_in_bundle: bundle manifest id is empty",); return Err("bundle manifest id is empty".to_string()); } let plugin_id = plugin_id.to_string(); @@ -719,22 +700,23 @@ impl PluginBundleStore { return Ok(()); } debug!( - "ensure_built_in_bundle: {} needs update (installed={}, new={})", plugin_id, installed.version, version + "ensure_built_in_bundle: {} needs update (installed={}, new={})", + plugin_id, installed.version, version ); } - debug!( - "ensure_built_in_bundle: installing {}", plugin_id - ); + debug!("ensure_built_in_bundle: installing {}", plugin_id); self.install_ovcsp_with_limits(bundle_path, InstallerLimits::default())?; if let Err(err) = self.approve_capabilities(&plugin_id, &version, true) { warn!( - "ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", plugin_id, version, err + "ensure_built_in_bundle: failed to auto-approve built-in {} ({}): {}", + plugin_id, version, err ); } else { debug!( - "ensure_built_in_bundle: auto-approved built-in {} ({})", plugin_id, version + "ensure_built_in_bundle: auto-approved built-in {} ({})", + plugin_id, version ); } Ok(()) @@ -759,9 +741,7 @@ impl PluginBundleStore { } let lower = id.to_ascii_lowercase(); if built_in_plugin_ids().contains(&lower) { - warn!( - "uninstall_plugin: cannot uninstall built-in plugin {}", id - ); + warn!("uninstall_plugin: cannot uninstall built-in plugin {}", id); return Err("built-in plugins cannot be removed".to_string()); } let dir = self.root.join(id); @@ -788,10 +768,7 @@ impl PluginBundleStore { /// - `Err(String)` when the plugin root cannot be read. pub fn list_installed(&self) -> Result, String> { let _timer = LogTimer::new(MODULE, "list_installed"); - trace!( - "list_installed: scanning {}", - self.root.display() - ); + trace!("list_installed: scanning {}", self.root.display()); if !self.root.is_dir() { debug!("list_installed: root does not exist"); @@ -822,10 +799,7 @@ impl PluginBundleStore { } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); - debug!( - "list_installed: found {} installed plugins", - out.len() - ); + debug!("list_installed: found {} installed plugins", out.len()); Ok(out) } @@ -875,17 +849,31 @@ impl PluginBundleStore { &self, plugin_id: &str, ) -> Result, String> { + trace!("get_current_installed: plugin_id='{}'", plugin_id); + let id = plugin_id.trim(); + debug!("get_current_installed: trimmed id='{}'", id); + if id.is_empty() { return Err("plugin id is empty".to_string()); } + + trace!("get_current_installed: reading index"); let Some(index) = self.read_index(id) else { + debug!("get_current_installed: no index found for '{}'", id); return Ok(None); }; + + trace!("get_current_installed: checking current pointer"); let Some(ver) = index.current.as_deref() else { + debug!("get_current_installed: no current version for '{}'", id); return Ok(None); }; - Ok(index.versions.get(ver).cloned()) + + debug!("get_current_installed: current version='{}'", ver); + let result = index.versions.get(ver).cloned(); + debug!("get_current_installed: found={}", result.is_some()); + Ok(result) } /// Updates capability approval for a specific plugin version. @@ -1021,12 +1009,19 @@ impl PluginBundleStore { /// - `Ok(Vec)` sorted by plugin id. /// - `Err(String)` when store traversal fails. pub fn list_current_components(&self) -> Result, String> { + trace!("list_current_components: root='{}'", self.root.display()); + if !self.root.is_dir() { + debug!("list_current_components: root is not a directory, returning empty"); return Ok(Vec::new()); } + + trace!("list_current_components: reading directory"); let entries = fs::read_dir(&self.root).map_err(|e| format!("read {}: {e}", self.root.display()))?; let mut out = Vec::new(); + + trace!("list_current_components: iterating entries"); for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { @@ -1036,11 +1031,27 @@ impl PluginBundleStore { Some(s) => s.to_string(), None => continue, }; + + debug!("list_current_components: checking plugin '{}'", plugin_id); if let Some(c) = self.load_current_components(&plugin_id)? { + debug!( + "list_current_components: plugin '{}' has components, has_module={}", + plugin_id, + c.module.is_some() + ); out.push(c); + } else { + debug!( + "list_current_components: plugin '{}' has no current components", + plugin_id + ); } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); + debug!( + "list_current_components: returning {} components", + out.len() + ); Ok(out) } @@ -1053,9 +1064,31 @@ impl PluginBundleStore { /// - `Some(InstalledPluginIndex)` when present and parseable. /// - `None` otherwise. fn read_index(&self, plugin_id: &str) -> Option { + trace!("read_index: plugin_id='{}'", plugin_id); let p = self.root.join(plugin_id).join("index.json"); - let text = fs::read_to_string(p).ok()?; - serde_json::from_str(&text).ok() + debug!("read_index: path='{}'", p.display()); + + let text = match fs::read_to_string(&p) { + Ok(t) => { + debug!("read_index: file read successfully"); + t + } + Err(e) => { + debug!("read_index: failed to read file: {}", e); + return None; + } + }; + + match serde_json::from_str(&text) { + Ok(index) => { + debug!("read_index: parsed index for '{}'", plugin_id); + Some(index) + } + Err(e) => { + debug!("read_index: failed to parse index: {}", e); + None + } + } } /// Writes plugin index metadata atomically. diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 19a82b3d..2d1e5294 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -5,7 +5,7 @@ use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_runtime_instance; use crate::plugin_runtime::spawn::SpawnConfig; use crate::settings::AppConfig; -use log::{info, warn}; +use log::{debug, info, trace, warn}; use parking_lot::Mutex; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -78,12 +78,25 @@ impl PluginRuntimeManager { /// - `Ok(())` when the plugin is running. /// - `Err(String)` when plugin lookup/startup fails. pub fn start_plugin(&self, plugin_id: &str) -> Result<(), String> { + trace!("start_plugin: plugin_id='{}'", plugin_id); let key = normalize_plugin_key(plugin_id)?; + debug!("start_plugin: key='{}'", key); + if let Some(existing) = self.processes.lock().get(&key) { + debug!("start_plugin: found existing runtime for key='{}'", key); return existing.runtime.ensure_running(); } + + trace!("start_plugin: resolving module runtime spec"); let spec = self.resolve_module_runtime_spec(plugin_id, None)?; + debug!( + "start_plugin: resolved spec for plugin_id='{}', key='{}'", + spec.plugin_id, spec.key + ); + + trace!("start_plugin: starting plugin spec"); self.start_plugin_spec(spec)?; + trace!("start_plugin: completed successfully"); info!("plugin: started '{}'", plugin_id); Ok(()) } @@ -100,11 +113,19 @@ impl PluginRuntimeManager { /// - `Ok(())` after stop/removal. /// - `Err(String)` if the identifier is invalid. pub fn stop_plugin(&self, plugin_id: &str) -> Result<(), String> { + trace!("stop_plugin: plugin_id='{}'", plugin_id); let key = normalize_plugin_key(plugin_id)?; + debug!("stop_plugin: key='{}'", key); + let process = self.processes.lock().remove(&key); + debug!("stop_plugin: removed from processes={}", process.is_some()); + if let Some(process) = process { + trace!("stop_plugin: calling runtime.stop()"); process.runtime.stop(); info!("plugin: stopped '{}'", plugin_id); + } else { + trace!("stop_plugin: no running process found"); } Ok(()) } @@ -138,15 +159,28 @@ impl PluginRuntimeManager { /// - `Ok(())` when the operation succeeds. /// - `Err(String)` when the operation fails. pub fn set_plugin_enabled(&self, plugin_id: &str, enabled: bool) -> Result<(), String> { + trace!( + "set_plugin_enabled: plugin_id='{}', enabled={}", + plugin_id, + enabled + ); let key = normalize_plugin_key(plugin_id)?; let is_running = self.processes.lock().contains_key(&key); + debug!( + "set_plugin_enabled: key='{}', currently_running={}", + key, is_running + ); if enabled && !is_running { + trace!("set_plugin_enabled: calling start_plugin"); self.start_plugin(plugin_id)?; info!("plugin: enabled '{}'", plugin_id); } else if !enabled && is_running { + trace!("set_plugin_enabled: calling stop_plugin"); self.stop_plugin(plugin_id)?; info!("plugin: disabled '{}'", plugin_id); + } else { + trace!("set_plugin_enabled: no action needed (already in desired state)"); } Ok(()) } @@ -325,13 +359,27 @@ impl PluginRuntimeManager { /// Starts or reuses a runtime for a resolved plugin runtime spec. fn start_plugin_spec(&self, spec: ModuleRuntimeSpec) -> Result<(), String> { + trace!( + "start_plugin_spec: key='{}', workspace_root={:?}", + spec.key, + spec.spawn.allowed_workspace_root + ); + if let Some(existing) = self.processes.lock().get(&spec.key) { + debug!( + "start_plugin_spec: found existing runtime for key='{}'", + spec.key + ); if existing.workspace_root == spec.spawn.allowed_workspace_root { + trace!("start_plugin_spec: reusing existing runtime with matching workspace"); return existing.runtime.ensure_running(); } + debug!("start_plugin_spec: workspace mismatch, will replace runtime"); } + trace!("start_plugin_spec: creating new instance"); let instance = self.create_instance(&spec)?; + debug!("start_plugin_spec: instance created, ensuring running"); instance.ensure_running()?; let mut lock = self.processes.lock(); @@ -339,16 +387,27 @@ impl PluginRuntimeManager { if existing.workspace_root == spec.spawn.allowed_workspace_root { let runtime = Arc::clone(&existing.runtime); drop(lock); + trace!("start_plugin_spec: found concurrent insert, reusing"); return runtime.ensure_running(); } } + let runtime_to_stop = lock .get(&spec.key) .map(|existing| Arc::clone(&existing.runtime)); if let Some(runtime) = runtime_to_stop { + debug!( + "start_plugin_spec: stopping old runtime for key='{}'", + spec.key + ); runtime.stop(); lock.remove(&spec.key); } + + trace!( + "start_plugin_spec: inserting new runtime for key='{}'", + spec.key + ); lock.insert( spec.key, RunningPlugin { @@ -373,28 +432,67 @@ impl PluginRuntimeManager { plugin_id: &str, allowed_workspace_root: Option, ) -> Result { + trace!( + "resolve_module_runtime_spec: plugin_id='{}', workspace_root={:?}", + plugin_id, + allowed_workspace_root + ); + let requested = plugin_id.trim(); + debug!("resolve_module_runtime_spec: trimmed='{}'", requested); + if requested.is_empty() { return Err("plugin id is empty".to_string()); } + trace!("resolve_module_runtime_spec: calling find_components"); let components = self.find_components(requested)?; - let module = components - .module - .ok_or_else(|| "plugin has no module component".to_string())?; + debug!( + "resolve_module_runtime_spec: found components for plugin_id='{}', has_module={}", + components.plugin_id, + components.module.is_some() + ); + + trace!("resolve_module_runtime_spec: checking module component"); + let module = match &components.module { + Some(m) => { + debug!( + "resolve_module_runtime_spec: module exec_path='{}'", + m.exec_path.display() + ); + m + } + None => { + warn!( + "resolve_module_runtime_spec: plugin '{}' has NO module component", + components.plugin_id + ); + return Err("plugin has no module component".to_string()); + } + }; + + trace!("resolve_module_runtime_spec: getting installed plugin info"); let installed = self .store .get_current_installed(&components.plugin_id)? .ok_or_else(|| "plugin is not installed".to_string())?; + debug!( + "resolve_module_runtime_spec: installed approval={:?}", + installed.approval + ); + let key = components.plugin_id.to_ascii_lowercase(); + debug!("resolve_module_runtime_spec: resolved key='{}'", key); + trace!("resolve_module_runtime_spec: building ModuleRuntimeSpec"); + let exec_path = module.exec_path.clone(); Ok(ModuleRuntimeSpec { plugin_id: components.plugin_id.clone(), key, default_enabled: components.default_enabled, spawn: SpawnConfig { plugin_id: components.plugin_id, - exec_path: module.exec_path, + exec_path, approval: installed.approval, allowed_workspace_root, }, @@ -403,11 +501,28 @@ impl PluginRuntimeManager { /// Finds installed plugin components by plugin id (case-insensitive). fn find_components(&self, plugin_id: &str) -> Result { - self.store - .list_current_components()? + trace!("find_components: plugin_id='{}'", plugin_id); + + let all_components = self.store.list_current_components()?; + debug!( + "find_components: found {} total components", + all_components.len() + ); + + let found = all_components .into_iter() - .find(|components| components.plugin_id.eq_ignore_ascii_case(plugin_id)) - .ok_or_else(|| "plugin not installed".to_string()) + .find(|components| components.plugin_id.eq_ignore_ascii_case(plugin_id)); + + match found { + Some(comp) => { + debug!("find_components: matched plugin_id='{}'", comp.plugin_id); + Ok(comp) + } + None => { + warn!("find_components: no plugin found matching '{}'", plugin_id); + Err("plugin not installed".to_string()) + } + } } } @@ -423,10 +538,12 @@ impl Drop for PluginRuntimeManager { /// Normalizes plugin ids to process map keys. fn normalize_plugin_key(plugin_id: &str) -> Result { + trace!("normalize_plugin_key: input='{}'", plugin_id); let plugin_id = plugin_id.trim().to_ascii_lowercase(); if plugin_id.is_empty() { return Err("plugin id is empty".to_string()); } + debug!("normalize_plugin_key: output='{}'", plugin_id); Ok(plugin_id) } diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index d2d2f4c9..1a6b72c2 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -3,6 +3,7 @@ use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; +use log::{debug, trace}; use std::path::Path; use std::sync::Arc; use wasmtime::component::Component; @@ -10,27 +11,57 @@ use wasmtime::Engine; /// Returns whether a module path is a valid component-model artifact. pub fn is_component_module(path: &Path) -> bool { + trace!("is_component_module: path='{}'", path.display()); + if !path.is_file() { + debug!("is_component_module: path is not a file, returning false"); return false; } + debug!("is_component_module: path is a file"); let engine = Engine::default(); - Component::from_file(&engine, path).is_ok() + let result = Component::from_file(&engine, path); + trace!( + "is_component_module: Component::from_file result={}", + result.is_ok() + ); + result.is_ok() } /// Selects and creates a runtime instance for a plugin module. pub fn create_runtime_instance( spawn: SpawnConfig, ) -> Result, String> { + trace!( + "create_runtime_instance: plugin_id='{}', exec_path='{}'", + spawn.plugin_id, + spawn.exec_path.display() + ); + debug!( + "create_runtime_instance: approval={:?}, workspace_root={:?}", + spawn.approval, spawn.allowed_workspace_root + ); + + trace!("create_runtime_instance: validating component module"); if !is_component_module(&spawn.exec_path) { + debug!( + "create_runtime_instance: validation failed for '{}'", + spawn.exec_path.display() + ); return Err(format!( "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", spawn.exec_path.display() )); } + trace!("create_runtime_instance: creating ComponentPluginRuntimeInstance"); let runtime: Arc = Arc::new(ComponentPluginRuntimeInstance::new(spawn.clone())); + debug!( + "create_runtime_instance: instance created, plugin_id='{}'", + spawn.plugin_id + ); + log::info!( "plugin runtime: selected `component` transport for plugin `{}` ({})", spawn.plugin_id, diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 89e7660c..e14c3a41 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -3,7 +3,7 @@ use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; use crate::plugins; use crate::state::AppState; -use log::{info, warn}; +use log::{debug, error, info, trace, warn}; use serde_json::Value; use tauri::Emitter; use tauri::Manager; @@ -118,10 +118,20 @@ pub fn set_plugin_enabled( plugin_id: String, enabled: bool, ) -> Result<(), String> { + trace!("set_plugin_enabled: entering with plugin_id='{}', enabled={}", plugin_id, enabled); + let plugin_id = plugin_id.trim().to_string(); + debug!("set_plugin_enabled: trimmed plugin_id='{}'", plugin_id); + + info!("set_plugin_enabled: plugin={}, enabled={}", plugin_id, enabled); + state .plugin_runtime() .set_plugin_enabled(&plugin_id, enabled) + .map_err(|e| { + error!("set_plugin_enabled failed: plugin={}, error={}", plugin_id, e); + e + }) } #[tauri::command] diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index faa16e04..65319bb5 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1288,11 +1288,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { }; const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { - console.log(`[persistSinglePluginToggle] ${pluginId} -> ${enabled}`); - if (!TAURI.has) { console.warn('[persistSinglePluginToggle] TAURI not available'); return; } + if (!TAURI.has) return; + console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); try { await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); - console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); await reloadPlugins(); await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); try { From 246ebd8e554e502d9cea5374942c60f8f6e7958b Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 19:21:54 +0000 Subject: [PATCH 45/96] Update manager.rs --- Backend/src/plugin_runtime/manager.rs | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 2d1e5294..9d683894 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -146,6 +146,31 @@ impl PluginRuntimeManager { } } + /// Checks if a plugin has a runtime module component. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(Some(true))` - plugin has a module. + /// - `Ok(Some(false))` - plugin has no module. + /// - `Err(String)` - plugin not found or other error. + pub fn has_module(&self, plugin_id: &str) -> Result, String> { + trace!("has_module: plugin_id='{}'", plugin_id); + let requested = plugin_id.trim(); + if requested.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let components = self.find_components(requested)?; + let has_module = components.module.is_some(); + debug!( + "has_module: plugin_id='{}', has_module={}", + plugin_id, has_module + ); + Ok(Some(has_module)) + } + /// Ensures a plugin is running or stopped based on enabled state. /// /// This is more efficient than sync_plugin_runtime_with_config when @@ -172,9 +197,23 @@ impl PluginRuntimeManager { ); if enabled && !is_running { - trace!("set_plugin_enabled: calling start_plugin"); - self.start_plugin(plugin_id)?; - info!("plugin: enabled '{}'", plugin_id); + let has_module = self.has_module(plugin_id)?; + match has_module { + Some(true) => { + trace!("set_plugin_enabled: calling start_plugin"); + self.start_plugin(plugin_id)?; + info!("plugin: enabled '{}'", plugin_id); + } + Some(false) => { + info!( + "plugin '{}' has no runtime module, marked as enabled", + plugin_id + ); + } + None => { + return Err(format!("plugin '{}' not found", plugin_id)); + } + } } else if !enabled && is_running { trace!("set_plugin_enabled: calling stop_plugin"); self.stop_plugin(plugin_id)?; From d5eacc2bfcbd17c5f7b5589bfb096ae9ef8445bf Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 19:28:57 +0000 Subject: [PATCH 46/96] Update settings.ts --- Frontend/src/scripts/features/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 65319bb5..c91c4394 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1289,9 +1289,9 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { if (!TAURI.has) return; - console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); try { await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); + console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); await reloadPlugins(); await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); try { From 4b29cdfe3d1d2c64d96e739e2c3fe66e97cdbd2e Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 20:04:35 +0000 Subject: [PATCH 47/96] Fix issues --- Backend/src/plugin_runtime/manager.rs | 4 ++-- Backend/src/plugins.rs | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 9d683894..95114826 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -558,8 +558,8 @@ impl PluginRuntimeManager { Ok(comp) } None => { - warn!("find_components: no plugin found matching '{}'", plugin_id); - Err("plugin not installed".to_string()) + warn!("find_components: no plugin found matching '{}' (plugin may exist but has no current version)", plugin_id); + Err("plugin has no current version".to_string()) } } } diff --git a/Backend/src/plugins.rs b/Backend/src/plugins.rs index d4db68c0..3563d76e 100644 --- a/Backend/src/plugins.rs +++ b/Backend/src/plugins.rs @@ -1,7 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later +use crate::plugin_bundles::PluginBundleStore; use crate::plugin_paths::{built_in_plugin_dirs, ensure_dir, plugins_dir, PLUGIN_MANIFEST_NAME}; -use log::warn; +use log::{debug, warn}; use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::{ @@ -211,6 +212,7 @@ impl PluginCache { fn reload(&self) { let built_in_ids = crate::plugin_bundles::built_in_plugin_ids(); + let bundle_store = PluginBundleStore::new_default(); let mut seen = HashSet::new(); let mut summaries: Vec = Vec::new(); let mut entries: HashMap = HashMap::new(); @@ -228,7 +230,16 @@ impl PluginCache { if !seen.insert(norm.clone()) { continue; } + let is_built_in = built_in_ids.contains(&norm); + + if !is_built_in { + if bundle_store.get_current_dir(&norm).ok().flatten().is_none() { + debug!("plugins: skipping '{}' - not properly installed (no current version)", norm); + continue; + } + } + let effective_origin = if is_built_in { PluginOrigin::BuiltIn } else { From e6c1950fa6a75995f4225c8474a91d7b79e259ae Mon Sep 17 00:00:00 2001 From: Jordon Date: Wed, 18 Feb 2026 23:16:05 +0000 Subject: [PATCH 48/96] Update --- .../src/plugin_runtime/component_instance.rs | 316 ++++++++++++++---- Backend/src/plugin_runtime/host_api.rs | 96 +++--- Backend/src/plugin_runtime/manager.rs | 45 ++- Backend/src/plugin_runtime/runtime_select.rs | 5 +- Backend/src/plugin_runtime/spawn.rs | 2 + Backend/src/plugin_runtime/vcs_proxy.rs | 143 +++----- Backend/src/plugin_vcs_backends.rs | 57 ++-- Backend/src/tauri_commands/conflicts.rs | 78 +++-- Backend/src/tauri_commands/plugins.rs | 22 +- Backend/src/tauri_commands/ssh.rs | 77 ++--- Backend/src/tauri_commands/updater.rs | 30 +- Cargo.lock | 200 ++++------- 12 files changed, 592 insertions(+), 479 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index f978cd96..1ce4e904 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -28,7 +28,7 @@ fn get_wasmtime_engine() -> &'static Engine { }) } -mod bindings { +mod bindings_vcs { wasmtime::component::bindgen!({ path: "../../Core/wit", world: "vcs", @@ -36,14 +36,64 @@ mod bindings { }); } -use bindings::exports::openvcs::plugin::vcs_api; +mod bindings_plugin { + wasmtime::component::bindgen!({ + path: "../../Core/wit", + world: "plugin", + additional_derives: [serde::Serialize, serde::Deserialize], + }); +} + +use bindings_vcs::exports::openvcs::plugin::vcs_api; + +/// Typed bindings handle selected for the running plugin world. +enum ComponentBindings { + /// Bindings for plugins exporting the `vcs` world. + Vcs(bindings_vcs::Vcs), + /// Bindings for plugins exporting the base `plugin` world. + Plugin(bindings_plugin::Plugin), +} /// Live component instance plus generated bindings handle. struct ComponentRuntime { /// Wasmtime store containing component state and host context. store: Store, /// Generated typed binding entrypoints for the plugin world. - bindings: bindings::Vcs, + bindings: ComponentBindings, +} + +impl ComponentRuntime { + /// Calls plugin `init` for whichever world is currently loaded. + fn call_init(&mut self, plugin_id: &str) -> Result<(), String> { + match &self.bindings { + ComponentBindings::Vcs(bindings) => bindings + .openvcs_plugin_plugin_api() + .call_init(&mut self.store) + .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? + .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), + ComponentBindings::Plugin(bindings) => bindings + .openvcs_plugin_plugin_api() + .call_init(&mut self.store) + .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? + .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), + } + } + + /// Calls plugin `deinit` for whichever world is currently loaded. + fn call_deinit(&mut self) { + match &self.bindings { + ComponentBindings::Vcs(bindings) => { + let _ = bindings + .openvcs_plugin_plugin_api() + .call_deinit(&mut self.store); + } + ComponentBindings::Plugin(bindings) => { + let _ = bindings + .openvcs_plugin_plugin_api() + .call_deinit(&mut self.store); + } + } + } } /// Host state stored inside the Wasmtime store for host imports. @@ -58,10 +108,20 @@ struct ComponentHostState { impl ComponentHostState { /// Converts core host errors into generated WIT host error type. - fn map_host_error( + fn map_host_error_vcs( + err: openvcs_core::app_api::PluginError, + ) -> bindings_vcs::openvcs::plugin::host_api::HostError { + bindings_vcs::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + } + + /// Converts core host errors into generated WIT host error type. + fn map_host_error_plugin( err: openvcs_core::app_api::PluginError, - ) -> bindings::openvcs::plugin::host_api::HostError { - bindings::openvcs::plugin::host_api::HostError { + ) -> bindings_plugin::openvcs::plugin::host_api::HostError { + bindings_plugin::openvcs::plugin::host_api::HostError { code: err.code, message: err.message, } @@ -78,16 +138,16 @@ impl WasiView for ComponentHostState { } } -impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { +impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { /// Returns runtime metadata for the current host process. fn get_runtime_info( &mut self, ) -> Result< - bindings::openvcs::plugin::host_api::RuntimeInfo, - bindings::openvcs::plugin::host_api::HostError, + bindings_vcs::openvcs::plugin::host_api::RuntimeInfo, + bindings_vcs::openvcs::plugin::host_api::HostError, > { let value = host_runtime_info(); - Ok(bindings::openvcs::plugin::host_api::RuntimeInfo { + Ok(bindings_vcs::openvcs::plugin::host_api::RuntimeInfo { os: value.os, arch: value.arch, container: value.container, @@ -98,8 +158,9 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { fn subscribe_event( &mut self, event_name: String, - ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - host_subscribe_event(&self.spawn, &event_name).map_err(ComponentHostState::map_host_error) + ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { + host_subscribe_event(&self.spawn, &event_name) + .map_err(ComponentHostState::map_host_error_vcs) } /// Emits a plugin-originated event through the host event bus. @@ -107,25 +168,25 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, event_name: String, payload: Vec, - ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { host_emit_event(&self.spawn, &event_name, &payload) - .map_err(ComponentHostState::map_host_error) + .map_err(ComponentHostState::map_host_error_vcs) } /// Forwards plugin notifications to host-side UI notification handling. fn ui_notify( &mut self, message: String, - ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { - host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error) + ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { + host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) } /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, path: String, - ) -> Result, bindings::openvcs::plugin::host_api::HostError> { - host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error) + ) -> Result, bindings_vcs::openvcs::plugin::host_api::HostError> { + host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error_vcs) } /// Writes a workspace file under capability and path constraints. @@ -133,9 +194,9 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, path: String, content: Vec, - ) -> Result<(), bindings::openvcs::plugin::host_api::HostError> { + ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { host_workspace_write_file(&self.spawn, &path, &content) - .map_err(ComponentHostState::map_host_error) + .map_err(ComponentHostState::map_host_error_vcs) } /// Executes `git` in a constrained host environment. @@ -143,11 +204,11 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { &mut self, cwd: Option, args: Vec, - env: Vec, + env: Vec, stdin: Option, ) -> Result< - bindings::openvcs::plugin::host_api::ProcessExecOutput, - bindings::openvcs::plugin::host_api::HostError, + bindings_vcs::openvcs::plugin::host_api::ProcessExecOutput, + bindings_vcs::openvcs::plugin::host_api::HostError, > { let env = env .into_iter() @@ -155,8 +216,8 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { .collect::>(); let value = host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) - .map_err(ComponentHostState::map_host_error)?; - Ok(bindings::openvcs::plugin::host_api::ProcessExecOutput { + .map_err(ComponentHostState::map_host_error_vcs)?; + Ok(bindings_vcs::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, status: value.status, stdout: value.stdout, @@ -167,7 +228,7 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { /// Logs plugin-emitted messages through the host logger. fn host_log( &mut self, - level: bindings::openvcs::plugin::host_api::LogLevel, + level: bindings_vcs::openvcs::plugin::host_api::LogLevel, target: String, message: String, ) { @@ -178,19 +239,142 @@ impl bindings::openvcs::plugin::host_api::Host for ComponentHostState { }; match level { - bindings::openvcs::plugin::host_api::LogLevel::Trace => { + bindings_vcs::openvcs::plugin::host_api::LogLevel::Trace => { log::trace!(target: &target, "{message}") } - bindings::openvcs::plugin::host_api::LogLevel::Debug => { + bindings_vcs::openvcs::plugin::host_api::LogLevel::Debug => { log::debug!(target: &target, "{message}") } - bindings::openvcs::plugin::host_api::LogLevel::Info => { + bindings_vcs::openvcs::plugin::host_api::LogLevel::Info => { log::info!(target: &target, "{message}") } - bindings::openvcs::plugin::host_api::LogLevel::Warn => { + bindings_vcs::openvcs::plugin::host_api::LogLevel::Warn => { log::warn!(target: &target, "{message}") } - bindings::openvcs::plugin::host_api::LogLevel::Error => { + bindings_vcs::openvcs::plugin::host_api::LogLevel::Error => { + log::error!(target: &target, "{message}") + } + }; + } +} + +impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { + /// Returns runtime metadata for the current host process. + fn get_runtime_info( + &mut self, + ) -> Result< + bindings_plugin::openvcs::plugin::host_api::RuntimeInfo, + bindings_plugin::openvcs::plugin::host_api::HostError, + > { + let value = host_runtime_info(); + Ok(bindings_plugin::openvcs::plugin::host_api::RuntimeInfo { + os: value.os, + arch: value.arch, + container: value.container, + }) + } + + /// Registers an event subscription for this plugin. + fn subscribe_event( + &mut self, + event_name: String, + ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { + host_subscribe_event(&self.spawn, &event_name) + .map_err(ComponentHostState::map_host_error_plugin) + } + + /// Emits a plugin-originated event through the host event bus. + fn emit_event( + &mut self, + event_name: String, + payload: Vec, + ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { + host_emit_event(&self.spawn, &event_name, &payload) + .map_err(ComponentHostState::map_host_error_plugin) + } + + /// Forwards plugin notifications to host-side UI notification handling. + fn ui_notify( + &mut self, + message: String, + ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { + host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) + } + + /// Reads a workspace file under capability and path constraints. + fn workspace_read_file( + &mut self, + path: String, + ) -> Result, bindings_plugin::openvcs::plugin::host_api::HostError> { + host_workspace_read_file(&self.spawn, &path) + .map_err(ComponentHostState::map_host_error_plugin) + } + + /// Writes a workspace file under capability and path constraints. + fn workspace_write_file( + &mut self, + path: String, + content: Vec, + ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { + host_workspace_write_file(&self.spawn, &path, &content) + .map_err(ComponentHostState::map_host_error_plugin) + } + + /// Executes `git` in a constrained host environment. + fn process_exec_git( + &mut self, + cwd: Option, + args: Vec, + env: Vec, + stdin: Option, + ) -> Result< + bindings_plugin::openvcs::plugin::host_api::ProcessExecOutput, + bindings_plugin::openvcs::plugin::host_api::HostError, + > { + let env = env + .into_iter() + .map(|var| (var.key, var.value)) + .collect::>(); + let value = + host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) + .map_err(ComponentHostState::map_host_error_plugin)?; + Ok( + bindings_plugin::openvcs::plugin::host_api::ProcessExecOutput { + success: value.success, + status: value.status, + stdout: value.stdout, + stderr: value.stderr, + }, + ) + } + + /// Logs plugin-emitted messages through the host logger. + fn host_log( + &mut self, + level: bindings_plugin::openvcs::plugin::host_api::LogLevel, + target: String, + message: String, + ) { + let target = if target.trim().is_empty() { + format!("plugin.{}", self.spawn.plugin_id) + } else { + format!("plugin.{}.{}", self.spawn.plugin_id, target) + }; + + match level { + bindings_plugin::openvcs::plugin::host_api::LogLevel::Trace => { + log::trace!(target: &target, "{message}") + } + bindings_plugin::openvcs::plugin::host_api::LogLevel::Debug => { + log::debug!(target: &target, "{message}") + } + bindings_plugin::openvcs::plugin::host_api::LogLevel::Info => { + log::info!(target: &target, "{message}") + } + bindings_plugin::openvcs::plugin::host_api::LogLevel::Warn => { + log::warn!(target: &target, "{message}") + } + bindings_plugin::openvcs::plugin::host_api::LogLevel::Error => { log::error!(target: &target, "{message}") } }; @@ -222,11 +406,6 @@ impl ComponentPluginRuntimeInstance { let mut linker = Linker::new(engine); wasmtime_wasi::p2::add_to_linker_sync(&mut linker) .map_err(|e| format!("link wasi imports: {e}"))?; - bindings::Vcs::add_to_linker::< - ComponentHostState, - wasmtime::component::HasSelf, - >(&mut linker, |state| state) - .map_err(|e| format!("link host imports: {e}"))?; let mut store = Store::new( engine, ComponentHostState { @@ -235,21 +414,33 @@ impl ComponentPluginRuntimeInstance { wasi: WasiCtx::builder().build(), }, ); - let bindings = bindings::Vcs::instantiate(&mut store, &component, &linker) - .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?; - - bindings - .openvcs_plugin_plugin_api() - .call_init(&mut store) - .map_err(|e| format!("component init trap for {}: {e}", self.spawn.plugin_id))? - .map_err(|e| { - format!( - "component init failed for {}: {}", - self.spawn.plugin_id, e.message - ) - })?; - - Ok(ComponentRuntime { store, bindings }) + let bindings = if self.spawn.is_vcs_backend { + bindings_vcs::Vcs::add_to_linker::< + ComponentHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state) + .map_err(|e| format!("link host imports: {e}"))?; + + ComponentBindings::Vcs( + bindings_vcs::Vcs::instantiate(&mut store, &component, &linker) + .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, + ) + } else { + bindings_plugin::Plugin::add_to_linker::< + ComponentHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state) + .map_err(|e| format!("link host imports: {e}"))?; + + ComponentBindings::Plugin( + bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) + .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, + ) + }; + + let mut runtime = ComponentRuntime { store, bindings }; + runtime.call_init(&self.spawn.plugin_id)?; + Ok(runtime) } /// Ensures a runtime exists and executes a closure with mutable access. @@ -301,13 +492,25 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { } #[allow(clippy::let_unit_value)] - /// Invokes a v1 ABI method exported by the plugin component. + /// Invokes a v1 VCS ABI method exported by the plugin component. + /// + /// Non-VCS plugins only expose lifecycle hooks (`init`/`deinit`) and return + /// an error for VCS RPC method calls. fn call(&self, method: &str, params: Value) -> Result { self.with_runtime(|runtime| { + let bindings = match &runtime.bindings { + ComponentBindings::Vcs(bindings) => bindings, + ComponentBindings::Plugin(_) => { + return Err(format!( + "component method `{method}` requires VCS backend exports for plugin `{}`", + self.spawn.plugin_id + )); + } + }; + macro_rules! invoke { ($method_name:literal, $call:ident $(, $arg:expr )* ) => { - runtime - .bindings + bindings .openvcs_plugin_vcs_api() .$call(&mut runtime.store $(, $arg )* ) .map_err(|e| { @@ -814,10 +1017,7 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { fn stop(&self) { let mut lock = self.runtime.lock(); if let Some(runtime) = lock.as_mut() { - let _ = runtime - .bindings - .openvcs_plugin_plugin_api() - .call_deinit(&mut runtime.store); + runtime.call_deinit(); } *lock = None; } diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 667a5168..ad72b711 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -86,11 +86,7 @@ fn host_error(code: &str, message: impl Into) -> PluginError { /// Resolves a plugin-supplied path under an allowed workspace root. fn resolve_under_root(root: &Path, path: &str) -> Result { - trace!( - "resolve_under_root: root={}, path={}", - root.display(), - path - ); + trace!("resolve_under_root: root={}, path={}", root.display(), path); if path.contains('\0') { warn!("resolve_under_root: path contains NUL"); return Err("path contains NUL".to_string()); @@ -105,14 +101,10 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { .canonicalize() .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; if p.starts_with(&root) { - trace!( - "resolve_under_root: resolved absolute path within root", - ); + trace!("resolve_under_root: resolved absolute path within root",); return Ok(p); } - warn!( - "resolve_under_root: path escapes workspace root", - ); + warn!("resolve_under_root: path escapes workspace root",); return Err("path escapes workspace root".to_string()); } @@ -122,18 +114,13 @@ fn resolve_under_root(root: &Path, path: &str) -> Result { Component::Normal(c) => clean.push(c), Component::CurDir => {} Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - warn!( - "resolve_under_root: invalid path component in '{}'", path - ); + warn!("resolve_under_root: invalid path component in '{}'", path); return Err("path must be relative and not contain '..'".to_string()); } } } let resolved = root.join(clean); - trace!( - "resolve_under_root: resolved to {}", - resolved.display() - ); + trace!("resolve_under_root: resolved to {}", resolved.display()); Ok(resolved) } @@ -161,11 +148,7 @@ fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), Str /// Reads bytes from a relative path constrained to the workspace root. fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { - trace!( - "read_file_under_root: root={}, rel={}", - root.display(), - rel - ); + trace!("read_file_under_root: root={}, rel={}", root.display(), rel); let path = resolve_under_root(root, rel)?; let result = fs::read(&path).map_err(|e| { error!( @@ -240,14 +223,16 @@ pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult if name.is_empty() { warn!( - "host_subscribe_event: empty event name from plugin {}", spawn.plugin_id + "host_subscribe_event: empty event name from plugin {}", + spawn.plugin_id ); return Err(host_error("host.invalid_event_name", "event name is empty")); } crate::plugin_runtime::events::subscribe(&spawn.plugin_id, name); debug!( - "host_subscribe_event: plugin {} subscribed to '{}'", spawn.plugin_id, name + "host_subscribe_event: plugin {} subscribed to '{}'", + spawn.plugin_id, name ); Ok(()) } @@ -265,7 +250,8 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> if name.is_empty() { warn!( - "host_emit_event: empty event name from plugin {}", spawn.plugin_id + "host_emit_event: empty event name from plugin {}", + spawn.plugin_id ); return Err(host_error("host.invalid_event_name", "event name is empty")); } @@ -275,7 +261,8 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> } else { serde_json::from_slice(payload).map_err(|err| { error!( - "host_emit_event: invalid JSON payload from plugin {}: {}", spawn.plugin_id, err + "host_emit_event: invalid JSON payload from plugin {}: {}", + spawn.plugin_id, err ); host_error( "host.invalid_payload", @@ -286,7 +273,8 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, name, payload_json); debug!( - "host_emit_event: plugin {} emitted '{}'", spawn.plugin_id, name + "host_emit_event: plugin {} emitted '{}'", + spawn.plugin_id, name ); Ok(()) } @@ -302,7 +290,8 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { if !caps.contains("ui.notifications") { warn!( - "host_ui_notify: capability denied for plugin {} (missing ui.notifications)", spawn.plugin_id + "host_ui_notify: capability denied for plugin {} (missing ui.notifications)", + spawn.plugin_id ); return Err(host_error( "capability.denied", @@ -313,7 +302,8 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let message = message.trim(); if !message.is_empty() { info!( - "host_ui_notify: plugin[{}] notify: {}", spawn.plugin_id, message + "host_ui_notify: plugin[{}] notify: {}", + spawn.plugin_id, message ); } Ok(()) @@ -332,7 +322,8 @@ pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult HostResult HostResult { let _timer = LogTimer::new(MODULE, "host_process_exec_git"); info!( - "host_process_exec_git: plugin={}, args={:?}", spawn.plugin_id, args + "host_process_exec_git: plugin={}, args={:?}", + spawn.plugin_id, args ); debug!( "host_process_exec_git: cwd={:?}, env_count={}, has_stdin={}", @@ -440,7 +437,8 @@ pub fn host_process_exec_git( if !caps.contains("process.exec") { warn!( - "host_process_exec_git: capability denied for plugin {} (missing process.exec)", spawn.plugin_id + "host_process_exec_git: capability denied for plugin {} (missing process.exec)", + spawn.plugin_id ); return Err(host_error( "capability.denied", @@ -454,13 +452,15 @@ pub fn host_process_exec_git( Some(raw) => { let Some(root) = spawn.allowed_workspace_root.as_ref() else { warn!( - "host_process_exec_git: no workspace context for plugin {}", spawn.plugin_id + "host_process_exec_git: no workspace context for plugin {}", + spawn.plugin_id ); return Err(host_error("workspace.denied", "no workspace context")); }; Some(resolve_under_root(root, raw).map_err(|e| { warn!( - "host_process_exec_git: invalid cwd for plugin {}: {}", spawn.plugin_id, e + "host_process_exec_git: invalid cwd for plugin {}: {}", + spawn.plugin_id, e ); host_error("workspace.denied", e) })?) @@ -492,32 +492,24 @@ pub fn host_process_exec_git( let stdin_text = stdin.unwrap_or_default(); let out = if stdin_text.is_empty() { cmd.output().map_err(|e| { - error!( - "host_process_exec_git: failed to spawn git: {}", e - ); + error!("host_process_exec_git: failed to spawn git: {}", e); host_error("process.error", format!("spawn git: {e}")) })? } else { cmd.stdin(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| { - error!( - "host_process_exec_git: failed to spawn git: {}", e - ); + error!("host_process_exec_git: failed to spawn git: {}", e); host_error("process.error", format!("spawn git: {e}")) })?; if let Some(mut child_stdin) = child.stdin.take() { if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { let _ = child.kill(); - error!( - "host_process_exec_git: failed to write stdin: {}", e - ); + error!("host_process_exec_git: failed to write stdin: {}", e); return Err(host_error("process.error", format!("write stdin: {e}"))); } } child.wait_with_output().map_err(|e| { - error!( - "host_process_exec_git: failed to wait for process: {}", e - ); + error!("host_process_exec_git: failed to wait for process: {}", e); host_error("process.error", format!("wait: {e}")) })? }; diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 95114826..4a320bf6 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -525,6 +525,7 @@ impl PluginRuntimeManager { trace!("resolve_module_runtime_spec: building ModuleRuntimeSpec"); let exec_path = module.exec_path.clone(); + let is_vcs_backend = !module.vcs_backends.is_empty(); Ok(ModuleRuntimeSpec { plugin_id: components.plugin_id.clone(), key, @@ -534,6 +535,7 @@ impl PluginRuntimeManager { exec_path, approval: installed.approval, allowed_workspace_root, + is_vcs_backend, }, }) } @@ -677,12 +679,53 @@ mod tests { assert!(!running.contains_key("themes.plugin")); } + #[test] + /// Verifies spawn config marks VCS backend plugins using manifest data. + fn resolve_spec_sets_vcs_backend_flag_from_manifest() { + let temp = tempdir().expect("tempdir"); + write_plugin(temp.path(), "utility.plugin", true); + write_vcs_plugin(temp.path(), "git.plugin", true); + + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let utility = manager + .resolve_module_runtime_spec("utility.plugin", None) + .expect("resolve utility plugin"); + assert!(!utility.spawn.is_vcs_backend); + + let vcs = manager + .resolve_module_runtime_spec("git.plugin", None) + .expect("resolve vcs plugin"); + assert!(vcs.spawn.is_vcs_backend); + } + /// Writes a minimal module-capable plugin layout into a temp store. fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + write_plugin_with_backends(root, plugin_id, default_enabled, false); + } + + /// Writes a minimal VCS-backend plugin layout into a temp store. + fn write_vcs_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { + write_plugin_with_backends(root, plugin_id, default_enabled, true); + } + + /// Writes a minimal module-capable plugin layout with optional VCS backends. + fn write_plugin_with_backends( + root: &std::path::Path, + plugin_id: &str, + default_enabled: bool, + include_vcs_backends: bool, + ) { let plugin_dir = root.join(plugin_id); fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); fs::write(plugin_dir.join("bin").join("plugin.wasm"), MINIMAL_WASM).expect("write wasm"); + let vcs_backends = if include_vcs_backends { + vec![serde_json::json!({ "id": "git", "name": "Git" })] + } else { + Vec::new() + }; + let manifest = serde_json::json!({ "id": plugin_id, "name": "Test Plugin", @@ -690,7 +733,7 @@ mod tests { "default_enabled": default_enabled, "module": { "exec": "plugin.wasm", - "vcs_backends": [] + "vcs_backends": vcs_backends } }); fs::write( diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 1a6b72c2..7c107324 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -38,8 +38,8 @@ pub fn create_runtime_instance( spawn.exec_path.display() ); debug!( - "create_runtime_instance: approval={:?}, workspace_root={:?}", - spawn.approval, spawn.allowed_workspace_root + "create_runtime_instance: approval={:?}, workspace_root={:?}, is_vcs_backend={}", + spawn.approval, spawn.allowed_workspace_root, spawn.is_vcs_backend ); trace!("create_runtime_instance: validating component module"); @@ -108,6 +108,7 @@ mod tests { approved_at_unix_ms: 0, }, allowed_workspace_root: None, + is_vcs_backend: false, }) { Ok(_) => panic!("expected non-component runtime rejection"), Err(err) => err, diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs index 07895e2e..f14841a9 100644 --- a/Backend/src/plugin_runtime/spawn.rs +++ b/Backend/src/plugin_runtime/spawn.rs @@ -14,4 +14,6 @@ pub struct SpawnConfig { pub approval: ApprovalState, /// Optional workspace root constraining file/process host operations. pub allowed_workspace_root: Option, + /// Whether this plugin exports a VCS backend interface. + pub is_vcs_backend: bool, } diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index a2ee07b0..0682afb0 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -46,7 +46,8 @@ impl PluginVcsProxy { let _timer = LogTimer::new(MODULE, "open_with_process"); let path_str = repo_path.to_string_lossy(); info!( - "open_with_process: backend={}, path={}", backend_id, path_str + "open_with_process: backend={}, path={}", + backend_id, path_str ); debug!( "open_with_process: config keys={:?}", @@ -60,13 +61,9 @@ impl PluginVcsProxy { runtime, }; - trace!( - "open_with_process: ensuring runtime is running", - ); + trace!("open_with_process: ensuring runtime is running",); p.runtime.ensure_running().map_err(|e| { - error!( - "open_with_process: failed to ensure runtime running: {}", e - ); + error!("open_with_process: failed to ensure runtime running: {}", e); VcsError::Backend { backend: p.backend_id.clone(), msg: e, @@ -82,7 +79,8 @@ impl PluginVcsProxy { })?; info!( - "open_with_process: opened backend {} for {}", backend_id, path_str + "open_with_process: opened backend {} for {}", + backend_id, path_str ); Ok(Arc::new(p)) } @@ -103,9 +101,7 @@ impl PluginVcsProxy { params.to_string().len() ); let result = self.runtime.call(method, params).map_err(|e| { - error!( - "call_value: RPC call '{}' failed: {}", method, e - ); + error!("call_value: RPC call '{}' failed: {}", method, e); VcsError::Backend { backend: self.backend_id.clone(), msg: e, @@ -135,7 +131,8 @@ impl PluginVcsProxy { let v = self.call_value(method, params)?; serde_json::from_value(v).map_err(|e| { error!( - "call_json: failed to deserialize response for '{}': {}", method, e + "call_json: failed to deserialize response for '{}': {}", + method, e ); VcsError::Backend { backend: self.backend_id.clone(), @@ -210,9 +207,7 @@ impl Vcs for PluginVcsProxy { caps } Err(e) => { - warn!( - "caps: failed to get capabilities, using defaults: {}", e - ); + warn!("caps: failed to get capabilities, using defaults: {}", e); Capabilities::default() } } @@ -229,9 +224,7 @@ impl Vcs for PluginVcsProxy { where Self: Sized, { - warn!( - "open: direct constructor not supported, use host runtime", - ); + warn!("open: direct constructor not supported, use host runtime",); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::open must be constructed via the host runtime".into(), @@ -251,9 +244,7 @@ impl Vcs for PluginVcsProxy { where Self: Sized, { - warn!( - "clone: direct constructor not supported, use host runtime", - ); + warn!("clone: direct constructor not supported, use host runtime",); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::clone must be constructed via the host runtime".into(), @@ -325,10 +316,7 @@ impl Vcs for PluginVcsProxy { let result: VcsResult> = self.call_json("local_branches", Value::Null); match &result { Ok(branches) => { - debug!( - "local_branches: found {} local branches", - branches.len() - ); + debug!("local_branches: found {} local branches", branches.len()); } Err(e) => { error!("local_branches: failed: {}", e); @@ -348,9 +336,7 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn create_branch(&self, name: &str, checkout: bool) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "create_branch"); - info!( - "create_branch: name={}, checkout={}", name, checkout - ); + info!("create_branch: name={}, checkout={}", name, checkout); let result = self.call_unit( "create_branch", json!({ "name": name, "checkout": checkout }), @@ -360,9 +346,7 @@ impl Vcs for PluginVcsProxy { debug!("create_branch: branch '{}' created", name); } Err(e) => { - error!( - "create_branch: failed to create '{}': {}", name, e - ); + error!("create_branch: failed to create '{}': {}", name, e); } } result @@ -385,9 +369,7 @@ impl Vcs for PluginVcsProxy { debug!("checkout_branch: switched to '{}'", name); } Err(e) => { - error!( - "checkout_branch: failed to switch to '{}': {}", name, e - ); + error!("checkout_branch: failed to switch to '{}': {}", name, e); } } result @@ -457,9 +439,7 @@ impl Vcs for PluginVcsProxy { debug!("remove_remote: remote '{}' removed", name); } Err(e) => { - error!( - "remove_remote: failed to remove '{}': {}", name, e - ); + error!("remove_remote: failed to remove '{}': {}", name, e); } } result @@ -512,7 +492,8 @@ impl Vcs for PluginVcsProxy { ) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "fetch_with_options"); info!( - "fetch_with_options: remote={}, refspec={}, opts={:?}", remote, refspec, opts + "fetch_with_options: remote={}, refspec={}, opts={:?}", + remote, refspec, opts ); let result = self.with_events(on, || { self.call_unit( @@ -570,9 +551,7 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn pull_ff_only(&self, remote: &str, branch: &str, on: Option) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "pull_ff_only"); - info!( - "pull_ff_only: remote={}, branch={}", remote, branch - ); + info!("pull_ff_only: remote={}, branch={}", remote, branch); let result = self.with_events(on, || { self.call_unit( "pull_ff_only", @@ -620,10 +599,7 @@ impl Vcs for PluginVcsProxy { paths.len(), message.len() ); - debug!( - "commit: message='{}'", - message.lines().next().unwrap_or("") - ); + debug!("commit: message='{}'", message.lines().next().unwrap_or("")); trace!("commit: paths={:?}", paths); let result = self.call_json( "commit", @@ -689,7 +665,8 @@ impl Vcs for PluginVcsProxy { match &result { Ok(summary) => { debug!( - "status_summary: {} staged, {} modified, {} untracked, {} conflicted", summary.staged, summary.modified, summary.untracked, summary.conflicted + "status_summary: {} staged, {} modified, {} untracked, {} conflicted", + summary.staged, summary.modified, summary.untracked, summary.conflicted ); } Err(e) => { @@ -743,10 +720,7 @@ impl Vcs for PluginVcsProxy { self.call_json("log_commits", json!({ "query": query })); match &result { Ok(commits) => { - debug!( - "log_commits: {} commits returned", - commits.len() - ); + debug!("log_commits: {} commits returned", commits.len()); } Err(e) => { error!("log_commits: failed: {}", e); @@ -771,11 +745,7 @@ impl Vcs for PluginVcsProxy { self.call_json("diff_file", json!({ "path": path_str.clone() })); match &result { Ok(lines) => { - debug!( - "diff_file: {} lines for {}", - lines.len(), - path_str - ); + debug!("diff_file: {} lines for {}", lines.len(), path_str); } Err(e) => { error!("diff_file: failed for '{}': {}", path_str, e); @@ -798,11 +768,7 @@ impl Vcs for PluginVcsProxy { let result: VcsResult> = self.call_json("diff_commit", json!({ "rev": rev })); match &result { Ok(lines) => { - debug!( - "diff_commit: {} lines for {}", - lines.len(), - rev - ); + debug!("diff_commit: {} lines for {}", lines.len(), rev); } Err(e) => { error!("diff_commit: failed for '{}': {}", rev, e); @@ -828,13 +794,12 @@ impl Vcs for PluginVcsProxy { match &result { Ok(details) => { debug!( - "conflict_details: got details for {} (binary={})", path_str, details.binary + "conflict_details: got details for {} (binary={})", + path_str, details.binary ); } Err(e) => { - error!( - "conflict_details: failed for '{}': {}", path_str, e - ); + error!("conflict_details: failed for '{}': {}", path_str, e); } } result @@ -852,9 +817,7 @@ impl Vcs for PluginVcsProxy { fn checkout_conflict_side(&self, path: &Path, side: ConflictSide) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "checkout_conflict_side"); let path_str = path_to_utf8(path)?; - info!( - "checkout_conflict_side: path={}, side={:?}", path_str, side - ); + info!("checkout_conflict_side: path={}, side={:?}", path_str, side); let result = self.call_unit( "checkout_conflict_side", json!({ "path": path_str.clone(), "side": side }), @@ -862,13 +825,12 @@ impl Vcs for PluginVcsProxy { match &result { Ok(()) => { debug!( - "checkout_conflict_side: resolved {} with {:?}", path_str, side + "checkout_conflict_side: resolved {} with {:?}", + path_str, side ); } Err(e) => { - error!( - "checkout_conflict_side: failed for '{}': {}", path_str, e - ); + error!("checkout_conflict_side: failed for '{}': {}", path_str, e); } } result @@ -898,14 +860,10 @@ impl Vcs for PluginVcsProxy { ); match &result { Ok(()) => { - debug!( - "write_merge_result: wrote resolved content to {}", path_str - ); + debug!("write_merge_result: wrote resolved content to {}", path_str); } Err(e) => { - error!( - "write_merge_result: failed for '{}': {}", path_str, e - ); + error!("write_merge_result: failed for '{}': {}", path_str, e); } } result @@ -972,10 +930,7 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` on backend failure. fn apply_reverse_patch(&self, patch: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "apply_reverse_patch"); - info!( - "apply_reverse_patch: patch_len={}", - patch.len() - ); + info!("apply_reverse_patch: patch_len={}", patch.len()); let result = self.call_unit("apply_reverse_patch", json!({ "patch": patch })); match &result { Ok(()) => { @@ -1006,9 +961,7 @@ impl Vcs for PluginVcsProxy { debug!("delete_branch: branch '{}' deleted", name); } Err(e) => { - error!( - "delete_branch: failed to delete '{}': {}", name, e - ); + error!("delete_branch: failed to delete '{}': {}", name, e); } } result @@ -1055,9 +1008,7 @@ impl Vcs for PluginVcsProxy { debug!("merge_into_current: merge completed"); } Err(e) => { - warn!( - "merge_into_current: merge may have conflicts: {}", e - ); + warn!("merge_into_current: merge may have conflicts: {}", e); } } result @@ -1138,7 +1089,8 @@ impl Vcs for PluginVcsProxy { fn set_branch_upstream(&self, branch: &str, upstream: &str) -> VcsResult<()> { let _timer = LogTimer::new(MODULE, "set_branch_upstream"); info!( - "set_branch_upstream: branch='{}' -> upstream='{}'", branch, upstream + "set_branch_upstream: branch='{}' -> upstream='{}'", + branch, upstream ); let result = self.call_unit( "set_branch_upstream", @@ -1170,9 +1122,7 @@ impl Vcs for PluginVcsProxy { let result = self.call_json("branch_upstream", json!({ "branch": branch })); match &result { Ok(Some(upstream)) => { - debug!( - "branch_upstream: '{}' tracks '{}'", branch, upstream - ); + debug!("branch_upstream: '{}' tracks '{}'", branch, upstream); } Ok(None) => { debug!("branch_upstream: '{}' has no upstream", branch); @@ -1424,11 +1374,7 @@ impl Vcs for PluginVcsProxy { self.call_json("stash_show", json!({ "selector": selector })); match &result { Ok(lines) => { - debug!( - "stash_show: {} lines for {}", - lines.len(), - selector - ); + debug!("stash_show: {} lines for {}", lines.len(), selector); } Err(e) => { error!("stash_show: failed: {}", e); @@ -1448,10 +1394,7 @@ impl Vcs for PluginVcsProxy { /// - `Err(VcsError)` when path is non-UTF8. fn path_to_utf8(path: &Path) -> Result { path.to_str().map(|s| s.to_string()).ok_or_else(|| { - warn!( - "path_to_utf8: non-UTF8 path: {}", - path.display() - ); + warn!("path_to_utf8: non-UTF8 path: {}", path.display()); VcsError::Backend { backend: BackendId::from("plugin"), msg: "non-utf8 path".into(), diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index e1cea9b9..dc97b4ea 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -71,7 +71,8 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { let manifest: PluginManifest = serde_json::from_str(&text).ok()?; debug!( - "load_manifest_from_dir: loaded manifest for plugin '{}'", manifest.id + "load_manifest_from_dir: loaded manifest for plugin '{}'", + manifest.id ); Some(manifest) } @@ -82,9 +83,7 @@ fn load_manifest_from_dir(plugin_dir: &Path) -> Option { /// - Directory/manifest pairs for readable built-in plugins. fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { let _timer = LogTimer::new(MODULE, "builtin_plugin_manifests"); - trace!( - "builtin_plugin_manifests: scanning built-in plugin dirs", - ); + trace!("builtin_plugin_manifests: scanning built-in plugin dirs",); let mut out = Vec::new(); let dirs = built_in_plugin_dirs(); @@ -137,15 +136,11 @@ fn builtin_plugin_manifests() -> Vec<(PathBuf, PluginManifest)> { /// - `Err(String)` if installed plugin components cannot be loaded. pub fn list_plugin_vcs_backends() -> Result, String> { let _timer = LogTimer::new(MODULE, "list_plugin_vcs_backends"); - info!( - "list_plugin_vcs_backends: discovering VCS backends", - ); + info!("list_plugin_vcs_backends: discovering VCS backends",); let store = PluginBundleStore::new_default(); let plugins = store.list_current_components().map_err(|e| { - error!( - "list_plugin_vcs_backends: failed to list components: {}", e - ); + error!("list_plugin_vcs_backends: failed to list components: {}", e); e })?; @@ -174,7 +169,8 @@ pub fn list_plugin_vcs_backends() -> Result, String for (id, name) in module.vcs_backends { let backend_id = BackendId::from(id.as_str()); debug!( - "list_plugin_vcs_backends: found backend '{}' from plugin '{}'", backend_id, p.plugin_id + "list_plugin_vcs_backends: found backend '{}' from plugin '{}'", + backend_id, p.plugin_id ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), @@ -254,7 +250,8 @@ pub fn list_plugin_vcs_backends() -> Result, String continue; } debug!( - "list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", backend_id, plugin_id + "list_plugin_vcs_backends: registering built-in backend '{}' from plugin '{}'", + backend_id, plugin_id ); let candidate = PluginBackendDescriptor { backend_id: backend_id.clone(), @@ -283,17 +280,12 @@ pub fn list_plugin_vcs_backends() -> Result, String /// - `true` when a matching plugin backend is available. /// - `false` otherwise. pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { - trace!( - "has_plugin_vcs_backend: checking for {}", - backend_id - ); + trace!("has_plugin_vcs_backend: checking for {}", backend_id); let result = list_plugin_vcs_backends().ok().is_some_and(|v| { v.iter() .any(|b| b.backend_id.as_ref() == backend_id.as_ref()) }); - debug!( - "has_plugin_vcs_backend: {} -> {}", backend_id, result - ); + debug!("has_plugin_vcs_backend: {} -> {}", backend_id, result); result } @@ -308,10 +300,7 @@ pub fn has_plugin_vcs_backend(backend_id: &BackendId) -> bool { pub fn plugin_vcs_backend_descriptor( backend_id: &BackendId, ) -> Result { - trace!( - "plugin_vcs_backend_descriptor: resolving {}", - backend_id - ); + trace!("plugin_vcs_backend_descriptor: resolving {}", backend_id); let backends = list_plugin_vcs_backends()?; let result = backends @@ -319,13 +308,15 @@ pub fn plugin_vcs_backend_descriptor( .find(|d| d.backend_id.as_ref() == backend_id.as_ref()) .ok_or_else(|| { warn!( - "plugin_vcs_backend_descriptor: unknown backend {}", backend_id + "plugin_vcs_backend_descriptor: unknown backend {}", + backend_id ); format!("Unknown VCS backend: {backend_id}") })?; debug!( - "plugin_vcs_backend_descriptor: found {} from plugin {}", backend_id, result.plugin_id + "plugin_vcs_backend_descriptor: found {} from plugin {}", + backend_id, result.plugin_id ); Ok(result) } @@ -354,18 +345,21 @@ pub fn open_repo_via_plugin_vcs_backend( let desc = plugin_vcs_backend_descriptor(&backend_id).map_err(|e| { error!( - "open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", backend_id, e + "open_repo_via_plugin_vcs_backend: failed to resolve backend {}: {}", + backend_id, e ); VcsError::Unsupported(backend_id.clone()) })?; debug!( - "open_repo_via_plugin_vcs_backend: resolved to plugin {}", desc.plugin_id + "open_repo_via_plugin_vcs_backend: resolved to plugin {}", + desc.plugin_id ); let cfg_value = serde_json::to_value(cfg).map_err(|e| { error!( - "open_repo_via_plugin_vcs_backend: failed to serialize config: {}", e + "open_repo_via_plugin_vcs_backend: failed to serialize config: {}", + e ); VcsError::Backend { backend: backend_id.clone(), @@ -382,7 +376,8 @@ pub fn open_repo_via_plugin_vcs_backend( .runtime_for_workspace_with_config(cfg, &desc.plugin_id, Some(path.to_path_buf())) .map_err(|e| { error!( - "open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", desc.plugin_id, e + "open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", + desc.plugin_id, e ); VcsError::Backend { backend: backend_id.clone(), @@ -390,9 +385,7 @@ pub fn open_repo_via_plugin_vcs_backend( } })?; - debug!( - "open_repo_via_plugin_vcs_backend: opening via plugin proxy", - ); + debug!("open_repo_via_plugin_vcs_backend: opening via plugin proxy",); let result = PluginVcsProxy::open_with_process(backend_id.clone(), runtime, path, cfg_value); diff --git a/Backend/src/tauri_commands/conflicts.rs b/Backend/src/tauri_commands/conflicts.rs index 809ee2bc..b2d1b62d 100644 --- a/Backend/src/tauri_commands/conflicts.rs +++ b/Backend/src/tauri_commands/conflicts.rs @@ -13,7 +13,6 @@ use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; - #[tauri::command] /// Returns conflict details for a repository file. /// @@ -30,7 +29,7 @@ pub async fn git_conflict_details( ) -> Result { let start = std::time::Instant::now(); info!("git_conflict_details: path='{}'", path); - + let repo = current_repo_or_err(&state)?; let path_clone = path.clone(); let result = run_repo_task("git_conflict_details", repo, move |repo| { @@ -42,11 +41,13 @@ pub async fn git_conflict_details( }) }) .await; - + match &result { Ok(details) => { debug!( - "git_conflict_details: found conflict details for '{}' ({:?})", path_clone, start.elapsed() + "git_conflict_details: found conflict details for '{}' ({:?})", + path_clone, + start.elapsed() ); trace!("git_conflict_details: binary={}", details.binary); } @@ -54,7 +55,7 @@ pub async fn git_conflict_details( error!("git_conflict_details: failed: {}", e); } } - + result } @@ -75,8 +76,11 @@ pub async fn git_resolve_conflict_side( side: String, ) -> Result<(), String> { let start = std::time::Instant::now(); - info!("git_resolve_conflict_side: path='{}', side='{}'", path, side); - + info!( + "git_resolve_conflict_side: path='{}', side='{}'", + path, side + ); + let repo = current_repo_or_err(&state)?; let path_clone = path.clone(); let side_clone = side.clone(); @@ -92,25 +96,26 @@ pub async fn git_resolve_conflict_side( repo.inner() .checkout_conflict_side(&PathBuf::from(&path), which) .map_err(|e| { - error!( - "git_resolve_conflict_side: failed for '{}': {}", path, e - ); + error!("git_resolve_conflict_side: failed for '{}': {}", path, e); e.to_string() }) }) .await; - + match &result { Ok(()) => { debug!( - "git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", path_clone, side_clone, start.elapsed() + "git_resolve_conflict_side: resolved '{}' with '{}' ({:?})", + path_clone, + side_clone, + start.elapsed() ); } Err(e) => { error!("git_resolve_conflict_side: failed: {}", e); } } - + result } @@ -132,34 +137,36 @@ pub async fn git_save_merge_result( ) -> Result<(), String> { let start = std::time::Instant::now(); info!( - "git_save_merge_result: path='{}', content_len={}", path, content.len() + "git_save_merge_result: path='{}', content_len={}", + path, + content.len() ); - + let repo = current_repo_or_err(&state)?; let path_clone = path.clone(); let result = run_repo_task("git_save_merge_result", repo, move |repo| { repo.inner() .write_merge_result(&PathBuf::from(&path), content.as_bytes()) .map_err(|e| { - error!( - "git_save_merge_result: failed for '{}': {}", path, e - ); + error!("git_save_merge_result: failed for '{}': {}", path, e); e.to_string() }) }) .await; - + match &result { Ok(()) => { debug!( - "git_save_merge_result: saved '{}' ({:?})", path_clone, start.elapsed() + "git_save_merge_result: saved '{}' ({:?})", + path_clone, + start.elapsed() ); } Err(e) => { error!("git_save_merge_result: failed: {}", e); } } - + result } @@ -193,22 +200,23 @@ fn tool_args(tool: &ExternalTool) -> (String, Vec) { pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> Result<(), String> { let start = std::time::Instant::now(); info!("git_launch_merge_tool: path='{}'", path); - + let cfg = state.config(); let tool = cfg.diff.external_merge.clone(); - + if !tool.enabled { warn!("git_launch_merge_tool: external merge tool is disabled"); return Err("no external merge tool configured".into()); } - + if tool.path.trim().is_empty() { warn!("git_launch_merge_tool: no tool path configured"); return Err("no external merge tool configured".into()); } debug!( - "git_launch_merge_tool: tool='{}', args='{}'", tool.path, tool.args + "git_launch_merge_tool: tool='{}', args='{}'", + tool.path, tool.args ); let repo = current_repo_or_err(&state)?; @@ -227,7 +235,9 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> }; trace!( - "git_launch_merge_tool: repo_root='{}', abs_path='{}'", repo_root.display(), abs.display() + "git_launch_merge_tool: repo_root='{}', abs_path='{}'", + repo_root.display(), + abs.display() ); let mut cmd = Command::new(&tool_path); @@ -255,26 +265,32 @@ pub async fn git_launch_merge_tool(state: State<'_, AppState>, path: String) -> } debug!( - "git_launch_merge_tool: spawning '{}' with args {:?}", tool_path, expanded + "git_launch_merge_tool: spawning '{}' with args {:?}", + tool_path, expanded ); cmd.spawn().map(|_| ()).map_err(|e| { - error!("git_launch_merge_tool: failed to spawn '{}': {}", tool_path, e); + error!( + "git_launch_merge_tool: failed to spawn '{}': {}", + tool_path, e + ); e.to_string() }) }) .await; - + match &result { Ok(()) => { info!( - "git_launch_merge_tool: launched tool for '{}' ({:?})", path_for_log, start.elapsed() + "git_launch_merge_tool: launched tool for '{}' ({:?})", + path_for_log, + start.elapsed() ); } Err(e) => { error!("git_launch_merge_tool: failed: {}", e); } } - + result } diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index e14c3a41..6f59790a 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -118,18 +118,28 @@ pub fn set_plugin_enabled( plugin_id: String, enabled: bool, ) -> Result<(), String> { - trace!("set_plugin_enabled: entering with plugin_id='{}', enabled={}", plugin_id, enabled); - + trace!( + "set_plugin_enabled: entering with plugin_id='{}', enabled={}", + plugin_id, + enabled + ); + let plugin_id = plugin_id.trim().to_string(); debug!("set_plugin_enabled: trimmed plugin_id='{}'", plugin_id); - - info!("set_plugin_enabled: plugin={}, enabled={}", plugin_id, enabled); - + + info!( + "set_plugin_enabled: plugin={}, enabled={}", + plugin_id, enabled + ); + state .plugin_runtime() .set_plugin_enabled(&plugin_id, enabled) .map_err(|e| { - error!("set_plugin_enabled failed: plugin={}, error={}", plugin_id, e); + error!( + "set_plugin_enabled failed: plugin={}, error={}", + plugin_id, e + ); e }) } diff --git a/Backend/src/tauri_commands/ssh.rs b/Backend/src/tauri_commands/ssh.rs index 5e5e7b44..e080e189 100644 --- a/Backend/src/tauri_commands/ssh.rs +++ b/Backend/src/tauri_commands/ssh.rs @@ -6,7 +6,6 @@ use log::{debug, error, info, trace, warn}; use serde::Serialize; use tauri::command; - /// Returns `~/.ssh/known_hosts` path. /// /// # Returns @@ -14,9 +13,7 @@ use tauri::command; /// - `Err(String)` when home directory cannot be resolved. fn known_hosts_path() -> Result { let home = dirs::home_dir().ok_or_else(|| { - error!( - "known_hosts_path: could not determine home directory", - ); + error!("known_hosts_path: could not determine home directory",); "Could not determine home directory".to_string() })?; let path = home.join(".ssh").join("known_hosts"); @@ -31,9 +28,7 @@ fn known_hosts_path() -> Result { /// - `Err(String)` when home directory cannot be resolved. fn ssh_dir_path() -> Result { let home = dirs::home_dir().ok_or_else(|| { - error!( - "ssh_dir_path: could not determine home directory", - ); + error!("ssh_dir_path: could not determine home directory",); "Could not determine home directory".to_string() })?; let path = home.join(".ssh"); @@ -48,24 +43,15 @@ fn ssh_dir_path() -> Result { /// - `Err(String)` on resolution or create failure. fn ensure_ssh_dir() -> Result { let home = dirs::home_dir().ok_or_else(|| { - error!( - "ensure_ssh_dir: could not determine home directory", - ); + error!("ensure_ssh_dir: could not determine home directory",); "Could not determine home directory".to_string() })?; let dir = home.join(".ssh"); fs::create_dir_all(&dir).map_err(|e| { - error!( - "ensure_ssh_dir: failed to create {}: {}", - dir.display(), - e - ); + error!("ensure_ssh_dir: failed to create {}: {}", dir.display(), e); format!("Failed to create ~/.ssh: {e}") })?; - debug!( - "ensure_ssh_dir: ssh directory ready at {}", - dir.display() - ); + debug!("ensure_ssh_dir: ssh directory ready at {}", dir.display()); Ok(dir) } @@ -107,12 +93,14 @@ fn run_command(cmd: &str, args: &[&str]) -> Result { if out.status.success() { debug!( - "run_command: {} succeeded in {:?} (code={})", cmd, elapsed, result.code + "run_command: {} succeeded in {:?} (code={})", + cmd, elapsed, result.code ); trace!("run_command: stdout='{}'", result.stdout); } else { warn!( - "run_command: {} failed in {:?} (code={}): {}", cmd, elapsed, result.code, result.stderr + "run_command: {} failed in {:?} (code={}): {}", + cmd, elapsed, result.code, result.stderr ); } @@ -145,9 +133,7 @@ fn keyscan(host: &str) -> Result { if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); - error!( - "keyscan: failed for '{}' in {:?}: {}", host, elapsed, err - ); + error!("keyscan: failed for '{}' in {:?}: {}", host, elapsed, err); return Err(if err.is_empty() { format!("ssh-keyscan exited with {}", out.status) } else { @@ -162,9 +148,7 @@ fn keyscan(host: &str) -> Result { return Err("ssh-keyscan returned no host keys".to_string()); } - debug!( - "keyscan: got keys for '{}' in {:?}", host, elapsed - ); + debug!("keyscan: got keys for '{}' in {:?}", host, elapsed); trace!( "keyscan: keys='{}'", s.lines().take(3).collect::>().join("\\n") @@ -206,17 +190,12 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { ensure_ssh_dir()?; let known_hosts = known_hosts_path()?; - debug!( - "ssh_trust_host: known_hosts path={}", - known_hosts.display() - ); + debug!("ssh_trust_host: known_hosts path={}", known_hosts.display()); // Avoid duplicating entries if the host is already present. if let Ok(existing) = fs::read_to_string(&known_hosts) { if existing.lines().any(|l| l.contains(host)) { - debug!( - "ssh_trust_host: host '{}' already in known_hosts", host - ); + debug!("ssh_trust_host: host '{}' already in known_hosts", host); return Ok(()); } } @@ -241,9 +220,7 @@ pub fn ssh_trust_host(host: String) -> Result<(), String> { })?; let elapsed = start.elapsed(); - info!( - "ssh_trust_host: host '{}' trusted in {:?}", host, elapsed - ); + info!("ssh_trust_host: host '{}' trusted in {:?}", host, elapsed); Ok(()) } @@ -268,9 +245,7 @@ pub fn ssh_agent_list_keys() -> Result { warn!("ssh_agent_list_keys: agent has no identities"); } code => { - warn!( - "ssh_agent_list_keys: agent returned code {}", code - ); + warn!("ssh_agent_list_keys: agent returned code {}", code); } } @@ -293,15 +268,11 @@ pub struct SshKeyCandidate { /// - `Ok(Vec)` sorted candidate list. /// - `Err(String)` when home/ssh directory resolution fails. pub fn ssh_key_candidates() -> Result, String> { - info!( - "ssh_key_candidates: scanning for SSH key candidates", - ); + info!("ssh_key_candidates: scanning for SSH key candidates",); let dir = ssh_dir_path()?; let Ok(read_dir) = fs::read_dir(&dir) else { - debug!( - "ssh_key_candidates: ssh directory does not exist or is not readable", - ); + debug!("ssh_key_candidates: ssh directory does not exist or is not readable",); return Ok(vec![]); }; @@ -322,10 +293,7 @@ pub fn ssh_key_candidates() -> Result, String> { || name.ends_with(".log") || name.ends_with(".old") { - trace!( - "ssh_key_candidates: skipping non-key file: {}", - name - ); + trace!("ssh_key_candidates: skipping non-key file: {}", name); continue; } @@ -348,10 +316,7 @@ pub fn ssh_key_candidates() -> Result, String> { } keys.sort_by(|a, b| a.name.cmp(&b.name)); - debug!( - "ssh_key_candidates: found {} candidate keys", - keys.len() - ); + debug!("ssh_key_candidates: found {} candidate keys", keys.len()); Ok(keys) } @@ -378,9 +343,7 @@ pub fn ssh_add_key(path: String) -> Result { if result.code == 0 { debug!("ssh_add_key: key added successfully"); } else { - warn!( - "ssh_add_key: failed to add key: {}", result.stderr - ); + warn!("ssh_add_key: failed to add key: {}", result.stderr); } Ok(result) diff --git a/Backend/src/tauri_commands/updater.rs b/Backend/src/tauri_commands/updater.rs index b964fb31..1b5dfaa3 100644 --- a/Backend/src/tauri_commands/updater.rs +++ b/Backend/src/tauri_commands/updater.rs @@ -5,7 +5,6 @@ use tauri::{Emitter, Manager, Runtime, Window}; use tauri_plugin_updater::UpdaterExt; - #[tauri::command] /// Downloads and installs an available application update. /// @@ -18,35 +17,36 @@ use tauri_plugin_updater::UpdaterExt; pub async fn updater_install_now(window: Window) -> Result<(), String> { let start = std::time::Instant::now(); info!("updater_install_now: starting update check"); - + let app = window.app_handle(); let updater = app.updater().map_err(|e| { error!("updater_install_now: failed to get updater: {}", e); e.to_string() })?; - + debug!("updater_install_now: checking for updates"); let check_result = updater.check().await.map_err(|e| { error!("updater_install_now: update check failed: {}", e); e.to_string() })?; - + match check_result { Some(update) => { let version = &update.version; let current_version = &update.current_version; info!( - "updater_install_now: update available: {} -> {}", current_version, version + "updater_install_now: update available: {} -> {}", + current_version, version ); debug!( "updater_install_now: update date={:?}, body_len={}", update.date, update.body.as_ref().map(|b| b.len()).unwrap_or(0) ); - + let app2 = app.clone(); let download_start = std::time::Instant::now(); - + update .download_and_install( |received, total| { @@ -57,7 +57,10 @@ pub async fn updater_install_now(window: Window) -> Result<(), St 0 }; trace!( - "updater_install_now: download progress {}/{} bytes ({}%)", received, total_val, percent + "updater_install_now: download progress {}/{} bytes ({}%)", + received, + total_val, + percent ); let payload = serde_json::json!({ "kind": "progress", @@ -69,7 +72,8 @@ pub async fn updater_install_now(window: Window) -> Result<(), St || { let download_elapsed = download_start.elapsed(); info!( - "updater_install_now: download completed in {:?}", download_elapsed + "updater_install_now: download completed in {:?}", + download_elapsed ); let _ = app2.emit( "update:progress", @@ -82,17 +86,19 @@ pub async fn updater_install_now(window: Window) -> Result<(), St error!("updater_install_now: download/install failed: {}", e); e.to_string() })?; - + let elapsed = start.elapsed(); info!( - "updater_install_now: update installed successfully in {:?}", elapsed + "updater_install_now: update installed successfully in {:?}", + elapsed ); Ok(()) } None => { let elapsed = start.elapsed(); debug!( - "updater_install_now: no update available (checked in {:?})", elapsed + "updater_install_now: no update available (checked in {:?})", + elapsed ); Ok(()) } diff --git a/Cargo.lock b/Cargo.lock index ccde5199..0a23eaf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,18 +298,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auditable-serde" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" -dependencies = [ - "semver", - "serde", - "serde_json", - "topological-sort", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -1479,6 +1467,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1553,7 +1547,6 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", - "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1999,7 +1992,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", "serde", ] @@ -2008,6 +2001,9 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3316,7 +3312,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "wit-bindgen 0.41.0", + "wit-bindgen 0.53.0", ] [[package]] @@ -3325,6 +3321,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", + "syn 2.0.114", ] [[package]] @@ -4718,15 +4715,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spdx" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" -dependencies = [ - "smallvec", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5504,12 +5492,6 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" -[[package]] -name = "topological-sort" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" - [[package]] name = "tower" version = "0.5.3" @@ -5936,16 +5918,6 @@ dependencies = [ "wat", ] -[[package]] -name = "wasm-encoder" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" -dependencies = [ - "leb128fmt", - "wasmparser 0.227.1", -] - [[package]] name = "wasm-encoder" version = "0.243.0" @@ -5978,33 +5950,26 @@ dependencies = [ [[package]] name = "wasm-metadata" -version = "0.227.1" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "auditable-serde", - "flate2", "indexmap 2.13.0", - "serde", - "serde_derive", - "serde_json", - "spdx", - "url", - "wasm-encoder 0.227.1", - "wasmparser 0.227.1", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] name = "wasm-metadata" -version = "0.244.0" +version = "0.245.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "ce52b194ec202d029751081d735c1ae49c1bacbdc2634c821a86211e3751300c" dependencies = [ "anyhow", "indexmap 2.13.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder 0.245.0", + "wasmparser 0.245.0", ] [[package]] @@ -6020,18 +5985,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - [[package]] name = "wasmparser" version = "0.243.0" @@ -6064,6 +6017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48a767a48974f0c8b66f211b96e01aa77feed58b8ccce4e7f0cff0ae55b174d4" dependencies = [ "bitflags 2.10.0", + "hashbrown 0.16.1", "indexmap 2.13.0", "semver", ] @@ -7111,16 +7065,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wit-bindgen" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" -dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro 0.41.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7131,14 +7075,13 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-core" -version = "0.41.0" +name = "wit-bindgen" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" +checksum = "d4453ede57df0e4dfddfe20835b934659de17abc79fe9dbdd36d28fa0ac1b959" dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser 0.227.1", + "bitflags 2.10.0", + "wit-bindgen-rust-macro 0.53.0", ] [[package]] @@ -7153,83 +7096,83 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.41.0" +name = "wit-bindgen-core" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" +checksum = "f7a381fbf6d0b3403a207adf15c84811e039d2c4a30d4bcc329be5b8953cdad3" dependencies = [ - "bitflags 2.10.0", - "futures", - "once_cell", + "anyhow", + "heck 0.5.0", + "wit-parser 0.245.0", ] [[package]] name = "wit-bindgen-rust" -version = "0.41.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", "indexmap 2.13.0", "prettyplease", "syn 2.0.114", - "wasm-metadata 0.227.1", - "wit-bindgen-core 0.41.0", - "wit-component 0.227.1", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", ] [[package]] name = "wit-bindgen-rust" -version = "0.51.0" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "22eb69865fd5fc2771e2197f3f0e75ddf7390c6ccb30895a6ea5837585bd4df4" dependencies = [ "anyhow", "heck 0.5.0", "indexmap 2.13.0", "prettyplease", "syn 2.0.114", - "wasm-metadata 0.244.0", - "wit-bindgen-core 0.51.0", - "wit-component 0.244.0", + "wasm-metadata 0.245.0", + "wit-bindgen-core 0.53.0", + "wit-component 0.245.0", ] [[package]] name = "wit-bindgen-rust-macro" -version = "0.41.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn 2.0.114", - "wit-bindgen-core 0.41.0", - "wit-bindgen-rust 0.41.0", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", ] [[package]] name = "wit-bindgen-rust-macro" -version = "0.51.0" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "f0916017a8d24501683b336f3205cc8958265b5cc6b9282b6a844701b17501c2" dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn 2.0.114", - "wit-bindgen-core 0.51.0", - "wit-bindgen-rust 0.51.0", + "wit-bindgen-core 0.53.0", + "wit-bindgen-rust 0.53.0", ] [[package]] name = "wit-component" -version = "0.227.1" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", @@ -7238,17 +7181,17 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.227.1", - "wasm-metadata 0.227.1", - "wasmparser 0.227.1", - "wit-parser 0.227.1", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] name = "wit-component" -version = "0.244.0" +version = "0.245.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "896efcb3d68ea1cb555d2d1df185b4071b39d91cf850456809bb0c90a0e4e66e" dependencies = [ "anyhow", "bitflags 2.10.0", @@ -7257,17 +7200,17 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata 0.244.0", - "wasmparser 0.244.0", - "wit-parser 0.244.0", + "wasm-encoder 0.245.0", + "wasm-metadata 0.245.0", + "wasmparser 0.245.0", + "wit-parser 0.245.0", ] [[package]] name = "wit-parser" -version = "0.227.1" +version = "0.243.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" dependencies = [ "anyhow", "id-arena", @@ -7278,14 +7221,14 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", + "wasmparser 0.243.0", ] [[package]] name = "wit-parser" -version = "0.243.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", @@ -7296,16 +7239,17 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.243.0", + "wasmparser 0.244.0", ] [[package]] name = "wit-parser" -version = "0.244.0" +version = "0.245.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "b5cda4f69fdc5a8d54f7032262217dd89410a933e3f86fdad854f5833caf3ccb" dependencies = [ "anyhow", + "hashbrown 0.16.1", "id-arena", "indexmap 2.13.0", "log", @@ -7314,7 +7258,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", + "wasmparser 0.245.0", ] [[package]] From 05e37ad919c39bae2bec6fcf7e8d9f25474383b2 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 08:29:03 +0000 Subject: [PATCH 49/96] Update docs --- AGENTS.md | 8 +- ARCHITECTURE.md | 4 +- DESIGN.md | 2 - PLANS.md | 2 +- README.md | 10 +- docs/plugin architecture.md | 263 +++++++----------------------------- docs/plugins.md | 181 ++++++------------------- 7 files changed, 102 insertions(+), 368 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc89d3ed..6af4f2c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ ### Linting and formatting -- `just fix`: formatter/lint quick fixes (runs `cargo fmt`, `cargo clippy --fix`, frontend type-check, and bundle verification). +- `just fix`: formatter/lint quick fixes (runs `cargo fmt`, `cargo clippy --fix`, and frontend type-check). - `cargo fmt --all`: format Rust code - `cargo clippy --all-targets -- -D warnings`: check Rust for issues - `cargo clippy --fix --all-targets --allow-dirty`: auto-fix clippy issues @@ -52,9 +52,9 @@ ## Plugin runtime & host expectations - Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). -- The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. The canonical host/plugin contract is defined in `Core/wit/openvcs-core.wit` (see `openvcs_core::app_api`). -- When changing host APIs, capability strings, or runtime behavior, update `Core/wit/openvcs-core.wit`, the generated bindings, and the runtime logic in `Backend/src/plugin_runtime`. -- JavaScript-based plugin UI contributions (e.g., `entry.js`) are deprecated: route new UI work through the host/app APIs rather than embedding JS so bundles remain Wasm-only. +- The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. +- The canonical host/plugin contract is defined under `Core/wit/` (`host.wit`, `plugin.wit`, `vcs.wit`). +- When changing host APIs, capabilities, or runtime behavior, update `Core/wit/` and the runtime logic in `Backend/src/plugin_runtime`. ## Coding style & conventions diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 766e6278..a3a677bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,7 +22,6 @@ Primary flow: Frontend: - `Frontend/src/scripts/main.ts`: UI bootstrap and feature wiring. - `Frontend/src/scripts/lib/tauri.ts`: minimal bridge wrapper for `invoke`/`listen`. -- `Frontend/src/scripts/plugins.ts`: UI plugin runtime and plugin-contributed UI hooks. - `Frontend/src/scripts/features/`: feature modules grouped by domain. - `Frontend/src/styles/`: tokens, layout, modal, and component styles. @@ -44,7 +43,7 @@ Backend: - Command boundary: Feature-facing backend API lives under `Backend/src/tauri_commands/`. - Backend/plugin boundary: - Backend communicates with plugin components over the component-model ABI defined in `Core/wit/openvcs-core.wit`, not in-process APIs. + Backend communicates with plugin components over the component-model ABI defined under `Core/wit/`. - Settings boundary: Backend persists/loads app configuration and mediates environment application. @@ -52,7 +51,6 @@ Backend: - Active repo backend is treated as dynamic availability; stale handles are rejected when backend disappears. - Plugin components that request capabilities require approval before execution. -- Frontend plugin scripts can extend UI, but repository operations still route through backend commands. - Output/log/progress signaling is centralized through backend event emission. ## Cross-Cutting Concerns diff --git a/DESIGN.md b/DESIGN.md index ac43e7e2..ac2a5b1e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -77,7 +77,6 @@ Current repo/backend validity checks and progress forwarding are centralized in ## Plugin Lifecycle - Plugin discovery, load, installation, uninstall, capability approval, and function invocation are handled in `Backend/src/tauri_commands/plugins.rs` plus plugin store/runtime modules. -- UI plugin loading is coordinated in `Frontend/src/scripts/plugins.ts`. ## State Model @@ -139,6 +138,5 @@ When adding or changing behavior: - `Backend/src/plugin_vcs_backends.rs` - `Frontend/src/scripts/lib/tauri.ts` - `Frontend/src/scripts/main.ts` -- `Frontend/src/scripts/plugins.ts` - `docs/plugin architecture.md` - `docs/plugins.md` diff --git a/PLANS.md b/PLANS.md index c38d4cbd..fd8a61f0 100644 --- a/PLANS.md +++ b/PLANS.md @@ -139,7 +139,7 @@ Prefer additive code changes followed by subtractions that keep tests passing. P Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.: - In crates/foo/planner.rs, define: + In src/foo/planner.rs, define: pub trait Planner { fn plan(&self, observed: &Observed) -> Vec; diff --git a/README.md b/README.md index 4d0f6ae2..0d97f4f2 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. - 🗃 **Git LFS helpers:** fetch/pull/prune, track/untrack, inspect tracked paths. - 🔐 **SSH helpers:** trust host keys, list/add SSH agent keys, key discovery. - 🎨 **Themes:** built-in light/dark themes, plus plugin-provided themes (standalone theme `.zip` packs are not supported). -- 🧩 **Plugins (early):** local plugins with manifests, hooks/actions, and UI contributions (no store yet). +- 🧩 **Plugins (early):** installable `.ovcsp` bundles (theme packs and/or Wasm modules). - 🔄 **Updater & logs:** update check/install, VCS output log window, app log tail/clear. ## Planned / Exploratory @@ -89,10 +89,6 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. . ├── Backend/ # Rust + Tauri backend (native logic, app entry) ├── Frontend/ # TypeScript + Vite frontend (UI layer) -├── crates/ # Rust crates for modular OpenVCS components -│ ├── openvcs-core # Core traits and abstractions -│ ├── openvcs-git # Git implementation -│ └── openvcs-git-libgit2 # Alternative Git backend (libgit2) ├── Cargo.toml # Workspace manifest ├── LICENSE └── README.md @@ -155,6 +151,7 @@ npm install **Run in development mode (dev server):** ```bash +cd Backend cargo tauri dev ``` @@ -181,7 +178,6 @@ cargo build - **Frontend:** TypeScript + Vite for a fast iteration loop. - **Backend:** Rust + Tauri commands for native operations. -- **Crates:** All modular logic (e.g., Git backend, core abstractions) lives under `crates/`. - **Bridge:** Tauri `invoke` is used to call Rust from the UI; events are used for progress/streaming. --- @@ -189,7 +185,7 @@ cargo build ## Testing - Use `just test` to run the full project test/check flow (runs `cargo test --workspace`, then frontend typecheck and tests). -- Use `just fix` to run formatting and quick fixes; it now also builds the frontend and typechecks (`npm run build` and `npm exec tsc -- -p tsconfig.json --noEmit`). +- Use `just fix` to run formatting and clippy fixes plus a frontend typecheck. - Frontend-only commands (from `Frontend/`): - `npm exec tsc -- -p tsconfig.json --noEmit` — TypeScript typecheck for the frontend. - `npm test` — run Vitest unit tests (added to the frontend devDependencies). diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index c1572ded..ff0a4137 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -1,242 +1,79 @@ # OpenVCS Plugin Architecture -This document describes OpenVCS's plugin system. +This document describes the current plugin system used by the OpenVCS desktop client. ## Architecture -``` -Client <---> Core <---> Plugin -``` - -No translation layers. Just **WIT and Rust**. - -- **Core** is the glue - provides WIT bindings, host implementation, plugin runtime -- **Plugins** are pure WASM components with no boilerplate -- **Client** loads and communicates with plugins via WIT - -## Plugin Types - -| Type | Code | Required Functions | -| -------------- | ---- | ------------------------------------ | -| **Theme** | No | None | -| **Code** | Yes | `init`, `deinit` | -| **Code + VCS** | Yes | `init`, `deinit` + all VCS functions | - -VCS is optional - plugins can implement it if they provide a VCS backend. - -## Plugin Structure - -### Theme Plugin - -``` -/ - openvcs.plugin.json - themes/ - / - theme.json - theme.css - ... -``` - -No Rust code. Theme plugins ship UI assets only. - -### Code Plugin - -``` -/ - openvcs.plugin.json - src/ - lib.rs # Rust library - Cargo.toml -``` - -Plugin author writes: - -```rust -// src/lib.rs -use openvcs_core::plugin_api::*; - -// Internal helpers - NOT ABI -fn helper() -> ... { ... } - -// Plugin ABI - functions in mod plugin are exported -#[openvcs_plugin] -mod plugin { - use super::*; - - pub fn init() -> Result<(), PluginError> { Ok(()) } - pub fn deinit() -> Result<(), PluginError> { Ok(()) } -} - -// Generate WIT Guest impl -openvcs_core::export_plugin!(plugin); -``` - -### VCS Plugin - -Same as Code Plugin, but implements VCS functions: - -```rust -// src/lib.rs -use openvcs_core::vcs_api::*; - -// Internal helpers - NOT ABI -fn helper() -> ... { ... } - -// Plugin ABI - functions in mod plugin are exported -#[openvcs_plugin] -mod plugin { - use super::*; - - pub fn init() -> Result<(), PluginError> { Ok(()) } - pub fn deinit() -> Result<(), PluginError> { Ok(()) } - pub fn get_caps() -> Result { ... } - pub fn list_branches() -> Result, PluginError> { ... } - // ... all VCS functions required -} - -// Generate WIT Guest impl -openvcs_core::export_plugin!(plugin); +```text +Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) ``` -## Why This Structure? +- The frontend talks to the backend via Tauri commands/events. +- The backend loads plugins as component-model WebAssembly modules and calls them via typed ABI bindings. -The `mod plugin` approach provides clear separation: +## Contracts (WIT) -- **Outside `mod plugin`** - Internal helpers, not exported to WIT -- **Inside `mod plugin`** - ABI functions, exported to WIT +The authoritative host/plugin contract lives under `Core/wit/`: -This is more explicit than marking every function, while keeping the plugin code organized. +- `Core/wit/host.wit`: host imports plugins can call (workspace IO, git process exec, notifications, logging, events) +- `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) +- `Core/wit/vcs.wit`: VCS backend world (`vcs`) -## WIT Interfaces +The backend generates host bindings from these contracts and links them into a Wasmtime component runtime. -### plugin.wit (Required) +## Plugin types -Required for all code plugins: +- Theme pack plugin + - Ships `themes/` assets. + - A plugin may ship themes alone or alongside a module. -```wit -interface plugin-api { - init: func() -> result<_, plugin-error> - deinit: func() -> result<_, plugin-error> -} +- Module plugin (lifecycle only) + - Exports the `plugin` world from `Core/wit/plugin.wit`. + - Must implement `plugin-api.init` and `plugin-api.deinit`. -world plugin { - import host-api; - export plugin-api; -} -``` - -### vcs.wit (Optional) - -For VCS backend plugins: - -```wit -interface vcs-api { - get-caps: func() -> result - open: func(path: string, config: list) -> result<_, plugin-error> - list-branches: func() -> result list - commit: func(message: string, name: string, email: string, paths: list) -> result - // ... all VCS functions -} - -world vcs { - import host-api; - export vcs-api; -} -``` - -### Custom WIT (Optional) - -Plugins can define their own WIT interfaces for **plugin-to-plugin** communication. +- VCS backend plugin + - Exports the `vcs` world from `Core/wit/vcs.wit`. + - Must export both `plugin-api` (lifecycle) and `vcs-api` (backend operations). -Example: A GitHub plugin exports a `github-api` interface that other plugins can call. +## Runtime implementation -## Plugin Dependencies +Key host code locations: -Plugins can declare dependencies on other plugins: - -```json -{ - "id": "my-plugin", - "dependencies": { - "openvcs.github": { - "required": false - }, - "openvcs.ai": { - "required": true - } - } -} -``` - -- Required dependencies: plugin fails to load if missing -- Optional dependencies: plugin loads without them (can check at runtime) - -## The Macros - -### `#[openvcs_plugin]` - -Marks a module as containing plugin ABI functions: - -```rust -#[openvcs_plugin] -mod plugin { - pub fn init() -> ... { } - pub fn deinit() -> ... { } -} -``` +- `Client/Backend/src/plugin_runtime/runtime_select.rs`: enforces component-only runtime (non-component modules are rejected). +- `Client/Backend/src/plugin_runtime/component_instance.rs`: Wasmtime component instantiation + typed calls. +- `Client/Backend/src/plugin_bundles.rs`: `.ovcsp` installation, indexing, capability approvals, module discovery. -### `export_plugin!` +## Bundle format (`.ovcsp`) -Generates the WIT Guest impl: +Plugins are installed from `.ovcsp` tar.xz archives. Layout: -```rust -openvcs_core::export_plugin!(plugin); -``` - -This must be called after the `#[openvcs_plugin]` mod is defined. - -## Building Plugins - -SDK builds plugins with: - -```bash -cargo build --lib --target wasm32-wasip1 -``` - -No shim generation. No code generation. Just compile the library to WASM. - -## Bundle Format (.ovcsp) - -An `.ovcsp` is a tar.xz archive: - -``` -/ +```text +/ openvcs.plugin.json + icon. (optional) + themes/ (optional; may coexist with a module) bin/ - .wasm - assets/... (optional) - themes/... (optional, theme plugins only) + .wasm (optional; must be a component) ``` ## Manifest (`openvcs.plugin.json`) -```json -{ - "id": "openvcs.git", - "name": "Git", - "version": "0.1.0", - "author": "OpenVCS Team", - "description": "Git VCS backend", - "default_enabled": true, - "dependencies": {} -} -``` +The host cares about: + +- `id` (required) +- `name`, `version` (optional but recommended) +- `default_enabled` (optional) +- `capabilities` (optional list of strings) +- `module.exec` (optional `.wasm` filename under `bin/`) +- `module.vcs_backends` (optional VCS backend ids the module provides) + +## Capabilities + +Plugins request capabilities through the manifest `capabilities` array. +The host enforces capability approval before allowing privileged host API calls. -## Security +## Security model -- Plugins run **out-of-process** in WebAssembly -- Client never loads third-party dynamic libraries -- No native code execution from plugins -- Capabilities declared in manifest, approved by user -- Process isolation + resource limits +- Plugins run out-of-process in a Wasmtime component runtime. +- Host APIs are explicit via WIT imports. +- Workspace file access is mediated by the host and can be confined to a selected workspace root. diff --git a/docs/plugins.md b/docs/plugins.md index a5fbb3b8..9545bac9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,164 +1,69 @@ -# Plugins (WIP) +# Plugins -OpenVCS plugins are local, user-installed extensions that can: +OpenVCS plugins are local extensions installed as `.ovcsp` bundles. -- Register one or more themes (using the existing `theme.json` theme-pack format) -- Run JavaScript/TypeScript-authored code in the UI -- Hook into app actions like commit/push/branch switch -- Add basic UI contributions (menu items + titlebar buttons) -- Add context menu items (right-click actions) in lists like files/commits/branches +Plugins may include themes, a Wasm module, or both. -## Plugin Location +## Where plugins live -Plugins are discovered from: +OpenVCS discovers plugins from two places: -- User plugins directory: OpenVCS config `plugins/` folder -- Built-in plugins directory (for packaged/bundled plugins): `built-in-plugins/` +- User plugins directory (in the OpenVCS config dir): `plugins/` +- Built-in plugins directory (bundled with the app): `built-in-plugins/` -## Plugin Format +## Bundle format (`.ovcsp`) -A plugin is a folder containing: +An `.ovcsp` is a tar.xz archive with this layout: -- `openvcs.plugin.json` (manifest) -- An optional JavaScript ESM entry file (referenced by `entry`) -- An optional `themes/` folder containing one or more theme packs (each containing a `theme.json`) +```text +/ + openvcs.plugin.json + icon. (optional) + themes/ (optional) + bin/ + .wasm (optional; must be a component) +``` -### `openvcs.plugin.json` +## Manifest (`openvcs.plugin.json`) -Minimal example: +Minimal theme-only plugin: ```json { - "id": "example.hello", - "name": "Hello Plugin", - "category": "Examples", - "tags": ["example", "demo"], - "version": "0.1.0", - "author": "You", - "description": "Demonstrates OpenVCS plugins.", - "entry": "entry.js" + "id": "example.theme-pack", + "name": "Example Theme Pack", + "version": "0.1.0" } ``` -Fields: - -- `id` (string, required): stable unique plugin id -- `name` (string, required): display name -- `category` (string, optional): broad grouping for UI (e.g. `Themes`, `Integrations`, `Examples`) -- `tags` (string[], optional): searchable keywords (e.g. `["git", "theme", "hooks"]`) -- `entry` (string, optional): relative path to a JS ESM module to run -- Theme packs are auto-detected under `themes/` within the plugin folder (no manifest field required) - - Theme ids are namespaced at runtime as `.` to avoid collisions - -## Running Code (TypeScript / JavaScript) - -Plugins run as JavaScript ESM modules in the UI. - -- Author in TypeScript if you want, but compile to a single `.js` file for `entry`. -- Keep it single-file for now; inline modules can’t reliably `import` sibling files by relative path. - -## Plugin API (UI Runtime) - -Plugin entry code can call the global `window.OpenVCS` API: - -- `window.OpenVCS.registerPlugin({ ... })` -- `window.OpenVCS.registerAction(id, handler)` -- `window.OpenVCS.addMenuItem({ label, action })` -- `window.OpenVCS.addTitlebarButton({ label, action })` -- `window.OpenVCS.notify(message)` -- `window.OpenVCS.invoke(cmd, args)` / `window.OpenVCS.listen(event, cb)` - -Menu items appear under the `Plugins` menu. Titlebar buttons appear next to `Push`. - -### Context menus - -Plugins can add items to existing right-click menus by including `contextMenus` in `registerPlugin(...)`. - -```js -window.OpenVCS?.registerPlugin({ - contextMenus: { - files: [{ label: 'Copy selected paths', action: 'my.plugin:copyPaths' }], - commits: [{ label: 'Copy commit hash', action: 'my.plugin:copyHash' }], - branches: [{ label: 'Copy branch name', action: 'my.plugin:copyBranch' }], - }, -}); -``` - -When the user clicks one of these items, OpenVCS runs the referenced action with a payload that describes the clicked object (e.g. `payload.paths` / `payload.commit` / `payload.branch`). - -### Calling plugin module RPC from the console - -Plugin modules register RPC methods (e.g. `example.notify.ping`) via `openvcs_core::plugin_runtime::register_delegate`, and you can hit those endpoints from the dev console using the new `window.callPluginMethod` helper (it wraps `call_plugin_module_method` so you don’t have to go through `window.OpenVCS.invoke` manually). Example: - -```js -await window.callPluginMethod( - 'example.notify', - 'example.notify.ping', - { message: 'hello from the console' }, -); -``` - -The helper spawns the plugin’s module process, passes the JSON-RPC request, waits for the response, and enforces any requested capabilities (you must have approved them in Settings → Plugins). There’s also a matching API on `window.OpenVCS` (`window.OpenVCS.callPlugin(...)`) if you breezily interact through that object. If you prefer a named shortcut, register a simple global after loading the plugin: +Minimal module plugin: -```js -window.example = window.example || {}; -window.example.notify = (message) => - window.callPluginMethod('example.notify', 'example.notify.ping', { message }); +```json +{ + "id": "example.plugin", + "name": "Example Plugin", + "version": "0.1.0", + "capabilities": [], + "module": { "exec": "example-plugin.wasm" } +} ``` -After that you can call `example.notify('hi')` in the console, and the helper will forward the call to the module. +Notes: -## Logging from plugin modules (Rust/WASI) +- `module.exec` must end with `.wasm`. +- The plugin runtime only loads component-model modules. +- If `themes/` exists, it is packaged and discovered automatically. -Plugin modules should write logs to **stderr** (never stdout) so they don’t interfere with the JSON message protocol. +## Building bundles -To keep plugin crates lightweight, `openvcs-core` provides logging macros so you don’t need to add a logging crate dependency: +The SDK provides two entrypoints: -```rs -openvcs_core::trace!("trace details"); -openvcs_core::debug!("debug details"); -openvcs_core::info!("hello from a plugin"); -openvcs_core::warn!("something looks off"); -openvcs_core::error!("something failed"); -``` - -If you prefer calling them without a prefix, import the macros you use: - -```rs -use openvcs_core::{debug, info, trace}; +```bash +# From a plugin directory +cargo openvcs dist -info!("hello"); -debug!("details"); -trace!("very verbose"); +# Or explicitly +cargo openvcs dist --plugin-dir /path/to/plugin --out /path/to/dist ``` -## Hooks - -Plugins can register hook handlers via `registerPlugin({ hooks: { ... } })`. - -Supported hook names: - -- `preCommit` / `onCommit` / `postCommit` -- `prePush` / `onPush` / `postPush` -- `preSwitchBranch` / `onSwitchBranch` / `postSwitchBranch` -- `preBranchCreate` / `onBranchCreate` / `postBranchCreate` -- `preBranchDelete` / `onBranchDelete` / `postBranchDelete` - -Pre-hooks can cancel the operation: - -- Call `ctx.cancel("reason")`, or -- Throw an error (the error message becomes the reason) - -Hook `ctx.data` is a plain object describing the operation (e.g. commit summary/description, branch names). For `preCommit`, mutating `ctx.data.summary` / `ctx.data.description` updates what gets sent to the backend. - -## Example Plugin - -See `docs/examples/hello-plugin/` for a minimal plugin with: - -- A menu item -- A titlebar button -- A `preCommit` hook that blocks commits starting with `WIP` - -## Managing Plugins - -Open **Settings → Plugins** to view installed plugins and enable/disable them. +See `Client/docs/plugin architecture.md` for the runtime model and `SDK/README.md` for packager details. From dc4433e697e86ecdf0e611e5712fbd88925ab9ed Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 08:34:25 +0000 Subject: [PATCH 50/96] Update AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 6af4f2c0..97ead9e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ - **ALL code must be documented**, not just public APIs. This includes: - Rust: Use doc comments (`///` for items, `//!` for modules) for all functions, structs, enums, traits, and fields. - TypeScript: Use JSDoc comments (`/** ... */`) for all functions, classes, interfaces, and types. +- When you change behavior, workflows, commands, paths, config, or plugin/runtime expectations, ALWAYS update the relevant documentation in the same change, even if the user does not explicitly ask. - Include usage examples for complex functions. - Keep README files in sync with code changes. - Document configuration options and environment variables. From 05c681722f6fca1199025256d9b55297bc33b3c5 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 08:35:47 +0000 Subject: [PATCH 51/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index c43b9a71..b5115dd7 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit c43b9a71b50243971035529a70ce533c18b099a2 +Subproject commit b5115dd796803115ee06e82bb16cb54c0da7a264 From bdd628d357f80baa222fea821fe96950d684f93a Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 08:48:48 +0000 Subject: [PATCH 52/96] Update plugins.md --- docs/plugins.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 9545bac9..5e11469f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -56,7 +56,13 @@ Notes: ## Building bundles -The SDK provides two entrypoints: +Install the SDK from crates.io: + +```bash +cargo install openvcs-sdk +``` + +Then build plugin bundles with: ```bash # From a plugin directory From db706092052722963fbcf2a7a6cc6a51f0e72096 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 10:43:07 +0000 Subject: [PATCH 53/96] Update staus --- Backend/src/lib.rs | 9 ++ .../src/plugin_runtime/component_instance.rs | 32 +++- Backend/src/plugin_runtime/host_api.rs | 139 ++++++++++++++++-- Frontend/src/scripts/lib/status.ts | 14 ++ Frontend/src/scripts/main.ts | 7 +- docs/plugin architecture.md | 8 +- 6 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 Frontend/src/scripts/lib/status.ts diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 26b730ab..fa3f3a12 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -123,6 +123,15 @@ pub fn run() { tauri::Builder::default() .manage(state::AppState::new_with_config()) .setup(|app| { + crate::plugin_runtime::host_api::set_status_event_emitter({ + let app_handle = app.handle().clone(); + move |message| { + if let Err(error) = app_handle.emit("status:set", message.to_string()) { + log::warn!("status:set emit failed: {}", error); + } + } + }); + let store = crate::plugin_bundles::PluginBundleStore::new_default(); if let Err(err) = store.sync_built_in_plugins() { warn!("plugins: failed to sync built-in bundles: {}", err); diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 1ce4e904..2ab7d478 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -3,8 +3,8 @@ use std::sync::OnceLock; use crate::plugin_runtime::host_api::{ - host_emit_event, host_process_exec_git, host_runtime_info, host_subscribe_event, - host_ui_notify, host_workspace_read_file, host_workspace_write_file, + host_emit_event, host_get_status, host_process_exec_git, host_runtime_info, host_set_status, + host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, }; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; @@ -181,6 +181,19 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) } + /// Sets footer status text through the host status API. + fn set_status( + &mut self, + message: String, + ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { + host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) + } + + /// Reads current footer status text through the host status API. + fn get_status(&mut self) -> Result { + host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_vcs) + } + /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, @@ -301,6 +314,21 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) } + /// Sets footer status text through the host status API. + fn set_status( + &mut self, + message: String, + ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { + host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) + } + + /// Reads current footer status text through the host status API. + fn get_status( + &mut self, + ) -> Result { + host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_plugin) + } + /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index ad72b711..f395f35b 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -4,6 +4,7 @@ use crate::logging::LogTimer; use crate::plugin_runtime::spawn::SpawnConfig; use log::{debug, error, info, trace, warn}; use openvcs_core::app_api::PluginError; +use parking_lot::RwLock; use serde_json::Value; use std::collections::HashSet; use std::ffi::OsString; @@ -11,8 +12,19 @@ use std::fs; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Arc, OnceLock}; const MODULE: &str = "host_api"; +const DEFAULT_STATUS_TEXT: &str = "Ready"; + +/// Callback type used to forward plugin status updates to the UI layer. +type StatusEventEmitter = dyn Fn(&str) + Send + Sync + 'static; + +/// Optional process-wide UI status event emitter set during backend startup. +static STATUS_EVENT_EMITTER: OnceLock> = OnceLock::new(); + +/// Shared process-wide status text used by plugin status setter/getter calls. +static STATUS_TEXT: OnceLock> = OnceLock::new(); // Whitelisted environment variables that are forwarded to child Git processes. const SANITIZED_ENV_KEYS: &[&str] = &[ @@ -61,6 +73,65 @@ fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option< (approved_caps, spawn.allowed_workspace_root.clone()) } +/// Returns the shared status text store singleton. +/// +/// # Returns +/// - Global [`RwLock`] containing the current status text. +fn status_text_store() -> &'static RwLock { + STATUS_TEXT.get_or_init(|| RwLock::new(DEFAULT_STATUS_TEXT.to_string())) +} + +/// Returns whether status-set capability is approved for this plugin. +/// +/// # Parameters +/// - `caps`: Approved capability identifiers. +/// +/// # Returns +/// - `true` when `status.set` is approved. +fn has_status_set_cap(caps: &HashSet) -> bool { + caps.contains("status.set") +} + +/// Returns whether status-get capability is approved for this plugin. +/// +/// `status.set` implies `status.get`. +/// +/// # Parameters +/// - `caps`: Approved capability identifiers. +/// +/// # Returns +/// - `true` when `status.get` or `status.set` is approved. +fn has_status_get_cap(caps: &HashSet) -> bool { + caps.contains("status.get") || has_status_set_cap(caps) +} + +/// Registers a callback used to forward status updates to the frontend. +/// +/// # Parameters +/// - `emitter`: Callback invoked with each status text update. +/// +/// # Returns +/// - `()`. +pub fn set_status_event_emitter(emitter: F) +where + F: Fn(&str) + Send + Sync + 'static, +{ + let _ = STATUS_EVENT_EMITTER.set(Arc::new(emitter)); +} + +/// Emits a status update through the configured frontend event callback. +/// +/// # Parameters +/// - `message`: Status text to emit. +/// +/// # Returns +/// - `()`. +fn emit_status_event(message: &str) { + if let Some(emitter) = STATUS_EVENT_EMITTER.get() { + emitter(message); + } +} + /// Host API result type alias for plugin-facing operations. pub(crate) type HostResult = Result; @@ -279,36 +350,82 @@ pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> Ok(()) } -/// Handles plugin notification requests gated by `ui.notifications` capability. +/// Handles legacy plugin notification requests through status setter semantics. pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { + host_set_status(spawn, message) +} + +/// Sets the client footer status text for a plugin. +/// +/// Requires `status.set` capability approval. +/// +/// # Parameters +/// - `spawn`: Plugin spawn/capability context. +/// - `message`: Status text to set. +/// +/// # Returns +/// - `Ok(())` when status is stored and emitted. +/// - `Err(PluginError)` when capability is denied. +pub fn host_set_status(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); trace!( - "host_ui_notify: plugin={}, message_len={}", + "host_set_status: plugin={}, message_len={}", spawn.plugin_id, message.len() ); - if !caps.contains("ui.notifications") { + if !has_status_set_cap(&caps) { warn!( - "host_ui_notify: capability denied for plugin {} (missing ui.notifications)", + "host_set_status: capability denied for plugin {} (missing status.set)", spawn.plugin_id ); return Err(host_error( "capability.denied", - "missing capability: ui.notifications", + "missing capability: status.set", )); } - let message = message.trim(); - if !message.is_empty() { - info!( - "host_ui_notify: plugin[{}] notify: {}", - spawn.plugin_id, message - ); + { + let mut status = status_text_store().write(); + *status = message.to_string(); } + + info!( + "host_set_status: plugin[{}] status: {}", + spawn.plugin_id, message + ); + emit_status_event(message); Ok(()) } +/// Gets the current client footer status text for a plugin. +/// +/// Requires either `status.get` or `status.set` capability approval. +/// +/// # Parameters +/// - `spawn`: Plugin spawn/capability context. +/// +/// # Returns +/// - `Ok(String)` with current status text. +/// - `Err(PluginError)` when capability is denied. +pub fn host_get_status(spawn: &SpawnConfig) -> HostResult { + let (caps, _) = approved_caps_and_workspace(spawn); + trace!("host_get_status: plugin={}", spawn.plugin_id); + + if !has_status_get_cap(&caps) { + warn!( + "host_get_status: capability denied for plugin {} (missing status.get)", + spawn.plugin_id + ); + return Err(host_error( + "capability.denied", + "missing capability: status.get", + )); + } + + Ok(status_text_store().read().clone()) +} + /// Reads a workspace file when the plugin has workspace read access. pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { let _timer = LogTimer::new(MODULE, "host_workspace_read_file"); diff --git a/Frontend/src/scripts/lib/status.ts b/Frontend/src/scripts/lib/status.ts new file mode 100644 index 00000000..63299a5e --- /dev/null +++ b/Frontend/src/scripts/lib/status.ts @@ -0,0 +1,14 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +import { qs, setText } from './dom'; + +const statusEl = qs('#status'); + +/** + * Updates the footer status text. + * @param text - Status text to display. + */ +export function setStatus(text: string) { + if (!statusEl) return; + setText(statusEl, text); +} diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index 7be8d831..dd639bc9 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -4,6 +4,7 @@ import './lib/logger'; import { TAURI } from './lib/tauri'; import { qs } from './lib/dom'; import { notify } from './lib/notify'; +import { setStatus } from './lib/status'; import { destroyOverlayScrollbarsFor, initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars'; import { prefs, state, hasRepo } from './state/state'; import { @@ -493,9 +494,9 @@ async function boot() { .catch(() => {}); } - // generic notifications from backend - TAURI.listen?.('ui:notify', ({ payload }) => { - try { notify(String((payload as any) ?? '')); } catch {} + // backend status updates (footer) + TAURI.listen?.('status:set', ({ payload }) => { + try { setStatus(String((payload as any) ?? '')); } catch {} }); // update available payload from backend -> open modal with notes diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index ff0a4137..907f7088 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -15,7 +15,7 @@ Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) The authoritative host/plugin contract lives under `Core/wit/`: -- `Core/wit/host.wit`: host imports plugins can call (workspace IO, git process exec, notifications, logging, events) +- `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, git process exec, notifications, logging, events) - `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) - `Core/wit/vcs.wit`: VCS backend world (`vcs`) @@ -72,6 +72,12 @@ The host cares about: Plugins request capabilities through the manifest `capabilities` array. The host enforces capability approval before allowing privileged host API calls. +Status APIs use dedicated capabilities: + +- `status.set`: allows plugins to call `set-status`. +- `status.get`: allows plugins to call `get-status`. +- `status.set` also implies `status.get`. + ## Security model - Plugins run out-of-process in a Wasmtime component runtime. From bda57898c2bb0bdeb75d1e0df50422822e5ff37e Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 12:03:35 +0000 Subject: [PATCH 54/96] Update --- Frontend/src/scripts/features/branches.ts | 3 +- Frontend/src/scripts/features/conflicts.ts | 3 +- .../src/scripts/features/repo/diffView.ts | 7 +-- Frontend/src/scripts/features/repo/history.ts | 5 +- .../src/scripts/features/repo/interactions.ts | 7 +-- Frontend/src/scripts/features/repo/stash.ts | 5 +- Frontend/src/scripts/features/settings.ts | 5 +- Frontend/src/scripts/lib/confirm.ts | 52 +++++++++++++++++++ 8 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 Frontend/src/scripts/lib/confirm.ts diff --git a/Frontend/src/scripts/features/branches.ts b/Frontend/src/scripts/features/branches.ts index a836b942..3a0fe065 100644 --- a/Frontend/src/scripts/features/branches.ts +++ b/Frontend/src/scripts/features/branches.ts @@ -3,6 +3,7 @@ // src/scripts/features/branches.ts import { qs } from '../lib/dom'; import { TAURI } from '../lib/tauri'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { refreshOverlayScrollbarsFor } from '../lib/scrollbars'; import { state } from '../state/state'; @@ -204,7 +205,7 @@ export function bindBranchUI() { }}); items.push({ label: 'Merge into current…', action: async () => { if (name === cur) { notify('Cannot merge a branch into itself'); return; } - const ok = window.confirm(`Merge '${name}' into '${cur}'?`); + const ok = await confirmBool(`Merge '${name}' into '${cur}'?`); if (!ok) return; try { if (TAURI.has) await TAURI.invoke('git_merge_branch', { name }); diff --git a/Frontend/src/scripts/features/conflicts.ts b/Frontend/src/scripts/features/conflicts.ts index 484b0ea7..2316e44f 100644 --- a/Frontend/src/scripts/features/conflicts.ts +++ b/Frontend/src/scripts/features/conflicts.ts @@ -1,6 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later import { TAURI } from '../lib/tauri'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { hydrate, openModal, closeModal } from '../ui/modals'; import { hydrateStatus } from './repo'; @@ -85,7 +86,7 @@ async function ensureSummaryModal() { abortBtn?.addEventListener('click', async () => { if (!TAURI.has) return; - const ok = window.confirm('Abort the merge? This will discard merge progress.'); + const ok = await confirmBool('Abort the merge? This will discard merge progress.'); if (!ok) return; try { await TAURI.invoke('git_merge_abort'); diff --git a/Frontend/src/scripts/features/repo/diffView.ts b/Frontend/src/scripts/features/repo/diffView.ts index ded9cc28..407fb9de 100644 --- a/Frontend/src/scripts/features/repo/diffView.ts +++ b/Frontend/src/scripts/features/repo/diffView.ts @@ -3,6 +3,7 @@ import { qsa, escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { state, prefs, disableDefaultSelectAll, DiffMeta, HunkNodeRefs } from '../../state/state'; import type { FileStatus, ConflictDetails } from '../../types'; @@ -142,7 +143,7 @@ export async function selectFile(file: FileStatus, index: number) { const items: CtxItem[] = []; items.push({ label: 'Discard hunk', action: async () => { if (!TAURI.has) return; - const ok = window.confirm('Discard this hunk? This cannot be undone.'); + const ok = await confirmBool('Discard this hunk? This cannot be undone.'); if (!ok) return; try { const patch = buildPatchForSelectedHunks(file.path, state.currentDiff, [hi]); @@ -156,7 +157,7 @@ export async function selectFile(file: FileStatus, index: number) { if (Array.isArray(selected) && selected.length > 0) { items.push({ label: 'Discard selected hunks (this file)', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard ${selected.length} selected hunk(s) in this file? This cannot be undone.`); + const ok = await confirmBool(`Discard ${selected.length} selected hunk(s) in this file? This cannot be undone.`); if (!ok) return; try { const patch = buildPatchForSelectedHunks(file.path, state.currentDiff, selected); @@ -172,7 +173,7 @@ export async function selectFile(file: FileStatus, index: number) { if (filesWithSel.length > 0) { items.push({ label: 'Discard selected hunks (all files)', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard selected hunks across ${filesWithSel.length} file(s)? This cannot be undone.`); + const ok = await confirmBool(`Discard selected hunks across ${filesWithSel.length} file(s)? This cannot be undone.`); if (!ok) return; try { let patch = ''; diff --git a/Frontend/src/scripts/features/repo/history.ts b/Frontend/src/scripts/features/repo/history.ts index c14c33c7..c0686a89 100644 --- a/Frontend/src/scripts/features/repo/history.ts +++ b/Frontend/src/scripts/features/repo/history.ts @@ -3,6 +3,7 @@ import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { getPluginContextMenuItems, runPluginAction } from '../../plugins'; import { prefs, state, statusClass, statusLabel } from '../../state/state'; @@ -66,7 +67,7 @@ async function openCommitActionsMenu(commit: any, x: number, y: number, opts?: C items.push({ label: 'Revert (reverse) commit…', action: async () => { const short = String(commit.id || '').slice(0, 7); - const ok = window.confirm(`Revert commit ${short}? This will create a new commit that undoes its changes.`); + const ok = await confirmBool(`Revert commit ${short}? This will create a new commit that undoes its changes.`); if (!ok) return; try { await TAURI.invoke('git_revert_commit', { id: commit.id }); @@ -302,7 +303,7 @@ export async function selectHistory(commit: any, index: number) { } const short = String(commit?.id || '').slice(0, 7) || '(unknown)'; - const ok = window.confirm(`Revert changes from commit ${short} for:\n${file?.path || '(unknown file)'}\n\nThis applies a reverse patch to your working tree and index.`); + const ok = await confirmBool(`Revert changes from commit ${short} for:\n${file?.path || '(unknown file)'}\n\nThis applies a reverse patch to your working tree and index.`); if (!ok) return; let patch = block.join('\n'); diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 40953d5a..33a43e15 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -1,6 +1,7 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later import { buildCtxMenu, CtxItem } from '../../lib/menu'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { TAURI } from '../../lib/tauri'; import { getPluginContextMenuItems, runPluginAction } from '../../plugins'; @@ -262,7 +263,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { const targets = (explicitMultiSelection ? selectedPaths.slice() : [f.path]).filter(Boolean); if (!targets.length) return; const label = targets.length > 1 ? `${targets.length} files` : targets[0]; - const ok = window.confirm(`Add ${label} to .gitignore?`); + const ok = await confirmBool(`Add ${label} to .gitignore?`); if (!ok) return; try { await TAURI.invoke('git_add_to_gitignore_paths', { paths: targets }); @@ -278,7 +279,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { items.push({ label: 'Discard all selected', action: async () => { if (!TAURI.has) return; const paths = selectedPaths.slice(); - const ok = window.confirm(`Discard all changes in ${paths.length} selected file(s)? This cannot be undone.`); + const ok = await confirmBool(`Discard all changes in ${paths.length} selected file(s)? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths }); await Promise.allSettled([hydrateStatus()]); } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } @@ -286,7 +287,7 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { } items.push({ label: 'Discard changes', action: async () => { if (!TAURI.has) return; - const ok = window.confirm(`Discard all changes in \n${f.path}? This cannot be undone.`); + const ok = await confirmBool(`Discard all changes in \n${f.path}? This cannot be undone.`); if (!ok) return; try { await TAURI.invoke('git_discard_paths', { paths: [f.path] }); await Promise.allSettled([hydrateStatus()]); } catch (e) { console.error('Discard failed:', e); notify('Discard failed'); } diff --git a/Frontend/src/scripts/features/repo/stash.ts b/Frontend/src/scripts/features/repo/stash.ts index c04c65cb..acc3750f 100644 --- a/Frontend/src/scripts/features/repo/stash.ts +++ b/Frontend/src/scripts/features/repo/stash.ts @@ -3,6 +3,7 @@ import { escapeHtml } from '../../lib/dom'; import { buildCtxMenu, CtxItem } from '../../lib/menu'; import { TAURI } from '../../lib/tauri'; +import { confirmBool } from '../../lib/confirm'; import { notify } from '../../lib/notify'; import { state } from '../../state/state'; import { openStashConfirm } from '../stashConfirm'; @@ -84,7 +85,7 @@ export function renderStashList(query: string): boolean { } catch (e) { console.error('Failed to apply stash:', e); notify('Failed to apply stash'); } }}); items.push({ label: 'Delete stash', action: async () => { - const ok = window.confirm(`Delete ${target}? This cannot be undone.`); + const ok = await confirmBool(`Delete ${target}? This cannot be undone.`); if (!ok) return; try { if (!TAURI.has) return; @@ -217,7 +218,7 @@ function wireStashFooterButtons(container: HTMLElement) { dropBtn?.addEventListener('click', async () => { const selector = getActiveStashSelector(); if (!selector) return; - const ok = window.confirm(`Drop ${selector}? This cannot be undone.`); + const ok = await confirmBool(`Drop ${selector}? This cannot be undone.`); if (!ok) return; try { if (!TAURI.has) return; diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index c91c4394..ded9da4c 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -3,6 +3,7 @@ import { TAURI } from '../lib/tauri'; import { openModal, closeModal } from '../ui/modals'; import { toKebab } from '../lib/dom'; +import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { setTheme } from '../ui/layout'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, DEFAULT_THEME_ID, getActiveThemeId, getAvailableThemes, refreshAvailableThemes, selectThemePack } from '../themes'; @@ -762,7 +763,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const caps = Array.isArray(installed?.requested_capabilities) ? installed.requested_capabilities : []; if (caps.length) { - const ok = window.confirm( + const ok = await confirmBool( `Plugin requests capabilities:\n\n- ${caps.join('\n- ')}\n\nApprove and allow it to run?` ); await TAURI.invoke('approve_plugin_capabilities', { @@ -1387,7 +1388,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const plugin = state.list.find((p) => String(p?.id || '').trim() === id) || null; if (!plugin) return; const label = String(plugin.name || plugin.id || 'plugin'); - if (!window.confirm(`Remove ${label}? This will delete the plugin bundle.`)) return; + if (!(await confirmBool(`Remove ${label}? This will delete the plugin bundle.`))) return; if (!TAURI.has) { notify('Plugin removal is only available in the desktop app.'); return; diff --git a/Frontend/src/scripts/lib/confirm.ts b/Frontend/src/scripts/lib/confirm.ts new file mode 100644 index 00000000..9d7febed --- /dev/null +++ b/Frontend/src/scripts/lib/confirm.ts @@ -0,0 +1,52 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +/** + * Attempts to coerce arbitrary confirm-return payloads into a boolean. + * + * @param value - Value returned by a confirm implementation. + * @returns A normalized confirmation decision. + */ +function coerceConfirmResult(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) return false; + if (normalized === 'true' || normalized === 'yes' || normalized === 'ok') return true; + if (normalized === 'false' || normalized === 'no' || normalized === 'cancel') return false; + } + if (value && typeof value === 'object') { + const record = value as Record; + const candidates = ['approved', 'ok', 'value', 'confirmed', 'result']; + for (const key of candidates) { + if (key in record) return coerceConfirmResult(record[key]); + } + } + return false; +} + +/** + * Shows a confirmation prompt and returns a normalized boolean result. + * + * Supports both synchronous browser `window.confirm` and Promise-returning + * confirm implementations exposed by embedded runtimes. + * + * @param message - Prompt text shown to the user. + * @returns `true` when the user confirms; otherwise `false`. + */ +export async function confirmBool(message: string): Promise { + const confirmFn = (window as any).confirm; + if (typeof confirmFn !== 'function') return false; + + try { + const maybe = confirmFn(message) as unknown; + if (maybe && typeof (maybe as PromiseLike).then === 'function') { + const resolved = await (maybe as PromiseLike); + return coerceConfirmResult(resolved); + } + return coerceConfirmResult(maybe); + } catch { + return false; + } +} From b7b283261f652af67fe06571ae28e494253da974 Mon Sep 17 00:00:00 2001 From: Jordon Date: Thu, 19 Feb 2026 12:14:11 +0000 Subject: [PATCH 55/96] Update plugins.rs --- Backend/src/tauri_commands/plugins.rs | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 6f59790a..d2c6d684 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -100,10 +100,11 @@ pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result } #[tauri::command] -/// Enables or disables a plugin without triggering a full runtime sync. +/// Enables or disables a plugin and persists the override. /// -/// This is more efficient than set_global_settings when only toggling -/// a single plugin's enabled state. +/// This updates runtime state immediately and writes the corresponding +/// `plugins.enabled`/`plugins.disabled` override in global settings so the +/// toggle remains stable across settings reloads and app restarts. /// /// # Parameters /// - `state`: Application state. @@ -141,7 +142,32 @@ pub fn set_plugin_enabled( plugin_id, e ); e - }) + })?; + + let mut cfg = state.config(); + let plugin_key = plugin_id.trim().to_ascii_lowercase(); + cfg.plugins + .enabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + cfg.plugins + .disabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + + if enabled { + cfg.plugins.enabled.push(plugin_key.clone()); + } else { + cfg.plugins.disabled.push(plugin_key.clone()); + } + + state.set_config(cfg).map_err(|e| { + error!( + "set_plugin_enabled: failed to persist plugin override for {}: {}", + plugin_id, e + ); + e + })?; + + Ok(()) } #[tauri::command] From 634c0fe827a97e7496acfeca3a6fb745bb03ecdb Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 11:40:43 +0000 Subject: [PATCH 56/96] Update --- Backend/src/lib.rs | 4 + .../src/plugin_runtime/component_instance.rs | 540 +++++++++++++++++- Backend/src/plugin_runtime/instance.rs | 38 ++ Backend/src/plugin_runtime/mod.rs | 2 + Backend/src/plugin_runtime/settings_store.rs | 87 +++ Backend/src/tauri_commands/plugins.rs | 263 +++++++++ Backend/tauri.conf.json | 2 +- Frontend/src/scripts/features/settings.ts | 101 +++- docs/plugin architecture.md | 13 +- docs/plugins.md | 15 + 10 files changed, 1057 insertions(+), 8 deletions(-) create mode 100644 Backend/src/plugin_runtime/settings_store.rs diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index fa3f3a12..4cf35b04 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -285,6 +285,10 @@ fn build_invoke_handler( tauri_commands::list_plugin_functions, tauri_commands::invoke_plugin_function, tauri_commands::call_plugin_module_method, + tauri_commands::list_plugin_menus, + tauri_commands::invoke_plugin_action, + tauri_commands::save_plugin_settings, + tauri_commands::reset_plugin_settings, tauri_commands::get_global_settings, tauri_commands::set_global_settings, tauri_commands::get_repo_settings, diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 2ab7d478..4a9f2cf9 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -7,7 +7,10 @@ use crate::plugin_runtime::host_api::{ host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, }; use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::settings_store; use crate::plugin_runtime::spawn::SpawnConfig; +use openvcs_core::settings::{SettingKv, SettingValue}; +use openvcs_core::ui::{Menu, UiButton, UiElement, UiText}; use parking_lot::Mutex; use serde::de::DeserializeOwned; use serde::Serialize; @@ -44,6 +47,15 @@ mod bindings_plugin { }); } +mod bindings_plugin_v1_1 { + wasmtime::component::bindgen!({ + path: "../../Core/wit", + world: "plugin-v1-1", + additional_derives: [serde::Serialize, serde::Deserialize], + }); +} + +use bindings_plugin_v1_1::exports::openvcs::plugin::plugin_api_v1_1; use bindings_vcs::exports::openvcs::plugin::vcs_api; /// Typed bindings handle selected for the running plugin world. @@ -52,6 +64,8 @@ enum ComponentBindings { Vcs(bindings_vcs::Vcs), /// Bindings for plugins exporting the base `plugin` world. Plugin(bindings_plugin::Plugin), + /// Bindings for plugins exporting the `plugin-v1-1` world. + PluginV11(bindings_plugin_v1_1::PluginV11), } /// Live component instance plus generated bindings handle. @@ -76,6 +90,11 @@ impl ComponentRuntime { .call_init(&mut self.store) .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), + ComponentBindings::PluginV11(bindings) => bindings + .openvcs_plugin_plugin_api_v1_1() + .call_init(&mut self.store) + .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? + .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), } } @@ -92,8 +111,167 @@ impl ComponentRuntime { .openvcs_plugin_plugin_api() .call_deinit(&mut self.store); } + ComponentBindings::PluginV11(bindings) => { + let _ = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_deinit(&mut self.store); + } } } + + /// Returns plugin-contributed menus for v1.1 plugins. + fn call_get_menus(&mut self, plugin_id: &str) -> Result, String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(Vec::new()), + }; + let menus = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_get_menus(&mut self.store) + .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component get-menus failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(menus.into_iter().map(map_menu_from_wit).collect()) + } + + /// Invokes a plugin action for v1.1 plugins. + fn call_handle_action(&mut self, plugin_id: &str, id: &str) -> Result<(), String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(()), + }; + bindings + .openvcs_plugin_plugin_api_v1_1() + .call_handle_action(&mut self.store, id) + .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component handle-action failed for {}: {}", + plugin_id, e.message + ) + }) + } + + /// Returns plugin settings defaults for v1.1 plugins. + fn call_settings_defaults(&mut self, plugin_id: &str) -> Result, String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(Vec::new()), + }; + let values = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_defaults(&mut self.store) + .map_err(|e| format!("component settings-defaults trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-defaults failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(values.into_iter().map(map_setting_from_wit).collect()) + } + + /// Calls plugin settings-on-load hook for v1.1 plugins. + fn call_settings_on_load( + &mut self, + plugin_id: &str, + values: Vec, + ) -> Result, String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(values), + }; + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_load(&mut self.store, &values) + .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-load failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_wit).collect()) + } + + /// Calls plugin settings-on-apply hook for v1.1 plugins. + fn call_settings_on_apply( + &mut self, + plugin_id: &str, + values: Vec, + ) -> Result<(), String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(()), + }; + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_apply(&mut self.store, &values) + .map_err(|e| format!("component settings-on-apply trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-apply failed for {}: {}", + plugin_id, e.message + ) + }) + } + + /// Calls plugin settings-on-save hook for v1.1 plugins. + fn call_settings_on_save( + &mut self, + plugin_id: &str, + values: Vec, + ) -> Result, String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(values), + }; + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_save(&mut self.store, &values) + .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-save failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_wit).collect()) + } + + /// Calls plugin settings-on-reset hook for v1.1 plugins. + fn call_settings_on_reset(&mut self, plugin_id: &str) -> Result<(), String> { + let bindings = match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings, + _ => return Ok(()), + }; + bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_reset(&mut self.store) + .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-reset failed for {}: {}", + plugin_id, e.message + ) + }) + } } /// Host state stored inside the Wasmtime store for host imports. @@ -409,6 +587,182 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { } } +impl bindings_plugin_v1_1::openvcs::plugin::host_api::Host for ComponentHostState { + /// Returns runtime metadata for the current host process. + fn get_runtime_info( + &mut self, + ) -> Result< + bindings_plugin_v1_1::openvcs::plugin::host_api::RuntimeInfo, + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError, + > { + let value = host_runtime_info(); + Ok( + bindings_plugin_v1_1::openvcs::plugin::host_api::RuntimeInfo { + os: value.os, + arch: value.arch, + container: value.container, + }, + ) + } + + /// Registers an event subscription for this plugin. + fn subscribe_event( + &mut self, + event_name: String, + ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_subscribe_event(&self.spawn, &event_name).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Emits a plugin-originated event through the host event bus. + fn emit_event( + &mut self, + event_name: String, + payload: Vec, + ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_emit_event(&self.spawn, &event_name, &payload).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Forwards plugin notifications to host-side UI notification handling. + fn ui_notify( + &mut self, + message: String, + ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_ui_notify(&self.spawn, &message).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Sets footer status text through the host status API. + fn set_status( + &mut self, + message: String, + ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_set_status(&self.spawn, &message).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Reads current footer status text through the host status API. + fn get_status( + &mut self, + ) -> Result { + host_get_status(&self.spawn).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Reads a workspace file under capability and path constraints. + fn workspace_read_file( + &mut self, + path: String, + ) -> Result, bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_workspace_read_file(&self.spawn, &path).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Writes a workspace file under capability and path constraints. + fn workspace_write_file( + &mut self, + path: String, + content: Vec, + ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { + host_workspace_write_file(&self.spawn, &path, &content).map_err(|err| { + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + } + }) + } + + /// Executes `git` in a constrained host environment. + fn process_exec_git( + &mut self, + cwd: Option, + args: Vec, + env: Vec, + stdin: Option, + ) -> Result< + bindings_plugin_v1_1::openvcs::plugin::host_api::ProcessExecOutput, + bindings_plugin_v1_1::openvcs::plugin::host_api::HostError, + > { + let env = env + .into_iter() + .map(|var| (var.key, var.value)) + .collect::>(); + let value = + host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) + .map_err( + |err| bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + }, + )?; + Ok( + bindings_plugin_v1_1::openvcs::plugin::host_api::ProcessExecOutput { + success: value.success, + status: value.status, + stdout: value.stdout, + stderr: value.stderr, + }, + ) + } + + /// Logs plugin-emitted messages through the host logger. + fn host_log( + &mut self, + level: bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel, + target: String, + message: String, + ) { + let target = if target.trim().is_empty() { + format!("plugin.{}", self.spawn.plugin_id) + } else { + format!("plugin.{}.{}", self.spawn.plugin_id, target) + }; + + match level { + bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Trace => { + log::trace!(target: &target, "{message}") + } + bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Debug => { + log::debug!(target: &target, "{message}") + } + bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Info => { + log::info!(target: &target, "{message}") + } + bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Warn => { + log::warn!(target: &target, "{message}") + } + bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Error => { + log::error!(target: &target, "{message}") + } + }; + } +} + /// Component-model runtime instance. pub struct ComponentPluginRuntimeInstance { /// Spawn configuration used to instantiate and identify the component. @@ -454,23 +808,66 @@ impl ComponentPluginRuntimeInstance { .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, ) } else { - bindings_plugin::Plugin::add_to_linker::< + bindings_plugin_v1_1::PluginV11::add_to_linker::< ComponentHostState, wasmtime::component::HasSelf, >(&mut linker, |state| state) .map_err(|e| format!("link host imports: {e}"))?; - ComponentBindings::Plugin( - bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) - .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, - ) + match bindings_plugin_v1_1::PluginV11::instantiate(&mut store, &component, &linker) { + Ok(v11) => ComponentBindings::PluginV11(v11), + Err(_) => { + let mut fallback_linker = Linker::new(engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut fallback_linker) + .map_err(|e| format!("link wasi imports: {e}"))?; + bindings_plugin::Plugin::add_to_linker::< + ComponentHostState, + wasmtime::component::HasSelf, + >(&mut fallback_linker, |state| state) + .map_err(|e| format!("link host imports: {e}"))?; + ComponentBindings::Plugin( + bindings_plugin::Plugin::instantiate( + &mut store, + &component, + &fallback_linker, + ) + .map_err(|e| { + format!("instantiate component {}: {e}", self.spawn.plugin_id) + })?, + ) + } + } }; let mut runtime = ComponentRuntime { store, bindings }; runtime.call_init(&self.spawn.plugin_id)?; + self.apply_persisted_settings(&mut runtime)?; Ok(runtime) } + /// Loads and applies persisted plugin settings for v1.1 plugins. + fn apply_persisted_settings(&self, runtime: &mut ComponentRuntime) -> Result<(), String> { + if !matches!(runtime.bindings, ComponentBindings::PluginV11(_)) { + return Ok(()); + } + + let defaults = runtime.call_settings_defaults(&self.spawn.plugin_id)?; + let mut values = defaults.clone(); + let loaded = settings_store::load_settings(&self.spawn.plugin_id)?; + + for entry in &mut values { + if let Some(raw) = loaded.get(&entry.id) { + if let Some(mapped) = setting_from_json_value(raw, &entry.value) { + entry.value = mapped; + } + } + } + + let values = runtime.call_settings_on_load(&self.spawn.plugin_id, values)?; + runtime.call_settings_on_apply(&self.spawn.plugin_id, values.clone())?; + settings_store::save_settings(&self.spawn.plugin_id, &settings_to_json_map(&values)) + } + /// Ensures a runtime exists and executes a closure with mutable access. fn with_runtime( &self, @@ -488,6 +885,98 @@ impl ComponentPluginRuntimeInstance { } } +/// Converts a v1.1 WIT menu into the shared core menu model. +fn map_menu_from_wit(menu: plugin_api_v1_1::Menu) -> Menu { + let elements = menu + .elements + .into_iter() + .map(|element| match element { + plugin_api_v1_1::UiElement::Text(text) => UiElement::Text(UiText { + id: text.id, + content: text.content, + }), + plugin_api_v1_1::UiElement::Button(button) => UiElement::Button(UiButton { + id: button.id, + label: button.label, + }), + }) + .collect::>(); + Menu { + id: menu.id, + label: menu.label, + elements, + } +} + +/// Converts a v1.1 WIT setting entry into the shared core setting model. +fn map_setting_from_wit(setting: plugin_api_v1_1::SettingKv) -> SettingKv { + SettingKv { + id: setting.id, + value: match setting.value { + plugin_api_v1_1::SettingValue::Boolean(v) => SettingValue::Bool(v), + plugin_api_v1_1::SettingValue::Signed32(v) => SettingValue::S32(v), + plugin_api_v1_1::SettingValue::Unsigned32(v) => SettingValue::U32(v), + plugin_api_v1_1::SettingValue::Float64(v) => SettingValue::F64(v), + plugin_api_v1_1::SettingValue::Text(v) => SettingValue::String(v), + }, + } +} + +/// Converts a shared core setting entry into a v1.1 WIT setting model. +fn map_setting_to_wit(setting: SettingKv) -> plugin_api_v1_1::SettingKv { + let value = match setting.value { + SettingValue::Bool(v) => plugin_api_v1_1::SettingValue::Boolean(v), + SettingValue::S32(v) => plugin_api_v1_1::SettingValue::Signed32(v), + SettingValue::U32(v) => plugin_api_v1_1::SettingValue::Unsigned32(v), + SettingValue::F64(v) => plugin_api_v1_1::SettingValue::Float64(v), + SettingValue::String(v) => plugin_api_v1_1::SettingValue::Text(v), + }; + plugin_api_v1_1::SettingKv { + id: setting.id, + value, + } +} + +/// Converts settings entries to a JSON map for persistence. +fn settings_to_json_map(values: &[SettingKv]) -> serde_json::Map { + let mut out = serde_json::Map::new(); + for entry in values { + out.insert(entry.id.clone(), setting_to_json_value(&entry.value)); + } + out +} + +/// Converts one typed setting value into JSON. +fn setting_to_json_value(value: &SettingValue) -> serde_json::Value { + match value { + SettingValue::Bool(v) => serde_json::Value::Bool(*v), + SettingValue::S32(v) => serde_json::Value::from(*v), + SettingValue::U32(v) => serde_json::Value::from(*v), + SettingValue::F64(v) => serde_json::Value::from(*v), + SettingValue::String(v) => serde_json::Value::String(v.clone()), + } +} + +/// Converts persisted JSON to a typed value using an existing setting type. +fn setting_from_json_value( + value: &serde_json::Value, + current: &SettingValue, +) -> Option { + match current { + SettingValue::Bool(_) => value.as_bool().map(SettingValue::Bool), + SettingValue::S32(_) => value + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .map(SettingValue::S32), + SettingValue::U32(_) => value + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(SettingValue::U32), + SettingValue::F64(_) => value.as_f64().map(SettingValue::F64), + SettingValue::String(_) => value.as_str().map(|v| SettingValue::String(v.to_string())), + } +} + /// Deserializes JSON RPC parameters for a named method. fn parse_method_params(method: &str, params: Value) -> Result { serde_json::from_value(params).map_err(|e| format!("invalid params for `{method}`: {e}")) @@ -534,6 +1023,12 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { self.spawn.plugin_id )); } + ComponentBindings::PluginV11(_) => { + return Err(format!( + "component method `{method}` requires VCS backend exports for plugin `{}`", + self.spawn.plugin_id + )); + } }; macro_rules! invoke { @@ -1041,6 +1536,41 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { }) } + /// Returns plugin-contributed UI menus. + fn get_menus(&self) -> Result, String> { + self.with_runtime(|runtime| runtime.call_get_menus(&self.spawn.plugin_id)) + } + + /// Invokes a plugin action by id. + fn handle_action(&self, id: &str) -> Result<(), String> { + self.with_runtime(|runtime| runtime.call_handle_action(&self.spawn.plugin_id, id)) + } + + /// Returns plugin settings defaults. + fn settings_defaults(&self) -> Result, String> { + self.with_runtime(|runtime| runtime.call_settings_defaults(&self.spawn.plugin_id)) + } + + /// Calls plugin settings-on-load hook. + fn settings_on_load(&self, values: Vec) -> Result, String> { + self.with_runtime(|runtime| runtime.call_settings_on_load(&self.spawn.plugin_id, values)) + } + + /// Calls plugin settings-on-apply hook. + fn settings_on_apply(&self, values: Vec) -> Result<(), String> { + self.with_runtime(|runtime| runtime.call_settings_on_apply(&self.spawn.plugin_id, values)) + } + + /// Calls plugin settings-on-save hook. + fn settings_on_save(&self, values: Vec) -> Result, String> { + self.with_runtime(|runtime| runtime.call_settings_on_save(&self.spawn.plugin_id, values)) + } + + /// Calls plugin settings-on-reset hook. + fn settings_on_reset(&self) -> Result<(), String> { + self.with_runtime(|runtime| runtime.call_settings_on_reset(&self.spawn.plugin_id)) + } + /// Deinitializes and drops the running component runtime. fn stop(&self) { let mut lock = self.runtime.lock(); diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index 01c329e8..4409e83b 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -1,6 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later use openvcs_core::models::VcsEvent; +use openvcs_core::settings::SettingKv; +use openvcs_core::ui::Menu; use serde_json::Value; use std::sync::Arc; @@ -12,6 +14,42 @@ pub trait PluginRuntimeInstance: Send + Sync { /// Calls a plugin method and returns JSON payload. fn call(&self, method: &str, params: Value) -> Result; + /// Returns plugin-contributed UI menus. + fn get_menus(&self) -> Result, String> { + Ok(Vec::new()) + } + + /// Invokes a plugin action by id. + fn handle_action(&self, _id: &str) -> Result<(), String> { + Ok(()) + } + + /// Returns plugin settings defaults. + fn settings_defaults(&self) -> Result, String> { + Ok(Vec::new()) + } + + /// Applies plugin settings values after host load. + #[allow(dead_code)] + fn settings_on_load(&self, values: Vec) -> Result, String> { + Ok(values) + } + + /// Applies effective plugin settings values at runtime. + fn settings_on_apply(&self, _values: Vec) -> Result<(), String> { + Ok(()) + } + + /// Validates and normalizes plugin settings before host save. + fn settings_on_save(&self, values: Vec) -> Result, String> { + Ok(values) + } + + /// Handles plugin settings reset callbacks. + fn settings_on_reset(&self) -> Result<(), String> { + Ok(()) + } + /// Installs an optional event sink for runtime-emitted events. fn set_event_sink(&self, _sink: Option>) {} diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index a3223abe..76b6582a 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -17,6 +17,8 @@ pub mod instance; pub mod manager; /// Runtime transport selection and factory helpers. pub mod runtime_select; +/// Plugin settings persistence helpers. +pub mod settings_store; /// Runtime spawn configuration types. pub mod spawn; /// `Vcs` trait adapter backed by plugin runtime RPC. diff --git a/Backend/src/plugin_runtime/settings_store.rs b/Backend/src/plugin_runtime/settings_store.rs new file mode 100644 index 00000000..deebc455 --- /dev/null +++ b/Backend/src/plugin_runtime/settings_store.rs @@ -0,0 +1,87 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Filesystem persistence for plugin settings JSON. + +use directories::ProjectDirs; +use serde_json::{Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Returns the root plugin data directory under the app config directory. +fn plugin_data_root() -> PathBuf { + if let Some(pd) = ProjectDirs::from("dev", "OpenVCS", "OpenVCS") { + pd.config_dir().join("plugin-data") + } else { + PathBuf::from("plugin-data") + } +} + +/// Builds the plugin settings JSON file path for a plugin id. +fn settings_file(plugin_id: &str) -> PathBuf { + plugin_data_root() + .join(plugin_id.trim().to_ascii_lowercase()) + .join("settings.json") +} + +/// Loads plugin settings from disk. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// +/// # Returns +/// - Loaded JSON object map. +/// - Empty map when no settings file exists. +pub fn load_settings(plugin_id: &str) -> Result, String> { + let path = settings_file(plugin_id); + if !path.is_file() { + return Ok(Map::new()); + } + let text = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + let value: Value = + serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?; + Ok(value.as_object().cloned().unwrap_or_default()) +} + +/// Saves plugin settings to disk using an atomic rename. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// - `settings`: Settings object map to persist. +/// +/// # Returns +/// - `Ok(())` when saved. +/// - `Err(String)` on IO or serialization failure. +pub fn save_settings(plugin_id: &str, settings: &Map) -> Result<(), String> { + let path = settings_file(plugin_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + let data = serde_json::to_string_pretty(settings) + .map_err(|e| format!("serialize settings for {plugin_id}: {e}"))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, data).map_err(|e| format!("write {}: {e}", tmp.display()))?; + fs::rename(&tmp, &path) + .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display())) +} + +/// Removes persisted settings for a plugin. +/// +/// # Parameters +/// - `plugin_id`: Plugin id used to resolve settings path. +/// +/// # Returns +/// - `Ok(())` when removed or not present. +/// - `Err(String)` on IO failure. +pub fn reset_settings(plugin_id: &str) -> Result<(), String> { + let path = settings_file(plugin_id); + remove_file_if_exists(&path) +} + +/// Removes a file if it exists. +fn remove_file_if_exists(path: &Path) -> Result<(), String> { + if path.is_file() { + fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display()))?; + } + Ok(()) +} diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index d2c6d684..d58eb603 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,14 +1,39 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; +use crate::plugin_runtime::settings_store; use crate::plugins; use crate::state::AppState; use log::{debug, error, info, trace, warn}; +use openvcs_core::settings::{SettingKv, SettingValue}; +use openvcs_core::ui::{Menu, UiElement}; use serde_json::Value; use tauri::Emitter; use tauri::Manager; use tauri::{Runtime, State, Window}; +/// JSON-friendly plugin setting entry payload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PluginSettingEntry { + /// Stable setting id. + pub id: String, + /// JSON value persisted by the host. + pub value: Value, +} + +/// JSON-friendly plugin menu payload returned to frontend. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginMenuPayload { + /// Owning plugin id. + pub plugin_id: String, + /// Menu id. + pub id: String, + /// User-visible label. + pub label: String, + /// Renderable menu elements. + pub elements: Vec, +} + #[tauri::command] /// Lists plugin summaries discovered by the backend. /// @@ -288,3 +313,241 @@ pub fn call_plugin_module_method( .plugin_runtime() .call_module_method_with_config(&cfg, plugin_id.trim(), method, params) } + +/// Returns plugin-contributed menus for enabled plugins. +/// +/// # Parameters +/// - `state`: Application state. +/// +/// # Returns +/// - `Ok(Vec)` sorted by plugin/menu label. +/// - `Err(String)` when runtime access fails. +#[tauri::command] +pub fn list_plugin_menus(state: State<'_, AppState>) -> Result, String> { + let cfg = state.config(); + let mut out: Vec = Vec::new(); + + for summary in plugins::list_plugins() { + let plugin_id = summary.id.trim().to_string(); + if plugin_id.is_empty() { + continue; + } + if !cfg.is_plugin_enabled(&plugin_id, summary.default_enabled) { + continue; + } + + let runtime = match state + .plugin_runtime() + .runtime_for_workspace_with_config(&cfg, &plugin_id, None) + { + Ok(runtime) => runtime, + Err(err) => { + warn!("list_plugin_menus: skip plugin {}: {}", plugin_id, err); + continue; + } + }; + + let menus = match runtime.get_menus() { + Ok(menus) => menus, + Err(err) => { + warn!( + "list_plugin_menus: plugin {} menu error: {}", + plugin_id, err + ); + continue; + } + }; + + for menu in menus { + out.push(menu_to_payload(&plugin_id, menu)); + } + } + + out.sort_by(|a, b| { + a.label + .to_ascii_lowercase() + .cmp(&b.label.to_ascii_lowercase()) + .then_with(|| a.plugin_id.cmp(&b.plugin_id)) + }); + + Ok(out) +} + +/// Invokes a plugin-provided action by id. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// - `action_id`: Action id from `get-menus`. +/// +/// # Returns +/// - `Ok(())` when action succeeds. +/// - `Err(String)` when runtime/action invocation fails. +#[tauri::command] +pub fn invoke_plugin_action( + state: State<'_, AppState>, + plugin_id: String, + action_id: String, +) -> Result<(), String> { + let cfg = state.config(); + let runtime = + state + .plugin_runtime() + .runtime_for_workspace_with_config(&cfg, plugin_id.trim(), None)?; + runtime.handle_action(action_id.trim()) +} + +/// Saves plugin settings and applies them immediately. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// - `values`: Setting entries from frontend. +/// +/// # Returns +/// - `Ok(())` when save + apply succeeds. +/// - `Err(String)` when validation or runtime calls fail. +#[tauri::command] +pub fn save_plugin_settings( + state: State<'_, AppState>, + plugin_id: String, + values: Vec, +) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let cfg = state.config(); + let runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(&cfg, &plugin_id, None)?; + let defaults = runtime.settings_defaults()?; + let incoming = merge_settings_with_defaults(defaults, values)?; + let normalized = runtime.settings_on_save(incoming)?; + settings_store::save_settings(&plugin_id, &settings_to_json_map(&normalized))?; + runtime.settings_on_apply(normalized) +} + +/// Resets plugin settings to defaults and applies them immediately. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// +/// # Returns +/// - `Ok(())` when reset + apply succeeds. +/// - `Err(String)` when runtime calls fail. +#[tauri::command] +pub fn reset_plugin_settings(state: State<'_, AppState>, plugin_id: String) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let cfg = state.config(); + let runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(&cfg, &plugin_id, None)?; + runtime.settings_on_reset()?; + settings_store::reset_settings(&plugin_id)?; + let defaults = runtime.settings_defaults()?; + runtime.settings_on_apply(defaults) +} + +/// Converts a plugin menu model to frontend payload. +fn menu_to_payload(plugin_id: &str, menu: Menu) -> PluginMenuPayload { + let elements = menu + .elements + .into_iter() + .map(|element| match element { + UiElement::Text(text) => serde_json::json!({ + "type": "text", + "id": text.id, + "content": text.content, + }), + UiElement::Button(button) => serde_json::json!({ + "type": "button", + "id": button.id, + "label": button.label, + }), + }) + .collect::>(); + + PluginMenuPayload { + plugin_id: plugin_id.to_string(), + id: menu.id, + label: menu.label, + elements, + } +} + +/// Merges incoming frontend values into typed defaults. +fn merge_settings_with_defaults( + mut defaults: Vec, + incoming: Vec, +) -> Result, String> { + let mut map: std::collections::HashMap = std::collections::HashMap::new(); + for item in incoming { + map.insert(item.id.trim().to_string(), item.value); + } + + for entry in &mut defaults { + if let Some(value) = map.get(&entry.id) { + entry.value = setting_from_json(&entry.id, value, &entry.value)?; + } + } + Ok(defaults) +} + +/// Converts JSON value into expected typed setting variant. +fn setting_from_json( + id: &str, + value: &Value, + expected: &SettingValue, +) -> Result { + match expected { + SettingValue::Bool(_) => value + .as_bool() + .map(SettingValue::Bool) + .ok_or_else(|| format!("setting `{id}` expects bool")), + SettingValue::S32(_) => value + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + .map(SettingValue::S32) + .ok_or_else(|| format!("setting `{id}` expects s32")), + SettingValue::U32(_) => value + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(SettingValue::U32) + .ok_or_else(|| format!("setting `{id}` expects u32")), + SettingValue::F64(_) => value + .as_f64() + .map(SettingValue::F64) + .ok_or_else(|| format!("setting `{id}` expects f64")), + SettingValue::String(_) => value + .as_str() + .map(|v| SettingValue::String(v.to_string())) + .ok_or_else(|| format!("setting `{id}` expects string")), + } +} + +/// Converts typed settings to JSON object map. +fn settings_to_json_map(values: &[SettingKv]) -> serde_json::Map { + let mut out = serde_json::Map::new(); + for entry in values { + out.insert(entry.id.clone(), setting_value_to_json(&entry.value)); + } + out +} + +/// Converts one typed setting variant to JSON. +fn setting_value_to_json(value: &SettingValue) -> Value { + match value { + SettingValue::Bool(v) => Value::Bool(*v), + SettingValue::S32(v) => Value::from(*v), + SettingValue::U32(v) => Value::from(*v), + SettingValue::F64(v) => Value::from(*v), + SettingValue::String(v) => Value::String(v.clone()), + } +} diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index 1b5e2e45..5a2cd715 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -4,7 +4,7 @@ "identifier": "dev.jordon.openvcs", "build": { "beforeDevCommand": "node ../Backend/scripts/run-tauri-before-command.js dev || node Backend/scripts/run-tauri-before-command.js dev || node scripts/run-tauri-before-command.js dev", - "beforeBuildCommand": "node Backend/scripts/run-tauri-before-command.js build || node ../Backend/scripts/run-tauri-before-command.js build || node scripts/run-tauri-before-command.js build", + "beforeBuildCommand": "node ../Backend/scripts/run-tauri-before-command.js build || node Backend/scripts/run-tauri-before-command.js build || node scripts/run-tauri-before-command.js build", "devUrl": "http://localhost:1420", "frontendDist": "../Frontend/dist" }, diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index ded9da4c..f6c800e7 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -15,6 +15,22 @@ import type { GlobalSettings, ThemeSummary } from '../types'; const THEME_PACK_HINT = 'Install a theme ZIP into the themes folder, or install a plugin that provides themes.'; const SYSTEM_DARK_MQ = matchMedia('(prefers-color-scheme: dark)'); +interface PluginMenuPayload { + plugin_id: string; + id: string; + label: string; + elements: Array<{ + type: 'text' | 'button' | string; + id?: string; + content?: string; + label?: string; + }>; +} + +function pluginSectionId(pluginId: string, menuId: string): string { + return `plugin-${toKebab(`${pluginId}-${menuId}`)}`; +} + export function applyAnimationPreference(enabled: boolean | undefined | null) { document.documentElement.dataset.animations = enabled === false ? 'off' : 'on'; } @@ -49,6 +65,71 @@ function themeTooltip(id: string): string { return details.join('\n') || THEME_PACK_HINT; } +async function renderPluginMenus(modal: HTMLElement): Promise { + const nav = modal.querySelector('#settings-nav'); + const panelsScroll = modal.querySelector('#settings-panels-scroll'); + if (!nav || !panelsScroll) return; + + nav.querySelectorAll('[data-plugin-menu="true"]').forEach((node) => node.remove()); + panelsScroll + .querySelectorAll('.panel-form[data-plugin-menu="true"]') + .forEach((node) => node.remove()); + + if (!TAURI.has) return; + let menus: PluginMenuPayload[] = []; + try { + menus = await TAURI.invoke('list_plugin_menus'); + } catch { + return; + } + + const pluginsNavBtn = nav.querySelector('[data-section="plugins"]'); + const pluginsNavLi = pluginsNavBtn?.closest('li') || null; + + for (const menu of menus) { + const section = pluginSectionId(menu.plugin_id, menu.id); + const navLi = document.createElement('li'); + navLi.dataset.pluginMenu = 'true'; + const navBtn = document.createElement('button'); + navBtn.className = 'seg-btn'; + navBtn.setAttribute('data-section', section); + navBtn.textContent = menu.label || menu.id; + navLi.appendChild(navBtn); + if (pluginsNavLi?.parentElement) { + pluginsNavLi.parentElement.insertBefore(navLi, pluginsNavLi); + } else { + nav.appendChild(navLi); + } + + const panel = document.createElement('form'); + panel.className = 'panel-form hidden'; + panel.setAttribute('data-panel', section); + panel.setAttribute('data-plugin-menu', 'true'); + panel.dataset.pluginId = menu.plugin_id; + panel.dataset.menuId = menu.id; + + for (const element of menu.elements || []) { + const group = document.createElement('div'); + group.className = 'group'; + if (element.type === 'text') { + const text = document.createElement('div'); + text.textContent = String(element.content || ''); + group.appendChild(text); + } else if (element.type === 'button') { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'tbtn'; + button.textContent = String(element.label || 'Action'); + button.dataset.pluginAction = String(element.id || ''); + button.dataset.pluginId = menu.plugin_id; + group.appendChild(button); + } + panel.appendChild(group); + } + panelsScroll.appendChild(panel); + } +} + async function rebuildThemePackOptions( selectEl: HTMLSelectElement, opts: { desiredId?: string | null; forceReload?: boolean } = {}, @@ -85,7 +166,11 @@ export function openSettings(section?: string){ const modal = document.getElementById('settings-modal') as HTMLElement | null; if (!modal) return; applyPluginSettingsSections(modal); - if (section) activateSection(modal, section); + renderPluginMenus(modal) + .catch(() => {}) + .finally(() => { + if (section) activateSection(modal, section); + }); // Prevent a "double-click to refresh" feel where the user opens the Theme dropdown // before the async settings/theme list has finished loading. @@ -163,6 +248,20 @@ export function wireSettings() { if (!target) return; activateSection(modal, target); }); + + panels.addEventListener('click', async (e) => { + const btn = (e.target as HTMLElement).closest('button[data-plugin-action][data-plugin-id]'); + if (!btn || !TAURI.has) return; + const pluginId = btn.dataset.pluginId || ''; + const actionId = btn.dataset.pluginAction || ''; + if (!pluginId || !actionId) return; + try { + await TAURI.invoke('invoke_plugin_action', { pluginId, actionId }); + } catch (err) { + console.error('Failed to invoke plugin action', err); + notify('Plugin action failed'); + } + }); } const lfsToggle = modal.querySelector('#set-lfs-enabled'); diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 907f7088..feab987a 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -16,7 +16,7 @@ Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) The authoritative host/plugin contract lives under `Core/wit/`: - `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, git process exec, notifications, logging, events) -- `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) +- `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) plus v1.1 plugin UI/settings world (`plugin-v1-1`) - `Core/wit/vcs.wit`: VCS backend world (`vcs`) The backend generates host bindings from these contracts and links them into a Wasmtime component runtime. @@ -31,6 +31,11 @@ The backend generates host bindings from these contracts and links them into a W - Exports the `plugin` world from `Core/wit/plugin.wit`. - Must implement `plugin-api.init` and `plugin-api.deinit`. +- Module plugin (UI + settings lifecycle) + - Exports the `plugin-v1-1` world from `Core/wit/plugin.wit`. + - Supports typed menu contributions (`get-menus` + `handle-action`) and settings hooks (`settings-defaults`, `settings-on-load`, `settings-on-apply`, `settings-on-save`, `settings-on-reset`). + - Plugins can implement only the hooks they care about when using `#[openvcs_plugin]`; defaults are injected for omitted hooks. + - VCS backend plugin - Exports the `vcs` world from `Core/wit/vcs.wit`. - Must export both `plugin-api` (lifecycle) and `vcs-api` (backend operations). @@ -83,3 +88,9 @@ Status APIs use dedicated capabilities: - Plugins run out-of-process in a Wasmtime component runtime. - Host APIs are explicit via WIT imports. - Workspace file access is mediated by the host and can be confined to a selected workspace root. + +## Plugin settings persistence + +- Plugin settings are persisted by the host (not by plugin code) in the user config directory under: + - `plugin-data//settings.json` +- This keeps settings stable across plugin updates because installed plugin directories are replaced during installation. diff --git a/docs/plugins.md b/docs/plugins.md index 5e11469f..8e517665 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -4,6 +4,8 @@ OpenVCS plugins are local extensions installed as `.ovcsp` bundles. Plugins may include themes, a Wasm module, or both. +Module plugins may optionally export UI menus and settings lifecycle hooks via the v1.1 plugin world (`plugin-v1-1`) in `Core/wit/plugin.wit`. + ## Where plugins live OpenVCS discovers plugins from two places: @@ -54,6 +56,19 @@ Notes: - The plugin runtime only loads component-model modules. - If `themes/` exists, it is packaged and discovered automatically. +## Plugin UI menus and settings + +- Plugins can contribute typed menus/elements (text and buttons today) that the client renders. +- Action buttons invoke plugin `handle-action` callbacks. +- Plugin settings persistence is automatic in the host under: + - `plugin-data//settings.json` +- Settings save/load/reset/apply flow is driven by plugin hooks: + - `settings-defaults` + - `settings-on-load` + - `settings-on-apply` + - `settings-on-save` + - `settings-on-reset` + ## Building bundles Install the SDK from crates.io: From 79401ce5a324b531be0b1b797e022e7f1de92f89 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 11:59:42 +0000 Subject: [PATCH 57/96] Update --- Frontend/src/scripts/features/settings.ts | 7 +++++++ docs/plugins.md | 1 + 2 files changed, 8 insertions(+) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index f6c800e7..80aadfa0 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1390,9 +1390,16 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { if (!TAURI.has) return; try { + const activeSection = String( + modal + .querySelector('#settings-nav .seg-btn.active') + ?.getAttribute('data-section') || '', + ).trim(); await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); await reloadPlugins(); + await renderPluginMenus(modal); + if (activeSection) activateSection(modal, activeSection); await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); try { await refreshAvailableThemes(); diff --git a/docs/plugins.md b/docs/plugins.md index 8e517665..12aded59 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -59,6 +59,7 @@ Notes: ## Plugin UI menus and settings - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. +- Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. - Action buttons invoke plugin `handle-action` callbacks. - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` From 19d56402ec70e5f3cee025067dd3efc43401230d Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 12:23:57 +0000 Subject: [PATCH 58/96] updates --- .../src/plugin_runtime/component_instance.rs | 1 + Backend/src/tauri_commands/plugins.rs | 83 +++++++++++-------- docs/plugin architecture.md | 1 + docs/plugins.md | 1 + 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 4a9f2cf9..c5ba4bc3 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -904,6 +904,7 @@ fn map_menu_from_wit(menu: plugin_api_v1_1::Menu) -> Menu { Menu { id: menu.id, label: menu.label, + order: menu.order, elements, } } diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index d58eb603..3408f002 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -325,7 +325,7 @@ pub fn call_plugin_module_method( #[tauri::command] pub fn list_plugin_menus(state: State<'_, AppState>) -> Result, String> { let cfg = state.config(); - let mut out: Vec = Vec::new(); + let mut collected: Vec<(String, Menu)> = Vec::new(); for summary in plugins::list_plugins() { let plugin_id = summary.id.trim().to_string(); @@ -359,20 +359,62 @@ pub fn list_plugin_menus(state: State<'_, AppState>) -> Result>(); + Ok(out) } +/// Converts a plugin menu model to frontend payload. +fn menu_to_payload(plugin_id: &str, menu: Menu) -> PluginMenuPayload { + let elements = menu + .elements + .into_iter() + .map(|element| match element { + UiElement::Text(text) => serde_json::json!({ + "type": "text", + "id": text.id, + "content": text.content, + }), + UiElement::Button(button) => serde_json::json!({ + "type": "button", + "id": button.id, + "label": button.label, + }), + }) + .collect::>(); + + PluginMenuPayload { + plugin_id: plugin_id.to_string(), + id: menu.id, + label: menu.label, + elements, + } +} + /// Invokes a plugin-provided action by id. /// /// # Parameters @@ -455,33 +497,6 @@ pub fn reset_plugin_settings(state: State<'_, AppState>, plugin_id: String) -> R runtime.settings_on_apply(defaults) } -/// Converts a plugin menu model to frontend payload. -fn menu_to_payload(plugin_id: &str, menu: Menu) -> PluginMenuPayload { - let elements = menu - .elements - .into_iter() - .map(|element| match element { - UiElement::Text(text) => serde_json::json!({ - "type": "text", - "id": text.id, - "content": text.content, - }), - UiElement::Button(button) => serde_json::json!({ - "type": "button", - "id": button.id, - "label": button.label, - }), - }) - .collect::>(); - - PluginMenuPayload { - plugin_id: plugin_id.to_string(), - id: menu.id, - label: menu.label, - elements, - } -} - /// Merges incoming frontend values into typed defaults. fn merge_settings_with_defaults( mut defaults: Vec, diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index feab987a..aab23b50 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -34,6 +34,7 @@ The backend generates host bindings from these contracts and links them into a W - Module plugin (UI + settings lifecycle) - Exports the `plugin-v1-1` world from `Core/wit/plugin.wit`. - Supports typed menu contributions (`get-menus` + `handle-action`) and settings hooks (`settings-defaults`, `settings-on-load`, `settings-on-apply`, `settings-on-save`, `settings-on-reset`). + - Menu records include an optional `order` hint (`option`); host menu rendering sorts by `order` (ascending) then label. - Plugins can implement only the hooks they care about when using `#[openvcs_plugin]`; defaults are injected for omitted hooks. - VCS backend plugin diff --git a/docs/plugins.md b/docs/plugins.md index 12aded59..5ba0c5a2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -60,6 +60,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. +- For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Action buttons invoke plugin `handle-action` callbacks. - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` From 5f479c31f7aafa164efc33067edc407924fc5f76 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 14:28:47 +0000 Subject: [PATCH 59/96] Update Settings --- Frontend/src/scripts/features/settings.ts | 60 +++++++++++++++++++++-- Frontend/src/styles/modal/settings.css | 23 +++++++++ docs/plugins.md | 1 + 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 80aadfa0..b71ae4a2 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -71,34 +71,82 @@ async function renderPluginMenus(modal: HTMLElement): Promise { if (!nav || !panelsScroll) return; nav.querySelectorAll('[data-plugin-menu="true"]').forEach((node) => node.remove()); + nav.querySelectorAll('[data-plugin-menus-wrap="true"]').forEach((node) => node.remove()); panelsScroll .querySelectorAll('.panel-form[data-plugin-menu="true"]') .forEach((node) => node.remove()); if (!TAURI.has) return; let menus: PluginMenuPayload[] = []; + let pluginSummaries: PluginSummary[] = []; try { menus = await TAURI.invoke('list_plugin_menus'); } catch { return; } + try { + pluginSummaries = await TAURI.invoke('list_plugins'); + } catch { + pluginSummaries = []; + } + + const pluginSources = new Map(); + for (const summary of Array.isArray(pluginSummaries) ? pluginSummaries : []) { + const id = String(summary?.id || '').trim().toLowerCase(); + if (!id) continue; + pluginSources.set(id, String(summary?.source || '').trim().toLowerCase()); + } const pluginsNavBtn = nav.querySelector('[data-section="plugins"]'); const pluginsNavLi = pluginsNavBtn?.closest('li') || null; + let thirdPartySublist: HTMLElement | null = null; + const ensureThirdPartySublist = (): HTMLElement => { + if (thirdPartySublist) return thirdPartySublist; + + const wrap = document.createElement('div'); + wrap.setAttribute('data-plugin-menus-wrap', 'true'); + + const heading = document.createElement('div'); + heading.className = 'settings-plugin-subhead'; + heading.textContent = 'Plugin Settings'; + wrap.appendChild(heading); + + const list = document.createElement('ul'); + list.className = 'settings-plugin-sublist'; + list.setAttribute('data-plugin-menus', 'true'); + wrap.appendChild(list); + + if (pluginsNavLi) { + pluginsNavLi.appendChild(wrap); + } else { + nav.appendChild(wrap); + } + + thirdPartySublist = list; + return list; + }; + for (const menu of menus) { const section = pluginSectionId(menu.plugin_id, menu.id); const navLi = document.createElement('li'); navLi.dataset.pluginMenu = 'true'; const navBtn = document.createElement('button'); + const source = pluginSources.get(String(menu.plugin_id || '').trim().toLowerCase()) || ''; + const isBuiltIn = source === 'built-in'; navBtn.className = 'seg-btn'; navBtn.setAttribute('data-section', section); navBtn.textContent = menu.label || menu.id; navLi.appendChild(navBtn); - if (pluginsNavLi?.parentElement) { - pluginsNavLi.parentElement.insertBefore(navLi, pluginsNavLi); + + if (isBuiltIn) { + if (pluginsNavLi?.parentElement) { + pluginsNavLi.parentElement.insertBefore(navLi, pluginsNavLi); + } else { + nav.appendChild(navLi); + } } else { - nav.appendChild(navLi); + ensureThirdPartySublist().appendChild(navLi); } const panel = document.createElement('form'); @@ -219,7 +267,11 @@ function activateSection(modal: HTMLElement, section: string) { // Plugins are applied immediately (no Save/Cancel). const actions = modal.querySelector('.sheet-actions'); - if (actions) actions.classList.toggle('hidden', safeSection === 'plugins'); + const activePanel = panels.querySelector( + `.panel-form[data-panel="${CSS.escape(safeSection)}"]`, + ); + const isPluginMenuPanel = activePanel?.getAttribute('data-plugin-menu') === 'true'; + if (actions) actions.classList.toggle('hidden', safeSection === 'plugins' || isPluginMenuPanel); } export function wireSettings() { diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index 3315d5b4..b2c80dcc 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -58,6 +58,29 @@ box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); } +#settings-modal nav [data-plugin-menus-wrap="true"]{ + margin-top: 0; + padding-top: 0; +} + +#settings-modal nav .settings-plugin-subhead{ + margin: 4rem 0 .35rem; + font-size: .74rem; + line-height: 1.2; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} + +#settings-modal nav .settings-plugin-sublist{ + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: .5rem; +} + /* Make the right-side content a flex column so the footer can hug the content when there's extra vertical space, but still stick while scrolling */ #settings-panels{ diff --git a/docs/plugins.md b/docs/plugins.md index 5ba0c5a2..bc4d3a9b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -61,6 +61,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. +- Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` From 0389864292992f8620f2e62695574a25f9f1ff47 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:01:39 +0000 Subject: [PATCH 60/96] Add permission button --- Frontend/src/scripts/features/settings.ts | 10 ++++++++++ Frontend/src/styles/modal/settings.css | 8 ++++++++ docs/plugins.md | 1 + 3 files changed, 19 insertions(+) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index b71ae4a2..9b550946 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1281,6 +1281,16 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { detailEl.appendChild(head); detailEl.appendChild(body); + + const footer = document.createElement('div'); + footer.className = 'plugin-detail-footer'; + const permissions = document.createElement('button'); + permissions.type = 'button'; + permissions.className = 'tbtn'; + permissions.id = 'plugins-permissions-selected'; + permissions.textContent = 'Permissions'; + footer.appendChild(permissions); + detailEl.appendChild(footer); }; const renderList = () => { diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index b2c80dcc..5e721d4b 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -265,6 +265,8 @@ .plugins-detail{ padding: .85rem .9rem; min-height: 0; + display: flex; + flex-direction: column; } .plugins-detail.empty{ color: var(--muted); @@ -301,6 +303,12 @@ display: grid; gap: .75rem; } +.plugin-detail-footer{ + margin-top: auto; + padding-top: .9rem; + display: flex; + justify-content: flex-end; +} .plugin-detail-body .desc{ color: var(--muted); white-space: pre-wrap; diff --git a/docs/plugins.md b/docs/plugins.md index bc4d3a9b..80d6bd74 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -63,6 +63,7 @@ Notes: - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. +- The Plugins details pane includes a `Permissions` button at the bottom-right (UI placeholder for future permissions workflow wiring). - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` - Settings save/load/reset/apply flow is driven by plugin hooks: From d2a22355895364818e8b769e5ea498d9f20c701c Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:29:22 +0000 Subject: [PATCH 61/96] Update --- Backend/src/lib.rs | 2 + Backend/src/plugin_bundles.rs | 59 +++ Backend/src/plugin_runtime/host_api.rs | 14 +- Backend/src/tauri_commands/plugins.rs | 102 ++++- Frontend/src/modals/plugin-permissions.html | 20 + .../src/scripts/features/pluginPermissions.ts | 366 ++++++++++++++++++ Frontend/src/scripts/features/settings.ts | 17 + Frontend/src/scripts/ui/modals.ts | 2 + Frontend/src/styles/index.css | 1 + .../src/styles/modal/plugin-permissions.css | 112 ++++++ docs/plugin architecture.md | 8 + docs/plugins.md | 5 +- 12 files changed, 696 insertions(+), 12 deletions(-) create mode 100644 Frontend/src/modals/plugin-permissions.html create mode 100644 Frontend/src/scripts/features/pluginPermissions.ts create mode 100644 Frontend/src/styles/modal/plugin-permissions.css diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 4cf35b04..e3343d5a 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -282,6 +282,8 @@ fn build_invoke_handler( tauri_commands::uninstall_plugin, tauri_commands::set_plugin_enabled, tauri_commands::approve_plugin_capabilities, + tauri_commands::get_plugin_permissions, + tauri_commands::set_plugin_permissions, tauri_commands::list_plugin_functions, tauri_commands::invoke_plugin_function, tauri_commands::call_plugin_module_method, diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index dc030990..59ae29b4 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -914,6 +914,65 @@ impl PluginBundleStore { Ok(()) } + /// Applies an explicit approved-capability set to the current plugin version. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier to update. + /// - `approved_capabilities`: Capability ids selected by the user. + /// + /// # Returns + /// - `Ok(())` when approval state is updated for the current version. + /// - `Err(String)` if the plugin/current version is missing or index write fails. + pub fn set_current_approved_capabilities( + &self, + plugin_id: &str, + approved_capabilities: Vec, + ) -> Result<(), String> { + let id = plugin_id.trim(); + if id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let mut index = self + .read_index(id) + .ok_or_else(|| "plugin is not installed".to_string())?; + let current = index + .current + .clone() + .ok_or_else(|| "plugin has no current version".to_string())?; + + let Some(version) = index.versions.get_mut(current.trim()) else { + return Err("current version is not installed".to_string()); + }; + + let requested = version + .requested_capabilities + .iter() + .cloned() + .collect::>(); + let mut approved = normalize_capabilities(approved_capabilities) + .into_iter() + .filter(|capability| requested.contains(capability)) + .collect::>(); + approved.sort(); + approved.dedup(); + + version.approval = if !version.requested_capabilities.is_empty() && approved.is_empty() { + ApprovalState::Denied { + denied_at_unix_ms: now_unix_ms(), + reason: None, + } + } else { + ApprovalState::Approved { + capabilities: approved, + approved_at_unix_ms: now_unix_ms(), + } + }; + + self.write_index(id, &index)?; + Ok(()) + } + /// Loads resolved components for the current plugin version. /// /// # Parameters diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index f395f35b..8f101093 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -365,7 +365,7 @@ pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { /// /// # Returns /// - `Ok(())` when status is stored and emitted. -/// - `Err(PluginError)` when capability is denied. +/// - `Ok(())` with a no-op when capability is denied. pub fn host_set_status(spawn: &SpawnConfig, message: &str) -> HostResult<()> { let (caps, _) = approved_caps_and_workspace(spawn); trace!( @@ -379,10 +379,7 @@ pub fn host_set_status(spawn: &SpawnConfig, message: &str) -> HostResult<()> { "host_set_status: capability denied for plugin {} (missing status.set)", spawn.plugin_id ); - return Err(host_error( - "capability.denied", - "missing capability: status.set", - )); + return Ok(()); } { @@ -407,7 +404,7 @@ pub fn host_set_status(spawn: &SpawnConfig, message: &str) -> HostResult<()> { /// /// # Returns /// - `Ok(String)` with current status text. -/// - `Err(PluginError)` when capability is denied. +/// - `Ok(String)` with current status text when capability is denied. pub fn host_get_status(spawn: &SpawnConfig) -> HostResult { let (caps, _) = approved_caps_and_workspace(spawn); trace!("host_get_status: plugin={}", spawn.plugin_id); @@ -417,10 +414,7 @@ pub fn host_get_status(spawn: &SpawnConfig) -> HostResult { "host_get_status: capability denied for plugin {} (missing status.get)", spawn.plugin_id ); - return Err(host_error( - "capability.denied", - "missing capability: status.get", - )); + return Ok(status_text_store().read().clone()); } Ok(status_text_store().read().clone()) diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 3408f002..28e90ca6 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,6 +1,6 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::plugin_bundles::{InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; +use crate::plugin_bundles::{ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; use crate::plugin_runtime::settings_store; use crate::plugins; use crate::state::AppState; @@ -34,6 +34,24 @@ pub struct PluginMenuPayload { pub elements: Vec, } +/// Permission metadata returned for a plugin's current installed version. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginPermissionsPayload { + /// Plugin id these permissions belong to. + pub plugin_id: String, + /// Current installed version, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Current approval state (`pending`, `approved`, `denied`). + pub approval_state: String, + /// Capability ids requested by the plugin. + #[serde(default)] + pub requested_capabilities: Vec, + /// Capability ids currently approved for the plugin. + #[serde(default)] + pub approved_capabilities: Vec, +} + #[tauri::command] /// Lists plugin summaries discovered by the backend. /// @@ -237,6 +255,88 @@ pub fn approve_plugin_capabilities( Ok(()) } +#[tauri::command] +/// Returns permission metadata for a plugin's current installed version. +/// +/// # Parameters +/// - `plugin_id`: Plugin id. +/// +/// # Returns +/// - `Ok(PluginPermissionsPayload)` on success. +/// - `Err(String)` when lookup fails. +pub fn get_plugin_permissions(plugin_id: String) -> Result { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let Some(current) = PluginBundleStore::new_default().get_current_installed(&plugin_id)? else { + return Ok(PluginPermissionsPayload { + plugin_id, + version: None, + approval_state: "pending".to_string(), + requested_capabilities: Vec::new(), + approved_capabilities: Vec::new(), + }); + }; + + let (approval_state, approved_capabilities) = match current.approval { + ApprovalState::Pending => ("pending".to_string(), Vec::new()), + ApprovalState::Denied { .. } => ("denied".to_string(), Vec::new()), + ApprovalState::Approved { capabilities, .. } => ("approved".to_string(), capabilities), + }; + + Ok(PluginPermissionsPayload { + plugin_id, + version: Some(current.version), + approval_state, + requested_capabilities: current.requested_capabilities, + approved_capabilities, + }) +} + +#[tauri::command] +/// Applies a selected approved-capabilities set for the current plugin version. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// - `approved_capabilities`: Selected capability ids to approve. +/// +/// # Returns +/// - `Ok(())` when permissions are saved. +/// - `Err(String)` when update fails. +pub fn set_plugin_permissions( + state: State<'_, AppState>, + plugin_id: String, + approved_capabilities: Vec, +) -> Result<(), String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + PluginBundleStore::new_default().set_current_approved_capabilities( + &plugin_id, + approved_capabilities, + )?; + + if let Err(err) = state.plugin_runtime().stop_plugin(&plugin_id) { + warn!( + "plugins: stop runtime after permissions update failed for {}: {}", + plugin_id, err + ); + } + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!( + "plugins: runtime sync after permissions update failed for {}: {}", + plugin_id, err + ); + } + + Ok(()) +} + #[tauri::command] /// Lists callable functions exported by a plugin module component. /// diff --git a/Frontend/src/modals/plugin-permissions.html b/Frontend/src/modals/plugin-permissions.html new file mode 100644 index 00000000..c2a67937 --- /dev/null +++ b/Frontend/src/modals/plugin-permissions.html @@ -0,0 +1,20 @@ + + diff --git a/Frontend/src/scripts/features/pluginPermissions.ts b/Frontend/src/scripts/features/pluginPermissions.ts new file mode 100644 index 00000000..a2641a8f --- /dev/null +++ b/Frontend/src/scripts/features/pluginPermissions.ts @@ -0,0 +1,366 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { notify } from '../lib/notify'; +import { TAURI } from '../lib/tauri'; +import { closeModal, openModal } from '../ui/modals'; + +/** Payload returned by backend permission lookup command. */ +interface PluginPermissionsPayload { + plugin_id: string; + version?: string; + approval_state: 'pending' | 'approved' | 'denied' | string; + requested_capabilities: string[]; + approved_capabilities: string[]; +} + +/** Option for one permission row selection. */ +interface PermissionChoice { + id: string; + label: string; + approvedCapabilities: string[]; +} + +/** Renderable permission row model. */ +interface PermissionRow { + key: string; + label: string; + detail: string; + choices: PermissionChoice[]; +} + +/** In-memory modal state stored on the modal element. */ +interface PluginPermissionsModalState { + pluginId: string; + rows: PermissionRow[]; + selectedChoiceByRow: Map; +} + +const MODAL_ID = 'plugin-permissions-modal'; + +/** Converts raw capability ids to trimmed lowercase unique values. */ +function normalizeCapabilities(values: string[]): string[] { + const out = new Set(); + for (const value of values || []) { + const id = String(value || '').trim().toLowerCase(); + if (id) out.add(id); + } + return [...out].sort(); +} + +/** Picks the best matching choice id from approved capabilities. */ +function defaultChoiceIdForRow(row: PermissionRow, approved: Set): string { + const choices = [...row.choices].sort( + (a, b) => b.approvedCapabilities.length - a.approvedCapabilities.length, + ); + for (const choice of choices) { + const requested = choice.approvedCapabilities; + if (requested.length && requested.every((capability) => approved.has(capability))) { + return choice.id; + } + if (!requested.length && !row.choices.some((c) => c.approvedCapabilities.some((cap) => approved.has(cap)))) { + return choice.id; + } + } + return row.choices[0]?.id || ''; +} + +/** Builds structured permission rows from requested capability ids. */ +function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { + const requested = normalizeCapabilities(requestedCapabilities); + const used = new Set(); + const rows: PermissionRow[] = []; + + const status = requested.filter((capability) => capability.startsWith('status.')); + if (status.length) { + for (const capability of status) used.add(capability); + const hasGet = status.includes('status.get'); + const hasSet = status.includes('status.set'); + if (hasGet && hasSet) { + rows.push({ + key: 'status', + label: 'Status', + detail: status.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'read', label: 'Read only', approvedCapabilities: ['status.get'] }, + { id: 'read-set', label: 'Read & Set', approvedCapabilities: ['status.get', 'status.set'] }, + ], + }); + } else { + rows.push({ + key: 'status', + label: 'Status', + detail: status.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'allow', label: 'Allow', approvedCapabilities: status }, + ], + }); + } + } + + const workspace = requested.filter((capability) => capability.startsWith('workspace.')); + if (workspace.length) { + for (const capability of workspace) used.add(capability); + const hasRead = workspace.includes('workspace.read'); + const hasWrite = workspace.includes('workspace.write'); + if (hasRead && hasWrite) { + rows.push({ + key: 'workspace', + label: 'Workspace', + detail: workspace.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'read', label: 'Read only', approvedCapabilities: ['workspace.read'] }, + { + id: 'read-write', + label: 'Read & Write', + approvedCapabilities: ['workspace.read', 'workspace.write'], + }, + ], + }); + } else { + rows.push({ + key: 'workspace', + label: 'Workspace', + detail: workspace.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'allow', label: 'Allow', approvedCapabilities: workspace }, + ], + }); + } + } + + const execution = requested.filter((capability) => capability.startsWith('process.')); + if (execution.length) { + for (const capability of execution) used.add(capability); + rows.push({ + key: 'execution', + label: 'Execution', + detail: execution.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'allow', label: 'Allow', approvedCapabilities: execution }, + ], + }); + } + + const ui = requested.filter((capability) => capability.startsWith('ui.')); + if (ui.length) { + for (const capability of ui) used.add(capability); + rows.push({ + key: 'ui', + label: 'UI', + detail: ui.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'allow', label: 'Allow', approvedCapabilities: ui }, + ], + }); + } + + const other = requested.filter((capability) => !used.has(capability)); + if (other.length) { + rows.push({ + key: 'other', + label: 'Something else', + detail: other.join(', '), + choices: [ + { id: 'deny', label: 'Deny', approvedCapabilities: [] }, + { id: 'allow', label: 'Allow', approvedCapabilities: other }, + ], + }); + } + + return rows; +} + +/** Builds approved capability ids from current row selections. */ +function selectedCapabilities(rows: PermissionRow[], selectedChoiceByRow: Map): string[] { + const approved = new Set(); + for (const row of rows) { + const selectedId = selectedChoiceByRow.get(row.key) || row.choices[0]?.id; + const selected = row.choices.find((choice) => choice.id === selectedId) || row.choices[0]; + for (const capability of selected?.approvedCapabilities || []) { + approved.add(capability); + } + } + return [...approved].sort(); +} + +/** Moves the animated highlight under the active choice button. */ +function positionChoiceIndicator(toggle: HTMLElement): void { + const indicator = toggle.querySelector('.plugin-permissions-toggle-indicator'); + const active = toggle.querySelector('.plugin-permissions-toggle-item.is-active'); + if (!indicator || !active) return; + indicator.style.width = `${active.offsetWidth}px`; + indicator.style.transform = `translateX(${active.offsetLeft}px)`; +} + +/** Applies selected state for one choice row and updates highlight animation. */ +function applyChoiceSelection( + toggle: HTMLElement, + rowKey: string, + choiceId: string, + selectedChoiceByRow: Map, +): void { + const options = toggle.querySelectorAll('.plugin-permissions-toggle-item'); + for (const option of options) { + const selected = option.dataset.choiceId === choiceId; + option.classList.toggle('is-active', selected); + option.setAttribute('aria-pressed', selected ? 'true' : 'false'); + } + selectedChoiceByRow.set(rowKey, choiceId); + positionChoiceIndicator(toggle); +} + +/** Builds a segmented choice control for one permission row. */ +function buildChoiceToggle( + row: PermissionRow, + defaultChoice: string, + selectedChoiceByRow: Map, +): HTMLElement { + const toggle = document.createElement('div'); + toggle.className = 'plugin-permissions-toggle'; + toggle.setAttribute('role', 'group'); + toggle.setAttribute('aria-label', `${row.label} permission`); + + const indicator = document.createElement('span'); + indicator.className = 'plugin-permissions-toggle-indicator'; + toggle.appendChild(indicator); + + for (const choice of row.choices) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'plugin-permissions-toggle-item'; + button.dataset.choiceId = choice.id; + button.textContent = choice.label; + button.addEventListener('click', () => { + applyChoiceSelection(toggle, row.key, choice.id, selectedChoiceByRow); + }); + toggle.appendChild(button); + } + + selectedChoiceByRow.set(row.key, defaultChoice); + requestAnimationFrame(() => { + applyChoiceSelection(toggle, row.key, defaultChoice, selectedChoiceByRow); + }); + + return toggle; +} + +/** Opens and populates plugin permissions modal for the selected plugin. */ +export async function openPluginPermissionsModal(pluginId: string, pluginName: string): Promise { + openModal(MODAL_ID); + + const modal = document.getElementById(MODAL_ID); + if (!(modal instanceof HTMLElement)) return; + + const title = modal.querySelector('#plugin-permissions-title'); + const rowsEl = modal.querySelector('#plugin-permissions-rows'); + const emptyEl = modal.querySelector('#plugin-permissions-empty'); + const applyBtn = modal.querySelector('#plugin-permissions-apply'); + if (!title || !rowsEl || !emptyEl || !applyBtn) return; + + const safePluginId = String(pluginId || '').trim(); + const safePluginName = String(pluginName || '').trim() || safePluginId || 'plugin'; + title.textContent = `Permissions for ${safePluginName}`; + rowsEl.replaceChildren(); + emptyEl.classList.add('hidden'); + emptyEl.textContent = 'Loading permissions…'; + emptyEl.classList.remove('hidden'); + applyBtn.disabled = true; + + if (!TAURI.has) { + emptyEl.textContent = 'The plugin does not request permissions'; + return; + } + + let payload: PluginPermissionsPayload; + try { + payload = await TAURI.invoke('get_plugin_permissions', { + pluginId: safePluginId, + }); + } catch (error) { + const message = String(error || '').trim(); + emptyEl.textContent = message || 'Failed to load plugin permissions'; + return; + } + + const requested = normalizeCapabilities(payload.requested_capabilities || []); + const approved = new Set(normalizeCapabilities(payload.approved_capabilities || [])); + const rows = buildPermissionRows(requested); + const selectedChoiceByRow = new Map(); + + if (!rows.length) { + emptyEl.textContent = 'The plugin does not request permissions'; + applyBtn.disabled = true; + (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }).__pluginPermissionsState = { + pluginId: safePluginId, + rows, + selectedChoiceByRow, + }; + return; + } + + rowsEl.replaceChildren(); + emptyEl.classList.add('hidden'); + + for (const row of rows) { + const rowEl = document.createElement('div'); + rowEl.className = 'plugin-permissions-row'; + + const labelWrap = document.createElement('div'); + labelWrap.className = 'plugin-permissions-label'; + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = row.label; + const detail = document.createElement('div'); + detail.className = 'detail'; + detail.textContent = row.detail; + labelWrap.appendChild(name); + labelWrap.appendChild(detail); + + const defaultChoice = defaultChoiceIdForRow(row, approved); + const toggle = buildChoiceToggle(row, defaultChoice, selectedChoiceByRow); + + rowEl.appendChild(labelWrap); + rowEl.appendChild(toggle); + rowsEl.appendChild(rowEl); + } + + applyBtn.disabled = false; + (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }).__pluginPermissionsState = { + pluginId: safePluginId, + rows, + selectedChoiceByRow, + }; + + if (!(applyBtn as unknown as { __bound?: boolean }).__bound) { + (applyBtn as unknown as { __bound?: boolean }).__bound = true; + applyBtn.addEventListener('click', async () => { + const state = + (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }) + .__pluginPermissionsState; + if (!state || !state.pluginId) return; + + try { + applyBtn.disabled = true; + const approvedCapabilities = selectedCapabilities(state.rows, state.selectedChoiceByRow); + await TAURI.invoke('set_plugin_permissions', { + pluginId: state.pluginId, + approvedCapabilities, + }); + notify('Plugin permissions updated'); + closeModal(MODAL_ID); + } catch (error) { + const message = String(error || '').trim(); + notify(message ? `Apply failed: ${message}` : 'Apply failed'); + } finally { + applyBtn.disabled = false; + } + }); + } +} diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 9b550946..fe9c3dd5 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -6,6 +6,7 @@ import { toKebab } from '../lib/dom'; import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { setTheme } from '../ui/layout'; +import { openPluginPermissionsModal } from './pluginPermissions'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, DEFAULT_THEME_ID, getActiveThemeId, getAvailableThemes, refreshAvailableThemes, selectThemePack } from '../themes'; import { reloadPlugins } from '../plugins'; import type { PluginSummary } from '../plugins'; @@ -1288,6 +1289,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { permissions.type = 'button'; permissions.className = 'tbtn'; permissions.id = 'plugins-permissions-selected'; + permissions.dataset.pluginPermissions = id; + permissions.dataset.pluginName = String(plugin.name || '').trim() || id; permissions.textContent = 'Permissions'; footer.appendChild(permissions); detailEl.appendChild(footer); @@ -1608,6 +1611,20 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { pane.addEventListener('click', (e) => { const target = e.target as HTMLElement | null; + const permissionsBtn = + target?.closest('[data-plugin-permissions]') || null; + if (permissionsBtn) { + const pluginId = String(permissionsBtn.dataset.pluginPermissions || '').trim(); + const pluginName = String(permissionsBtn.dataset.pluginName || '').trim() || pluginId; + if (pluginId) { + openPluginPermissionsModal(pluginId, pluginName).catch((err) => { + const msg = String(err || '').trim(); + notify(msg ? `Failed to open permissions: ${msg}` : 'Failed to open permissions'); + }); + } + return; + } + const toggleBtn = target?.closest('[data-plugin-toggle]') || null; if (toggleBtn) { const id = String(toggleBtn.dataset.pluginToggle || '').trim(); diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 3f8a9a60..8b9610a9 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -6,6 +6,7 @@ import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from "../lib/sc import settingsHtml from "@modals/settings.html?raw"; import cmdHtml from "@modals/commandSheet.html?raw"; import aboutHtml from "@modals/about.html?raw"; +import pluginPermissionsHtml from "@modals/plugin-permissions.html?raw"; import { wireSettings } from "../features/settings"; import repoSettingsHtml from "@modals/repo-settings.html?raw"; import { wireRepoSettings } from "../features/repoSettings"; @@ -35,6 +36,7 @@ import repoSwitchDrawerHtml from "@modals/repoSwitchDrawer.html?raw"; const FRAGMENTS: Record = { "settings-modal": settingsHtml, "about-modal": aboutHtml, + "plugin-permissions-modal": pluginPermissionsHtml, "command-modal": cmdHtml, "repo-switch-drawer": repoSwitchDrawerHtml, "repo-settings-modal": repoSettingsHtml, diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index 0790b19e..333694c1 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -11,6 +11,7 @@ @import "./modal/modal-base.css"; @import "./modal/command-sheet.css"; @import "./modal/settings.css"; +@import "./modal/plugin-permissions.css"; @import "./modal/repo-settings.css"; @import "./output-log.css"; @import "./modal/new-branch.css"; diff --git a/Frontend/src/styles/modal/plugin-permissions.css b/Frontend/src/styles/modal/plugin-permissions.css new file mode 100644 index 00000000..be773000 --- /dev/null +++ b/Frontend/src/styles/modal/plugin-permissions.css @@ -0,0 +1,112 @@ +/* ========================================================================== + plugin-permissions.css — Styles for plugin permissions modal + ========================================================================== */ + +#plugin-permissions-modal .dialog.sheet { + width: clamp(520px, 80vw, 720px); + max-height: min(620px, calc(100vh - 24px)); + display: flex; + flex-direction: column; +} + +#plugin-permissions-modal .plugin-permissions-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + display: grid; + gap: 0.7rem; +} + +#plugin-permissions-modal .plugin-permissions-rows { + display: grid; + gap: 0.55rem; +} + +#plugin-permissions-modal .plugin-permissions-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.7rem; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface); +} + +#plugin-permissions-modal .plugin-permissions-label { + min-width: 0; +} + +#plugin-permissions-modal .plugin-permissions-label .name { + font-weight: 700; +} + +#plugin-permissions-modal .plugin-permissions-label .detail { + margin-top: 0.2rem; + color: var(--muted); + font-size: 0.84rem; + overflow-wrap: anywhere; +} + +#plugin-permissions-modal .plugin-permissions-toggle { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(88px, 1fr); + gap: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in oklab, var(--surface-2) 92%, transparent); + overflow: hidden; + isolation: isolate; +} + +#plugin-permissions-modal .plugin-permissions-toggle-indicator { + position: absolute; + top: 2px; + left: 0; + height: calc(100% - 4px); + width: 0; + border-radius: 8px; + background: color-mix(in oklab, var(--accent) 22%, transparent); + border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--border)); + transition: transform 170ms cubic-bezier(.2,.8,.2,1), width 170ms cubic-bezier(.2,.8,.2,1); + z-index: 0; +} + +#plugin-permissions-modal .plugin-permissions-toggle-item { + position: relative; + z-index: 1; + border: 0; + background: transparent; + color: var(--muted); + padding: 0.45rem 0.7rem; + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} + +#plugin-permissions-modal .plugin-permissions-toggle-item:hover { + color: var(--text); +} + +#plugin-permissions-modal .plugin-permissions-toggle-item.is-active { + color: var(--text); + font-weight: 700; +} + +#plugin-permissions-modal .plugin-permissions-toggle-item:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); +} + +@media (max-width: 720px) { + #plugin-permissions-modal .plugin-permissions-row { + grid-template-columns: 1fr; + gap: 0.55rem; + } + + #plugin-permissions-modal .plugin-permissions-toggle { + width: 100%; + } +} diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index aab23b50..1ca2a83e 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -78,12 +78,20 @@ The host cares about: Plugins request capabilities through the manifest `capabilities` array. The host enforces capability approval before allowing privileged host API calls. +The Settings > Plugins details pane exposes a `Permissions` modal where users can +adjust approval choices for the currently installed plugin version and apply +changes immediately. + Status APIs use dedicated capabilities: - `status.set`: allows plugins to call `set-status`. - `status.get`: allows plugins to call `get-status`. - `status.set` also implies `status.get`. +When a plugin calls status APIs without approved status capability, the host logs +a warning and ignores the status mutation/read request instead of failing plugin +startup. + ## Security model - Plugins run out-of-process in a Wasmtime component runtime. diff --git a/docs/plugins.md b/docs/plugins.md index 80d6bd74..43c919cf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -55,6 +55,7 @@ Notes: - `module.exec` must end with `.wasm`. - The plugin runtime only loads component-model modules. - If `themes/` exists, it is packaged and discovered automatically. +- If a plugin calls status APIs without approved status capability, the host logs a warning and ignores the action (the plugin still loads). ## Plugin UI menus and settings @@ -63,7 +64,9 @@ Notes: - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. -- The Plugins details pane includes a `Permissions` button at the bottom-right (UI placeholder for future permissions workflow wiring). +- The Plugins details pane includes a bottom-right `Permissions` button that opens a stacked modal titled `Permissions for `. +- The permissions modal lists only permissions requested by that plugin, shows segmented button choices (for example `Allow` / `Deny`, with richer choices for some permission groups), and includes an `Apply changes` button. +- When a plugin requests no capabilities, the modal shows: `The plugin does not request permissions`. - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` - Settings save/load/reset/apply flow is driven by plugin hooks: From f1b94e42fadcf95b76212d5dfb4b5c25985c35a6 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:38:53 +0000 Subject: [PATCH 62/96] Update --- Backend/src/plugin_runtime/manager.rs | 44 ++++++++++++++++++++++----- Backend/src/tauri_commands/plugins.rs | 22 +++++++++++--- docs/plugin architecture.md | 6 ++++ docs/plugins.md | 1 + 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 4a320bf6..15364e2c 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -130,6 +130,27 @@ impl PluginRuntimeManager { Ok(()) } + /// Returns a running runtime instance for a plugin, without starting it. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// + /// # Returns + /// - `Ok(Some(runtime))` when currently running. + /// - `Ok(None)` when not running. + /// - `Err(String)` if the identifier is invalid. + pub fn running_runtime_for_plugin( + &self, + plugin_id: &str, + ) -> Result>, String> { + let key = normalize_plugin_key(plugin_id)?; + Ok(self + .processes + .lock() + .get(&key) + .map(|process| Arc::clone(&process.runtime))) + } + /// Stops all running plugins. pub fn stop_all_plugins(&self) { let running: Vec = { @@ -343,7 +364,8 @@ impl PluginRuntimeManager { /// /// # Returns /// - `Ok(Value)` plugin RPC response payload. - /// - `Err(String)` when plugin state validation or RPC dispatch fails. + /// - `Err(String)` when plugin state validation fails, plugin is not running, + /// or RPC dispatch fails. pub fn call_module_method_for_workspace_with_config( &self, cfg: &AppConfig, @@ -356,14 +378,17 @@ impl PluginRuntimeManager { if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { return Err(format!("plugin `{}` is disabled", spec.plugin_id)); } - - self.start_plugin_spec(spec.clone())?; let rpc = self .processes .lock() .get(&spec.key) .map(|p| Arc::clone(&p.runtime)) - .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id))?; + .ok_or_else(|| { + format!( + "plugin `{}` is not running; enable the plugin to start its runtime", + spec.plugin_id + ) + })?; rpc.call(method, params) } @@ -376,7 +401,7 @@ impl PluginRuntimeManager { /// /// # Returns /// - `Ok(Arc)` running runtime instance. - /// - `Err(String)` when plugin state validation or startup fails. + /// - `Err(String)` when plugin state validation fails or runtime is not running. pub fn runtime_for_workspace_with_config( &self, cfg: &AppConfig, @@ -387,13 +412,16 @@ impl PluginRuntimeManager { if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { return Err(format!("plugin `{}` is disabled", spec.plugin_id)); } - - self.start_plugin_spec(spec.clone())?; self.processes .lock() .get(&spec.key) .map(|p| Arc::clone(&p.runtime)) - .ok_or_else(|| format!("plugin `{}` is not running", spec.plugin_id)) + .ok_or_else(|| { + format!( + "plugin `{}` is not running; enable the plugin to start its runtime", + spec.plugin_id + ) + }) } /// Starts or reuses a runtime for a resolved plugin runtime spec. diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 28e90ca6..38be391d 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -435,12 +435,24 @@ pub fn list_plugin_menus(state: State<'_, AppState>) -> Result {} + Ok(Some(false)) | Ok(None) => continue, + Err(err) => { + warn!("list_plugin_menus: skip plugin {}: {}", plugin_id, err); + continue; + } + } - let runtime = match state - .plugin_runtime() - .runtime_for_workspace_with_config(&cfg, &plugin_id, None) - { - Ok(runtime) => runtime, + let runtime = match state.plugin_runtime().running_runtime_for_plugin(&plugin_id) { + Ok(Some(runtime)) => runtime, + Ok(None) => { + debug!( + "list_plugin_menus: skip plugin {} because runtime is not running", + plugin_id + ); + continue; + } Err(err) => { warn!("list_plugin_menus: skip plugin {}: {}", plugin_id, err); continue; diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 1ca2a83e..6c93732f 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -98,6 +98,12 @@ startup. - Host APIs are explicit via WIT imports. - Workspace file access is mediated by the host and can be confined to a selected workspace root. +## Runtime lifecycle + +- Module runtimes are started/stopped by lifecycle operations (startup sync and plugin enable/disable toggles). +- Backend plugin command calls do not implicitly start stopped plugin runtimes. +- If a plugin is enabled but not currently running, module RPC/menu calls return a `not running` error until runtime is restored. + ## Plugin settings persistence - Plugin settings are persisted by the host (not by plugin code) in the user config directory under: diff --git a/docs/plugins.md b/docs/plugins.md index 43c919cf..430b87cf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -61,6 +61,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. +- Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. From dbf2fefcfd579d87cc0af24d1e739235803757dc Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:45:26 +0000 Subject: [PATCH 63/96] Updates --- Frontend/src/modals/settings.html | 6 - .../src/scripts/features/pluginPermissions.ts | 20 ++- Frontend/src/scripts/features/settings.ts | 120 +----------------- 3 files changed, 14 insertions(+), 132 deletions(-) diff --git a/Frontend/src/modals/settings.html b/Frontend/src/modals/settings.html index e37b286a..85845634 100644 --- a/Frontend/src/modals/settings.html +++ b/Frontend/src/modals/settings.html @@ -242,12 +242,6 @@

Installed Plugins

Plugins can add themes, UI actions, and hooks. Disabling a plugin prevents its code from running and hides any themes it provides. -
-

Installed Bundles (.ovcsp)

-
-
-
-
diff --git a/Frontend/src/scripts/features/pluginPermissions.ts b/Frontend/src/scripts/features/pluginPermissions.ts index a2641a8f..69745867 100644 --- a/Frontend/src/scripts/features/pluginPermissions.ts +++ b/Frontend/src/scripts/features/pluginPermissions.ts @@ -80,7 +80,7 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { rows.push({ key: 'status', label: 'Status', - detail: status.join(', '), + detail: 'Lets the plugin read and update the footer status text.', choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'read', label: 'Read only', approvedCapabilities: ['status.get'] }, @@ -88,10 +88,13 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { ], }); } else { + const statusDetail = hasGet + ? 'Lets the plugin read the footer status text.' + : 'Lets the plugin update the footer status text.'; rows.push({ key: 'status', label: 'Status', - detail: status.join(', '), + detail: statusDetail, choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'allow', label: 'Allow', approvedCapabilities: status }, @@ -109,7 +112,7 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { rows.push({ key: 'workspace', label: 'Workspace', - detail: workspace.join(', '), + detail: 'Lets the plugin read and write files inside the active workspace.', choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'read', label: 'Read only', approvedCapabilities: ['workspace.read'] }, @@ -121,10 +124,13 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { ], }); } else { + const workspaceDetail = hasRead + ? 'Lets the plugin read files inside the active workspace.' + : 'Lets the plugin write files inside the active workspace.'; rows.push({ key: 'workspace', label: 'Workspace', - detail: workspace.join(', '), + detail: workspaceDetail, choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'allow', label: 'Allow', approvedCapabilities: workspace }, @@ -139,7 +145,7 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { rows.push({ key: 'execution', label: 'Execution', - detail: execution.join(', '), + detail: 'Lets the plugin run Git commands through the host in your workspace context.', choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'allow', label: 'Allow', approvedCapabilities: execution }, @@ -153,7 +159,7 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { rows.push({ key: 'ui', label: 'UI', - detail: ui.join(', '), + detail: 'Lets the plugin trigger user-facing interface actions such as notifications.', choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'allow', label: 'Allow', approvedCapabilities: ui }, @@ -166,7 +172,7 @@ function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { rows.push({ key: 'other', label: 'Something else', - detail: other.join(', '), + detail: `Lets the plugin use additional host features requested by its manifest (${other.join(', ')}).`, choices: [ { id: 'deny', label: 'Deny', approvedCapabilities: [] }, { id: 'allow', label: 'Allow', approvedCapabilities: other }, diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index fe9c3dd5..3dfaa178 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -780,125 +780,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const installBundleBtn = modal.querySelector('#plugins-install-bundle'); const enableAllBtn = modal.querySelector('#plugins-enable-all'); const disableAllBtn = modal.querySelector('#plugins-disable-all'); - const bundleListEl = modal.querySelector('#plugin-bundles-list'); - if (!pane || !listEl || !detailEl || !groupLabelEl || !searchEl || !installBundleBtn || !enableAllBtn || !disableAllBtn || !bundleListEl) return; - - const renderBundles = async () => { - bundleListEl.innerHTML = ''; - if (!TAURI.has) { - bundleListEl.textContent = 'Bundles are only available in the desktop app.'; - return; - } - - let bundles: any[] = []; - try { - bundles = await TAURI.invoke('list_installed_bundles'); - } catch (err) { - bundleListEl.textContent = 'Failed to load installed bundles.'; - return; - } - - if (!Array.isArray(bundles) || bundles.length === 0) { - bundleListEl.textContent = 'No bundles installed.'; - return; - } - - const wrap = document.createElement('div'); - wrap.style.display = 'grid'; - wrap.style.gap = '.5rem'; - - for (const b of bundles) { - const pluginId = String(b?.plugin_id || '').trim(); - const current = String(b?.current || '').trim(); - const versions = b?.versions && typeof b.versions === 'object' ? b.versions : {}; - const cur = current && versions[current] ? versions[current] : null; - const approval = cur?.approval?.Pending ? 'Pending' : (cur?.approval?.Denied ? 'Denied' : (cur?.approval?.Approved ? 'Approved' : 'Pending')); - const requestedCaps: string[] = Array.isArray(cur?.requested_capabilities) ? cur.requested_capabilities : []; - - const row = document.createElement('div'); - row.className = 'card'; - (row.style as any).padding = '.6rem .7rem'; - (row.style as any).display = 'flex'; - (row.style as any).gap = '.75rem'; - (row.style as any).alignItems = 'center'; - - const left = document.createElement('div'); - left.style.flex = '1'; - const title = document.createElement('div'); - title.textContent = pluginId || '(unknown plugin)'; - const sub = document.createElement('div'); - sub.className = 'muted'; - sub.style.fontSize = '.85rem'; - sub.textContent = current ? `version ${current} • ${approval}` : `no current version • ${approval}`; - left.appendChild(title); - left.appendChild(sub); - - const actions = document.createElement('div'); - actions.style.display = 'flex'; - actions.style.gap = '.4rem'; - - const approveBtn = document.createElement('button'); - approveBtn.type = 'button'; - approveBtn.className = 'tbtn'; - approveBtn.textContent = 'Approve'; - approveBtn.disabled = !pluginId || !current; - approveBtn.addEventListener('click', async () => { - try { - await TAURI.invoke('approve_plugin_capabilities', { - pluginId, - version: current, - approved: true, - }); - notify('Capabilities approved'); - await renderBundles(); - } catch (err) { - const msg = String(err || '').trim(); - notify(msg ? `Approve failed: ${msg}` : 'Approve failed'); - } - }); - - const denyBtn = document.createElement('button'); - denyBtn.type = 'button'; - denyBtn.className = 'tbtn'; - denyBtn.textContent = 'Deny'; - denyBtn.disabled = !pluginId || !current; - denyBtn.addEventListener('click', async () => { - try { - await TAURI.invoke('approve_plugin_capabilities', { - pluginId, - version: current, - approved: false, - }); - notify('Capabilities denied'); - await renderBundles(); - } catch (err) { - const msg = String(err || '').trim(); - notify(msg ? `Deny failed: ${msg}` : 'Deny failed'); - } - }); - - actions.appendChild(approveBtn); - actions.appendChild(denyBtn); - - if (requestedCaps.length) { - const caps = document.createElement('div'); - caps.className = 'muted'; - caps.style.fontSize = '.8rem'; - caps.style.marginTop = '.2rem'; - caps.textContent = `requested: ${requestedCaps.join(', ')}`; - left.appendChild(caps); - } - - row.appendChild(left); - row.appendChild(actions); - wrap.appendChild(row); - } - - bundleListEl.appendChild(wrap); - }; - - await renderBundles(); + if (!pane || !listEl || !detailEl || !groupLabelEl || !searchEl || !installBundleBtn || !enableAllBtn || !disableAllBtn) return; // This settings pane can be initialized multiple times during navigation/rerender. // Avoid stacking duplicate click handlers which would open many dialogs. @@ -926,7 +809,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { notify(ok ? 'Capabilities approved' : 'Capabilities denied'); } - await renderBundles(); await reloadPluginSummaries(); } catch (err) { const msg = String(err || '').trim(); From ad0a672eafb60483374b2534660f30a4f67df221 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:47:52 +0000 Subject: [PATCH 64/96] Improve buttons --- Frontend/src/scripts/features/settings.ts | 2 +- Frontend/src/styles/modal/settings.css | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 3dfaa178..2dc2d118 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1121,7 +1121,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { actions.className = 'plugin-detail-actions'; const toggle = document.createElement('button'); toggle.type = 'button'; - toggle.className = 'tbtn'; + toggle.className = `tbtn plugin-toggle-btn ${isEnabledNow ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; toggle.id = 'plugins-toggle-selected'; toggle.textContent = isEnabledNow ? 'Disable' : 'Enable'; toggle.dataset.pluginToggle = id; diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index 5e721d4b..eb3c9742 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -298,6 +298,26 @@ gap: .5rem; align-items: center; } +.plugin-toggle-btn.plugin-toggle-btn-enable{ + border-color: color-mix(in oklab, #2aa84a 55%, var(--border)); + background: color-mix(in oklab, #2aa84a 20%, transparent); + color: color-mix(in oklab, #2aa84a 82%, white 18%); +} +.plugin-toggle-btn.plugin-toggle-btn-enable:hover{ + border-color: #2aa84a; + background: color-mix(in oklab, #2aa84a 28%, transparent); + color: #eaf9ef; +} +.plugin-toggle-btn.plugin-toggle-btn-disable{ + border-color: color-mix(in oklab, #d64545 55%, var(--border)); + background: color-mix(in oklab, #d64545 18%, transparent); + color: color-mix(in oklab, #d64545 80%, white 20%); +} +.plugin-toggle-btn.plugin-toggle-btn-disable:hover{ + border-color: #d64545; + background: color-mix(in oklab, #d64545 26%, transparent); + color: #fdeeee; +} .plugin-detail-body{ margin-top: .85rem; display: grid; From 0b0fa1d780f094871bc0a77aaf8c5351ae325d75 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:52:24 +0000 Subject: [PATCH 65/96] Update button --- Frontend/src/scripts/features/settings.ts | 67 ++++++++++++++++------- Frontend/src/styles/modal/settings.css | 4 ++ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 2dc2d118..6cd74533 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1025,6 +1025,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { list: PluginSummary[]; disabled: Set; enabled: Set; + pendingToggleById: Map; query: string; selectedId: string | null; }; @@ -1032,6 +1033,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { list: [], disabled: new Set(), enabled: new Set(), + pendingToggleById: new Map(), query: '', selectedId: null, }; @@ -1091,7 +1093,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { detailEl.classList.remove('empty'); const id = String(plugin.id).trim(); + const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); + const pendingToggle = state.pendingToggleById.get(idLower); + const pendingDesiredEnabled = typeof pendingToggle === 'boolean' ? pendingToggle : isEnabledNow; const version = String(plugin.version || '').trim(); const author = String(plugin.author || '').trim(); const category = String(plugin.category || '').trim(); @@ -1121,9 +1126,16 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { actions.className = 'plugin-detail-actions'; const toggle = document.createElement('button'); toggle.type = 'button'; - toggle.className = `tbtn plugin-toggle-btn ${isEnabledNow ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; + toggle.className = `tbtn plugin-toggle-btn ${pendingDesiredEnabled ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; toggle.id = 'plugins-toggle-selected'; - toggle.textContent = isEnabledNow ? 'Disable' : 'Enable'; + toggle.disabled = typeof pendingToggle === 'boolean'; + toggle.textContent = pendingToggle === true + ? 'Enabling...' + : pendingToggle === false + ? 'Disabling...' + : isEnabledNow + ? 'Disable' + : 'Enable'; toggle.dataset.pluginToggle = id; actions.appendChild(toggle); @@ -1351,7 +1363,29 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { try { await refreshAvailableThemes(); } catch (e) { console.warn('refreshAvailableThemes failed:', e); } - } catch (e) { console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } + } catch (e) { + console.error('Failed to toggle plugin:', e); + notify('Failed to toggle plugin'); + } finally { + state.pendingToggleById.delete(pluginId.trim().toLowerCase()); + renderDetails(getFiltered()); + } + }; + + const queuePluginToggle = (pluginIdRaw: string, enabled: boolean) => { + const id = String(pluginIdRaw || '').trim().toLowerCase(); + if (!id) return; + if (enabled) { + state.disabled.delete(id); + state.enabled.add(id); + } else { + state.enabled.delete(id); + state.disabled.add(id); + } + state.pendingToggleById.set(id, enabled); + updateCounts(); + renderDetails(getFiltered()); + persistSinglePluginToggle(id, enabled).catch(() => {}); }; if (!(pane as any).__wired) { @@ -1510,11 +1544,16 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const toggleBtn = target?.closest('[data-plugin-toggle]') || null; if (toggleBtn) { const id = String(toggleBtn.dataset.pluginToggle || '').trim(); - const checkbox = pane.querySelector(`input[type="checkbox"][data-plugin-id="${CSS.escape(id)}"]`); - if (checkbox) { - checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event('change', { bubbles: true })); - } + if (!id) return; + const plugin = state.list.find( + (p) => String(p?.id || '').trim().toLowerCase() === id.toLowerCase(), + ); + const desiredEnabled = plugin ? !pluginIsEnabled(plugin) : false; + const checkbox = pane.querySelector( + `input[type="checkbox"][data-plugin-id="${CSS.escape(id)}"]`, + ); + if (checkbox) checkbox.checked = desiredEnabled; + queuePluginToggle(id, desiredEnabled); return; } @@ -1556,17 +1595,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { if (!el || el.type !== 'checkbox' || !el.dataset.pluginId) return; const id = String(el.dataset.pluginId).trim().toLowerCase(); if (!id) return; - const wasEnabled = state.enabled.has(id); - if (el.checked) { - state.disabled.delete(id); - state.enabled.add(id); - } else { - state.enabled.delete(id); - state.disabled.add(id); - } - updateCounts(); - renderDetails(getFiltered()); - persistSinglePluginToggle(id, el.checked).catch(() => {}); + queuePluginToggle(id, el.checked); }); searchEl.addEventListener('input', () => { diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index eb3c9742..7c7611e8 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -298,6 +298,10 @@ gap: .5rem; align-items: center; } +.plugin-toggle-btn{ + min-width: 7.6rem; + text-align: center; +} .plugin-toggle-btn.plugin-toggle-btn-enable{ border-color: color-mix(in oklab, #2aa84a 55%, var(--border)); background: color-mix(in oklab, #2aa84a 20%, transparent); From 57a016ff560d81e6b269eb182e98ec79644bf839 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 15:59:57 +0000 Subject: [PATCH 66/96] Update --- Backend/src/tauri_commands/plugins.rs | 27 +++++++----- Frontend/src/scripts/features/settings.ts | 54 ++++++++++++++++++----- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 38be391d..b6a79d60 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -157,7 +157,7 @@ pub fn uninstall_plugin(state: State<'_, AppState>, plugin_id: String) -> Result /// # Returns /// - `Ok(())` when the operation succeeds. /// - `Err(String)` when the operation fails. -pub fn set_plugin_enabled( +pub async fn set_plugin_enabled( state: State<'_, AppState>, plugin_id: String, enabled: bool, @@ -176,16 +176,21 @@ pub fn set_plugin_enabled( plugin_id, enabled ); - state - .plugin_runtime() - .set_plugin_enabled(&plugin_id, enabled) - .map_err(|e| { - error!( - "set_plugin_enabled failed: plugin={}, error={}", - plugin_id, e - ); - e - })?; + let runtime = state.plugin_runtime(); + let plugin_id_for_runtime = plugin_id.clone(); + tauri::async_runtime::spawn_blocking(move || { + runtime + .set_plugin_enabled(&plugin_id_for_runtime, enabled) + .map_err(|e| { + error!( + "set_plugin_enabled failed: plugin={}, error={}", + plugin_id_for_runtime, e + ); + e + }) + }) + .await + .map_err(|e| format!("set_plugin_enabled task join failed: {e}"))??; let mut cfg = state.config(); let plugin_key = plugin_id.trim().to_ascii_lowercase(); diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 6cd74533..1b7fcfe6 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1026,6 +1026,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { disabled: Set; enabled: Set; pendingToggleById: Map; + errorToggleById: Set; + errorTimerById: Map; query: string; selectedId: string | null; }; @@ -1034,6 +1036,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { disabled: new Set(), enabled: new Set(), pendingToggleById: new Map(), + errorToggleById: new Set(), + errorTimerById: new Map(), query: '', selectedId: null, }; @@ -1096,7 +1100,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); const pendingToggle = state.pendingToggleById.get(idLower); - const pendingDesiredEnabled = typeof pendingToggle === 'boolean' ? pendingToggle : isEnabledNow; + const hasToggleError = state.errorToggleById.has(idLower); + const isDisablingAction = typeof pendingToggle === 'boolean' ? pendingToggle === false : isEnabledNow; const version = String(plugin.version || '').trim(); const author = String(plugin.author || '').trim(); const category = String(plugin.category || '').trim(); @@ -1126,16 +1131,18 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { actions.className = 'plugin-detail-actions'; const toggle = document.createElement('button'); toggle.type = 'button'; - toggle.className = `tbtn plugin-toggle-btn ${pendingDesiredEnabled ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; + toggle.className = `tbtn plugin-toggle-btn ${hasToggleError ? 'plugin-toggle-btn-disable' : (isDisablingAction ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable')}`; toggle.id = 'plugins-toggle-selected'; - toggle.disabled = typeof pendingToggle === 'boolean'; - toggle.textContent = pendingToggle === true - ? 'Enabling...' - : pendingToggle === false - ? 'Disabling...' - : isEnabledNow - ? 'Disable' - : 'Enable'; + toggle.disabled = typeof pendingToggle === 'boolean' || hasToggleError; + toggle.textContent = hasToggleError + ? 'Error' + : pendingToggle === true + ? 'Enabling...' + : pendingToggle === false + ? 'Disabling...' + : isEnabledNow + ? 'Disable' + : 'Enable'; toggle.dataset.pluginToggle = id; actions.appendChild(toggle); @@ -1348,6 +1355,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const persistSinglePluginToggle = async (pluginId: string, enabled: boolean) => { if (!TAURI.has) return; + const idLower = pluginId.trim().toLowerCase(); try { const activeSection = String( modal @@ -1364,10 +1372,28 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await refreshAvailableThemes(); } catch (e) { console.warn('refreshAvailableThemes failed:', e); } } catch (e) { + if (enabled) { + state.enabled.delete(idLower); + state.disabled.add(idLower); + } else { + state.disabled.delete(idLower); + state.enabled.add(idLower); + } + const existingTimer = state.errorTimerById.get(idLower); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + } + state.errorToggleById.add(idLower); + const timer = window.setTimeout(() => { + state.errorToggleById.delete(idLower); + state.errorTimerById.delete(idLower); + renderDetails(getFiltered()); + }, 2000); + state.errorTimerById.set(idLower, timer); console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } finally { - state.pendingToggleById.delete(pluginId.trim().toLowerCase()); + state.pendingToggleById.delete(idLower); renderDetails(getFiltered()); } }; @@ -1375,6 +1401,12 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const queuePluginToggle = (pluginIdRaw: string, enabled: boolean) => { const id = String(pluginIdRaw || '').trim().toLowerCase(); if (!id) return; + const existingTimer = state.errorTimerById.get(id); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + state.errorTimerById.delete(id); + } + state.errorToggleById.delete(id); if (enabled) { state.disabled.delete(id); state.enabled.add(id); From ec12f807b802d45995005fb4b79e059ab5f33dbd Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 16:14:43 +0000 Subject: [PATCH 67/96] Update --- Backend/src/tauri_commands/plugins.rs | 30 ++++++++++++++++++++++++--- docs/plugins.md | 1 + 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index b6a79d60..e181fb5b 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -176,9 +176,11 @@ pub async fn set_plugin_enabled( plugin_id, enabled ); + let plugin_key = plugin_id.trim().to_ascii_lowercase(); + let runtime = state.plugin_runtime(); let plugin_id_for_runtime = plugin_id.clone(); - tauri::async_runtime::spawn_blocking(move || { + let runtime_result = tauri::async_runtime::spawn_blocking(move || { runtime .set_plugin_enabled(&plugin_id_for_runtime, enabled) .map_err(|e| { @@ -190,10 +192,32 @@ pub async fn set_plugin_enabled( }) }) .await - .map_err(|e| format!("set_plugin_enabled task join failed: {e}"))??; + .map_err(|e| format!("set_plugin_enabled task join failed: {e}"))?; + + if let Err(err) = runtime_result { + if enabled { + let mut fallback_cfg = state.config(); + fallback_cfg + .plugins + .enabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + fallback_cfg + .plugins + .disabled + .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); + fallback_cfg.plugins.disabled.push(plugin_key.clone()); + if let Err(persist_error) = state.set_config(fallback_cfg) { + warn!( + "set_plugin_enabled: failed to persist disable fallback for {}: {}", + plugin_id, persist_error + ); + } + let _ = state.plugin_runtime().stop_plugin(&plugin_id); + } + return Err(err); + } let mut cfg = state.config(); - let plugin_key = plugin_id.trim().to_ascii_lowercase(); cfg.plugins .enabled .retain(|id| !id.trim().eq_ignore_ascii_case(&plugin_key)); diff --git a/docs/plugins.md b/docs/plugins.md index 430b87cf..d6f8f3fd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -62,6 +62,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. - Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. +- If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. From b705ddfa1ae77007dcbe9a598afb517ff6e00c5f Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 16:18:03 +0000 Subject: [PATCH 68/96] Update settings.ts --- Frontend/src/scripts/features/settings.ts | 49 +++++++++++++---------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 1b7fcfe6..a4dfddd3 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1222,7 +1222,9 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { for (const plugin of filtered) { const id = String(plugin.id).trim(); + const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); + const pendingToggle = state.pendingToggleById.get(idLower); const li = document.createElement('li'); li.className = 'plugin-row'; @@ -1277,6 +1279,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = isEnabledNow; + checkbox.disabled = typeof pendingToggle === 'boolean'; checkbox.dataset.pluginId = id; checkbox.setAttribute('aria-label', `Enable ${String(plugin.name || '').trim() || 'plugin'}`); @@ -1363,6 +1366,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { ?.getAttribute('data-section') || '', ).trim(); await TAURI.invoke('set_plugin_enabled', { pluginId, enabled }); + if (enabled) { + state.disabled.delete(idLower); + state.enabled.add(idLower); + } else { + state.enabled.delete(idLower); + state.disabled.add(idLower); + } console.log(`Plugin '${pluginId}' ${enabled ? 'enabled' : 'disabled'}`); await reloadPlugins(); await renderPluginMenus(modal); @@ -1372,13 +1382,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await refreshAvailableThemes(); } catch (e) { console.warn('refreshAvailableThemes failed:', e); } } catch (e) { - if (enabled) { - state.enabled.delete(idLower); - state.disabled.add(idLower); - } else { - state.disabled.delete(idLower); - state.enabled.add(idLower); - } const existingTimer = state.errorTimerById.get(idLower); if (typeof existingTimer === 'number') { window.clearTimeout(existingTimer); @@ -1387,37 +1390,35 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const timer = window.setTimeout(() => { state.errorToggleById.delete(idLower); state.errorTimerById.delete(idLower); - renderDetails(getFiltered()); + updateCounts(); + renderList(); }, 2000); state.errorTimerById.set(idLower, timer); console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } finally { state.pendingToggleById.delete(idLower); - renderDetails(getFiltered()); + updateCounts(); + renderList(); } }; const queuePluginToggle = (pluginIdRaw: string, enabled: boolean) => { const id = String(pluginIdRaw || '').trim().toLowerCase(); if (!id) return; + if (state.pendingToggleById.has(id)) return; const existingTimer = state.errorTimerById.get(id); if (typeof existingTimer === 'number') { window.clearTimeout(existingTimer); state.errorTimerById.delete(id); } state.errorToggleById.delete(id); - if (enabled) { - state.disabled.delete(id); - state.enabled.add(id); - } else { - state.enabled.delete(id); - state.disabled.add(id); - } state.pendingToggleById.set(id, enabled); - updateCounts(); - renderDetails(getFiltered()); - persistSinglePluginToggle(id, enabled).catch(() => {}); + renderList(); + void (async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await persistSinglePluginToggle(id, enabled); + })(); }; if (!(pane as any).__wired) { @@ -1584,7 +1585,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const checkbox = pane.querySelector( `input[type="checkbox"][data-plugin-id="${CSS.escape(id)}"]`, ); - if (checkbox) checkbox.checked = desiredEnabled; + if (checkbox) checkbox.checked = plugin ? pluginIsEnabled(plugin) : false; queuePluginToggle(id, desiredEnabled); return; } @@ -1627,7 +1628,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { if (!el || el.type !== 'checkbox' || !el.dataset.pluginId) return; const id = String(el.dataset.pluginId).trim().toLowerCase(); if (!id) return; - queuePluginToggle(id, el.checked); + const plugin = state.list.find( + (p) => String(p?.id || '').trim().toLowerCase() === id, + ); + const currentEnabled = plugin ? pluginIsEnabled(plugin) : false; + const desiredEnabled = !currentEnabled; + el.checked = currentEnabled; + queuePluginToggle(id, desiredEnabled); }); searchEl.addEventListener('input', () => { From 65353e2525a02a81ff2023ccbe2d16dd7657f87c Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 16:28:25 +0000 Subject: [PATCH 69/96] Update plugin status icons --- Frontend/src/scripts/features/settings.ts | 20 ++++++- Frontend/src/styles/modal/settings.css | 70 ++++++++++++++++++++++- docs/plugins.md | 1 + 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index a4dfddd3..55e57e06 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1276,15 +1276,31 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { main.appendChild(icon); main.appendChild(text); + const checkboxWrap = document.createElement('label'); + checkboxWrap.className = 'plugin-check'; + checkboxWrap.dataset.state = pendingToggle === true + ? 'enabling' + : isEnabledNow + ? 'enabled' + : 'disabled'; + const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.className = 'plugin-check-input'; checkbox.checked = isEnabledNow; checkbox.disabled = typeof pendingToggle === 'boolean'; checkbox.dataset.pluginId = id; checkbox.setAttribute('aria-label', `Enable ${String(plugin.name || '').trim() || 'plugin'}`); + const checkboxUi = document.createElement('span'); + checkboxUi.className = 'plugin-check-ui'; + checkboxUi.setAttribute('aria-hidden', 'true'); + + checkboxWrap.appendChild(checkbox); + checkboxWrap.appendChild(checkboxUi); + li.appendChild(main); - li.appendChild(checkbox); + li.appendChild(checkboxWrap); listEl.appendChild(li); } @@ -1595,7 +1611,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const id = String(row.dataset.plugin || '').trim(); if (!id) return; - const isCheckbox = !!target?.closest('input[type="checkbox"]'); + const isCheckbox = !!target?.closest('.plugin-check'); if (!isCheckbox) { const now = Date.now(); const idKey = id.toLowerCase(); diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index 7c7611e8..c8a1bf9b 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -214,9 +214,77 @@ overflow: hidden; text-overflow: ellipsis; } -.plugin-row input[type="checkbox"]{ +.plugin-check{ + position: relative; + width: 1.1rem; + height: 1.1rem; + flex: 0 0 1.1rem; + display: inline-grid; + place-items: center; +} + +.plugin-check-input{ + -webkit-appearance: none; + appearance: none; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: transparent; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.plugin-check-ui{ + position: relative; width: 1.1rem; height: 1.1rem; + border: 0; + background: transparent; + box-shadow: none; + border-radius: .3rem; + transition: color .15s ease; +} + +.plugin-check-input:focus-visible + .plugin-check-ui{ + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); +} + +.plugin-check[data-state="enabled"] .plugin-check-ui::after{ + content: ""; + position: absolute; + left: 5px; + top: 1px; + width: 4px; + height: 8px; + border: solid color-mix(in oklab, #2aa84a 85%, white 15%); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.plugin-check[data-state="enabling"] .plugin-check-ui::after{ + content: ""; + position: absolute; + inset: 3px; + border-radius: 999px; + border: 2px solid color-mix(in oklab, #2aa84a 50%, transparent); + border-top-color: color-mix(in oklab, #2aa84a 95%, white 5%); + animation: plugin-check-spin .75s linear infinite; +} + +.plugin-check[data-state="enabling"] .plugin-check-input{ + cursor: progress; +} + +.plugin-check-input:disabled{ + cursor: not-allowed; +} + +@keyframes plugin-check-spin { + to { transform: rotate(360deg); } } .plugins-context-menu{ diff --git a/docs/plugins.md b/docs/plugins.md index d6f8f3fd..1890996a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -61,6 +61,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. +- Plugin list checkboxes are tri-state in the UI: disabled, enabled (green check), and enabling (animated pending indicator). - Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. - If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. From 644babd50a00640ca3de09587917e41981eb5cf3 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 16:29:57 +0000 Subject: [PATCH 70/96] Update --- Frontend/src/scripts/features/settings.ts | 33 +++++++---------------- Frontend/src/styles/modal/settings.css | 12 +++++++++ 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 55e57e06..cf9372b4 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -1027,7 +1027,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { enabled: Set; pendingToggleById: Map; errorToggleById: Set; - errorTimerById: Map; query: string; selectedId: string | null; }; @@ -1037,7 +1036,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { enabled: new Set(), pendingToggleById: new Map(), errorToggleById: new Set(), - errorTimerById: new Map(), query: '', selectedId: null, }; @@ -1133,7 +1131,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { toggle.type = 'button'; toggle.className = `tbtn plugin-toggle-btn ${hasToggleError ? 'plugin-toggle-btn-disable' : (isDisablingAction ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable')}`; toggle.id = 'plugins-toggle-selected'; - toggle.disabled = typeof pendingToggle === 'boolean' || hasToggleError; + toggle.disabled = typeof pendingToggle === 'boolean'; toggle.textContent = hasToggleError ? 'Error' : pendingToggle === true @@ -1225,6 +1223,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); const pendingToggle = state.pendingToggleById.get(idLower); + const hasToggleError = state.errorToggleById.has(idLower); const li = document.createElement('li'); li.className = 'plugin-row'; @@ -1278,11 +1277,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const checkboxWrap = document.createElement('label'); checkboxWrap.className = 'plugin-check'; - checkboxWrap.dataset.state = pendingToggle === true - ? 'enabling' - : isEnabledNow - ? 'enabled' - : 'disabled'; + checkboxWrap.dataset.state = hasToggleError + ? 'error' + : pendingToggle === true + ? 'enabling' + : isEnabledNow + ? 'enabled' + : 'disabled'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; @@ -1398,18 +1399,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await refreshAvailableThemes(); } catch (e) { console.warn('refreshAvailableThemes failed:', e); } } catch (e) { - const existingTimer = state.errorTimerById.get(idLower); - if (typeof existingTimer === 'number') { - window.clearTimeout(existingTimer); - } state.errorToggleById.add(idLower); - const timer = window.setTimeout(() => { - state.errorToggleById.delete(idLower); - state.errorTimerById.delete(idLower); - updateCounts(); - renderList(); - }, 2000); - state.errorTimerById.set(idLower, timer); console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } finally { @@ -1423,11 +1413,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const id = String(pluginIdRaw || '').trim().toLowerCase(); if (!id) return; if (state.pendingToggleById.has(id)) return; - const existingTimer = state.errorTimerById.get(id); - if (typeof existingTimer === 'number') { - window.clearTimeout(existingTimer); - state.errorTimerById.delete(id); - } state.errorToggleById.delete(id); state.pendingToggleById.set(id, enabled); renderList(); diff --git a/Frontend/src/styles/modal/settings.css b/Frontend/src/styles/modal/settings.css index c8a1bf9b..166eaa5a 100644 --- a/Frontend/src/styles/modal/settings.css +++ b/Frontend/src/styles/modal/settings.css @@ -279,6 +279,18 @@ cursor: progress; } +.plugin-check[data-state="error"] .plugin-check-ui::after{ + content: "!"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #e35353; + font-size: .95rem; + font-weight: 800; + line-height: 1; +} + .plugin-check-input:disabled{ cursor: not-allowed; } From d51a770b736fe11456ccddfcf0bd248e0f5936da Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 20:01:41 +0000 Subject: [PATCH 71/96] Delete openvcs.official-themes.ovcsp --- Backend/built-in-plugins/openvcs.official-themes.ovcsp | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 Backend/built-in-plugins/openvcs.official-themes.ovcsp diff --git a/Backend/built-in-plugins/openvcs.official-themes.ovcsp b/Backend/built-in-plugins/openvcs.official-themes.ovcsp deleted file mode 100644 index 04d452f5..00000000 --- a/Backend/built-in-plugins/openvcs.official-themes.ovcsp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6930423bd40dc9315a1274756610eb77bbe931b6e4e8f506c697c87cfaac7deb -size 17424 From c218b2bead999cef3eab93303e8ace2e0bfb4d1b Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 20:01:45 +0000 Subject: [PATCH 72/96] Delete openvcs.git.ovcsp --- Backend/built-in-plugins/openvcs.git.ovcsp | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 Backend/built-in-plugins/openvcs.git.ovcsp diff --git a/Backend/built-in-plugins/openvcs.git.ovcsp b/Backend/built-in-plugins/openvcs.git.ovcsp deleted file mode 100644 index 2c27021e..00000000 --- a/Backend/built-in-plugins/openvcs.git.ovcsp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1a6039d5d659cd84df3d0314442e0f840013330903f7b1b8bc5d35ea536b9aad -size 174232 From 0ad5e737d075b684f3df911cc8054a156f89fcaa Mon Sep 17 00:00:00 2001 From: Jordon Date: Sat, 21 Feb 2026 20:50:35 +0000 Subject: [PATCH 73/96] Update --- Backend/src/lib.rs | 1 + .../src/plugin_runtime/component_instance.rs | 66 ++++++++++++------- Backend/src/plugin_runtime/host_api.rs | 56 +++++++++------- Backend/src/plugin_runtime/manager.rs | 63 +++++++++++++++++- Backend/src/tauri_commands/plugins.rs | 33 ++++++++-- Backend/tauri.conf.json | 4 +- Cargo.lock | 10 +++ Cargo.toml | 1 + .../src/scripts/features/pluginPermissions.ts | 2 +- Frontend/src/scripts/features/settings.ts | 48 ++++++++++++-- docs/plugin architecture.md | 2 +- docs/plugins.md | 1 + 12 files changed, 224 insertions(+), 63 deletions(-) diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index e3343d5a..ec56e509 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -276,6 +276,7 @@ fn build_invoke_handler( tauri_commands::list_themes, tauri_commands::load_theme, tauri_commands::list_plugins, + tauri_commands::list_plugin_start_failures, tauri_commands::load_plugin, tauri_commands::install_ovcsp, tauri_commands::list_installed_bundles, diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index c5ba4bc3..bbabaafb 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -3,7 +3,7 @@ use std::sync::OnceLock; use crate::plugin_runtime::host_api::{ - host_emit_event, host_get_status, host_process_exec_git, host_runtime_info, host_set_status, + host_emit_event, host_get_status, host_process_exec, host_runtime_info, host_set_status, host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, }; use crate::plugin_runtime::instance::PluginRuntimeInstance; @@ -390,10 +390,11 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_vcs) } - /// Executes `git` in a constrained host environment. - fn process_exec_git( + /// Executes a command in a constrained host environment. + fn process_exec( &mut self, cwd: Option, + program: String, args: Vec, env: Vec, stdin: Option, @@ -405,9 +406,15 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { .into_iter() .map(|var| (var.key, var.value)) .collect::>(); - let value = - host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) - .map_err(ComponentHostState::map_host_error_vcs)?; + let value = host_process_exec( + &self.spawn, + cwd.as_deref(), + &program, + &args, + &env, + stdin.as_deref(), + ) + .map_err(ComponentHostState::map_host_error_vcs)?; Ok(bindings_vcs::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, status: value.status, @@ -526,10 +533,11 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_plugin) } - /// Executes `git` in a constrained host environment. - fn process_exec_git( + /// Executes a command in a constrained host environment. + fn process_exec( &mut self, cwd: Option, + program: String, args: Vec, env: Vec, stdin: Option, @@ -541,9 +549,15 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .into_iter() .map(|var| (var.key, var.value)) .collect::>(); - let value = - host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) - .map_err(ComponentHostState::map_host_error_plugin)?; + let value = host_process_exec( + &self.spawn, + cwd.as_deref(), + &program, + &args, + &env, + stdin.as_deref(), + ) + .map_err(ComponentHostState::map_host_error_plugin)?; Ok( bindings_plugin::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, @@ -697,10 +711,11 @@ impl bindings_plugin_v1_1::openvcs::plugin::host_api::Host for ComponentHostStat }) } - /// Executes `git` in a constrained host environment. - fn process_exec_git( + /// Executes a command in a constrained host environment. + fn process_exec( &mut self, cwd: Option, + program: String, args: Vec, env: Vec, stdin: Option, @@ -712,14 +727,20 @@ impl bindings_plugin_v1_1::openvcs::plugin::host_api::Host for ComponentHostStat .into_iter() .map(|var| (var.key, var.value)) .collect::>(); - let value = - host_process_exec_git(&self.spawn, cwd.as_deref(), &args, &env, stdin.as_deref()) - .map_err( - |err| bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - }, - )?; + let value = host_process_exec( + &self.spawn, + cwd.as_deref(), + &program, + &args, + &env, + stdin.as_deref(), + ) + .map_err( + |err| bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { + code: err.code, + message: err.message, + }, + )?; Ok( bindings_plugin_v1_1::openvcs::plugin::host_api::ProcessExecOutput { success: value.success, @@ -1493,7 +1514,8 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { } let p: Params = parse_method_params(method, params)?; let out = invoke!("stash_show", call_stash_show, &p.selector)?; - encode_method_result(&self.spawn.plugin_id, method, out) + let normalized = out.lines().map(str::to_string).collect::>(); + encode_method_result(&self.spawn.plugin_id, method, normalized) } "cherry_pick" => { #[derive(serde::Deserialize)] diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 8f101093..0bd3edde 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -26,7 +26,7 @@ static STATUS_EVENT_EMITTER: OnceLock> = OnceLock::new() /// Shared process-wide status text used by plugin status setter/getter calls. static STATUS_TEXT: OnceLock> = OnceLock::new(); -// Whitelisted environment variables that are forwarded to child Git processes. +// Whitelisted environment variables that are forwarded to child processes. const SANITIZED_ENV_KEYS: &[&str] = &[ "HOME", "USER", @@ -135,7 +135,7 @@ fn emit_status_event(message: &str) { /// Host API result type alias for plugin-facing operations. pub(crate) type HostResult = Result; -/// Host-side result for `process-exec-git` mapped into WIT bindings by runtime glue. +/// Host-side result for `process-exec` mapped into WIT bindings by runtime glue. pub(crate) struct HostProcessExecOutput { /// Whether the process exited successfully. pub success: bool, @@ -237,7 +237,7 @@ fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { Ok(result) } -/// Builds a sanitized child-process environment for Git execution. +/// Builds a sanitized child-process environment for command execution. fn sanitized_env() -> Vec<(OsString, OsString)> { let mut out: Vec<(OsString, OsString)> = Vec::new(); @@ -519,27 +519,32 @@ pub fn host_workspace_write_file( Ok(()) } -/// Executes `git` with sanitized environment and capability checks. -pub fn host_process_exec_git( +/// Executes a program with sanitized environment and capability checks. +pub fn host_process_exec( spawn: &SpawnConfig, cwd: Option<&str>, + program: &str, args: &[String], env: &[(String, String)], stdin: Option<&str>, ) -> HostResult { - let _timer = LogTimer::new(MODULE, "host_process_exec_git"); + let _timer = LogTimer::new(MODULE, "host_process_exec"); + let program = program.trim(); + if program.is_empty() { + return Err(host_error("process.error", "program is empty")); + } info!( - "host_process_exec_git: plugin={}, args={:?}", - spawn.plugin_id, args + "host_process_exec: plugin={}, program='{}', args={:?}", + spawn.plugin_id, program, args ); debug!( - "host_process_exec_git: cwd={:?}, env_count={}, has_stdin={}", + "host_process_exec: cwd={:?}, env_count={}, has_stdin={}", cwd, env.len(), stdin.is_some() ); trace!( - "host_process_exec_git: env={:?}, stdin_len={}", + "host_process_exec: env={:?}, stdin_len={}", env, stdin.map(|s| s.len()).unwrap_or(0) ); @@ -548,7 +553,7 @@ pub fn host_process_exec_git( if !caps.contains("process.exec") { warn!( - "host_process_exec_git: capability denied for plugin {} (missing process.exec)", + "host_process_exec: capability denied for plugin {} (missing process.exec)", spawn.plugin_id ); return Err(host_error( @@ -563,14 +568,14 @@ pub fn host_process_exec_git( Some(raw) => { let Some(root) = spawn.allowed_workspace_root.as_ref() else { warn!( - "host_process_exec_git: no workspace context for plugin {}", + "host_process_exec: no workspace context for plugin {}", spawn.plugin_id ); return Err(host_error("workspace.denied", "no workspace context")); }; Some(resolve_under_root(root, raw).map_err(|e| { warn!( - "host_process_exec_git: invalid cwd for plugin {}: {}", + "host_process_exec: invalid cwd for plugin {}: {}", spawn.plugin_id, e ); host_error("workspace.denied", e) @@ -579,13 +584,14 @@ pub fn host_process_exec_git( }; debug!( - "host_process_exec_git: executing git with cwd={:?}", + "host_process_exec: executing '{}' with cwd={:?}", + program, cwd.as_ref().map(|p| p.display()) ); let start = std::time::Instant::now(); - let mut cmd = Command::new("git"); + let mut cmd = Command::new(program); if let Some(cwd) = cwd.as_ref() { cmd.current_dir(cwd); } @@ -603,24 +609,24 @@ pub fn host_process_exec_git( let stdin_text = stdin.unwrap_or_default(); let out = if stdin_text.is_empty() { cmd.output().map_err(|e| { - error!("host_process_exec_git: failed to spawn git: {}", e); - host_error("process.error", format!("spawn git: {e}")) + error!("host_process_exec: failed to spawn '{}': {}", program, e); + host_error("process.error", format!("spawn {program}: {e}")) })? } else { cmd.stdin(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| { - error!("host_process_exec_git: failed to spawn git: {}", e); - host_error("process.error", format!("spawn git: {e}")) + error!("host_process_exec: failed to spawn '{}': {}", program, e); + host_error("process.error", format!("spawn {program}: {e}")) })?; if let Some(mut child_stdin) = child.stdin.take() { if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { let _ = child.kill(); - error!("host_process_exec_git: failed to write stdin: {}", e); + error!("host_process_exec: failed to write stdin: {}", e); return Err(host_error("process.error", format!("write stdin: {e}"))); } } child.wait_with_output().map_err(|e| { - error!("host_process_exec_git: failed to wait for process: {}", e); + error!("host_process_exec: failed to wait for process: {}", e); host_error("process.error", format!("wait: {e}")) })? }; @@ -635,19 +641,21 @@ pub fn host_process_exec_git( if result.success { debug!( - "host_process_exec_git: git {:?} succeeded in {:?} (code={})", + "host_process_exec: '{}' {:?} succeeded in {:?} (code={})", + program, args.first(), elapsed, result.status ); trace!( - "host_process_exec_git: stdout_len={}, stderr_len={}", + "host_process_exec: stdout_len={}, stderr_len={}", result.stdout.len(), result.stderr.len() ); } else { warn!( - "host_process_exec_git: git {:?} failed in {:?} (code={}): {}", + "host_process_exec: '{}' {:?} failed in {:?} (code={}): {}", + program, args.first(), elapsed, result.status, diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 15364e2c..91d60135 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -31,6 +31,8 @@ pub struct PluginRuntimeManager { store: PluginBundleStore, /// Running plugin runtime instances keyed by normalized plugin id. processes: Mutex>, + /// Last known runtime startup failures keyed by normalized plugin id. + start_failures: Mutex>, } /// Runtime handle tracked for a running plugin. @@ -63,9 +65,52 @@ impl PluginRuntimeManager { Self { store, processes: Mutex::new(HashMap::new()), + start_failures: Mutex::new(HashMap::new()), } } + /// Records the latest startup failure for a plugin id. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + /// - `error`: Startup error message. + fn record_start_failure(&self, plugin_id: &str, error: &str) { + let key = plugin_id.trim().to_ascii_lowercase(); + if key.is_empty() { + return; + } + self.start_failures + .lock() + .insert(key, error.trim().to_string()); + } + + /// Clears any tracked startup failure for a plugin id. + /// + /// # Parameters + /// - `plugin_id`: Plugin identifier. + fn clear_start_failure(&self, plugin_id: &str) { + let key = plugin_id.trim().to_ascii_lowercase(); + if key.is_empty() { + return; + } + self.start_failures.lock().remove(&key); + } + + /// Returns plugin ids whose last startup attempt failed. + /// + /// # Returns + /// - Sorted plugin id list for startup failures. + pub fn failed_plugin_starts(&self) -> Vec { + let mut out = self + .start_failures + .lock() + .keys() + .cloned() + .collect::>(); + out.sort(); + out + } + /// Starts a plugin module process if needed. /// /// This operation is idempotent. If the plugin is already running, it @@ -84,7 +129,16 @@ impl PluginRuntimeManager { if let Some(existing) = self.processes.lock().get(&key) { debug!("start_plugin: found existing runtime for key='{}'", key); - return existing.runtime.ensure_running(); + match existing.runtime.ensure_running() { + Ok(()) => { + self.clear_start_failure(plugin_id); + return Ok(()); + } + Err(err) => { + self.record_start_failure(plugin_id, &err); + return Err(err); + } + } } trace!("start_plugin: resolving module runtime spec"); @@ -95,7 +149,11 @@ impl PluginRuntimeManager { ); trace!("start_plugin: starting plugin spec"); - self.start_plugin_spec(spec)?; + if let Err(err) = self.start_plugin_spec(spec) { + self.record_start_failure(plugin_id, &err); + return Err(err); + } + self.clear_start_failure(plugin_id); trace!("start_plugin: completed successfully"); info!("plugin: started '{}'", plugin_id); Ok(()) @@ -127,6 +185,7 @@ impl PluginRuntimeManager { } else { trace!("stop_plugin: no running process found"); } + self.clear_start_failure(plugin_id); Ok(()) } diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index e181fb5b..eec2032d 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,6 +1,8 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::plugin_bundles::{ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; +use crate::plugin_bundles::{ + ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore, +}; use crate::plugin_runtime::settings_store; use crate::plugins; use crate::state::AppState; @@ -61,6 +63,18 @@ pub fn list_plugins() -> Vec { plugins::list_plugins() } +#[tauri::command] +/// Lists plugin ids whose most recent runtime startup attempt failed. +/// +/// # Parameters +/// - `state`: Application state. +/// +/// # Returns +/// - Sorted plugin id list. +pub fn list_plugin_start_failures(state: State<'_, AppState>) -> Vec { + state.plugin_runtime().failed_plugin_starts() +} + #[tauri::command] /// Loads details for a specific plugin id. /// @@ -345,10 +359,8 @@ pub fn set_plugin_permissions( return Err("plugin id is empty".to_string()); } - PluginBundleStore::new_default().set_current_approved_capabilities( - &plugin_id, - approved_capabilities, - )?; + PluginBundleStore::new_default() + .set_current_approved_capabilities(&plugin_id, approved_capabilities)?; if let Err(err) = state.plugin_runtime().stop_plugin(&plugin_id) { warn!( @@ -473,7 +485,10 @@ pub fn list_plugin_menus(state: State<'_, AppState>) -> Result runtime, Ok(None) => { debug!( @@ -511,7 +526,11 @@ pub fn list_plugin_menus(state: State<'_, AppState>) -> Result; pendingToggleById: Map; errorToggleById: Set; + buttonErrorToggleById: Set; + buttonErrorTimerById: Map; query: string; selectedId: string | null; }; @@ -1036,6 +1038,8 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { enabled: new Set(), pendingToggleById: new Map(), errorToggleById: new Set(), + buttonErrorToggleById: new Set(), + buttonErrorTimerById: new Map(), query: '', selectedId: null, }; @@ -1044,6 +1048,23 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { state.enabled = enabled; state.query = String(searchEl.value || '').trim(); + const syncStartFailures = async (): Promise => { + if (!TAURI.has) { + state.errorToggleById.clear(); + return; + } + try { + const failed = await TAURI.invoke('list_plugin_start_failures'); + state.errorToggleById = new Set( + (Array.isArray(failed) ? failed : []) + .map((id) => String(id || '').trim().toLowerCase()) + .filter(Boolean), + ); + } catch (err) { + console.warn('list_plugin_start_failures failed', err); + } + }; + const pluginIsEnabled = (p: PluginSummary): boolean => { const id = String(p?.id || '').trim().toLowerCase(); if (!id) return false; @@ -1098,7 +1119,7 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const idLower = id.toLowerCase(); const isEnabledNow = pluginIsEnabled(plugin); const pendingToggle = state.pendingToggleById.get(idLower); - const hasToggleError = state.errorToggleById.has(idLower); + const hasButtonError = state.buttonErrorToggleById.has(idLower); const isDisablingAction = typeof pendingToggle === 'boolean' ? pendingToggle === false : isEnabledNow; const version = String(plugin.version || '').trim(); const author = String(plugin.author || '').trim(); @@ -1129,10 +1150,10 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { actions.className = 'plugin-detail-actions'; const toggle = document.createElement('button'); toggle.type = 'button'; - toggle.className = `tbtn plugin-toggle-btn ${hasToggleError ? 'plugin-toggle-btn-disable' : (isDisablingAction ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable')}`; + toggle.className = `tbtn plugin-toggle-btn ${(hasButtonError || isDisablingAction) ? 'plugin-toggle-btn-disable' : 'plugin-toggle-btn-enable'}`; toggle.id = 'plugins-toggle-selected'; - toggle.disabled = typeof pendingToggle === 'boolean'; - toggle.textContent = hasToggleError + toggle.disabled = typeof pendingToggle === 'boolean' || hasButtonError; + toggle.textContent = hasButtonError ? 'Error' : pendingToggle === true ? 'Enabling...' @@ -1324,11 +1345,13 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } state.list = Array.isArray(list) ? list : []; + await syncStartFailures(); ensureSelection(getFiltered()); renderList(); updateCounts(); } + await syncStartFailures(); ensureSelection(getFiltered()); (modal as any)[stateKey] = state; renderList(); @@ -1400,6 +1423,17 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { } catch (e) { console.warn('refreshAvailableThemes failed:', e); } } catch (e) { state.errorToggleById.add(idLower); + const existingTimer = state.buttonErrorTimerById.get(idLower); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + } + state.buttonErrorToggleById.add(idLower); + const timer = window.setTimeout(() => { + state.buttonErrorToggleById.delete(idLower); + state.buttonErrorTimerById.delete(idLower); + renderDetails(getFiltered()); + }, 2000); + state.buttonErrorTimerById.set(idLower, timer); console.error('Failed to toggle plugin:', e); notify('Failed to toggle plugin'); } finally { @@ -1413,6 +1447,12 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const id = String(pluginIdRaw || '').trim().toLowerCase(); if (!id) return; if (state.pendingToggleById.has(id)) return; + const existingTimer = state.buttonErrorTimerById.get(id); + if (typeof existingTimer === 'number') { + window.clearTimeout(existingTimer); + state.buttonErrorTimerById.delete(id); + } + state.buttonErrorToggleById.delete(id); state.errorToggleById.delete(id); state.pendingToggleById.set(id, enabled); renderList(); diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 6c93732f..7c71b0f3 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -15,7 +15,7 @@ Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) The authoritative host/plugin contract lives under `Core/wit/`: -- `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, git process exec, notifications, logging, events) +- `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, process exec, notifications, logging, events) - `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) plus v1.1 plugin UI/settings world (`plugin-v1-1`) - `Core/wit/vcs.wit`: VCS backend world (`vcs`) diff --git a/docs/plugins.md b/docs/plugins.md index 1890996a..990d795c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -62,6 +62,7 @@ Notes: - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. - Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. - Plugin list checkboxes are tri-state in the UI: disabled, enabled (green check), and enabling (animated pending indicator). +- If plugin runtime startup fails (including startup sync on app launch), the plugin list shows a persistent red `!` marker for that plugin until the next retry. - Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. - If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. From 44320d3c07041acaa11289040b411c1982aa8484 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 08:17:18 +0000 Subject: [PATCH 74/96] Update --- Backend/built-in-plugins/Git | 2 +- Backend/src/lib.rs | 12 +- .../src/plugin_runtime/component_instance.rs | 1259 ++++++++------- Backend/src/plugin_runtime/instance.rs | 4 - Backend/src/plugin_runtime/manager.rs | 144 +- Backend/src/plugin_runtime/runtime_select.rs | 16 +- Backend/src/plugin_runtime/vcs_proxy.rs | 1369 +++-------------- Backend/src/plugin_vcs_backends.rs | 31 +- Backend/src/tauri_commands/backends.rs | 101 +- Backend/src/tauri_commands/plugins.rs | 77 - Frontend/src/scripts/plugins.ts | 20 - Frontend/src/scripts/state/state.ts | 4 +- docs/plugin architecture.md | 1 + docs/plugins.md | 1 + 14 files changed, 1055 insertions(+), 1986 deletions(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index b5115dd7..7239f6da 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit b5115dd796803115ee06e82bb16cb54c0da7a264 +Subproject commit 7239f6da7d1c6aec08dd79abc2bededf45f80972 diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index ec56e509..974bf1d0 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -132,10 +132,6 @@ pub fn run() { } }); - let store = crate::plugin_bundles::PluginBundleStore::new_default(); - if let Err(err) = store.sync_built_in_plugins() { - warn!("plugins: failed to sync built-in bundles: {}", err); - } // If the application bundle includes a `built-in-plugins` resource // directory, resolve its location via Tauri and register the // containing resource directory so runtime discovery can include @@ -158,6 +154,10 @@ pub fn run() { ); } } + let store = crate::plugin_bundles::PluginBundleStore::new_default(); + if let Err(err) = store.sync_built_in_plugins() { + warn!("plugins: failed to sync built-in bundles: {}", err); + } let state = app.state::(); if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { warn!("plugins: failed to sync runtime on startup: {}", err); @@ -220,7 +220,6 @@ fn build_invoke_handler( tauri_commands::list_vcs_backends_cmd, tauri_commands::set_vcs_backend_cmd, tauri_commands::reopen_current_repo_cmd, - tauri_commands::call_vcs_backend_method, tauri_commands::validate_git_url, tauri_commands::validate_add_path, tauri_commands::validate_clone_input, @@ -285,9 +284,6 @@ fn build_invoke_handler( tauri_commands::approve_plugin_capabilities, tauri_commands::get_plugin_permissions, tauri_commands::set_plugin_permissions, - tauri_commands::list_plugin_functions, - tauri_commands::invoke_plugin_function, - tauri_commands::call_plugin_module_method, tauri_commands::list_plugin_menus, tauri_commands::invoke_plugin_action, tauri_commands::save_plugin_settings, diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index bbabaafb..2e50c2da 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -9,12 +9,13 @@ use crate::plugin_runtime::host_api::{ use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::settings_store; use crate::plugin_runtime::spawn::SpawnConfig; +use openvcs_core::models::{ + BranchItem, BranchKind, Capabilities, CommitItem, ConflictDetails, ConflictSide, FetchOptions, + LogQuery, StashItem, StatusPayload, StatusSummary, +}; use openvcs_core::settings::{SettingKv, SettingValue}; use openvcs_core::ui::{Menu, UiButton, UiElement, UiText}; use parking_lot::Mutex; -use serde::de::DeserializeOwned; -use serde::Serialize; -use serde_json::Value; use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::{Cache, CacheConfig, Config, Engine, Store}; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; @@ -904,6 +905,710 @@ impl ComponentPluginRuntimeInstance { })?; f(runtime) } + + /// Ensures the runtime exports the VCS world and runs a typed call. + fn with_vcs_bindings( + &self, + method: &str, + f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, + ) -> Result { + self.with_runtime(|runtime| { + let bindings = match &runtime.bindings { + ComponentBindings::Vcs(bindings) => bindings, + _ => { + return Err(format!( + "component method `{method}` requires VCS backend exports for plugin `{}`", + self.spawn.plugin_id + )); + } + }; + f(bindings, &mut runtime.store) + }) + } + + /// Converts nested trap/plugin results into backend error strings. + fn map_vcs_result( + &self, + method: &str, + out: Result, E>, + ) -> Result { + out.map_err(|e| { + format!( + "component call trap for {}.{}: {e}", + self.spawn.plugin_id, method + ) + })? + .map_err(|e| { + format!( + "component call failed for {}.{}: {}: {}", + self.spawn.plugin_id, method, e.code, e.message + ) + }) + } + + /// Calls typed `get-caps`. + pub fn vcs_get_caps(&self) -> Result { + self.with_vcs_bindings("caps", |bindings, store| { + let out = self.map_vcs_result( + "caps", + bindings.openvcs_plugin_vcs_api().call_get_caps(store), + )?; + Ok(Capabilities { + commits: out.commits, + branches: out.branches, + tags: out.tags, + staging: out.staging, + push_pull: out.push_pull, + fast_forward: out.fast_forward, + }) + }) + } + + /// Calls typed `open`. + pub fn vcs_open(&self, path: &str, config: &[u8]) -> Result<(), String> { + self.with_vcs_bindings("open", |bindings, store| { + self.map_vcs_result( + "open", + bindings + .openvcs_plugin_vcs_api() + .call_open(store, path, config), + ) + }) + } + + /// Calls typed `get-current-branch`. + pub fn vcs_get_current_branch(&self) -> Result, String> { + self.with_vcs_bindings("current_branch", |bindings, store| { + self.map_vcs_result( + "current_branch", + bindings + .openvcs_plugin_vcs_api() + .call_get_current_branch(store), + ) + }) + } + + /// Calls typed `list-branches`. + pub fn vcs_list_branches(&self) -> Result, String> { + self.with_vcs_bindings("branches", |bindings, store| { + let out = self.map_vcs_result( + "branches", + bindings.openvcs_plugin_vcs_api().call_list_branches(store), + )?; + Ok(out + .into_iter() + .map(|item| BranchItem { + name: item.name, + full_ref: item.full_ref, + kind: match item.kind { + vcs_api::BranchKind::Local => BranchKind::Local, + vcs_api::BranchKind::Remote(remote) => BranchKind::Remote { remote }, + vcs_api::BranchKind::Unknown => BranchKind::Unknown, + }, + current: item.current, + }) + .collect()) + }) + } + + /// Calls typed `list-local-branches`. + pub fn vcs_list_local_branches(&self) -> Result, String> { + self.with_vcs_bindings("local_branches", |bindings, store| { + self.map_vcs_result( + "local_branches", + bindings + .openvcs_plugin_vcs_api() + .call_list_local_branches(store), + ) + }) + } + + /// Calls typed `create-branch`. + pub fn vcs_create_branch(&self, name: &str, checkout: bool) -> Result<(), String> { + self.with_vcs_bindings("create_branch", |bindings, store| { + self.map_vcs_result( + "create_branch", + bindings + .openvcs_plugin_vcs_api() + .call_create_branch(store, name, checkout), + ) + }) + } + + /// Calls typed `checkout-branch`. + pub fn vcs_checkout_branch(&self, name: &str) -> Result<(), String> { + self.with_vcs_bindings("checkout_branch", |bindings, store| { + self.map_vcs_result( + "checkout_branch", + bindings + .openvcs_plugin_vcs_api() + .call_checkout_branch(store, name), + ) + }) + } + + /// Calls typed `ensure-remote`. + pub fn vcs_ensure_remote(&self, name: &str, url: &str) -> Result<(), String> { + self.with_vcs_bindings("ensure_remote", |bindings, store| { + self.map_vcs_result( + "ensure_remote", + bindings + .openvcs_plugin_vcs_api() + .call_ensure_remote(store, name, url), + ) + }) + } + + /// Calls typed `list-remotes`. + pub fn vcs_list_remotes(&self) -> Result, String> { + self.with_vcs_bindings("list_remotes", |bindings, store| { + let out = self.map_vcs_result( + "list_remotes", + bindings.openvcs_plugin_vcs_api().call_list_remotes(store), + )?; + Ok(out + .into_iter() + .map(|entry| (entry.name, entry.url)) + .collect()) + }) + } + + /// Calls typed `remove-remote`. + pub fn vcs_remove_remote(&self, name: &str) -> Result<(), String> { + self.with_vcs_bindings("remove_remote", |bindings, store| { + self.map_vcs_result( + "remove_remote", + bindings + .openvcs_plugin_vcs_api() + .call_remove_remote(store, name), + ) + }) + } + + /// Calls typed `fetch`. + pub fn vcs_fetch(&self, remote: &str, refspec: &str) -> Result<(), String> { + self.with_vcs_bindings("fetch", |bindings, store| { + self.map_vcs_result( + "fetch", + bindings + .openvcs_plugin_vcs_api() + .call_fetch(store, remote, refspec), + ) + }) + } + + /// Calls typed `fetch-with-options`. + pub fn vcs_fetch_with_options( + &self, + remote: &str, + refspec: &str, + opts: FetchOptions, + ) -> Result<(), String> { + self.with_vcs_bindings("fetch_with_options", |bindings, store| { + self.map_vcs_result( + "fetch_with_options", + bindings.openvcs_plugin_vcs_api().call_fetch_with_options( + store, + remote, + refspec, + vcs_api::FetchOptions { prune: opts.prune }, + ), + ) + }) + } + + /// Calls typed `push`. + pub fn vcs_push(&self, remote: &str, refspec: &str) -> Result<(), String> { + self.with_vcs_bindings("push", |bindings, store| { + self.map_vcs_result( + "push", + bindings + .openvcs_plugin_vcs_api() + .call_push(store, remote, refspec), + ) + }) + } + + /// Calls typed `pull-ff-only`. + pub fn vcs_pull_ff_only(&self, remote: &str, branch: &str) -> Result<(), String> { + self.with_vcs_bindings("pull_ff_only", |bindings, store| { + self.map_vcs_result( + "pull_ff_only", + bindings + .openvcs_plugin_vcs_api() + .call_pull_ff_only(store, remote, branch), + ) + }) + } + + /// Calls typed `commit`. + pub fn vcs_commit( + &self, + message: &str, + name: &str, + email: &str, + paths: &[String], + ) -> Result { + self.with_vcs_bindings("commit", |bindings, store| { + self.map_vcs_result( + "commit", + bindings + .openvcs_plugin_vcs_api() + .call_commit(store, message, name, email, paths), + ) + }) + } + + /// Calls typed `commit-index`. + pub fn vcs_commit_index( + &self, + message: &str, + name: &str, + email: &str, + ) -> Result { + self.with_vcs_bindings("commit_index", |bindings, store| { + self.map_vcs_result( + "commit_index", + bindings + .openvcs_plugin_vcs_api() + .call_commit_index(store, message, name, email), + ) + }) + } + + /// Calls typed `get-status-summary`. + pub fn vcs_get_status_summary(&self) -> Result { + self.with_vcs_bindings("status_summary", |bindings, store| { + let out = self.map_vcs_result( + "status_summary", + bindings + .openvcs_plugin_vcs_api() + .call_get_status_summary(store), + )?; + Ok(StatusSummary { + untracked: out.untracked as usize, + modified: out.modified as usize, + staged: out.staged as usize, + conflicted: out.conflicted as usize, + }) + }) + } + + /// Calls typed `get-status-payload`. + pub fn vcs_get_status_payload(&self) -> Result { + self.with_vcs_bindings("status_payload", |bindings, store| { + let out = self.map_vcs_result( + "status_payload", + bindings + .openvcs_plugin_vcs_api() + .call_get_status_payload(store), + )?; + Ok(StatusPayload { + files: out + .files + .into_iter() + .map(|file| openvcs_core::models::FileEntry { + path: file.path, + old_path: file.old_path, + status: file.status, + staged: file.staged, + resolved_conflict: file.resolved_conflict, + hunks: file.hunks, + }) + .collect(), + ahead: out.ahead, + behind: out.behind, + }) + }) + } + + /// Calls typed `list-commits`. + pub fn vcs_list_commits(&self, query: &LogQuery) -> Result, String> { + self.with_vcs_bindings("log_commits", |bindings, store| { + let query = vcs_api::LogQuery { + rev: query.rev.clone(), + path: query.path.clone(), + since_utc: query.since_utc.clone(), + until_utc: query.until_utc.clone(), + author_contains: query.author_contains.clone(), + skip: query.skip, + limit: query.limit, + topo_order: query.topo_order, + include_merges: query.include_merges, + }; + let out = self.map_vcs_result( + "log_commits", + bindings + .openvcs_plugin_vcs_api() + .call_list_commits(store, &query), + )?; + Ok(out + .into_iter() + .map(|commit| CommitItem { + id: commit.id, + msg: commit.msg, + meta: commit.meta, + author: commit.author, + }) + .collect()) + }) + } + + /// Calls typed `diff-file`. + pub fn vcs_diff_file(&self, path: &str) -> Result, String> { + self.with_vcs_bindings("diff_file", |bindings, store| { + self.map_vcs_result( + "diff_file", + bindings + .openvcs_plugin_vcs_api() + .call_diff_file(store, path), + ) + }) + } + + /// Calls typed `diff-commit`. + pub fn vcs_diff_commit(&self, rev: &str) -> Result, String> { + self.with_vcs_bindings("diff_commit", |bindings, store| { + self.map_vcs_result( + "diff_commit", + bindings + .openvcs_plugin_vcs_api() + .call_diff_commit(store, rev), + ) + }) + } + + /// Calls typed `get-conflict-details`. + pub fn vcs_get_conflict_details(&self, path: &str) -> Result { + self.with_vcs_bindings("conflict_details", |bindings, store| { + let out = self.map_vcs_result( + "conflict_details", + bindings + .openvcs_plugin_vcs_api() + .call_get_conflict_details(store, path), + )?; + Ok(ConflictDetails { + path: out.path, + ours: out.ours, + theirs: out.theirs, + base: out.base, + binary: out.binary, + lfs_pointer: out.lfs_pointer, + }) + }) + } + + /// Calls typed `checkout-conflict-side`. + pub fn vcs_checkout_conflict_side(&self, path: &str, side: ConflictSide) -> Result<(), String> { + self.with_vcs_bindings("checkout_conflict_side", |bindings, store| { + let side = match side { + ConflictSide::Ours => vcs_api::ConflictSide::Ours, + ConflictSide::Theirs => vcs_api::ConflictSide::Theirs, + }; + self.map_vcs_result( + "checkout_conflict_side", + bindings + .openvcs_plugin_vcs_api() + .call_checkout_conflict_side(store, path, side), + ) + }) + } + + /// Calls typed `write-merge-result`. + pub fn vcs_write_merge_result(&self, path: &str, content: &[u8]) -> Result<(), String> { + self.with_vcs_bindings("write_merge_result", |bindings, store| { + self.map_vcs_result( + "write_merge_result", + bindings + .openvcs_plugin_vcs_api() + .call_write_merge_result(store, path, content), + ) + }) + } + + /// Calls typed `stage-patch`. + pub fn vcs_stage_patch(&self, patch: &str) -> Result<(), String> { + self.with_vcs_bindings("stage_patch", |bindings, store| { + self.map_vcs_result( + "stage_patch", + bindings + .openvcs_plugin_vcs_api() + .call_stage_patch(store, patch), + ) + }) + } + + /// Calls typed `discard-paths`. + pub fn vcs_discard_paths(&self, paths: &[String]) -> Result<(), String> { + self.with_vcs_bindings("discard_paths", |bindings, store| { + self.map_vcs_result( + "discard_paths", + bindings + .openvcs_plugin_vcs_api() + .call_discard_paths(store, paths), + ) + }) + } + + /// Calls typed `apply-reverse-patch`. + pub fn vcs_apply_reverse_patch(&self, patch: &str) -> Result<(), String> { + self.with_vcs_bindings("apply_reverse_patch", |bindings, store| { + self.map_vcs_result( + "apply_reverse_patch", + bindings + .openvcs_plugin_vcs_api() + .call_apply_reverse_patch(store, patch), + ) + }) + } + + /// Calls typed `delete-branch`. + pub fn vcs_delete_branch(&self, name: &str, force: bool) -> Result<(), String> { + self.with_vcs_bindings("delete_branch", |bindings, store| { + self.map_vcs_result( + "delete_branch", + bindings + .openvcs_plugin_vcs_api() + .call_delete_branch(store, name, force), + ) + }) + } + + /// Calls typed `rename-branch`. + pub fn vcs_rename_branch(&self, old: &str, new: &str) -> Result<(), String> { + self.with_vcs_bindings("rename_branch", |bindings, store| { + self.map_vcs_result( + "rename_branch", + bindings + .openvcs_plugin_vcs_api() + .call_rename_branch(store, old, new), + ) + }) + } + + /// Calls typed `merge-into-current`. + pub fn vcs_merge_into_current(&self, name: &str, message: Option<&str>) -> Result<(), String> { + self.with_vcs_bindings("merge_into_current", |bindings, store| { + self.map_vcs_result( + "merge_into_current", + bindings + .openvcs_plugin_vcs_api() + .call_merge_into_current(store, name, message), + ) + }) + } + + /// Calls typed `merge-abort`. + pub fn vcs_merge_abort(&self) -> Result<(), String> { + self.with_vcs_bindings("merge_abort", |bindings, store| { + self.map_vcs_result( + "merge_abort", + bindings.openvcs_plugin_vcs_api().call_merge_abort(store), + ) + }) + } + + /// Calls typed `merge-continue`. + pub fn vcs_merge_continue(&self) -> Result<(), String> { + self.with_vcs_bindings("merge_continue", |bindings, store| { + self.map_vcs_result( + "merge_continue", + bindings.openvcs_plugin_vcs_api().call_merge_continue(store), + ) + }) + } + + /// Calls typed `is-merge-in-progress`. + pub fn vcs_is_merge_in_progress(&self) -> Result { + self.with_vcs_bindings("merge_in_progress", |bindings, store| { + self.map_vcs_result( + "merge_in_progress", + bindings + .openvcs_plugin_vcs_api() + .call_is_merge_in_progress(store), + ) + }) + } + + /// Calls typed `set-branch-upstream`. + pub fn vcs_set_branch_upstream(&self, branch: &str, upstream: &str) -> Result<(), String> { + self.with_vcs_bindings("set_branch_upstream", |bindings, store| { + self.map_vcs_result( + "set_branch_upstream", + bindings + .openvcs_plugin_vcs_api() + .call_set_branch_upstream(store, branch, upstream), + ) + }) + } + + /// Calls typed `get-branch-upstream`. + pub fn vcs_get_branch_upstream(&self, branch: &str) -> Result, String> { + self.with_vcs_bindings("branch_upstream", |bindings, store| { + self.map_vcs_result( + "branch_upstream", + bindings + .openvcs_plugin_vcs_api() + .call_get_branch_upstream(store, branch), + ) + }) + } + + /// Calls typed `hard-reset-head`. + pub fn vcs_hard_reset_head(&self) -> Result<(), String> { + self.with_vcs_bindings("hard_reset_head", |bindings, store| { + self.map_vcs_result( + "hard_reset_head", + bindings + .openvcs_plugin_vcs_api() + .call_hard_reset_head(store), + ) + }) + } + + /// Calls typed `reset-soft-to`. + pub fn vcs_reset_soft_to(&self, rev: &str) -> Result<(), String> { + self.with_vcs_bindings("reset_soft_to", |bindings, store| { + self.map_vcs_result( + "reset_soft_to", + bindings + .openvcs_plugin_vcs_api() + .call_reset_soft_to(store, rev), + ) + }) + } + + /// Calls typed `get-identity`. + pub fn vcs_get_identity(&self) -> Result, String> { + self.with_vcs_bindings("get_identity", |bindings, store| { + let out = self.map_vcs_result( + "get_identity", + bindings.openvcs_plugin_vcs_api().call_get_identity(store), + )?; + Ok(out.map(|id| (id.name, id.email))) + }) + } + + /// Calls typed `set-identity-local`. + pub fn vcs_set_identity_local(&self, name: &str, email: &str) -> Result<(), String> { + self.with_vcs_bindings("set_identity_local", |bindings, store| { + self.map_vcs_result( + "set_identity_local", + bindings + .openvcs_plugin_vcs_api() + .call_set_identity_local(store, name, email), + ) + }) + } + + /// Calls typed `list-stashes`. + pub fn vcs_list_stashes(&self) -> Result, String> { + self.with_vcs_bindings("stash_list", |bindings, store| { + let out = self.map_vcs_result( + "stash_list", + bindings.openvcs_plugin_vcs_api().call_list_stashes(store), + )?; + Ok(out + .into_iter() + .map(|stash| StashItem { + selector: stash.selector, + msg: stash.msg, + meta: stash.meta, + }) + .collect()) + }) + } + + /// Calls typed `stash-push`. + pub fn vcs_stash_push( + &self, + message: Option<&str>, + include_untracked: bool, + ) -> Result { + self.with_vcs_bindings("stash_push", |bindings, store| { + self.map_vcs_result( + "stash_push", + bindings.openvcs_plugin_vcs_api().call_stash_push( + store, + message, + include_untracked, + ), + ) + }) + } + + /// Calls typed `stash-apply`. + pub fn vcs_stash_apply(&self, selector: &str) -> Result<(), String> { + self.with_vcs_bindings("stash_apply", |bindings, store| { + self.map_vcs_result( + "stash_apply", + bindings + .openvcs_plugin_vcs_api() + .call_stash_apply(store, selector), + ) + }) + } + + /// Calls typed `stash-pop`. + pub fn vcs_stash_pop(&self, selector: &str) -> Result<(), String> { + self.with_vcs_bindings("stash_pop", |bindings, store| { + self.map_vcs_result( + "stash_pop", + bindings + .openvcs_plugin_vcs_api() + .call_stash_pop(store, selector), + ) + }) + } + + /// Calls typed `stash-drop`. + pub fn vcs_stash_drop(&self, selector: &str) -> Result<(), String> { + self.with_vcs_bindings("stash_drop", |bindings, store| { + self.map_vcs_result( + "stash_drop", + bindings + .openvcs_plugin_vcs_api() + .call_stash_drop(store, selector), + ) + }) + } + + /// Calls typed `stash-show`. + pub fn vcs_stash_show(&self, selector: &str) -> Result, String> { + self.with_vcs_bindings("stash_show", |bindings, store| { + let out = self.map_vcs_result( + "stash_show", + bindings + .openvcs_plugin_vcs_api() + .call_stash_show(store, selector), + )?; + Ok(out.lines().map(str::to_string).collect()) + }) + } + + /// Calls typed `cherry-pick`. + pub fn vcs_cherry_pick(&self, commit: &str) -> Result<(), String> { + self.with_vcs_bindings("cherry_pick", |bindings, store| { + self.map_vcs_result( + "cherry_pick", + bindings + .openvcs_plugin_vcs_api() + .call_cherry_pick(store, commit), + ) + }) + } + + /// Calls typed `revert-commit`. + pub fn vcs_revert_commit(&self, commit: &str, no_edit: bool) -> Result<(), String> { + self.with_vcs_bindings("revert_commit", |bindings, store| { + self.map_vcs_result( + "revert_commit", + bindings + .openvcs_plugin_vcs_api() + .call_revert_commit(store, commit, no_edit), + ) + }) + } } /// Converts a v1.1 WIT menu into the shared core menu model. @@ -999,25 +1704,6 @@ fn setting_from_json_value( } } -/// Deserializes JSON RPC parameters for a named method. -fn parse_method_params(method: &str, params: Value) -> Result { - serde_json::from_value(params).map_err(|e| format!("invalid params for `{method}`: {e}")) -} - -/// Serializes a method result to JSON with contextual error reporting. -fn encode_method_result( - plugin_id: &str, - method: &str, - value: T, -) -> Result { - serde_json::to_value(value).map_err(|e| { - format!( - "serialize component result for `{}` method `{}`: {e}", - plugin_id, method - ) - }) -} - impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { /// Starts the component runtime when not already running. fn ensure_running(&self) -> Result<(), String> { @@ -1030,535 +1716,6 @@ impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { Ok(()) } - #[allow(clippy::let_unit_value)] - /// Invokes a v1 VCS ABI method exported by the plugin component. - /// - /// Non-VCS plugins only expose lifecycle hooks (`init`/`deinit`) and return - /// an error for VCS RPC method calls. - fn call(&self, method: &str, params: Value) -> Result { - self.with_runtime(|runtime| { - let bindings = match &runtime.bindings { - ComponentBindings::Vcs(bindings) => bindings, - ComponentBindings::Plugin(_) => { - return Err(format!( - "component method `{method}` requires VCS backend exports for plugin `{}`", - self.spawn.plugin_id - )); - } - ComponentBindings::PluginV11(_) => { - return Err(format!( - "component method `{method}` requires VCS backend exports for plugin `{}`", - self.spawn.plugin_id - )); - } - }; - - macro_rules! invoke { - ($method_name:literal, $call:ident $(, $arg:expr )* ) => { - bindings - .openvcs_plugin_vcs_api() - .$call(&mut runtime.store $(, $arg )* ) - .map_err(|e| { - format!( - "component call trap for {}.{}: {e}", - self.spawn.plugin_id, $method_name - ) - })? - .map_err(|e| { - format!( - "component call failed for {}.{}: {}: {}", - self.spawn.plugin_id, $method_name, e.code, e.message - ) - }) - }; - } - - match method { - "caps" => { - let out = invoke!("caps", call_get_caps)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "open" => { - #[derive(serde::Deserialize)] - struct Params { - path: String, - #[serde(default)] - config: Value, - } - let p: Params = parse_method_params(method, params)?; - let config = serde_json::to_vec(&p.config) - .map_err(|e| format!("serialize `open` config: {e}"))?; - let out = invoke!("open", call_open, &p.path, &config)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "clone" => { - #[derive(serde::Deserialize)] - struct Params { - url: String, - dest: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("clone", call_clone_repo, &p.url, &p.dest)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "workdir" => { - let out = invoke!("workdir", call_get_workdir)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "current_branch" => { - let out = invoke!("current_branch", call_get_current_branch)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "branches" => { - let out = invoke!("branches", call_list_branches)?; - let normalized = out - .into_iter() - .map(|item| { - let kind = match item.kind { - vcs_api::BranchKind::Local => { - serde_json::json!({ "type": "Local" }) - } - vcs_api::BranchKind::Remote(remote) => { - serde_json::json!({ "type": "Remote", "remote": remote }) - } - vcs_api::BranchKind::Unknown => { - serde_json::json!({ "type": "Unknown" }) - } - }; - serde_json::json!({ - "name": item.name, - "full_ref": item.full_ref, - "kind": kind, - "current": item.current, - }) - }) - .collect::>(); - encode_method_result(&self.spawn.plugin_id, method, normalized) - } - "local_branches" => { - let out = invoke!("local_branches", call_list_local_branches)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "create_branch" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - checkout: bool, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("create_branch", call_create_branch, &p.name, p.checkout)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "checkout_branch" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("checkout_branch", call_checkout_branch, &p.name)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "ensure_remote" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - url: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("ensure_remote", call_ensure_remote, &p.name, &p.url)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "list_remotes" => { - let out = invoke!("list_remotes", call_list_remotes)?; - let remotes = out - .into_iter() - .map(|entry| (entry.name, entry.url)) - .collect::>(); - encode_method_result(&self.spawn.plugin_id, method, remotes) - } - "remove_remote" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("remove_remote", call_remove_remote, &p.name)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "fetch" => { - #[derive(serde::Deserialize)] - struct Params { - remote: String, - refspec: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("fetch", call_fetch, &p.remote, &p.refspec)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "fetch_with_options" => { - #[derive(serde::Deserialize)] - struct Params { - remote: String, - refspec: String, - opts: vcs_api::FetchOptions, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "fetch_with_options", - call_fetch_with_options, - &p.remote, - &p.refspec, - p.opts - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "push" => { - #[derive(serde::Deserialize)] - struct Params { - remote: String, - refspec: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("push", call_push, &p.remote, &p.refspec)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "pull_ff_only" => { - #[derive(serde::Deserialize)] - struct Params { - remote: String, - branch: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("pull_ff_only", call_pull_ff_only, &p.remote, &p.branch)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "commit" => { - #[derive(serde::Deserialize)] - struct Params { - message: String, - name: String, - email: String, - paths: Vec, - } - let p: Params = parse_method_params(method, params)?; - let out = - invoke!("commit", call_commit, &p.message, &p.name, &p.email, &p.paths)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "commit_index" => { - #[derive(serde::Deserialize)] - struct Params { - message: String, - name: String, - email: String, - } - let p: Params = parse_method_params(method, params)?; - let out = - invoke!("commit_index", call_commit_index, &p.message, &p.name, &p.email)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "status_summary" => { - let out = invoke!("status_summary", call_get_status_summary)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "status_payload" => { - let out = invoke!("status_payload", call_get_status_payload)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "log_commits" => { - #[derive(serde::Deserialize)] - struct Params { - query: vcs_api::LogQuery, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("log_commits", call_list_commits, &p.query)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "diff_file" => { - #[derive(serde::Deserialize)] - struct Params { - path: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("diff_file", call_diff_file, &p.path)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "diff_commit" => { - #[derive(serde::Deserialize)] - struct Params { - rev: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("diff_commit", call_diff_commit, &p.rev)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "conflict_details" => { - #[derive(serde::Deserialize)] - struct Params { - path: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("conflict_details", call_get_conflict_details, &p.path)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "checkout_conflict_side" => { - #[derive(serde::Deserialize)] - struct Params { - path: String, - side: vcs_api::ConflictSide, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "checkout_conflict_side", - call_checkout_conflict_side, - &p.path, - p.side - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "write_merge_result" => { - #[derive(serde::Deserialize)] - struct Params { - path: String, - content: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "write_merge_result", - call_write_merge_result, - &p.path, - p.content.as_bytes() - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stage_patch" => { - #[derive(serde::Deserialize)] - struct Params { - patch: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("stage_patch", call_stage_patch, &p.patch)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "discard_paths" => { - #[derive(serde::Deserialize)] - struct Params { - paths: Vec, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("discard_paths", call_discard_paths, &p.paths)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "apply_reverse_patch" => { - #[derive(serde::Deserialize)] - struct Params { - patch: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("apply_reverse_patch", call_apply_reverse_patch, &p.patch)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "delete_branch" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - force: bool, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("delete_branch", call_delete_branch, &p.name, p.force)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "rename_branch" => { - #[derive(serde::Deserialize)] - struct Params { - old: String, - new: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("rename_branch", call_rename_branch, &p.old, &p.new)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "merge_into_current" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - #[serde(default)] - message: Option, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "merge_into_current", - call_merge_into_current, - &p.name, - p.message.as_deref() - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "merge_abort" => { - let out = invoke!("merge_abort", call_merge_abort)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "merge_continue" => { - let out = invoke!("merge_continue", call_merge_continue)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "merge_in_progress" => { - let out = invoke!("merge_in_progress", call_is_merge_in_progress)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "set_branch_upstream" => { - #[derive(serde::Deserialize)] - struct Params { - branch: String, - upstream: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "set_branch_upstream", - call_set_branch_upstream, - &p.branch, - &p.upstream - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "branch_upstream" => { - #[derive(serde::Deserialize)] - struct Params { - branch: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("branch_upstream", call_get_branch_upstream, &p.branch)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "hard_reset_head" => { - let out = invoke!("hard_reset_head", call_hard_reset_head)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "reset_soft_to" => { - #[derive(serde::Deserialize)] - struct Params { - rev: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("reset_soft_to", call_reset_soft_to, &p.rev)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "get_identity" => { - let out = invoke!("get_identity", call_get_identity)?; - let mapped = out.map(|identity| (identity.name, identity.email)); - encode_method_result(&self.spawn.plugin_id, method, mapped) - } - "set_identity_local" => { - #[derive(serde::Deserialize)] - struct Params { - name: String, - email: String, - } - let p: Params = parse_method_params(method, params)?; - let out = - invoke!("set_identity_local", call_set_identity_local, &p.name, &p.email)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_list" => { - let out = invoke!("stash_list", call_list_stashes)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_push" => { - #[derive(serde::Deserialize)] - struct Params { - #[serde(default)] - message: Option, - #[serde(default)] - include_untracked: bool, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!( - "stash_push", - call_stash_push, - p.message.as_deref(), - p.include_untracked - )?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_apply" => { - #[derive(serde::Deserialize)] - struct Params { - selector: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("stash_apply", call_stash_apply, &p.selector)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_pop" => { - #[derive(serde::Deserialize)] - struct Params { - selector: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("stash_pop", call_stash_pop, &p.selector)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_drop" => { - #[derive(serde::Deserialize)] - struct Params { - selector: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("stash_drop", call_stash_drop, &p.selector)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "stash_show" => { - #[derive(serde::Deserialize)] - struct Params { - selector: String, - } - let p: Params = parse_method_params(method, params)?; - let out = invoke!("stash_show", call_stash_show, &p.selector)?; - let normalized = out.lines().map(str::to_string).collect::>(); - encode_method_result(&self.spawn.plugin_id, method, normalized) - } - "cherry_pick" => { - #[derive(serde::Deserialize)] - struct Params { - #[serde(default)] - commit: Option, - #[serde(default)] - rev: Option, - } - let p: Params = parse_method_params(method, params)?; - let commit = p - .commit - .or(p.rev) - .ok_or_else(|| "missing `commit`/`rev`".to_string())?; - let out = invoke!("cherry_pick", call_cherry_pick, &commit)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - "revert_commit" => { - #[derive(serde::Deserialize)] - struct Params { - #[serde(default)] - commit: Option, - #[serde(default)] - rev: Option, - #[serde(default)] - no_edit: bool, - } - let p: Params = parse_method_params(method, params)?; - let commit = p - .commit - .or(p.rev) - .ok_or_else(|| "missing `commit`/`rev`".to_string())?; - let out = invoke!("revert_commit", call_revert_commit, &commit, p.no_edit)?; - encode_method_result(&self.spawn.plugin_id, method, out) - } - _ => Err(format!( - "component method `{method}` is not part of the v1 ABI contract for plugin `{}`", - self.spawn.plugin_id - )), - } - }) - } - /// Returns plugin-contributed UI menus. fn get_menus(&self) -> Result, String> { self.with_runtime(|runtime| runtime.call_get_menus(&self.spawn.plugin_id)) diff --git a/Backend/src/plugin_runtime/instance.rs b/Backend/src/plugin_runtime/instance.rs index 4409e83b..56b0b10b 100644 --- a/Backend/src/plugin_runtime/instance.rs +++ b/Backend/src/plugin_runtime/instance.rs @@ -3,7 +3,6 @@ use openvcs_core::models::VcsEvent; use openvcs_core::settings::SettingKv; use openvcs_core::ui::Menu; -use serde_json::Value; use std::sync::Arc; /// Runtime instance abstraction used by the plugin runtime manager. @@ -11,9 +10,6 @@ pub trait PluginRuntimeInstance: Send + Sync { /// Ensures the underlying runtime instance is started. fn ensure_running(&self) -> Result<(), String>; - /// Calls a plugin method and returns JSON payload. - fn call(&self, method: &str, params: Value) -> Result; - /// Returns plugin-contributed UI menus. fn get_menus(&self) -> Result, String> { Ok(Vec::new()) diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 91d60135..7cc7c3ac 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -7,7 +7,6 @@ use crate::plugin_runtime::spawn::SpawnConfig; use crate::settings::AppConfig; use log::{debug, info, trace, warn}; use parking_lot::Mutex; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -277,22 +276,26 @@ impl PluginRuntimeManager { ); if enabled && !is_running { - let has_module = self.has_module(plugin_id)?; - match has_module { - Some(true) => { - trace!("set_plugin_enabled: calling start_plugin"); - self.start_plugin(plugin_id)?; - info!("plugin: enabled '{}'", plugin_id); + let components = self.find_components(plugin_id)?; + match components.module { + Some(module) => { + if !module.vcs_backends.is_empty() { + info!( + "plugin '{}' is a VCS backend; runtime starts when opening a repository", + plugin_id + ); + } else { + trace!("set_plugin_enabled: calling start_plugin"); + self.start_plugin(plugin_id)?; + info!("plugin: enabled '{}'", plugin_id); + } } - Some(false) => { + None => { info!( "plugin '{}' has no runtime module, marked as enabled", plugin_id ); } - None => { - return Err(format!("plugin '{}' not found", plugin_id)); - } } } else if !enabled && is_running { trace!("set_plugin_enabled: calling stop_plugin"); @@ -335,6 +338,14 @@ impl PluginRuntimeManager { continue; } + let is_vcs_backend = component + .module + .as_ref() + .is_some_and(|module| !module.vcs_backends.is_empty()); + if is_vcs_backend { + continue; + } + let key = plugin_id.to_ascii_lowercase(); if cfg.is_plugin_enabled(plugin_id, component.default_enabled) { desired_running.insert(key.clone()); @@ -390,55 +401,27 @@ impl PluginRuntimeManager { } } - /// Calls a module RPC method through the persistent plugin process. - /// - /// # Parameters - /// - `cfg`: App config snapshot used to enforce enabled-state checks. - /// - `plugin_id`: Plugin identifier. - /// - `method`: RPC method name. - /// - `params`: JSON method params. - /// - /// # Returns - /// - `Ok(Value)` plugin RPC result. - /// - `Err(String)` when plugin is disabled/not available or RPC fails. - pub fn call_module_method_with_config( - &self, - cfg: &AppConfig, - plugin_id: &str, - method: &str, - params: Value, - ) -> Result { - self.call_module_method_for_workspace_with_config(cfg, plugin_id, method, params, None) - } - - /// Calls a module RPC method through the persistent plugin process with an - /// optional workspace-root confinement. + /// Returns the persistent runtime instance for a plugin workspace. /// /// # Parameters /// - `cfg`: App config snapshot used for enabled-state checks. /// - `plugin_id`: Plugin identifier. - /// - `method`: RPC method name. - /// - `params`: JSON RPC parameters. /// - `allowed_workspace_root`: Optional workspace root for host capability confinement. /// /// # Returns - /// - `Ok(Value)` plugin RPC response payload. - /// - `Err(String)` when plugin state validation fails, plugin is not running, - /// or RPC dispatch fails. - pub fn call_module_method_for_workspace_with_config( + /// - `Ok(Arc)` running runtime instance. + /// - `Err(String)` when plugin state validation fails or runtime is not running. + pub fn runtime_for_workspace_with_config( &self, cfg: &AppConfig, plugin_id: &str, - method: &str, - params: Value, allowed_workspace_root: Option, - ) -> Result { + ) -> Result, String> { let spec = self.resolve_module_runtime_spec(plugin_id, allowed_workspace_root)?; if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { return Err(format!("plugin `{}` is disabled", spec.plugin_id)); } - let rpc = self - .processes + self.processes .lock() .get(&spec.key) .map(|p| Arc::clone(&p.runtime)) @@ -447,40 +430,33 @@ impl PluginRuntimeManager { "plugin `{}` is not running; enable the plugin to start its runtime", spec.plugin_id ) - })?; - rpc.call(method, params) + }) } - /// Returns the persistent runtime instance for a plugin workspace. + /// Resolves spawn configuration for a VCS backend plugin within a workspace root. /// /// # Parameters /// - `cfg`: App config snapshot used for enabled-state checks. /// - `plugin_id`: Plugin identifier. - /// - `allowed_workspace_root`: Optional workspace root for host capability confinement. + /// - `workspace_root`: Canonical workspace root for host capability confinement. /// /// # Returns - /// - `Ok(Arc)` running runtime instance. - /// - `Err(String)` when plugin state validation fails or runtime is not running. - pub fn runtime_for_workspace_with_config( + /// - `Ok(SpawnConfig)` resolved spawn settings for a VCS backend runtime. + /// - `Err(String)` when plugin is disabled, missing runtime module, or is not a VCS backend. + pub fn vcs_spawn_for_workspace_with_config( &self, cfg: &AppConfig, plugin_id: &str, - allowed_workspace_root: Option, - ) -> Result, String> { - let spec = self.resolve_module_runtime_spec(plugin_id, allowed_workspace_root)?; + workspace_root: PathBuf, + ) -> Result { + let spec = self.resolve_module_runtime_spec(plugin_id, Some(workspace_root))?; if !cfg.is_plugin_enabled(&spec.plugin_id, spec.default_enabled) { return Err(format!("plugin `{}` is disabled", spec.plugin_id)); } - self.processes - .lock() - .get(&spec.key) - .map(|p| Arc::clone(&p.runtime)) - .ok_or_else(|| { - format!( - "plugin `{}` is not running; enable the plugin to start its runtime", - spec.plugin_id - ) - }) + if !spec.spawn.is_vcs_backend { + return Err(format!("plugin `{}` is not a VCS backend", spec.plugin_id)); + } + Ok(spec.spawn) } /// Starts or reuses a runtime for a resolved plugin runtime spec. @@ -766,6 +742,44 @@ mod tests { assert!(!running.contains_key("themes.plugin")); } + #[test] + /// Verifies startup sync does not eagerly start VCS backend runtimes. + fn sync_does_not_autostart_vcs_backend_plugins() { + let temp = tempdir().expect("tempdir"); + write_vcs_plugin(temp.path(), "git.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + manager + .sync_plugin_runtime_with_config(&cfg) + .expect("sync succeeds"); + + let running = manager.processes.lock(); + assert!(!running.contains_key("git.plugin")); + } + + #[test] + /// Verifies VCS spawn resolution includes workspace confinement. + fn vcs_spawn_resolution_sets_workspace_root() { + let temp = tempdir().expect("tempdir"); + write_vcs_plugin(temp.path(), "git.plugin", true); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + let cfg = AppConfig::default(); + let workspace_root = temp.path().join("repo"); + std::fs::create_dir_all(&workspace_root).expect("create repo root"); + + let spawn = manager + .vcs_spawn_for_workspace_with_config(&cfg, "git.plugin", workspace_root.clone()) + .expect("resolve vcs spawn"); + + assert!(spawn.is_vcs_backend); + assert_eq!( + spawn.allowed_workspace_root.as_deref(), + Some(workspace_root.as_path()) + ); + } + #[test] /// Verifies spawn config marks VCS backend plugins using manifest data. fn resolve_spec_sets_vcs_backend_flag_from_manifest() { diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 7c107324..8109e19d 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -55,8 +55,7 @@ pub fn create_runtime_instance( } trace!("create_runtime_instance: creating ComponentPluginRuntimeInstance"); - let runtime: Arc = - Arc::new(ComponentPluginRuntimeInstance::new(spawn.clone())); + let runtime: Arc = create_component_runtime_instance(spawn.clone())?; debug!( "create_runtime_instance: instance created, plugin_id='{}'", spawn.plugin_id @@ -70,6 +69,19 @@ pub fn create_runtime_instance( Ok(runtime) } +/// Creates a component runtime instance with the provided spawn context. +pub fn create_component_runtime_instance( + spawn: SpawnConfig, +) -> Result, String> { + if !is_component_module(&spawn.exec_path) { + return Err(format!( + "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", + spawn.exec_path.display() + )); + } + Ok(Arc::new(ComponentPluginRuntimeInstance::new(spawn))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index 0682afb0..c5892218 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -1,45 +1,35 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later + use crate::logging::LogTimer; +use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; -use log::{debug, error, info, trace, warn}; +use log::{debug, error, info, warn}; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, StatusSummary, VcsEvent, }; use openvcs_core::{BackendId, OnEvent, Result as VcsResult, Vcs, VcsError}; -use serde::de::DeserializeOwned; -use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::Arc; const MODULE: &str = "vcs_proxy"; -/// [`Vcs`] implementation that forwards operations to a plugin runtime. +/// [`Vcs`] implementation that forwards operations to typed plugin runtime calls. pub struct PluginVcsProxy { /// Backend identifier represented by this proxy instance. backend_id: BackendId, /// Repository worktree path associated with this backend session. workdir: PathBuf, - /// Started plugin runtime used for RPC calls. - runtime: Arc, + /// Started plugin runtime used for typed WIT calls. + runtime: Arc, } impl PluginVcsProxy { /// Opens a repository through a previously started plugin module runtime. - /// - /// # Parameters - /// - `backend_id`: Backend id exposed by the plugin. - /// - `runtime`: Persistent plugin runtime instance. - /// - `repo_path`: Repository working-tree path to open. - /// - `cfg`: Serialized config payload forwarded to the plugin. - /// - /// # Returns - /// - `Ok(Arc)` when the plugin backend is opened successfully. - /// - `Err(VcsError)` when startup or open RPC fails. pub fn open_with_process( backend_id: BackendId, - runtime: Arc, + runtime: Arc, repo_path: &Path, cfg: serde_json::Value, ) -> Result, VcsError> { @@ -49,34 +39,31 @@ impl PluginVcsProxy { "open_with_process: backend={}, path={}", backend_id, path_str ); - debug!( - "open_with_process: config keys={:?}", - cfg.as_object().map(|o| o.keys().collect::>()) - ); - let workdir = repo_path.to_path_buf(); let p = PluginVcsProxy { backend_id: backend_id.clone(), - workdir: workdir.clone(), + workdir: repo_path.to_path_buf(), runtime, }; - trace!("open_with_process: ensuring runtime is running",); - p.runtime.ensure_running().map_err(|e| { - error!("open_with_process: failed to ensure runtime running: {}", e); - VcsError::Backend { - backend: p.backend_id.clone(), - msg: e, - } + p.runtime.ensure_running().map_err(|e| VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, })?; - debug!("open_with_process: runtime confirmed running"); - let params = json!({ "path": path_to_utf8(repo_path)?, "config": cfg }); - trace!("open_with_process: calling open RPC"); - p.call_unit("open", params.clone()).map_err(|e| { - error!("open_with_process: open RPC failed: {}", e); - e + let config = serde_json::to_vec(&cfg).map_err(|e| VcsError::Backend { + backend: p.backend_id.clone(), + msg: format!("serialize open config: {e}"), })?; + p.runtime + .vcs_open(path_to_utf8(repo_path)?.as_str(), &config) + .map_err(|e| { + error!("open_with_process: open call failed: {}", e); + VcsError::Backend { + backend: p.backend_id.clone(), + msg: e, + } + })?; info!( "open_with_process: opened backend {} for {}", @@ -85,404 +72,120 @@ impl PluginVcsProxy { Ok(Arc::new(p)) } - /// Calls a plugin RPC method and maps transport errors to [`VcsError`]. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(Value)` RPC result payload. - /// - `Err(VcsError)` on RPC failure. - fn call_value(&self, method: &str, params: Value) -> Result { - trace!( - "call_value: method={}, params_len={}", - method, - params.to_string().len() - ); - let result = self.runtime.call(method, params).map_err(|e| { - error!("call_value: RPC call '{}' failed: {}", method, e); - VcsError::Backend { - backend: self.backend_id.clone(), - msg: e, - } - }); - if let Ok(ref v) = result { - trace!( - "call_value: method={} returned {} bytes", - method, - v.to_string().len() - ); - } - result - } - - /// Calls a plugin RPC method and deserializes its JSON result. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(T)` deserialized result. - /// - `Err(VcsError)` on RPC or decode failure. - fn call_json(&self, method: &str, params: Value) -> Result { - trace!("call_json: method={}", method); - let v = self.call_value(method, params)?; - serde_json::from_value(v).map_err(|e| { - error!( - "call_json: failed to deserialize response for '{}': {}", - method, e - ); - VcsError::Backend { - backend: self.backend_id.clone(), - msg: format!("invalid plugin response for {method}: {e}"), - } - }) - } - - /// Calls a plugin RPC method that returns no meaningful value. - /// - /// # Parameters - /// - `method`: RPC method name. - /// - `params`: JSON method parameters. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on RPC failure. - fn call_unit(&self, method: &str, params: Value) -> Result<(), VcsError> { - trace!("call_unit: method={}", method); - let _ = self.call_value(method, params)?; - Ok(()) - } - /// Runs an operation while temporarily installing an event callback sink. - /// - /// # Parameters - /// - `on`: Optional event callback. - /// - `f`: Operation to execute while the callback is installed. - /// - /// # Returns - /// - `Ok(R)` operation result. - /// - `Err(VcsError)` operation error. fn with_events(&self, on: Option, f: F) -> Result where F: FnOnce() -> Result, { - if on.is_some() { - debug!("with_events: installing event callback"); - } let sink: Option> = on.map(|cb| Arc::new(move |evt| cb(evt)) as _); self.runtime.set_event_sink(sink); let res = f(); self.runtime.set_event_sink(None); - if res.is_err() { - warn!("with_events: operation failed"); - } res } + + /// Maps string runtime errors into backend-scoped VCS errors. + fn map_runtime_error(&self, err: String) -> VcsError { + VcsError::Backend { + backend: self.backend_id.clone(), + msg: err, + } + } } impl Vcs for PluginVcsProxy { - /// Returns the backend identifier for this proxy. - /// - /// # Returns - /// - Backend id value. fn id(&self) -> BackendId { - trace!("id: returning backend_id={}", self.backend_id); self.backend_id.clone() } - /// Returns capability flags reported by the plugin. - /// - /// # Returns - /// - Capability set; defaults on decode failure. fn caps(&self) -> Capabilities { - trace!("caps: querying plugin capabilities"); - let result = self.call_json("caps", Value::Null); - match result { - Ok(caps) => { - debug!("caps: received capabilities from plugin"); - caps - } - Err(e) => { - warn!("caps: failed to get capabilities, using defaults: {}", e); - Capabilities::default() - } - } + self.runtime.vcs_get_caps().unwrap_or_else(|e| { + warn!("caps: failed to query capabilities: {}", e); + Capabilities::default() + }) } - /// Unsupported direct constructor for this proxy. - /// - /// # Parameters - /// - `_path`: Ignored path argument. - /// - /// # Returns - /// - Always `Err(VcsError)`. fn open(_path: &Path) -> VcsResult where Self: Sized, { - warn!("open: direct constructor not supported, use host runtime",); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::open must be constructed via the host runtime".into(), }) } - /// Unsupported direct clone constructor for this proxy. - /// - /// # Parameters - /// - `_url`: Ignored URL argument. - /// - `_dest`: Ignored destination argument. - /// - `_on`: Ignored event callback. - /// - /// # Returns - /// - Always `Err(VcsError)`. fn clone(_url: &str, _dest: &Path, _on: Option) -> VcsResult where Self: Sized, { - warn!("clone: direct constructor not supported, use host runtime",); Err(VcsError::Backend { backend: BackendId::from("plugin"), msg: "PluginVcsProxy::clone must be constructed via the host runtime".into(), }) } - /// Returns repository workdir associated with this proxy. - /// - /// # Returns - /// - Workdir path reference. fn workdir(&self) -> &Path { - trace!("workdir: returning {}", self.workdir.display()); &self.workdir } - /// Returns current local branch if attached. - /// - /// # Returns - /// - `Ok(Some(String))` branch name. - /// - `Ok(None)` on detached HEAD. - /// - `Err(VcsError)` on backend failure. fn current_branch(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "current_branch"); - trace!("current_branch: querying current branch"); - let result = self.call_json("current_branch", Value::Null); - match &result { - Ok(Some(branch)) => { - debug!("current_branch: on branch '{}'", branch); - } - Ok(None) => { - debug!("current_branch: detached HEAD"); - } - Err(e) => { - error!("current_branch: failed: {}", e); - } - } - result + self.runtime + .vcs_get_current_branch() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns local/remote branch records. - /// - /// # Returns - /// - `Ok(Vec)` branch list. - /// - `Err(VcsError)` on backend failure. fn branches(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "branches"); - trace!("branches: querying all branches"); - let result: VcsResult> = - self.call_json("branches", Value::Null); - match &result { - Ok(branches) => { - debug!("branches: found {} branches", branches.len()); - } - Err(e) => { - error!("branches: failed: {}", e); - } - } - result + self.runtime + .vcs_list_branches() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns local branch names. - /// - /// # Returns - /// - `Ok(Vec)` local branch names. - /// - `Err(VcsError)` on backend failure. fn local_branches(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "local_branches"); - trace!("local_branches: querying local branches"); - let result: VcsResult> = self.call_json("local_branches", Value::Null); - match &result { - Ok(branches) => { - debug!("local_branches: found {} local branches", branches.len()); - } - Err(e) => { - error!("local_branches: failed: {}", e); - } - } - result + self.runtime + .vcs_list_local_branches() + .map_err(|e| self.map_runtime_error(e)) } - /// Creates a branch and optionally checks it out. - /// - /// # Parameters - /// - `name`: Branch name. - /// - `checkout`: Whether to checkout the new branch. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn create_branch(&self, name: &str, checkout: bool) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "create_branch"); - info!("create_branch: name={}, checkout={}", name, checkout); - let result = self.call_unit( - "create_branch", - json!({ "name": name, "checkout": checkout }), - ); - match &result { - Ok(()) => { - debug!("create_branch: branch '{}' created", name); - } - Err(e) => { - error!("create_branch: failed to create '{}': {}", name, e); - } - } - result + self.runtime + .vcs_create_branch(name, checkout) + .map_err(|e| self.map_runtime_error(e)) } - /// Checks out an existing branch. - /// - /// # Parameters - /// - `name`: Branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn checkout_branch(&self, name: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "checkout_branch"); - info!("checkout_branch: name={}", name); - let result = self.call_unit("checkout_branch", json!({ "name": name })); - match &result { - Ok(()) => { - debug!("checkout_branch: switched to '{}'", name); - } - Err(e) => { - error!("checkout_branch: failed to switch to '{}': {}", name, e); - } - } - result + self.runtime + .vcs_checkout_branch(name) + .map_err(|e| self.map_runtime_error(e)) } - /// Creates or updates a remote URL. - /// - /// # Parameters - /// - `name`: Remote name. - /// - `url`: Remote URL. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn ensure_remote(&self, name: &str, url: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "ensure_remote"); - info!("ensure_remote: name={}, url={}", name, url); - let result = self.call_unit("ensure_remote", json!({ "name": name, "url": url })); - match &result { - Ok(()) => { - debug!("ensure_remote: remote '{}' configured", name); - } - Err(e) => { - error!("ensure_remote: failed for '{}': {}", name, e); - } - } - result + self.runtime + .vcs_ensure_remote(name, url) + .map_err(|e| self.map_runtime_error(e)) } - /// Lists configured remotes. - /// - /// # Returns - /// - `Ok(Vec<(String, String)>)` name/url pairs. - /// - `Err(VcsError)` on backend failure. fn list_remotes(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "list_remotes"); - trace!("list_remotes: querying remotes"); - let result: VcsResult> = self.call_json("list_remotes", Value::Null); - match &result { - Ok(remotes) => { - debug!("list_remotes: found {} remotes", remotes.len()); - for (name, url) in remotes { - trace!("list_remotes: remote '{}' -> '{}'", name, url); - } - } - Err(e) => { - error!("list_remotes: failed: {}", e); - } - } - result + self.runtime + .vcs_list_remotes() + .map_err(|e| self.map_runtime_error(e)) } - /// Removes a configured remote. - /// - /// # Parameters - /// - `name`: Remote name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn remove_remote(&self, name: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "remove_remote"); - info!("remove_remote: name={}", name); - let result = self.call_unit("remove_remote", json!({ "name": name })); - match &result { - Ok(()) => { - debug!("remove_remote: remote '{}' removed", name); - } - Err(e) => { - error!("remove_remote: failed to remove '{}': {}", name, e); - } - } - result + self.runtime + .vcs_remove_remote(name) + .map_err(|e| self.map_runtime_error(e)) } - /// Fetches a refspec from a remote. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn fetch(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "fetch"); - info!("fetch: remote={}, refspec={}", remote, refspec); - let result = self.with_events(on, || { - self.call_unit("fetch", json!({ "remote": remote, "refspec": refspec })) - }); - match &result { - Ok(()) => { - debug!("fetch: completed successfully"); - } - Err(e) => { - error!("fetch: failed: {}", e); - } - } - result + self.with_events(on, || { + self.runtime + .vcs_fetch(remote, refspec) + .map_err(|e| self.map_runtime_error(e)) + }) } - /// Fetches using explicit options payload. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `opts`: Fetch option flags. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn fetch_with_options( &self, remote: &str, @@ -490,96 +193,29 @@ impl Vcs for PluginVcsProxy { opts: FetchOptions, on: Option, ) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "fetch_with_options"); - info!( - "fetch_with_options: remote={}, refspec={}, opts={:?}", - remote, refspec, opts - ); - let result = self.with_events(on, || { - self.call_unit( - "fetch_with_options", - json!({ "remote": remote, "refspec": refspec, "opts": opts }), - ) - }); - match &result { - Ok(()) => { - debug!("fetch_with_options: completed successfully"); - } - Err(e) => { - error!("fetch_with_options: failed: {}", e); - } - } - result + self.with_events(on, || { + self.runtime + .vcs_fetch_with_options(remote, refspec, opts) + .map_err(|e| self.map_runtime_error(e)) + }) } - /// Pushes a refspec to a remote. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `refspec`: Refspec expression. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn push(&self, remote: &str, refspec: &str, on: Option) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "push"); - info!("push: remote={}, refspec={}", remote, refspec); - let result = self.with_events(on, || { - self.call_unit("push", json!({ "remote": remote, "refspec": refspec })) - }); - match &result { - Ok(()) => { - debug!("push: completed successfully"); - } - Err(e) => { - error!("push: failed: {}", e); - } - } - result + self.with_events(on, || { + self.runtime + .vcs_push(remote, refspec) + .map_err(|e| self.map_runtime_error(e)) + }) } - /// Pulls from upstream using fast-forward-only strategy. - /// - /// # Parameters - /// - `remote`: Remote name. - /// - `branch`: Branch name. - /// - `on`: Optional progress callback. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn pull_ff_only(&self, remote: &str, branch: &str, on: Option) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "pull_ff_only"); - info!("pull_ff_only: remote={}, branch={}", remote, branch); - let result = self.with_events(on, || { - self.call_unit( - "pull_ff_only", - json!({ "remote": remote, "branch": branch }), - ) - }); - match &result { - Ok(()) => { - debug!("pull_ff_only: completed successfully"); - } - Err(e) => { - error!("pull_ff_only: failed: {}", e); - } - } - result + self.with_events(on, || { + self.runtime + .vcs_pull_ff_only(remote, branch) + .map_err(|e| self.map_runtime_error(e)) + }) } - /// Creates a commit from selected paths. - /// - /// # Parameters - /// - `message`: Commit message. - /// - `name`: Author name. - /// - `email`: Author email. - /// - `paths`: Paths to include. - /// - /// # Returns - /// - `Ok(String)` created commit id. - /// - `Err(VcsError)` on backend failure. fn commit( &self, message: &str, @@ -587,817 +223,244 @@ impl Vcs for PluginVcsProxy { email: &str, paths: &[PathBuf], ) -> VcsResult { - let _timer = LogTimer::new(MODULE, "commit"); - let paths: Vec = paths + let paths = paths .iter() .map(|p| p.to_string_lossy().to_string()) - .collect(); - info!( - "commit: author={} <{}>, paths={}, message_len={}", - name, - email, - paths.len(), - message.len() - ); - debug!("commit: message='{}'", message.lines().next().unwrap_or("")); - trace!("commit: paths={:?}", paths); - let result = self.call_json( - "commit", - json!({ "message": message, "name": name, "email": email, "paths": paths }), - ); - match &result { - Ok(commit_id) => { - debug!("commit: created commit {}", commit_id); - } - Err(e) => { - error!("commit: failed: {}", e); - } - } - result + .collect::>(); + self.runtime + .vcs_commit(message, name, email, &paths) + .map_err(|e| self.map_runtime_error(e)) } - /// Creates a commit from the index. - /// - /// # Parameters - /// - `message`: Commit message. - /// - `name`: Author name. - /// - `email`: Author email. - /// - /// # Returns - /// - `Ok(String)` commit id. - /// - `Err(VcsError)` on backend failure. fn commit_index(&self, message: &str, name: &str, email: &str) -> VcsResult { - let _timer = LogTimer::new(MODULE, "commit_index"); - info!( - "commit_index: author={} <{}>, message_len={}", - name, - email, - message.len() - ); - debug!( - "commit_index: message='{}'", - message.lines().next().unwrap_or("") - ); - let result = self.call_json( - "commit_index", - json!({ "message": message, "name": name, "email": email }), - ); - match &result { - Ok(commit_id) => { - debug!("commit_index: created commit {}", commit_id); - } - Err(e) => { - error!("commit_index: failed: {}", e); - } - } - result + self.runtime + .vcs_commit_index(message, name, email) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns summarized status information. - /// - /// # Returns - /// - `Ok(StatusSummary)` summary payload. - /// - `Err(VcsError)` on backend failure. fn status_summary(&self) -> VcsResult { - let _timer = LogTimer::new(MODULE, "status_summary"); - trace!("status_summary: querying status summary"); - let result: VcsResult = self.call_json("status_summary", Value::Null); - match &result { - Ok(summary) => { - debug!( - "status_summary: {} staged, {} modified, {} untracked, {} conflicted", - summary.staged, summary.modified, summary.untracked, summary.conflicted - ); - } - Err(e) => { - error!("status_summary: failed: {}", e); - } - } - result + self.runtime + .vcs_get_status_summary() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns full status payload. - /// - /// # Returns - /// - `Ok(StatusPayload)` status payload. - /// - `Err(VcsError)` on backend failure. fn status_payload(&self) -> VcsResult { - let _timer = LogTimer::new(MODULE, "status_payload"); - trace!("status_payload: querying full status"); - let result: VcsResult = self.call_json("status_payload", Value::Null); - match &result { - Ok(payload) => { - debug!( - "status_payload: {} files, {} ahead, {} behind", - payload.files.len(), - payload.ahead, - payload.behind - ); - } - Err(e) => { - error!("status_payload: failed: {}", e); - } - } - result + self.runtime + .vcs_get_status_payload() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns commit log entries for a query. - /// - /// # Parameters - /// - `query`: Log query payload. - /// - /// # Returns - /// - `Ok(Vec)` commit entries. - /// - `Err(VcsError)` on backend failure. fn log_commits(&self, query: &LogQuery) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "log_commits"); - trace!( - "log_commits: querying commits (limit={:?}, skip={:?})", - query.limit, - query.skip - ); - let result: VcsResult> = - self.call_json("log_commits", json!({ "query": query })); - match &result { - Ok(commits) => { - debug!("log_commits: {} commits returned", commits.len()); - } - Err(e) => { - error!("log_commits: failed: {}", e); - } - } - result + self.runtime + .vcs_list_commits(query) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns diff lines for a file path. - /// - /// # Parameters - /// - `path`: Repository-relative path. - /// - /// # Returns - /// - `Ok(Vec)` diff lines. - /// - `Err(VcsError)` on backend failure. fn diff_file(&self, path: &Path) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "diff_file"); - let path_str = path_to_utf8(path)?; - trace!("diff_file: path={}", path_str); - let result: VcsResult> = - self.call_json("diff_file", json!({ "path": path_str.clone() })); - match &result { - Ok(lines) => { - debug!("diff_file: {} lines for {}", lines.len(), path_str); - } - Err(e) => { - error!("diff_file: failed for '{}': {}", path_str, e); - } - } - result + self.runtime + .vcs_diff_file(path_to_utf8(path)?.as_str()) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns diff lines for a commit/revision. - /// - /// # Parameters - /// - `rev`: Revision selector. - /// - /// # Returns - /// - `Ok(Vec)` diff lines. - /// - `Err(VcsError)` on backend failure. fn diff_commit(&self, rev: &str) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "diff_commit"); - trace!("diff_commit: rev={}", rev); - let result: VcsResult> = self.call_json("diff_commit", json!({ "rev": rev })); - match &result { - Ok(lines) => { - debug!("diff_commit: {} lines for {}", lines.len(), rev); - } - Err(e) => { - error!("diff_commit: failed for '{}': {}", rev, e); - } - } - result + self.runtime + .vcs_diff_commit(rev) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns merge-conflict details for a file. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - /// # Returns - /// - `Ok(ConflictDetails)` conflict payload. - /// - `Err(VcsError)` on backend failure. fn conflict_details(&self, path: &Path) -> VcsResult { - let _timer = LogTimer::new(MODULE, "conflict_details"); - let path_str = path_to_utf8(path)?; - info!("conflict_details: path={}", path_str); - let result: VcsResult = - self.call_json("conflict_details", json!({ "path": path_str.clone() })); - match &result { - Ok(details) => { - debug!( - "conflict_details: got details for {} (binary={})", - path_str, details.binary - ); - } - Err(e) => { - error!("conflict_details: failed for '{}': {}", path_str, e); - } - } - result + self.runtime + .vcs_get_conflict_details(path_to_utf8(path)?.as_str()) + .map_err(|e| self.map_runtime_error(e)) } - /// Checks out a specific conflict side for a file. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - `side`: Conflict side selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn checkout_conflict_side(&self, path: &Path, side: ConflictSide) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "checkout_conflict_side"); - let path_str = path_to_utf8(path)?; - info!("checkout_conflict_side: path={}, side={:?}", path_str, side); - let result = self.call_unit( - "checkout_conflict_side", - json!({ "path": path_str.clone(), "side": side }), - ); - match &result { - Ok(()) => { - debug!( - "checkout_conflict_side: resolved {} with {:?}", - path_str, side - ); - } - Err(e) => { - error!("checkout_conflict_side: failed for '{}': {}", path_str, e); - } - } - result + self.runtime + .vcs_checkout_conflict_side(path_to_utf8(path)?.as_str(), side) + .map_err(|e| self.map_runtime_error(e)) } - /// Writes merged file content for a conflict path. - /// - /// # Parameters - /// - `path`: Conflict file path. - /// - `content`: Resolved bytes. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn write_merge_result(&self, path: &Path, content: &[u8]) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "write_merge_result"); - let path_str = path_to_utf8(path)?; - let content_str = String::from_utf8_lossy(content).to_string(); - info!( - "write_merge_result: path={}, content_len={}", - path_str, - content_str.len() - ); - let result = self.call_unit( - "write_merge_result", - json!({ "path": path_str.clone(), "content": content_str }), - ); - match &result { - Ok(()) => { - debug!("write_merge_result: wrote resolved content to {}", path_str); - } - Err(e) => { - error!("write_merge_result: failed for '{}': {}", path_str, e); - } - } - result + self.runtime + .vcs_write_merge_result(path_to_utf8(path)?.as_str(), content) + .map_err(|e| self.map_runtime_error(e)) } - /// Stages a patch in the index. - /// - /// # Parameters - /// - `patch`: Unified patch text. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stage_patch(&self, patch: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "stage_patch"); - info!("stage_patch: patch_len={}", patch.len()); - let result = self.call_unit("stage_patch", json!({ "patch": patch })); - match &result { - Ok(()) => { - debug!("stage_patch: patch staged successfully"); - } - Err(e) => { - error!("stage_patch: failed: {}", e); - } - } - result + self.runtime + .vcs_stage_patch(patch) + .map_err(|e| self.map_runtime_error(e)) } - /// Discards changes for explicit paths. - /// - /// # Parameters - /// - `paths`: Paths to discard. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn discard_paths(&self, paths: &[PathBuf]) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "discard_paths"); - let paths: Vec = paths + let paths = paths .iter() .map(|p| p.to_string_lossy().to_string()) - .collect(); - info!("discard_paths: count={}", paths.len()); - trace!("discard_paths: paths={:?}", paths); - let result = self.call_unit("discard_paths", json!({ "paths": paths })); - match &result { - Ok(()) => { - debug!("discard_paths: changes discarded"); - } - Err(e) => { - error!("discard_paths: failed: {}", e); - } - } - result + .collect::>(); + self.runtime + .vcs_discard_paths(&paths) + .map_err(|e| self.map_runtime_error(e)) } - /// Applies a patch in reverse to discard hunks. - /// - /// # Parameters - /// - `patch`: Unified patch text. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn apply_reverse_patch(&self, patch: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "apply_reverse_patch"); - info!("apply_reverse_patch: patch_len={}", patch.len()); - let result = self.call_unit("apply_reverse_patch", json!({ "patch": patch })); - match &result { - Ok(()) => { - debug!("apply_reverse_patch: patch applied in reverse"); - } - Err(e) => { - error!("apply_reverse_patch: failed: {}", e); - } - } - result + self.runtime + .vcs_apply_reverse_patch(patch) + .map_err(|e| self.map_runtime_error(e)) } - /// Deletes a branch. - /// - /// # Parameters - /// - `name`: Branch name. - /// - `force`: Force-delete flag. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn delete_branch(&self, name: &str, force: bool) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "delete_branch"); - info!("delete_branch: name={}, force={}", name, force); - let result = self.call_unit("delete_branch", json!({ "name": name, "force": force })); - match &result { - Ok(()) => { - debug!("delete_branch: branch '{}' deleted", name); - } - Err(e) => { - error!("delete_branch: failed to delete '{}': {}", name, e); - } - } - result + self.runtime + .vcs_delete_branch(name, force) + .map_err(|e| self.map_runtime_error(e)) } - /// Renames a branch. - /// - /// # Parameters - /// - `old`: Existing branch name. - /// - `new`: New branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn rename_branch(&self, old: &str, new: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "rename_branch"); - info!("rename_branch: old='{}' -> new='{}'", old, new); - let result = self.call_unit("rename_branch", json!({ "old": old, "new": new })); - match &result { - Ok(()) => { - debug!("rename_branch: branch renamed"); - } - Err(e) => { - error!("rename_branch: failed: {}", e); - } - } - result + self.runtime + .vcs_rename_branch(old, new) + .map_err(|e| self.map_runtime_error(e)) } - /// Merges a branch into the current branch. - /// - /// # Parameters - /// - `name`: Source branch name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_into_current(&self, name: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "merge_into_current"); - info!("merge_into_current: source='{}'", name); - let result = self.call_unit("merge_into_current", json!({ "name": name })); - match &result { - Ok(()) => { - debug!("merge_into_current: merge completed"); - } - Err(e) => { - warn!("merge_into_current: merge may have conflicts: {}", e); - } - } - result + self.runtime + .vcs_merge_into_current(name, None) + .map_err(|e| self.map_runtime_error(e)) + } + + fn merge_into_current_with_message(&self, name: &str, message: Option<&str>) -> VcsResult<()> { + self.runtime + .vcs_merge_into_current(name, message) + .map_err(|e| self.map_runtime_error(e)) } - /// Aborts an in-progress merge. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_abort(&self) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "merge_abort"); - info!("merge_abort: aborting merge"); - let result = self.call_unit("merge_abort", Value::Null); - match &result { - Ok(()) => { - debug!("merge_abort: merge aborted"); - } - Err(e) => { - error!("merge_abort: failed: {}", e); - } - } - result + self.runtime + .vcs_merge_abort() + .map_err(|e| self.map_runtime_error(e)) } - /// Continues an in-progress merge. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn merge_continue(&self) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "merge_continue"); - info!("merge_continue: continuing merge"); - let result = self.call_unit("merge_continue", Value::Null); - match &result { - Ok(()) => { - debug!("merge_continue: merge continued"); - } - Err(e) => { - error!("merge_continue: failed: {}", e); - } - } - result + self.runtime + .vcs_merge_continue() + .map_err(|e| self.map_runtime_error(e)) } - /// Returns whether a merge is currently in progress. - /// - /// # Returns - /// - `Ok(bool)` merge state. - /// - `Err(VcsError)` on backend failure. fn merge_in_progress(&self) -> VcsResult { - let _timer = LogTimer::new(MODULE, "merge_in_progress"); - trace!("merge_in_progress: checking merge state"); - let result = self.call_json("merge_in_progress", Value::Null); - match &result { - Ok(true) => { - debug!("merge_in_progress: merge is in progress"); - } - Ok(false) => { - debug!("merge_in_progress: no merge in progress"); - } - Err(e) => { - error!("merge_in_progress: failed: {}", e); - } - } - result + self.runtime + .vcs_is_merge_in_progress() + .map_err(|e| self.map_runtime_error(e)) } - /// Sets upstream tracking branch for a local branch. - /// - /// # Parameters - /// - `branch`: Local branch name. - /// - `upstream`: Upstream ref name. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn set_branch_upstream(&self, branch: &str, upstream: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "set_branch_upstream"); - info!( - "set_branch_upstream: branch='{}' -> upstream='{}'", - branch, upstream - ); - let result = self.call_unit( - "set_branch_upstream", - json!({ "branch": branch, "upstream": upstream }), - ); - match &result { - Ok(()) => { - debug!("set_branch_upstream: upstream set"); - } - Err(e) => { - error!("set_branch_upstream: failed: {}", e); - } - } - result + self.runtime + .vcs_set_branch_upstream(branch, upstream) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns upstream ref for a local branch. - /// - /// # Parameters - /// - `branch`: Local branch name. - /// - /// # Returns - /// - `Ok(Some(String))` upstream ref. - /// - `Ok(None)` when unset. - /// - `Err(VcsError)` on backend failure. fn branch_upstream(&self, branch: &str) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "branch_upstream"); - trace!("branch_upstream: branch='{}'", branch); - let result = self.call_json("branch_upstream", json!({ "branch": branch })); - match &result { - Ok(Some(upstream)) => { - debug!("branch_upstream: '{}' tracks '{}'", branch, upstream); - } - Ok(None) => { - debug!("branch_upstream: '{}' has no upstream", branch); - } - Err(e) => { - error!("branch_upstream: failed: {}", e); - } - } - result + self.runtime + .vcs_get_branch_upstream(branch) + .map_err(|e| self.map_runtime_error(e)) } - /// Performs a hard reset of HEAD/worktree. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn hard_reset_head(&self) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "hard_reset_head"); - warn!("hard_reset_head: performing hard reset"); - let result = self.call_unit("hard_reset_head", Value::Null); - match &result { - Ok(()) => { - debug!("hard_reset_head: reset completed"); - } - Err(e) => { - error!("hard_reset_head: failed: {}", e); - } - } - result + self.runtime + .vcs_hard_reset_head() + .map_err(|e| self.map_runtime_error(e)) } - /// Performs a soft reset to a revision. - /// - /// # Parameters - /// - `rev`: Target revision. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn reset_soft_to(&self, rev: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "reset_soft_to"); - info!("reset_soft_to: rev={}", rev); - let result = self.call_unit("reset_soft_to", json!({ "rev": rev })); - match &result { - Ok(()) => { - debug!("reset_soft_to: reset to '{}'", rev); - } - Err(e) => { - error!("reset_soft_to: failed: {}", e); - } - } - result + self.runtime + .vcs_reset_soft_to(rev) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns configured repository identity if available. - /// - /// # Returns - /// - `Ok(Some((String, String)))` name/email pair. - /// - `Ok(None)` when unset. - /// - `Err(VcsError)` on backend failure. fn get_identity(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "get_identity"); - trace!("get_identity: querying repository identity"); - let result = self.call_json("get_identity", Value::Null); - match &result { - Ok(Some((name, email))) => { - debug!("get_identity: {} <{}>", name, email); - } - Ok(None) => { - debug!("get_identity: no identity configured"); - } - Err(e) => { - error!("get_identity: failed: {}", e); - } - } - result + self.runtime + .vcs_get_identity() + .map_err(|e| self.map_runtime_error(e)) } - /// Sets repository-local identity. - /// - /// # Parameters - /// - `name`: Author name. - /// - `email`: Author email. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn set_identity_local(&self, name: &str, email: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "set_identity_local"); - info!("set_identity_local: {} <{}>", name, email); - let result = self.call_unit( - "set_identity_local", - json!({ "name": name, "email": email }), - ); - match &result { - Ok(()) => { - debug!("set_identity_local: identity set"); - } - Err(e) => { - error!("set_identity_local: failed: {}", e); - } - } - result + self.runtime + .vcs_set_identity_local(name, email) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns stash entries. - /// - /// # Returns - /// - `Ok(Vec)` stash list. - /// - `Err(VcsError)` on backend failure. fn stash_list(&self) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "stash_list"); - trace!("stash_list: querying stash entries"); - let result: VcsResult> = self.call_json("stash_list", Value::Null); - match &result { - Ok(stashes) => { - debug!("stash_list: {} stash entries", stashes.len()); - } - Err(e) => { - error!("stash_list: failed: {}", e); - } - } - result + self.runtime + .vcs_list_stashes() + .map_err(|e| self.map_runtime_error(e)) } - /// Creates a stash entry. - /// - /// # Parameters - /// - `message`: Stash message. - /// - `include_untracked`: Whether to include untracked files. - /// - `paths`: Optional path subset. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_push( &self, message: &str, include_untracked: bool, - paths: &[PathBuf], + _paths: &[PathBuf], ) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "stash_push"); - let paths: Vec = paths - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - info!( - "stash_push: message='{}', include_untracked={}, paths={}", - message.lines().next().unwrap_or(""), - include_untracked, - paths.len() - ); - let result = self.call_unit( - "stash_push", - json!({ "message": message, "include_untracked": include_untracked, "paths": paths }), - ); - match &result { - Ok(()) => { - debug!("stash_push: stash created"); - } - Err(e) => { - error!("stash_push: failed: {}", e); - } - } - result + let message = if message.trim().is_empty() { + None + } else { + Some(message) + }; + let _ = self + .runtime + .vcs_stash_push(message, include_untracked) + .map_err(|e| self.map_runtime_error(e))?; + Ok(()) } - /// Applies a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_apply(&self, selector: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "stash_apply"); - info!("stash_apply: selector={}", selector); - let result = self.call_unit("stash_apply", json!({ "selector": selector })); - match &result { - Ok(()) => { - debug!("stash_apply: stash applied"); - } - Err(e) => { - error!("stash_apply: failed: {}", e); - } - } - result + self.runtime + .vcs_stash_apply(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Pops a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_pop(&self, selector: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "stash_pop"); - info!("stash_pop: selector={}", selector); - let result = self.call_unit("stash_pop", json!({ "selector": selector })); - match &result { - Ok(()) => { - debug!("stash_pop: stash popped"); - } - Err(e) => { - error!("stash_pop: failed: {}", e); - } - } - result + self.runtime + .vcs_stash_pop(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Drops a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(())` on success. - /// - `Err(VcsError)` on backend failure. fn stash_drop(&self, selector: &str) -> VcsResult<()> { - let _timer = LogTimer::new(MODULE, "stash_drop"); - info!("stash_drop: selector={}", selector); - let result = self.call_unit("stash_drop", json!({ "selector": selector })); - match &result { - Ok(()) => { - debug!("stash_drop: stash dropped"); - } - Err(e) => { - error!("stash_drop: failed: {}", e); - } - } - result + self.runtime + .vcs_stash_drop(selector) + .map_err(|e| self.map_runtime_error(e)) } - /// Returns patch lines for a stash entry. - /// - /// # Parameters - /// - `selector`: Stash selector. - /// - /// # Returns - /// - `Ok(Vec)` stash diff lines. - /// - `Err(VcsError)` on backend failure. fn stash_show(&self, selector: &str) -> VcsResult> { - let _timer = LogTimer::new(MODULE, "stash_show"); - trace!("stash_show: selector={}", selector); - let result: VcsResult> = - self.call_json("stash_show", json!({ "selector": selector })); - match &result { - Ok(lines) => { - debug!("stash_show: {} lines for {}", lines.len(), selector); - } - Err(e) => { - error!("stash_show: failed: {}", e); - } - } - result + self.runtime + .vcs_stash_show(selector) + .map_err(|e| self.map_runtime_error(e)) + } + + fn cherry_pick(&self, rev: &str) -> VcsResult<()> { + self.runtime + .vcs_cherry_pick(rev) + .map_err(|e| self.map_runtime_error(e)) + } + + fn revert_commit(&self, rev: &str, no_edit: bool) -> VcsResult<()> { + self.runtime + .vcs_revert_commit(rev, no_edit) + .map_err(|e| self.map_runtime_error(e)) } } -/// Converts a filesystem path to UTF-8 text for JSON RPC transport. -/// -/// # Parameters -/// - `path`: Filesystem path. -/// -/// # Returns -/// - `Ok(String)` UTF-8 path. -/// - `Err(VcsError)` when path is non-UTF8. +impl Drop for PluginVcsProxy { + /// Stops the underlying plugin runtime when the proxy is dropped. + fn drop(&mut self) { + debug!("drop: stopping VCS plugin runtime for {}", self.backend_id); + self.runtime.stop(); + } +} + +/// Converts a filesystem path to UTF-8 text. fn path_to_utf8(path: &Path) -> Result { - path.to_str().map(|s| s.to_string()).ok_or_else(|| { - warn!("path_to_utf8: non-UTF8 path: {}", path.display()); - VcsError::Backend { + path.to_str() + .map(str::to_string) + .ok_or_else(|| VcsError::Backend { backend: BackendId::from("plugin"), - msg: "non-utf8 path".into(), - } - }) + msg: format!("non-utf8 path: {}", path.display()), + }) } diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index dc97b4ea..1f5b565e 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -5,6 +5,8 @@ use crate::logging::LogTimer; use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::runtime_select::create_component_runtime_instance; use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; use log::{debug, error, info, trace, warn}; @@ -367,16 +369,21 @@ pub fn open_repo_via_plugin_vcs_backend( } })?; + let workspace_root = std::fs::canonicalize(path).map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: format!("canonicalize repo root: {e}"), + })?; + trace!( - "open_repo_via_plugin_vcs_backend: getting runtime for plugin {}", + "open_repo_via_plugin_vcs_backend: resolving spawn for plugin {}", desc.plugin_id ); - let runtime = runtime_manager - .runtime_for_workspace_with_config(cfg, &desc.plugin_id, Some(path.to_path_buf())) + let spawn = runtime_manager + .vcs_spawn_for_workspace_with_config(cfg, &desc.plugin_id, workspace_root) .map_err(|e| { error!( - "open_repo_via_plugin_vcs_backend: failed to get runtime for plugin {}: {}", + "open_repo_via_plugin_vcs_backend: failed to resolve spawn for plugin {}: {}", desc.plugin_id, e ); VcsError::Backend { @@ -385,6 +392,22 @@ pub fn open_repo_via_plugin_vcs_backend( } })?; + let runtime = create_component_runtime_instance(spawn).map_err(|e| { + error!( + "open_repo_via_plugin_vcs_backend: failed to create runtime for plugin {}: {}", + desc.plugin_id, e + ); + VcsError::Backend { + backend: backend_id.clone(), + msg: e, + } + })?; + + runtime.ensure_running().map_err(|e| VcsError::Backend { + backend: backend_id.clone(), + msg: e, + })?; + debug!("open_repo_via_plugin_vcs_backend: opening via plugin proxy",); let result = PluginVcsProxy::open_with_process(backend_id.clone(), runtime, path, cfg_value); diff --git a/Backend/src/tauri_commands/backends.rs b/Backend/src/tauri_commands/backends.rs index d1f247f0..472f5b02 100644 --- a/Backend/src/tauri_commands/backends.rs +++ b/Backend/src/tauri_commands/backends.rs @@ -1,11 +1,10 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use log::{error, info, warn}; -use serde_json::Value; -use tauri::{async_runtime, Runtime, State, Window}; +use tauri::{async_runtime, State}; use openvcs_core::BackendId; use std::collections::BTreeMap; @@ -178,99 +177,3 @@ pub async fn reopen_current_repo_cmd(state: State<'_, AppState>) -> Result<(), S state.set_current_repo(new_repo); Ok(()) } - -/// Call an arbitrary RPC method on a VCS backend module. -/// -/// This is intentionally backend-agnostic so plugin UI can access backend-specific helpers -/// (e.g. Git LFS) without hardcoding them into the host's generic VCS trait. -/// -/// # Parameters -/// - `window`: Calling Tauri window handle. -/// - `state`: Shared application state. -/// - `backend_id`: Backend id to invoke. -/// - `method`: RPC method name. -/// - `params`: JSON payload passed to the backend method. -/// -/// # Returns -/// - `Ok(Value)` containing the backend method result. -/// - `Err(String)` when validation, backend resolution, or RPC execution fails. -#[tauri::command] -pub async fn call_vcs_backend_method( - _window: Window, - state: State<'_, AppState>, - backend_id: BackendId, - method: String, - params: Value, -) -> Result { - let backend_id_str = backend_id.as_ref().to_string(); - let method = method.trim().to_string(); - if method.is_empty() { - return Err("method is empty".to_string()); - } - - let desc = plugin_vcs_backends::plugin_vcs_backend_descriptor(&backend_id) - .map_err(|_| format!("Unknown VCS backend: {backend_id_str}"))?; - - let repo_root = state - .current_repo() - .map(|repo| repo.inner().workdir().to_path_buf()) - .ok_or_else(|| "No repository selected".to_string())?; - let allowed_workspace_root = resolve_allowed_workspace_root(&repo_root, ¶ms)?; - - let cfg = state.config(); - state - .plugin_runtime() - .call_module_method_for_workspace_with_config( - &cfg, - &desc.plugin_id, - &method, - params, - allowed_workspace_root, - ) -} - -/// Resolves optional backend workspace path and enforces repo-root confinement. -/// -/// # Parameters -/// - `repo_root`: Repository root path. -/// - `params`: Backend method params. -/// -/// # Returns -/// - `Ok(Some(PathBuf))` resolved workspace path. -/// - `Ok(None)` when no path restriction should be applied. -/// - `Err(String)` when requested path escapes repo root. -fn resolve_allowed_workspace_root( - repo_root: &Path, - params: &Value, -) -> Result, String> { - let repo_root = std::fs::canonicalize(repo_root) - .map_err(|e| format!("Failed to resolve repository root: {e}"))?; - - let requested = params - .get("path") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(PathBuf::from); - - let Some(requested) = requested else { - return Ok(Some(repo_root)); - }; - - let requested_abs = if requested.is_absolute() { - requested - } else { - repo_root.join(requested) - }; - let requested_abs = std::fs::canonicalize(&requested_abs) - .map_err(|e| format!("Invalid backend workspace path: {e}"))?; - - if requested_abs == repo_root || requested_abs.starts_with(&repo_root) { - Ok(Some(requested_abs)) - } else { - Err(format!( - "Backend workspace path escapes repository root: {}", - requested_abs.display() - )) - } -} diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index eec2032d..e0c00fd9 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -378,83 +378,6 @@ pub fn set_plugin_permissions( Ok(()) } -#[tauri::command] -/// Lists callable functions exported by a plugin module component. -/// -/// # Parameters -/// - `plugin_id`: Plugin id to inspect. -/// -/// # Returns -/// - `Ok(Value)` containing function descriptors. -/// - `Err(String)` when plugin lookup or RPC fails. -pub fn list_plugin_functions( - state: State<'_, AppState>, - plugin_id: String, -) -> Result { - let cfg = state.config(); - state.plugin_runtime().call_module_method_with_config( - &cfg, - plugin_id.trim(), - "functions.list", - Value::Null, - ) -} - -#[tauri::command] -/// Invokes a plugin function by id. -/// -/// # Parameters -/// - `plugin_id`: Plugin id to invoke. -/// - `function_id`: Function id exported by the plugin. -/// - `args`: JSON argument payload. -/// -/// # Returns -/// - `Ok(Value)` function result payload. -/// - `Err(String)` when invocation fails. -pub fn invoke_plugin_function( - state: State<'_, AppState>, - plugin_id: String, - function_id: String, - args: Value, -) -> Result { - let cfg = state.config(); - state.plugin_runtime().call_module_method_with_config( - &cfg, - plugin_id.trim(), - "functions.invoke", - serde_json::json!({ "id": function_id.trim(), "args": args }), - ) -} - -#[tauri::command] -/// Calls an arbitrary method on a plugin module component. -/// -/// # Parameters -/// - `plugin_id`: Plugin id to invoke. -/// - `method`: Module RPC method name. -/// - `params`: Optional JSON params payload. -/// -/// # Returns -/// - `Ok(Value)` method result payload. -/// - `Err(String)` when lookup/validation/RPC fails. -pub fn call_plugin_module_method( - state: State<'_, AppState>, - plugin_id: String, - method: String, - params: Option, -) -> Result { - let method = method.trim(); - if method.is_empty() { - return Err("method is empty".to_string()); - } - - let cfg = state.config(); - let params = params.unwrap_or(Value::Null); - state - .plugin_runtime() - .call_module_method_with_config(&cfg, plugin_id.trim(), method, params) -} - /// Returns plugin-contributed menus for enabled plugins. /// /// # Parameters diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index 08d27080..739aa26a 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -127,9 +127,7 @@ declare global { invoke(cmd: string, args?: Json): Promise; listen(event: string, cb: (evt: { payload: T }) => void): Promise<{ unlisten: () => void }>; notify(msg: string): void; - callPlugin?(pluginId: string, method: string, params?: Json): Promise; }; - callPluginMethod?: (pluginId: string, method: string, params?: Json) => Promise; __openvcsPluginContext?: { id: string } | null; } } @@ -464,17 +462,6 @@ function registerPlugin(reg: PluginRegistration) { /** Installs the `window.OpenVCS` plugin registration API once. */ function installGlobalApi() { if (window.OpenVCS) return; - const callPluginMethod = ( - pluginId: string, - method: string, - params?: Json, - ) => { - return TAURI.invoke('call_plugin_module_method', { - pluginId, - method, - params: params ?? null, - }); - }; window.OpenVCS = { registerPlugin, registerTheme, @@ -505,14 +492,7 @@ function installGlobalApi() { notify(msg: string) { notify(msg); }, - callPlugin(pluginId: string, method: string, params?: Json) { - return callPluginMethod(pluginId, method, params); - }, }; - if (!window.callPluginMethod) { - window.callPluginMethod = (pluginId: string, method: string, params?: Json) => - callPluginMethod(pluginId, method, params); - } } /** Renders plugin-provided settings sections inside the settings modal. */ diff --git a/Frontend/src/scripts/state/state.ts b/Frontend/src/scripts/state/state.ts index 23c3284d..88d9fa67 100644 --- a/Frontend/src/scripts/state/state.ts +++ b/Frontend/src/scripts/state/state.ts @@ -71,8 +71,8 @@ export const state = { // repoPath: '' as string, }; -/** True iff a repo is selected AND we know the current branch. Always boolean. */ -export const hasRepo = (): boolean => Boolean(state.hasRepo && state.branch); +/** True iff a repository is selected. Always boolean. */ +export const hasRepo = (): boolean => Boolean(state.hasRepo); /** True iff there are staged/unstaged changes. Always boolean. */ export const hasChanges = (): boolean => diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 7c71b0f3..f9a712bc 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -101,6 +101,7 @@ startup. ## Runtime lifecycle - Module runtimes are started/stopped by lifecycle operations (startup sync and plugin enable/disable toggles). +- VCS backend plugin runtimes are repo-scoped and started only when opening a repository through that backend. - Backend plugin command calls do not implicitly start stopped plugin runtimes. - If a plugin is enabled but not currently running, module RPC/menu calls return a `not running` error until runtime is restored. diff --git a/docs/plugins.md b/docs/plugins.md index 990d795c..c1fc9524 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -68,6 +68,7 @@ Notes: - For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. +- Plugin IPC is contract-driven: backend calls map to typed WIT exports rather than arbitrary string-named module methods. - The Plugins details pane includes a bottom-right `Permissions` button that opens a stacked modal titled `Permissions for `. - The permissions modal lists only permissions requested by that plugin, shows segmented button choices (for example `Allow` / `Deny`, with richer choices for some permission groups), and includes an `Apply changes` button. - When a plugin requests no capabilities, the modal shows: `The plugin does not request permissions`. From 01c1d137c92ac28360d6113a038bad9444afdd7c Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 10:51:36 +0000 Subject: [PATCH 75/96] Update --- Backend/src/lib.rs | 1 + .../src/plugin_runtime/component_instance.rs | 293 ++++++++++++------ Backend/src/plugin_runtime/host_api.rs | 5 +- Backend/src/plugin_vcs_backends.rs | 75 ++++- Backend/src/tauri_commands/branches.rs | 22 +- Backend/src/tauri_commands/plugins.rs | 228 +++++++++++++- Backend/src/tauri_commands/remotes.rs | 30 +- Frontend/src/scripts/features/diff.ts | 13 - Frontend/src/scripts/features/settings.ts | 275 ++++++++++++---- Frontend/src/scripts/main.ts | 11 +- README.md | 4 +- 11 files changed, 733 insertions(+), 224 deletions(-) diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 974bf1d0..cd1a3f3a 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -286,6 +286,7 @@ fn build_invoke_handler( tauri_commands::set_plugin_permissions, tauri_commands::list_plugin_menus, tauri_commands::invoke_plugin_action, + tauri_commands::get_plugin_settings, tauri_commands::save_plugin_settings, tauri_commands::reset_plugin_settings, tauri_commands::get_global_settings, diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 2e50c2da..94c82b58 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -57,6 +57,7 @@ mod bindings_plugin_v1_1 { } use bindings_plugin_v1_1::exports::openvcs::plugin::plugin_api_v1_1; +use bindings_vcs::exports::openvcs::plugin::plugin_api_v1_1 as vcs_settings_api; use bindings_vcs::exports::openvcs::plugin::vcs_api; /// Typed bindings handle selected for the running plugin world. @@ -82,7 +83,7 @@ impl ComponentRuntime { fn call_init(&mut self, plugin_id: &str) -> Result<(), String> { match &self.bindings { ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_plugin_api() + .openvcs_plugin_plugin_api_v1_1() .call_init(&mut self.store) .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), @@ -104,7 +105,7 @@ impl ComponentRuntime { match &self.bindings { ComponentBindings::Vcs(bindings) => { let _ = bindings - .openvcs_plugin_plugin_api() + .openvcs_plugin_plugin_api_v1_1() .call_deinit(&mut self.store); } ComponentBindings::Plugin(bindings) => { @@ -159,21 +160,39 @@ impl ComponentRuntime { /// Returns plugin settings defaults for v1.1 plugins. fn call_settings_defaults(&mut self, plugin_id: &str) -> Result, String> { - let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, - _ => return Ok(Vec::new()), - }; - let values = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_defaults(&mut self.store) - .map_err(|e| format!("component settings-defaults trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-defaults failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(values.into_iter().map(map_setting_from_wit).collect()) + match &self.bindings { + ComponentBindings::PluginV11(bindings) => { + let values = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_defaults(&mut self.store) + .map_err(|e| { + format!("component settings-defaults trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-defaults failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(values.into_iter().map(map_setting_from_wit).collect()) + } + ComponentBindings::Vcs(bindings) => { + let values = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_defaults(&mut self.store) + .map_err(|e| { + format!("component settings-defaults trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-defaults failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(values.into_iter().map(map_setting_from_vcs_wit).collect()) + } + _ => Ok(Vec::new()), + } } /// Calls plugin settings-on-load hook for v1.1 plugins. @@ -182,25 +201,43 @@ impl ComponentRuntime { plugin_id: &str, values: Vec, ) -> Result, String> { - let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, - _ => return Ok(values), - }; - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_load(&mut self.store, &values) - .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-load failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_wit).collect()) + match &self.bindings { + ComponentBindings::PluginV11(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_load(&mut self.store, &values) + .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-load failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_wit).collect()) + } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_load(&mut self.store, &values) + .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-load failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) + } + _ => Ok(values), + } } /// Calls plugin settings-on-apply hook for v1.1 plugins. @@ -209,24 +246,45 @@ impl ComponentRuntime { plugin_id: &str, values: Vec, ) -> Result<(), String> { - let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, - _ => return Ok(()), - }; - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_apply(&mut self.store, &values) - .map_err(|e| format!("component settings-on-apply trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-apply failed for {}: {}", - plugin_id, e.message - ) - }) + match &self.bindings { + ComponentBindings::PluginV11(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_apply(&mut self.store, &values) + .map_err(|e| { + format!("component settings-on-apply trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-on-apply failed for {}: {}", + plugin_id, e.message + ) + }) + } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_apply(&mut self.store, &values) + .map_err(|e| { + format!("component settings-on-apply trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-on-apply failed for {}: {}", + plugin_id, e.message + ) + }) + } + _ => Ok(()), + } } /// Calls plugin settings-on-save hook for v1.1 plugins. @@ -235,43 +293,70 @@ impl ComponentRuntime { plugin_id: &str, values: Vec, ) -> Result, String> { - let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, - _ => return Ok(values), - }; - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_save(&mut self.store, &values) - .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-save failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_wit).collect()) + match &self.bindings { + ComponentBindings::PluginV11(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_save(&mut self.store, &values) + .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-save failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_wit).collect()) + } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_save(&mut self.store, &values) + .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-save failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) + } + _ => Ok(values), + } } /// Calls plugin settings-on-reset hook for v1.1 plugins. fn call_settings_on_reset(&mut self, plugin_id: &str) -> Result<(), String> { - let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, - _ => return Ok(()), - }; - bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_reset(&mut self.store) - .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-reset failed for {}: {}", - plugin_id, e.message - ) - }) + match &self.bindings { + ComponentBindings::PluginV11(bindings) => bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_reset(&mut self.store) + .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-reset failed for {}: {}", + plugin_id, e.message + ) + }), + ComponentBindings::Vcs(bindings) => bindings + .openvcs_plugin_plugin_api_v1_1() + .call_settings_on_reset(&mut self.store) + .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-reset failed for {}: {}", + plugin_id, e.message + ) + }), + _ => Ok(()), + } } } @@ -869,7 +954,10 @@ impl ComponentPluginRuntimeInstance { /// Loads and applies persisted plugin settings for v1.1 plugins. fn apply_persisted_settings(&self, runtime: &mut ComponentRuntime) -> Result<(), String> { - if !matches!(runtime.bindings, ComponentBindings::PluginV11(_)) { + if !matches!( + runtime.bindings, + ComponentBindings::PluginV11(_) | ComponentBindings::Vcs(_) + ) { return Ok(()); } @@ -1649,6 +1737,20 @@ fn map_setting_from_wit(setting: plugin_api_v1_1::SettingKv) -> SettingKv { } } +/// Maps settings value from VCS settings interface into core settings model. +fn map_setting_from_vcs_wit(setting: vcs_settings_api::SettingKv) -> SettingKv { + SettingKv { + id: setting.id, + value: match setting.value { + vcs_settings_api::SettingValue::Boolean(v) => SettingValue::Bool(v), + vcs_settings_api::SettingValue::Signed32(v) => SettingValue::S32(v), + vcs_settings_api::SettingValue::Unsigned32(v) => SettingValue::U32(v), + vcs_settings_api::SettingValue::Float64(v) => SettingValue::F64(v), + vcs_settings_api::SettingValue::Text(v) => SettingValue::String(v), + }, + } +} + /// Converts a shared core setting entry into a v1.1 WIT setting model. fn map_setting_to_wit(setting: SettingKv) -> plugin_api_v1_1::SettingKv { let value = match setting.value { @@ -1664,6 +1766,21 @@ fn map_setting_to_wit(setting: SettingKv) -> plugin_api_v1_1::SettingKv { } } +/// Maps core settings model into VCS settings interface values. +fn map_setting_to_vcs_wit(setting: SettingKv) -> vcs_settings_api::SettingKv { + let value = match setting.value { + SettingValue::Bool(v) => vcs_settings_api::SettingValue::Boolean(v), + SettingValue::S32(v) => vcs_settings_api::SettingValue::Signed32(v), + SettingValue::U32(v) => vcs_settings_api::SettingValue::Unsigned32(v), + SettingValue::F64(v) => vcs_settings_api::SettingValue::Float64(v), + SettingValue::String(v) => vcs_settings_api::SettingValue::Text(v), + }; + vcs_settings_api::SettingKv { + id: setting.id, + value, + } +} + /// Converts settings entries to a JSON map for persistence. fn settings_to_json_map(values: &[SettingKv]) -> serde_json::Map { let mut out = serde_json::Map::new(); diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 0bd3edde..704dfe23 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -601,7 +601,10 @@ pub fn host_process_exec( cmd.env(k, v); } for (k, v) in env { - if matches!(k.as_str(), "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT") { + if matches!( + k.as_str(), + "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT" | "OPENVCS_SSH_MODE" | "OPENVCS_SSH" + ) { cmd.env(k, v); } } diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 1f5b565e..26f293a2 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -7,6 +7,7 @@ use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::runtime_select::create_component_runtime_instance; +use crate::plugin_runtime::settings_store; use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; use log::{debug, error, info, trace, warn}; @@ -20,6 +21,69 @@ use std::{ const MODULE: &str = "plugin_vcs_backends"; +/// Default merge message template used for legacy migration. +const DEFAULT_MERGE_TEMPLATE: &str = "Merged branch '{branch:source}' into '{branch:target}'"; + +/// Returns plugin-scoped open config for a VCS backend plugin. +fn plugin_open_config(cfg: &AppConfig, plugin_id: &str) -> serde_json::Value { + let mut settings = settings_store::load_settings(plugin_id).unwrap_or_default(); + + // Back-compat migration: if legacy host git settings exist but plugin settings are + // empty, seed the plugin settings once so VCS backends are plugin-scoped. + if settings.is_empty() && plugin_id.eq_ignore_ascii_case("openvcs.git") { + settings.insert( + "prune_on_fetch".to_string(), + serde_json::Value::Bool(cfg.git.prune_on_fetch), + ); + settings.insert( + "fetch_on_focus".to_string(), + serde_json::Value::Bool(cfg.git.fetch_on_focus), + ); + settings.insert( + "allow_hooks".to_string(), + serde_json::Value::String( + match cfg.git.allow_hooks { + crate::settings::HookPolicy::Allow => "allow", + crate::settings::HookPolicy::Ask => "ask", + crate::settings::HookPolicy::Deny => "deny", + } + .to_string(), + ), + ); + settings.insert( + "ssh_binary".to_string(), + serde_json::Value::String( + match cfg.git.ssh_binary { + crate::settings::GitSshBinary::Auto => "auto", + crate::settings::GitSshBinary::Host => "host", + crate::settings::GitSshBinary::Bundled => "bundled", + crate::settings::GitSshBinary::Custom => "custom", + } + .to_string(), + ), + ); + settings.insert( + "ssh_path".to_string(), + serde_json::Value::String(cfg.git.ssh_path.clone()), + ); + settings.insert( + "respect_core_autocrlf".to_string(), + serde_json::Value::Bool(cfg.git.respect_core_autocrlf), + ); + settings.insert( + "merge_commit_message_template".to_string(), + serde_json::Value::String(if cfg.git.merge_commit_message_template.trim().is_empty() { + DEFAULT_MERGE_TEMPLATE.to_string() + } else { + cfg.git.merge_commit_message_template.clone() + }), + ); + let _ = settings_store::save_settings(plugin_id, &settings); + } + + serde_json::Value::Object(settings) +} + /// Determines whether a plugin is enabled considering config overrides. /// /// # Parameters @@ -358,16 +422,7 @@ pub fn open_repo_via_plugin_vcs_backend( desc.plugin_id ); - let cfg_value = serde_json::to_value(cfg).map_err(|e| { - error!( - "open_repo_via_plugin_vcs_backend: failed to serialize config: {}", - e - ); - VcsError::Backend { - backend: backend_id.clone(), - msg: format!("serialize config: {e}"), - } - })?; + let cfg_value = plugin_open_config(cfg, &desc.plugin_id); let workspace_root = std::fs::canonicalize(path).map_err(|e| VcsError::Backend { backend: backend_id.clone(), diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 3d9daef1..caec4fba 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -7,10 +7,13 @@ use tauri::State; use openvcs_core::models::{BranchItem, BranchKind}; +use crate::plugin_runtime::settings_store; use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; +const DEFAULT_MERGE_TEMPLATE: &str = "Merged branch '{branch:source}' into '{branch:target}'"; + /// Extracts repository owner/user segment from remote URL. /// /// # Parameters @@ -104,6 +107,23 @@ fn apply_merge_template( .replace("{repo:username}", repo_username) } +/// Returns the merge commit message template from Git plugin settings. +fn git_merge_message_template(state: &AppState) -> String { + let legacy = state.with_config(|cfg| cfg.git.merge_commit_message_template.clone()); + let value = settings_store::load_settings("openvcs.git") + .ok() + .and_then(|settings| settings.get("merge_commit_message_template").cloned()) + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or(legacy); + + let trimmed = value.trim(); + if trimmed.is_empty() { + DEFAULT_MERGE_TEMPLATE.to_string() + } else { + trimmed.to_string() + } +} + #[tauri::command] /// Returns normalized local/remote branches for the current repository. /// @@ -372,7 +392,7 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul } let repo = current_repo_or_err(&state)?; let branch = name.to_string(); - let template = state.with_config(|cfg| cfg.git.merge_commit_message_template.clone()); + let template = git_merge_message_template(&state); run_repo_task("git_merge_branch", repo, move |repo| { let vcs = repo.inner(); let target_branch = vcs diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index e0c00fd9..09542a9c 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -3,6 +3,7 @@ use crate::plugin_bundles::{ ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore, }; +use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::settings_store; use crate::plugins; use crate::state::AppState; @@ -10,6 +11,7 @@ use log::{debug, error, info, trace, warn}; use openvcs_core::settings::{SettingKv, SettingValue}; use openvcs_core::ui::{Menu, UiElement}; use serde_json::Value; +use std::sync::Arc; use tauri::Emitter; use tauri::Manager; use tauri::{Runtime, State, Window}; @@ -23,6 +25,38 @@ pub struct PluginSettingEntry { pub value: Value, } +/// Choice item for settings rendered as a select input. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginSettingOptionPayload { + /// Persisted value for the option. + pub value: String, + /// User-visible option label. + pub label: String, +} + +/// Plugin setting metadata and current value payload. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginSettingFieldPayload { + /// Stable setting id. + pub id: String, + /// Setting value kind (`bool`, `s32`, `u32`, `f64`, `text`). + pub kind: String, + /// User-visible label. + pub label: String, + /// Optional help text. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Default setting value. + pub default_value: Value, + /// Effective current value (persisted override or default). + pub value: Value, + /// Optional options for text-select style inputs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + /// Origin of this schema (`runtime`). + pub source: String, +} + /// JSON-friendly plugin menu payload returned to frontend. #[derive(Debug, Clone, serde::Serialize)] pub struct PluginMenuPayload { @@ -522,6 +556,53 @@ pub fn invoke_plugin_action( runtime.handle_action(action_id.trim()) } +/// Returns plugin settings schema with effective values. +/// +/// # Parameters +/// - `state`: Application state. +/// - `plugin_id`: Plugin id. +/// +/// # Returns +/// - `Ok(Vec)` schema and values. +/// - `Err(String)` when plugin/settings resolution fails. +#[tauri::command] +pub fn get_plugin_settings( + state: State<'_, AppState>, + plugin_id: String, +) -> Result, String> { + let plugin_id = plugin_id.trim().to_string(); + if plugin_id.is_empty() { + return Err("plugin id is empty".to_string()); + } + + let cfg = state.config(); + migrate_legacy_git_settings_if_needed(&plugin_id, &cfg)?; + let (defaults, _runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + let persisted = settings_store::load_settings(&plugin_id)?; + + Ok(defaults + .into_iter() + .map(|default| { + let id = default.id.trim().to_string(); + let effective_value = persisted + .get(&id) + .and_then(|raw| setting_from_json(&id, raw, &default.value).ok()) + .unwrap_or_else(|| default.value.clone()); + + PluginSettingFieldPayload { + id, + kind: setting_kind_name(&default.value).to_string(), + label: default.id, + description: None, + default_value: setting_value_to_json(&default.value), + value: setting_value_to_json(&effective_value), + options: Vec::new(), + source: "runtime".to_string(), + } + }) + .collect::>()) +} + /// Saves plugin settings and applies them immediately. /// /// # Parameters @@ -544,14 +625,24 @@ pub fn save_plugin_settings( } let cfg = state.config(); - let runtime = state - .plugin_runtime() - .runtime_for_workspace_with_config(&cfg, &plugin_id, None)?; - let defaults = runtime.settings_defaults()?; + let (defaults, runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + if defaults.is_empty() { + return Err(format!("plugin `{plugin_id}` does not declare settings")); + } let incoming = merge_settings_with_defaults(defaults, values)?; - let normalized = runtime.settings_on_save(incoming)?; + + let normalized = if let Some(runtime) = runtime.as_ref() { + runtime.settings_on_save(incoming)? + } else { + incoming + }; + settings_store::save_settings(&plugin_id, &settings_to_json_map(&normalized))?; - runtime.settings_on_apply(normalized) + + if let Some(runtime) = runtime { + runtime.settings_on_apply(normalized)?; + } + Ok(()) } /// Resets plugin settings to defaults and applies them immediately. @@ -571,13 +662,126 @@ pub fn reset_plugin_settings(state: State<'_, AppState>, plugin_id: String) -> R } let cfg = state.config(); - let runtime = state - .plugin_runtime() - .runtime_for_workspace_with_config(&cfg, &plugin_id, None)?; - runtime.settings_on_reset()?; + let (_defaults, runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; + + if let Some(runtime) = runtime.as_ref() { + runtime.settings_on_reset()?; + } + settings_store::reset_settings(&plugin_id)?; - let defaults = runtime.settings_defaults()?; - runtime.settings_on_apply(defaults) + + if let Some(runtime) = runtime { + let defaults = runtime.settings_defaults()?; + runtime.settings_on_apply(defaults)?; + } + + Ok(()) +} + +/// Resolves plugin settings defaults from runtime hooks. +fn resolve_plugin_settings_defaults( + state: &AppState, + cfg: &crate::settings::AppConfig, + plugin_id: &str, +) -> Result<(Vec, Option>), String> { + let mut runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(cfg, plugin_id, None) + .ok(); + + if runtime.is_none() { + let _ = state.plugin_runtime().start_plugin(plugin_id); + runtime = state + .plugin_runtime() + .runtime_for_workspace_with_config(cfg, plugin_id, None) + .ok(); + } + + if let Some(runtime_ref) = runtime.as_ref() { + let runtime_defaults = runtime_ref.settings_defaults()?; + if !runtime_defaults.is_empty() { + return Ok((runtime_defaults, runtime)); + } + } + + Ok((Vec::new(), runtime)) +} + +/// Seeds plugin-scoped settings from legacy host Git settings once. +fn migrate_legacy_git_settings_if_needed( + plugin_id: &str, + cfg: &crate::settings::AppConfig, +) -> Result<(), String> { + if !plugin_id.eq_ignore_ascii_case("openvcs.git") { + return Ok(()); + } + + let current = settings_store::load_settings(plugin_id)?; + if !current.is_empty() { + return Ok(()); + } + + let mut seeded = serde_json::Map::new(); + seeded.insert( + "prune_on_fetch".to_string(), + serde_json::Value::Bool(cfg.git.prune_on_fetch), + ); + seeded.insert( + "fetch_on_focus".to_string(), + serde_json::Value::Bool(cfg.git.fetch_on_focus), + ); + seeded.insert( + "allow_hooks".to_string(), + serde_json::Value::String( + match cfg.git.allow_hooks { + crate::settings::HookPolicy::Allow => "allow", + crate::settings::HookPolicy::Ask => "ask", + crate::settings::HookPolicy::Deny => "deny", + } + .to_string(), + ), + ); + seeded.insert( + "ssh_binary".to_string(), + serde_json::Value::String( + match cfg.git.ssh_binary { + crate::settings::GitSshBinary::Auto => "auto", + crate::settings::GitSshBinary::Host => "host", + crate::settings::GitSshBinary::Bundled => "bundled", + crate::settings::GitSshBinary::Custom => "custom", + } + .to_string(), + ), + ); + seeded.insert( + "ssh_path".to_string(), + serde_json::Value::String(cfg.git.ssh_path.clone()), + ); + seeded.insert( + "respect_core_autocrlf".to_string(), + serde_json::Value::Bool(cfg.git.respect_core_autocrlf), + ); + seeded.insert( + "merge_commit_message_template".to_string(), + serde_json::Value::String(if cfg.git.merge_commit_message_template.trim().is_empty() { + "Merged branch '{branch:source}' into '{branch:target}'".to_string() + } else { + cfg.git.merge_commit_message_template.clone() + }), + ); + + settings_store::save_settings(plugin_id, &seeded) +} + +/// Returns a stable string kind name for a typed setting. +fn setting_kind_name(value: &SettingValue) -> &'static str { + match value { + SettingValue::Bool(_) => "bool", + SettingValue::S32(_) => "s32", + SettingValue::U32(_) => "u32", + SettingValue::F64(_) => "f64", + SettingValue::String(_) => "text", + } } /// Merges incoming frontend values into typed defaults. diff --git a/Backend/src/tauri_commands/remotes.rs b/Backend/src/tauri_commands/remotes.rs index 0649d51d..8437f88c 100644 --- a/Backend/src/tauri_commands/remotes.rs +++ b/Backend/src/tauri_commands/remotes.rs @@ -4,7 +4,6 @@ use log::{error, info, warn}; use tauri::{Emitter, Manager, Runtime, State, Window}; use openvcs_core::models::{CommitItem, LogQuery, VcsEvent}; -use openvcs_core::FetchOptions; use openvcs_core::Vcs; use openvcs_core::VcsError; @@ -234,9 +233,6 @@ pub async fn git_fetch( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; let current = run_repo_task("git_fetch", repo, move |repo| { info!("git_fetch called"); let on = Some(progress_bridge(app.clone())); @@ -268,10 +264,7 @@ pub async fn git_fetch( } info!("Fetching '{refspec}' from remote '{remote}' (current branch '{current}')"); - if let Err(e) = repo - .inner() - .fetch_with_options(&remote, &refspec, fetch_opts, on) - { + if let Err(e) = repo.inner().fetch(&remote, &refspec, on) { let msg = e.to_string(); let url = remote_url_for(repo.inner(), &remote).unwrap_or_default(); emit_ssh_prompt(&app, &remote, &url, &msg); @@ -309,9 +302,6 @@ pub async fn git_fetch_all( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; run_repo_task("git_fetch_all", repo, move |repo| { info!("git_fetch_all called"); let on = Some(progress_bridge(app.clone())); @@ -332,15 +322,9 @@ pub async fn git_fetch_all( // Some backends/environments can be picky about force-refspec syntax; fall back to a // non-force refspec so we still populate `refs/remotes//*` for the UI. - if let Err(e) = - repo.inner() - .fetch_with_options(&r, &refspec_force, fetch_opts, on.clone()) - { + if let Err(e) = repo.inner().fetch(&r, &refspec_force, on.clone()) { warn!("Fetch (force refspec) failed for remote '{r}': {e}; retrying without '+'"); - if let Err(e2) = - repo.inner() - .fetch_with_options(&r, &refspec, fetch_opts, on.clone()) - { + if let Err(e2) = repo.inner().fetch(&r, &refspec, on.clone()) { let msg = e2.to_string(); emit_ssh_prompt(&app, &r, &url, &msg); error!("Fetch failed for remote '{r}': {msg}"); @@ -513,9 +497,6 @@ pub async fn git_push( ) -> Result<(), String> { let repo = current_repo_or_err(&state)?; let app = window.app_handle().clone(); - let fetch_opts = FetchOptions { - prune: state.with_config(|c| c.git.prune_on_fetch), - }; let current = run_repo_task("git_push", repo, move |repo| { info!("git_push called"); let on = Some(progress_bridge(app.clone())); @@ -543,10 +524,7 @@ pub async fn git_push( // Pushing does not update local remote-tracking refs (refs/remotes/origin/*), // which the UI uses for ahead/behind; refresh them best-effort. let on_fetch = Some(progress_bridge(app)); - if let Err(e) = repo - .inner() - .fetch_with_options("origin", ¤t, fetch_opts, on_fetch) - { + if let Err(e) = repo.inner().fetch("origin", ¤t, on_fetch) { warn!("Post-push fetch failed for branch '{current}': {e}"); } diff --git a/Frontend/src/scripts/features/diff.ts b/Frontend/src/scripts/features/diff.ts index 82739b5c..f0bea069 100644 --- a/Frontend/src/scripts/features/diff.ts +++ b/Frontend/src/scripts/features/diff.ts @@ -37,19 +37,6 @@ export function bindCommit() { ...Object.keys(linesMap).filter(p => linesMap[p] && Object.keys(linesMap[p] || {}).length > 0), ])); - // Guard: libgit2 backend does not support partial-hunk commit (stage_patch) - if (TAURI.has && partialFiles.length > 0) { - try { - const cfg = await TAURI.invoke('get_global_settings'); - const backend = String(cfg?.git?.backend || 'system'); - if (backend === 'libgit2') { - notify('Partial-hunk commits are not supported with the Libgit2 backend. Commit full files or switch to System backend in Settings.'); - clearBusy('Ready'); - return; - } - } catch {} - } - // Build patch only from hunks; ignore selectedFiles for commit content per latest request let combinedPatch = ''; for (const path of partialFiles) { diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index c3fdc914..f16fd625 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -28,6 +28,21 @@ interface PluginMenuPayload { }>; } +interface PluginSettingOptionPayload { + value: string; + label: string; +} + +interface PluginSettingFieldPayload { + id: string; + kind: 'bool' | 's32' | 'u32' | 'f64' | 'text' | string; + label: string; + description?: string | null; + default_value: unknown; + value: unknown; + options?: PluginSettingOptionPayload[]; +} + function pluginSectionId(pluginId: string, menuId: string): string { return `plugin-${toKebab(`${pluginId}-${menuId}`)}`; } @@ -92,10 +107,12 @@ async function renderPluginMenus(modal: HTMLElement): Promise { } const pluginSources = new Map(); + const pluginNames = new Map(); for (const summary of Array.isArray(pluginSummaries) ? pluginSummaries : []) { const id = String(summary?.id || '').trim().toLowerCase(); if (!id) continue; pluginSources.set(id, String(summary?.source || '').trim().toLowerCase()); + pluginNames.set(id, String(summary?.name || summary?.id || '').trim() || id); } const pluginsNavBtn = nav.querySelector('[data-section="plugins"]'); @@ -177,6 +194,119 @@ async function renderPluginMenus(modal: HTMLElement): Promise { } panelsScroll.appendChild(panel); } + + for (const summary of Array.isArray(pluginSummaries) ? pluginSummaries : []) { + const pluginId = String(summary?.id || '').trim(); + const pluginKey = pluginId.toLowerCase(); + if (!pluginId) continue; + + let fields: PluginSettingFieldPayload[] = []; + try { + fields = await TAURI.invoke('get_plugin_settings', { pluginId }); + } catch { + continue; + } + if (!Array.isArray(fields) || fields.length === 0) continue; + + const section = `plugin-settings-${toKebab(pluginId)}`; + const navLi = document.createElement('li'); + navLi.dataset.pluginMenu = 'true'; + const navBtn = document.createElement('button'); + navBtn.className = 'seg-btn'; + navBtn.setAttribute('data-section', section); + navBtn.textContent = pluginNames.get(pluginKey) || pluginId; + navLi.appendChild(navBtn); + ensureThirdPartySublist().appendChild(navLi); + + const panel = document.createElement('form'); + panel.className = 'panel-form hidden'; + panel.setAttribute('data-panel', section); + panel.setAttribute('data-plugin-menu', 'true'); + panel.dataset.pluginId = pluginId; + panel.dataset.pluginSettings = 'true'; + + const settingsWrap = document.createElement('div'); + settingsWrap.className = 'group'; + const heading = document.createElement('h4'); + heading.className = 'settings-section-title'; + heading.textContent = 'Settings'; + settingsWrap.appendChild(heading); + + const controls = new Map(); + for (const field of fields) { + const settingId = String(field?.id || '').trim(); + if (!settingId) continue; + const kind = String(field?.kind || '').trim().toLowerCase(); + + const row = document.createElement('div'); + row.className = 'group'; + + const hasOptions = Array.isArray(field.options) && field.options.length > 0; + let control: HTMLInputElement | HTMLSelectElement; + + if (kind === 'bool') { + const labelEl = document.createElement('label'); + labelEl.className = 'checkbox'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = Boolean(field.value); + labelEl.appendChild(input); + labelEl.append(` ${String(field?.label || settingId).trim() || settingId}`); + control = input; + row.appendChild(labelEl); + } else if (kind === 'text' && hasOptions) { + const labelEl = document.createElement('label'); + labelEl.textContent = String(field?.label || settingId).trim() || settingId; + row.appendChild(labelEl); + const select = document.createElement('select'); + for (const option of field.options || []) { + const opt = document.createElement('option'); + opt.value = String(option?.value || ''); + opt.textContent = String(option?.label || option?.value || '').trim() || opt.value; + select.appendChild(opt); + } + const value = String(field?.value ?? ''); + if (value && Array.from(select.options).some((opt) => opt.value === value)) { + select.value = value; + } + control = select; + row.appendChild(control); + } else { + const labelEl = document.createElement('label'); + labelEl.textContent = String(field?.label || settingId).trim() || settingId; + row.appendChild(labelEl); + const input = document.createElement('input'); + if (kind === 's32' || kind === 'u32' || kind === 'f64') { + input.type = 'number'; + input.step = kind === 'f64' ? 'any' : '1'; + if (kind === 'u32') input.min = '0'; + const n = Number(field?.value ?? field?.default_value ?? 0); + input.value = Number.isFinite(n) ? String(n) : '0'; + } else { + input.type = 'text'; + input.value = String(field?.value ?? field?.default_value ?? ''); + } + control = input; + row.appendChild(control); + } + + control.setAttribute('data-setting-id', settingId); + control.setAttribute('data-setting-kind', kind); + controls.set(settingId, control); + + const description = String(field?.description || '').trim(); + if (description) { + const hint = document.createElement('small'); + hint.textContent = description; + row.appendChild(hint); + } + + settingsWrap.appendChild(row); + } + + panel.appendChild(settingsWrap); + panelsScroll.appendChild(panel); + } } async function rebuildThemePackOptions( @@ -266,13 +396,55 @@ function activateSection(modal: HTMLElement, section: string) { p.classList.toggle('hidden', p.getAttribute('data-panel') !== safeSection); }); - // Plugins are applied immediately (no Save/Cancel). + // Keep footer actions hidden for action-only plugin menu panels. const actions = modal.querySelector('.sheet-actions'); const activePanel = panels.querySelector( `.panel-form[data-panel="${CSS.escape(safeSection)}"]`, ); const isPluginMenuPanel = activePanel?.getAttribute('data-plugin-menu') === 'true'; - if (actions) actions.classList.toggle('hidden', safeSection === 'plugins' || isPluginMenuPanel); + const isPluginSettingsPanel = activePanel?.getAttribute('data-plugin-settings') === 'true'; + const hideActions = safeSection === 'plugins' || (isPluginMenuPanel && !isPluginSettingsPanel); + if (actions) actions.classList.toggle('hidden', hideActions); +} + +/** Collects typed plugin setting values from a plugin-settings panel. */ +function collectPluginSettingsFromPanel( + panel: HTMLElement, +): Array<{ id: string; value: unknown }> { + const entries: Array<{ id: string; value: unknown }> = []; + const controls = panel.querySelectorAll( + '[data-setting-id][data-setting-kind]', + ); + + for (const control of controls) { + const settingId = String(control.getAttribute('data-setting-id') || '').trim(); + const kind = String(control.getAttribute('data-setting-kind') || '') + .trim() + .toLowerCase(); + if (!settingId || !kind) continue; + + let value: unknown; + if (kind === 'bool' && control instanceof HTMLInputElement) { + value = control.checked; + } else if (kind === 's32' || kind === 'u32' || kind === 'f64') { + const n = Number(control.value); + if (!Number.isFinite(n)) { + value = 0; + } else if (kind === 's32') { + value = Math.trunc(n); + } else if (kind === 'u32') { + value = Math.max(0, Math.trunc(n)); + } else { + value = n; + } + } else { + value = control.value ?? ''; + } + + entries.push({ id: settingId, value }); + } + + return entries; } export function wireSettings() { @@ -410,19 +582,27 @@ export function wireSettings() { settingsSave?.addEventListener('click', async () => { try { - const baseRaw = (modal as HTMLElement).dataset.currentCfg || '{}'; - const base = JSON.parse(baseRaw || '{}'); - const prevBackend: string = String(base?.git?.backend || 'system'); + const activePanel = modal.querySelector('#settings-panels .panel-form:not(.hidden)'); + if (activePanel?.getAttribute('data-plugin-settings') === 'true') { + if (!TAURI.has) return; + const pluginId = String(activePanel.dataset.pluginId || '').trim(); + if (!pluginId) { + notify('Failed to save plugin settings'); + return; + } + await TAURI.invoke('save_plugin_settings', { + pluginId, + values: collectPluginSettingsFromPanel(activePanel), + }); + notify('Plugin settings saved'); + closeModal('settings-modal'); + return; + } + const next = collectSettingsFromForm(modal); if (TAURI.has) { await TAURI.invoke('set_global_settings', { cfg: next }); - - // If Git engine changed, reopen the current repo so the plugin can reconfigure. - const newBackend: string = String(next?.git?.backend || 'system'); - if (newBackend && newBackend !== prevBackend) { - try { await TAURI.invoke('reopen_current_repo_cmd'); } catch {} - } } modal.dataset.currentCfg = JSON.stringify(next); @@ -451,6 +631,22 @@ export function wireSettings() { settingsReset?.addEventListener('click', async () => { try { + const activePanel = modal.querySelector('#settings-panels .panel-form:not(.hidden)'); + if (activePanel?.getAttribute('data-plugin-settings') === 'true') { + if (!TAURI.has) return; + const pluginId = String(activePanel.dataset.pluginId || '').trim(); + const section = String(activePanel.getAttribute('data-panel') || '').trim(); + if (!pluginId) { + notify('Failed to reset plugin settings'); + return; + } + await TAURI.invoke('reset_plugin_settings', { pluginId }); + notify('Plugin settings reset'); + await renderPluginMenus(modal); + if (section) activateSection(modal, section); + return; + } + if (!TAURI.has) return; const cur = await TAURI.invoke('get_global_settings'); @@ -465,7 +661,6 @@ export function wireSettings() { telemetry: false, crash_reports: false, }; - cur.git = { backend: 'system', default_branch: 'main', prune_on_fetch: true, fetch_on_focus: true, allow_hooks: 'ask', respect_core_autocrlf: true, merge_commit_message_template: "Merged branch '{branch:source}' into '{branch:target}'" }; cur.diff = { tab_width: 4, ignore_whitespace: 'none', max_file_size_mb: 10, intraline: true, show_binary_placeholders: true, external_diff: {enabled:false,path:'',args:''}, external_merge: {enabled:false,path:'',args:''}, binary_exts: ['png','jpg','dds','uasset'] }; cur.lfs = { enabled: true, concurrency: 4, require_lock_before_edit: false, background_fetch_on_checkout: true }; cur.performance = { progressive_render: true, gpu_accel: true, animations: true }; @@ -507,20 +702,6 @@ function collectSettingsFromForm(root: HTMLElement): GlobalSettings { checks_on_launch: !!get('#set-checks-on-launch')?.checked, }; - if (get('#set-git-backend') || get('#set-merge-message-template') || get('#set-git-ssh-binary')) { - o.git = { - ...o.git, - backend: get('#set-git-backend')?.value as any, - merge_commit_message_template: get('#set-merge-message-template')?.value ?? '', - ssh_binary: (get('#set-git-ssh-binary')?.value || 'auto') as any, - ssh_path: (get('#set-git-ssh-path')?.value || '').trim(), - prune_on_fetch: !!get('#set-prune-on-fetch')?.checked, - fetch_on_focus: !!get('#set-fetch-on-focus')?.checked, - allow_hooks: get('#set-hook-policy')?.value, - respect_core_autocrlf: !!get('#set-respect-autocrlf')?.checked, - }; - } - o.diff = { ...o.diff, tab_width: Number(get('#set-tab-width')?.value ?? 0), @@ -660,24 +841,6 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { const elChk = get('#set-checks-on-launch'); if (elChk) elChk.checked = !!cfg.general?.checks_on_launch; const elRl = get('#set-recents-limit'); if (elRl) elRl.value = String(cfg.ux?.recents_limit ?? 10); - await refreshGitBackendOptions(m, cfg); - const elMmt = get('#set-merge-message-template'); - if (elMmt) elMmt.value = cfg.git?.merge_commit_message_template ?? ''; - const elSshBin = get('#set-git-ssh-binary'); - if (elSshBin) elSshBin.value = toKebab(cfg.git?.ssh_binary) || 'auto'; - const elSshPath = get('#set-git-ssh-path'); - if (elSshPath) elSshPath.value = cfg.git?.ssh_path ?? ''; - if (elSshPath) { - const enabled = (elSshBin?.value || 'auto') === 'custom'; - elSshPath.disabled = !enabled; - if (!enabled) elSshPath.value = ''; - } - const elPr = get('#set-prune-on-fetch'); if (elPr) elPr.checked = !!cfg.git?.prune_on_fetch; - const elFoF = get('#set-fetch-on-focus'); if (elFoF) elFoF.checked = !!cfg.git?.fetch_on_focus; - - const elHp = get('#set-hook-policy'); if (elHp) elHp.value = toKebab(cfg.git?.allow_hooks); - const elRc = get('#set-respect-autocrlf'); if (elRc) elRc.checked = !!cfg.git?.respect_core_autocrlf; - const elTw = get('#set-tab-width'); if (elTw) elTw.value = String(cfg.diff?.tab_width ?? 0); const elIw = get('#set-ignore-whitespace'); if (elIw) elIw.value = toKebab(cfg.diff?.ignore_whitespace); const elMx = get('#set-max-file-size-mb'); if (elMx) elMx.value = String(cfg.diff?.max_file_size_mb ?? 0); @@ -715,28 +878,6 @@ export async function loadSettingsIntoForm(root?: HTMLElement) { const elKeep= get('#set-log-keep'); if (elKeep) elKeep.value = String(cfg.logging?.retain_archives ?? 10); } -async function refreshGitBackendOptions(modal: HTMLElement, cfg: GlobalSettings) { - const elGb = modal.querySelector('#set-git-backend'); - if (!elGb) return; - - const backend = String(cfg.git?.backend || '').trim(); - const options: Array<[string, string]> = [ - ['system', 'System'], - ['libgit2', 'Libgit2'], - ]; - - elGb.innerHTML = ''; - for (const [id, label] of options) { - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = label; - elGb.appendChild(opt); - } - - elGb.disabled = false; - elGb.value = (backend === 'libgit2') ? 'libgit2' : 'system'; -} - async function refreshDefaultBackendOptions(modal: HTMLElement, cfg: GlobalSettings) { const el = modal.querySelector('#set-default-backend'); if (!el) return; @@ -1367,7 +1508,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await TAURI.invoke('set_global_settings', { cfg: next }); modal.dataset.currentCfg = JSON.stringify(next); await reloadPlugins(); - await refreshGitBackendOptions(modal, next); try { await refreshAvailableThemes(); const themeSel = modal.querySelector('#set-theme'); @@ -1417,7 +1557,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { await reloadPlugins(); await renderPluginMenus(modal); if (activeSection) activateSection(modal, activeSection); - await refreshGitBackendOptions(modal, await TAURI.invoke('get_global_settings')); try { await refreshAvailableThemes(); } catch (e) { console.warn('refreshAvailableThemes failed:', e); } diff --git a/Frontend/src/scripts/main.ts b/Frontend/src/scripts/main.ts index dd639bc9..80bc2056 100644 --- a/Frontend/src/scripts/main.ts +++ b/Frontend/src/scripts/main.ts @@ -509,11 +509,16 @@ async function boot() { async function onFocus() { if (focusInFlight) return focusInFlight; focusInFlight = (async () => { - let doFetch = false; + let doFetch = true; if (TAURI.has) { try { - const cfg = await TAURI.invoke('get_global_settings'); - doFetch = cfg?.git?.fetch_on_focus !== false; // default true when unset + const fields = await TAURI.invoke>('get_plugin_settings', { + pluginId: 'openvcs.git', + }); + const fetchSetting = (Array.isArray(fields) ? fields : []).find((field) => String(field?.id || '').trim() === 'fetch_on_focus'); + if (fetchSetting && typeof fetchSetting.value === 'boolean') { + doFetch = fetchSetting.value; + } } catch {} } if (doFetch) { diff --git a/README.md b/README.md index 0d97f4f2..4d57eb9f 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. ## Features (Current) -- 🔗 **Git support** with a selectable backend (**system Git** by default; **libgit2** optional). +- 🔗 **Git support** via the built-in `openvcs.git` plugin (System Git execution). - 📁 **Repo workflows:** clone, open existing repos, recent repos list, optional reopen of last repo on launch. - ✅ **Status & diffs:** working tree status, per-file diff, commit diff, discard changes. - 🧩 **Staging & commits:** stage files, partial staging/commits via patch, commit from index. @@ -127,7 +127,7 @@ A Flatpak manifest exists under `packaging/flatpak/`, but Flatpak support is cur Known issues/limitations: -- The sandbox does not provide `git`, but OpenVCS currently defaults to the **system Git** backend; in Flatpak you may need to switch to the **libgit2** backend in settings. +- The sandbox does not provide `git`, and OpenVCS currently relies on **system Git** via plugin execution. - If the frontend assets are not included correctly, the app can show a blank window / “could not connect to localhost” (dev server) instead of loading `Frontend/dist`. For local build notes see `packaging/flatpak/README.md`. From 07ec3b47d7d41b09c8a520c5312f192c857d4e70 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 11:01:11 +0000 Subject: [PATCH 76/96] Update functions --- Backend/src/plugin_vcs_backends.rs | 64 ++---------------------- Backend/src/state.rs | 15 ------ Backend/src/tauri_commands/branches.rs | 14 +++--- Backend/src/tauri_commands/plugins.rs | 67 -------------------------- 4 files changed, 11 insertions(+), 149 deletions(-) diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 26f293a2..1302700b 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -21,67 +21,9 @@ use std::{ const MODULE: &str = "plugin_vcs_backends"; -/// Default merge message template used for legacy migration. -const DEFAULT_MERGE_TEMPLATE: &str = "Merged branch '{branch:source}' into '{branch:target}'"; - /// Returns plugin-scoped open config for a VCS backend plugin. -fn plugin_open_config(cfg: &AppConfig, plugin_id: &str) -> serde_json::Value { - let mut settings = settings_store::load_settings(plugin_id).unwrap_or_default(); - - // Back-compat migration: if legacy host git settings exist but plugin settings are - // empty, seed the plugin settings once so VCS backends are plugin-scoped. - if settings.is_empty() && plugin_id.eq_ignore_ascii_case("openvcs.git") { - settings.insert( - "prune_on_fetch".to_string(), - serde_json::Value::Bool(cfg.git.prune_on_fetch), - ); - settings.insert( - "fetch_on_focus".to_string(), - serde_json::Value::Bool(cfg.git.fetch_on_focus), - ); - settings.insert( - "allow_hooks".to_string(), - serde_json::Value::String( - match cfg.git.allow_hooks { - crate::settings::HookPolicy::Allow => "allow", - crate::settings::HookPolicy::Ask => "ask", - crate::settings::HookPolicy::Deny => "deny", - } - .to_string(), - ), - ); - settings.insert( - "ssh_binary".to_string(), - serde_json::Value::String( - match cfg.git.ssh_binary { - crate::settings::GitSshBinary::Auto => "auto", - crate::settings::GitSshBinary::Host => "host", - crate::settings::GitSshBinary::Bundled => "bundled", - crate::settings::GitSshBinary::Custom => "custom", - } - .to_string(), - ), - ); - settings.insert( - "ssh_path".to_string(), - serde_json::Value::String(cfg.git.ssh_path.clone()), - ); - settings.insert( - "respect_core_autocrlf".to_string(), - serde_json::Value::Bool(cfg.git.respect_core_autocrlf), - ); - settings.insert( - "merge_commit_message_template".to_string(), - serde_json::Value::String(if cfg.git.merge_commit_message_template.trim().is_empty() { - DEFAULT_MERGE_TEMPLATE.to_string() - } else { - cfg.git.merge_commit_message_template.clone() - }), - ); - let _ = settings_store::save_settings(plugin_id, &settings); - } - - serde_json::Value::Object(settings) +fn plugin_open_config(plugin_id: &str) -> serde_json::Value { + serde_json::Value::Object(settings_store::load_settings(plugin_id).unwrap_or_default()) } /// Determines whether a plugin is enabled considering config overrides. @@ -422,7 +364,7 @@ pub fn open_repo_via_plugin_vcs_backend( desc.plugin_id ); - let cfg_value = plugin_open_config(cfg, &desc.plugin_id); + let cfg_value = plugin_open_config(&desc.plugin_id); let workspace_root = std::fs::canonicalize(path).map_err(|e| VcsError::Backend { backend: backend_id.clone(), diff --git a/Backend/src/state.rs b/Backend/src/state.rs index 2ddc6738..e475097c 100644 --- a/Backend/src/state.rs +++ b/Backend/src/state.rs @@ -114,21 +114,6 @@ impl AppState { self.config.read().clone() } - /// Read-only closure access (avoid cloning if you’re just reading). - /// - /// # Parameters - /// - `f`: Closure invoked with a shared reference to the current config. - /// - /// # Returns - /// - Whatever value the closure returns. - pub fn with_config(&self, f: F) -> R - where - F: FnOnce(&AppConfig) -> R, - { - let cfg = self.config.read(); - f(&cfg) - } - /// Replace whole config: validate → save → swap (readers never see an unsaved state). /// /// # Parameters diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index caec4fba..85204501 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -6,7 +6,9 @@ use log::{debug, error, info, warn}; use tauri::State; use openvcs_core::models::{BranchItem, BranchKind}; +use openvcs_core::BackendId; +use crate::plugin_vcs_backends; use crate::plugin_runtime::settings_store; use crate::state::AppState; @@ -107,14 +109,14 @@ fn apply_merge_template( .replace("{repo:username}", repo_username) } -/// Returns the merge commit message template from Git plugin settings. -fn git_merge_message_template(state: &AppState) -> String { - let legacy = state.with_config(|cfg| cfg.git.merge_commit_message_template.clone()); - let value = settings_store::load_settings("openvcs.git") +/// Returns the merge message template from the active backend plugin settings. +fn backend_merge_message_template(backend_id: &BackendId) -> String { + let value = plugin_vcs_backends::plugin_vcs_backend_descriptor(backend_id) .ok() + .and_then(|descriptor| settings_store::load_settings(&descriptor.plugin_id).ok()) .and_then(|settings| settings.get("merge_commit_message_template").cloned()) .and_then(|value| value.as_str().map(str::to_string)) - .unwrap_or(legacy); + .unwrap_or_default(); let trimmed = value.trim(); if trimmed.is_empty() { @@ -392,7 +394,7 @@ pub async fn git_merge_branch(state: State<'_, AppState>, name: String) -> Resul } let repo = current_repo_or_err(&state)?; let branch = name.to_string(); - let template = git_merge_message_template(&state); + let template = backend_merge_message_template(&repo.id()); run_repo_task("git_merge_branch", repo, move |repo| { let vcs = repo.inner(); let target_branch = vcs diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index 09542a9c..f03a7f7f 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -576,7 +576,6 @@ pub fn get_plugin_settings( } let cfg = state.config(); - migrate_legacy_git_settings_if_needed(&plugin_id, &cfg)?; let (defaults, _runtime) = resolve_plugin_settings_defaults(&state, &cfg, &plugin_id)?; let persisted = settings_store::load_settings(&plugin_id)?; @@ -707,72 +706,6 @@ fn resolve_plugin_settings_defaults( Ok((Vec::new(), runtime)) } -/// Seeds plugin-scoped settings from legacy host Git settings once. -fn migrate_legacy_git_settings_if_needed( - plugin_id: &str, - cfg: &crate::settings::AppConfig, -) -> Result<(), String> { - if !plugin_id.eq_ignore_ascii_case("openvcs.git") { - return Ok(()); - } - - let current = settings_store::load_settings(plugin_id)?; - if !current.is_empty() { - return Ok(()); - } - - let mut seeded = serde_json::Map::new(); - seeded.insert( - "prune_on_fetch".to_string(), - serde_json::Value::Bool(cfg.git.prune_on_fetch), - ); - seeded.insert( - "fetch_on_focus".to_string(), - serde_json::Value::Bool(cfg.git.fetch_on_focus), - ); - seeded.insert( - "allow_hooks".to_string(), - serde_json::Value::String( - match cfg.git.allow_hooks { - crate::settings::HookPolicy::Allow => "allow", - crate::settings::HookPolicy::Ask => "ask", - crate::settings::HookPolicy::Deny => "deny", - } - .to_string(), - ), - ); - seeded.insert( - "ssh_binary".to_string(), - serde_json::Value::String( - match cfg.git.ssh_binary { - crate::settings::GitSshBinary::Auto => "auto", - crate::settings::GitSshBinary::Host => "host", - crate::settings::GitSshBinary::Bundled => "bundled", - crate::settings::GitSshBinary::Custom => "custom", - } - .to_string(), - ), - ); - seeded.insert( - "ssh_path".to_string(), - serde_json::Value::String(cfg.git.ssh_path.clone()), - ); - seeded.insert( - "respect_core_autocrlf".to_string(), - serde_json::Value::Bool(cfg.git.respect_core_autocrlf), - ); - seeded.insert( - "merge_commit_message_template".to_string(), - serde_json::Value::String(if cfg.git.merge_commit_message_template.trim().is_empty() { - "Merged branch '{branch:source}' into '{branch:target}'".to_string() - } else { - cfg.git.merge_commit_message_template.clone() - }), - ); - - settings_store::save_settings(plugin_id, &seeded) -} - /// Returns a stable string kind name for a typed setting. fn setting_kind_name(value: &SettingValue) -> &'static str { match value { From c80f6c723d974fa117e89cc5d0d7afe89b241869 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 11:30:19 +0000 Subject: [PATCH 77/96] Improve Settings menu button --- Frontend/src/scripts/features/repoSettings.ts | 13 ++++++++++- Frontend/src/scripts/features/settings.ts | 22 +++++++++++++++++-- Frontend/src/styles/components.css | 6 +++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Frontend/src/scripts/features/repoSettings.ts b/Frontend/src/scripts/features/repoSettings.ts index 1c0ca2ff..9016825d 100644 --- a/Frontend/src/scripts/features/repoSettings.ts +++ b/Frontend/src/scripts/features/repoSettings.ts @@ -20,6 +20,12 @@ export async function wireRepoSettings() { const addRemoteBtn = modal.querySelector('#git-remote-add') as HTMLButtonElement | null; const saveBtn = modal.querySelector('#repo-settings-save') as HTMLButtonElement | null; + if (saveBtn) { + saveBtn.style.width = '5rem'; + saveBtn.style.textAlign = 'center'; + saveBtn.style.boxSizing = 'border-box'; + } + const rows: RemoteRow[] = []; let initialRemotesKey = ''; @@ -122,7 +128,12 @@ export async function wireRepoSettings() { // Remote-tracking branches only exist after a fetch; do it once after remotes are modified. try { await TAURI.invoke('git_fetch_all', {}); } catch { /* ignore */ } } - closeModal('repo-settings-modal'); + saveBtn.classList.add('saved-state'); + saveBtn.textContent = 'Saved!'; + setTimeout(() => { + saveBtn.textContent = 'Save'; + saveBtn.classList.remove('saved-state'); + }, 2000); } catch { notify('Failed to save repository settings'); } diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index f16fd625..69324b0d 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -580,7 +580,15 @@ export function wireSettings() { const settingsSave = modal.querySelector('#settings-save') as HTMLButtonElement | null; const settingsReset = modal.querySelector('#settings-reset') as HTMLButtonElement | null; + if (settingsSave) { + settingsSave.style.width = '5rem'; + settingsSave.style.textAlign = 'center'; + } + settingsSave?.addEventListener('click', async () => { + if (!settingsSave || settingsSave.classList.contains('saved-state')) return; + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; try { const activePanel = modal.querySelector('#settings-panels .panel-form:not(.hidden)'); if (activePanel?.getAttribute('data-plugin-settings') === 'true') { @@ -595,7 +603,12 @@ export function wireSettings() { values: collectPluginSettingsFromPanel(activePanel), }); notify('Plugin settings saved'); - closeModal('settings-modal'); + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; + setTimeout(() => { + settingsSave.textContent = 'Save'; + settingsSave.classList.remove('saved-state'); + }, 2000); return; } @@ -625,7 +638,12 @@ export function wireSettings() { } catch {} notify('Settings saved'); - closeModal('settings-modal'); + settingsSave.classList.add('saved-state'); + settingsSave.textContent = 'Saved!'; + setTimeout(() => { + settingsSave.textContent = 'Save'; + settingsSave.classList.remove('saved-state'); + }, 2000); } catch (e) { console.error('Failed to save settings:', e); notify('Failed to save settings'); } }); diff --git a/Frontend/src/styles/components.css b/Frontend/src/styles/components.css index 146fc287..a74eee4a 100644 --- a/Frontend/src/styles/components.css +++ b/Frontend/src/styles/components.css @@ -54,6 +54,12 @@ button:disabled,.btn:disabled,.btn.primary:disabled,.tbtn:disabled,.pick:disable background:var(--surface-2); border-color:var(--border); color:var(--muted); } +/* Saved state - keep same dimensions as normal button */ +button.saved-state,.btn.saved-state,.tbtn.saved-state{ + opacity:1; cursor:default; pointer-events:auto; transform:none!important; + background:var(--accent); border-color:var(--accent); color:#fff; +} + /* Split button (main action + caret) */ .split-btn{ display:inline-flex; From 749b04106bcfd672ea1a3c354a9d68e29409f935 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 12:00:07 +0000 Subject: [PATCH 78/96] Update --- Backend/src/plugin_runtime/component_instance.rs | 4 ++++ Backend/src/tauri_commands/plugins.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 94c82b58..e2e81a27 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -1727,6 +1727,7 @@ fn map_menu_from_wit(menu: plugin_api_v1_1::Menu) -> Menu { fn map_setting_from_wit(setting: plugin_api_v1_1::SettingKv) -> SettingKv { SettingKv { id: setting.id, + label: setting.label, value: match setting.value { plugin_api_v1_1::SettingValue::Boolean(v) => SettingValue::Bool(v), plugin_api_v1_1::SettingValue::Signed32(v) => SettingValue::S32(v), @@ -1741,6 +1742,7 @@ fn map_setting_from_wit(setting: plugin_api_v1_1::SettingKv) -> SettingKv { fn map_setting_from_vcs_wit(setting: vcs_settings_api::SettingKv) -> SettingKv { SettingKv { id: setting.id, + label: setting.label, value: match setting.value { vcs_settings_api::SettingValue::Boolean(v) => SettingValue::Bool(v), vcs_settings_api::SettingValue::Signed32(v) => SettingValue::S32(v), @@ -1762,6 +1764,7 @@ fn map_setting_to_wit(setting: SettingKv) -> plugin_api_v1_1::SettingKv { }; plugin_api_v1_1::SettingKv { id: setting.id, + label: setting.label, value, } } @@ -1777,6 +1780,7 @@ fn map_setting_to_vcs_wit(setting: SettingKv) -> vcs_settings_api::SettingKv { }; vcs_settings_api::SettingKv { id: setting.id, + label: setting.label, value, } } diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index f03a7f7f..d97a7c02 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -589,9 +589,9 @@ pub fn get_plugin_settings( .unwrap_or_else(|| default.value.clone()); PluginSettingFieldPayload { - id, + id: id.clone(), kind: setting_kind_name(&default.value).to_string(), - label: default.id, + label: default.label.unwrap_or(id), description: None, default_value: setting_value_to_json(&default.value), value: setting_value_to_json(&effective_value), From c09f9c3269a7e66b30766ccbdf567b87d27cb941 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 14:27:45 +0000 Subject: [PATCH 79/96] Update plugins.md --- docs/plugins.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index c1fc9524..9ba04c13 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -4,7 +4,7 @@ OpenVCS plugins are local extensions installed as `.ovcsp` bundles. Plugins may include themes, a Wasm module, or both. -Module plugins may optionally export UI menus and settings lifecycle hooks via the v1.1 plugin world (`plugin-v1-1`) in `Core/wit/plugin.wit`. +Module plugins may optionally export UI menus and settings lifecycle hooks via the plugin world in `Core/wit/plugin.wit`. ## Where plugins live @@ -65,7 +65,7 @@ Notes: - If plugin runtime startup fails (including startup sync on app launch), the plugin list shows a persistent red `!` marker for that plugin until the next retry. - Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. - If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. -- For `plugin-v1-1` menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. +- For plugin menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. - Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. - Action buttons invoke plugin `handle-action` callbacks. - Plugin IPC is contract-driven: backend calls map to typed WIT exports rather than arbitrary string-named module methods. From 60c027c64d771043022f2300beb2fb38f8de33e4 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 14:27:48 +0000 Subject: [PATCH 80/96] Update plugin architecture.md --- docs/plugin architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index f9a712bc..a6ce7ee1 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -16,7 +16,7 @@ Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) The authoritative host/plugin contract lives under `Core/wit/`: - `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, process exec, notifications, logging, events) -- `Core/wit/plugin.wit`: base plugin lifecycle world (`plugin`) plus v1.1 plugin UI/settings world (`plugin-v1-1`) +- `Core/wit/plugin.wit`: plugin world with lifecycle, UI, and settings support - `Core/wit/vcs.wit`: VCS backend world (`vcs`) The backend generates host bindings from these contracts and links them into a Wasmtime component runtime. @@ -32,7 +32,7 @@ The backend generates host bindings from these contracts and links them into a W - Must implement `plugin-api.init` and `plugin-api.deinit`. - Module plugin (UI + settings lifecycle) - - Exports the `plugin-v1-1` world from `Core/wit/plugin.wit`. + - Exports the `plugin` world from `Core/wit/plugin.wit`. - Supports typed menu contributions (`get-menus` + `handle-action`) and settings hooks (`settings-defaults`, `settings-on-load`, `settings-on-apply`, `settings-on-save`, `settings-on-reset`). - Menu records include an optional `order` hint (`option`); host menu rendering sorts by `order` (ascending) then label. - Plugins can implement only the hooks they care about when using `#[openvcs_plugin]`; defaults are injected for omitted hooks. From c088983e3e7ef716fa1556325611c8223a5d0718 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 17:03:01 +0000 Subject: [PATCH 81/96] Update branches.rs --- Backend/src/tauri_commands/branches.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/src/tauri_commands/branches.rs b/Backend/src/tauri_commands/branches.rs index 85204501..b64106de 100644 --- a/Backend/src/tauri_commands/branches.rs +++ b/Backend/src/tauri_commands/branches.rs @@ -8,8 +8,8 @@ use tauri::State; use openvcs_core::models::{BranchItem, BranchKind}; use openvcs_core::BackendId; -use crate::plugin_vcs_backends; use crate::plugin_runtime::settings_store; +use crate::plugin_vcs_backends; use crate::state::AppState; use super::{current_repo_or_err, run_repo_task}; From 4e1d663b9e91da1183a5416bfb9886ece87e95ba Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 22 Feb 2026 17:03:03 +0000 Subject: [PATCH 82/96] Update component_instance.rs --- .../src/plugin_runtime/component_instance.rs | 448 +++--------------- 1 file changed, 55 insertions(+), 393 deletions(-) diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index e2e81a27..0002847d 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -48,26 +48,14 @@ mod bindings_plugin { }); } -mod bindings_plugin_v1_1 { - wasmtime::component::bindgen!({ - path: "../../Core/wit", - world: "plugin-v1-1", - additional_derives: [serde::Serialize, serde::Deserialize], - }); -} - -use bindings_plugin_v1_1::exports::openvcs::plugin::plugin_api_v1_1; -use bindings_vcs::exports::openvcs::plugin::plugin_api_v1_1 as vcs_settings_api; +use bindings_plugin::exports::openvcs::plugin::plugin_api; +use bindings_plugin::exports::openvcs::plugin::plugin_api as vcs_settings_api; use bindings_vcs::exports::openvcs::plugin::vcs_api; /// Typed bindings handle selected for the running plugin world. enum ComponentBindings { - /// Bindings for plugins exporting the `vcs` world. - Vcs(bindings_vcs::Vcs), - /// Bindings for plugins exporting the base `plugin` world. + /// Bindings for plugins exporting the `plugin` world. Plugin(bindings_plugin::Plugin), - /// Bindings for plugins exporting the `plugin-v1-1` world. - PluginV11(bindings_plugin_v1_1::PluginV11), } /// Live component instance plus generated bindings handle. @@ -82,53 +70,33 @@ impl ComponentRuntime { /// Calls plugin `init` for whichever world is currently loaded. fn call_init(&mut self, plugin_id: &str) -> Result<(), String> { match &self.bindings { - ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_plugin_api_v1_1() - .call_init(&mut self.store) - .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? - .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), ComponentBindings::Plugin(bindings) => bindings .openvcs_plugin_plugin_api() .call_init(&mut self.store) .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), - ComponentBindings::PluginV11(bindings) => bindings - .openvcs_plugin_plugin_api_v1_1() - .call_init(&mut self.store) - .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? - .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), } } /// Calls plugin `deinit` for whichever world is currently loaded. fn call_deinit(&mut self) { match &self.bindings { - ComponentBindings::Vcs(bindings) => { - let _ = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_deinit(&mut self.store); - } ComponentBindings::Plugin(bindings) => { let _ = bindings .openvcs_plugin_plugin_api() .call_deinit(&mut self.store); } - ComponentBindings::PluginV11(bindings) => { - let _ = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_deinit(&mut self.store); - } } } /// Returns plugin-contributed menus for v1.1 plugins. fn call_get_menus(&mut self, plugin_id: &str) -> Result, String> { let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, + ComponentBindings::Plugin(bindings) => bindings, _ => return Ok(Vec::new()), }; let menus = bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_get_menus(&mut self.store) .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? .map_err(|e| { @@ -143,11 +111,11 @@ impl ComponentRuntime { /// Invokes a plugin action for v1.1 plugins. fn call_handle_action(&mut self, plugin_id: &str, id: &str) -> Result<(), String> { let bindings = match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings, + ComponentBindings::Plugin(bindings) => bindings, _ => return Ok(()), }; bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_handle_action(&mut self.store, id) .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? .map_err(|e| { @@ -161,9 +129,9 @@ impl ComponentRuntime { /// Returns plugin settings defaults for v1.1 plugins. fn call_settings_defaults(&mut self, plugin_id: &str) -> Result, String> { match &self.bindings { - ComponentBindings::PluginV11(bindings) => { + ComponentBindings::Plugin(bindings) => { let values = bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_settings_defaults(&mut self.store) .map_err(|e| { format!("component settings-defaults trap for {}: {e}", plugin_id) @@ -176,22 +144,6 @@ impl ComponentRuntime { })?; Ok(values.into_iter().map(map_setting_from_wit).collect()) } - ComponentBindings::Vcs(bindings) => { - let values = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_defaults(&mut self.store) - .map_err(|e| { - format!("component settings-defaults trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-defaults failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(values.into_iter().map(map_setting_from_vcs_wit).collect()) - } - _ => Ok(Vec::new()), } } @@ -202,13 +154,13 @@ impl ComponentRuntime { values: Vec, ) -> Result, String> { match &self.bindings { - ComponentBindings::PluginV11(bindings) => { + ComponentBindings::Plugin(bindings) => { let values = values .into_iter() .map(map_setting_to_wit) .collect::>(); let out = bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_settings_on_load(&mut self.store, &values) .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? .map_err(|e| { @@ -219,24 +171,6 @@ impl ComponentRuntime { })?; Ok(out.into_iter().map(map_setting_from_wit).collect()) } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_load(&mut self.store, &values) - .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-load failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) - } - _ => Ok(values), } } @@ -247,31 +181,13 @@ impl ComponentRuntime { values: Vec, ) -> Result<(), String> { match &self.bindings { - ComponentBindings::PluginV11(bindings) => { + ComponentBindings::Plugin(bindings) => { let values = values .into_iter() .map(map_setting_to_wit) .collect::>(); bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_apply(&mut self.store, &values) - .map_err(|e| { - format!("component settings-on-apply trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-on-apply failed for {}: {}", - plugin_id, e.message - ) - }) - } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_settings_on_apply(&mut self.store, &values) .map_err(|e| { format!("component settings-on-apply trap for {}: {e}", plugin_id) @@ -283,7 +199,6 @@ impl ComponentRuntime { ) }) } - _ => Ok(()), } } @@ -294,13 +209,13 @@ impl ComponentRuntime { values: Vec, ) -> Result, String> { match &self.bindings { - ComponentBindings::PluginV11(bindings) => { + ComponentBindings::Plugin(bindings) => { let values = values .into_iter() .map(map_setting_to_wit) .collect::>(); let out = bindings - .openvcs_plugin_plugin_api_v1_1() + .openvcs_plugin_plugin_api() .call_settings_on_save(&mut self.store, &values) .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? .map_err(|e| { @@ -311,42 +226,14 @@ impl ComponentRuntime { })?; Ok(out.into_iter().map(map_setting_from_wit).collect()) } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_save(&mut self.store, &values) - .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-save failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) - } - _ => Ok(values), } } /// Calls plugin settings-on-reset hook for v1.1 plugins. fn call_settings_on_reset(&mut self, plugin_id: &str) -> Result<(), String> { match &self.bindings { - ComponentBindings::PluginV11(bindings) => bindings - .openvcs_plugin_plugin_api_v1_1() - .call_settings_on_reset(&mut self.store) - .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-reset failed for {}: {}", - plugin_id, e.message - ) - }), - ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_plugin_api_v1_1() + ComponentBindings::Plugin(bindings) => bindings + .openvcs_plugin_plugin_api() .call_settings_on_reset(&mut self.store) .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? .map_err(|e| { @@ -355,7 +242,6 @@ impl ComponentRuntime { plugin_id, e.message ) }), - _ => Ok(()), } } } @@ -392,18 +278,7 @@ impl ComponentHostState { } } -impl WasiView for ComponentHostState { - /// Returns mutable WASI context and resource table view. - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { - /// Returns runtime metadata for the current host process. fn get_runtime_info( &mut self, ) -> Result< @@ -418,7 +293,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { }) } - /// Registers an event subscription for this plugin. fn subscribe_event( &mut self, event_name: String, @@ -427,7 +301,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_vcs) } - /// Emits a plugin-originated event through the host event bus. fn emit_event( &mut self, event_name: String, @@ -437,7 +310,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_vcs) } - /// Forwards plugin notifications to host-side UI notification handling. fn ui_notify( &mut self, message: String, @@ -445,7 +317,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) } - /// Sets footer status text through the host status API. fn set_status( &mut self, message: String, @@ -453,12 +324,10 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) } - /// Reads current footer status text through the host status API. fn get_status(&mut self) -> Result { host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_vcs) } - /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, path: String, @@ -466,7 +335,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error_vcs) } - /// Writes a workspace file under capability and path constraints. fn workspace_write_file( &mut self, path: String, @@ -476,7 +344,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_vcs) } - /// Executes a command in a constrained host environment. fn process_exec( &mut self, cwd: Option, @@ -509,7 +376,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { }) } - /// Logs plugin-emitted messages through the host logger. fn host_log( &mut self, level: bindings_vcs::openvcs::plugin::host_api::LogLevel, @@ -543,7 +409,6 @@ impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { } impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { - /// Returns runtime metadata for the current host process. fn get_runtime_info( &mut self, ) -> Result< @@ -558,7 +423,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { }) } - /// Registers an event subscription for this plugin. fn subscribe_event( &mut self, event_name: String, @@ -567,7 +431,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_plugin) } - /// Emits a plugin-originated event through the host event bus. fn emit_event( &mut self, event_name: String, @@ -577,7 +440,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_plugin) } - /// Forwards plugin notifications to host-side UI notification handling. fn ui_notify( &mut self, message: String, @@ -585,7 +447,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) } - /// Sets footer status text through the host status API. fn set_status( &mut self, message: String, @@ -593,14 +454,12 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) } - /// Reads current footer status text through the host status API. fn get_status( &mut self, ) -> Result { host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_plugin) } - /// Reads a workspace file under capability and path constraints. fn workspace_read_file( &mut self, path: String, @@ -609,7 +468,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_plugin) } - /// Writes a workspace file under capability and path constraints. fn workspace_write_file( &mut self, path: String, @@ -619,7 +477,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { .map_err(ComponentHostState::map_host_error_plugin) } - /// Executes a command in a constrained host environment. fn process_exec( &mut self, cwd: Option, @@ -654,7 +511,6 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { ) } - /// Logs plugin-emitted messages through the host logger. fn host_log( &mut self, level: bindings_plugin::openvcs::plugin::host_api::LogLevel, @@ -687,186 +543,13 @@ impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { } } -impl bindings_plugin_v1_1::openvcs::plugin::host_api::Host for ComponentHostState { - /// Returns runtime metadata for the current host process. - fn get_runtime_info( - &mut self, - ) -> Result< - bindings_plugin_v1_1::openvcs::plugin::host_api::RuntimeInfo, - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError, - > { - let value = host_runtime_info(); - Ok( - bindings_plugin_v1_1::openvcs::plugin::host_api::RuntimeInfo { - os: value.os, - arch: value.arch, - container: value.container, - }, - ) - } - - /// Registers an event subscription for this plugin. - fn subscribe_event( - &mut self, - event_name: String, - ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_subscribe_event(&self.spawn, &event_name).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Emits a plugin-originated event through the host event bus. - fn emit_event( - &mut self, - event_name: String, - payload: Vec, - ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_emit_event(&self.spawn, &event_name, &payload).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Forwards plugin notifications to host-side UI notification handling. - fn ui_notify( - &mut self, - message: String, - ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_ui_notify(&self.spawn, &message).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Sets footer status text through the host status API. - fn set_status( - &mut self, - message: String, - ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_set_status(&self.spawn, &message).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Reads current footer status text through the host status API. - fn get_status( - &mut self, - ) -> Result { - host_get_status(&self.spawn).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Reads a workspace file under capability and path constraints. - fn workspace_read_file( - &mut self, - path: String, - ) -> Result, bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_workspace_read_file(&self.spawn, &path).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Writes a workspace file under capability and path constraints. - fn workspace_write_file( - &mut self, - path: String, - content: Vec, - ) -> Result<(), bindings_plugin_v1_1::openvcs::plugin::host_api::HostError> { - host_workspace_write_file(&self.spawn, &path, &content).map_err(|err| { - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - }) - } - - /// Executes a command in a constrained host environment. - fn process_exec( - &mut self, - cwd: Option, - program: String, - args: Vec, - env: Vec, - stdin: Option, - ) -> Result< - bindings_plugin_v1_1::openvcs::plugin::host_api::ProcessExecOutput, - bindings_plugin_v1_1::openvcs::plugin::host_api::HostError, - > { - let env = env - .into_iter() - .map(|var| (var.key, var.value)) - .collect::>(); - let value = host_process_exec( - &self.spawn, - cwd.as_deref(), - &program, - &args, - &env, - stdin.as_deref(), - ) - .map_err( - |err| bindings_plugin_v1_1::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - }, - )?; - Ok( - bindings_plugin_v1_1::openvcs::plugin::host_api::ProcessExecOutput { - success: value.success, - status: value.status, - stdout: value.stdout, - stderr: value.stderr, - }, - ) - } - - /// Logs plugin-emitted messages through the host logger. - fn host_log( - &mut self, - level: bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel, - target: String, - message: String, - ) { - let target = if target.trim().is_empty() { - format!("plugin.{}", self.spawn.plugin_id) - } else { - format!("plugin.{}.{}", self.spawn.plugin_id, target) - }; - - match level { - bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Trace => { - log::trace!(target: &target, "{message}") - } - bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Debug => { - log::debug!(target: &target, "{message}") - } - bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Info => { - log::info!(target: &target, "{message}") - } - bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Warn => { - log::warn!(target: &target, "{message}") - } - bindings_plugin_v1_1::openvcs::plugin::host_api::LogLevel::Error => { - log::error!(target: &target, "{message}") - } - }; +impl WasiView for ComponentHostState { + /// Returns mutable WASI context and resource table view. + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } } } @@ -903,26 +586,15 @@ impl ComponentPluginRuntimeInstance { wasi: WasiCtx::builder().build(), }, ); - let bindings = if self.spawn.is_vcs_backend { - bindings_vcs::Vcs::add_to_linker::< - ComponentHostState, - wasmtime::component::HasSelf, - >(&mut linker, |state| state) - .map_err(|e| format!("link host imports: {e}"))?; - - ComponentBindings::Vcs( - bindings_vcs::Vcs::instantiate(&mut store, &component, &linker) - .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, - ) - } else { - bindings_plugin_v1_1::PluginV11::add_to_linker::< + let bindings = { + bindings_plugin::Plugin::add_to_linker::< ComponentHostState, wasmtime::component::HasSelf, >(&mut linker, |state| state) .map_err(|e| format!("link host imports: {e}"))?; - match bindings_plugin_v1_1::PluginV11::instantiate(&mut store, &component, &linker) { - Ok(v11) => ComponentBindings::PluginV11(v11), + match bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) { + Ok(v11) => ComponentBindings::Plugin(v11), Err(_) => { let mut fallback_linker = Linker::new(engine); wasmtime_wasi::p2::add_to_linker_sync(&mut fallback_linker) @@ -954,10 +626,7 @@ impl ComponentPluginRuntimeInstance { /// Loads and applies persisted plugin settings for v1.1 plugins. fn apply_persisted_settings(&self, runtime: &mut ComponentRuntime) -> Result<(), String> { - if !matches!( - runtime.bindings, - ComponentBindings::PluginV11(_) | ComponentBindings::Vcs(_) - ) { + if !matches!(runtime.bindings, ComponentBindings::Plugin(_)) { return Ok(()); } @@ -994,24 +663,17 @@ impl ComponentPluginRuntimeInstance { f(runtime) } - /// Ensures the runtime exports the VCS world and runs a typed call. + /// Runs a closure with VCS bindings for VCS backend plugins. + /// NOTE: Currently VCS backends use plugin world, so this needs architectural changes. fn with_vcs_bindings( &self, method: &str, - f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, + _f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, ) -> Result { - self.with_runtime(|runtime| { - let bindings = match &runtime.bindings { - ComponentBindings::Vcs(bindings) => bindings, - _ => { - return Err(format!( - "component method `{method}` requires VCS backend exports for plugin `{}`", - self.spawn.plugin_id - )); - } - }; - f(bindings, &mut runtime.store) - }) + Err(format!( + "VCS operations not yet supported for plugin world-based VCS backends: {}", + method + )) } /// Converts nested trap/plugin results into backend error strings. @@ -1699,17 +1361,17 @@ impl ComponentPluginRuntimeInstance { } } -/// Converts a v1.1 WIT menu into the shared core menu model. -fn map_menu_from_wit(menu: plugin_api_v1_1::Menu) -> Menu { +/// Converts a plugin WIT menu into the shared core menu model. +fn map_menu_from_wit(menu: plugin_api::Menu) -> Menu { let elements = menu .elements .into_iter() .map(|element| match element { - plugin_api_v1_1::UiElement::Text(text) => UiElement::Text(UiText { + plugin_api::UiElement::Text(text) => UiElement::Text(UiText { id: text.id, content: text.content, }), - plugin_api_v1_1::UiElement::Button(button) => UiElement::Button(UiButton { + plugin_api::UiElement::Button(button) => UiElement::Button(UiButton { id: button.id, label: button.label, }), @@ -1723,17 +1385,17 @@ fn map_menu_from_wit(menu: plugin_api_v1_1::Menu) -> Menu { } } -/// Converts a v1.1 WIT setting entry into the shared core setting model. -fn map_setting_from_wit(setting: plugin_api_v1_1::SettingKv) -> SettingKv { +/// Converts a plugin WIT setting entry into the shared core setting model. +fn map_setting_from_wit(setting: plugin_api::SettingKv) -> SettingKv { SettingKv { id: setting.id, label: setting.label, value: match setting.value { - plugin_api_v1_1::SettingValue::Boolean(v) => SettingValue::Bool(v), - plugin_api_v1_1::SettingValue::Signed32(v) => SettingValue::S32(v), - plugin_api_v1_1::SettingValue::Unsigned32(v) => SettingValue::U32(v), - plugin_api_v1_1::SettingValue::Float64(v) => SettingValue::F64(v), - plugin_api_v1_1::SettingValue::Text(v) => SettingValue::String(v), + plugin_api::SettingValue::Boolean(v) => SettingValue::Bool(v), + plugin_api::SettingValue::Signed32(v) => SettingValue::S32(v), + plugin_api::SettingValue::Unsigned32(v) => SettingValue::U32(v), + plugin_api::SettingValue::Float64(v) => SettingValue::F64(v), + plugin_api::SettingValue::Text(v) => SettingValue::String(v), }, } } @@ -1753,16 +1415,16 @@ fn map_setting_from_vcs_wit(setting: vcs_settings_api::SettingKv) -> SettingKv { } } -/// Converts a shared core setting entry into a v1.1 WIT setting model. -fn map_setting_to_wit(setting: SettingKv) -> plugin_api_v1_1::SettingKv { +/// Converts a shared core setting entry into a plugin WIT setting model. +fn map_setting_to_wit(setting: SettingKv) -> plugin_api::SettingKv { let value = match setting.value { - SettingValue::Bool(v) => plugin_api_v1_1::SettingValue::Boolean(v), - SettingValue::S32(v) => plugin_api_v1_1::SettingValue::Signed32(v), - SettingValue::U32(v) => plugin_api_v1_1::SettingValue::Unsigned32(v), - SettingValue::F64(v) => plugin_api_v1_1::SettingValue::Float64(v), - SettingValue::String(v) => plugin_api_v1_1::SettingValue::Text(v), + SettingValue::Bool(v) => plugin_api::SettingValue::Boolean(v), + SettingValue::S32(v) => plugin_api::SettingValue::Signed32(v), + SettingValue::U32(v) => plugin_api::SettingValue::Unsigned32(v), + SettingValue::F64(v) => plugin_api::SettingValue::Float64(v), + SettingValue::String(v) => plugin_api::SettingValue::Text(v), }; - plugin_api_v1_1::SettingKv { + plugin_api::SettingKv { id: setting.id, label: setting.label, value, From 9e624d26115126bf3edc58af5e0e05095cda0a9e Mon Sep 17 00:00:00 2001 From: Jordon Date: Fri, 27 Feb 2026 01:28:15 +0000 Subject: [PATCH 83/96] Update --- Backend/built-in-plugins/Git | 2 +- .../src/plugin_runtime/component_instance.rs | 258 +++++++++++++----- 2 files changed, 198 insertions(+), 62 deletions(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index 7239f6da..d9d57293 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit 7239f6da7d1c6aec08dd79abc2bededf45f80972 +Subproject commit d9d572936c0c7eec4b91e47c83db54c4f475c450 diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs index 0002847d..ecbe627e 100644 --- a/Backend/src/plugin_runtime/component_instance.rs +++ b/Backend/src/plugin_runtime/component_instance.rs @@ -49,13 +49,15 @@ mod bindings_plugin { } use bindings_plugin::exports::openvcs::plugin::plugin_api; -use bindings_plugin::exports::openvcs::plugin::plugin_api as vcs_settings_api; +use bindings_vcs::exports::openvcs::plugin::plugin_api_vcs as vcs_settings_api; use bindings_vcs::exports::openvcs::plugin::vcs_api; /// Typed bindings handle selected for the running plugin world. enum ComponentBindings { /// Bindings for plugins exporting the `plugin` world. Plugin(bindings_plugin::Plugin), + /// Bindings for plugins exporting the `vcs` world. + Vcs(bindings_vcs::Vcs), } /// Live component instance plus generated bindings handle. @@ -75,6 +77,11 @@ impl ComponentRuntime { .call_init(&mut self.store) .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), + ComponentBindings::Vcs(bindings) => bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_init(&mut self.store) + .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? + .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), } } @@ -86,44 +93,70 @@ impl ComponentRuntime { .openvcs_plugin_plugin_api() .call_deinit(&mut self.store); } + ComponentBindings::Vcs(bindings) => { + let _ = bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_deinit(&mut self.store); + } } } /// Returns plugin-contributed menus for v1.1 plugins. fn call_get_menus(&mut self, plugin_id: &str) -> Result, String> { - let bindings = match &self.bindings { - ComponentBindings::Plugin(bindings) => bindings, - _ => return Ok(Vec::new()), - }; - let menus = bindings - .openvcs_plugin_plugin_api() - .call_get_menus(&mut self.store) - .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component get-menus failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(menus.into_iter().map(map_menu_from_wit).collect()) + match &self.bindings { + ComponentBindings::Plugin(bindings) => { + let menus = bindings + .openvcs_plugin_plugin_api() + .call_get_menus(&mut self.store) + .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component get-menus failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(menus.into_iter().map(map_menu_from_wit).collect()) + } + ComponentBindings::Vcs(bindings) => { + let menus = bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_get_menus(&mut self.store) + .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component get-menus failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(menus.into_iter().map(map_menu_from_vcs_wit).collect()) + } + } } /// Invokes a plugin action for v1.1 plugins. fn call_handle_action(&mut self, plugin_id: &str, id: &str) -> Result<(), String> { - let bindings = match &self.bindings { - ComponentBindings::Plugin(bindings) => bindings, - _ => return Ok(()), - }; - bindings - .openvcs_plugin_plugin_api() - .call_handle_action(&mut self.store, id) - .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component handle-action failed for {}: {}", - plugin_id, e.message - ) - }) + match &self.bindings { + ComponentBindings::Plugin(bindings) => bindings + .openvcs_plugin_plugin_api() + .call_handle_action(&mut self.store, id) + .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component handle-action failed for {}: {}", + plugin_id, e.message + ) + }), + ComponentBindings::Vcs(bindings) => bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_handle_action(&mut self.store, id) + .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component handle-action failed for {}: {}", + plugin_id, e.message + ) + }), + } } /// Returns plugin settings defaults for v1.1 plugins. @@ -144,6 +177,21 @@ impl ComponentRuntime { })?; Ok(values.into_iter().map(map_setting_from_wit).collect()) } + ComponentBindings::Vcs(bindings) => { + let values = bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_settings_defaults(&mut self.store) + .map_err(|e| { + format!("component settings-defaults trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-defaults failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(values.into_iter().map(map_setting_from_vcs_wit).collect()) + } } } @@ -171,6 +219,23 @@ impl ComponentRuntime { })?; Ok(out.into_iter().map(map_setting_from_wit).collect()) } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_settings_on_load(&mut self.store, &values) + .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-load failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) + } } } @@ -199,6 +264,24 @@ impl ComponentRuntime { ) }) } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_settings_on_apply(&mut self.store, &values) + .map_err(|e| { + format!("component settings-on-apply trap for {}: {e}", plugin_id) + })? + .map_err(|e| { + format!( + "component settings-on-apply failed for {}: {}", + plugin_id, e.message + ) + }) + } } } @@ -226,6 +309,23 @@ impl ComponentRuntime { })?; Ok(out.into_iter().map(map_setting_from_wit).collect()) } + ComponentBindings::Vcs(bindings) => { + let values = values + .into_iter() + .map(map_setting_to_vcs_wit) + .collect::>(); + let out = bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_settings_on_save(&mut self.store, &values) + .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-save failed for {}: {}", + plugin_id, e.message + ) + })?; + Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) + } } } @@ -242,6 +342,16 @@ impl ComponentRuntime { plugin_id, e.message ) }), + ComponentBindings::Vcs(bindings) => bindings + .openvcs_plugin_vcs_plugin_api_vcs() + .call_settings_on_reset(&mut self.store) + .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? + .map_err(|e| { + format!( + "component settings-on-reset failed for {}: {}", + plugin_id, e.message + ) + }), } } } @@ -586,36 +696,28 @@ impl ComponentPluginRuntimeInstance { wasi: WasiCtx::builder().build(), }, ); - let bindings = { + let bindings = if self.spawn.is_vcs_backend { + bindings_vcs::Vcs::add_to_linker::< + ComponentHostState, + wasmtime::component::HasSelf, + >(&mut linker, |state| state) + .map_err(|e| format!("link host imports: {e}"))?; + + ComponentBindings::Vcs( + bindings_vcs::Vcs::instantiate(&mut store, &component, &linker) + .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, + ) + } else { bindings_plugin::Plugin::add_to_linker::< ComponentHostState, wasmtime::component::HasSelf, >(&mut linker, |state| state) .map_err(|e| format!("link host imports: {e}"))?; - match bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) { - Ok(v11) => ComponentBindings::Plugin(v11), - Err(_) => { - let mut fallback_linker = Linker::new(engine); - wasmtime_wasi::p2::add_to_linker_sync(&mut fallback_linker) - .map_err(|e| format!("link wasi imports: {e}"))?; - bindings_plugin::Plugin::add_to_linker::< - ComponentHostState, - wasmtime::component::HasSelf, - >(&mut fallback_linker, |state| state) - .map_err(|e| format!("link host imports: {e}"))?; - ComponentBindings::Plugin( - bindings_plugin::Plugin::instantiate( - &mut store, - &component, - &fallback_linker, - ) - .map_err(|e| { - format!("instantiate component {}: {e}", self.spawn.plugin_id) - })?, - ) - } - } + ComponentBindings::Plugin( + bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) + .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, + ) }; let mut runtime = ComponentRuntime { store, bindings }; @@ -626,7 +728,10 @@ impl ComponentPluginRuntimeInstance { /// Loads and applies persisted plugin settings for v1.1 plugins. fn apply_persisted_settings(&self, runtime: &mut ComponentRuntime) -> Result<(), String> { - if !matches!(runtime.bindings, ComponentBindings::Plugin(_)) { + if !matches!( + runtime.bindings, + ComponentBindings::Plugin(_) | ComponentBindings::Vcs(_) + ) { return Ok(()); } @@ -664,16 +769,23 @@ impl ComponentPluginRuntimeInstance { } /// Runs a closure with VCS bindings for VCS backend plugins. - /// NOTE: Currently VCS backends use plugin world, so this needs architectural changes. fn with_vcs_bindings( &self, method: &str, - _f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, + f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, ) -> Result { - Err(format!( - "VCS operations not yet supported for plugin world-based VCS backends: {}", - method - )) + self.with_runtime(|runtime| { + let bindings = match &runtime.bindings { + ComponentBindings::Vcs(bindings) => bindings, + _ => { + return Err(format!( + "component method `{method}` requires VCS backend exports for plugin `{}`", + self.spawn.plugin_id + )); + } + }; + f(bindings, &mut runtime.store) + }) } /// Converts nested trap/plugin results into backend error strings. @@ -1385,6 +1497,30 @@ fn map_menu_from_wit(menu: plugin_api::Menu) -> Menu { } } +/// Converts a VCS-plugin WIT menu into the shared core menu model. +fn map_menu_from_vcs_wit(menu: vcs_settings_api::Menu) -> Menu { + let elements = menu + .elements + .into_iter() + .map(|element| match element { + vcs_settings_api::UiElement::Text(text) => UiElement::Text(UiText { + id: text.id, + content: text.content, + }), + vcs_settings_api::UiElement::Button(button) => UiElement::Button(UiButton { + id: button.id, + label: button.label, + }), + }) + .collect::>(); + Menu { + id: menu.id, + label: menu.label, + order: menu.order, + elements, + } +} + /// Converts a plugin WIT setting entry into the shared core setting model. fn map_setting_from_wit(setting: plugin_api::SettingKv) -> SettingKv { SettingKv { From dd4b4c110845435187c12710f02a4573a2574719 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 09:34:57 +0000 Subject: [PATCH 84/96] Added opencode workflow --- .github/workflows/opencode.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 00000000..72fbcfab --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + model: openai/codex-mini-latest \ No newline at end of file From c5a816bb688c53be1b29cd50a68e19bda2f1c354 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 09:39:59 +0000 Subject: [PATCH 85/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index d9d57293..d0b457cb 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit d9d572936c0c7eec4b91e47c83db54c4f475c450 +Subproject commit d0b457cb6d30bfda45b68a6a98712da98911a15c From eadd39672d728013004b6b4084da9e9e5433d6c0 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 09:45:18 +0000 Subject: [PATCH 86/96] Update opencode.yml --- .github/workflows/opencode.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 72fbcfab..f0c5a978 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -30,4 +30,4 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: - model: openai/codex-mini-latest \ No newline at end of file + model: openai/gpt-5.1-codex-mini \ No newline at end of file From 3745710ec7440ca51c6ed0871e26da54be0c60ca Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 09:51:03 +0000 Subject: [PATCH 87/96] Update opencode.yml --- .github/workflows/opencode.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index f0c5a978..5686e868 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,10 +9,12 @@ on: jobs: opencode: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + github.actor == 'Jordonbc' && ( + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + ) runs-on: ubuntu-latest permissions: id-token: write @@ -30,4 +32,4 @@ jobs: env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: - model: openai/gpt-5.1-codex-mini \ No newline at end of file + model: openai/gpt-5.1-codex-mini From 292abd3978983f3221dbbb853a12c4c1305288f5 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 09:59:08 +0000 Subject: [PATCH 88/96] Create opencode-review.yml --- .github/workflows/opencode-review.yml | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/opencode-review.yml diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml new file mode 100644 index 00000000..71532249 --- /dev/null +++ b/.github/workflows/opencode-review.yml @@ -0,0 +1,31 @@ +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + review: + if: github.actor == 'Jordonbc' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: anomalyco/opencode/github@latest + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: openai/gpt-5.1-codex-mini + use_github_token: true + prompt: | + Review this pull request: + - Check for code quality issues + - Look for potential bugs + - Suggest improvements From 587d2fd10f9d9d7cc080d9dadd55ccae99a861a3 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 10:29:11 +0000 Subject: [PATCH 89/96] Update --- .github/workflows/nightly.yml | 3 - .github/workflows/publish-stable.yml | 3 - AGENTS.md | 8 +- ARCHITECTURE.md | 10 +- Backend/Cargo.toml | 5 +- Backend/build.rs | 13 + Backend/scripts/ensure-built-in-plugins.js | 36 + Backend/src/lib.rs | 20 +- Backend/src/logging.rs | 4 +- Backend/src/plugin_bundles.rs | 81 +- Backend/src/plugin_paths.rs | 21 + .../src/plugin_runtime/component_instance.rs | 1681 ----------------- Backend/src/plugin_runtime/events.rs | 17 - Backend/src/plugin_runtime/host_api.rs | 668 +------ Backend/src/plugin_runtime/manager.rs | 25 +- Backend/src/plugin_runtime/mod.rs | 9 +- Backend/src/plugin_runtime/node_instance.rs | 900 +++++++++ Backend/src/plugin_runtime/protocol.rs | 263 +++ Backend/src/plugin_runtime/runtime_select.rs | 109 +- Backend/src/plugin_runtime/spawn.rs | 7 +- Backend/src/plugin_runtime/vcs_proxy.rs | 9 +- Backend/src/plugin_vcs_backends.rs | 23 +- Backend/src/tauri_commands/plugins.rs | 160 +- Backend/tauri.conf.json | 3 +- Cargo.lock | 1451 +------------- Cargo.toml | 1 - DESIGN.md | 10 +- Frontend/src/modals/plugin-permissions.html | 20 - .../src/scripts/features/pluginPermissions.ts | 372 ---- Frontend/src/scripts/features/settings.ts | 50 +- Frontend/src/scripts/plugins.ts | 10 - Frontend/src/scripts/ui/modals.ts | 2 - Frontend/src/styles/index.css | 1 - .../src/styles/modal/plugin-permissions.css | 112 -- README.md | 2 +- docs/plugin architecture.md | 96 +- docs/plugins.md | 46 +- 37 files changed, 1525 insertions(+), 4726 deletions(-) delete mode 100644 Backend/src/plugin_runtime/component_instance.rs create mode 100644 Backend/src/plugin_runtime/node_instance.rs create mode 100644 Backend/src/plugin_runtime/protocol.rs delete mode 100644 Frontend/src/modals/plugin-permissions.html delete mode 100644 Frontend/src/scripts/features/pluginPermissions.ts delete mode 100644 Frontend/src/styles/modal/plugin-permissions.css diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d6390fce..8c3bb83a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -176,9 +176,6 @@ jobs: with: components: rustfmt, clippy - - name: Add WASI target - run: rustup target add wasm32-wasip1 - - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml index dcc607c7..ee5b4779 100644 --- a/.github/workflows/publish-stable.yml +++ b/.github/workflows/publish-stable.yml @@ -66,9 +66,6 @@ jobs: with: components: rustfmt, clippy - - name: Add WASI target - run: rustup target add wasm32-wasip1 - - name: Setup sccache uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9 diff --git a/AGENTS.md b/AGENTS.md index 97ead9e3..6b7cf168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,9 +52,9 @@ ## Plugin runtime & host expectations - Plugin components live under `Backend/built-in-plugins/` and follow the manifest format in `openvcs.plugin.json`. Built-in bundles ship with the AppImage/Flatpak and are also built by the SDK (`cargo openvcs dist`). -- The backend loads plugin modules as Wasmtime component-model `*.wasm` files via `Backend/src/plugin_runtime/component_instance.rs`. -- The canonical host/plugin contract is defined under `Core/wit/` (`host.wit`, `plugin.wit`, `vcs.wit`). -- When changing host APIs, capabilities, or runtime behavior, update `Core/wit/` and the runtime logic in `Backend/src/plugin_runtime`. +- The backend loads plugin modules as Node.js runtime scripts (`*.mjs|*.js|*.cjs`) via `Backend/src/plugin_runtime/node_instance.rs`. +- The canonical host/plugin contract is JSON-RPC over stdio with method names in `Backend/src/plugin_runtime/protocol.rs`. +- When changing host APIs or runtime behavior, update protocol constants and runtime logic in `Backend/src/plugin_runtime`. ## Coding style & conventions @@ -169,7 +169,7 @@ ## Commit & PR guidelines - Use short, imperative commit subjects (optionally scoped, e.g., `backend: refresh plugin runtime config`). Keep changelist focused; avoid mixing UI and backend refactors unless necessary. -- PRs should target the `Dev` branch, include a summary, issue links, commands/tests run, and highlight architecture implications (host API changes, plugin capability updates, security decisions). +- PRs should target the `Dev` branch, include a summary, issue links, commands/tests run, and highlight architecture implications (host API/protocol changes and security decisions). - Do not modify plugin code inside submodules unless explicitly asked; treat submodule updates as pointer bumps after upstream changes. - Keep this AGENTS (and other module-level copies you rely on) current whenever workflows, tooling, or responsibilities change so future contributors can find accurate guidance. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3a677bb..8a54dc5f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,8 +31,8 @@ Backend: - `Backend/src/state.rs`: app config, repo state, recents, output log. - `Backend/src/repo.rs`: repository handle wrapper around `Arc`. - `Backend/src/plugin_vcs_backends.rs`: backend discovery and open logic. -- `Backend/src/plugin_bundles.rs`: `.ovcsp` install/index/component resolution. -- `Backend/src/plugin_runtime/component_instance.rs`: plugin component instantiation and typed ABI calls. +- `Backend/src/plugin_bundles.rs`: `.ovcsp` install/index/runtime resolution. +- `Backend/src/plugin_runtime/node_instance.rs`: plugin process lifecycle and JSON-RPC calls. - `Backend/src/plugin_runtime/vcs_proxy.rs`: `Vcs` trait proxy over plugin RPC. - `Backend/src/plugins.rs`: plugin discovery/manifest summarization for UI. @@ -43,14 +43,14 @@ Backend: - Command boundary: Feature-facing backend API lives under `Backend/src/tauri_commands/`. - Backend/plugin boundary: - Backend communicates with plugin components over the component-model ABI defined under `Core/wit/`. + Backend communicates with plugin processes over JSON-RPC over stdio. - Settings boundary: Backend persists/loads app configuration and mediates environment application. ## Architecture Invariants - Active repo backend is treated as dynamic availability; stale handles are rejected when backend disappears. -- Plugin components that request capabilities require approval before execution. +- Plugin modules require approval before execution. - Output/log/progress signaling is centralized through backend event emission. ## Cross-Cutting Concerns @@ -58,7 +58,7 @@ Backend: - State lifecycle: Startup config load, optional reopen-last-repo, runtime config updates. - Plugin lifecycle: - Built-in/user plugin discovery, install/uninstall, capability approvals. + Built-in/user plugin discovery, install/uninstall, and approval gating. - Reliability: RPC timeout handling, respawn backoff, and auto-disable after repeated crashes. - UX: diff --git a/Backend/Cargo.toml b/Backend/Cargo.toml index a4967b17..403f7e7b 100644 --- a/Backend/Cargo.toml +++ b/Backend/Cargo.toml @@ -25,7 +25,7 @@ serde_json = "1.0" default = [] [dependencies] -openvcs-core = { path = "../../Core", features = ["plugin-protocol", "vcs"] } +openvcs-core = { path = "../../Core", features = ["vcs"] } tauri = { version = "2.9", features = [] } tauri-plugin-opener = "2.5" @@ -50,8 +50,7 @@ shlex = "1.2" sha2 = "0.10" hex = "0.4" os_pipe = "1.2" -wasmtime = "41" -wasmtime-wasi = "41" +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/Backend/build.rs b/Backend/build.rs index 15e239be..2281c8dc 100644 --- a/Backend/build.rs +++ b/Backend/build.rs @@ -110,6 +110,18 @@ fn ensure_generated_builtins_resource_dir(manifest_dir: &std::path::Path) { } } +/// Ensures the generated bundled Node runtime resource directory exists. +fn ensure_generated_node_runtime_resource_dir(manifest_dir: &std::path::Path) { + let generated = manifest_dir.join("../target/openvcs/node-runtime"); + if let Err(err) = fs::create_dir_all(&generated) { + panic!( + "failed to create generated node runtime resource dir {}: {}", + generated.display(), + err + ); + } +} + /// Generates Tauri build config and exports build-time metadata env vars. fn main() { // Base config path (in the Backend crate) @@ -255,6 +267,7 @@ fn main() { println!("cargo:rustc-env=OPENVCS_BUILD={}", build_id); ensure_generated_builtins_resource_dir(&manifest_dir); + ensure_generated_node_runtime_resource_dir(&manifest_dir); // Proceed with tauri build steps tauri_build::build(); diff --git a/Backend/scripts/ensure-built-in-plugins.js b/Backend/scripts/ensure-built-in-plugins.js index 423ba9df..9592f5d7 100644 --- a/Backend/scripts/ensure-built-in-plugins.js +++ b/Backend/scripts/ensure-built-in-plugins.js @@ -9,6 +9,7 @@ const backendDir = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(backendDir, '..'); const pluginSources = path.join(backendDir, 'built-in-plugins'); const pluginBundles = path.join(repoRoot, 'target', 'openvcs', 'built-in-plugins'); +const nodeRuntimeDir = path.join(repoRoot, 'target', 'openvcs', 'node-runtime'); const skipDirs = new Set(['target', '.git', 'node_modules', 'dist']); @@ -71,6 +72,39 @@ function ensureBundlesDir() { fs.mkdirSync(pluginBundles, { recursive: true }); } +function ensureNodeRuntimeDir() { + fs.mkdirSync(nodeRuntimeDir, { recursive: true }); +} + +function ensureBundledNodeRuntime() { + const src = process.execPath; + const outName = process.platform === 'win32' ? 'node.exe' : 'node'; + const dest = path.join(nodeRuntimeDir, outName); + + let shouldCopy = true; + if (fs.existsSync(dest)) { + try { + const srcStat = fs.statSync(src); + const destStat = fs.statSync(dest); + shouldCopy = srcStat.size !== destStat.size || srcStat.mtimeMs > destStat.mtimeMs; + } catch { + shouldCopy = true; + } + } + + if (!shouldCopy) return; + + fs.copyFileSync(src, dest); + if (process.platform !== 'win32') { + try { + fs.chmodSync(dest, 0o755); + } catch { + // Ignore chmod errors on restricted filesystems. + } + } + console.log(`Bundled node runtime -> ${dest}`); +} + function runDistCommand() { console.log('Built-in plugin bundles need rebuilding; running cargo openvcs dist …'); const pluginDirArg = 'built-in-plugins'; @@ -90,6 +124,8 @@ function runDistCommand() { } ensureBundlesDir(); +ensureNodeRuntimeDir(); +ensureBundledNodeRuntime(); const outdated = findOutdatedPlugin(); if (outdated) { diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index cd1a3f3a..0b9004e3 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -154,6 +154,22 @@ pub fn run() { ); } } + if let Ok(node_runtime_dir) = app.path().resolve("node-runtime", BaseDirectory::Resource) { + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + let bundled_node = node_runtime_dir.join(node_name); + if bundled_node.is_file() { + crate::plugin_paths::set_node_executable_path(bundled_node.clone()); + log::info!( + "plugins: using bundled node runtime: {}", + bundled_node.display() + ); + } else { + log::warn!( + "plugins: bundled node runtime missing at {}; plugin modules will not start", + bundled_node.display() + ); + } + } let store = crate::plugin_bundles::PluginBundleStore::new_default(); if let Err(err) = store.sync_built_in_plugins() { warn!("plugins: failed to sync built-in bundles: {}", err); @@ -281,9 +297,7 @@ fn build_invoke_handler( tauri_commands::list_installed_bundles, tauri_commands::uninstall_plugin, tauri_commands::set_plugin_enabled, - tauri_commands::approve_plugin_capabilities, - tauri_commands::get_plugin_permissions, - tauri_commands::set_plugin_permissions, + tauri_commands::set_plugin_approval, tauri_commands::list_plugin_menus, tauri_commands::invoke_plugin_action, tauri_commands::get_plugin_settings, diff --git a/Backend/src/logging.rs b/Backend/src/logging.rs index 80990dd4..853028d7 100644 --- a/Backend/src/logging.rs +++ b/Backend/src/logging.rs @@ -319,12 +319,10 @@ pub fn init() { writeln!(buf, "[{}] [{}] {:5} [{}]: {}", date, time, ts, source, msg) }); - // Wasmtime/Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. + // Cranelift can be extremely verbose at TRACE/DEBUG and drown out OpenVCS logs. // Keep these at WARN+ even if the user enables a global TRACE filter. - builder.filter_module("wasmtime", log::LevelFilter::Warn); builder.filter_module("cranelift", log::LevelFilter::Warn); builder.filter_module("cranelift_codegen", log::LevelFilter::Warn); - builder.filter_module("cranelift_wasm", log::LevelFilter::Warn); builder.filter_module("cranelift_native", log::LevelFilter::Warn); // If RUST_LOG is unset, apply level from settings diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index 59ae29b4..f6155113 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -44,7 +44,9 @@ impl Default for InstallerLimits { } } -/// Capability approval status for an installed plugin version. +/// Legacy approval status for an installed plugin version. +/// +/// Node runtime currently operates in trust mode and auto-approves installs. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ApprovalState { @@ -914,65 +916,6 @@ impl PluginBundleStore { Ok(()) } - /// Applies an explicit approved-capability set to the current plugin version. - /// - /// # Parameters - /// - `plugin_id`: Plugin identifier to update. - /// - `approved_capabilities`: Capability ids selected by the user. - /// - /// # Returns - /// - `Ok(())` when approval state is updated for the current version. - /// - `Err(String)` if the plugin/current version is missing or index write fails. - pub fn set_current_approved_capabilities( - &self, - plugin_id: &str, - approved_capabilities: Vec, - ) -> Result<(), String> { - let id = plugin_id.trim(); - if id.is_empty() { - return Err("plugin id is empty".to_string()); - } - - let mut index = self - .read_index(id) - .ok_or_else(|| "plugin is not installed".to_string())?; - let current = index - .current - .clone() - .ok_or_else(|| "plugin has no current version".to_string())?; - - let Some(version) = index.versions.get_mut(current.trim()) else { - return Err("current version is not installed".to_string()); - }; - - let requested = version - .requested_capabilities - .iter() - .cloned() - .collect::>(); - let mut approved = normalize_capabilities(approved_capabilities) - .into_iter() - .filter(|capability| requested.contains(capability)) - .collect::>(); - approved.sort(); - approved.dedup(); - - version.approval = if !version.requested_capabilities.is_empty() && approved.is_empty() { - ApprovalState::Denied { - denied_at_unix_ms: now_unix_ms(), - reason: None, - } - } else { - ApprovalState::Approved { - capabilities: approved, - approved_at_unix_ms: now_unix_ms(), - } - }; - - self.write_index(id, &index)?; - Ok(()) - } - /// Loads resolved components for the current plugin version. /// /// # Parameters @@ -1374,7 +1317,7 @@ fn platform_exec_name(base: &str) -> String { base.to_string() } -/// Validates that a declared entrypoint exists and is a wasm module. +/// Validates that a declared entrypoint exists and is a Node module. /// /// # Parameters /// - `version_dir`: Installed version directory. @@ -1393,9 +1336,11 @@ fn validate_entrypoint(version_dir: &Path, exec: Option<&str>, label: &str) -> R return Ok(()); } - if !trimmed.ends_with(".wasm") { + let lower = trimmed.to_ascii_lowercase(); + let is_supported = lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"); + if !is_supported { return Err(format!( - "{} entrypoint must be a .wasm module, got: {}", + "{} entrypoint must be a .js/.mjs/.cjs Node module, got: {}", label, trimmed )); } @@ -1657,7 +1602,7 @@ mod tests { name: "test.plugin/openvcs.plugin.json".into(), data: basic_manifest( "test.plugin", - ",\"module\":{\"exec\":\"missing.wasm\",\"vcs_backends\":[\"x\"]}", + ",\"module\":{\"exec\":\"missing.mjs\",\"vcs_backends\":[\"x\"]}", ), unix_mode: None, kind: TarEntryKind::File, @@ -1679,7 +1624,7 @@ mod tests { fn install_rejects_functions_component() { let bundle = make_tar_xz_bundle(vec![TarEntry { name: "test.plugin/openvcs.plugin.json".into(), - data: basic_manifest("test.plugin", ",\"functions\":{\"exec\":\"legacy.wasm\"}"), + data: basic_manifest("test.plugin", ",\"functions\":{\"exec\":\"legacy.mjs\"}"), unix_mode: None, kind: TarEntryKind::File, }]); @@ -1706,14 +1651,14 @@ mod tests { name: "test.plugin/openvcs.plugin.json".into(), data: basic_manifest( "test.plugin", - ",\"module\":{\"exec\":\"mod.wasm\",\"vcs_backends\":[]}", + ",\"module\":{\"exec\":\"mod.mjs\",\"vcs_backends\":[]}", ), unix_mode: None, kind: TarEntryKind::File, }, TarEntry { - name: "test.plugin/bin/mod.wasm".into(), - data: b"\0asm".to_vec(), + name: "test.plugin/bin/mod.mjs".into(), + data: b"export {};\n".to_vec(), unix_mode: Some(0o100644), kind: TarEntryKind::File, }, diff --git a/Backend/src/plugin_paths.rs b/Backend/src/plugin_paths.rs index a69642ac..be9504a1 100644 --- a/Backend/src/plugin_paths.rs +++ b/Backend/src/plugin_paths.rs @@ -22,6 +22,7 @@ pub const BUILT_IN_PLUGINS_DIR_NAME: &str = "built-in-plugins"; // it here so plugin discovery can include resources embedded in the // application bundle. static RESOURCE_DIR: OnceLock = OnceLock::new(); +static NODE_EXECUTABLE: OnceLock = OnceLock::new(); static LOGGED_BUILTIN_DIRS: AtomicBool = AtomicBool::new(false); /// Returns the user-writable plugin installation directory. @@ -140,3 +141,23 @@ pub fn set_resource_dir(path: PathBuf) { // it's fine if this fails to set more than once; first set wins. let _ = RESOURCE_DIR.set(path); } + +/// Sets the resolved bundled Node executable path used by plugin runtime. +/// +/// # Parameters +/// - `path`: Absolute path to the bundled Node binary. +/// +/// # Returns +/// - `()`. +pub fn set_node_executable_path(path: PathBuf) { + let _ = NODE_EXECUTABLE.set(path); +} + +/// Returns the bundled Node executable path when configured. +/// +/// # Returns +/// - `Some(PathBuf)` when a bundled runtime was resolved. +/// - `None` when host should fall back to `node` on PATH. +pub fn node_executable_path() -> Option { + NODE_EXECUTABLE.get().cloned() +} diff --git a/Backend/src/plugin_runtime/component_instance.rs b/Backend/src/plugin_runtime/component_instance.rs deleted file mode 100644 index ecbe627e..00000000 --- a/Backend/src/plugin_runtime/component_instance.rs +++ /dev/null @@ -1,1681 +0,0 @@ -// Copyright © 2025-2026 OpenVCS Contributors -// SPDX-License-Identifier: GPL-3.0-or-later -use std::sync::OnceLock; - -use crate::plugin_runtime::host_api::{ - host_emit_event, host_get_status, host_process_exec, host_runtime_info, host_set_status, - host_subscribe_event, host_ui_notify, host_workspace_read_file, host_workspace_write_file, -}; -use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::settings_store; -use crate::plugin_runtime::spawn::SpawnConfig; -use openvcs_core::models::{ - BranchItem, BranchKind, Capabilities, CommitItem, ConflictDetails, ConflictSide, FetchOptions, - LogQuery, StashItem, StatusPayload, StatusSummary, -}; -use openvcs_core::settings::{SettingKv, SettingValue}; -use openvcs_core::ui::{Menu, UiButton, UiElement, UiText}; -use parking_lot::Mutex; -use wasmtime::component::{Component, Linker, ResourceTable}; -use wasmtime::{Cache, CacheConfig, Config, Engine, Store}; -use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; - -static WASMTIME_ENGINE: OnceLock = OnceLock::new(); - -fn get_wasmtime_engine() -> &'static Engine { - WASMTIME_ENGINE.get_or_init(|| { - let cache_config = CacheConfig::new(); - let cache = Cache::new(cache_config).expect("create wasmtime cache"); - let mut config = Config::default(); - config.cache(Some(cache)); - Engine::new(&config).expect("create wasmtime engine") - }) -} - -mod bindings_vcs { - wasmtime::component::bindgen!({ - path: "../../Core/wit", - world: "vcs", - additional_derives: [serde::Serialize, serde::Deserialize], - }); -} - -mod bindings_plugin { - wasmtime::component::bindgen!({ - path: "../../Core/wit", - world: "plugin", - additional_derives: [serde::Serialize, serde::Deserialize], - }); -} - -use bindings_plugin::exports::openvcs::plugin::plugin_api; -use bindings_vcs::exports::openvcs::plugin::plugin_api_vcs as vcs_settings_api; -use bindings_vcs::exports::openvcs::plugin::vcs_api; - -/// Typed bindings handle selected for the running plugin world. -enum ComponentBindings { - /// Bindings for plugins exporting the `plugin` world. - Plugin(bindings_plugin::Plugin), - /// Bindings for plugins exporting the `vcs` world. - Vcs(bindings_vcs::Vcs), -} - -/// Live component instance plus generated bindings handle. -struct ComponentRuntime { - /// Wasmtime store containing component state and host context. - store: Store, - /// Generated typed binding entrypoints for the plugin world. - bindings: ComponentBindings, -} - -impl ComponentRuntime { - /// Calls plugin `init` for whichever world is currently loaded. - fn call_init(&mut self, plugin_id: &str) -> Result<(), String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => bindings - .openvcs_plugin_plugin_api() - .call_init(&mut self.store) - .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? - .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), - ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_init(&mut self.store) - .map_err(|e| format!("component init trap for {}: {e}", plugin_id))? - .map_err(|e| format!("component init failed for {}: {}", plugin_id, e.message)), - } - } - - /// Calls plugin `deinit` for whichever world is currently loaded. - fn call_deinit(&mut self) { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let _ = bindings - .openvcs_plugin_plugin_api() - .call_deinit(&mut self.store); - } - ComponentBindings::Vcs(bindings) => { - let _ = bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_deinit(&mut self.store); - } - } - } - - /// Returns plugin-contributed menus for v1.1 plugins. - fn call_get_menus(&mut self, plugin_id: &str) -> Result, String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let menus = bindings - .openvcs_plugin_plugin_api() - .call_get_menus(&mut self.store) - .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component get-menus failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(menus.into_iter().map(map_menu_from_wit).collect()) - } - ComponentBindings::Vcs(bindings) => { - let menus = bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_get_menus(&mut self.store) - .map_err(|e| format!("component get-menus trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component get-menus failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(menus.into_iter().map(map_menu_from_vcs_wit).collect()) - } - } - } - - /// Invokes a plugin action for v1.1 plugins. - fn call_handle_action(&mut self, plugin_id: &str, id: &str) -> Result<(), String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => bindings - .openvcs_plugin_plugin_api() - .call_handle_action(&mut self.store, id) - .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component handle-action failed for {}: {}", - plugin_id, e.message - ) - }), - ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_handle_action(&mut self.store, id) - .map_err(|e| format!("component handle-action trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component handle-action failed for {}: {}", - plugin_id, e.message - ) - }), - } - } - - /// Returns plugin settings defaults for v1.1 plugins. - fn call_settings_defaults(&mut self, plugin_id: &str) -> Result, String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let values = bindings - .openvcs_plugin_plugin_api() - .call_settings_defaults(&mut self.store) - .map_err(|e| { - format!("component settings-defaults trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-defaults failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(values.into_iter().map(map_setting_from_wit).collect()) - } - ComponentBindings::Vcs(bindings) => { - let values = bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_settings_defaults(&mut self.store) - .map_err(|e| { - format!("component settings-defaults trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-defaults failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(values.into_iter().map(map_setting_from_vcs_wit).collect()) - } - } - } - - /// Calls plugin settings-on-load hook for v1.1 plugins. - fn call_settings_on_load( - &mut self, - plugin_id: &str, - values: Vec, - ) -> Result, String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api() - .call_settings_on_load(&mut self.store, &values) - .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-load failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_wit).collect()) - } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_settings_on_load(&mut self.store, &values) - .map_err(|e| format!("component settings-on-load trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-load failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) - } - } - } - - /// Calls plugin settings-on-apply hook for v1.1 plugins. - fn call_settings_on_apply( - &mut self, - plugin_id: &str, - values: Vec, - ) -> Result<(), String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - bindings - .openvcs_plugin_plugin_api() - .call_settings_on_apply(&mut self.store, &values) - .map_err(|e| { - format!("component settings-on-apply trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-on-apply failed for {}: {}", - plugin_id, e.message - ) - }) - } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_settings_on_apply(&mut self.store, &values) - .map_err(|e| { - format!("component settings-on-apply trap for {}: {e}", plugin_id) - })? - .map_err(|e| { - format!( - "component settings-on-apply failed for {}: {}", - plugin_id, e.message - ) - }) - } - } - } - - /// Calls plugin settings-on-save hook for v1.1 plugins. - fn call_settings_on_save( - &mut self, - plugin_id: &str, - values: Vec, - ) -> Result, String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_plugin_api() - .call_settings_on_save(&mut self.store, &values) - .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-save failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_wit).collect()) - } - ComponentBindings::Vcs(bindings) => { - let values = values - .into_iter() - .map(map_setting_to_vcs_wit) - .collect::>(); - let out = bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_settings_on_save(&mut self.store, &values) - .map_err(|e| format!("component settings-on-save trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-save failed for {}: {}", - plugin_id, e.message - ) - })?; - Ok(out.into_iter().map(map_setting_from_vcs_wit).collect()) - } - } - } - - /// Calls plugin settings-on-reset hook for v1.1 plugins. - fn call_settings_on_reset(&mut self, plugin_id: &str) -> Result<(), String> { - match &self.bindings { - ComponentBindings::Plugin(bindings) => bindings - .openvcs_plugin_plugin_api() - .call_settings_on_reset(&mut self.store) - .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-reset failed for {}: {}", - plugin_id, e.message - ) - }), - ComponentBindings::Vcs(bindings) => bindings - .openvcs_plugin_vcs_plugin_api_vcs() - .call_settings_on_reset(&mut self.store) - .map_err(|e| format!("component settings-on-reset trap for {}: {e}", plugin_id))? - .map_err(|e| { - format!( - "component settings-on-reset failed for {}: {}", - plugin_id, e.message - ) - }), - } - } -} - -/// Host state stored inside the Wasmtime store for host imports. -struct ComponentHostState { - /// Spawn-time plugin metadata and capability context. - spawn: SpawnConfig, - /// Component resource table used by WASI/component model. - table: ResourceTable, - /// WASI context exposed to the component. - wasi: WasiCtx, -} - -impl ComponentHostState { - /// Converts core host errors into generated WIT host error type. - fn map_host_error_vcs( - err: openvcs_core::app_api::PluginError, - ) -> bindings_vcs::openvcs::plugin::host_api::HostError { - bindings_vcs::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - } - - /// Converts core host errors into generated WIT host error type. - fn map_host_error_plugin( - err: openvcs_core::app_api::PluginError, - ) -> bindings_plugin::openvcs::plugin::host_api::HostError { - bindings_plugin::openvcs::plugin::host_api::HostError { - code: err.code, - message: err.message, - } - } -} - -impl bindings_vcs::openvcs::plugin::host_api::Host for ComponentHostState { - fn get_runtime_info( - &mut self, - ) -> Result< - bindings_vcs::openvcs::plugin::host_api::RuntimeInfo, - bindings_vcs::openvcs::plugin::host_api::HostError, - > { - let value = host_runtime_info(); - Ok(bindings_vcs::openvcs::plugin::host_api::RuntimeInfo { - os: value.os, - arch: value.arch, - container: value.container, - }) - } - - fn subscribe_event( - &mut self, - event_name: String, - ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { - host_subscribe_event(&self.spawn, &event_name) - .map_err(ComponentHostState::map_host_error_vcs) - } - - fn emit_event( - &mut self, - event_name: String, - payload: Vec, - ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { - host_emit_event(&self.spawn, &event_name, &payload) - .map_err(ComponentHostState::map_host_error_vcs) - } - - fn ui_notify( - &mut self, - message: String, - ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { - host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) - } - - fn set_status( - &mut self, - message: String, - ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { - host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_vcs) - } - - fn get_status(&mut self) -> Result { - host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_vcs) - } - - fn workspace_read_file( - &mut self, - path: String, - ) -> Result, bindings_vcs::openvcs::plugin::host_api::HostError> { - host_workspace_read_file(&self.spawn, &path).map_err(ComponentHostState::map_host_error_vcs) - } - - fn workspace_write_file( - &mut self, - path: String, - content: Vec, - ) -> Result<(), bindings_vcs::openvcs::plugin::host_api::HostError> { - host_workspace_write_file(&self.spawn, &path, &content) - .map_err(ComponentHostState::map_host_error_vcs) - } - - fn process_exec( - &mut self, - cwd: Option, - program: String, - args: Vec, - env: Vec, - stdin: Option, - ) -> Result< - bindings_vcs::openvcs::plugin::host_api::ProcessExecOutput, - bindings_vcs::openvcs::plugin::host_api::HostError, - > { - let env = env - .into_iter() - .map(|var| (var.key, var.value)) - .collect::>(); - let value = host_process_exec( - &self.spawn, - cwd.as_deref(), - &program, - &args, - &env, - stdin.as_deref(), - ) - .map_err(ComponentHostState::map_host_error_vcs)?; - Ok(bindings_vcs::openvcs::plugin::host_api::ProcessExecOutput { - success: value.success, - status: value.status, - stdout: value.stdout, - stderr: value.stderr, - }) - } - - fn host_log( - &mut self, - level: bindings_vcs::openvcs::plugin::host_api::LogLevel, - target: String, - message: String, - ) { - let target = if target.trim().is_empty() { - format!("plugin.{}", self.spawn.plugin_id) - } else { - format!("plugin.{}.{}", self.spawn.plugin_id, target) - }; - - match level { - bindings_vcs::openvcs::plugin::host_api::LogLevel::Trace => { - log::trace!(target: &target, "{message}") - } - bindings_vcs::openvcs::plugin::host_api::LogLevel::Debug => { - log::debug!(target: &target, "{message}") - } - bindings_vcs::openvcs::plugin::host_api::LogLevel::Info => { - log::info!(target: &target, "{message}") - } - bindings_vcs::openvcs::plugin::host_api::LogLevel::Warn => { - log::warn!(target: &target, "{message}") - } - bindings_vcs::openvcs::plugin::host_api::LogLevel::Error => { - log::error!(target: &target, "{message}") - } - }; - } -} - -impl bindings_plugin::openvcs::plugin::host_api::Host for ComponentHostState { - fn get_runtime_info( - &mut self, - ) -> Result< - bindings_plugin::openvcs::plugin::host_api::RuntimeInfo, - bindings_plugin::openvcs::plugin::host_api::HostError, - > { - let value = host_runtime_info(); - Ok(bindings_plugin::openvcs::plugin::host_api::RuntimeInfo { - os: value.os, - arch: value.arch, - container: value.container, - }) - } - - fn subscribe_event( - &mut self, - event_name: String, - ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { - host_subscribe_event(&self.spawn, &event_name) - .map_err(ComponentHostState::map_host_error_plugin) - } - - fn emit_event( - &mut self, - event_name: String, - payload: Vec, - ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { - host_emit_event(&self.spawn, &event_name, &payload) - .map_err(ComponentHostState::map_host_error_plugin) - } - - fn ui_notify( - &mut self, - message: String, - ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { - host_ui_notify(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) - } - - fn set_status( - &mut self, - message: String, - ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { - host_set_status(&self.spawn, &message).map_err(ComponentHostState::map_host_error_plugin) - } - - fn get_status( - &mut self, - ) -> Result { - host_get_status(&self.spawn).map_err(ComponentHostState::map_host_error_plugin) - } - - fn workspace_read_file( - &mut self, - path: String, - ) -> Result, bindings_plugin::openvcs::plugin::host_api::HostError> { - host_workspace_read_file(&self.spawn, &path) - .map_err(ComponentHostState::map_host_error_plugin) - } - - fn workspace_write_file( - &mut self, - path: String, - content: Vec, - ) -> Result<(), bindings_plugin::openvcs::plugin::host_api::HostError> { - host_workspace_write_file(&self.spawn, &path, &content) - .map_err(ComponentHostState::map_host_error_plugin) - } - - fn process_exec( - &mut self, - cwd: Option, - program: String, - args: Vec, - env: Vec, - stdin: Option, - ) -> Result< - bindings_plugin::openvcs::plugin::host_api::ProcessExecOutput, - bindings_plugin::openvcs::plugin::host_api::HostError, - > { - let env = env - .into_iter() - .map(|var| (var.key, var.value)) - .collect::>(); - let value = host_process_exec( - &self.spawn, - cwd.as_deref(), - &program, - &args, - &env, - stdin.as_deref(), - ) - .map_err(ComponentHostState::map_host_error_plugin)?; - Ok( - bindings_plugin::openvcs::plugin::host_api::ProcessExecOutput { - success: value.success, - status: value.status, - stdout: value.stdout, - stderr: value.stderr, - }, - ) - } - - fn host_log( - &mut self, - level: bindings_plugin::openvcs::plugin::host_api::LogLevel, - target: String, - message: String, - ) { - let target = if target.trim().is_empty() { - format!("plugin.{}", self.spawn.plugin_id) - } else { - format!("plugin.{}.{}", self.spawn.plugin_id, target) - }; - - match level { - bindings_plugin::openvcs::plugin::host_api::LogLevel::Trace => { - log::trace!(target: &target, "{message}") - } - bindings_plugin::openvcs::plugin::host_api::LogLevel::Debug => { - log::debug!(target: &target, "{message}") - } - bindings_plugin::openvcs::plugin::host_api::LogLevel::Info => { - log::info!(target: &target, "{message}") - } - bindings_plugin::openvcs::plugin::host_api::LogLevel::Warn => { - log::warn!(target: &target, "{message}") - } - bindings_plugin::openvcs::plugin::host_api::LogLevel::Error => { - log::error!(target: &target, "{message}") - } - }; - } -} - -impl WasiView for ComponentHostState { - /// Returns mutable WASI context and resource table view. - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - -/// Component-model runtime instance. -pub struct ComponentPluginRuntimeInstance { - /// Spawn configuration used to instantiate and identify the component. - spawn: SpawnConfig, - /// Lazily initialized runtime state. - runtime: Mutex>, -} - -impl ComponentPluginRuntimeInstance { - /// Creates a new component runtime instance. - pub fn new(spawn: SpawnConfig) -> Self { - Self { - spawn, - runtime: Mutex::new(None), - } - } - - /// Instantiates the component runtime and executes plugin initialization. - fn instantiate_runtime(&self) -> Result { - let engine = get_wasmtime_engine(); - let component = Component::from_file(engine, &self.spawn.exec_path) - .map_err(|e| format!("load component {}: {e}", self.spawn.exec_path.display()))?; - let mut linker = Linker::new(engine); - wasmtime_wasi::p2::add_to_linker_sync(&mut linker) - .map_err(|e| format!("link wasi imports: {e}"))?; - let mut store = Store::new( - engine, - ComponentHostState { - spawn: self.spawn.clone(), - table: ResourceTable::new(), - wasi: WasiCtx::builder().build(), - }, - ); - let bindings = if self.spawn.is_vcs_backend { - bindings_vcs::Vcs::add_to_linker::< - ComponentHostState, - wasmtime::component::HasSelf, - >(&mut linker, |state| state) - .map_err(|e| format!("link host imports: {e}"))?; - - ComponentBindings::Vcs( - bindings_vcs::Vcs::instantiate(&mut store, &component, &linker) - .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, - ) - } else { - bindings_plugin::Plugin::add_to_linker::< - ComponentHostState, - wasmtime::component::HasSelf, - >(&mut linker, |state| state) - .map_err(|e| format!("link host imports: {e}"))?; - - ComponentBindings::Plugin( - bindings_plugin::Plugin::instantiate(&mut store, &component, &linker) - .map_err(|e| format!("instantiate component {}: {e}", self.spawn.plugin_id))?, - ) - }; - - let mut runtime = ComponentRuntime { store, bindings }; - runtime.call_init(&self.spawn.plugin_id)?; - self.apply_persisted_settings(&mut runtime)?; - Ok(runtime) - } - - /// Loads and applies persisted plugin settings for v1.1 plugins. - fn apply_persisted_settings(&self, runtime: &mut ComponentRuntime) -> Result<(), String> { - if !matches!( - runtime.bindings, - ComponentBindings::Plugin(_) | ComponentBindings::Vcs(_) - ) { - return Ok(()); - } - - let defaults = runtime.call_settings_defaults(&self.spawn.plugin_id)?; - let mut values = defaults.clone(); - let loaded = settings_store::load_settings(&self.spawn.plugin_id)?; - - for entry in &mut values { - if let Some(raw) = loaded.get(&entry.id) { - if let Some(mapped) = setting_from_json_value(raw, &entry.value) { - entry.value = mapped; - } - } - } - - let values = runtime.call_settings_on_load(&self.spawn.plugin_id, values)?; - runtime.call_settings_on_apply(&self.spawn.plugin_id, values.clone())?; - settings_store::save_settings(&self.spawn.plugin_id, &settings_to_json_map(&values)) - } - - /// Ensures a runtime exists and executes a closure with mutable access. - fn with_runtime( - &self, - f: impl FnOnce(&mut ComponentRuntime) -> Result, - ) -> Result { - self.ensure_running()?; - let mut lock = self.runtime.lock(); - let runtime = lock.as_mut().ok_or_else(|| { - format!( - "component runtime not running for `{}`", - self.spawn.plugin_id - ) - })?; - f(runtime) - } - - /// Runs a closure with VCS bindings for VCS backend plugins. - fn with_vcs_bindings( - &self, - method: &str, - f: impl FnOnce(&bindings_vcs::Vcs, &mut Store) -> Result, - ) -> Result { - self.with_runtime(|runtime| { - let bindings = match &runtime.bindings { - ComponentBindings::Vcs(bindings) => bindings, - _ => { - return Err(format!( - "component method `{method}` requires VCS backend exports for plugin `{}`", - self.spawn.plugin_id - )); - } - }; - f(bindings, &mut runtime.store) - }) - } - - /// Converts nested trap/plugin results into backend error strings. - fn map_vcs_result( - &self, - method: &str, - out: Result, E>, - ) -> Result { - out.map_err(|e| { - format!( - "component call trap for {}.{}: {e}", - self.spawn.plugin_id, method - ) - })? - .map_err(|e| { - format!( - "component call failed for {}.{}: {}: {}", - self.spawn.plugin_id, method, e.code, e.message - ) - }) - } - - /// Calls typed `get-caps`. - pub fn vcs_get_caps(&self) -> Result { - self.with_vcs_bindings("caps", |bindings, store| { - let out = self.map_vcs_result( - "caps", - bindings.openvcs_plugin_vcs_api().call_get_caps(store), - )?; - Ok(Capabilities { - commits: out.commits, - branches: out.branches, - tags: out.tags, - staging: out.staging, - push_pull: out.push_pull, - fast_forward: out.fast_forward, - }) - }) - } - - /// Calls typed `open`. - pub fn vcs_open(&self, path: &str, config: &[u8]) -> Result<(), String> { - self.with_vcs_bindings("open", |bindings, store| { - self.map_vcs_result( - "open", - bindings - .openvcs_plugin_vcs_api() - .call_open(store, path, config), - ) - }) - } - - /// Calls typed `get-current-branch`. - pub fn vcs_get_current_branch(&self) -> Result, String> { - self.with_vcs_bindings("current_branch", |bindings, store| { - self.map_vcs_result( - "current_branch", - bindings - .openvcs_plugin_vcs_api() - .call_get_current_branch(store), - ) - }) - } - - /// Calls typed `list-branches`. - pub fn vcs_list_branches(&self) -> Result, String> { - self.with_vcs_bindings("branches", |bindings, store| { - let out = self.map_vcs_result( - "branches", - bindings.openvcs_plugin_vcs_api().call_list_branches(store), - )?; - Ok(out - .into_iter() - .map(|item| BranchItem { - name: item.name, - full_ref: item.full_ref, - kind: match item.kind { - vcs_api::BranchKind::Local => BranchKind::Local, - vcs_api::BranchKind::Remote(remote) => BranchKind::Remote { remote }, - vcs_api::BranchKind::Unknown => BranchKind::Unknown, - }, - current: item.current, - }) - .collect()) - }) - } - - /// Calls typed `list-local-branches`. - pub fn vcs_list_local_branches(&self) -> Result, String> { - self.with_vcs_bindings("local_branches", |bindings, store| { - self.map_vcs_result( - "local_branches", - bindings - .openvcs_plugin_vcs_api() - .call_list_local_branches(store), - ) - }) - } - - /// Calls typed `create-branch`. - pub fn vcs_create_branch(&self, name: &str, checkout: bool) -> Result<(), String> { - self.with_vcs_bindings("create_branch", |bindings, store| { - self.map_vcs_result( - "create_branch", - bindings - .openvcs_plugin_vcs_api() - .call_create_branch(store, name, checkout), - ) - }) - } - - /// Calls typed `checkout-branch`. - pub fn vcs_checkout_branch(&self, name: &str) -> Result<(), String> { - self.with_vcs_bindings("checkout_branch", |bindings, store| { - self.map_vcs_result( - "checkout_branch", - bindings - .openvcs_plugin_vcs_api() - .call_checkout_branch(store, name), - ) - }) - } - - /// Calls typed `ensure-remote`. - pub fn vcs_ensure_remote(&self, name: &str, url: &str) -> Result<(), String> { - self.with_vcs_bindings("ensure_remote", |bindings, store| { - self.map_vcs_result( - "ensure_remote", - bindings - .openvcs_plugin_vcs_api() - .call_ensure_remote(store, name, url), - ) - }) - } - - /// Calls typed `list-remotes`. - pub fn vcs_list_remotes(&self) -> Result, String> { - self.with_vcs_bindings("list_remotes", |bindings, store| { - let out = self.map_vcs_result( - "list_remotes", - bindings.openvcs_plugin_vcs_api().call_list_remotes(store), - )?; - Ok(out - .into_iter() - .map(|entry| (entry.name, entry.url)) - .collect()) - }) - } - - /// Calls typed `remove-remote`. - pub fn vcs_remove_remote(&self, name: &str) -> Result<(), String> { - self.with_vcs_bindings("remove_remote", |bindings, store| { - self.map_vcs_result( - "remove_remote", - bindings - .openvcs_plugin_vcs_api() - .call_remove_remote(store, name), - ) - }) - } - - /// Calls typed `fetch`. - pub fn vcs_fetch(&self, remote: &str, refspec: &str) -> Result<(), String> { - self.with_vcs_bindings("fetch", |bindings, store| { - self.map_vcs_result( - "fetch", - bindings - .openvcs_plugin_vcs_api() - .call_fetch(store, remote, refspec), - ) - }) - } - - /// Calls typed `fetch-with-options`. - pub fn vcs_fetch_with_options( - &self, - remote: &str, - refspec: &str, - opts: FetchOptions, - ) -> Result<(), String> { - self.with_vcs_bindings("fetch_with_options", |bindings, store| { - self.map_vcs_result( - "fetch_with_options", - bindings.openvcs_plugin_vcs_api().call_fetch_with_options( - store, - remote, - refspec, - vcs_api::FetchOptions { prune: opts.prune }, - ), - ) - }) - } - - /// Calls typed `push`. - pub fn vcs_push(&self, remote: &str, refspec: &str) -> Result<(), String> { - self.with_vcs_bindings("push", |bindings, store| { - self.map_vcs_result( - "push", - bindings - .openvcs_plugin_vcs_api() - .call_push(store, remote, refspec), - ) - }) - } - - /// Calls typed `pull-ff-only`. - pub fn vcs_pull_ff_only(&self, remote: &str, branch: &str) -> Result<(), String> { - self.with_vcs_bindings("pull_ff_only", |bindings, store| { - self.map_vcs_result( - "pull_ff_only", - bindings - .openvcs_plugin_vcs_api() - .call_pull_ff_only(store, remote, branch), - ) - }) - } - - /// Calls typed `commit`. - pub fn vcs_commit( - &self, - message: &str, - name: &str, - email: &str, - paths: &[String], - ) -> Result { - self.with_vcs_bindings("commit", |bindings, store| { - self.map_vcs_result( - "commit", - bindings - .openvcs_plugin_vcs_api() - .call_commit(store, message, name, email, paths), - ) - }) - } - - /// Calls typed `commit-index`. - pub fn vcs_commit_index( - &self, - message: &str, - name: &str, - email: &str, - ) -> Result { - self.with_vcs_bindings("commit_index", |bindings, store| { - self.map_vcs_result( - "commit_index", - bindings - .openvcs_plugin_vcs_api() - .call_commit_index(store, message, name, email), - ) - }) - } - - /// Calls typed `get-status-summary`. - pub fn vcs_get_status_summary(&self) -> Result { - self.with_vcs_bindings("status_summary", |bindings, store| { - let out = self.map_vcs_result( - "status_summary", - bindings - .openvcs_plugin_vcs_api() - .call_get_status_summary(store), - )?; - Ok(StatusSummary { - untracked: out.untracked as usize, - modified: out.modified as usize, - staged: out.staged as usize, - conflicted: out.conflicted as usize, - }) - }) - } - - /// Calls typed `get-status-payload`. - pub fn vcs_get_status_payload(&self) -> Result { - self.with_vcs_bindings("status_payload", |bindings, store| { - let out = self.map_vcs_result( - "status_payload", - bindings - .openvcs_plugin_vcs_api() - .call_get_status_payload(store), - )?; - Ok(StatusPayload { - files: out - .files - .into_iter() - .map(|file| openvcs_core::models::FileEntry { - path: file.path, - old_path: file.old_path, - status: file.status, - staged: file.staged, - resolved_conflict: file.resolved_conflict, - hunks: file.hunks, - }) - .collect(), - ahead: out.ahead, - behind: out.behind, - }) - }) - } - - /// Calls typed `list-commits`. - pub fn vcs_list_commits(&self, query: &LogQuery) -> Result, String> { - self.with_vcs_bindings("log_commits", |bindings, store| { - let query = vcs_api::LogQuery { - rev: query.rev.clone(), - path: query.path.clone(), - since_utc: query.since_utc.clone(), - until_utc: query.until_utc.clone(), - author_contains: query.author_contains.clone(), - skip: query.skip, - limit: query.limit, - topo_order: query.topo_order, - include_merges: query.include_merges, - }; - let out = self.map_vcs_result( - "log_commits", - bindings - .openvcs_plugin_vcs_api() - .call_list_commits(store, &query), - )?; - Ok(out - .into_iter() - .map(|commit| CommitItem { - id: commit.id, - msg: commit.msg, - meta: commit.meta, - author: commit.author, - }) - .collect()) - }) - } - - /// Calls typed `diff-file`. - pub fn vcs_diff_file(&self, path: &str) -> Result, String> { - self.with_vcs_bindings("diff_file", |bindings, store| { - self.map_vcs_result( - "diff_file", - bindings - .openvcs_plugin_vcs_api() - .call_diff_file(store, path), - ) - }) - } - - /// Calls typed `diff-commit`. - pub fn vcs_diff_commit(&self, rev: &str) -> Result, String> { - self.with_vcs_bindings("diff_commit", |bindings, store| { - self.map_vcs_result( - "diff_commit", - bindings - .openvcs_plugin_vcs_api() - .call_diff_commit(store, rev), - ) - }) - } - - /// Calls typed `get-conflict-details`. - pub fn vcs_get_conflict_details(&self, path: &str) -> Result { - self.with_vcs_bindings("conflict_details", |bindings, store| { - let out = self.map_vcs_result( - "conflict_details", - bindings - .openvcs_plugin_vcs_api() - .call_get_conflict_details(store, path), - )?; - Ok(ConflictDetails { - path: out.path, - ours: out.ours, - theirs: out.theirs, - base: out.base, - binary: out.binary, - lfs_pointer: out.lfs_pointer, - }) - }) - } - - /// Calls typed `checkout-conflict-side`. - pub fn vcs_checkout_conflict_side(&self, path: &str, side: ConflictSide) -> Result<(), String> { - self.with_vcs_bindings("checkout_conflict_side", |bindings, store| { - let side = match side { - ConflictSide::Ours => vcs_api::ConflictSide::Ours, - ConflictSide::Theirs => vcs_api::ConflictSide::Theirs, - }; - self.map_vcs_result( - "checkout_conflict_side", - bindings - .openvcs_plugin_vcs_api() - .call_checkout_conflict_side(store, path, side), - ) - }) - } - - /// Calls typed `write-merge-result`. - pub fn vcs_write_merge_result(&self, path: &str, content: &[u8]) -> Result<(), String> { - self.with_vcs_bindings("write_merge_result", |bindings, store| { - self.map_vcs_result( - "write_merge_result", - bindings - .openvcs_plugin_vcs_api() - .call_write_merge_result(store, path, content), - ) - }) - } - - /// Calls typed `stage-patch`. - pub fn vcs_stage_patch(&self, patch: &str) -> Result<(), String> { - self.with_vcs_bindings("stage_patch", |bindings, store| { - self.map_vcs_result( - "stage_patch", - bindings - .openvcs_plugin_vcs_api() - .call_stage_patch(store, patch), - ) - }) - } - - /// Calls typed `discard-paths`. - pub fn vcs_discard_paths(&self, paths: &[String]) -> Result<(), String> { - self.with_vcs_bindings("discard_paths", |bindings, store| { - self.map_vcs_result( - "discard_paths", - bindings - .openvcs_plugin_vcs_api() - .call_discard_paths(store, paths), - ) - }) - } - - /// Calls typed `apply-reverse-patch`. - pub fn vcs_apply_reverse_patch(&self, patch: &str) -> Result<(), String> { - self.with_vcs_bindings("apply_reverse_patch", |bindings, store| { - self.map_vcs_result( - "apply_reverse_patch", - bindings - .openvcs_plugin_vcs_api() - .call_apply_reverse_patch(store, patch), - ) - }) - } - - /// Calls typed `delete-branch`. - pub fn vcs_delete_branch(&self, name: &str, force: bool) -> Result<(), String> { - self.with_vcs_bindings("delete_branch", |bindings, store| { - self.map_vcs_result( - "delete_branch", - bindings - .openvcs_plugin_vcs_api() - .call_delete_branch(store, name, force), - ) - }) - } - - /// Calls typed `rename-branch`. - pub fn vcs_rename_branch(&self, old: &str, new: &str) -> Result<(), String> { - self.with_vcs_bindings("rename_branch", |bindings, store| { - self.map_vcs_result( - "rename_branch", - bindings - .openvcs_plugin_vcs_api() - .call_rename_branch(store, old, new), - ) - }) - } - - /// Calls typed `merge-into-current`. - pub fn vcs_merge_into_current(&self, name: &str, message: Option<&str>) -> Result<(), String> { - self.with_vcs_bindings("merge_into_current", |bindings, store| { - self.map_vcs_result( - "merge_into_current", - bindings - .openvcs_plugin_vcs_api() - .call_merge_into_current(store, name, message), - ) - }) - } - - /// Calls typed `merge-abort`. - pub fn vcs_merge_abort(&self) -> Result<(), String> { - self.with_vcs_bindings("merge_abort", |bindings, store| { - self.map_vcs_result( - "merge_abort", - bindings.openvcs_plugin_vcs_api().call_merge_abort(store), - ) - }) - } - - /// Calls typed `merge-continue`. - pub fn vcs_merge_continue(&self) -> Result<(), String> { - self.with_vcs_bindings("merge_continue", |bindings, store| { - self.map_vcs_result( - "merge_continue", - bindings.openvcs_plugin_vcs_api().call_merge_continue(store), - ) - }) - } - - /// Calls typed `is-merge-in-progress`. - pub fn vcs_is_merge_in_progress(&self) -> Result { - self.with_vcs_bindings("merge_in_progress", |bindings, store| { - self.map_vcs_result( - "merge_in_progress", - bindings - .openvcs_plugin_vcs_api() - .call_is_merge_in_progress(store), - ) - }) - } - - /// Calls typed `set-branch-upstream`. - pub fn vcs_set_branch_upstream(&self, branch: &str, upstream: &str) -> Result<(), String> { - self.with_vcs_bindings("set_branch_upstream", |bindings, store| { - self.map_vcs_result( - "set_branch_upstream", - bindings - .openvcs_plugin_vcs_api() - .call_set_branch_upstream(store, branch, upstream), - ) - }) - } - - /// Calls typed `get-branch-upstream`. - pub fn vcs_get_branch_upstream(&self, branch: &str) -> Result, String> { - self.with_vcs_bindings("branch_upstream", |bindings, store| { - self.map_vcs_result( - "branch_upstream", - bindings - .openvcs_plugin_vcs_api() - .call_get_branch_upstream(store, branch), - ) - }) - } - - /// Calls typed `hard-reset-head`. - pub fn vcs_hard_reset_head(&self) -> Result<(), String> { - self.with_vcs_bindings("hard_reset_head", |bindings, store| { - self.map_vcs_result( - "hard_reset_head", - bindings - .openvcs_plugin_vcs_api() - .call_hard_reset_head(store), - ) - }) - } - - /// Calls typed `reset-soft-to`. - pub fn vcs_reset_soft_to(&self, rev: &str) -> Result<(), String> { - self.with_vcs_bindings("reset_soft_to", |bindings, store| { - self.map_vcs_result( - "reset_soft_to", - bindings - .openvcs_plugin_vcs_api() - .call_reset_soft_to(store, rev), - ) - }) - } - - /// Calls typed `get-identity`. - pub fn vcs_get_identity(&self) -> Result, String> { - self.with_vcs_bindings("get_identity", |bindings, store| { - let out = self.map_vcs_result( - "get_identity", - bindings.openvcs_plugin_vcs_api().call_get_identity(store), - )?; - Ok(out.map(|id| (id.name, id.email))) - }) - } - - /// Calls typed `set-identity-local`. - pub fn vcs_set_identity_local(&self, name: &str, email: &str) -> Result<(), String> { - self.with_vcs_bindings("set_identity_local", |bindings, store| { - self.map_vcs_result( - "set_identity_local", - bindings - .openvcs_plugin_vcs_api() - .call_set_identity_local(store, name, email), - ) - }) - } - - /// Calls typed `list-stashes`. - pub fn vcs_list_stashes(&self) -> Result, String> { - self.with_vcs_bindings("stash_list", |bindings, store| { - let out = self.map_vcs_result( - "stash_list", - bindings.openvcs_plugin_vcs_api().call_list_stashes(store), - )?; - Ok(out - .into_iter() - .map(|stash| StashItem { - selector: stash.selector, - msg: stash.msg, - meta: stash.meta, - }) - .collect()) - }) - } - - /// Calls typed `stash-push`. - pub fn vcs_stash_push( - &self, - message: Option<&str>, - include_untracked: bool, - ) -> Result { - self.with_vcs_bindings("stash_push", |bindings, store| { - self.map_vcs_result( - "stash_push", - bindings.openvcs_plugin_vcs_api().call_stash_push( - store, - message, - include_untracked, - ), - ) - }) - } - - /// Calls typed `stash-apply`. - pub fn vcs_stash_apply(&self, selector: &str) -> Result<(), String> { - self.with_vcs_bindings("stash_apply", |bindings, store| { - self.map_vcs_result( - "stash_apply", - bindings - .openvcs_plugin_vcs_api() - .call_stash_apply(store, selector), - ) - }) - } - - /// Calls typed `stash-pop`. - pub fn vcs_stash_pop(&self, selector: &str) -> Result<(), String> { - self.with_vcs_bindings("stash_pop", |bindings, store| { - self.map_vcs_result( - "stash_pop", - bindings - .openvcs_plugin_vcs_api() - .call_stash_pop(store, selector), - ) - }) - } - - /// Calls typed `stash-drop`. - pub fn vcs_stash_drop(&self, selector: &str) -> Result<(), String> { - self.with_vcs_bindings("stash_drop", |bindings, store| { - self.map_vcs_result( - "stash_drop", - bindings - .openvcs_plugin_vcs_api() - .call_stash_drop(store, selector), - ) - }) - } - - /// Calls typed `stash-show`. - pub fn vcs_stash_show(&self, selector: &str) -> Result, String> { - self.with_vcs_bindings("stash_show", |bindings, store| { - let out = self.map_vcs_result( - "stash_show", - bindings - .openvcs_plugin_vcs_api() - .call_stash_show(store, selector), - )?; - Ok(out.lines().map(str::to_string).collect()) - }) - } - - /// Calls typed `cherry-pick`. - pub fn vcs_cherry_pick(&self, commit: &str) -> Result<(), String> { - self.with_vcs_bindings("cherry_pick", |bindings, store| { - self.map_vcs_result( - "cherry_pick", - bindings - .openvcs_plugin_vcs_api() - .call_cherry_pick(store, commit), - ) - }) - } - - /// Calls typed `revert-commit`. - pub fn vcs_revert_commit(&self, commit: &str, no_edit: bool) -> Result<(), String> { - self.with_vcs_bindings("revert_commit", |bindings, store| { - self.map_vcs_result( - "revert_commit", - bindings - .openvcs_plugin_vcs_api() - .call_revert_commit(store, commit, no_edit), - ) - }) - } -} - -/// Converts a plugin WIT menu into the shared core menu model. -fn map_menu_from_wit(menu: plugin_api::Menu) -> Menu { - let elements = menu - .elements - .into_iter() - .map(|element| match element { - plugin_api::UiElement::Text(text) => UiElement::Text(UiText { - id: text.id, - content: text.content, - }), - plugin_api::UiElement::Button(button) => UiElement::Button(UiButton { - id: button.id, - label: button.label, - }), - }) - .collect::>(); - Menu { - id: menu.id, - label: menu.label, - order: menu.order, - elements, - } -} - -/// Converts a VCS-plugin WIT menu into the shared core menu model. -fn map_menu_from_vcs_wit(menu: vcs_settings_api::Menu) -> Menu { - let elements = menu - .elements - .into_iter() - .map(|element| match element { - vcs_settings_api::UiElement::Text(text) => UiElement::Text(UiText { - id: text.id, - content: text.content, - }), - vcs_settings_api::UiElement::Button(button) => UiElement::Button(UiButton { - id: button.id, - label: button.label, - }), - }) - .collect::>(); - Menu { - id: menu.id, - label: menu.label, - order: menu.order, - elements, - } -} - -/// Converts a plugin WIT setting entry into the shared core setting model. -fn map_setting_from_wit(setting: plugin_api::SettingKv) -> SettingKv { - SettingKv { - id: setting.id, - label: setting.label, - value: match setting.value { - plugin_api::SettingValue::Boolean(v) => SettingValue::Bool(v), - plugin_api::SettingValue::Signed32(v) => SettingValue::S32(v), - plugin_api::SettingValue::Unsigned32(v) => SettingValue::U32(v), - plugin_api::SettingValue::Float64(v) => SettingValue::F64(v), - plugin_api::SettingValue::Text(v) => SettingValue::String(v), - }, - } -} - -/// Maps settings value from VCS settings interface into core settings model. -fn map_setting_from_vcs_wit(setting: vcs_settings_api::SettingKv) -> SettingKv { - SettingKv { - id: setting.id, - label: setting.label, - value: match setting.value { - vcs_settings_api::SettingValue::Boolean(v) => SettingValue::Bool(v), - vcs_settings_api::SettingValue::Signed32(v) => SettingValue::S32(v), - vcs_settings_api::SettingValue::Unsigned32(v) => SettingValue::U32(v), - vcs_settings_api::SettingValue::Float64(v) => SettingValue::F64(v), - vcs_settings_api::SettingValue::Text(v) => SettingValue::String(v), - }, - } -} - -/// Converts a shared core setting entry into a plugin WIT setting model. -fn map_setting_to_wit(setting: SettingKv) -> plugin_api::SettingKv { - let value = match setting.value { - SettingValue::Bool(v) => plugin_api::SettingValue::Boolean(v), - SettingValue::S32(v) => plugin_api::SettingValue::Signed32(v), - SettingValue::U32(v) => plugin_api::SettingValue::Unsigned32(v), - SettingValue::F64(v) => plugin_api::SettingValue::Float64(v), - SettingValue::String(v) => plugin_api::SettingValue::Text(v), - }; - plugin_api::SettingKv { - id: setting.id, - label: setting.label, - value, - } -} - -/// Maps core settings model into VCS settings interface values. -fn map_setting_to_vcs_wit(setting: SettingKv) -> vcs_settings_api::SettingKv { - let value = match setting.value { - SettingValue::Bool(v) => vcs_settings_api::SettingValue::Boolean(v), - SettingValue::S32(v) => vcs_settings_api::SettingValue::Signed32(v), - SettingValue::U32(v) => vcs_settings_api::SettingValue::Unsigned32(v), - SettingValue::F64(v) => vcs_settings_api::SettingValue::Float64(v), - SettingValue::String(v) => vcs_settings_api::SettingValue::Text(v), - }; - vcs_settings_api::SettingKv { - id: setting.id, - label: setting.label, - value, - } -} - -/// Converts settings entries to a JSON map for persistence. -fn settings_to_json_map(values: &[SettingKv]) -> serde_json::Map { - let mut out = serde_json::Map::new(); - for entry in values { - out.insert(entry.id.clone(), setting_to_json_value(&entry.value)); - } - out -} - -/// Converts one typed setting value into JSON. -fn setting_to_json_value(value: &SettingValue) -> serde_json::Value { - match value { - SettingValue::Bool(v) => serde_json::Value::Bool(*v), - SettingValue::S32(v) => serde_json::Value::from(*v), - SettingValue::U32(v) => serde_json::Value::from(*v), - SettingValue::F64(v) => serde_json::Value::from(*v), - SettingValue::String(v) => serde_json::Value::String(v.clone()), - } -} - -/// Converts persisted JSON to a typed value using an existing setting type. -fn setting_from_json_value( - value: &serde_json::Value, - current: &SettingValue, -) -> Option { - match current { - SettingValue::Bool(_) => value.as_bool().map(SettingValue::Bool), - SettingValue::S32(_) => value - .as_i64() - .and_then(|v| i32::try_from(v).ok()) - .map(SettingValue::S32), - SettingValue::U32(_) => value - .as_u64() - .and_then(|v| u32::try_from(v).ok()) - .map(SettingValue::U32), - SettingValue::F64(_) => value.as_f64().map(SettingValue::F64), - SettingValue::String(_) => value.as_str().map(|v| SettingValue::String(v.to_string())), - } -} - -impl PluginRuntimeInstance for ComponentPluginRuntimeInstance { - /// Starts the component runtime when not already running. - fn ensure_running(&self) -> Result<(), String> { - let mut lock = self.runtime.lock(); - if lock.is_some() { - return Ok(()); - } - let runtime = self.instantiate_runtime()?; - *lock = Some(runtime); - Ok(()) - } - - /// Returns plugin-contributed UI menus. - fn get_menus(&self) -> Result, String> { - self.with_runtime(|runtime| runtime.call_get_menus(&self.spawn.plugin_id)) - } - - /// Invokes a plugin action by id. - fn handle_action(&self, id: &str) -> Result<(), String> { - self.with_runtime(|runtime| runtime.call_handle_action(&self.spawn.plugin_id, id)) - } - - /// Returns plugin settings defaults. - fn settings_defaults(&self) -> Result, String> { - self.with_runtime(|runtime| runtime.call_settings_defaults(&self.spawn.plugin_id)) - } - - /// Calls plugin settings-on-load hook. - fn settings_on_load(&self, values: Vec) -> Result, String> { - self.with_runtime(|runtime| runtime.call_settings_on_load(&self.spawn.plugin_id, values)) - } - - /// Calls plugin settings-on-apply hook. - fn settings_on_apply(&self, values: Vec) -> Result<(), String> { - self.with_runtime(|runtime| runtime.call_settings_on_apply(&self.spawn.plugin_id, values)) - } - - /// Calls plugin settings-on-save hook. - fn settings_on_save(&self, values: Vec) -> Result, String> { - self.with_runtime(|runtime| runtime.call_settings_on_save(&self.spawn.plugin_id, values)) - } - - /// Calls plugin settings-on-reset hook. - fn settings_on_reset(&self) -> Result<(), String> { - self.with_runtime(|runtime| runtime.call_settings_on_reset(&self.spawn.plugin_id)) - } - - /// Deinitializes and drops the running component runtime. - fn stop(&self) { - let mut lock = self.runtime.lock(); - if let Some(runtime) = lock.as_mut() { - runtime.call_deinit(); - } - *lock = None; - } -} diff --git a/Backend/src/plugin_runtime/events.rs b/Backend/src/plugin_runtime/events.rs index 80516d8b..44a3f368 100644 --- a/Backend/src/plugin_runtime/events.rs +++ b/Backend/src/plugin_runtime/events.rs @@ -39,23 +39,6 @@ pub fn unregister_plugin(plugin_id: &str) { } } -/// Subscribes a plugin to a named host/plugin event channel. -/// -/// # Parameters -/// - `plugin_id`: Subscriber plugin id. -/// - `event`: Event name to subscribe to. -/// -/// # Returns -/// - `()`. -pub fn subscribe(plugin_id: &str, event: &str) { - if let Ok(mut lock) = registry().lock() { - lock.subs - .entry(plugin_id.to_string()) - .or_default() - .insert(event.to_string()); - } -} - /// Emits an event originating from a plugin to other subscribers. /// /// # Parameters diff --git a/Backend/src/plugin_runtime/host_api.rs b/Backend/src/plugin_runtime/host_api.rs index 704dfe23..553ff4a6 100644 --- a/Backend/src/plugin_runtime/host_api.rs +++ b/Backend/src/plugin_runtime/host_api.rs @@ -1,114 +1,31 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::logging::LogTimer; -use crate::plugin_runtime::spawn::SpawnConfig; -use log::{debug, error, info, trace, warn}; -use openvcs_core::app_api::PluginError; -use parking_lot::RwLock; -use serde_json::Value; -use std::collections::HashSet; -use std::ffi::OsString; -use std::fs; -use std::io::Write; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, OnceLock}; - -const MODULE: &str = "host_api"; -const DEFAULT_STATUS_TEXT: &str = "Ready"; - -/// Callback type used to forward plugin status updates to the UI layer. -type StatusEventEmitter = dyn Fn(&str) + Send + Sync + 'static; +//! Minimal host-side plugin runtime APIs. -/// Optional process-wide UI status event emitter set during backend startup. -static STATUS_EVENT_EMITTER: OnceLock> = OnceLock::new(); +use parking_lot::RwLock; +use std::sync::OnceLock; -/// Shared process-wide status text used by plugin status setter/getter calls. +/// Global status emitter callback used by backend->frontend bridge. +static STATUS_EVENT_EMITTER: OnceLock> = OnceLock::new(); +/// Shared in-memory status text for plugin updates. static STATUS_TEXT: OnceLock> = OnceLock::new(); -// Whitelisted environment variables that are forwarded to child processes. -const SANITIZED_ENV_KEYS: &[&str] = &[ - "HOME", - "USER", - "USERPROFILE", - "TMPDIR", - "TEMP", - "TMP", - "LANG", - "LC_ALL", - "SSH_AUTH_SOCK", - "SSH_AGENT_PID", - "GIT_SSH_COMMAND", - "OPENVCS_SSH_MODE", - "OPENVCS_SSH", -]; - -#[cfg(unix)] -const DEFAULT_PATH_UNIX: &str = "/usr/bin:/bin"; -#[cfg(windows)] -const DEFAULT_PATH_WINDOWS_SUFFIX: &str = "\\System32"; - -/// Detects the runtime container kind for diagnostics. -fn runtime_container_kind() -> &'static str { - if matches!( - std::env::var("OPENVCS_FLATPAK").as_deref(), - Ok("1") | Ok("true") | Ok("yes") | Ok("on") - ) { - "flatpak" - } else if std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { - "appimage" - } else { - "native" - } -} - -/// Extracts approved capabilities and optional workspace root from spawn config. -fn approved_caps_and_workspace(spawn: &SpawnConfig) -> (HashSet, Option) { - let approved_caps = match &spawn.approval { - crate::plugin_bundles::ApprovalState::Approved { capabilities, .. } => { - capabilities.iter().cloned().collect::>() - } - _ => HashSet::new(), - }; - (approved_caps, spawn.allowed_workspace_root.clone()) -} - -/// Returns the shared status text store singleton. -/// -/// # Returns -/// - Global [`RwLock`] containing the current status text. +/// Returns global status storage singleton. fn status_text_store() -> &'static RwLock { - STATUS_TEXT.get_or_init(|| RwLock::new(DEFAULT_STATUS_TEXT.to_string())) + STATUS_TEXT.get_or_init(|| RwLock::new(String::new())) } -/// Returns whether status-set capability is approved for this plugin. -/// -/// # Parameters -/// - `caps`: Approved capability identifiers. -/// -/// # Returns -/// - `true` when `status.set` is approved. -fn has_status_set_cap(caps: &HashSet) -> bool { - caps.contains("status.set") -} - -/// Returns whether status-get capability is approved for this plugin. -/// -/// `status.set` implies `status.get`. -/// -/// # Parameters -/// - `caps`: Approved capability identifiers. -/// -/// # Returns -/// - `true` when `status.get` or `status.set` is approved. -fn has_status_get_cap(caps: &HashSet) -> bool { - caps.contains("status.get") || has_status_set_cap(caps) +/// Emits a status text event through the configured backend emitter. +fn emit_status_event(message: &str) { + if let Some(emitter) = STATUS_EVENT_EMITTER.get() { + emitter(message); + } } -/// Registers a callback used to forward status updates to the frontend. +/// Installs status event emitter callback for plugin-originated status updates. /// /// # Parameters -/// - `emitter`: Callback invoked with each status text update. +/// - `emitter`: Callback invoked with status text updates. /// /// # Returns /// - `()`. @@ -116,555 +33,24 @@ pub fn set_status_event_emitter(emitter: F) where F: Fn(&str) + Send + Sync + 'static, { - let _ = STATUS_EVENT_EMITTER.set(Arc::new(emitter)); + let _ = STATUS_EVENT_EMITTER.set(Box::new(emitter)); } -/// Emits a status update through the configured frontend event callback. +/// Sets status text without permission checks. /// -/// # Parameters -/// - `message`: Status text to emit. -/// -/// # Returns -/// - `()`. -fn emit_status_event(message: &str) { - if let Some(emitter) = STATUS_EVENT_EMITTER.get() { - emitter(message); - } -} - -/// Host API result type alias for plugin-facing operations. -pub(crate) type HostResult = Result; - -/// Host-side result for `process-exec` mapped into WIT bindings by runtime glue. -pub(crate) struct HostProcessExecOutput { - /// Whether the process exited successfully. - pub success: bool, - /// Numeric process exit status code. - pub status: i32, - /// Captured standard output text. - pub stdout: String, - /// Captured standard error text. - pub stderr: String, -} - -/// Creates a structured plugin host error. -fn host_error(code: &str, message: impl Into) -> PluginError { - PluginError { - code: code.to_string(), - message: message.into(), - } -} - -/// Resolves a plugin-supplied path under an allowed workspace root. -fn resolve_under_root(root: &Path, path: &str) -> Result { - trace!("resolve_under_root: root={}, path={}", root.display(), path); - if path.contains('\0') { - warn!("resolve_under_root: path contains NUL"); - return Err("path contains NUL".to_string()); - } - - let p = Path::new(path); - if p.is_absolute() { - let root = root - .canonicalize() - .map_err(|e| format!("canonicalize root {}: {e}", root.display()))?; - let p = p - .canonicalize() - .map_err(|e| format!("canonicalize path {}: {e}", p.display()))?; - if p.starts_with(&root) { - trace!("resolve_under_root: resolved absolute path within root",); - return Ok(p); - } - warn!("resolve_under_root: path escapes workspace root",); - return Err("path escapes workspace root".to_string()); - } - - let mut clean = PathBuf::new(); - for comp in p.components() { - match comp { - Component::Normal(c) => clean.push(c), - Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => { - warn!("resolve_under_root: invalid path component in '{}'", path); - return Err("path must be relative and not contain '..'".to_string()); - } - } - } - let resolved = root.join(clean); - trace!("resolve_under_root: resolved to {}", resolved.display()); - Ok(resolved) -} - -/// Writes bytes to a relative path constrained to the workspace root. -fn write_file_under_root(root: &Path, rel: &str, bytes: &[u8]) -> Result<(), String> { - trace!( - "write_file_under_root: root={}, rel={}, len={}", - root.display(), - rel, - bytes.len() - ); - let path = resolve_under_root(root, rel)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; - } - fs::write(&path, bytes).map_err(|e| { - error!( - "write_file_under_root: failed to write {}: {}", - path.display(), - e - ); - format!("write {}: {e}", path.display()) - }) -} - -/// Reads bytes from a relative path constrained to the workspace root. -fn read_file_under_root(root: &Path, rel: &str) -> Result, String> { - trace!("read_file_under_root: root={}, rel={}", root.display(), rel); - let path = resolve_under_root(root, rel)?; - let result = fs::read(&path).map_err(|e| { - error!( - "read_file_under_root: failed to read {}: {}", - path.display(), - e - ); - format!("read {}: {e}", path.display()) - })?; - debug!( - "read_file_under_root: read {} bytes from {}", - result.len(), - rel - ); - Ok(result) -} - -/// Builds a sanitized child-process environment for command execution. -fn sanitized_env() -> Vec<(OsString, OsString)> { - let mut out: Vec<(OsString, OsString)> = Vec::new(); - - for &k in SANITIZED_ENV_KEYS { - if let Ok(v) = std::env::var(k) { - out.push((k.into(), v.into())); - } - } - - #[cfg(unix)] - { - out.push(("PATH".into(), DEFAULT_PATH_UNIX.into())); - } - #[cfg(windows)] - { - if let Ok(sysroot) = std::env::var("SystemRoot") { - out.push(( - "PATH".into(), - format!("{sysroot}{}", DEFAULT_PATH_WINDOWS_SUFFIX).into(), - )); - } - } - - out -} - -/// Returns runtime metadata exposed to plugins. -pub fn host_runtime_info() -> openvcs_core::RuntimeInfo { - trace!("host_runtime_info: gathering runtime info"); - let kind = runtime_container_kind(); - let info = openvcs_core::RuntimeInfo { - os: Some(std::env::consts::OS.to_string()), - arch: Some(std::env::consts::ARCH.to_string()), - container: Some(kind.to_string()), - }; - debug!( - "host_runtime_info: os={}, arch={}, container={}", - info.os.as_deref().unwrap_or("unknown"), - info.arch.as_deref().unwrap_or("unknown"), - kind - ); - info -} - -/// Registers a plugin subscription for a named host event. -pub fn host_subscribe_event(spawn: &SpawnConfig, event_name: &str) -> HostResult<()> { - let _timer = LogTimer::new(MODULE, "host_subscribe_event"); - let name = event_name.trim(); - trace!( - "host_subscribe_event: plugin={}, event='{}'", - spawn.plugin_id, - name - ); - - if name.is_empty() { - warn!( - "host_subscribe_event: empty event name from plugin {}", - spawn.plugin_id - ); - return Err(host_error("host.invalid_event_name", "event name is empty")); - } - - crate::plugin_runtime::events::subscribe(&spawn.plugin_id, name); - debug!( - "host_subscribe_event: plugin {} subscribed to '{}'", - spawn.plugin_id, name - ); - Ok(()) -} - -/// Emits a plugin-originated event with JSON payload validation. -pub fn host_emit_event(spawn: &SpawnConfig, event_name: &str, payload: &[u8]) -> HostResult<()> { - let _timer = LogTimer::new(MODULE, "host_emit_event"); - let name = event_name.trim(); - trace!( - "host_emit_event: plugin={}, event='{}', payload_len={}", - spawn.plugin_id, - name, - payload.len() - ); - - if name.is_empty() { - warn!( - "host_emit_event: empty event name from plugin {}", - spawn.plugin_id - ); - return Err(host_error("host.invalid_event_name", "event name is empty")); - } - - let payload_json = if payload.is_empty() { - Value::Null - } else { - serde_json::from_slice(payload).map_err(|err| { - error!( - "host_emit_event: invalid JSON payload from plugin {}: {}", - spawn.plugin_id, err - ); - host_error( - "host.invalid_payload", - format!("payload is not valid JSON: {err}"), - ) - })? - }; - - crate::plugin_runtime::events::emit_from_plugin(&spawn.plugin_id, name, payload_json); - debug!( - "host_emit_event: plugin {} emitted '{}'", - spawn.plugin_id, name - ); - Ok(()) -} - -/// Handles legacy plugin notification requests through status setter semantics. -pub fn host_ui_notify(spawn: &SpawnConfig, message: &str) -> HostResult<()> { - host_set_status(spawn, message) -} - -/// Sets the client footer status text for a plugin. -/// -/// Requires `status.set` capability approval. -/// -/// # Parameters -/// - `spawn`: Plugin spawn/capability context. -/// - `message`: Status text to set. -/// -/// # Returns -/// - `Ok(())` when status is stored and emitted. -/// - `Ok(())` with a no-op when capability is denied. -pub fn host_set_status(spawn: &SpawnConfig, message: &str) -> HostResult<()> { - let (caps, _) = approved_caps_and_workspace(spawn); - trace!( - "host_set_status: plugin={}, message_len={}", - spawn.plugin_id, - message.len() - ); - - if !has_status_set_cap(&caps) { - warn!( - "host_set_status: capability denied for plugin {} (missing status.set)", - spawn.plugin_id - ); - return Ok(()); - } - - { - let mut status = status_text_store().write(); - *status = message.to_string(); - } - - info!( - "host_set_status: plugin[{}] status: {}", - spawn.plugin_id, message - ); - emit_status_event(message); - Ok(()) -} - -/// Gets the current client footer status text for a plugin. -/// -/// Requires either `status.get` or `status.set` capability approval. +/// This is used by the Node plugin runtime where plugin trust is explicit and +/// capability gates are disabled. /// /// # Parameters -/// - `spawn`: Plugin spawn/capability context. +/// - `message`: New status text. /// /// # Returns -/// - `Ok(String)` with current status text. -/// - `Ok(String)` with current status text when capability is denied. -pub fn host_get_status(spawn: &SpawnConfig) -> HostResult { - let (caps, _) = approved_caps_and_workspace(spawn); - trace!("host_get_status: plugin={}", spawn.plugin_id); - - if !has_status_get_cap(&caps) { - warn!( - "host_get_status: capability denied for plugin {} (missing status.get)", - spawn.plugin_id - ); - return Ok(status_text_store().read().clone()); - } - - Ok(status_text_store().read().clone()) -} - -/// Reads a workspace file when the plugin has workspace read access. -pub fn host_workspace_read_file(spawn: &SpawnConfig, path: &str) -> HostResult> { - let _timer = LogTimer::new(MODULE, "host_workspace_read_file"); - trace!( - "host_workspace_read_file: plugin={}, path='{}'", - spawn.plugin_id, - path - ); - - let (caps, workspace_root) = approved_caps_and_workspace(spawn); - - if !caps.contains("workspace.read") && !caps.contains("workspace.write") { - warn!( - "host_workspace_read_file: capability denied for plugin {} (missing workspace.read)", - spawn.plugin_id - ); - return Err(host_error( - "capability.denied", - "missing capability: workspace.read (or workspace.write)", - )); - } - - let Some(root) = workspace_root.as_ref() else { - warn!( - "host_workspace_read_file: no workspace context for plugin {}", - spawn.plugin_id - ); - return Err(host_error("workspace.denied", "no workspace context")); - }; - - let result = read_file_under_root(root, path).map_err(|err| { - error!( - "host_workspace_read_file: failed for plugin {}: {}", - spawn.plugin_id, err - ); - host_error("workspace.error", err) - })?; - - debug!( - "host_workspace_read_file: plugin {} read {} bytes from '{}'", - spawn.plugin_id, - result.len(), - path - ); - Ok(result) -} - -/// Writes a workspace file when the plugin has workspace write access. -pub fn host_workspace_write_file( - spawn: &SpawnConfig, - path: &str, - content: &[u8], -) -> HostResult<()> { - let _timer = LogTimer::new(MODULE, "host_workspace_write_file"); - trace!( - "host_workspace_write_file: plugin={}, path='{}', len={}", - spawn.plugin_id, - path, - content.len() - ); - - let (caps, workspace_root) = approved_caps_and_workspace(spawn); - - if !caps.contains("workspace.write") { - warn!( - "host_workspace_write_file: capability denied for plugin {} (missing workspace.write)", - spawn.plugin_id - ); - return Err(host_error( - "capability.denied", - "missing capability: workspace.write", - )); - } - - let Some(root) = workspace_root.as_ref() else { - warn!( - "host_workspace_write_file: no workspace context for plugin {}", - spawn.plugin_id - ); - return Err(host_error("workspace.denied", "no workspace context")); - }; - - write_file_under_root(root, path, content).map_err(|err| { - error!( - "host_workspace_write_file: failed for plugin {}: {}", - spawn.plugin_id, err - ); - host_error("workspace.error", err) - })?; - - debug!( - "host_workspace_write_file: plugin {} wrote {} bytes to '{}'", - spawn.plugin_id, - content.len(), - path - ); - Ok(()) -} - -/// Executes a program with sanitized environment and capability checks. -pub fn host_process_exec( - spawn: &SpawnConfig, - cwd: Option<&str>, - program: &str, - args: &[String], - env: &[(String, String)], - stdin: Option<&str>, -) -> HostResult { - let _timer = LogTimer::new(MODULE, "host_process_exec"); - let program = program.trim(); - if program.is_empty() { - return Err(host_error("process.error", "program is empty")); - } - info!( - "host_process_exec: plugin={}, program='{}', args={:?}", - spawn.plugin_id, program, args - ); - debug!( - "host_process_exec: cwd={:?}, env_count={}, has_stdin={}", - cwd, - env.len(), - stdin.is_some() - ); - trace!( - "host_process_exec: env={:?}, stdin_len={}", - env, - stdin.map(|s| s.len()).unwrap_or(0) - ); - - let (caps, workspace_root) = approved_caps_and_workspace(spawn); - - if !caps.contains("process.exec") { - warn!( - "host_process_exec: capability denied for plugin {} (missing process.exec)", - spawn.plugin_id - ); - return Err(host_error( - "capability.denied", - "missing capability: process.exec", - )); - } - - let cwd = match cwd { - None => workspace_root, - Some(raw) if raw.trim().is_empty() => workspace_root, - Some(raw) => { - let Some(root) = spawn.allowed_workspace_root.as_ref() else { - warn!( - "host_process_exec: no workspace context for plugin {}", - spawn.plugin_id - ); - return Err(host_error("workspace.denied", "no workspace context")); - }; - Some(resolve_under_root(root, raw).map_err(|e| { - warn!( - "host_process_exec: invalid cwd for plugin {}: {}", - spawn.plugin_id, e - ); - host_error("workspace.denied", e) - })?) - } - }; - - debug!( - "host_process_exec: executing '{}' with cwd={:?}", - program, - cwd.as_ref().map(|p| p.display()) - ); - - let start = std::time::Instant::now(); - - let mut cmd = Command::new(program); - if let Some(cwd) = cwd.as_ref() { - cmd.current_dir(cwd); - } - cmd.args(args); - cmd.env_clear(); - for (k, v) in sanitized_env() { - cmd.env(k, v); - } - for (k, v) in env { - if matches!( - k.as_str(), - "GIT_SSH_COMMAND" | "GIT_TERMINAL_PROMPT" | "OPENVCS_SSH_MODE" | "OPENVCS_SSH" - ) { - cmd.env(k, v); - } - } - - let stdin_text = stdin.unwrap_or_default(); - let out = if stdin_text.is_empty() { - cmd.output().map_err(|e| { - error!("host_process_exec: failed to spawn '{}': {}", program, e); - host_error("process.error", format!("spawn {program}: {e}")) - })? - } else { - cmd.stdin(Stdio::piped()); - let mut child = cmd.spawn().map_err(|e| { - error!("host_process_exec: failed to spawn '{}': {}", program, e); - host_error("process.error", format!("spawn {program}: {e}")) - })?; - if let Some(mut child_stdin) = child.stdin.take() { - if let Err(e) = child_stdin.write_all(stdin_text.as_bytes()) { - let _ = child.kill(); - error!("host_process_exec: failed to write stdin: {}", e); - return Err(host_error("process.error", format!("write stdin: {e}"))); - } - } - child.wait_with_output().map_err(|e| { - error!("host_process_exec: failed to wait for process: {}", e); - host_error("process.error", format!("wait: {e}")) - })? - }; - - let elapsed = start.elapsed(); - let result = HostProcessExecOutput { - success: out.status.success(), - status: out.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&out.stdout).to_string(), - stderr: String::from_utf8_lossy(&out.stderr).to_string(), - }; - - if result.success { - debug!( - "host_process_exec: '{}' {:?} succeeded in {:?} (code={})", - program, - args.first(), - elapsed, - result.status - ); - trace!( - "host_process_exec: stdout_len={}, stderr_len={}", - result.stdout.len(), - result.stderr.len() - ); - } else { - warn!( - "host_process_exec: '{}' {:?} failed in {:?} (code={}): {}", - program, - args.first(), - elapsed, - result.status, - result.stderr.lines().next().unwrap_or("") - ); +/// - `()`. +pub fn set_status_text_unchecked(message: &str) { + let trimmed = message.trim(); + if trimmed.is_empty() { + return; } - - Ok(result) + *status_text_store().write() = trimmed.to_string(); + emit_status_event(trimmed); } diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index 7cc7c3ac..ea759ed8 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -583,6 +583,16 @@ impl PluginRuntimeManager { installed.approval ); + if !matches!( + installed.approval, + crate::plugin_bundles::ApprovalState::Approved { .. } + ) { + return Err(format!( + "plugin '{}' is not approved to run", + components.plugin_id + )); + } + let key = components.plugin_id.to_ascii_lowercase(); debug!("resolve_module_runtime_spec: resolved key='{}'", key); @@ -596,7 +606,6 @@ impl PluginRuntimeManager { spawn: SpawnConfig { plugin_id: components.plugin_id, exec_path, - approval: installed.approval, allowed_workspace_root, is_vcs_backend, }, @@ -660,11 +669,7 @@ mod tests { use std::fs; use tempfile::tempdir; - const MINIMAL_WASM: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, - 0x02, 0x01, 0x00, 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, - 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, - ]; + const MINIMAL_NODE_MODULE: &str = "export {};\n"; #[test] /// Verifies repeated start/stop calls keep runtime state stable. @@ -819,7 +824,11 @@ mod tests { ) { let plugin_dir = root.join(plugin_id); fs::create_dir_all(plugin_dir.join("bin")).expect("create plugin dir"); - fs::write(plugin_dir.join("bin").join("plugin.wasm"), MINIMAL_WASM).expect("write wasm"); + fs::write( + plugin_dir.join("bin").join("plugin.mjs"), + MINIMAL_NODE_MODULE, + ) + .expect("write node module"); let vcs_backends = if include_vcs_backends { vec![serde_json::json!({ "id": "git", "name": "Git" })] @@ -833,7 +842,7 @@ mod tests { "version": "1.0.0", "default_enabled": default_enabled, "module": { - "exec": "plugin.wasm", + "exec": "plugin.mjs", "vcs_backends": vcs_backends } }); diff --git a/Backend/src/plugin_runtime/mod.rs b/Backend/src/plugin_runtime/mod.rs index 76b6582a..fb454c5a 100644 --- a/Backend/src/plugin_runtime/mod.rs +++ b/Backend/src/plugin_runtime/mod.rs @@ -2,11 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! Plugin runtime subsystem modules. //! -//! These modules provide plugin process/component lifecycle management, +//! These modules provide plugin process lifecycle management, //! host API bridging, and backend proxy adapters. - -/// Component-model runtime implementation and ABI dispatch. -pub mod component_instance; /// In-memory plugin event subscription registry. pub mod events; /// Host functions exposed to plugin modules. @@ -15,6 +12,10 @@ pub mod host_api; pub mod instance; /// Long-lived plugin runtime lifecycle manager. pub mod manager; +/// Node.js runtime implementation and JSON-RPC client. +pub mod node_instance; +/// JSON-RPC protocol constants and framing helpers. +pub mod protocol; /// Runtime transport selection and factory helpers. pub mod runtime_select; /// Plugin settings persistence helpers. diff --git a/Backend/src/plugin_runtime/node_instance.rs b/Backend/src/plugin_runtime/node_instance.rs new file mode 100644 index 00000000..befc025b --- /dev/null +++ b/Backend/src/plugin_runtime/node_instance.rs @@ -0,0 +1,900 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! Node.js plugin runtime implementation. +//! +//! This runtime spawns long-lived Node processes and exchanges JSON-RPC 2.0 +//! messages over stdio using an LSP-style framing protocol. + +use crate::plugin_paths; +use crate::plugin_runtime::events; +use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::protocol::{ + read_framed_message, write_framed_message, Methods, NotificationMethods, RpcError, RpcRequest, + RpcResponse, PROTOCOL_VERSION, +}; +use crate::plugin_runtime::spawn::SpawnConfig; +use base64::Engine; +use log::{debug, info, trace, warn}; +use openvcs_core::models::{ + Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, + StatusSummary, VcsEvent, +}; +use openvcs_core::settings::SettingKv; +use openvcs_core::ui::Menu; +use parking_lot::{Mutex, RwLock}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::BufReader; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Arc; + +/// Live stdio-backed JSON-RPC process handle. +struct NodeRpcProcess { + /// Child process hosting the plugin runtime. + child: Child, + /// Writable stdin stream for requests. + stdin: ChildStdin, + /// Readable stdout stream for responses and notifications. + stdout: BufReader, + /// Monotonic request id counter. + next_request_id: u64, +} + +impl NodeRpcProcess { + /// Sends one JSON-RPC request and waits for a matching response. + /// + /// # Parameters + /// - `method`: RPC method name. + /// - `params`: RPC params payload. + /// - `plugin_id`: Plugin id for diagnostics. + /// - `on_notification`: Callback for incoming notifications. + /// + /// # Returns + /// - `Ok(T)` decoded response result. + /// - `Err(String)` when transport/protocol/plugin errors occur. + fn call( + &mut self, + method: &str, + params: Value, + plugin_id: &str, + on_notification: &mut dyn FnMut(&str, &Value) -> Result<(), String>, + ) -> Result + where + T: DeserializeOwned, + { + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .ok_or_else(|| "rpc request id overflow".to_string())?; + + let request = RpcRequest { + jsonrpc: "2.0".to_string(), + id: request_id, + method: method.to_string(), + params, + }; + let request_value = + serde_json::to_value(request).map_err(|e| format!("encode rpc request: {e}"))?; + write_framed_message(&mut self.stdin, &request_value)?; + + loop { + let message = read_framed_message(&mut self.stdout)?; + + if let Some(method_name) = message.get("method").and_then(Value::as_str) { + let params = message.get("params").cloned().unwrap_or(Value::Null); + on_notification(method_name, ¶ms)?; + continue; + } + + let response: RpcResponse = serde_json::from_value(message) + .map_err(|e| format!("decode rpc response for '{method}': {e}"))?; + if response.id != request_id { + debug!( + "node rpc: ignoring out-of-order response id={} for method='{}' (expected={})", + response.id, method, request_id + ); + continue; + } + + if let Some(error) = response.error { + return Err(format_rpc_error(plugin_id, method, &error)); + } + + let result = response.result.unwrap_or(Value::Null); + return serde_json::from_value(result) + .map_err(|e| format!("decode rpc result for '{method}': {e}")); + } + } +} + +/// Parsed plugin initialize response payload. +#[derive(Debug, Deserialize)] +struct InitializeResponse { + /// Protocol version accepted by plugin. + protocol_version: u32, +} + +/// Parsed session-open response payload. +#[derive(Debug, Deserialize)] +struct OpenSessionResponse { + /// Stable plugin-owned session id. + session_id: String, +} + +/// Parsed identity response payload. +#[derive(Debug, Deserialize)] +struct IdentityResponse { + /// User name. + name: String, + /// User email. + email: String, +} + +/// Parsed remote descriptor response payload. +#[derive(Debug, Deserialize)] +struct RemoteEntry { + /// Remote name. + name: String, + /// Remote URL. + url: String, +} + +/// Node runtime implementation backing plugin RPC calls. +pub struct NodePluginRuntimeInstance { + /// Spawn configuration for this runtime instance. + spawn: SpawnConfig, + /// Lazily started plugin process. + process: Mutex>, + /// Active VCS session id for backend plugins. + vcs_session_id: Mutex>, + /// Optional sink for VCS progress events. + event_sink: RwLock>>, +} + +impl NodePluginRuntimeInstance { + /// Creates a node runtime instance for the given spawn config. + /// + /// # Parameters + /// - `spawn`: Runtime spawn details. + /// + /// # Returns + /// - New runtime instance. + pub fn new(spawn: SpawnConfig) -> Self { + Self { + spawn, + process: Mutex::new(None), + vcs_session_id: Mutex::new(None), + event_sink: RwLock::new(None), + } + } + + /// Resolves the bundled Node executable path used to launch plugins. + /// + /// # Returns + /// - `Ok(String)` with absolute path to bundled node runtime. + /// - `Err(String)` when bundled runtime path is unavailable. + fn node_executable(&self) -> Result { + let Some(path) = plugin_paths::node_executable_path() else { + return Err( + "bundled node runtime is unavailable; plugin execution requires app-bundled node" + .to_string(), + ); + }; + Ok(path.display().to_string()) + } + + /// Starts the plugin process and performs initialization handshake. + /// + /// # Returns + /// - `Ok(NodeRpcProcess)` when startup succeeds. + /// - `Err(String)` when process startup or init RPC fails. + fn spawn_process(&self) -> Result { + let node_exec = self.node_executable()?; + info!( + "plugin runtime: starting node plugin '{}' via '{}'", + self.spawn.plugin_id, node_exec + ); + + let mut cmd = Command::new(&node_exec); + cmd.arg(&self.spawn.exec_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .env("OPENVCS_PLUGIN_ID", self.spawn.plugin_id.trim()); + + let mut child = cmd.spawn().map_err(|e| { + format!( + "spawn node runtime '{}': {e}", + self.spawn.exec_path.display() + ) + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| "node runtime missing stdin pipe".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "node runtime missing stdout pipe".to_string())?; + + let mut process = NodeRpcProcess { + child, + stdin, + stdout: BufReader::new(stdout), + next_request_id: 1, + }; + + let initialize: InitializeResponse = process.call( + Methods::PLUGIN_INITIALIZE, + json!({ + "plugin_id": self.spawn.plugin_id, + "protocol_version": PROTOCOL_VERSION, + }), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + )?; + if initialize.protocol_version != PROTOCOL_VERSION { + return Err(format!( + "plugin '{}' protocol mismatch: expected {}, got {}", + self.spawn.plugin_id, PROTOCOL_VERSION, initialize.protocol_version + )); + } + + let _: Value = process.call( + Methods::PLUGIN_INIT, + Value::Object(serde_json::Map::new()), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + )?; + + Ok(process) + } + + /// Ensures a process exists and runs a closure against it. + /// + /// # Parameters + /// - `f`: Closure to execute with mutable process access. + /// + /// # Returns + /// - Closure return value. + fn with_process( + &self, + f: impl FnOnce(&mut NodeRpcProcess) -> Result, + ) -> Result { + let mut lock = self.process.lock(); + if lock.is_none() { + *lock = Some(self.spawn_process()?); + } + let process = lock + .as_mut() + .ok_or_else(|| "node runtime did not initialize".to_string())?; + f(process) + } + + /// Sends one RPC request to the plugin process. + /// + /// # Parameters + /// - `method`: Method name. + /// - `params`: Params object. + /// + /// # Returns + /// - Decoded result value. + fn rpc_call(&self, method: &str, params: Value) -> Result + where + T: DeserializeOwned, + { + self.with_process(|process| { + process.call( + method, + params, + self.spawn.plugin_id.as_str(), + &mut |notif_method, notif_params| { + self.handle_notification(notif_method, notif_params); + Ok(()) + }, + ) + }) + } + + /// Sends one RPC request to the plugin process where result is ignored. + /// + /// # Parameters + /// - `method`: Method name. + /// - `params`: Params object. + /// + /// # Returns + /// - `Ok(())` when request succeeds. + fn rpc_call_unit(&self, method: &str, params: Value) -> Result<(), String> { + let _: Value = self.rpc_call(method, params)?; + Ok(()) + } + + /// Builds a session-scoped RPC params object. + /// + /// # Parameters + /// - `extra`: Additional method parameters. + /// + /// # Returns + /// - Session-scoped params object. + fn session_params(&self, extra: Value) -> Result { + let session_id = self + .vcs_session_id + .lock() + .clone() + .ok_or_else(|| "vcs session is not open".to_string())?; + + let mut map = serde_json::Map::new(); + map.insert("session_id".to_string(), Value::String(session_id)); + if let Value::Object(extra_map) = extra { + for (key, value) in extra_map { + map.insert(key, value); + } + } + Ok(Value::Object(map)) + } + + /// Handles one plugin-originated notification. + /// + /// # Parameters + /// - `method`: Notification method. + /// - `params`: Notification payload. + fn handle_notification(&self, method: &str, params: &Value) { + match method { + NotificationMethods::HOST_LOG => { + let level = params + .get("level") + .and_then(Value::as_str) + .unwrap_or("info") + .to_ascii_lowercase(); + let target = params + .get("target") + .and_then(Value::as_str) + .unwrap_or("plugin") + .trim(); + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + match level.as_str() { + "trace" => log::trace!(target: target, "{}", message), + "debug" => log::debug!(target: target, "{}", message), + "warn" => log::warn!(target: target, "{}", message), + "error" => log::error!(target: target, "{}", message), + _ => log::info!(target: target, "{}", message), + } + } + NotificationMethods::HOST_UI_NOTIFY => { + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if !message.is_empty() { + info!("plugin ui notify ({}): {}", self.spawn.plugin_id, message); + } + } + NotificationMethods::HOST_STATUS_SET => { + let message = params + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + crate::plugin_runtime::host_api::set_status_text_unchecked(message); + } + NotificationMethods::HOST_EVENT_EMIT => { + let event_name = params + .get("event_name") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + if event_name.is_empty() { + return; + } + let payload = params.get("payload").cloned().unwrap_or(Value::Null); + events::emit_from_plugin(self.spawn.plugin_id.as_str(), event_name, payload); + } + NotificationMethods::VCS_EVENT => { + let Some(raw_event) = params.get("event") else { + return; + }; + let event: VcsEvent = match serde_json::from_value(raw_event.clone()) { + Ok(value) => value, + Err(err) => { + warn!( + "plugin '{}' emitted invalid vcs.event payload: {}", + self.spawn.plugin_id, err + ); + return; + } + }; + if let Some(sink) = self.event_sink.read().clone() { + sink(event); + } + } + other => { + trace!( + "plugin '{}' emitted unknown notification '{}'", + self.spawn.plugin_id, + other + ); + } + } + } + + /// Closes an active VCS session when present. + fn close_vcs_session(&self) { + let session_id = self.vcs_session_id.lock().take(); + if let Some(session_id) = session_id { + let _ = self.rpc_call_unit(Methods::VCS_CLOSE, json!({ "session_id": session_id })); + } + } + + /// Calls `vcs.get-caps` and returns backend capability flags. + pub fn vcs_get_caps(&self) -> Result { + self.rpc_call(Methods::VCS_GET_CAPS, Value::Object(serde_json::Map::new())) + } + + /// Calls `vcs.open` and stores active session id. + /// + /// # Parameters + /// - `path`: Repository workdir path. + /// - `config`: Serialized open config JSON bytes. + pub fn vcs_open(&self, path: &str, config: &[u8]) -> Result<(), String> { + let config_value = if config.is_empty() { + Value::Object(serde_json::Map::new()) + } else { + serde_json::from_slice(config).unwrap_or_else(|_| Value::Object(serde_json::Map::new())) + }; + let result: OpenSessionResponse = self.rpc_call( + Methods::VCS_OPEN, + json!({ + "path": path, + "config": config_value, + }), + )?; + *self.vcs_session_id.lock() = Some(result.session_id); + Ok(()) + } + + /// Calls `vcs.get-current-branch`. + pub fn vcs_get_current_branch(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_CURRENT_BRANCH, params) + } + + /// Calls `vcs.list-branches`. + pub fn vcs_list_branches(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_BRANCHES, params) + } + + /// Calls `vcs.list-local-branches`. + pub fn vcs_list_local_branches(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_LOCAL_BRANCHES, params) + } + + /// Calls `vcs.create-branch`. + pub fn vcs_create_branch(&self, name: &str, checkout: bool) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "checkout": checkout }))?; + self.rpc_call_unit(Methods::VCS_CREATE_BRANCH, params) + } + + /// Calls `vcs.checkout-branch`. + pub fn vcs_checkout_branch(&self, name: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name }))?; + self.rpc_call_unit(Methods::VCS_CHECKOUT_BRANCH, params) + } + + /// Calls `vcs.ensure-remote`. + pub fn vcs_ensure_remote(&self, name: &str, url: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "url": url }))?; + self.rpc_call_unit(Methods::VCS_ENSURE_REMOTE, params) + } + + /// Calls `vcs.list-remotes`. + pub fn vcs_list_remotes(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + let entries: Vec = self.rpc_call(Methods::VCS_LIST_REMOTES, params)?; + Ok(entries + .into_iter() + .map(|entry| (entry.name, entry.url)) + .collect()) + } + + /// Calls `vcs.remove-remote`. + pub fn vcs_remove_remote(&self, name: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name }))?; + self.rpc_call_unit(Methods::VCS_REMOVE_REMOTE, params) + } + + /// Calls `vcs.fetch`. + pub fn vcs_fetch(&self, remote: &str, refspec: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "refspec": refspec }))?; + self.rpc_call_unit(Methods::VCS_FETCH, params) + } + + /// Calls `vcs.fetch-with-options`. + pub fn vcs_fetch_with_options( + &self, + remote: &str, + refspec: &str, + opts: FetchOptions, + ) -> Result<(), String> { + let params = self.session_params(json!({ + "remote": remote, + "refspec": refspec, + "opts": opts, + }))?; + self.rpc_call_unit(Methods::VCS_FETCH_WITH_OPTIONS, params) + } + + /// Calls `vcs.push`. + pub fn vcs_push(&self, remote: &str, refspec: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "refspec": refspec }))?; + self.rpc_call_unit(Methods::VCS_PUSH, params) + } + + /// Calls `vcs.pull-ff-only`. + pub fn vcs_pull_ff_only(&self, remote: &str, branch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "remote": remote, "branch": branch }))?; + self.rpc_call_unit(Methods::VCS_PULL_FF_ONLY, params) + } + + /// Calls `vcs.commit`. + pub fn vcs_commit( + &self, + message: &str, + name: &str, + email: &str, + paths: &[String], + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "name": name, + "email": email, + "paths": paths, + }))?; + self.rpc_call(Methods::VCS_COMMIT, params) + } + + /// Calls `vcs.commit-index`. + pub fn vcs_commit_index( + &self, + message: &str, + name: &str, + email: &str, + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "name": name, + "email": email, + }))?; + self.rpc_call(Methods::VCS_COMMIT_INDEX, params) + } + + /// Calls `vcs.get-status-summary`. + pub fn vcs_get_status_summary(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_STATUS_SUMMARY, params) + } + + /// Calls `vcs.get-status-payload`. + pub fn vcs_get_status_payload(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_GET_STATUS_PAYLOAD, params) + } + + /// Calls `vcs.list-commits`. + pub fn vcs_list_commits( + &self, + query: &LogQuery, + ) -> Result, String> { + let params = self.session_params(json!({ "query": query }))?; + self.rpc_call(Methods::VCS_LIST_COMMITS, params) + } + + /// Calls `vcs.diff-file`. + pub fn vcs_diff_file(&self, path: &str) -> Result, String> { + let params = self.session_params(json!({ "path": path }))?; + self.rpc_call(Methods::VCS_DIFF_FILE, params) + } + + /// Calls `vcs.diff-commit`. + pub fn vcs_diff_commit(&self, rev: &str) -> Result, String> { + let params = self.session_params(json!({ "rev": rev }))?; + self.rpc_call(Methods::VCS_DIFF_COMMIT, params) + } + + /// Calls `vcs.get-conflict-details`. + pub fn vcs_get_conflict_details(&self, path: &str) -> Result { + let params = self.session_params(json!({ "path": path }))?; + self.rpc_call(Methods::VCS_GET_CONFLICT_DETAILS, params) + } + + /// Calls `vcs.checkout-conflict-side`. + pub fn vcs_checkout_conflict_side(&self, path: &str, side: ConflictSide) -> Result<(), String> { + let params = self.session_params(json!({ "path": path, "side": side }))?; + self.rpc_call_unit(Methods::VCS_CHECKOUT_CONFLICT_SIDE, params) + } + + /// Calls `vcs.write-merge-result`. + pub fn vcs_write_merge_result(&self, path: &str, content: &[u8]) -> Result<(), String> { + let content_b64 = base64::engine::general_purpose::STANDARD.encode(content); + let params = self.session_params(json!({ + "path": path, + "content_b64": content_b64, + }))?; + self.rpc_call_unit(Methods::VCS_WRITE_MERGE_RESULT, params) + } + + /// Calls `vcs.stage-patch`. + pub fn vcs_stage_patch(&self, patch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "patch": patch }))?; + self.rpc_call_unit(Methods::VCS_STAGE_PATCH, params) + } + + /// Calls `vcs.discard-paths`. + pub fn vcs_discard_paths(&self, paths: &[String]) -> Result<(), String> { + let params = self.session_params(json!({ "paths": paths }))?; + self.rpc_call_unit(Methods::VCS_DISCARD_PATHS, params) + } + + /// Calls `vcs.apply-reverse-patch`. + pub fn vcs_apply_reverse_patch(&self, patch: &str) -> Result<(), String> { + let params = self.session_params(json!({ "patch": patch }))?; + self.rpc_call_unit(Methods::VCS_APPLY_REVERSE_PATCH, params) + } + + /// Calls `vcs.delete-branch`. + pub fn vcs_delete_branch(&self, name: &str, force: bool) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "force": force }))?; + self.rpc_call_unit(Methods::VCS_DELETE_BRANCH, params) + } + + /// Calls `vcs.rename-branch`. + pub fn vcs_rename_branch(&self, old: &str, new: &str) -> Result<(), String> { + let params = self.session_params(json!({ "old": old, "new": new }))?; + self.rpc_call_unit(Methods::VCS_RENAME_BRANCH, params) + } + + /// Calls `vcs.merge-into-current`. + pub fn vcs_merge_into_current(&self, name: &str, message: Option<&str>) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "message": message }))?; + self.rpc_call_unit(Methods::VCS_MERGE_INTO_CURRENT, params) + } + + /// Calls `vcs.merge-abort`. + pub fn vcs_merge_abort(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_MERGE_ABORT, params) + } + + /// Calls `vcs.merge-continue`. + pub fn vcs_merge_continue(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_MERGE_CONTINUE, params) + } + + /// Calls `vcs.is-merge-in-progress`. + pub fn vcs_is_merge_in_progress(&self) -> Result { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_IS_MERGE_IN_PROGRESS, params) + } + + /// Calls `vcs.set-branch-upstream`. + pub fn vcs_set_branch_upstream(&self, branch: &str, upstream: &str) -> Result<(), String> { + let params = self.session_params(json!({ "branch": branch, "upstream": upstream }))?; + self.rpc_call_unit(Methods::VCS_SET_BRANCH_UPSTREAM, params) + } + + /// Calls `vcs.get-branch-upstream`. + pub fn vcs_get_branch_upstream(&self, branch: &str) -> Result, String> { + let params = self.session_params(json!({ "branch": branch }))?; + self.rpc_call(Methods::VCS_GET_BRANCH_UPSTREAM, params) + } + + /// Calls `vcs.hard-reset-head`. + pub fn vcs_hard_reset_head(&self) -> Result<(), String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call_unit(Methods::VCS_HARD_RESET_HEAD, params) + } + + /// Calls `vcs.reset-soft-to`. + pub fn vcs_reset_soft_to(&self, rev: &str) -> Result<(), String> { + let params = self.session_params(json!({ "rev": rev }))?; + self.rpc_call_unit(Methods::VCS_RESET_SOFT_TO, params) + } + + /// Calls `vcs.get-identity`. + pub fn vcs_get_identity(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + let identity: Option = + self.rpc_call(Methods::VCS_GET_IDENTITY, params)?; + Ok(identity.map(|value| (value.name, value.email))) + } + + /// Calls `vcs.set-identity-local`. + pub fn vcs_set_identity_local(&self, name: &str, email: &str) -> Result<(), String> { + let params = self.session_params(json!({ "name": name, "email": email }))?; + self.rpc_call_unit(Methods::VCS_SET_IDENTITY_LOCAL, params) + } + + /// Calls `vcs.list-stashes`. + pub fn vcs_list_stashes(&self) -> Result, String> { + let params = self.session_params(Value::Object(serde_json::Map::new()))?; + self.rpc_call(Methods::VCS_LIST_STASHES, params) + } + + /// Calls `vcs.stash-push`. + pub fn vcs_stash_push( + &self, + message: Option<&str>, + include_untracked: bool, + ) -> Result { + let params = self.session_params(json!({ + "message": message, + "include_untracked": include_untracked, + }))?; + self.rpc_call(Methods::VCS_STASH_PUSH, params) + } + + /// Calls `vcs.stash-apply`. + pub fn vcs_stash_apply(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_APPLY, params) + } + + /// Calls `vcs.stash-pop`. + pub fn vcs_stash_pop(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_POP, params) + } + + /// Calls `vcs.stash-drop`. + pub fn vcs_stash_drop(&self, selector: &str) -> Result<(), String> { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call_unit(Methods::VCS_STASH_DROP, params) + } + + /// Calls `vcs.stash-show`. + pub fn vcs_stash_show(&self, selector: &str) -> Result { + let params = self.session_params(json!({ "selector": selector }))?; + self.rpc_call(Methods::VCS_STASH_SHOW, params) + } + + /// Calls `vcs.cherry-pick`. + pub fn vcs_cherry_pick(&self, commit: &str) -> Result<(), String> { + let params = self.session_params(json!({ "commit": commit }))?; + self.rpc_call_unit(Methods::VCS_CHERRY_PICK, params) + } + + /// Calls `vcs.revert-commit`. + pub fn vcs_revert_commit(&self, commit: &str, no_edit: bool) -> Result<(), String> { + let params = self.session_params(json!({ "commit": commit, "no_edit": no_edit }))?; + self.rpc_call_unit(Methods::VCS_REVERT_COMMIT, params) + } +} + +impl PluginRuntimeInstance for NodePluginRuntimeInstance { + /// Ensures the underlying Node process is running. + fn ensure_running(&self) -> Result<(), String> { + self.with_process(|_| Ok(())) + } + + /// Calls `plugin.get-menus`. + fn get_menus(&self) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_GET_MENUS, + Value::Object(serde_json::Map::new()), + ) + } + + /// Calls `plugin.handle-action`. + fn handle_action(&self, id: &str) -> Result<(), String> { + self.rpc_call_unit(Methods::PLUGIN_HANDLE_ACTION, json!({ "id": id })) + } + + /// Calls `plugin.settings-defaults`. + fn settings_defaults(&self) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_DEFAULTS, + Value::Object(serde_json::Map::new()), + ) + } + + /// Calls `plugin.settings-on-load`. + fn settings_on_load(&self, values: Vec) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_ON_LOAD, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-apply`. + fn settings_on_apply(&self, values: Vec) -> Result<(), String> { + self.rpc_call_unit( + Methods::PLUGIN_SETTINGS_ON_APPLY, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-save`. + fn settings_on_save(&self, values: Vec) -> Result, String> { + self.rpc_call( + Methods::PLUGIN_SETTINGS_ON_SAVE, + json!({ "values": values }), + ) + } + + /// Calls `plugin.settings-on-reset`. + fn settings_on_reset(&self) -> Result<(), String> { + self.rpc_call_unit( + Methods::PLUGIN_SETTINGS_ON_RESET, + Value::Object(serde_json::Map::new()), + ) + } + + /// Updates the optional VCS event sink. + fn set_event_sink(&self, sink: Option>) { + *self.event_sink.write() = sink; + } + + /// Stops the runtime and clears local session state. + fn stop(&self) { + self.close_vcs_session(); + + let process = self.process.lock().take(); + if let Some(mut process) = process { + let _ = process.call::( + Methods::PLUGIN_DEINIT, + Value::Object(serde_json::Map::new()), + self.spawn.plugin_id.as_str(), + &mut |method, params| { + self.handle_notification(method, params); + Ok(()) + }, + ); + + let _ = process.child.kill(); + let _ = process.child.wait(); + } + *self.vcs_session_id.lock() = None; + } +} + +impl Drop for NodePluginRuntimeInstance { + /// Ensures child process cleanup on drop. + fn drop(&mut self) { + if let Some(mut process) = self.process.get_mut().take() { + let _ = process.child.kill(); + let _ = process.child.wait(); + } + } +} + +/// Formats an RPC error payload into a user-facing message. +fn format_rpc_error(plugin_id: &str, method: &str, error: &RpcError) -> String { + let detail = error + .data + .as_ref() + .and_then(|value| value.get("message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| error.message.trim()); + format!( + "plugin '{}' rpc '{}' failed (code {}): {}", + plugin_id, method, error.code, detail + ) +} diff --git a/Backend/src/plugin_runtime/protocol.rs b/Backend/src/plugin_runtime/protocol.rs new file mode 100644 index 00000000..3762eb07 --- /dev/null +++ b/Backend/src/plugin_runtime/protocol.rs @@ -0,0 +1,263 @@ +// Copyright © 2025-2026 OpenVCS Contributors +// SPDX-License-Identifier: GPL-3.0-or-later +//! JSON-RPC protocol primitives for Node-based plugins. +//! +//! The Node plugin runtime uses JSON-RPC 2.0 messages framed with an +//! LSP-style `Content-Length` header over stdio. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{BufRead, Write}; + +/// Protocol version used by host and plugin at initialization. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Host-to-plugin request method names. +pub struct Methods; + +#[allow(dead_code)] +impl Methods { + /// Performs runtime handshake and capability discovery. + pub const PLUGIN_INITIALIZE: &'static str = "plugin.initialize"; + /// Starts plugin lifecycle state. + pub const PLUGIN_INIT: &'static str = "plugin.init"; + /// Stops plugin lifecycle state. + pub const PLUGIN_DEINIT: &'static str = "plugin.deinit"; + /// Requests plugin-contributed menus. + pub const PLUGIN_GET_MENUS: &'static str = "plugin.get_menus"; + /// Invokes a plugin action by id. + pub const PLUGIN_HANDLE_ACTION: &'static str = "plugin.handle_action"; + /// Requests plugin settings defaults. + pub const PLUGIN_SETTINGS_DEFAULTS: &'static str = "plugin.settings.defaults"; + /// Invokes settings on-load callback. + pub const PLUGIN_SETTINGS_ON_LOAD: &'static str = "plugin.settings.on_load"; + /// Invokes settings on-apply callback. + pub const PLUGIN_SETTINGS_ON_APPLY: &'static str = "plugin.settings.on_apply"; + /// Invokes settings on-save callback. + pub const PLUGIN_SETTINGS_ON_SAVE: &'static str = "plugin.settings.on_save"; + /// Invokes settings on-reset callback. + pub const PLUGIN_SETTINGS_ON_RESET: &'static str = "plugin.settings.on_reset"; + + /// Returns backend capability flags. + pub const VCS_GET_CAPS: &'static str = "vcs.get_caps"; + /// Opens a repository and creates a VCS session. + pub const VCS_OPEN: &'static str = "vcs.open"; + /// Closes a VCS session. + pub const VCS_CLOSE: &'static str = "vcs.close"; + /// Clones a repository. + pub const VCS_CLONE_REPO: &'static str = "vcs.clone_repo"; + /// Returns session workdir. + pub const VCS_GET_WORKDIR: &'static str = "vcs.get_workdir"; + /// Returns current branch. + pub const VCS_GET_CURRENT_BRANCH: &'static str = "vcs.get_current_branch"; + /// Lists branches. + pub const VCS_LIST_BRANCHES: &'static str = "vcs.list_branches"; + /// Lists local branch names. + pub const VCS_LIST_LOCAL_BRANCHES: &'static str = "vcs.list_local_branches"; + /// Creates a branch. + pub const VCS_CREATE_BRANCH: &'static str = "vcs.create_branch"; + /// Checks out a branch. + pub const VCS_CHECKOUT_BRANCH: &'static str = "vcs.checkout_branch"; + /// Creates or updates a remote. + pub const VCS_ENSURE_REMOTE: &'static str = "vcs.ensure_remote"; + /// Lists remotes. + pub const VCS_LIST_REMOTES: &'static str = "vcs.list_remotes"; + /// Removes a remote. + pub const VCS_REMOVE_REMOTE: &'static str = "vcs.remove_remote"; + /// Performs fetch. + pub const VCS_FETCH: &'static str = "vcs.fetch"; + /// Performs fetch with options. + pub const VCS_FETCH_WITH_OPTIONS: &'static str = "vcs.fetch_with_options"; + /// Performs push. + pub const VCS_PUSH: &'static str = "vcs.push"; + /// Performs fast-forward-only pull. + pub const VCS_PULL_FF_ONLY: &'static str = "vcs.pull_ff_only"; + /// Creates commit from selected paths. + pub const VCS_COMMIT: &'static str = "vcs.commit"; + /// Creates commit from index. + pub const VCS_COMMIT_INDEX: &'static str = "vcs.commit_index"; + /// Returns status summary. + pub const VCS_GET_STATUS_SUMMARY: &'static str = "vcs.get_status_summary"; + /// Returns status payload. + pub const VCS_GET_STATUS_PAYLOAD: &'static str = "vcs.get_status_payload"; + /// Lists commits by query. + pub const VCS_LIST_COMMITS: &'static str = "vcs.list_commits"; + /// Diffs a file. + pub const VCS_DIFF_FILE: &'static str = "vcs.diff_file"; + /// Diffs a commit. + pub const VCS_DIFF_COMMIT: &'static str = "vcs.diff_commit"; + /// Returns conflict details. + pub const VCS_GET_CONFLICT_DETAILS: &'static str = "vcs.get_conflict_details"; + /// Checks out one side of a conflict. + pub const VCS_CHECKOUT_CONFLICT_SIDE: &'static str = "vcs.checkout_conflict_side"; + /// Writes merge conflict resolution content. + pub const VCS_WRITE_MERGE_RESULT: &'static str = "vcs.write_merge_result"; + /// Stages a text patch. + pub const VCS_STAGE_PATCH: &'static str = "vcs.stage_patch"; + /// Discards path changes. + pub const VCS_DISCARD_PATHS: &'static str = "vcs.discard_paths"; + /// Applies reverse patch. + pub const VCS_APPLY_REVERSE_PATCH: &'static str = "vcs.apply_reverse_patch"; + /// Deletes a branch. + pub const VCS_DELETE_BRANCH: &'static str = "vcs.delete_branch"; + /// Renames a branch. + pub const VCS_RENAME_BRANCH: &'static str = "vcs.rename_branch"; + /// Merges a branch into current. + pub const VCS_MERGE_INTO_CURRENT: &'static str = "vcs.merge_into_current"; + /// Aborts merge. + pub const VCS_MERGE_ABORT: &'static str = "vcs.merge_abort"; + /// Continues merge. + pub const VCS_MERGE_CONTINUE: &'static str = "vcs.merge_continue"; + /// Returns whether a merge is in progress. + pub const VCS_IS_MERGE_IN_PROGRESS: &'static str = "vcs.is_merge_in_progress"; + /// Sets branch upstream. + pub const VCS_SET_BRANCH_UPSTREAM: &'static str = "vcs.set_branch_upstream"; + /// Gets branch upstream. + pub const VCS_GET_BRANCH_UPSTREAM: &'static str = "vcs.get_branch_upstream"; + /// Hard resets HEAD. + pub const VCS_HARD_RESET_HEAD: &'static str = "vcs.hard_reset_head"; + /// Soft resets to revision. + pub const VCS_RESET_SOFT_TO: &'static str = "vcs.reset_soft_to"; + /// Returns configured identity. + pub const VCS_GET_IDENTITY: &'static str = "vcs.get_identity"; + /// Sets configured identity. + pub const VCS_SET_IDENTITY_LOCAL: &'static str = "vcs.set_identity_local"; + /// Lists stashes. + pub const VCS_LIST_STASHES: &'static str = "vcs.list_stashes"; + /// Pushes stash. + pub const VCS_STASH_PUSH: &'static str = "vcs.stash_push"; + /// Applies stash. + pub const VCS_STASH_APPLY: &'static str = "vcs.stash_apply"; + /// Pops stash. + pub const VCS_STASH_POP: &'static str = "vcs.stash_pop"; + /// Drops stash. + pub const VCS_STASH_DROP: &'static str = "vcs.stash_drop"; + /// Shows stash diff. + pub const VCS_STASH_SHOW: &'static str = "vcs.stash_show"; + /// Cherry-picks commit. + pub const VCS_CHERRY_PICK: &'static str = "vcs.cherry_pick"; + /// Reverts commit. + pub const VCS_REVERT_COMMIT: &'static str = "vcs.revert_commit"; +} + +/// Plugin-to-host notification method names. +pub struct NotificationMethods; + +impl NotificationMethods { + /// Emits plugin log records to host logging. + pub const HOST_LOG: &'static str = "host.log"; + /// Requests a host UI notification. + pub const HOST_UI_NOTIFY: &'static str = "host.ui_notify"; + /// Requests setting host status text. + pub const HOST_STATUS_SET: &'static str = "host.status_set"; + /// Emits plugin-scoped events. + pub const HOST_EVENT_EMIT: &'static str = "host.event_emit"; + /// Emits VCS operation progress events. + pub const VCS_EVENT: &'static str = "vcs.event"; +} + +/// JSON-RPC error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + /// Numeric error code. + pub code: i32, + /// Human-readable message. + pub message: String, + /// Optional extra error payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC request object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcRequest { + /// JSON-RPC protocol version string. + pub jsonrpc: String, + /// Request id. + pub id: u64, + /// Request method name. + pub method: String, + /// Request params payload. + pub params: Value, +} + +/// JSON-RPC response object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcResponse { + /// JSON-RPC protocol version string. + pub jsonrpc: String, + /// Response id. + pub id: u64, + /// Optional success payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Optional error payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Writes one framed JSON message to a plugin stdio stream. +/// +/// # Parameters +/// - `writer`: Target stream. +/// - `value`: JSON value to encode. +/// +/// # Returns +/// - `Ok(())` when write succeeds. +/// - `Err(String)` when serialization or IO fails. +pub fn write_framed_message(writer: &mut impl Write, value: &Value) -> Result<(), String> { + let payload = serde_json::to_vec(value).map_err(|e| format!("serialize rpc payload: {e}"))?; + let header = format!("Content-Length: {}\r\n\r\n", payload.len()); + writer + .write_all(header.as_bytes()) + .map_err(|e| format!("write rpc header: {e}"))?; + writer + .write_all(&payload) + .map_err(|e| format!("write rpc payload: {e}"))?; + writer.flush().map_err(|e| format!("flush rpc stream: {e}")) +} + +/// Reads one framed JSON message from a plugin stdio stream. +/// +/// # Parameters +/// - `reader`: Source stream. +/// +/// # Returns +/// - `Ok(Value)` decoded JSON message. +/// - `Err(String)` when framing, decode, or IO fails. +pub fn read_framed_message(reader: &mut impl BufRead) -> Result { + let mut content_length: Option = None; + + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .map_err(|e| format!("read rpc header: {e}"))?; + if read == 0 { + return Err("plugin rpc stream closed".to_string()); + } + + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + + let Some((name, value)) = trimmed.split_once(':') else { + return Err(format!("invalid rpc header line: {trimmed}")); + }; + if name.eq_ignore_ascii_case("content-length") { + let parsed = value + .trim() + .parse::() + .map_err(|e| format!("invalid Content-Length '{value}': {e}"))?; + content_length = Some(parsed); + } + } + + let length = content_length.ok_or_else(|| "missing Content-Length header".to_string())?; + let mut payload = vec![0u8; length]; + reader + .read_exact(&mut payload) + .map_err(|e| format!("read rpc payload: {e}"))?; + serde_json::from_slice(&payload).map_err(|e| format!("parse rpc payload: {e}")) +} diff --git a/Backend/src/plugin_runtime/runtime_select.rs b/Backend/src/plugin_runtime/runtime_select.rs index 8109e19d..64eed933 100644 --- a/Backend/src/plugin_runtime/runtime_select.rs +++ b/Backend/src/plugin_runtime/runtime_select.rs @@ -1,31 +1,30 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; +//! Plugin runtime selection helpers. +//! +//! OpenVCS now runs plugin modules as long-lived Node.js scripts. + use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use crate::plugin_runtime::spawn::SpawnConfig; use log::{debug, trace}; use std::path::Path; use std::sync::Arc; -use wasmtime::component::Component; -use wasmtime::Engine; -/// Returns whether a module path is a valid component-model artifact. -pub fn is_component_module(path: &Path) -> bool { - trace!("is_component_module: path='{}'", path.display()); +/// Returns whether a module path is a supported Node.js entry file. +pub fn is_node_module(path: &Path) -> bool { + trace!("is_node_module: path='{}'", path.display()); if !path.is_file() { - debug!("is_component_module: path is not a file, returning false"); + debug!("is_node_module: path is not a file"); return false; } - debug!("is_component_module: path is a file"); - - let engine = Engine::default(); - let result = Component::from_file(&engine, path); - trace!( - "is_component_module: Component::from_file result={}", - result.is_ok() - ); - result.is_ok() + let ext = path + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_default(); + matches!(ext.as_str(), "js" | "mjs" | "cjs") } /// Selects and creates a runtime instance for a plugin module. @@ -37,94 +36,54 @@ pub fn create_runtime_instance( spawn.plugin_id, spawn.exec_path.display() ); - debug!( - "create_runtime_instance: approval={:?}, workspace_root={:?}, is_vcs_backend={}", - spawn.approval, spawn.allowed_workspace_root, spawn.is_vcs_backend - ); - trace!("create_runtime_instance: validating component module"); - if !is_component_module(&spawn.exec_path) { - debug!( - "create_runtime_instance: validation failed for '{}'", - spawn.exec_path.display() - ); + if !is_node_module(&spawn.exec_path) { return Err(format!( - "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", + "plugin runtime: '{}' must be a .js/.mjs/.cjs Node entrypoint", spawn.exec_path.display() )); } - trace!("create_runtime_instance: creating ComponentPluginRuntimeInstance"); - let runtime: Arc = create_component_runtime_instance(spawn.clone())?; - debug!( - "create_runtime_instance: instance created, plugin_id='{}'", - spawn.plugin_id - ); - - log::info!( - "plugin runtime: selected `component` transport for plugin `{}` ({})", - spawn.plugin_id, - spawn.exec_path.display() - ); + let runtime: Arc = create_node_runtime_instance(spawn)?; Ok(runtime) } -/// Creates a component runtime instance with the provided spawn context. -pub fn create_component_runtime_instance( +/// Creates a concrete runtime instance used by VCS proxy adapters. +pub fn create_node_runtime_instance( spawn: SpawnConfig, -) -> Result, String> { - if !is_component_module(&spawn.exec_path) { +) -> Result, String> { + if !is_node_module(&spawn.exec_path) { return Err(format!( - "plugin runtime: `{}` is not a component-model plugin (stdio runtime removed)", + "plugin runtime: '{}' must be a .js/.mjs/.cjs Node entrypoint", spawn.exec_path.display() )); } - Ok(Arc::new(ComponentPluginRuntimeInstance::new(spawn))) + Ok(Arc::new(NodePluginRuntimeInstance::new(spawn))) } #[cfg(test)] mod tests { use super::*; - use crate::plugin_bundles::ApprovalState; use std::fs; use tempfile::tempdir; - const MINIMAL_WASM: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, - 0x02, 0x01, 0x00, 0x07, 0x0b, 0x01, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x00, 0x00, - 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, - ]; - #[test] - /// Verifies that a core Wasm module is rejected as a component. - fn core_wasm_is_not_detected_as_component() { + /// Verifies non-script files are rejected by node runtime detection. + fn non_script_path_is_not_detected_as_node_module() { let temp = tempdir().expect("tempdir"); - let wasm_path = temp.path().join("plugin.wasm"); - fs::write(&wasm_path, MINIMAL_WASM).expect("write wasm"); + let file_path = temp.path().join("plugin.bin"); + fs::write(&file_path, b"binary").expect("write file"); - assert!(!is_component_module(&wasm_path)); + assert!(!is_node_module(&file_path)); } #[test] - /// Verifies runtime selection returns a clear non-component error. - fn selection_rejects_core_wasm() { + /// Verifies `.mjs` files are accepted as runtime modules. + fn mjs_path_is_detected_as_node_module() { let temp = tempdir().expect("tempdir"); - let wasm_path = temp.path().join("plugin.wasm"); - fs::write(&wasm_path, MINIMAL_WASM).expect("write wasm"); + let script_path = temp.path().join("plugin.mjs"); + fs::write(&script_path, b"export {}\n").expect("write script"); - let err = match create_runtime_instance(SpawnConfig { - plugin_id: "test.plugin".to_string(), - exec_path: wasm_path, - approval: ApprovalState::Approved { - capabilities: Vec::new(), - approved_at_unix_ms: 0, - }, - allowed_workspace_root: None, - is_vcs_backend: false, - }) { - Ok(_) => panic!("expected non-component runtime rejection"), - Err(err) => err, - }; - assert!(err.contains("stdio runtime removed")); + assert!(is_node_module(&script_path)); } } diff --git a/Backend/src/plugin_runtime/spawn.rs b/Backend/src/plugin_runtime/spawn.rs index f14841a9..86f725dd 100644 --- a/Backend/src/plugin_runtime/spawn.rs +++ b/Backend/src/plugin_runtime/spawn.rs @@ -1,6 +1,5 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::plugin_bundles::ApprovalState; use std::path::PathBuf; /// Runtime launch configuration for a plugin module instance. @@ -8,11 +7,9 @@ use std::path::PathBuf; pub struct SpawnConfig { /// Canonical plugin identifier used for logging and routing. pub plugin_id: String, - /// Path to the plugin component/module executable. + /// Path to the plugin Node.js module executable. pub exec_path: PathBuf, - /// Persisted capability approval state for this plugin install. - pub approval: ApprovalState, - /// Optional workspace root constraining file/process host operations. + /// Optional workspace root captured for backward-compatible APIs. pub allowed_workspace_root: Option, /// Whether this plugin exports a VCS backend interface. pub is_vcs_backend: bool, diff --git a/Backend/src/plugin_runtime/vcs_proxy.rs b/Backend/src/plugin_runtime/vcs_proxy.rs index c5892218..dab62f34 100644 --- a/Backend/src/plugin_runtime/vcs_proxy.rs +++ b/Backend/src/plugin_runtime/vcs_proxy.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::logging::LogTimer; -use crate::plugin_runtime::component_instance::ComponentPluginRuntimeInstance; use crate::plugin_runtime::instance::PluginRuntimeInstance; +use crate::plugin_runtime::node_instance::NodePluginRuntimeInstance; use log::{debug, error, info, warn}; use openvcs_core::models::{ Capabilities, ConflictDetails, ConflictSide, FetchOptions, LogQuery, StashItem, StatusPayload, @@ -21,15 +21,15 @@ pub struct PluginVcsProxy { backend_id: BackendId, /// Repository worktree path associated with this backend session. workdir: PathBuf, - /// Started plugin runtime used for typed WIT calls. - runtime: Arc, + /// Started plugin runtime used for JSON-RPC calls. + runtime: Arc, } impl PluginVcsProxy { /// Opens a repository through a previously started plugin module runtime. pub fn open_with_process( backend_id: BackendId, - runtime: Arc, + runtime: Arc, repo_path: &Path, cfg: serde_json::Value, ) -> Result, VcsError> { @@ -431,6 +431,7 @@ impl Vcs for PluginVcsProxy { fn stash_show(&self, selector: &str) -> VcsResult> { self.runtime .vcs_stash_show(selector) + .map(|value| value.lines().map(|line| line.to_string()).collect()) .map_err(|e| self.map_runtime_error(e)) } diff --git a/Backend/src/plugin_vcs_backends.rs b/Backend/src/plugin_vcs_backends.rs index 1302700b..3f9d2ffa 100644 --- a/Backend/src/plugin_vcs_backends.rs +++ b/Backend/src/plugin_vcs_backends.rs @@ -6,7 +6,7 @@ use crate::logging::LogTimer; use crate::plugin_bundles::{PluginBundleStore, PluginManifest, VcsBackendProvide}; use crate::plugin_paths::{built_in_plugin_dirs, PLUGIN_MANIFEST_NAME}; use crate::plugin_runtime::instance::PluginRuntimeInstance; -use crate::plugin_runtime::runtime_select::create_component_runtime_instance; +use crate::plugin_runtime::runtime_select::create_node_runtime_instance; use crate::plugin_runtime::settings_store; use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager}; use crate::settings::AppConfig; @@ -166,6 +166,25 @@ pub fn list_plugin_vcs_backends() -> Result, String ); continue; } + + let approved = store + .get_current_installed(&p.plugin_id) + .ok() + .flatten() + .is_some_and(|installed| { + matches!( + installed.approval, + crate::plugin_bundles::ApprovalState::Approved { .. } + ) + }); + if !approved { + trace!( + "list_plugin_vcs_backends: plugin {} is not approved", + p.plugin_id + ); + continue; + } + let Some(module) = p.module else { trace!( "list_plugin_vcs_backends: plugin {} has no module", @@ -389,7 +408,7 @@ pub fn open_repo_via_plugin_vcs_backend( } })?; - let runtime = create_component_runtime_instance(spawn).map_err(|e| { + let runtime = create_node_runtime_instance(spawn).map_err(|e| { error!( "open_repo_via_plugin_vcs_backend: failed to create runtime for plugin {}: {}", desc.plugin_id, e diff --git a/Backend/src/tauri_commands/plugins.rs b/Backend/src/tauri_commands/plugins.rs index d97a7c02..29c0f5dd 100644 --- a/Backend/src/tauri_commands/plugins.rs +++ b/Backend/src/tauri_commands/plugins.rs @@ -1,8 +1,6 @@ // Copyright © 2025-2026 OpenVCS Contributors // SPDX-License-Identifier: GPL-3.0-or-later -use crate::plugin_bundles::{ - ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore, -}; +use crate::plugin_bundles::{ApprovalState, InstalledPlugin, InstalledPluginIndex, PluginBundleStore}; use crate::plugin_runtime::instance::PluginRuntimeInstance; use crate::plugin_runtime::settings_store; use crate::plugins; @@ -12,8 +10,6 @@ use openvcs_core::settings::{SettingKv, SettingValue}; use openvcs_core::ui::{Menu, UiElement}; use serde_json::Value; use std::sync::Arc; -use tauri::Emitter; -use tauri::Manager; use tauri::{Runtime, State, Window}; /// JSON-friendly plugin setting entry payload. @@ -70,24 +66,6 @@ pub struct PluginMenuPayload { pub elements: Vec, } -/// Permission metadata returned for a plugin's current installed version. -#[derive(Debug, Clone, serde::Serialize)] -pub struct PluginPermissionsPayload { - /// Plugin id these permissions belong to. - pub plugin_id: String, - /// Current installed version, when available. - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - /// Current approval state (`pending`, `approved`, `denied`). - pub approval_state: String, - /// Capability ids requested by the plugin. - #[serde(default)] - pub requested_capabilities: Vec, - /// Capability ids currently approved for the plugin. - #[serde(default)] - pub approved_capabilities: Vec, -} - #[tauri::command] /// Lists plugin summaries discovered by the backend. /// @@ -126,14 +104,14 @@ pub fn load_plugin(id: String) -> Result { /// Installs an `.ovcsp` plugin bundle. /// /// # Parameters -/// - `window`: Calling window handle used for capability prompt events. +/// - `window`: Calling window handle. /// - `bundle_path`: Filesystem path to the bundle. /// /// # Returns /// - `Ok(InstalledPlugin)` with install metadata. /// - `Err(String)` when installation fails. pub async fn install_ovcsp( - window: Window, + _window: Window, state: State<'_, AppState>, bundle_path: String, ) -> Result { @@ -145,19 +123,10 @@ pub async fn install_ovcsp( installed.plugin_id, installed.version ); - if !installed.requested_capabilities.is_empty() { - let _ = window.app_handle().emit( - "plugins:capabilities-requested", - serde_json::json!({ - "pluginId": installed.plugin_id, - "version": installed.version, - "capabilities": installed.requested_capabilities, - }), - ); - } - - if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { - warn!("plugins: runtime sync after install failed: {}", err); + if matches!(installed.approval, ApprovalState::Approved { .. }) { + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!("plugins: runtime sync after install failed: {}", err); + } } Ok(installed) @@ -291,120 +260,45 @@ pub async fn set_plugin_enabled( } #[tauri::command] -/// Approves or denies requested capabilities for a plugin version. +/// Approves or denies an installed plugin version for runtime startup. /// /// # Parameters +/// - `state`: Application state. /// - `plugin_id`: Plugin id. -/// - `version`: Installed plugin version. +/// - `version`: Installed version string. /// - `approved`: Approval decision. /// /// # Returns -/// - `Ok(())` when state is updated. -/// - `Err(String)` when update fails. -pub fn approve_plugin_capabilities( +/// - `Ok(())` when plugin approval is updated. +/// - `Err(String)` when plugin/version lookup fails. +pub fn set_plugin_approval( state: State<'_, AppState>, plugin_id: String, version: String, approved: bool, ) -> Result<(), String> { let plugin_id = plugin_id.trim().to_string(); - let version = version.trim(); - PluginBundleStore::new_default().approve_capabilities(&plugin_id, version, approved)?; - - if approved { - info!( - "plugin: capabilities approved for '{}' v{}", - plugin_id, version - ); - } else { - info!( - "plugin: capabilities denied for '{}' v{}", - plugin_id, version - ); - } - - if !approved { - let _ = state.plugin_runtime().stop_plugin(&plugin_id); - } else if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { - warn!("plugins: runtime sync after approval failed: {}", err); - } - - Ok(()) -} - -#[tauri::command] -/// Returns permission metadata for a plugin's current installed version. -/// -/// # Parameters -/// - `plugin_id`: Plugin id. -/// -/// # Returns -/// - `Ok(PluginPermissionsPayload)` on success. -/// - `Err(String)` when lookup fails. -pub fn get_plugin_permissions(plugin_id: String) -> Result { - let plugin_id = plugin_id.trim().to_string(); + let version = version.trim().to_string(); if plugin_id.is_empty() { return Err("plugin id is empty".to_string()); } - - let Some(current) = PluginBundleStore::new_default().get_current_installed(&plugin_id)? else { - return Ok(PluginPermissionsPayload { - plugin_id, - version: None, - approval_state: "pending".to_string(), - requested_capabilities: Vec::new(), - approved_capabilities: Vec::new(), - }); - }; - - let (approval_state, approved_capabilities) = match current.approval { - ApprovalState::Pending => ("pending".to_string(), Vec::new()), - ApprovalState::Denied { .. } => ("denied".to_string(), Vec::new()), - ApprovalState::Approved { capabilities, .. } => ("approved".to_string(), capabilities), - }; - - Ok(PluginPermissionsPayload { - plugin_id, - version: Some(current.version), - approval_state, - requested_capabilities: current.requested_capabilities, - approved_capabilities, - }) -} - -#[tauri::command] -/// Applies a selected approved-capabilities set for the current plugin version. -/// -/// # Parameters -/// - `state`: Application state. -/// - `plugin_id`: Plugin id. -/// - `approved_capabilities`: Selected capability ids to approve. -/// -/// # Returns -/// - `Ok(())` when permissions are saved. -/// - `Err(String)` when update fails. -pub fn set_plugin_permissions( - state: State<'_, AppState>, - plugin_id: String, - approved_capabilities: Vec, -) -> Result<(), String> { - let plugin_id = plugin_id.trim().to_string(); - if plugin_id.is_empty() { - return Err("plugin id is empty".to_string()); + if version.is_empty() { + return Err("version is empty".to_string()); } - PluginBundleStore::new_default() - .set_current_approved_capabilities(&plugin_id, approved_capabilities)?; + let store = PluginBundleStore::new_default(); + store.approve_capabilities(&plugin_id, &version, approved)?; - if let Err(err) = state.plugin_runtime().stop_plugin(&plugin_id) { - warn!( - "plugins: stop runtime after permissions update failed for {}: {}", - plugin_id, err - ); - } - if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + if approved { + if let Err(err) = state.plugin_runtime().sync_plugin_runtime() { + warn!( + "plugins: runtime sync after approval failed for {}: {}", + plugin_id, err + ); + } + } else if let Err(err) = state.plugin_runtime().stop_plugin(&plugin_id) { warn!( - "plugins: runtime sync after permissions update failed for {}: {}", + "plugins: stop runtime after denial failed for {}: {}", plugin_id, err ); } diff --git a/Backend/tauri.conf.json b/Backend/tauri.conf.json index 5b3491cc..e93f1799 100644 --- a/Backend/tauri.conf.json +++ b/Backend/tauri.conf.json @@ -41,7 +41,8 @@ "icons/icon.ico" ], "resources": [ - "../target/openvcs/built-in-plugins" + "../target/openvcs/built-in-plugins", + "../target/openvcs/node-runtime" ], "licenseFile": "../LICENSE", "publisher": "Jordon Brooks", diff --git a/Cargo.lock b/Cargo.lock index a910f64d..e946a986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -52,18 +43,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -189,7 +168,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -220,7 +199,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -246,7 +225,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -331,15 +310,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -397,9 +367,6 @@ name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" -dependencies = [ - "allocator-api2", -] [[package]] name = "bytemuck" @@ -465,84 +432,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "cap-fs-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-net-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" -dependencies = [ - "cap-primitives", - "cap-std", - "rustix 1.1.3", - "smallvec", -] - -[[package]] -name = "cap-primitives" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" -dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes", - "ipnet", - "maybe-owned", - "rustix 1.1.3", - "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-rand" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" -dependencies = [ - "ambient-authority", - "rand 0.8.5", -] - -[[package]] -name = "cap-std" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix 1.1.3", -] - -[[package]] -name = "cap-time-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" -dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix 1.1.3", - "winx", -] - [[package]] name = "cargo-platform" version = "0.1.9" @@ -612,7 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon 0.12.16", + "target-lexicon", ] [[package]] @@ -643,15 +532,6 @@ dependencies = [ "inout", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -739,15 +619,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -757,144 +628,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-assembler-x64" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0377b13bf002a0774fcccac4f1102a10f04893d24060cf4b7350c87e4cbb647c" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa027979140d023b25bf7509fb7ede3a54c3d3871fb5ead4673c4b633f671a2" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618e4da87d9179a70b3c2f664451ca8898987aa6eb9f487d16988588b5d8cc40" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-bitset" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db53764b5dad233b37b8f5dc54d3caa9900c54579195e00f17ea21f03f71aaa7" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-codegen" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae927f1d8c0abddaa863acd201471d56e7fc6c3925104f4861ed4dc3e28b421" -dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.15.5", - "log", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon 0.13.4", - "wasmtime-internal-math", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fcf1e3e6757834bd2584f4cbff023fcc198e9279dcb5d684b4bb27a9b19f54" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck 0.5.0", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205dcb9e6ccf9d368b7466be675ff6ee54a63e36da6fe20e72d45169cf6fd254" - -[[package]] -name = "cranelift-control" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108eca9fcfe86026054f931eceaf57b722c1b97464bf8265323a9b5877238817" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d96496910065d3165f84ff8e1e393916f4c086f88ac8e1b407678bc78735aa" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e303983ad7e23c850f24d9c41fc3cb346e1b930f066d3966545e4c98dac5c9fb" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon 0.13.4", -] - -[[package]] -name = "cranelift-isle" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b0cf8d867d891245836cac7abafb0a5b0ea040a019d720702b3b8bcba40bfa" - -[[package]] -name = "cranelift-native" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24b641e315443e27807b69c440fe766737d7e718c68beb665a2d69259c77bf3" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon 0.13.4", -] - -[[package]] -name = "cranelift-srcgen" -version = "0.128.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e378a54e7168a689486d67ee1f818b7e5356e54ae51a1d7a53f4f13f7f8b7a" - [[package]] name = "crc" version = "3.3.0" @@ -928,25 +661,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1035,15 +749,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "uuid", -] - [[package]] name = "deflate64" version = "0.1.10" @@ -1104,16 +809,6 @@ dependencies = [ "dirs-sys", ] -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1131,21 +826,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - [[package]] name = "dispatch" version = "0.2.0" @@ -1234,12 +918,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "embed-resource" version = "3.0.6" @@ -1260,27 +938,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "endi" version = "1.1.1" @@ -1379,29 +1036,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.1.3", - "windows-sys 0.59.0", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -1438,12 +1078,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.9" @@ -1467,12 +1101,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.5.0" @@ -1509,17 +1137,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-set-times" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" -dependencies = [ - "io-lifetimes", - "rustix 1.1.3", - "windows-sys 0.59.0", -] - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1539,20 +1156,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -1560,7 +1163,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1628,7 +1230,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1649,20 +1250,6 @@ dependencies = [ "byteorder", ] -[[package]] -name = "fxprof-processed-profile" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" -dependencies = [ - "bitflags 2.10.0", - "debugid", - "rustc-hash", - "serde", - "serde_derive", - "serde_json", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1821,17 +1408,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -dependencies = [ - "fallible-iterator", - "indexmap 2.13.0", - "stable_deref_trait", -] - [[package]] name = "gio" version = "0.18.4" @@ -1992,8 +1568,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", - "serde", + "foldhash", ] [[package]] @@ -2001,9 +1576,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -2298,24 +1870,10 @@ dependencies = [ ] [[package]] -name = "im-rc" -version = "15.1.0" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" -dependencies = [ - "bitmaps", - "rand_core 0.6.4", - "rand_xoshiro", - "sized-chunks", - "typenum", - "version_check", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", @@ -2372,22 +1930,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "io-extras" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" -dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "io-lifetimes" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" - [[package]] name = "ipnet" version = "2.11.0" @@ -2429,41 +1971,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "ittapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" -dependencies = [ - "anyhow", - "ittapi-sys", - "log", -] - -[[package]] -name = "ittapi-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" -dependencies = [ - "cc", -] - [[package]] name = "javascriptcore-rs" version = "1.1.2" @@ -2624,12 +2137,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2682,12 +2189,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.12" @@ -2699,12 +2200,6 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2759,15 +2254,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.14.1" @@ -2799,27 +2285,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.3", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -3226,18 +2697,6 @@ dependencies = [ "objc2-security", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "crc32fast", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3272,6 +2731,7 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" name = "openvcs" version = "0.1.1" dependencies = [ + "base64 0.22.1", "directories", "dirs", "env_logger", @@ -3296,8 +2756,6 @@ dependencies = [ "time", "tokio", "toml 0.9.12+spec-1.1.0", - "wasmtime", - "wasmtime-wasi", "xz2", "zip 7.4.0", ] @@ -3307,31 +2765,9 @@ name = "openvcs-core" version = "0.1.8" dependencies = [ "log", - "openvcs-core-derive", - "proc-macro2", "serde", "serde_json", "thiserror 2.0.18", - "wit-bindgen 0.53.0", -] - -[[package]] -name = "openvcs-core-derive" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "openvcs-git-plugin" -version = "0.1.0" -dependencies = [ - "openvcs-core", - "serde", - "serde_json", - "tempfile", ] [[package]] @@ -3450,16 +2886,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.13.0", -] - [[package]] name = "phf" version = "0.8.0" @@ -3659,7 +3085,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -3678,18 +3104,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -3804,29 +3218,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulley-interpreter" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01051a5b172e07f9197b85060e6583b942aec679dac08416647bf7e7dc916b65" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-math", -] - -[[package]] -name = "pulley-macros" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf194f5b1a415ef3a44ee35056f4009092cc4038a9f7e3c7c1e392f48ee7dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -3932,41 +3323,12 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3985,17 +3347,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -4027,20 +3378,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "regalloc2" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.15.5", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.3" @@ -4147,18 +3484,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -4168,19 +3493,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.3" @@ -4190,20 +3502,10 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustix-linux-procfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" -dependencies = [ - "once_cell", - "rustix 1.1.3", -] - [[package]] name = "rustls" version = "0.23.36" @@ -4283,12 +3585,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -4541,19 +3837,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.13.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4642,16 +3925,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - [[package]] name = "slab" version = "0.4.12" @@ -4663,9 +3936,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -4834,22 +4104,6 @@ dependencies = [ "version-compare", ] -[[package]] -name = "system-interface" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" -dependencies = [ - "bitflags 2.10.0", - "cap-fs-ext", - "cap-std", - "fd-lock", - "io-lifetimes", - "rustix 0.38.44", - "windows-sys 0.59.0", - "winx", -] - [[package]] name = "tao" version = "0.34.5" @@ -4918,12 +4172,6 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -[[package]] -name = "target-lexicon" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" - [[package]] name = "tauri" version = "2.10.2" @@ -5260,7 +4508,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -5275,15 +4523,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -5688,24 +4927,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -5836,7 +5063,7 @@ version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -5845,7 +5072,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -5907,37 +5134,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-compose" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af801b6f36459023eaec63fdbaedad2fd5a4ab7dc74ecc110a8b5d375c5775e4" -dependencies = [ - "anyhow", - "heck 0.5.0", - "im-rc", - "indexmap 2.13.0", - "log", - "petgraph", - "serde", - "serde_derive", - "serde_yaml", - "smallvec", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wat", -] - -[[package]] -name = "wasm-encoder" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" -dependencies = [ - "leb128fmt", - "wasmparser 0.243.0", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -5945,17 +5141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d568e113f706ee7a7df9b33547bb80721f55abffc79b3dc4d09c368690e662" -dependencies = [ - "leb128fmt", - "wasmparser 0.245.0", + "wasmparser", ] [[package]] @@ -5966,20 +5152,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.13.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce52b194ec202d029751081d735c1ae49c1bacbdc2634c821a86211e3751300c" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder 0.245.0", - "wasmparser 0.245.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -5995,19 +5169,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.244.0" @@ -6020,364 +5181,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48a767a48974f0c8b66f211b96e01aa77feed58b8ccce4e7f0cff0ae55b174d4" -dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.16.1", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.243.0", -] - -[[package]] -name = "wasmtime" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19f56cece843fa95dd929f5568ff8739c7e3873b530ceea9eda2aa02a0b4142" -dependencies = [ - "addr2line", - "anyhow", - "async-trait", - "bitflags 2.10.0", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "futures", - "fxprof-processed-profile", - "gimli", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "ittapi", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rayon", - "rustix 1.1.3", - "semver", - "serde", - "serde_derive", - "serde_json", - "smallvec", - "target-lexicon 0.13.4", - "tempfile", - "wasm-compose", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cache", - "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", - "wat", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-environ" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf9dff572c950258548cbbaf39033f68f8dcd0b43b22e80def9fe12d532d3e5" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "indexmap 2.13.0", - "log", - "object", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon 0.13.4", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmprinter", - "wasmtime-internal-component-util", -] - -[[package]] -name = "wasmtime-internal-cache" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f52a985f5b5dae53147fc596f3a313c334e2c24fd1ba708634e1382f6ecd727" -dependencies = [ - "base64 0.22.1", - "directories-next", - "log", - "postcard", - "rustix 1.1.3", - "serde", - "serde_derive", - "sha2", - "toml 0.9.12+spec-1.1.0", - "wasmtime-environ", - "windows-sys 0.61.2", - "zstd", -] - -[[package]] -name = "wasmtime-internal-component-macro" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7920dc7dcb608352f5fe93c52582e65075b7643efc5dac3fc717c1645a8d29a0" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn 2.0.114", - "wasmtime-internal-component-util", - "wasmtime-internal-wit-bindgen", - "wit-parser 0.243.0", -] - -[[package]] -name = "wasmtime-internal-component-util" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066f5aed35aa60580a2ac0df145c0f0d4b04319862fee1d6036693e1cca43a12" - -[[package]] -name = "wasmtime-internal-cranelift" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb8002dc415b7773d7949ee360c05ee8f91627ec25a7a0b01ee03831bdfdda1" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon 0.13.4", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-math", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9c562c5a272bc9f615d8f0c085a4360bafa28eef9aa5947e63d204b1129b22" -dependencies = [ - "cc", - "cfg-if", - "libc", - "rustix 1.1.3", - "wasmtime-environ", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db673148f26e1211db3913c12c75594be9e3858a71fa297561e9162b1a49cfb0" -dependencies = [ - "cc", - "object", - "rustix 1.1.3", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bada5ca1cc47df7d14100e2254e187c2486b426df813cea2dd2553a7469f7674" -dependencies = [ - "anyhow", - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-math" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6f615d528eda9adc6eefb062135f831b5215c348f4c3ec3e143690c730605b" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da169d4f789b586e1b2612ba8399c653ed5763edf3e678884ba785bb151d018f" - -[[package]] -name = "wasmtime-internal-unwinder" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4888301f3393e4e8c75c938cce427293fade300fee3fc8fd466fdf3e54ae068e" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "log", - "object", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ba3124cc2cbcd362672f9f077303ccc4cd61daa908f73447b7fdaece75ff9f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "wasmtime-internal-winch" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90a4182515dabba776656de4ebd62efad03399e261cf937ecccb838ce8823534" -dependencies = [ - "cranelift-codegen", - "gimli", - "log", - "object", - "target-lexicon 0.13.4", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "winch-codegen", -] - -[[package]] -name = "wasmtime-internal-wit-bindgen" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87acbd416227cdd279565ba49e57cf7f08d112657c3b3f39b70250acdfd094fe" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "heck 0.5.0", - "indexmap 2.13.0", - "wit-parser 0.243.0", -] - -[[package]] -name = "wasmtime-wasi" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a1bdb4948463ed22559a640e687fed0df50b66353144aa6a9496c041ecd927" -dependencies = [ - "anyhow", - "async-trait", - "bitflags 2.10.0", - "bytes", - "cap-fs-ext", - "cap-net-ext", - "cap-rand", - "cap-std", - "cap-time-ext", - "fs-set-times", - "futures", - "io-extras", - "io-lifetimes", - "rustix 1.1.3", - "system-interface", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "wasmtime", - "wasmtime-wasi-io", - "wiggle", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-wasi-io" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7873d8b990d3cf1105ef491abf2b3cf1e19ff6722d24d5ca662026ea082cdff" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "futures", - "wasmtime", -] - -[[package]] -name = "wast" -version = "35.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" -dependencies = [ - "leb128", -] - -[[package]] -name = "wast" -version = "245.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ffc7471e16a6f3c7a3c3a230314915b5dcd158e5ef13ccda2f43358a9df00c" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.245.0", -] - -[[package]] -name = "wat" -version = "1.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bcac6f915e2a84a4c0d9df9d41ad7518d99cda13f3bb83e3b8c22bf8726ab6" -dependencies = [ - "wast 245.0.0", -] - [[package]] name = "web-sys" version = "0.3.85" @@ -6477,46 +5280,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "wiggle" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f05d2a9932ca235984248dc98471ae83d1985e095682d049af4c296f54f0fb4" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "thiserror 2.0.18", - "tracing", - "wasmtime", - "wiggle-macro", -] - -[[package]] -name = "wiggle-generate" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f773d51c1696bd7d028aa35c884d9fc58f48d79a1176dfbad6c908de314235" -dependencies = [ - "anyhow", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.114", - "witx", -] - -[[package]] -name = "wiggle-macro" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e976fe0cecd60041f66b15ad45ebc997952af13da9bf9d90261c7b025057edc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", - "wiggle-generate", -] - [[package]] name = "winapi" version = "0.3.9" @@ -6548,26 +5311,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "winch-codegen" -version = "41.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f31dcfdfaf9d6df9e1124d7c8ee6fc29af5b99b89d11ae731c138e0f5bd77b" -dependencies = [ - "anyhow", - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon 0.13.4", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "wasmtime-internal-math", -] - [[package]] name = "window-vibrancy" version = "0.6.0" @@ -7065,33 +5808,13 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winx" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags 2.10.0", - "windows-sys 0.59.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "wit-bindgen-rust-macro 0.51.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4453ede57df0e4dfddfe20835b934659de17abc79fe9dbdd36d28fa0ac1b959" -dependencies = [ - "bitflags 2.10.0", - "wit-bindgen-rust-macro 0.53.0", + "wit-bindgen-rust-macro", ] [[package]] @@ -7102,18 +5825,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck 0.5.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7a381fbf6d0b3403a207adf15c84811e039d2c4a30d4bcc329be5b8953cdad3" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser 0.245.0", + "wit-parser", ] [[package]] @@ -7127,25 +5839,9 @@ dependencies = [ "indexmap 2.13.0", "prettyplease", "syn 2.0.114", - "wasm-metadata 0.244.0", - "wit-bindgen-core 0.51.0", - "wit-component 0.244.0", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22eb69865fd5fc2771e2197f3f0e75ddf7390c6ccb30895a6ea5837585bd4df4" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.114", - "wasm-metadata 0.245.0", - "wit-bindgen-core 0.53.0", - "wit-component 0.245.0", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] @@ -7159,23 +5855,8 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "wit-bindgen-core 0.51.0", - "wit-bindgen-rust 0.51.0", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0916017a8d24501683b336f3205cc8958265b5cc6b9282b6a844701b17501c2" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.114", - "wit-bindgen-core 0.53.0", - "wit-bindgen-rust 0.53.0", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] @@ -7191,47 +5872,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata 0.244.0", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-component" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896efcb3d68ea1cb555d2d1df185b4071b39d91cf850456809bb0c90a0e4e66e" -dependencies = [ - "anyhow", - "bitflags 2.10.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.245.0", - "wasm-metadata 0.245.0", - "wasmparser 0.245.0", - "wit-parser 0.245.0", -] - -[[package]] -name = "wit-parser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.243.0", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] @@ -7249,38 +5893,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.245.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5cda4f69fdc5a8d54f7032262217dd89410a933e3f86fdad854f5833caf3ccb" -dependencies = [ - "anyhow", - "hashbrown 0.16.1", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.245.0", -] - -[[package]] -name = "witx" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" -dependencies = [ - "anyhow", - "log", - "thiserror 1.0.69", - "wast 35.0.2", + "wasmparser", ] [[package]] @@ -7362,7 +5975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix", ] [[package]] @@ -7419,7 +6032,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.3", + "rustix", "serde", "serde_repr", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 24fbd624..1c7a1663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "Backend", - "Backend/built-in-plugins/Git", ] resolver = "2" diff --git a/DESIGN.md b/DESIGN.md index ac2a5b1e..9096f774 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -27,7 +27,7 @@ OpenVCS client is split into three main runtime concerns: 1. UI layer (`Frontend/src/scripts/`): renders state and invokes backend commands. 2. Host layer (`Backend/src/`): owns app state, command handling, and orchestration. -3. Plugin components (`.ovcsp` bundles): out-of-process modules used by backend/plugin runtime. +3. Plugin modules (`.ovcsp` bundles): out-of-process Node.js modules used by backend/plugin runtime. Primary request flow: @@ -48,7 +48,7 @@ UI code under `Frontend/src/scripts/features/` delegates repository operations t ### 3) Backend to plugin communication is process-isolated -Plugin backend/function components communicate over the component-model ABI (`Backend/src/plugin_runtime/component_instance.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. +Plugin backend/function modules communicate over JSON-RPC over stdio (`Backend/src/plugin_runtime/node_instance.rs` and `Backend/src/plugin_runtime/vcs_proxy.rs`), not in-process calls. ### 4) Safety checks are centralized @@ -76,7 +76,7 @@ Current repo/backend validity checks and progress forwarding are centralized in ## Plugin Lifecycle -- Plugin discovery, load, installation, uninstall, capability approval, and function invocation are handled in `Backend/src/tauri_commands/plugins.rs` plus plugin store/runtime modules. +- Plugin discovery, load, installation, uninstall, approval gating, and function invocation are handled in `Backend/src/tauri_commands/plugins.rs` plus plugin store/runtime modules. ## State Model @@ -113,7 +113,7 @@ Frontend listeners are attached through `TAURI.listen(...)` in feature modules. - Command handlers return stringified errors at the Tauri boundary for predictable UI handling. - Plugin module execution has timeout/restart/backoff behavior in runtime helpers. -- Bundle install/runtime path and capability checks are enforced in backend plugin subsystems. +- Bundle install/runtime path and approval checks are enforced in backend plugin subsystems. - Output logging is centralized so operations can be inspected in the output-log UI. ## Contributor Guidance @@ -133,7 +133,7 @@ When adding or changing behavior: - `Backend/src/lib.rs` - `Backend/src/tauri_commands/mod.rs` - `Backend/src/tauri_commands/shared.rs` -- `Backend/src/plugin_runtime/component_instance.rs` +- `Backend/src/plugin_runtime/node_instance.rs` - `Backend/src/plugin_runtime/vcs_proxy.rs` - `Backend/src/plugin_vcs_backends.rs` - `Frontend/src/scripts/lib/tauri.ts` diff --git a/Frontend/src/modals/plugin-permissions.html b/Frontend/src/modals/plugin-permissions.html deleted file mode 100644 index c2a67937..00000000 --- a/Frontend/src/modals/plugin-permissions.html +++ /dev/null @@ -1,20 +0,0 @@ - - diff --git a/Frontend/src/scripts/features/pluginPermissions.ts b/Frontend/src/scripts/features/pluginPermissions.ts deleted file mode 100644 index 9d60f367..00000000 --- a/Frontend/src/scripts/features/pluginPermissions.ts +++ /dev/null @@ -1,372 +0,0 @@ -// Copyright © 2025-2026 OpenVCS Contributors -// SPDX-License-Identifier: GPL-3.0-or-later - -import { notify } from '../lib/notify'; -import { TAURI } from '../lib/tauri'; -import { closeModal, openModal } from '../ui/modals'; - -/** Payload returned by backend permission lookup command. */ -interface PluginPermissionsPayload { - plugin_id: string; - version?: string; - approval_state: 'pending' | 'approved' | 'denied' | string; - requested_capabilities: string[]; - approved_capabilities: string[]; -} - -/** Option for one permission row selection. */ -interface PermissionChoice { - id: string; - label: string; - approvedCapabilities: string[]; -} - -/** Renderable permission row model. */ -interface PermissionRow { - key: string; - label: string; - detail: string; - choices: PermissionChoice[]; -} - -/** In-memory modal state stored on the modal element. */ -interface PluginPermissionsModalState { - pluginId: string; - rows: PermissionRow[]; - selectedChoiceByRow: Map; -} - -const MODAL_ID = 'plugin-permissions-modal'; - -/** Converts raw capability ids to trimmed lowercase unique values. */ -function normalizeCapabilities(values: string[]): string[] { - const out = new Set(); - for (const value of values || []) { - const id = String(value || '').trim().toLowerCase(); - if (id) out.add(id); - } - return [...out].sort(); -} - -/** Picks the best matching choice id from approved capabilities. */ -function defaultChoiceIdForRow(row: PermissionRow, approved: Set): string { - const choices = [...row.choices].sort( - (a, b) => b.approvedCapabilities.length - a.approvedCapabilities.length, - ); - for (const choice of choices) { - const requested = choice.approvedCapabilities; - if (requested.length && requested.every((capability) => approved.has(capability))) { - return choice.id; - } - if (!requested.length && !row.choices.some((c) => c.approvedCapabilities.some((cap) => approved.has(cap)))) { - return choice.id; - } - } - return row.choices[0]?.id || ''; -} - -/** Builds structured permission rows from requested capability ids. */ -function buildPermissionRows(requestedCapabilities: string[]): PermissionRow[] { - const requested = normalizeCapabilities(requestedCapabilities); - const used = new Set(); - const rows: PermissionRow[] = []; - - const status = requested.filter((capability) => capability.startsWith('status.')); - if (status.length) { - for (const capability of status) used.add(capability); - const hasGet = status.includes('status.get'); - const hasSet = status.includes('status.set'); - if (hasGet && hasSet) { - rows.push({ - key: 'status', - label: 'Status', - detail: 'Lets the plugin read and update the footer status text.', - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'read', label: 'Read only', approvedCapabilities: ['status.get'] }, - { id: 'read-set', label: 'Read & Set', approvedCapabilities: ['status.get', 'status.set'] }, - ], - }); - } else { - const statusDetail = hasGet - ? 'Lets the plugin read the footer status text.' - : 'Lets the plugin update the footer status text.'; - rows.push({ - key: 'status', - label: 'Status', - detail: statusDetail, - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'allow', label: 'Allow', approvedCapabilities: status }, - ], - }); - } - } - - const workspace = requested.filter((capability) => capability.startsWith('workspace.')); - if (workspace.length) { - for (const capability of workspace) used.add(capability); - const hasRead = workspace.includes('workspace.read'); - const hasWrite = workspace.includes('workspace.write'); - if (hasRead && hasWrite) { - rows.push({ - key: 'workspace', - label: 'Workspace', - detail: 'Lets the plugin read and write files inside the active workspace.', - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'read', label: 'Read only', approvedCapabilities: ['workspace.read'] }, - { - id: 'read-write', - label: 'Read & Write', - approvedCapabilities: ['workspace.read', 'workspace.write'], - }, - ], - }); - } else { - const workspaceDetail = hasRead - ? 'Lets the plugin read files inside the active workspace.' - : 'Lets the plugin write files inside the active workspace.'; - rows.push({ - key: 'workspace', - label: 'Workspace', - detail: workspaceDetail, - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'allow', label: 'Allow', approvedCapabilities: workspace }, - ], - }); - } - } - - const execution = requested.filter((capability) => capability.startsWith('process.')); - if (execution.length) { - for (const capability of execution) used.add(capability); - rows.push({ - key: 'execution', - label: 'Execution', - detail: 'Lets the plugin run host-allowed commands in your workspace context.', - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'allow', label: 'Allow', approvedCapabilities: execution }, - ], - }); - } - - const ui = requested.filter((capability) => capability.startsWith('ui.')); - if (ui.length) { - for (const capability of ui) used.add(capability); - rows.push({ - key: 'ui', - label: 'UI', - detail: 'Lets the plugin trigger user-facing interface actions such as notifications.', - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'allow', label: 'Allow', approvedCapabilities: ui }, - ], - }); - } - - const other = requested.filter((capability) => !used.has(capability)); - if (other.length) { - rows.push({ - key: 'other', - label: 'Something else', - detail: `Lets the plugin use additional host features requested by its manifest (${other.join(', ')}).`, - choices: [ - { id: 'deny', label: 'Deny', approvedCapabilities: [] }, - { id: 'allow', label: 'Allow', approvedCapabilities: other }, - ], - }); - } - - return rows; -} - -/** Builds approved capability ids from current row selections. */ -function selectedCapabilities(rows: PermissionRow[], selectedChoiceByRow: Map): string[] { - const approved = new Set(); - for (const row of rows) { - const selectedId = selectedChoiceByRow.get(row.key) || row.choices[0]?.id; - const selected = row.choices.find((choice) => choice.id === selectedId) || row.choices[0]; - for (const capability of selected?.approvedCapabilities || []) { - approved.add(capability); - } - } - return [...approved].sort(); -} - -/** Moves the animated highlight under the active choice button. */ -function positionChoiceIndicator(toggle: HTMLElement): void { - const indicator = toggle.querySelector('.plugin-permissions-toggle-indicator'); - const active = toggle.querySelector('.plugin-permissions-toggle-item.is-active'); - if (!indicator || !active) return; - indicator.style.width = `${active.offsetWidth}px`; - indicator.style.transform = `translateX(${active.offsetLeft}px)`; -} - -/** Applies selected state for one choice row and updates highlight animation. */ -function applyChoiceSelection( - toggle: HTMLElement, - rowKey: string, - choiceId: string, - selectedChoiceByRow: Map, -): void { - const options = toggle.querySelectorAll('.plugin-permissions-toggle-item'); - for (const option of options) { - const selected = option.dataset.choiceId === choiceId; - option.classList.toggle('is-active', selected); - option.setAttribute('aria-pressed', selected ? 'true' : 'false'); - } - selectedChoiceByRow.set(rowKey, choiceId); - positionChoiceIndicator(toggle); -} - -/** Builds a segmented choice control for one permission row. */ -function buildChoiceToggle( - row: PermissionRow, - defaultChoice: string, - selectedChoiceByRow: Map, -): HTMLElement { - const toggle = document.createElement('div'); - toggle.className = 'plugin-permissions-toggle'; - toggle.setAttribute('role', 'group'); - toggle.setAttribute('aria-label', `${row.label} permission`); - - const indicator = document.createElement('span'); - indicator.className = 'plugin-permissions-toggle-indicator'; - toggle.appendChild(indicator); - - for (const choice of row.choices) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'plugin-permissions-toggle-item'; - button.dataset.choiceId = choice.id; - button.textContent = choice.label; - button.addEventListener('click', () => { - applyChoiceSelection(toggle, row.key, choice.id, selectedChoiceByRow); - }); - toggle.appendChild(button); - } - - selectedChoiceByRow.set(row.key, defaultChoice); - requestAnimationFrame(() => { - applyChoiceSelection(toggle, row.key, defaultChoice, selectedChoiceByRow); - }); - - return toggle; -} - -/** Opens and populates plugin permissions modal for the selected plugin. */ -export async function openPluginPermissionsModal(pluginId: string, pluginName: string): Promise { - openModal(MODAL_ID); - - const modal = document.getElementById(MODAL_ID); - if (!(modal instanceof HTMLElement)) return; - - const title = modal.querySelector('#plugin-permissions-title'); - const rowsEl = modal.querySelector('#plugin-permissions-rows'); - const emptyEl = modal.querySelector('#plugin-permissions-empty'); - const applyBtn = modal.querySelector('#plugin-permissions-apply'); - if (!title || !rowsEl || !emptyEl || !applyBtn) return; - - const safePluginId = String(pluginId || '').trim(); - const safePluginName = String(pluginName || '').trim() || safePluginId || 'plugin'; - title.textContent = `Permissions for ${safePluginName}`; - rowsEl.replaceChildren(); - emptyEl.classList.add('hidden'); - emptyEl.textContent = 'Loading permissions…'; - emptyEl.classList.remove('hidden'); - applyBtn.disabled = true; - - if (!TAURI.has) { - emptyEl.textContent = 'The plugin does not request permissions'; - return; - } - - let payload: PluginPermissionsPayload; - try { - payload = await TAURI.invoke('get_plugin_permissions', { - pluginId: safePluginId, - }); - } catch (error) { - const message = String(error || '').trim(); - emptyEl.textContent = message || 'Failed to load plugin permissions'; - return; - } - - const requested = normalizeCapabilities(payload.requested_capabilities || []); - const approved = new Set(normalizeCapabilities(payload.approved_capabilities || [])); - const rows = buildPermissionRows(requested); - const selectedChoiceByRow = new Map(); - - if (!rows.length) { - emptyEl.textContent = 'The plugin does not request permissions'; - applyBtn.disabled = true; - (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }).__pluginPermissionsState = { - pluginId: safePluginId, - rows, - selectedChoiceByRow, - }; - return; - } - - rowsEl.replaceChildren(); - emptyEl.classList.add('hidden'); - - for (const row of rows) { - const rowEl = document.createElement('div'); - rowEl.className = 'plugin-permissions-row'; - - const labelWrap = document.createElement('div'); - labelWrap.className = 'plugin-permissions-label'; - const name = document.createElement('div'); - name.className = 'name'; - name.textContent = row.label; - const detail = document.createElement('div'); - detail.className = 'detail'; - detail.textContent = row.detail; - labelWrap.appendChild(name); - labelWrap.appendChild(detail); - - const defaultChoice = defaultChoiceIdForRow(row, approved); - const toggle = buildChoiceToggle(row, defaultChoice, selectedChoiceByRow); - - rowEl.appendChild(labelWrap); - rowEl.appendChild(toggle); - rowsEl.appendChild(rowEl); - } - - applyBtn.disabled = false; - (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }).__pluginPermissionsState = { - pluginId: safePluginId, - rows, - selectedChoiceByRow, - }; - - if (!(applyBtn as unknown as { __bound?: boolean }).__bound) { - (applyBtn as unknown as { __bound?: boolean }).__bound = true; - applyBtn.addEventListener('click', async () => { - const state = - (modal as unknown as { __pluginPermissionsState?: PluginPermissionsModalState }) - .__pluginPermissionsState; - if (!state || !state.pluginId) return; - - try { - applyBtn.disabled = true; - const approvedCapabilities = selectedCapabilities(state.rows, state.selectedChoiceByRow); - await TAURI.invoke('set_plugin_permissions', { - pluginId: state.pluginId, - approvedCapabilities, - }); - notify('Plugin permissions updated'); - closeModal(MODAL_ID); - } catch (error) { - const message = String(error || '').trim(); - notify(message ? `Apply failed: ${message}` : 'Apply failed'); - } finally { - applyBtn.disabled = false; - } - }); - } -} diff --git a/Frontend/src/scripts/features/settings.ts b/Frontend/src/scripts/features/settings.ts index 69324b0d..1122016f 100644 --- a/Frontend/src/scripts/features/settings.ts +++ b/Frontend/src/scripts/features/settings.ts @@ -6,7 +6,6 @@ import { toKebab } from '../lib/dom'; import { confirmBool } from '../lib/confirm'; import { notify } from '../lib/notify'; import { setTheme } from '../ui/layout'; -import { openPluginPermissionsModal } from './pluginPermissions'; import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, DEFAULT_THEME_ID, getActiveThemeId, getAvailableThemes, refreshAvailableThemes, selectThemePack } from '../themes'; import { reloadPlugins } from '../plugins'; import type { PluginSummary } from '../plugins'; @@ -955,17 +954,23 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { const installed = await TAURI.invoke('install_ovcsp', { bundlePath }); notify(`Installed ${installed?.plugin_id || 'plugin'} ${installed?.version || ''}`.trim()); - const caps = Array.isArray(installed?.requested_capabilities) ? installed.requested_capabilities : []; - if (caps.length) { - const ok = await confirmBool( - `Plugin requests capabilities:\n\n- ${caps.join('\n- ')}\n\nApprove and allow it to run?` + const pluginId = String(installed?.plugin_id || '').trim(); + const version = String(installed?.version || '').trim(); + if (pluginId && version) { + const trusted = await confirmBool( + 'Trust this plugin and allow it to run?\n\n' + + 'Only approve plugins from sources you trust.' ); - await TAURI.invoke('approve_plugin_capabilities', { - pluginId: String(installed?.plugin_id || '').trim(), - version: String(installed?.version || '').trim(), - approved: ok, + await TAURI.invoke('set_plugin_approval', { + pluginId, + version, + approved: trusted, }); - notify(ok ? 'Capabilities approved' : 'Capabilities denied'); + if (trusted) { + notify('Plugin approved'); + } else { + notify('Plugin installed but not approved to run'); + } } await reloadPluginSummaries(); @@ -1362,17 +1367,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { detailEl.appendChild(head); detailEl.appendChild(body); - const footer = document.createElement('div'); - footer.className = 'plugin-detail-footer'; - const permissions = document.createElement('button'); - permissions.type = 'button'; - permissions.className = 'tbtn'; - permissions.id = 'plugins-permissions-selected'; - permissions.dataset.pluginPermissions = id; - permissions.dataset.pluginName = String(plugin.name || '').trim() || id; - permissions.textContent = 'Permissions'; - footer.appendChild(permissions); - detailEl.appendChild(footer); }; const renderList = () => { @@ -1758,20 +1752,6 @@ async function loadPluginsIntoForm(modal: HTMLElement, cfg: GlobalSettings) { pane.addEventListener('click', (e) => { const target = e.target as HTMLElement | null; - const permissionsBtn = - target?.closest('[data-plugin-permissions]') || null; - if (permissionsBtn) { - const pluginId = String(permissionsBtn.dataset.pluginPermissions || '').trim(); - const pluginName = String(permissionsBtn.dataset.pluginName || '').trim() || pluginId; - if (pluginId) { - openPluginPermissionsModal(pluginId, pluginName).catch((err) => { - const msg = String(err || '').trim(); - notify(msg ? `Failed to open permissions: ${msg}` : 'Failed to open permissions'); - }); - } - return; - } - const toggleBtn = target?.closest('[data-plugin-toggle]') || null; if (toggleBtn) { const id = String(toggleBtn.dataset.pluginToggle || '').trim(); diff --git a/Frontend/src/scripts/plugins.ts b/Frontend/src/scripts/plugins.ts index 739aa26a..3383f1ec 100644 --- a/Frontend/src/scripts/plugins.ts +++ b/Frontend/src/scripts/plugins.ts @@ -675,17 +675,7 @@ export async function initPlugins(): Promise { for (const summary of Array.isArray(list) ? list : []) { const pluginId = String(summary?.id || '').trim(); if (!pluginId) continue; - if (!summary.entry) continue; if (!isPluginEnabled(summary)) continue; - - try { - const payload = await TAURI.invoke('load_plugin', { id: pluginId }); - const code = typeof payload?.entry === 'string' ? payload.entry : ''; - if (!code.trim()) continue; - injectPluginModule(code, pluginId); - } catch (err) { - console.warn(`load_plugin failed (${pluginId})`, err); - } } ensurePluginsMenuPlaceholder(); diff --git a/Frontend/src/scripts/ui/modals.ts b/Frontend/src/scripts/ui/modals.ts index 8b9610a9..3f8a9a60 100644 --- a/Frontend/src/scripts/ui/modals.ts +++ b/Frontend/src/scripts/ui/modals.ts @@ -6,7 +6,6 @@ import { initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from "../lib/sc import settingsHtml from "@modals/settings.html?raw"; import cmdHtml from "@modals/commandSheet.html?raw"; import aboutHtml from "@modals/about.html?raw"; -import pluginPermissionsHtml from "@modals/plugin-permissions.html?raw"; import { wireSettings } from "../features/settings"; import repoSettingsHtml from "@modals/repo-settings.html?raw"; import { wireRepoSettings } from "../features/repoSettings"; @@ -36,7 +35,6 @@ import repoSwitchDrawerHtml from "@modals/repoSwitchDrawer.html?raw"; const FRAGMENTS: Record = { "settings-modal": settingsHtml, "about-modal": aboutHtml, - "plugin-permissions-modal": pluginPermissionsHtml, "command-modal": cmdHtml, "repo-switch-drawer": repoSwitchDrawerHtml, "repo-settings-modal": repoSettingsHtml, diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css index 333694c1..0790b19e 100644 --- a/Frontend/src/styles/index.css +++ b/Frontend/src/styles/index.css @@ -11,7 +11,6 @@ @import "./modal/modal-base.css"; @import "./modal/command-sheet.css"; @import "./modal/settings.css"; -@import "./modal/plugin-permissions.css"; @import "./modal/repo-settings.css"; @import "./output-log.css"; @import "./modal/new-branch.css"; diff --git a/Frontend/src/styles/modal/plugin-permissions.css b/Frontend/src/styles/modal/plugin-permissions.css deleted file mode 100644 index be773000..00000000 --- a/Frontend/src/styles/modal/plugin-permissions.css +++ /dev/null @@ -1,112 +0,0 @@ -/* ========================================================================== - plugin-permissions.css — Styles for plugin permissions modal - ========================================================================== */ - -#plugin-permissions-modal .dialog.sheet { - width: clamp(520px, 80vw, 720px); - max-height: min(620px, calc(100vh - 24px)); - display: flex; - flex-direction: column; -} - -#plugin-permissions-modal .plugin-permissions-body { - flex: 1 1 auto; - min-height: 0; - overflow: auto; - display: grid; - gap: 0.7rem; -} - -#plugin-permissions-modal .plugin-permissions-rows { - display: grid; - gap: 0.55rem; -} - -#plugin-permissions-modal .plugin-permissions-row { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 0.75rem; - padding: 0.6rem 0.7rem; - border: 1px solid var(--border); - border-radius: var(--r-sm); - background: var(--surface); -} - -#plugin-permissions-modal .plugin-permissions-label { - min-width: 0; -} - -#plugin-permissions-modal .plugin-permissions-label .name { - font-weight: 700; -} - -#plugin-permissions-modal .plugin-permissions-label .detail { - margin-top: 0.2rem; - color: var(--muted); - font-size: 0.84rem; - overflow-wrap: anywhere; -} - -#plugin-permissions-modal .plugin-permissions-toggle { - position: relative; - display: grid; - grid-auto-flow: column; - grid-auto-columns: minmax(88px, 1fr); - gap: 0; - border: 1px solid var(--border); - border-radius: 10px; - background: color-mix(in oklab, var(--surface-2) 92%, transparent); - overflow: hidden; - isolation: isolate; -} - -#plugin-permissions-modal .plugin-permissions-toggle-indicator { - position: absolute; - top: 2px; - left: 0; - height: calc(100% - 4px); - width: 0; - border-radius: 8px; - background: color-mix(in oklab, var(--accent) 22%, transparent); - border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--border)); - transition: transform 170ms cubic-bezier(.2,.8,.2,1), width 170ms cubic-bezier(.2,.8,.2,1); - z-index: 0; -} - -#plugin-permissions-modal .plugin-permissions-toggle-item { - position: relative; - z-index: 1; - border: 0; - background: transparent; - color: var(--muted); - padding: 0.45rem 0.7rem; - font: inherit; - font-size: 0.9rem; - cursor: pointer; -} - -#plugin-permissions-modal .plugin-permissions-toggle-item:hover { - color: var(--text); -} - -#plugin-permissions-modal .plugin-permissions-toggle-item.is-active { - color: var(--text); - font-weight: 700; -} - -#plugin-permissions-modal .plugin-permissions-toggle-item:focus-visible { - outline: none; - box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--accent) 45%, transparent); -} - -@media (max-width: 720px) { - #plugin-permissions-modal .plugin-permissions-row { - grid-template-columns: 1fr; - gap: 0.55rem; - } - - #plugin-permissions-modal .plugin-permissions-toggle { - width: 100%; - } -} diff --git a/README.md b/README.md index 4d57eb9f..a0ab0f64 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Swap `stable` for `dev` in the URL if you want the bleeding-edge installer. - 🗃 **Git LFS helpers:** fetch/pull/prune, track/untrack, inspect tracked paths. - 🔐 **SSH helpers:** trust host keys, list/add SSH agent keys, key discovery. - 🎨 **Themes:** built-in light/dark themes, plus plugin-provided themes (standalone theme `.zip` packs are not supported). -- 🧩 **Plugins (early):** installable `.ovcsp` bundles (theme packs and/or Wasm modules). +- 🧩 **Plugins (early):** installable `.ovcsp` bundles (theme packs and/or Node.js modules). - 🔄 **Updater & logs:** update check/install, VCS output log window, app log tail/clear. ## Planned / Exploratory diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index a6ce7ee1..6fe70856 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -1,53 +1,48 @@ # OpenVCS Plugin Architecture -This document describes the current plugin system used by the OpenVCS desktop client. +OpenVCS plugins run as long-lived Node.js processes and are authored in TypeScript. ## Architecture ```text -Client (Frontend) -> Client (Backend host) <-> Plugin (Wasm component) +Client (Frontend) -> Client (Backend host) <-> Plugin (Node.js process) ``` - The frontend talks to the backend via Tauri commands/events. -- The backend loads plugins as component-model WebAssembly modules and calls them via typed ABI bindings. +- The backend starts each plugin module as a persistent Node.js process. +- Host and plugin communicate through JSON-RPC 2.0 over stdio with `Content-Length` framing. -## Contracts (WIT) +## Runtime contract -The authoritative host/plugin contract lives under `Core/wit/`: +- Method names and framing live in `Client/Backend/src/plugin_runtime/protocol.rs`. +- Runtime process implementation lives in: + - `Client/Backend/src/plugin_runtime/node_instance.rs` + - `Client/Backend/src/plugin_runtime/runtime_select.rs` -- `Core/wit/host.wit`: host imports plugins can call (workspace IO, status set/get, process exec, notifications, logging, events) -- `Core/wit/plugin.wit`: plugin world with lifecycle, UI, and settings support -- `Core/wit/vcs.wit`: VCS backend world (`vcs`) +Core groups of host->plugin methods: -The backend generates host bindings from these contracts and links them into a Wasmtime component runtime. +- `plugin.*`: lifecycle, menus, and settings hooks +- `vcs.*`: backend operations for repository workflows + +Core plugin->host notifications: + +- `host.log` +- `host.ui_notify` +- `host.status_set` +- `host.event_emit` +- `vcs.event` +- Plugin runtime requires the app-bundled Node binary (`node-runtime/node` or `node.exe`); no system `node` fallback. ## Plugin types - Theme pack plugin - - Ships `themes/` assets. - - A plugin may ship themes alone or alongside a module. + - Ships `themes/` assets only. -- Module plugin (lifecycle only) - - Exports the `plugin` world from `Core/wit/plugin.wit`. - - Must implement `plugin-api.init` and `plugin-api.deinit`. - -- Module plugin (UI + settings lifecycle) - - Exports the `plugin` world from `Core/wit/plugin.wit`. - - Supports typed menu contributions (`get-menus` + `handle-action`) and settings hooks (`settings-defaults`, `settings-on-load`, `settings-on-apply`, `settings-on-save`, `settings-on-reset`). - - Menu records include an optional `order` hint (`option`); host menu rendering sorts by `order` (ascending) then label. - - Plugins can implement only the hooks they care about when using `#[openvcs_plugin]`; defaults are injected for omitted hooks. +- Module plugin (lifecycle + optional settings/UI hooks) + - Exposes `plugin.*` methods over JSON-RPC. - VCS backend plugin - - Exports the `vcs` world from `Core/wit/vcs.wit`. - - Must export both `plugin-api` (lifecycle) and `vcs-api` (backend operations). - -## Runtime implementation - -Key host code locations: - -- `Client/Backend/src/plugin_runtime/runtime_select.rs`: enforces component-only runtime (non-component modules are rejected). -- `Client/Backend/src/plugin_runtime/component_instance.rs`: Wasmtime component instantiation + typed calls. -- `Client/Backend/src/plugin_bundles.rs`: `.ovcsp` installation, indexing, capability approvals, module discovery. + - Exposes both `plugin.*` and `vcs.*` methods. ## Bundle format (`.ovcsp`) @@ -59,54 +54,35 @@ Plugins are installed from `.ovcsp` tar.xz archives. Layout: icon. (optional) themes/ (optional; may coexist with a module) bin/ - .wasm (optional; must be a component) + .mjs|.js|.cjs ``` ## Manifest (`openvcs.plugin.json`) -The host cares about: +The host currently consumes: - `id` (required) - `name`, `version` (optional but recommended) - `default_enabled` (optional) -- `capabilities` (optional list of strings) -- `module.exec` (optional `.wasm` filename under `bin/`) +- `module.exec` (optional Node entry filename under `bin/`) - `module.vcs_backends` (optional VCS backend ids the module provides) -## Capabilities - -Plugins request capabilities through the manifest `capabilities` array. -The host enforces capability approval before allowing privileged host API calls. - -The Settings > Plugins details pane exposes a `Permissions` modal where users can -adjust approval choices for the currently installed plugin version and apply -changes immediately. - -Status APIs use dedicated capabilities: - -- `status.set`: allows plugins to call `set-status`. -- `status.get`: allows plugins to call `get-status`. -- `status.set` also implies `status.get`. - -When a plugin calls status APIs without approved status capability, the host logs -a warning and ignores the status mutation/read request instead of failing plugin -startup. - ## Security model -- Plugins run out-of-process in a Wasmtime component runtime. -- Host APIs are explicit via WIT imports. -- Workspace file access is mediated by the host and can be confined to a selected workspace root. +Plugins are trust-model based: + +- No per-capability permission prompts. +- Plugins have full system access within their own Node process. +- Plugin module startup is gated by installed-version approval state. +- Install only plugins from authors you trust. ## Runtime lifecycle - Module runtimes are started/stopped by lifecycle operations (startup sync and plugin enable/disable toggles). -- VCS backend plugin runtimes are repo-scoped and started only when opening a repository through that backend. +- VCS backend plugin runtimes are repo-scoped and started when opening a repository through that backend. - Backend plugin command calls do not implicitly start stopped plugin runtimes. -- If a plugin is enabled but not currently running, module RPC/menu calls return a `not running` error until runtime is restored. ## Plugin settings persistence -- Plugin settings are persisted by the host (not by plugin code) in the user config directory under: +- Plugin settings are persisted by the host in the user config directory under: - `plugin-data//settings.json` -- This keeps settings stable across plugin updates because installed plugin directories are replaced during installation. diff --git a/docs/plugins.md b/docs/plugins.md index 9ba04c13..51391c98 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,9 +2,7 @@ OpenVCS plugins are local extensions installed as `.ovcsp` bundles. -Plugins may include themes, a Wasm module, or both. - -Module plugins may optionally export UI menus and settings lifecycle hooks via the plugin world in `Core/wit/plugin.wit`. +Plugins may include themes, a Node.js module, or both. ## Where plugins live @@ -23,7 +21,7 @@ An `.ovcsp` is a tar.xz archive with this layout: icon. (optional) themes/ (optional) bin/ - .wasm (optional; must be a component) + .mjs|.js|.cjs ``` ## Manifest (`openvcs.plugin.json`) @@ -45,41 +43,39 @@ Minimal module plugin: "id": "example.plugin", "name": "Example Plugin", "version": "0.1.0", - "capabilities": [], - "module": { "exec": "example-plugin.wasm" } + "module": { "exec": "example-plugin.mjs" } } ``` Notes: -- `module.exec` must end with `.wasm`. -- The plugin runtime only loads component-model modules. +- `module.exec` must end with `.js`, `.mjs`, or `.cjs`. +- The runtime loads only Node entry files from `bin/`. - If `themes/` exists, it is packaged and discovered automatically. -- If a plugin calls status APIs without approved status capability, the host logs a warning and ignores the action (the plugin still loads). ## Plugin UI menus and settings - Plugins can contribute typed menus/elements (text and buttons today) that the client renders. -- Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus in the same open modal. +- Enabling/disabling a plugin from the Settings > Plugins pane refreshes plugin-contributed menus. - Plugin list checkboxes are tri-state in the UI: disabled, enabled (green check), and enabling (animated pending indicator). -- If plugin runtime startup fails (including startup sync on app launch), the plugin list shows a persistent red `!` marker for that plugin until the next retry. -- Plugin menus are fetched only from plugins with a currently running module runtime; enabled plugins that are not running (for example after a crash) do not contribute menus until runtime is restored. +- If plugin runtime startup fails, the plugin list shows a persistent red `!` marker for that plugin until retry. +- Plugin menus are fetched only from plugins with a currently running module runtime. - If enabling a plugin fails during runtime startup, the host keeps that plugin disabled and returns an error to the UI. -- For plugin menus, plugins can provide an optional `menu.order` (`u32`) hint; lower values render earlier, and menus without an order are sorted after ordered menus by label. -- Built-in plugin menus are shown as normal top-level Settings sections; third-party plugin menus are grouped under Settings > Plugins in the `Plugin Settings` subsection. -- Action buttons invoke plugin `handle-action` callbacks. -- Plugin IPC is contract-driven: backend calls map to typed WIT exports rather than arbitrary string-named module methods. -- The Plugins details pane includes a bottom-right `Permissions` button that opens a stacked modal titled `Permissions for `. -- The permissions modal lists only permissions requested by that plugin, shows segmented button choices (for example `Allow` / `Deny`, with richer choices for some permission groups), and includes an `Apply changes` button. -- When a plugin requests no capabilities, the modal shows: `The plugin does not request permissions`. - Plugin settings persistence is automatic in the host under: - `plugin-data//settings.json` -- Settings save/load/reset/apply flow is driven by plugin hooks: - - `settings-defaults` - - `settings-on-load` - - `settings-on-apply` - - `settings-on-save` - - `settings-on-reset` + +## Security model + +Plugins are trust-model based and do not use per-capability permission prompts. +Plugins run with full system access in their own Node process. + +Before a plugin module can start, the installed version must be marked +`approved` in plugin installation metadata. + +Plugin modules run only with the app-bundled Node runtime; OpenVCS does not +fall back to `node` from system PATH. + +Install only plugins you trust. ## Building bundles From 71e8741b9537a693aac39701cc52ad59ea8ac2ce Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 11:15:15 +0000 Subject: [PATCH 90/96] Update Git --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index d0b457cb..bc5c271a 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit d0b457cb6d30bfda45b68a6a98712da98911a15c +Subproject commit bc5c271a55f2629300f080c1e457a0a02f31a037 From 29a75e75a2f9d77cd228e226f22d68da4d3f5273 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 11:19:32 +0000 Subject: [PATCH 91/96] Fix issues with plugins --- Backend/src/lib.rs | 62 ++++++++++--- Backend/src/plugin_bundles.rs | 125 +++++++++++++++++++++++--- Backend/src/plugin_runtime/manager.rs | 83 ++++++++++++++--- 3 files changed, 232 insertions(+), 38 deletions(-) diff --git a/Backend/src/lib.rs b/Backend/src/lib.rs index 0b9004e3..abf8c6d0 100644 --- a/Backend/src/lib.rs +++ b/Backend/src/lib.rs @@ -7,6 +7,7 @@ use log::warn; use openvcs_core::BackendId; +use std::path::PathBuf; use std::sync::Arc; use tauri::path::BaseDirectory; use tauri::WindowEvent; @@ -104,6 +105,27 @@ fn try_reopen_last_repo(app_handle: &tauri::AppHandle) { } } +/// Resolves a development fallback path for the bundled Node runtime. +/// +/// In `cargo tauri dev`, the generated runtime is placed under +/// `target/openvcs/node-runtime`, while Tauri resource resolution can point at +/// `target/debug/node-runtime`. This helper probes the generated location. +/// +/// # Returns +/// - `Some(PathBuf)` when the dev bundled node binary exists. +/// - `None` when the path cannot be derived or does not exist. +fn resolve_dev_bundled_node_fallback() -> Option { + let exe = std::env::current_exe().ok()?; + let exe_dir = exe.parent()?; + let target_dir = exe_dir.parent()?; + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + let candidate = target_dir + .join("openvcs") + .join("node-runtime") + .join(node_name); + candidate.is_file().then_some(candidate) +} + /// Starts the OpenVCS backend runtime and Tauri application. /// /// This configures logging, plugin bundle synchronization, startup restore @@ -154,22 +176,34 @@ pub fn run() { ); } } - if let Ok(node_runtime_dir) = app.path().resolve("node-runtime", BaseDirectory::Resource) { - let node_name = if cfg!(windows) { "node.exe" } else { "node" }; - let bundled_node = node_runtime_dir.join(node_name); - if bundled_node.is_file() { - crate::plugin_paths::set_node_executable_path(bundled_node.clone()); - log::info!( - "plugins: using bundled node runtime: {}", - bundled_node.display() - ); - } else { - log::warn!( - "plugins: bundled node runtime missing at {}; plugin modules will not start", - bundled_node.display() - ); + let node_name = if cfg!(windows) { "node.exe" } else { "node" }; + let mut node_candidates: Vec = Vec::new(); + if let Ok(node_runtime_dir) = app.path().resolve("node-runtime", BaseDirectory::Resource) + { + node_candidates.push(node_runtime_dir.join(node_name)); + } + if let Some(dev_fallback) = resolve_dev_bundled_node_fallback() { + if !node_candidates.iter().any(|p| p == &dev_fallback) { + node_candidates.push(dev_fallback); } } + + if let Some(bundled_node) = node_candidates.iter().find(|path| path.is_file()) { + crate::plugin_paths::set_node_executable_path(bundled_node.to_path_buf()); + log::info!( + "plugins: using bundled node runtime: {}", + bundled_node.display() + ); + } else if let Some(primary) = node_candidates.first() { + log::warn!( + "plugins: bundled node runtime missing at {}; plugin modules will not start", + primary.display() + ); + } else { + log::warn!( + "plugins: bundled node runtime path could not be resolved; plugin modules will not start" + ); + } let store = crate::plugin_bundles::PluginBundleStore::new_default(); if let Err(err) = store.sync_built_in_plugins() { warn!("plugins: failed to sync built-in bundles: {}", err); diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index f6155113..d1bf905a 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -1035,18 +1035,27 @@ impl PluginBundleStore { }; debug!("list_current_components: checking plugin '{}'", plugin_id); - if let Some(c) = self.load_current_components(&plugin_id)? { - debug!( - "list_current_components: plugin '{}' has components, has_module={}", - plugin_id, - c.module.is_some() - ); - out.push(c); - } else { - debug!( - "list_current_components: plugin '{}' has no current components", - plugin_id - ); + match self.load_current_components(&plugin_id) { + Ok(Some(c)) => { + debug!( + "list_current_components: plugin '{}' has components, has_module={}", + plugin_id, + c.module.is_some() + ); + out.push(c); + } + Ok(None) => { + debug!( + "list_current_components: plugin '{}' has no current components", + plugin_id + ); + } + Err(err) => { + warn!( + "list_current_components: skipping invalid plugin '{}': {}", + plugin_id, err + ); + } } } out.sort_by(|a, b| a.plugin_id.cmp(&b.plugin_id)); @@ -1762,4 +1771,96 @@ mod tests { let err = store.install_ovcsp_with_limits(&bundle_path, limits); assert!(err.is_err()); } + + #[test] + /// Verifies component listing skips invalid plugins instead of failing globally. + fn list_current_components_skips_invalid_plugins() { + let root = tempdir().expect("tempdir"); + let store = PluginBundleStore::new_at(root.path().to_path_buf()); + + write_installed_plugin(root.path(), "valid.theme", "1.0.0", None); + write_installed_plugin(root.path(), "broken.runtime", "1.0.0", Some("plugin.wasm")); + + let components = store + .list_current_components() + .expect("list current components"); + + assert_eq!(components.len(), 1); + assert_eq!(components[0].plugin_id, "valid.theme"); + assert!(components[0].module.is_none()); + } + + /// Writes an installed plugin directory with optional module entrypoint. + fn write_installed_plugin( + root: &std::path::Path, + plugin_id: &str, + version: &str, + module_exec: Option<&str>, + ) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = if let Some(exec) = module_exec { + serde_json::json!({ + "id": plugin_id, + "name": "Test", + "version": version, + "default_enabled": false, + "module": { + "exec": exec, + "vcs_backends": [] + } + }) + } else { + serde_json::json!({ + "id": plugin_id, + "name": "Test", + "version": version, + "default_enabled": false + }) + }; + + fs::write( + plugin_dir.join(PLUGIN_MANIFEST_NAME), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some(version.to_string()), + versions: { + let mut versions = BTreeMap::new(); + versions.insert( + version.to_string(), + InstalledPluginVersion { + version: version.to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + versions + }, + }; + + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: version.to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } } diff --git a/Backend/src/plugin_runtime/manager.rs b/Backend/src/plugin_runtime/manager.rs index ea759ed8..f9abf3ec 100644 --- a/Backend/src/plugin_runtime/manager.rs +++ b/Backend/src/plugin_runtime/manager.rs @@ -615,18 +615,8 @@ impl PluginRuntimeManager { /// Finds installed plugin components by plugin id (case-insensitive). fn find_components(&self, plugin_id: &str) -> Result { trace!("find_components: plugin_id='{}'", plugin_id); - - let all_components = self.store.list_current_components()?; - debug!( - "find_components: found {} total components", - all_components.len() - ); - - let found = all_components - .into_iter() - .find(|components| components.plugin_id.eq_ignore_ascii_case(plugin_id)); - - match found { + let normalized = normalize_plugin_key(plugin_id)?; + match self.store.load_current_components(&normalized)? { Some(comp) => { debug!("find_components: matched plugin_id='{}'", comp.plugin_id); Ok(comp) @@ -805,6 +795,19 @@ mod tests { assert!(vcs.spawn.is_vcs_backend); } + #[test] + /// Verifies enabling a non-runtime plugin does not depend on other plugins. + fn enabling_non_runtime_plugin_ignores_unrelated_invalid_plugin() { + let temp = tempdir().expect("tempdir"); + write_non_runtime_plugin(temp.path(), "themes.plugin", false); + write_invalid_runtime_plugin(temp.path(), "broken.plugin"); + let manager = PluginRuntimeManager::new(PluginBundleStore::new_at(temp.path().into())); + + manager + .set_plugin_enabled("themes.plugin", true) + .expect("enable themes plugin"); + } + /// Writes a minimal module-capable plugin layout into a temp store. fn write_plugin(root: &std::path::Path, plugin_id: &str, default_enabled: bool) { write_plugin_with_backends(root, plugin_id, default_enabled, false); @@ -938,4 +941,60 @@ mod tests { ) .expect("write current"); } + + /// Writes a plugin with an invalid runtime module entrypoint extension. + fn write_invalid_runtime_plugin(root: &std::path::Path, plugin_id: &str) { + let plugin_dir = root.join(plugin_id); + fs::create_dir_all(&plugin_dir).expect("create plugin dir"); + + let manifest = serde_json::json!({ + "id": plugin_id, + "name": "Broken Plugin", + "version": "1.0.0", + "default_enabled": false, + "module": { + "exec": "broken.wasm", + "vcs_backends": [] + } + }); + fs::write( + plugin_dir.join("openvcs.plugin.json"), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let mut versions = BTreeMap::new(); + versions.insert( + "1.0.0".to_string(), + InstalledPluginVersion { + version: "1.0.0".to_string(), + bundle_sha256: "sha".to_string(), + installed_at_unix_ms: 0, + requested_capabilities: Vec::new(), + approval: ApprovalState::Approved { + capabilities: Vec::new(), + approved_at_unix_ms: 0, + }, + }, + ); + let index = InstalledPluginIndex { + plugin_id: plugin_id.to_string(), + current: Some("1.0.0".to_string()), + versions, + }; + fs::write( + plugin_dir.join("index.json"), + serde_json::to_vec_pretty(&index).expect("serialize index"), + ) + .expect("write index"); + + let current = CurrentPointer { + version: "1.0.0".to_string(), + }; + fs::write( + plugin_dir.join("current.json"), + serde_json::to_vec_pretty(¤t).expect("serialize current"), + ) + .expect("write current"); + } } From c010358f05ffe96934bc816902643ffe17914131 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 11:55:17 +0000 Subject: [PATCH 92/96] Update architecture --- docs/plugin architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index 6fe70856..b8c99250 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -31,7 +31,7 @@ Core plugin->host notifications: - `host.status_set` - `host.event_emit` - `vcs.event` -- Plugin runtime requires the app-bundled Node binary (`node-runtime/node` or `node.exe`); no system `node` fallback. +- Plugin runtime requires the app-bundled Node binary (`node-runtime/node` or `node.exe`); no system `node` fallback. In dev runs, the backend also probes the generated bundled path under `target/openvcs/node-runtime/`. ## Plugin types From 480fc76cdd05b8c7a7a5f0e251cdf282f1b41143 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 11:55:29 +0000 Subject: [PATCH 93/96] Update submodule --- Backend/built-in-plugins/Git | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/built-in-plugins/Git b/Backend/built-in-plugins/Git index bc5c271a..a02bb5fc 160000 --- a/Backend/built-in-plugins/Git +++ b/Backend/built-in-plugins/Git @@ -1 +1 @@ -Subproject commit bc5c271a55f2629300f080c1e457a0a02f31a037 +Subproject commit a02bb5fc215a10a5ec0eed279c61d60d781791d4 From b60582f2a11c6adfa1fa9f17acdb648517e107e4 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 1 Mar 2026 11:58:56 +0000 Subject: [PATCH 94/96] Updated agents file --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6b7cf168..2b34fc17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ - **ALL code must be documented**, not just public APIs. This includes: - Rust: Use doc comments (`///` for items, `//!` for modules) for all functions, structs, enums, traits, and fields. - TypeScript: Use JSDoc comments (`/** ... */`) for all functions, classes, interfaces, and types. +- All functions must include documentation comments. +- All code files MUST be no more than 1000 lines; split files before they exceed this limit. - When you change behavior, workflows, commands, paths, config, or plugin/runtime expectations, ALWAYS update the relevant documentation in the same change, even if the user does not explicitly ask. - Include usage examples for complex functions. - Keep README files in sync with code changes. From 468cc81e355ec9c4e7a20a3d1dd4101f9c75bc62 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Mar 2026 11:31:29 +0000 Subject: [PATCH 95/96] Fix issues --- .github/workflows/opencode-review.yml | 7 ++++--- .github/workflows/opencode.yml | 7 ++++--- Backend/src/plugin_bundles.rs | 14 +++++++++++++- docs/plugin architecture.md | 7 +++++++ docs/plugins.md | 4 ++++ 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 71532249..4d54cbbe 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -11,13 +11,14 @@ jobs: permissions: id-token: write contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write steps: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: anomalyco/opencode/github@latest + # Pinned to an immutable commit for CodeQL (tag: github-v1.2.17) + - uses: anomalyco/opencode/github@2410593023d2c61f05123c9b0faf189a28dfbeee env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 5686e868..504e7f5c 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -19,8 +19,8 @@ jobs: permissions: id-token: write contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write steps: - name: Checkout repository uses: actions/checkout@v6 @@ -28,7 +28,8 @@ jobs: persist-credentials: false - name: Run opencode - uses: anomalyco/opencode/github@latest + # Pinned to an immutable commit for CodeQL (tag: github-v1.2.17) + uses: anomalyco/opencode/github@2410593023d2c61f05123c9b0faf189a28dfbeee env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: diff --git a/Backend/src/plugin_bundles.rs b/Backend/src/plugin_bundles.rs index d1bf905a..40412261 100644 --- a/Backend/src/plugin_bundles.rs +++ b/Backend/src/plugin_bundles.rs @@ -36,7 +36,7 @@ impl Default for InstallerLimits { /// - Default [`InstallerLimits`] values. fn default() -> Self { Self { - max_files: 4096, + max_files: 50_000, max_file_bytes: 64 * 1024 * 1024, max_total_bytes: 512 * 1024 * 1024, max_compression_ratio: 200, @@ -448,6 +448,18 @@ impl PluginBundleStore { return Err(format!("unsupported tar entry type: {}", raw_name)); } + if stripped + .as_os_str() + .to_string_lossy() + .to_ascii_lowercase() + .ends_with(".node") + { + return Err(format!( + "bundle contains unsupported native Node addon: {}", + raw_name + )); + } + total_files += 1; if total_files > limits.max_files { return Err(format!( diff --git a/docs/plugin architecture.md b/docs/plugin architecture.md index b8c99250..36830412 100644 --- a/docs/plugin architecture.md +++ b/docs/plugin architecture.md @@ -55,6 +55,8 @@ Plugins are installed from `.ovcsp` tar.xz archives. Layout: themes/ (optional; may coexist with a module) bin/ .mjs|.js|.cjs + ...other runtime files + node_modules/ (optional; pre-bundled npm dependencies) ``` ## Manifest (`openvcs.plugin.json`) @@ -67,6 +69,11 @@ The host currently consumes: - `module.exec` (optional Node entry filename under `bin/`) - `module.vcs_backends` (optional VCS backend ids the module provides) +Dependency notes: + +- Plugin dependencies are expected to be pre-bundled in `.ovcsp`. +- The host does not run npm/yarn/pnpm during plugin install/update. + ## Security model Plugins are trust-model based: diff --git a/docs/plugins.md b/docs/plugins.md index 51391c98..f0b10b93 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -22,6 +22,8 @@ An `.ovcsp` is a tar.xz archive with this layout: themes/ (optional) bin/ .mjs|.js|.cjs + ...other runtime files + node_modules/ (optional; pre-bundled npm dependencies) ``` ## Manifest (`openvcs.plugin.json`) @@ -52,6 +54,8 @@ Notes: - `module.exec` must end with `.js`, `.mjs`, or `.cjs`. - The runtime loads only Node entry files from `bin/`. - If `themes/` exists, it is packaged and discovered automatically. +- Dependency installation is a packaging concern (SDK), not an app install concern. +- OpenVCS does not run npm during plugin installation or updates. ## Plugin UI menus and settings From dbc69b607ac848107e5ba3551abb647a54a5d3c3 Mon Sep 17 00:00:00 2001 From: Jordon Date: Sun, 15 Mar 2026 11:46:44 +0000 Subject: [PATCH 96/96] Remove extra separater from file context menu --- .../src/scripts/features/repo/interactions.ts | 1 - Frontend/src/scripts/lib/menu.ts | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Frontend/src/scripts/features/repo/interactions.ts b/Frontend/src/scripts/features/repo/interactions.ts index 33a43e15..0bd25670 100644 --- a/Frontend/src/scripts/features/repo/interactions.ts +++ b/Frontend/src/scripts/features/repo/interactions.ts @@ -274,7 +274,6 @@ export async function onFileContextMenu(ev: MouseEvent, f: FileStatus) { } }}); items.push({ label: '---' }); - items.push({ label: '---' }); if (explicitMultiSelection) { items.push({ label: 'Discard all selected', action: async () => { if (!TAURI.has) return; diff --git a/Frontend/src/scripts/lib/menu.ts b/Frontend/src/scripts/lib/menu.ts index 08f6ef4c..09375df0 100644 --- a/Frontend/src/scripts/lib/menu.ts +++ b/Frontend/src/scripts/lib/menu.ts @@ -14,7 +14,23 @@ export function buildCtxMenu(items: CtxItem[], x: number, y: number) { // Position gets clamped to viewport after measuring. m.style.left = `${Math.round(x)}px`; m.style.top = `${Math.round(y)}px`; - items.forEach((it) => { + // Normalize separators: remove leading/trailing, collapse consecutive. + const normalized: CtxItem[] = []; + let lastWasSep = true; // start as true to drop leading separators + for (const it of items) { + const isSep = it.label === '---'; + if (isSep) { + if (!lastWasSep) { normalized.push(it); } + } else { + normalized.push(it); + } + lastWasSep = isSep; + } + // Drop trailing separator if present + if (normalized.length > 0 && normalized[normalized.length - 1].label === '---') { + normalized.pop(); + } + normalized.forEach((it) => { if (it.label === '---') { const sep = document.createElement('div'); sep.className = 'sep';