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