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    /// Run a hook in one plugin's context only (fire-and-forget).
259    /// Handlers registered by other plugins are skipped.
260    pub fn run_hook_for_plugin(&self, plugin: &str, hook_name: &str, args: super::hooks::HookArgs) {
261        #[cfg(feature = "plugins")]
262        {
263            if let Some(ref manager) = self.inner {
264                manager.run_hook_for_plugin(plugin, hook_name, args);
265            }
266        }
267        #[cfg(not(feature = "plugins"))]
268        {
269            let _ = (plugin, hook_name, args);
270        }
271    }
272
273    /// Deliver a response to a pending async plugin operation.
274    pub fn deliver_response(&self, response: super::api::PluginResponse) {
275        #[cfg(feature = "plugins")]
276        {
277            if let Some(ref manager) = self.inner {
278                manager.deliver_response(response);
279            }
280        }
281        #[cfg(not(feature = "plugins"))]
282        {
283            let _ = response;
284        }
285    }
286
287    /// Process pending plugin commands (non-blocking).
288    pub fn process_commands(&mut self) -> Vec<super::api::PluginCommand> {
289        // Drain any test-injected commands first so they appear at the
290        // front of the returned batch — matching the order the real
291        // plugin thread would have produced if the inject call were a
292        // genuine plugin response.
293        let mut commands = std::mem::take(&mut self.pending_injected_commands);
294        #[cfg(feature = "plugins")]
295        {
296            if let Some(ref mut manager) = self.inner {
297                commands.extend(manager.process_commands());
298            }
299        }
300        commands
301    }
302
303    /// Process commands, blocking until `HookCompleted` for the given hook arrives.
304    /// See [`PluginThreadHandle::process_commands_until_hook_completed`] for details.
305    ///
306    // TODO: This method is currently unused (dead code). Either wire it into the
307    // render path to synchronously wait for plugin responses (e.g. conceals from
308    // lines_changed), or remove it along with PluginThreadHandle's implementation
309    // and the HookCompleted sentinel if the non-blocking drain approach is sufficient.
310    pub fn process_commands_until_hook_completed(
311        &mut self,
312        hook_name: &str,
313        timeout: std::time::Duration,
314    ) -> Vec<super::api::PluginCommand> {
315        #[cfg(feature = "plugins")]
316        {
317            if let Some(ref mut manager) = self.inner {
318                return manager.process_commands_until_hook_completed(hook_name, timeout);
319            }
320            Vec::new()
321        }
322        #[cfg(not(feature = "plugins"))]
323        {
324            let _ = (hook_name, timeout);
325            Vec::new()
326        }
327    }
328
329    /// Get the state snapshot handle for updating editor state.
330    #[cfg(feature = "plugins")]
331    pub fn state_snapshot_handle(&self) -> Option<Arc<RwLock<super::api::EditorStateSnapshot>>> {
332        self.inner.as_ref().map(|m| m.state_snapshot_handle())
333    }
334
335    /// Streaming-search handle registry shared with the plugin runtime.
336    /// Producers spawned by `BeginSearch` look up the handle here and write
337    /// directly into its `SearchHandleState`; consumers (the plugin) drain
338    /// the same state via `_searchHandleTake`.
339    #[cfg(feature = "plugins")]
340    pub fn search_handles_handle(&self) -> Option<fresh_core::api::SearchHandleRegistry> {
341        self.inner.as_ref().map(|m| m.search_handles_handle())
342    }
343
344    /// Streaming-search registry accessor (no-op build).
345    #[cfg(not(feature = "plugins"))]
346    pub fn search_handles_handle(&self) -> Option<fresh_core::api::SearchHandleRegistry> {
347        None
348    }
349
350    /// Execute a plugin action asynchronously.
351    #[cfg(feature = "plugins")]
352    pub fn execute_action_async(
353        &self,
354        action_name: &str,
355    ) -> Option<anyhow::Result<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>>>
356    {
357        self.inner
358            .as_ref()
359            .map(|m| m.execute_action_async(action_name))
360    }
361
362    /// List all loaded plugins.
363    #[cfg(feature = "plugins")]
364    pub fn list_plugins(
365        &self,
366    ) -> Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo> {
367        self.inner
368            .as_ref()
369            .map(|m| m.list_plugins())
370            .unwrap_or_default()
371    }
372
373    /// Collect the isolated-declarations `.d.ts` emit of every loaded
374    /// plugin that produced one. Returns `(plugin_name, d_ts_source)`
375    /// pairs — callers use this to assemble `plugins.d.ts`.
376    ///
377    /// Available in all builds: without the `plugins` feature it
378    /// returns an empty vec, letting `editor_init` call this
379    /// unconditionally.
380    pub fn plugin_declarations(&self) -> Vec<(String, String)> {
381        #[cfg(feature = "plugins")]
382        {
383            self.list_plugins()
384                .into_iter()
385                .filter_map(|info| info.declarations.map(|d| (info.name, d)))
386                .collect()
387        }
388        #[cfg(not(feature = "plugins"))]
389        {
390            Vec::new()
391        }
392    }
393
394    /// Reload a plugin by name.
395    #[cfg(feature = "plugins")]
396    pub fn reload_plugin(&self, name: &str) -> anyhow::Result<()> {
397        self.inner
398            .as_ref()
399            .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
400            .reload_plugin(name)
401    }
402
403    /// Submit a "load plugins from dir with config" request without
404    /// blocking. Returns `None` when the plugin runtime is inactive (no
405    /// thread), or when the request couldn't be submitted. Used by the
406    /// startup async-load path.
407    #[cfg(feature = "plugins")]
408    pub fn load_plugins_from_dir_with_config_request(
409        &self,
410        dir: &Path,
411        plugin_configs: &HashMap<String, PluginConfig>,
412    ) -> Option<
413        fresh_plugin_runtime::thread::oneshot::Receiver<
414            fresh_plugin_runtime::thread::PluginsDirLoadResult,
415        >,
416    > {
417        self.inner.as_ref().and_then(|m| {
418            m.load_plugins_from_dir_with_config_request(dir, plugin_configs)
419                .ok()
420        })
421    }
422
423    /// Submit a "load plugin from source" request without blocking.
424    /// Returns `None` when the plugin runtime is inactive.
425    #[cfg(feature = "plugins")]
426    pub fn load_plugin_from_source_request(
427        &self,
428        source: &str,
429        name: &str,
430        is_typescript: bool,
431    ) -> Option<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>> {
432        self.inner.as_ref().and_then(|m| {
433            m.load_plugin_from_source_request(source, name, is_typescript)
434                .ok()
435        })
436    }
437
438    /// Submit a "list plugins" request without blocking. Submitted after
439    /// a batch of dir-load requests, this guarantees the response covers
440    /// every plugin loaded by that batch (FIFO request channel).
441    #[cfg(feature = "plugins")]
442    pub fn list_plugins_request(
443        &self,
444    ) -> Option<
445        fresh_plugin_runtime::thread::oneshot::Receiver<
446            Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo>,
447        >,
448    > {
449        self.inner
450            .as_ref()
451            .and_then(|m| m.list_plugins_request().ok())
452    }
453
454    /// Check if any handlers are registered for a hook.
455    ///
456    /// Blocking call (round-trips through the plugin thread). Suitable for
457    /// rare events (mouse clicks, command dispatch). For per-render gating
458    /// use `has_subscribers` instead — it reads a shared registry directly.
459    pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
460        #[cfg(feature = "plugins")]
461        {
462            self.inner
463                .as_ref()
464                .map(|m| m.has_hook_handlers(hook_name))
465                .unwrap_or(false)
466        }
467        #[cfg(not(feature = "plugins"))]
468        {
469            let _ = hook_name;
470            false
471        }
472    }
473
474    /// Non-blocking variant of `has_hook_handlers`. Reads the shared
475    /// `event_handlers` registry directly — safe to call on the hot
476    /// render path. Returns `false` when plugins are disabled.
477    pub fn has_subscribers(&self, hook_name: &str) -> bool {
478        #[cfg(feature = "plugins")]
479        {
480            self.inner
481                .as_ref()
482                .map(|m| m.has_subscribers(hook_name))
483                .unwrap_or(false)
484        }
485        #[cfg(not(feature = "plugins"))]
486        {
487            let _ = hook_name;
488            false
489        }
490    }
491
492    /// Resolve an async callback in the plugin runtime
493    #[cfg(feature = "plugins")]
494    pub fn resolve_callback(&self, callback_id: super::api::JsCallbackId, result_json: String) {
495        if let Some(inner) = &self.inner {
496            inner.resolve_callback(callback_id, result_json);
497        }
498    }
499
500    /// Resolve an async callback in the plugin runtime (no-op when plugins disabled)
501    #[cfg(not(feature = "plugins"))]
502    pub fn resolve_callback(
503        &self,
504        callback_id: fresh_core::api::JsCallbackId,
505        result_json: String,
506    ) {
507        let _ = (callback_id, result_json);
508    }
509
510    /// Reject an async callback in the plugin runtime
511    #[cfg(feature = "plugins")]
512    pub fn reject_callback(&self, callback_id: super::api::JsCallbackId, error: String) {
513        if let Some(inner) = &self.inner {
514            inner.reject_callback(callback_id, error);
515        }
516    }
517
518    /// Reject an async callback in the plugin runtime (no-op when plugins disabled)
519    #[cfg(not(feature = "plugins"))]
520    pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
521        let _ = (callback_id, error);
522    }
523}