Skip to main content

fresh/services/plugins/
manager.rs

1//! Unified Plugin Manager
2//!
3//! This module provides a unified interface for the plugin system that works
4//! regardless of whether the `plugins` feature is enabled. When plugins are
5//! disabled, all methods are no-ops, avoiding the need for cfg attributes
6//! scattered throughout the codebase.
7
8use crate::config_io::DirectoryContext;
9use crate::input::command_registry::CommandRegistry;
10use fresh_core::config::PluginConfig;
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::{Arc, RwLock};
14
15#[cfg(feature = "plugins")]
16use super::bridge::EditorServiceBridge;
17#[cfg(feature = "plugins")]
18use fresh_plugin_runtime::PluginThreadHandle;
19
20/// Unified plugin manager that abstracts over the plugin system.
21///
22/// When the `plugins` feature is enabled, this wraps `PluginThreadHandle`.
23/// When disabled, all methods are no-ops.
24pub struct PluginManager {
25    #[cfg(feature = "plugins")]
26    inner: Option<PluginThreadHandle>,
27    #[cfg(not(feature = "plugins"))]
28    _phantom: std::marker::PhantomData<()>,
29    /// Test-only side channel: commands pushed via
30    /// [`Self::test_inject_command`] are returned by the next
31    /// `process_commands()` call as if they had come from the plugin
32    /// thread. Always present (zero overhead — empty `Vec`) so
33    /// integration tests in `tests/` can use it without an extra
34    /// feature flag.
35    pending_injected_commands: Vec<super::api::PluginCommand>,
36}
37
38impl PluginManager {
39    /// Create a new plugin manager.
40    ///
41    /// When `plugins` feature is enabled and `enable` is true, spawns the plugin thread.
42    /// Otherwise, creates a no-op manager.
43    pub fn new(
44        enable: bool,
45        command_registry: Arc<RwLock<CommandRegistry>>,
46        dir_context: DirectoryContext,
47        theme_cache: Arc<RwLock<HashMap<String, serde_json::Value>>>,
48    ) -> Self {
49        #[cfg(feature = "plugins")]
50        {
51            if enable {
52                let services = Arc::new(EditorServiceBridge {
53                    command_registry: command_registry.clone(),
54                    dir_context,
55                    theme_cache,
56                });
57                match PluginThreadHandle::spawn(services) {
58                    Ok(handle) => {
59                        return Self {
60                            inner: Some(handle),
61                            pending_injected_commands: Vec::new(),
62                        }
63                    }
64                    Err(e) => {
65                        tracing::error!("Failed to spawn TypeScript plugin thread: {}", e);
66                        #[cfg(debug_assertions)]
67                        panic!("TypeScript plugin thread creation failed: {}", e);
68                    }
69                }
70            } else {
71                tracing::info!("Plugins disabled via --no-plugins flag");
72            }
73            Self {
74                inner: None,
75                pending_injected_commands: Vec::new(),
76            }
77        }
78
79        #[cfg(not(feature = "plugins"))]
80        {
81            let _ = command_registry; // Suppress unused warning
82            let _ = dir_context; // Suppress unused warning
83            let _ = theme_cache; // Suppress unused warning
84            if enable {
85                tracing::warn!("Plugins requested but compiled without plugin support");
86            }
87            Self {
88                _phantom: std::marker::PhantomData,
89                pending_injected_commands: Vec::new(),
90            }
91        }
92    }
93
94    /// Inject a [`PluginCommand`](super::api::PluginCommand) into the
95    /// manager's pending queue as if it had arrived from the plugin
96    /// thread. Returned by the next `process_commands()` call.
97    ///
98    /// Intended for tests that need to deterministically reproduce
99    /// renderer/plugin races (e.g. the mid-render `process_commands`
100    /// path in `Editor::render`) without spinning up the real plugin
101    /// runtime. Production code should not call this.
102    pub fn test_inject_command(&mut self, command: super::api::PluginCommand) {
103        self.pending_injected_commands.push(command);
104    }
105
106    /// Check if the plugin system is active (has a running plugin thread,
107    /// or — in tests — has commands queued via [`Self::test_inject_command`]).
108    pub fn is_active(&self) -> bool {
109        if !self.pending_injected_commands.is_empty() {
110            return true;
111        }
112        #[cfg(feature = "plugins")]
113        {
114            self.inner.is_some()
115        }
116        #[cfg(not(feature = "plugins"))]
117        {
118            false
119        }
120    }
121
122    /// Check if the plugin thread is still alive
123    pub fn is_alive(&self) -> bool {
124        #[cfg(feature = "plugins")]
125        {
126            self.inner.as_ref().map(|h| h.is_alive()).unwrap_or(false)
127        }
128        #[cfg(not(feature = "plugins"))]
129        {
130            false
131        }
132    }
133
134    /// Check thread health and panic if the plugin thread died due to a panic.
135    /// This propagates plugin thread panics to the calling thread.
136    /// Call this periodically (e.g., in wait loops) to fail fast on plugin errors.
137    pub fn check_thread_health(&mut self) {
138        #[cfg(feature = "plugins")]
139        {
140            if let Some(ref mut handle) = self.inner {
141                handle.check_thread_health();
142            }
143        }
144    }
145
146    /// Load plugins from a directory.
147    pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
148        #[cfg(feature = "plugins")]
149        {
150            if let Some(ref manager) = self.inner {
151                return manager.load_plugins_from_dir(dir);
152            }
153            Vec::new()
154        }
155        #[cfg(not(feature = "plugins"))]
156        {
157            let _ = dir;
158            Vec::new()
159        }
160    }
161
162    /// Load plugins from a directory with config support.
163    /// Returns (errors, discovered_plugins) where discovered_plugins is a map of
164    /// plugin name -> PluginConfig with paths populated.
165    #[cfg(feature = "plugins")]
166    pub fn load_plugins_from_dir_with_config(
167        &self,
168        dir: &Path,
169        plugin_configs: &HashMap<String, PluginConfig>,
170    ) -> (Vec<String>, HashMap<String, PluginConfig>) {
171        if let Some(ref manager) = self.inner {
172            return manager.load_plugins_from_dir_with_config(dir, plugin_configs);
173        }
174        (Vec::new(), HashMap::new())
175    }
176
177    /// Load plugins from a directory with config support (no-op when plugins disabled).
178    #[cfg(not(feature = "plugins"))]
179    pub fn load_plugins_from_dir_with_config(
180        &self,
181        dir: &Path,
182        plugin_configs: &HashMap<String, PluginConfig>,
183    ) -> (Vec<String>, HashMap<String, PluginConfig>) {
184        let _ = (dir, plugin_configs);
185        (Vec::new(), HashMap::new())
186    }
187
188    /// Unload a plugin by name.
189    pub fn unload_plugin(&self, name: &str) -> anyhow::Result<()> {
190        #[cfg(feature = "plugins")]
191        {
192            self.inner
193                .as_ref()
194                .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
195                .unload_plugin(name)
196        }
197        #[cfg(not(feature = "plugins"))]
198        {
199            let _ = name;
200            Ok(())
201        }
202    }
203
204    /// Load a single plugin by path.
205    pub fn load_plugin(&self, path: &Path) -> anyhow::Result<()> {
206        #[cfg(feature = "plugins")]
207        {
208            self.inner
209                .as_ref()
210                .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
211                .load_plugin(path)
212        }
213        #[cfg(not(feature = "plugins"))]
214        {
215            let _ = path;
216            Ok(())
217        }
218    }
219
220    /// Load a plugin from source code directly (no file I/O).
221    ///
222    /// If a plugin with the same name is already loaded, it will be unloaded first
223    /// (hot-reload semantics). This is used for "Load Plugin from Buffer".
224    pub fn load_plugin_from_source(
225        &self,
226        source: &str,
227        name: &str,
228        is_typescript: bool,
229    ) -> anyhow::Result<()> {
230        #[cfg(feature = "plugins")]
231        {
232            self.inner
233                .as_ref()
234                .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
235                .load_plugin_from_source(source, name, is_typescript)
236        }
237        #[cfg(not(feature = "plugins"))]
238        {
239            let _ = (source, name, is_typescript);
240            Ok(())
241        }
242    }
243
244    /// Run a hook (fire-and-forget).
245    pub fn run_hook(&self, hook_name: &str, args: super::hooks::HookArgs) {
246        #[cfg(feature = "plugins")]
247        {
248            if let Some(ref manager) = self.inner {
249                manager.run_hook(hook_name, args);
250            }
251        }
252        #[cfg(not(feature = "plugins"))]
253        {
254            let _ = (hook_name, args);
255        }
256    }
257
258    /// Deliver a response to a pending async plugin operation.
259    pub fn deliver_response(&self, response: super::api::PluginResponse) {
260        #[cfg(feature = "plugins")]
261        {
262            if let Some(ref manager) = self.inner {
263                manager.deliver_response(response);
264            }
265        }
266        #[cfg(not(feature = "plugins"))]
267        {
268            let _ = response;
269        }
270    }
271
272    /// Process pending plugin commands (non-blocking).
273    pub fn process_commands(&mut self) -> Vec<super::api::PluginCommand> {
274        // Drain any test-injected commands first so they appear at the
275        // front of the returned batch — matching the order the real
276        // plugin thread would have produced if the inject call were a
277        // genuine plugin response.
278        let mut commands = std::mem::take(&mut self.pending_injected_commands);
279        #[cfg(feature = "plugins")]
280        {
281            if let Some(ref mut manager) = self.inner {
282                commands.extend(manager.process_commands());
283            }
284        }
285        commands
286    }
287
288    /// Process commands, blocking until `HookCompleted` for the given hook arrives.
289    /// See [`PluginThreadHandle::process_commands_until_hook_completed`] for details.
290    ///
291    // TODO: This method is currently unused (dead code). Either wire it into the
292    // render path to synchronously wait for plugin responses (e.g. conceals from
293    // lines_changed), or remove it along with PluginThreadHandle's implementation
294    // and the HookCompleted sentinel if the non-blocking drain approach is sufficient.
295    pub fn process_commands_until_hook_completed(
296        &mut self,
297        hook_name: &str,
298        timeout: std::time::Duration,
299    ) -> Vec<super::api::PluginCommand> {
300        #[cfg(feature = "plugins")]
301        {
302            if let Some(ref mut manager) = self.inner {
303                return manager.process_commands_until_hook_completed(hook_name, timeout);
304            }
305            Vec::new()
306        }
307        #[cfg(not(feature = "plugins"))]
308        {
309            let _ = (hook_name, timeout);
310            Vec::new()
311        }
312    }
313
314    /// Get the state snapshot handle for updating editor state.
315    #[cfg(feature = "plugins")]
316    pub fn state_snapshot_handle(&self) -> Option<Arc<RwLock<super::api::EditorStateSnapshot>>> {
317        self.inner.as_ref().map(|m| m.state_snapshot_handle())
318    }
319
320    /// Execute a plugin action asynchronously.
321    #[cfg(feature = "plugins")]
322    pub fn execute_action_async(
323        &self,
324        action_name: &str,
325    ) -> Option<anyhow::Result<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>>>
326    {
327        self.inner
328            .as_ref()
329            .map(|m| m.execute_action_async(action_name))
330    }
331
332    /// List all loaded plugins.
333    #[cfg(feature = "plugins")]
334    pub fn list_plugins(
335        &self,
336    ) -> Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo> {
337        self.inner
338            .as_ref()
339            .map(|m| m.list_plugins())
340            .unwrap_or_default()
341    }
342
343    /// Collect the isolated-declarations `.d.ts` emit of every loaded
344    /// plugin that produced one. Returns `(plugin_name, d_ts_source)`
345    /// pairs — callers use this to assemble `plugins.d.ts`.
346    ///
347    /// Available in all builds: without the `plugins` feature it
348    /// returns an empty vec, letting `editor_init` call this
349    /// unconditionally.
350    pub fn plugin_declarations(&self) -> Vec<(String, String)> {
351        #[cfg(feature = "plugins")]
352        {
353            self.list_plugins()
354                .into_iter()
355                .filter_map(|info| info.declarations.map(|d| (info.name, d)))
356                .collect()
357        }
358        #[cfg(not(feature = "plugins"))]
359        {
360            Vec::new()
361        }
362    }
363
364    /// Reload a plugin by name.
365    #[cfg(feature = "plugins")]
366    pub fn reload_plugin(&self, name: &str) -> anyhow::Result<()> {
367        self.inner
368            .as_ref()
369            .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
370            .reload_plugin(name)
371    }
372
373    /// Submit a "load plugins from dir with config" request without
374    /// blocking. Returns `None` when the plugin runtime is inactive (no
375    /// thread), or when the request couldn't be submitted. Used by the
376    /// startup async-load path.
377    #[cfg(feature = "plugins")]
378    pub fn load_plugins_from_dir_with_config_request(
379        &self,
380        dir: &Path,
381        plugin_configs: &HashMap<String, PluginConfig>,
382    ) -> Option<
383        fresh_plugin_runtime::thread::oneshot::Receiver<
384            fresh_plugin_runtime::thread::PluginsDirLoadResult,
385        >,
386    > {
387        self.inner.as_ref().and_then(|m| {
388            m.load_plugins_from_dir_with_config_request(dir, plugin_configs)
389                .ok()
390        })
391    }
392
393    /// Submit a "load plugin from source" request without blocking.
394    /// Returns `None` when the plugin runtime is inactive.
395    #[cfg(feature = "plugins")]
396    pub fn load_plugin_from_source_request(
397        &self,
398        source: &str,
399        name: &str,
400        is_typescript: bool,
401    ) -> Option<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>> {
402        self.inner.as_ref().and_then(|m| {
403            m.load_plugin_from_source_request(source, name, is_typescript)
404                .ok()
405        })
406    }
407
408    /// Submit a "list plugins" request without blocking. Submitted after
409    /// a batch of dir-load requests, this guarantees the response covers
410    /// every plugin loaded by that batch (FIFO request channel).
411    #[cfg(feature = "plugins")]
412    pub fn list_plugins_request(
413        &self,
414    ) -> Option<
415        fresh_plugin_runtime::thread::oneshot::Receiver<
416            Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo>,
417        >,
418    > {
419        self.inner
420            .as_ref()
421            .and_then(|m| m.list_plugins_request().ok())
422    }
423
424    /// Check if any handlers are registered for a hook.
425    pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
426        #[cfg(feature = "plugins")]
427        {
428            self.inner
429                .as_ref()
430                .map(|m| m.has_hook_handlers(hook_name))
431                .unwrap_or(false)
432        }
433        #[cfg(not(feature = "plugins"))]
434        {
435            let _ = hook_name;
436            false
437        }
438    }
439
440    /// Resolve an async callback in the plugin runtime
441    #[cfg(feature = "plugins")]
442    pub fn resolve_callback(&self, callback_id: super::api::JsCallbackId, result_json: String) {
443        if let Some(inner) = &self.inner {
444            inner.resolve_callback(callback_id, result_json);
445        }
446    }
447
448    /// Resolve an async callback in the plugin runtime (no-op when plugins disabled)
449    #[cfg(not(feature = "plugins"))]
450    pub fn resolve_callback(
451        &self,
452        callback_id: fresh_core::api::JsCallbackId,
453        result_json: String,
454    ) {
455        let _ = (callback_id, result_json);
456    }
457
458    /// Reject an async callback in the plugin runtime
459    #[cfg(feature = "plugins")]
460    pub fn reject_callback(&self, callback_id: super::api::JsCallbackId, error: String) {
461        if let Some(inner) = &self.inner {
462            inner.reject_callback(callback_id, error);
463        }
464    }
465
466    /// Reject an async callback in the plugin runtime (no-op when plugins disabled)
467    #[cfg(not(feature = "plugins"))]
468    pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
469        let _ = (callback_id, error);
470    }
471
472    /// Call a streaming callback with partial data (does not consume the callback).
473    /// When `done` is true, the JS side cleans up.
474    #[cfg(feature = "plugins")]
475    pub fn call_streaming_callback(
476        &self,
477        callback_id: fresh_core::api::JsCallbackId,
478        result_json: String,
479        done: bool,
480    ) {
481        if let Some(inner) = &self.inner {
482            inner.call_streaming_callback(callback_id, result_json, done);
483        }
484    }
485
486    /// Call a streaming callback (no-op when plugins disabled)
487    #[cfg(not(feature = "plugins"))]
488    pub fn call_streaming_callback(
489        &self,
490        callback_id: fresh_core::api::JsCallbackId,
491        result_json: String,
492        done: bool,
493    ) {
494        let _ = (callback_id, result_json, done);
495    }
496}