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    /// Streaming-search handle registry shared with the plugin runtime.
321    /// Producers spawned by `BeginSearch` look up the handle here and write
322    /// directly into its `SearchHandleState`; consumers (the plugin) drain
323    /// the same state via `_searchHandleTake`.
324    #[cfg(feature = "plugins")]
325    pub fn search_handles_handle(&self) -> Option<fresh_core::api::SearchHandleRegistry> {
326        self.inner.as_ref().map(|m| m.search_handles_handle())
327    }
328
329    /// Streaming-search registry accessor (no-op build).
330    #[cfg(not(feature = "plugins"))]
331    pub fn search_handles_handle(&self) -> Option<fresh_core::api::SearchHandleRegistry> {
332        None
333    }
334
335    /// Execute a plugin action asynchronously.
336    #[cfg(feature = "plugins")]
337    pub fn execute_action_async(
338        &self,
339        action_name: &str,
340    ) -> Option<anyhow::Result<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>>>
341    {
342        self.inner
343            .as_ref()
344            .map(|m| m.execute_action_async(action_name))
345    }
346
347    /// List all loaded plugins.
348    #[cfg(feature = "plugins")]
349    pub fn list_plugins(
350        &self,
351    ) -> Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo> {
352        self.inner
353            .as_ref()
354            .map(|m| m.list_plugins())
355            .unwrap_or_default()
356    }
357
358    /// Collect the isolated-declarations `.d.ts` emit of every loaded
359    /// plugin that produced one. Returns `(plugin_name, d_ts_source)`
360    /// pairs — callers use this to assemble `plugins.d.ts`.
361    ///
362    /// Available in all builds: without the `plugins` feature it
363    /// returns an empty vec, letting `editor_init` call this
364    /// unconditionally.
365    pub fn plugin_declarations(&self) -> Vec<(String, String)> {
366        #[cfg(feature = "plugins")]
367        {
368            self.list_plugins()
369                .into_iter()
370                .filter_map(|info| info.declarations.map(|d| (info.name, d)))
371                .collect()
372        }
373        #[cfg(not(feature = "plugins"))]
374        {
375            Vec::new()
376        }
377    }
378
379    /// Reload a plugin by name.
380    #[cfg(feature = "plugins")]
381    pub fn reload_plugin(&self, name: &str) -> anyhow::Result<()> {
382        self.inner
383            .as_ref()
384            .ok_or_else(|| anyhow::anyhow!("Plugin system not active"))?
385            .reload_plugin(name)
386    }
387
388    /// Submit a "load plugins from dir with config" request without
389    /// blocking. Returns `None` when the plugin runtime is inactive (no
390    /// thread), or when the request couldn't be submitted. Used by the
391    /// startup async-load path.
392    #[cfg(feature = "plugins")]
393    pub fn load_plugins_from_dir_with_config_request(
394        &self,
395        dir: &Path,
396        plugin_configs: &HashMap<String, PluginConfig>,
397    ) -> Option<
398        fresh_plugin_runtime::thread::oneshot::Receiver<
399            fresh_plugin_runtime::thread::PluginsDirLoadResult,
400        >,
401    > {
402        self.inner.as_ref().and_then(|m| {
403            m.load_plugins_from_dir_with_config_request(dir, plugin_configs)
404                .ok()
405        })
406    }
407
408    /// Submit a "load plugin from source" request without blocking.
409    /// Returns `None` when the plugin runtime is inactive.
410    #[cfg(feature = "plugins")]
411    pub fn load_plugin_from_source_request(
412        &self,
413        source: &str,
414        name: &str,
415        is_typescript: bool,
416    ) -> Option<fresh_plugin_runtime::thread::oneshot::Receiver<anyhow::Result<()>>> {
417        self.inner.as_ref().and_then(|m| {
418            m.load_plugin_from_source_request(source, name, is_typescript)
419                .ok()
420        })
421    }
422
423    /// Submit a "list plugins" request without blocking. Submitted after
424    /// a batch of dir-load requests, this guarantees the response covers
425    /// every plugin loaded by that batch (FIFO request channel).
426    #[cfg(feature = "plugins")]
427    pub fn list_plugins_request(
428        &self,
429    ) -> Option<
430        fresh_plugin_runtime::thread::oneshot::Receiver<
431            Vec<fresh_plugin_runtime::backend::quickjs_backend::TsPluginInfo>,
432        >,
433    > {
434        self.inner
435            .as_ref()
436            .and_then(|m| m.list_plugins_request().ok())
437    }
438
439    /// Check if any handlers are registered for a hook.
440    ///
441    /// Blocking call (round-trips through the plugin thread). Suitable for
442    /// rare events (mouse clicks, command dispatch). For per-render gating
443    /// use `has_subscribers` instead — it reads a shared registry directly.
444    pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
445        #[cfg(feature = "plugins")]
446        {
447            self.inner
448                .as_ref()
449                .map(|m| m.has_hook_handlers(hook_name))
450                .unwrap_or(false)
451        }
452        #[cfg(not(feature = "plugins"))]
453        {
454            let _ = hook_name;
455            false
456        }
457    }
458
459    /// Non-blocking variant of `has_hook_handlers`. Reads the shared
460    /// `event_handlers` registry directly — safe to call on the hot
461    /// render path. Returns `false` when plugins are disabled.
462    pub fn has_subscribers(&self, hook_name: &str) -> bool {
463        #[cfg(feature = "plugins")]
464        {
465            self.inner
466                .as_ref()
467                .map(|m| m.has_subscribers(hook_name))
468                .unwrap_or(false)
469        }
470        #[cfg(not(feature = "plugins"))]
471        {
472            let _ = hook_name;
473            false
474        }
475    }
476
477    /// Resolve an async callback in the plugin runtime
478    #[cfg(feature = "plugins")]
479    pub fn resolve_callback(&self, callback_id: super::api::JsCallbackId, result_json: String) {
480        if let Some(inner) = &self.inner {
481            inner.resolve_callback(callback_id, result_json);
482        }
483    }
484
485    /// Resolve an async callback in the plugin runtime (no-op when plugins disabled)
486    #[cfg(not(feature = "plugins"))]
487    pub fn resolve_callback(
488        &self,
489        callback_id: fresh_core::api::JsCallbackId,
490        result_json: String,
491    ) {
492        let _ = (callback_id, result_json);
493    }
494
495    /// Reject an async callback in the plugin runtime
496    #[cfg(feature = "plugins")]
497    pub fn reject_callback(&self, callback_id: super::api::JsCallbackId, error: String) {
498        if let Some(inner) = &self.inner {
499            inner.reject_callback(callback_id, error);
500        }
501    }
502
503    /// Reject an async callback in the plugin runtime (no-op when plugins disabled)
504    #[cfg(not(feature = "plugins"))]
505    pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
506        let _ = (callback_id, error);
507    }
508}