Skip to main content

fresh_plugin_runtime/
thread.rs

1//! Plugin Thread: Dedicated thread for TypeScript plugin execution
2//!
3//! This module implements a dedicated thread architecture for plugin execution,
4//! using QuickJS as the JavaScript runtime with oxc for TypeScript transpilation.
5//!
6//! Architecture:
7//! - Main thread (UI) sends requests to plugin thread via channel
8//! - Plugin thread owns QuickJS runtime and persistent tokio runtime
9//! - Results are sent back via the existing PluginCommand channel
10//! - Async operations complete naturally without runtime destruction
11
12use crate::backend::quickjs_backend::{AsyncResourceOwners, PendingResponses, TsPluginInfo};
13use crate::backend::QuickJsBackend;
14use anyhow::{anyhow, Result};
15use fresh_core::api::{EditorStateSnapshot, JsCallbackId, PluginCommand, SearchHandleRegistry};
16use fresh_core::hooks::HookArgs;
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::rc::Rc;
21use std::sync::{Arc, RwLock};
22use std::thread::{self, JoinHandle};
23use std::time::Duration;
24
25// Re-export PluginConfig from fresh-core
26pub use fresh_core::config::PluginConfig;
27
28/// Consume and discard a `Result` from a fire-and-forget channel send.
29///
30/// Use when the receiver may have been dropped (e.g. during shutdown) and
31/// failure is expected and non-actionable.
32fn fire_and_forget<T, E: std::fmt::Debug>(result: std::result::Result<T, E>) {
33    if let Err(e) = result {
34        tracing::trace!(error = ?e, "fire-and-forget send failed");
35    }
36}
37
38/// Result type for `LoadPluginsFromDirWithConfig`: `(load errors,
39/// discovered plugins keyed by name)`. Aliased so the non-blocking
40/// `_request` helpers can return a clippy-tractable receiver type.
41pub type PluginsDirLoadResult = (Vec<String>, HashMap<String, PluginConfig>);
42
43/// Request messages sent to the plugin thread
44#[derive(Debug)]
45pub enum PluginRequest {
46    /// Load a plugin from a file
47    LoadPlugin {
48        path: PathBuf,
49        response: oneshot::Sender<Result<()>>,
50    },
51
52    /// Resolve an async callback with a result (for async operations like SpawnProcess, Delay)
53    ResolveCallback {
54        callback_id: fresh_core::api::JsCallbackId,
55        result_json: String,
56    },
57
58    /// Reject an async callback with an error
59    RejectCallback {
60        callback_id: fresh_core::api::JsCallbackId,
61        error: String,
62    },
63
64    /// Load all plugins from a directory
65    LoadPluginsFromDir {
66        dir: PathBuf,
67        response: oneshot::Sender<Vec<String>>,
68    },
69
70    /// Load all plugins from a directory with config support
71    /// Returns (errors, discovered_plugins) where discovered_plugins contains
72    /// all found plugins with their paths and enabled status
73    LoadPluginsFromDirWithConfig {
74        dir: PathBuf,
75        plugin_configs: HashMap<String, PluginConfig>,
76        response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
77    },
78
79    /// Load a plugin from source code (no file I/O)
80    LoadPluginFromSource {
81        source: String,
82        name: String,
83        is_typescript: bool,
84        response: oneshot::Sender<Result<()>>,
85    },
86
87    /// Unload a plugin by name
88    UnloadPlugin {
89        name: String,
90        response: oneshot::Sender<Result<()>>,
91    },
92
93    /// Reload a plugin by name
94    ReloadPlugin {
95        name: String,
96        response: oneshot::Sender<Result<()>>,
97    },
98
99    /// Execute a plugin action
100    ExecuteAction {
101        action_name: String,
102        response: oneshot::Sender<Result<()>>,
103    },
104
105    /// Run a hook (fire-and-forget, no response needed). When `target`
106    /// is set, only that plugin's handlers run — used for events that
107    /// belong to one plugin, like a panel's `widget_event`.
108    RunHook {
109        hook_name: String,
110        args: HookArgs,
111        target: Option<String>,
112    },
113
114    /// Check if any handlers are registered for a hook
115    HasHookHandlers {
116        hook_name: String,
117        response: oneshot::Sender<bool>,
118    },
119
120    /// List all loaded plugins
121    ListPlugins {
122        response: oneshot::Sender<Vec<TsPluginInfo>>,
123    },
124
125    /// Track an async resource (buffer/terminal) that was just created.
126    /// Sent by deliver_response when the editor confirms resource creation.
127    TrackAsyncResource {
128        plugin_name: String,
129        resource: TrackedAsyncResource,
130    },
131
132    /// Shutdown the plugin thread
133    Shutdown,
134}
135
136/// An async resource whose creation was confirmed by the editor.
137/// Used to update plugin_tracked_state for cleanup on unload.
138#[derive(Debug)]
139pub enum TrackedAsyncResource {
140    VirtualBuffer(fresh_core::BufferId),
141    CompositeBuffer(fresh_core::BufferId),
142    Terminal(fresh_core::TerminalId),
143    WatchHandle(u64),
144}
145
146/// Simple oneshot channel implementation
147pub mod oneshot {
148    use std::fmt;
149    use std::sync::mpsc;
150
151    pub struct Sender<T>(mpsc::SyncSender<T>);
152    pub struct Receiver<T>(mpsc::Receiver<T>);
153
154    use anyhow::Result;
155
156    impl<T> fmt::Debug for Sender<T> {
157        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158            f.debug_tuple("Sender").finish()
159        }
160    }
161
162    impl<T> fmt::Debug for Receiver<T> {
163        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164            f.debug_tuple("Receiver").finish()
165        }
166    }
167
168    impl<T> Sender<T> {
169        pub fn send(self, value: T) -> Result<(), T> {
170            self.0.send(value).map_err(|e| e.0)
171        }
172    }
173
174    impl<T> Receiver<T> {
175        pub fn recv(self) -> Result<T, mpsc::RecvError> {
176            self.0.recv()
177        }
178
179        pub fn recv_timeout(
180            self,
181            timeout: std::time::Duration,
182        ) -> Result<T, mpsc::RecvTimeoutError> {
183            self.0.recv_timeout(timeout)
184        }
185
186        pub fn try_recv(&self) -> Result<T, mpsc::TryRecvError> {
187            self.0.try_recv()
188        }
189    }
190
191    pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
192        let (tx, rx) = mpsc::sync_channel(1);
193        (Sender(tx), Receiver(rx))
194    }
195}
196
197/// Handle to the plugin thread for sending requests
198pub struct PluginThreadHandle {
199    /// Channel to send requests to the plugin thread
200    /// Wrapped in Option so we can drop it to signal shutdown
201    request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
202
203    /// Thread join handle
204    thread_handle: Option<JoinHandle<()>>,
205
206    /// State snapshot handle for editor to update
207    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
208
209    /// Pending response senders for async operations (shared with runtime)
210    pending_responses: PendingResponses,
211
212    /// Receiver for plugin commands (polled by editor directly)
213    command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
214
215    /// Shared map of request_id → plugin_name for async resource creations.
216    /// JsEditorApi inserts entries at creation time; deliver_response reads them
217    /// when the editor confirms resource creation to track the actual IDs.
218    async_resource_owners: AsyncResourceOwners,
219
220    /// Streaming-search handle registry. JsEditorApi's `_beginSearch`
221    /// inserts an `Arc<SearchHandleState>`; the editor's `BeginSearch`
222    /// handler looks it up by handle id so its parallel searcher tasks
223    /// write directly into the same shared state the JS side drains via
224    /// `_searchHandleTake`.
225    search_handles: SearchHandleRegistry,
226
227    /// Shared registry of `event_handlers` so the editor thread can
228    /// cheaply ask "does any plugin subscribe to hook X?" before doing
229    /// expensive per-render work (e.g. building hook args). See
230    /// `EventHandlerRegistry` in `quickjs_backend.rs`.
231    event_handlers: crate::backend::quickjs_backend::EventHandlerRegistry,
232}
233
234impl PluginThreadHandle {
235    /// Create a new plugin thread and return its handle
236    pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
237        tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
238
239        // Create channel for plugin commands
240        let (command_sender, command_receiver) = std::sync::mpsc::channel();
241
242        // Create editor state snapshot for query API
243        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
244
245        // Create pending responses map (shared between handle and runtime)
246        let pending_responses: PendingResponses =
247            Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
248        let thread_pending_responses = Arc::clone(&pending_responses);
249
250        // Create async resource owners map (shared between handle and runtime)
251        let async_resource_owners: AsyncResourceOwners =
252            Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
253        let thread_async_resource_owners = Arc::clone(&async_resource_owners);
254
255        // Streaming-search handle registry shared with the editor thread.
256        let search_handles: SearchHandleRegistry =
257            Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
258        let thread_search_handles = Arc::clone(&search_handles);
259
260        // Plugin event-handler registry, shared with the editor thread so
261        // the renderer can skip expensive hook arg building when no plugin
262        // subscribes.
263        let event_handlers: crate::backend::quickjs_backend::EventHandlerRegistry =
264            Arc::new(RwLock::new(std::collections::HashMap::new()));
265        let thread_event_handlers = Arc::clone(&event_handlers);
266
267        // Create channel for requests (unbounded allows sync send, async recv)
268        let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
269
270        // Clone state snapshot for the thread
271        let thread_state_snapshot = Arc::clone(&state_snapshot);
272
273        // Spawn the plugin thread
274        tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
275        let thread_handle = thread::spawn(move || {
276            tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
277            // Create tokio runtime for the plugin thread
278            let rt = match tokio::runtime::Builder::new_current_thread()
279                .enable_all()
280                .build()
281            {
282                Ok(rt) => {
283                    tracing::debug!("Plugin thread: tokio runtime created successfully");
284                    rt
285                }
286                Err(e) => {
287                    tracing::error!("Failed to create plugin thread runtime: {}", e);
288                    return;
289                }
290            };
291
292            // Create QuickJS runtime with state
293            tracing::debug!("Plugin thread: creating QuickJS runtime");
294            let runtime = match QuickJsBackend::with_state_responses_and_resources(
295                Arc::clone(&thread_state_snapshot),
296                command_sender,
297                thread_pending_responses,
298                services.clone(),
299                thread_async_resource_owners,
300                thread_search_handles,
301                thread_event_handlers,
302            ) {
303                Ok(rt) => {
304                    tracing::debug!("Plugin thread: QuickJS runtime created successfully");
305                    rt
306                }
307                Err(e) => {
308                    tracing::error!("Failed to create QuickJS runtime: {}", e);
309                    return;
310                }
311            };
312
313            // Create internal manager state
314            let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
315
316            // Run the event loop with a LocalSet to allow concurrent task execution
317            tracing::debug!("Plugin thread: starting event loop with LocalSet");
318            let local = tokio::task::LocalSet::new();
319            local.block_on(&rt, async {
320                // Wrap runtime in RefCell for interior mutability during concurrent operations
321                let runtime = Rc::new(RefCell::new(runtime));
322                tracing::debug!("Plugin thread: entering plugin_thread_loop");
323                plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
324            });
325
326            tracing::info!("Plugin thread shutting down");
327        });
328
329        tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
330        tracing::info!("Plugin thread spawned");
331
332        Ok(Self {
333            request_sender: Some(request_sender),
334            thread_handle: Some(thread_handle),
335            state_snapshot,
336            pending_responses,
337            command_receiver,
338            async_resource_owners,
339            search_handles,
340            event_handlers,
341        })
342    }
343
344    /// Accessor for the streaming-search handle registry.
345    pub fn search_handles_handle(&self) -> SearchHandleRegistry {
346        Arc::clone(&self.search_handles)
347    }
348
349    /// Non-blocking check: does any loaded plugin subscribe to `hook_name`?
350    /// Used by the renderer to skip building expensive hook args (e.g.
351    /// the full tokenized viewport for `view_transform_request`) when
352    /// nothing would consume them. Reads from the shared
353    /// `event_handlers` registry directly — no channel round-trip.
354    pub fn has_subscribers(&self, hook_name: &str) -> bool {
355        self.event_handlers
356            .read()
357            .map(|h| h.get(hook_name).is_some_and(|v| !v.is_empty()))
358            .unwrap_or(false)
359    }
360
361    /// Check if the plugin thread is still alive
362    pub fn is_alive(&self) -> bool {
363        self.thread_handle
364            .as_ref()
365            .map(|h| !h.is_finished())
366            .unwrap_or(false)
367    }
368
369    /// Check thread health and panic if the plugin thread died due to a panic.
370    /// This propagates plugin thread panics to the calling thread.
371    /// Call this periodically to detect plugin thread failures.
372    pub fn check_thread_health(&mut self) {
373        if let Some(handle) = &self.thread_handle {
374            if handle.is_finished() {
375                tracing::error!(
376                    "check_thread_health: plugin thread is finished, checking for panic"
377                );
378                // Thread finished - take ownership and check result
379                if let Some(handle) = self.thread_handle.take() {
380                    match handle.join() {
381                        Ok(()) => {
382                            tracing::warn!("Plugin thread exited normally (unexpected)");
383                        }
384                        Err(panic_payload) => {
385                            // Re-panic with the original panic message to propagate it
386                            std::panic::resume_unwind(panic_payload);
387                        }
388                    }
389                }
390            }
391        }
392    }
393
394    /// Deliver a response to a pending async operation in the plugin
395    ///
396    /// This is called by the editor after processing a command that requires a response.
397    pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
398        // First try to find a pending Rust request (oneshot channel)
399        if respond_to_pending(&self.pending_responses, response.clone()) {
400            return;
401        }
402
403        // If not found, it must be a JS callback
404        use fresh_core::api::PluginResponse;
405
406        match response {
407            PluginResponse::VirtualBufferCreated {
408                request_id,
409                buffer_id,
410                split_id,
411            } => {
412                // Track the created buffer for cleanup on plugin unload
413                self.track_async_resource(
414                    request_id,
415                    TrackedAsyncResource::VirtualBuffer(buffer_id),
416                );
417                // Return an object with bufferId and splitId (camelCase for JS)
418                let result = serde_json::json!({
419                    "bufferId": buffer_id.0,
420                    "splitId": split_id.map(|s| s.0)
421                });
422                self.resolve_callback(JsCallbackId(request_id), result.to_string());
423            }
424            PluginResponse::LspRequest { request_id, result } => match result {
425                Ok(value) => {
426                    self.resolve_callback(JsCallbackId(request_id), value.to_string());
427                }
428                Err(e) => {
429                    self.reject_callback(JsCallbackId(request_id), e);
430                }
431            },
432            PluginResponse::HighlightsComputed { request_id, spans } => {
433                self.resolve_json_callback(request_id, &spans, "[]");
434            }
435            PluginResponse::BufferText { request_id, text } => match text {
436                Ok(content) => {
437                    // JSON stringify the content string
438                    let result =
439                        serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
440                    self.resolve_callback(JsCallbackId(request_id), result);
441                }
442                Err(e) => {
443                    self.reject_callback(JsCallbackId(request_id), e);
444                }
445            },
446            PluginResponse::CompositeBufferCreated {
447                request_id,
448                buffer_id,
449            } => {
450                // Track the created buffer for cleanup on plugin unload
451                self.track_async_resource(
452                    request_id,
453                    TrackedAsyncResource::CompositeBuffer(buffer_id),
454                );
455                // Return just the buffer_id number, not an object
456                self.resolve_callback(JsCallbackId(request_id), buffer_id.0.to_string());
457            }
458            PluginResponse::LineStartPosition {
459                request_id,
460                position,
461            } => {
462                self.resolve_json_callback(request_id, position, "null");
463            }
464            PluginResponse::LineEndPosition {
465                request_id,
466                position,
467            } => {
468                self.resolve_json_callback(request_id, position, "null");
469            }
470            PluginResponse::BufferLineCount { request_id, count } => {
471                self.resolve_json_callback(request_id, count, "null");
472            }
473            PluginResponse::TerminalCreated {
474                request_id,
475                buffer_id,
476                terminal_id,
477                split_id,
478            } => {
479                // Track the created terminal for cleanup on plugin unload
480                self.track_async_resource(request_id, TrackedAsyncResource::Terminal(terminal_id));
481                let result = serde_json::json!({
482                    "bufferId": buffer_id.0,
483                    "terminalId": terminal_id.0,
484                    "splitId": split_id.map(|s| s.0)
485                });
486                self.resolve_callback(JsCallbackId(request_id), result.to_string());
487            }
488            PluginResponse::SplitByLabel {
489                request_id,
490                split_id,
491            } => {
492                self.resolve_json_callback(request_id, split_id.map(|s| s.0), "null");
493            }
494            PluginResponse::WatchPathRegistered { request_id, result } => match result {
495                Ok(handle) => {
496                    self.track_async_resource(
497                        request_id,
498                        TrackedAsyncResource::WatchHandle(handle),
499                    );
500                    self.resolve_callback(JsCallbackId(request_id), handle.to_string());
501                }
502                Err(e) => {
503                    self.reject_callback(JsCallbackId(request_id), e);
504                }
505            },
506        }
507    }
508
509    /// Serialize `value` to JSON and resolve a JS callback with the result.
510    /// Uses `fallback` as the JSON string if serialization fails.
511    fn resolve_json_callback(&self, request_id: u64, value: impl serde::Serialize, fallback: &str) {
512        let result = serde_json::to_string(&value).unwrap_or_else(|_| fallback.to_string());
513        self.resolve_callback(JsCallbackId(request_id), result);
514    }
515
516    /// Look up the plugin that owns a request_id and send a TrackAsyncResource
517    /// request to the plugin thread so it can update plugin_tracked_state.
518    fn track_async_resource(&self, request_id: u64, resource: TrackedAsyncResource) {
519        let plugin_name = self
520            .async_resource_owners
521            .lock()
522            .ok()
523            .and_then(|mut owners| owners.remove(&request_id));
524        if let Some(plugin_name) = plugin_name {
525            if let Some(sender) = self.request_sender.as_ref() {
526                fire_and_forget(sender.send(PluginRequest::TrackAsyncResource {
527                    plugin_name,
528                    resource,
529                }));
530            }
531        }
532    }
533
534    /// Load a plugin from a file (blocking)
535    pub fn load_plugin(&self, path: &Path) -> Result<()> {
536        let (tx, rx) = oneshot::channel();
537        self.request_sender
538            .as_ref()
539            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
540            .send(PluginRequest::LoadPlugin {
541                path: path.to_path_buf(),
542                response: tx,
543            })
544            .map_err(|_| anyhow!("Plugin thread not responding"))?;
545
546        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
547    }
548
549    /// Load all plugins from a directory (blocking)
550    pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
551        let (tx, rx) = oneshot::channel();
552        let Some(sender) = self.request_sender.as_ref() else {
553            return vec!["Plugin thread shut down".to_string()];
554        };
555        if sender
556            .send(PluginRequest::LoadPluginsFromDir {
557                dir: dir.to_path_buf(),
558                response: tx,
559            })
560            .is_err()
561        {
562            return vec!["Plugin thread not responding".to_string()];
563        }
564
565        rx.recv()
566            .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
567    }
568
569    /// Load all plugins from a directory with config support (blocking)
570    /// Returns (errors, discovered_plugins) where discovered_plugins is a map of
571    /// plugin name -> PluginConfig with paths populated.
572    pub fn load_plugins_from_dir_with_config(
573        &self,
574        dir: &Path,
575        plugin_configs: &HashMap<String, PluginConfig>,
576    ) -> (Vec<String>, HashMap<String, PluginConfig>) {
577        let (tx, rx) = oneshot::channel();
578        let Some(sender) = self.request_sender.as_ref() else {
579            return (vec!["Plugin thread shut down".to_string()], HashMap::new());
580        };
581        if sender
582            .send(PluginRequest::LoadPluginsFromDirWithConfig {
583                dir: dir.to_path_buf(),
584                plugin_configs: plugin_configs.clone(),
585                response: tx,
586            })
587            .is_err()
588        {
589            return (
590                vec!["Plugin thread not responding".to_string()],
591                HashMap::new(),
592            );
593        }
594
595        rx.recv()
596            .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
597    }
598
599    /// Load a plugin from source code directly (blocking).
600    ///
601    /// If a plugin with the same name is already loaded, it will be unloaded first
602    /// (hot-reload semantics).
603    pub fn load_plugin_from_source(
604        &self,
605        source: &str,
606        name: &str,
607        is_typescript: bool,
608    ) -> Result<()> {
609        let (tx, rx) = oneshot::channel();
610        self.request_sender
611            .as_ref()
612            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
613            .send(PluginRequest::LoadPluginFromSource {
614                source: source.to_string(),
615                name: name.to_string(),
616                is_typescript,
617                response: tx,
618            })
619            .map_err(|_| anyhow!("Plugin thread not responding"))?;
620
621        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
622    }
623
624    /// Unload a plugin (blocking)
625    pub fn unload_plugin(&self, name: &str) -> Result<()> {
626        let (tx, rx) = oneshot::channel();
627        self.request_sender
628            .as_ref()
629            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
630            .send(PluginRequest::UnloadPlugin {
631                name: name.to_string(),
632                response: tx,
633            })
634            .map_err(|_| anyhow!("Plugin thread not responding"))?;
635
636        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
637    }
638
639    /// Reload a plugin (blocking)
640    pub fn reload_plugin(&self, name: &str) -> Result<()> {
641        let (tx, rx) = oneshot::channel();
642        self.request_sender
643            .as_ref()
644            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
645            .send(PluginRequest::ReloadPlugin {
646                name: name.to_string(),
647                response: tx,
648            })
649            .map_err(|_| anyhow!("Plugin thread not responding"))?;
650
651        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
652    }
653
654    /// Execute a plugin action (non-blocking)
655    ///
656    /// Returns a receiver that will receive the result when the action completes.
657    /// The caller should poll this while processing commands to avoid deadlock.
658    pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
659        tracing::trace!("execute_action_async: starting action '{}'", action_name);
660        let (tx, rx) = oneshot::channel();
661        self.request_sender
662            .as_ref()
663            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
664            .send(PluginRequest::ExecuteAction {
665                action_name: action_name.to_string(),
666                response: tx,
667            })
668            .map_err(|_| anyhow!("Plugin thread not responding"))?;
669
670        tracing::trace!("execute_action_async: request sent for '{}'", action_name);
671        Ok(rx)
672    }
673
674    /// Run a hook (non-blocking, fire-and-forget)
675    ///
676    /// This is the key improvement: hooks are now non-blocking.
677    /// The plugin thread will execute them asynchronously and
678    /// any results will come back via the PluginCommand channel.
679    pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
680        if let Some(sender) = self.request_sender.as_ref() {
681            fire_and_forget(sender.send(PluginRequest::RunHook {
682                hook_name: hook_name.to_string(),
683                args,
684                target: None,
685            }));
686        }
687    }
688
689    /// Run a hook in a single plugin's context only (non-blocking,
690    /// fire-and-forget). Handlers registered by other plugins are
691    /// skipped.
692    pub fn run_hook_for_plugin(&self, plugin: &str, hook_name: &str, args: HookArgs) {
693        if let Some(sender) = self.request_sender.as_ref() {
694            fire_and_forget(sender.send(PluginRequest::RunHook {
695                hook_name: hook_name.to_string(),
696                args,
697                target: Some(plugin.to_string()),
698            }));
699        }
700    }
701
702    /// Check if any handlers are registered for a hook (blocking)
703    pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
704        let (tx, rx) = oneshot::channel();
705        let Some(sender) = self.request_sender.as_ref() else {
706            return false;
707        };
708        if sender
709            .send(PluginRequest::HasHookHandlers {
710                hook_name: hook_name.to_string(),
711                response: tx,
712            })
713            .is_err()
714        {
715            return false;
716        }
717
718        rx.recv().unwrap_or(false)
719    }
720
721    /// List all loaded plugins (blocking)
722    pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
723        let (tx, rx) = oneshot::channel();
724        let Some(sender) = self.request_sender.as_ref() else {
725            return vec![];
726        };
727        if sender
728            .send(PluginRequest::ListPlugins { response: tx })
729            .is_err()
730        {
731            return vec![];
732        }
733
734        rx.recv().unwrap_or_default()
735    }
736
737    /// Submit a "load plugins from dir with config" request without blocking.
738    /// Returns the response receiver for the caller to await elsewhere
739    /// (typically a forwarder thread that bridges to `AsyncBridge`).
740    pub fn load_plugins_from_dir_with_config_request(
741        &self,
742        dir: &Path,
743        plugin_configs: &HashMap<String, PluginConfig>,
744    ) -> Result<oneshot::Receiver<PluginsDirLoadResult>> {
745        let (tx, rx) = oneshot::channel();
746        self.request_sender
747            .as_ref()
748            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
749            .send(PluginRequest::LoadPluginsFromDirWithConfig {
750                dir: dir.to_path_buf(),
751                plugin_configs: plugin_configs.clone(),
752                response: tx,
753            })
754            .map_err(|_| anyhow!("Plugin thread not responding"))?;
755        Ok(rx)
756    }
757
758    /// Submit a "load plugin from source" request without blocking.
759    /// Returns the response receiver.
760    pub fn load_plugin_from_source_request(
761        &self,
762        source: &str,
763        name: &str,
764        is_typescript: bool,
765    ) -> Result<oneshot::Receiver<Result<()>>> {
766        let (tx, rx) = oneshot::channel();
767        self.request_sender
768            .as_ref()
769            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
770            .send(PluginRequest::LoadPluginFromSource {
771                source: source.to_string(),
772                name: name.to_string(),
773                is_typescript,
774                response: tx,
775            })
776            .map_err(|_| anyhow!("Plugin thread not responding"))?;
777        Ok(rx)
778    }
779
780    /// Submit a "list plugins" request without blocking. The plugin thread
781    /// processes requests FIFO, so submitting this immediately after a
782    /// batch of `LoadPluginsFromDirWithConfig` guarantees the response
783    /// reflects all of those loads having completed.
784    pub fn list_plugins_request(&self) -> Result<oneshot::Receiver<Vec<TsPluginInfo>>> {
785        let (tx, rx) = oneshot::channel();
786        self.request_sender
787            .as_ref()
788            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
789            .send(PluginRequest::ListPlugins { response: tx })
790            .map_err(|_| anyhow!("Plugin thread not responding"))?;
791        Ok(rx)
792    }
793
794    /// Process pending plugin commands (non-blocking)
795    ///
796    /// Returns immediately with any pending commands by polling the command queue directly.
797    /// This does not require the plugin thread to respond, avoiding deadlocks.
798    pub fn process_commands(&mut self) -> Vec<PluginCommand> {
799        let mut commands = Vec::new();
800        while let Ok(cmd) = self.command_receiver.try_recv() {
801            commands.push(cmd);
802        }
803        commands
804    }
805
806    /// Process commands, blocking until `HookCompleted` for the given hook arrives.
807    ///
808    /// After the render loop fires a hook like `lines_changed`, the plugin thread
809    /// processes it and sends back commands (AddConceal, etc.) followed by a
810    /// `HookCompleted` sentinel. This method waits for that sentinel so the
811    /// render has all conceal/overlay updates before painting the frame.
812    ///
813    /// Returns all non-sentinel commands collected while waiting.
814    /// Falls back to non-blocking drain if the timeout expires.
815    pub fn process_commands_until_hook_completed(
816        &mut self,
817        hook_name: &str,
818        timeout: std::time::Duration,
819    ) -> Vec<PluginCommand> {
820        let mut commands = Vec::new();
821        let deadline = std::time::Instant::now() + timeout;
822
823        loop {
824            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
825            if remaining.is_zero() {
826                // Timeout: drain whatever is available
827                while let Ok(cmd) = self.command_receiver.try_recv() {
828                    if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
829                        commands.push(cmd);
830                    }
831                }
832                break;
833            }
834
835            match self.command_receiver.recv_timeout(remaining) {
836                Ok(PluginCommand::HookCompleted {
837                    hook_name: ref name,
838                }) if name == hook_name => {
839                    // Got our sentinel — drain any remaining commands
840                    while let Ok(cmd) = self.command_receiver.try_recv() {
841                        if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
842                            commands.push(cmd);
843                        }
844                    }
845                    break;
846                }
847                Ok(PluginCommand::HookCompleted { .. }) => {
848                    // Sentinel for a different hook, keep waiting
849                    continue;
850                }
851                Ok(cmd) => {
852                    commands.push(cmd);
853                }
854                Err(_) => {
855                    // Timeout or disconnected
856                    break;
857                }
858            }
859        }
860
861        commands
862    }
863
864    /// Get the state snapshot handle for editor to update
865    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
866        Arc::clone(&self.state_snapshot)
867    }
868
869    /// Shutdown the plugin thread
870    pub fn shutdown(&mut self) {
871        tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
872
873        // Drop all pending response senders - this wakes up any plugin code waiting for responses
874        // by causing their oneshot receivers to return an error
875        if let Ok(mut pending) = self.pending_responses.lock() {
876            if !pending.is_empty() {
877                tracing::warn!(
878                    "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
879                    pending.len(),
880                    pending.keys().collect::<Vec<_>>()
881                );
882                pending.clear(); // Drop all senders, waking up waiting receivers
883            }
884        }
885
886        // First send a Shutdown request to allow clean processing of pending work
887        if let Some(sender) = self.request_sender.as_ref() {
888            tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
889            fire_and_forget(sender.send(PluginRequest::Shutdown));
890        }
891
892        // Then drop the sender to close the channel - this reliably wakes the receiver
893        // even when it's parked in a tokio LocalSet (the Shutdown message above may not wake it)
894        tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
895        self.request_sender.take();
896
897        if let Some(handle) = self.thread_handle.take() {
898            tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
899            if handle.join().is_err() {
900                tracing::trace!("plugin thread panicked during join");
901            }
902            tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
903        }
904
905        tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
906    }
907
908    /// Resolve an async callback in the plugin runtime
909    /// Called by the app when async operations (SpawnProcess, Delay) complete
910    pub fn resolve_callback(
911        &self,
912        callback_id: fresh_core::api::JsCallbackId,
913        result_json: String,
914    ) {
915        if let Some(sender) = self.request_sender.as_ref() {
916            fire_and_forget(sender.send(PluginRequest::ResolveCallback {
917                callback_id,
918                result_json,
919            }));
920        }
921    }
922
923    /// Reject an async callback in the plugin runtime
924    /// Called by the app when async operations fail
925    pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
926        if let Some(sender) = self.request_sender.as_ref() {
927            fire_and_forget(sender.send(PluginRequest::RejectCallback { callback_id, error }));
928        }
929    }
930}
931
932impl Drop for PluginThreadHandle {
933    fn drop(&mut self) {
934        self.shutdown();
935    }
936}
937
938fn respond_to_pending(
939    pending_responses: &PendingResponses,
940    response: fresh_core::api::PluginResponse,
941) -> bool {
942    let request_id = response.request_id();
943    let sender = {
944        let mut pending = pending_responses.lock().unwrap();
945        pending.remove(&request_id)
946    };
947
948    if let Some(tx) = sender {
949        fire_and_forget(tx.send(response));
950        true
951    } else {
952        false
953    }
954}
955
956#[cfg(test)]
957mod plugin_thread_tests {
958    use super::*;
959    use fresh_core::api::PluginResponse;
960    use serde_json::json;
961    use std::collections::HashMap;
962    use std::sync::{Arc, Mutex};
963    use tokio::sync::oneshot;
964
965    #[test]
966    fn respond_to_pending_sends_lsp_response() {
967        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
968        let (tx, mut rx) = oneshot::channel();
969        pending.lock().unwrap().insert(123, tx);
970
971        respond_to_pending(
972            &pending,
973            PluginResponse::LspRequest {
974                request_id: 123,
975                result: Ok(json!({ "key": "value" })),
976            },
977        );
978
979        let response = rx.try_recv().expect("expected response");
980        match response {
981            PluginResponse::LspRequest { result, .. } => {
982                assert_eq!(result.unwrap(), json!({ "key": "value" }));
983            }
984            _ => panic!("unexpected variant"),
985        }
986
987        assert!(pending.lock().unwrap().is_empty());
988    }
989
990    #[test]
991    fn respond_to_pending_handles_virtual_buffer_created() {
992        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
993        let (tx, mut rx) = oneshot::channel();
994        pending.lock().unwrap().insert(456, tx);
995
996        respond_to_pending(
997            &pending,
998            PluginResponse::VirtualBufferCreated {
999                request_id: 456,
1000                buffer_id: fresh_core::BufferId(7),
1001                split_id: Some(fresh_core::SplitId(1)),
1002            },
1003        );
1004
1005        let response = rx.try_recv().expect("expected response");
1006        match response {
1007            PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
1008                assert_eq!(buffer_id.0, 7);
1009            }
1010            _ => panic!("unexpected variant"),
1011        }
1012
1013        assert!(pending.lock().unwrap().is_empty());
1014    }
1015}
1016
1017/// Main loop for the plugin thread
1018///
1019/// Uses `tokio::select!` to interleave request handling with periodic event loop
1020/// polling. This allows long-running promises (like process spawns) to make progress
1021/// even when no requests are coming in, preventing the UI from getting stuck.
1022async fn plugin_thread_loop(
1023    runtime: Rc<RefCell<QuickJsBackend>>,
1024    plugins: &mut HashMap<String, TsPluginInfo>,
1025    mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
1026) {
1027    tracing::info!("Plugin thread event loop started");
1028
1029    // Interval for polling the JS event loop when there's pending work
1030    let poll_interval = Duration::from_millis(1);
1031    let mut has_pending_work = false;
1032
1033    loop {
1034        // Check for fatal JS errors (e.g., unhandled promise rejections in test mode)
1035        // These are set via set_fatal_js_error() because panicking inside FFI callbacks
1036        // is caught by rquickjs and doesn't terminate the thread.
1037        if crate::backend::has_fatal_js_error() {
1038            if let Some(error_msg) = crate::backend::take_fatal_js_error() {
1039                tracing::error!(
1040                    "Fatal JS error detected, terminating plugin thread: {}",
1041                    error_msg
1042                );
1043                panic!("Fatal plugin error: {}", error_msg);
1044            }
1045        }
1046
1047        tokio::select! {
1048            biased; // Prefer handling requests over polling
1049
1050            request = request_receiver.recv() => {
1051                match request {
1052                    Some(PluginRequest::ExecuteAction {
1053                        action_name,
1054                        response,
1055                    }) => {
1056                        // Start the action without blocking - this allows us to process
1057                        // ResolveCallback requests that the action may be waiting for.
1058                        let result = runtime.borrow_mut().start_action(&action_name);
1059                        fire_and_forget(response.send(result));
1060                        has_pending_work = true; // Action may have started async work
1061                    }
1062                    Some(request) => {
1063                        let should_shutdown =
1064                            handle_request(request, Rc::clone(&runtime), plugins).await;
1065
1066                        if should_shutdown {
1067                            break;
1068                        }
1069                        has_pending_work = true; // Request may have started async work
1070                    }
1071                    None => {
1072                        // Channel closed
1073                        tracing::info!("Plugin thread request channel closed");
1074                        break;
1075                    }
1076                }
1077            }
1078
1079            // Poll the JS event loop periodically to make progress on pending promises
1080            _ = tokio::time::sleep(poll_interval), if has_pending_work => {
1081                has_pending_work = runtime.borrow_mut().poll_event_loop_once();
1082            }
1083        }
1084    }
1085}
1086
1087/// Run a hook with Rc<RefCell<QuickJsBackend>>
1088///
1089/// # Safety (clippy::await_holding_refcell_ref)
1090/// The RefCell borrow held across await is safe because:
1091/// - This runs on a single-threaded tokio runtime (no parallel task execution)
1092/// - No spawn_local calls exist that could create concurrent access to `runtime`
1093/// - The runtime Rc<RefCell<>> is never shared with other concurrent tasks
1094#[allow(clippy::await_holding_refcell_ref)]
1095async fn run_hook_internal_rc(
1096    runtime: Rc<RefCell<QuickJsBackend>>,
1097    hook_name: &str,
1098    args: &HookArgs,
1099    target: Option<&str>,
1100) -> Result<()> {
1101    // Convert HookArgs to serde_json::Value using hook_args_to_json which produces flat JSON
1102    // (not enum-tagged JSON from serde's default Serialize)
1103    let json_start = std::time::Instant::now();
1104    let json_data = fresh_core::hooks::hook_args_to_json(args)?;
1105    tracing::trace!(
1106        hook = hook_name,
1107        json_us = json_start.elapsed().as_micros(),
1108        "hook args serialized"
1109    );
1110
1111    // Emit to TypeScript handlers
1112    let emit_start = std::time::Instant::now();
1113    runtime
1114        .borrow_mut()
1115        .emit_to(hook_name, &json_data, target)
1116        .await?;
1117    tracing::trace!(
1118        hook = hook_name,
1119        emit_ms = emit_start.elapsed().as_millis(),
1120        "emit completed"
1121    );
1122
1123    Ok(())
1124}
1125
1126/// Handle a single request in the plugin thread
1127#[allow(clippy::await_holding_refcell_ref)]
1128async fn handle_request(
1129    request: PluginRequest,
1130    runtime: Rc<RefCell<QuickJsBackend>>,
1131    plugins: &mut HashMap<String, TsPluginInfo>,
1132) -> bool {
1133    match request {
1134        PluginRequest::LoadPlugin { path, response } => {
1135            let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
1136            fire_and_forget(response.send(result));
1137        }
1138
1139        PluginRequest::LoadPluginsFromDir { dir, response } => {
1140            let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
1141            fire_and_forget(response.send(errors));
1142        }
1143
1144        PluginRequest::LoadPluginsFromDirWithConfig {
1145            dir,
1146            plugin_configs,
1147            response,
1148        } => {
1149            let (errors, discovered) = load_plugins_from_dir_with_config_internal(
1150                Rc::clone(&runtime),
1151                plugins,
1152                &dir,
1153                &plugin_configs,
1154            )
1155            .await;
1156            fire_and_forget(response.send((errors, discovered)));
1157        }
1158
1159        PluginRequest::LoadPluginFromSource {
1160            source,
1161            name,
1162            is_typescript,
1163            response,
1164        } => {
1165            let result = load_plugin_from_source_internal(
1166                Rc::clone(&runtime),
1167                plugins,
1168                &source,
1169                &name,
1170                is_typescript,
1171            );
1172            fire_and_forget(response.send(result));
1173        }
1174
1175        PluginRequest::UnloadPlugin { name, response } => {
1176            let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
1177            fire_and_forget(response.send(result));
1178        }
1179
1180        PluginRequest::ReloadPlugin { name, response } => {
1181            let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
1182            fire_and_forget(response.send(result));
1183        }
1184
1185        PluginRequest::ExecuteAction {
1186            action_name,
1187            response,
1188        } => {
1189            // This is handled in plugin_thread_loop with select! for concurrent processing
1190            // If we get here, it's an unexpected state
1191            tracing::error!(
1192                "ExecuteAction should be handled in main loop, not here: {}",
1193                action_name
1194            );
1195            fire_and_forget(response.send(Err(anyhow::anyhow!(
1196                "Internal error: ExecuteAction in wrong handler"
1197            ))));
1198        }
1199
1200        PluginRequest::RunHook {
1201            hook_name,
1202            args,
1203            target,
1204        } => {
1205            // Fire-and-forget hook execution
1206            let hook_start = std::time::Instant::now();
1207            // Use info level for prompt hooks to aid debugging
1208            if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1209                tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
1210            } else {
1211                tracing::trace!(hook = %hook_name, "RunHook request received");
1212            }
1213            if let Err(e) =
1214                run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args, target.as_deref())
1215                    .await
1216            {
1217                let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
1218                tracing::error!("{}", error_msg);
1219                // Surface the error to the UI
1220                runtime.borrow_mut().send_status(error_msg);
1221            }
1222            // Send sentinel so the main thread can wait deterministically
1223            // for all commands from this hook to be available.
1224            runtime.borrow().send_hook_completed(hook_name.clone());
1225            if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1226                tracing::info!(
1227                    hook = %hook_name,
1228                    elapsed_ms = hook_start.elapsed().as_millis(),
1229                    "RunHook completed (prompt hook)"
1230                );
1231            } else {
1232                tracing::trace!(
1233                    hook = %hook_name,
1234                    elapsed_ms = hook_start.elapsed().as_millis(),
1235                    "RunHook completed"
1236                );
1237            }
1238        }
1239
1240        PluginRequest::HasHookHandlers {
1241            hook_name,
1242            response,
1243        } => {
1244            let has_handlers = runtime.borrow().has_handlers(&hook_name);
1245            fire_and_forget(response.send(has_handlers));
1246        }
1247
1248        PluginRequest::ListPlugins { response } => {
1249            let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
1250            fire_and_forget(response.send(plugin_list));
1251        }
1252
1253        PluginRequest::ResolveCallback {
1254            callback_id,
1255            result_json,
1256        } => {
1257            // One callback resolves per async op completion; a plugin that
1258            // fires async calls in bulk (or a feedback loop) drives this at
1259            // high frequency, and `result_json` can be large. Keep both at
1260            // `trace` so it's off by default and not amplifying log volume.
1261            tracing::trace!(
1262                "ResolveCallback: resolving callback_id={} with result_json={}",
1263                callback_id,
1264                result_json
1265            );
1266            runtime
1267                .borrow_mut()
1268                .resolve_callback(callback_id, &result_json);
1269            // resolve_callback now runs execute_pending_job() internally
1270            tracing::trace!(
1271                "ResolveCallback: done resolving callback_id={}",
1272                callback_id
1273            );
1274        }
1275
1276        PluginRequest::RejectCallback { callback_id, error } => {
1277            runtime.borrow_mut().reject_callback(callback_id, &error);
1278            // reject_callback now runs execute_pending_job() internally
1279        }
1280
1281        PluginRequest::TrackAsyncResource {
1282            plugin_name,
1283            resource,
1284        } => {
1285            let rt = runtime.borrow();
1286            let mut tracked = rt.plugin_tracked_state.borrow_mut();
1287            let state = tracked.entry(plugin_name).or_default();
1288            match resource {
1289                TrackedAsyncResource::VirtualBuffer(buffer_id) => {
1290                    state.virtual_buffer_ids.push(buffer_id);
1291                }
1292                TrackedAsyncResource::CompositeBuffer(buffer_id) => {
1293                    state.composite_buffer_ids.push(buffer_id);
1294                }
1295                TrackedAsyncResource::Terminal(terminal_id) => {
1296                    state.terminal_ids.push(terminal_id);
1297                }
1298                TrackedAsyncResource::WatchHandle(handle) => {
1299                    state.watch_handles.push(handle);
1300                }
1301            }
1302        }
1303
1304        PluginRequest::Shutdown => {
1305            tracing::info!("Plugin thread received shutdown request");
1306            return true;
1307        }
1308    }
1309
1310    false
1311}
1312
1313/// Result of the parallel preparation phase for a single plugin.
1314/// Contains everything needed to execute the plugin — no further I/O or transpilation required.
1315struct PreparedPlugin {
1316    name: String,
1317    path: PathBuf,
1318    js_code: String,
1319    i18n: Option<HashMap<String, HashMap<String, String>>>,
1320    dependencies: Vec<String>,
1321    /// `.d.ts` emit for the plugin source, produced by oxc's
1322    /// isolated-declarations transformer. Present on every successful
1323    /// TS/JS prepare; callers can use it to assemble a consolidated
1324    /// plugins.d.ts so init.ts/other plugins can reach each plugin's
1325    /// public types without manual `as`-casts. `None` only when
1326    /// isolated-declarations emit failed outright — the plugin still
1327    /// loads at runtime.
1328    declarations: Option<String>,
1329}
1330
1331/// Prepare a plugin for execution: read source, transpile, extract dependencies.
1332///
1333/// This function does I/O and CPU-bound work only — no QuickJS interaction.
1334/// It is safe to call from any thread (all inputs/outputs are Send).
1335fn prepare_plugin(path: &Path) -> Result<PreparedPlugin> {
1336    let plugin_name = path
1337        .file_stem()
1338        .and_then(|s| s.to_str())
1339        .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1340        .to_string();
1341
1342    let source = std::fs::read_to_string(path)
1343        .map_err(|e| anyhow!("Failed to read plugin {}: {}", path.display(), e))?;
1344
1345    let filename = path
1346        .file_name()
1347        .and_then(|s| s.to_str())
1348        .unwrap_or("plugin.ts");
1349
1350    // Extract dependencies before transpilation
1351    let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1352
1353    // Emit `.d.ts` via oxc's isolated-declarations before the
1354    // transpile step consumes `source`. We want the raw TS (every
1355    // `export type`, `export interface`, and `declare global` block
1356    // the plugin author wrote) so downstream plugins and init.ts
1357    // reach the plugin's public types without casts. Failures are
1358    // non-fatal — the plugin still runs.
1359    let declarations = if filename.ends_with(".ts") {
1360        match fresh_parser_js::emit_isolated_declarations(&source, filename) {
1361            Ok(dts) => Some(dts),
1362            Err(e) => {
1363                tracing::warn!(
1364                    "Plugin {} isolated-declarations emit failed: {}",
1365                    path.display(),
1366                    e
1367                );
1368                None
1369            }
1370        }
1371    } else {
1372        None
1373    };
1374
1375    // Transpile/bundle to JS (same logic as QuickJsBackend::load_module_with_source)
1376    let js_code = if fresh_parser_js::has_es_imports(&source) {
1377        match fresh_parser_js::bundle_module(path) {
1378            Ok(bundled) => bundled,
1379            Err(e) => {
1380                tracing::warn!(
1381                    "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
1382                    path.display(),
1383                    e
1384                );
1385                return Err(anyhow!("Bundling failed for {}: {}", plugin_name, e));
1386            }
1387        }
1388    } else if fresh_parser_js::has_es_module_syntax(&source) {
1389        let stripped = fresh_parser_js::strip_imports_and_exports(&source);
1390        if filename.ends_with(".ts") {
1391            fresh_parser_js::transpile_typescript(&stripped, filename)?
1392        } else {
1393            stripped
1394        }
1395    } else if filename.ends_with(".ts") {
1396        fresh_parser_js::transpile_typescript(&source, filename)?
1397    } else {
1398        source
1399    };
1400
1401    // Load accompanying .i18n.json file
1402    let i18n_path = path.with_extension("i18n.json");
1403    let i18n = if i18n_path.exists() {
1404        std::fs::read_to_string(&i18n_path)
1405            .ok()
1406            .and_then(|content| serde_json::from_str(&content).ok())
1407    } else {
1408        None
1409    };
1410
1411    Ok(PreparedPlugin {
1412        name: plugin_name,
1413        path: path.to_path_buf(),
1414        js_code,
1415        i18n,
1416        dependencies,
1417        declarations,
1418    })
1419}
1420
1421/// Execute a pre-prepared plugin in QuickJS. This is the serial phase —
1422/// must run on the plugin thread.
1423fn execute_prepared_plugin(
1424    runtime: &Rc<RefCell<QuickJsBackend>>,
1425    plugins: &mut HashMap<String, TsPluginInfo>,
1426    prepared: &PreparedPlugin,
1427) -> Result<()> {
1428    // Register i18n strings
1429    if let Some(ref i18n) = prepared.i18n {
1430        runtime
1431            .borrow_mut()
1432            .services
1433            .register_plugin_strings(&prepared.name, i18n.clone());
1434        tracing::debug!("Loaded i18n strings for plugin '{}'", prepared.name);
1435    }
1436
1437    let path_str = prepared
1438        .path
1439        .to_str()
1440        .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1441
1442    let exec_start = std::time::Instant::now();
1443    runtime
1444        .borrow_mut()
1445        .execute_js(&prepared.js_code, path_str)?;
1446    let exec_elapsed = exec_start.elapsed();
1447
1448    tracing::debug!(
1449        "execute_prepared_plugin: plugin '{}' executed in {:?}",
1450        prepared.name,
1451        exec_elapsed
1452    );
1453
1454    plugins.insert(
1455        prepared.name.clone(),
1456        TsPluginInfo {
1457            name: prepared.name.clone(),
1458            path: prepared.path.clone(),
1459            enabled: true,
1460            declarations: prepared.declarations.clone(),
1461        },
1462    );
1463
1464    Ok(())
1465}
1466
1467#[allow(clippy::await_holding_refcell_ref)]
1468async fn load_plugin_internal(
1469    runtime: Rc<RefCell<QuickJsBackend>>,
1470    plugins: &mut HashMap<String, TsPluginInfo>,
1471    path: &Path,
1472) -> Result<()> {
1473    let plugin_name = path
1474        .file_stem()
1475        .and_then(|s| s.to_str())
1476        .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1477        .to_string();
1478
1479    tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
1480    tracing::debug!(
1481        "load_plugin_internal: starting module load for plugin '{}'",
1482        plugin_name
1483    );
1484
1485    // Load and execute the module, passing plugin name for command registration
1486    let path_str = path
1487        .to_str()
1488        .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1489
1490    // Try to load accompanying .i18n.json file
1491    let i18n_path = path.with_extension("i18n.json");
1492    if i18n_path.exists() {
1493        if let Ok(content) = std::fs::read_to_string(&i18n_path) {
1494            if let Ok(strings) = serde_json::from_str::<
1495                std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1496            >(&content)
1497            {
1498                runtime
1499                    .borrow_mut()
1500                    .services
1501                    .register_plugin_strings(&plugin_name, strings);
1502                tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
1503            }
1504        }
1505    }
1506
1507    let load_start = std::time::Instant::now();
1508    runtime
1509        .borrow_mut()
1510        .load_module_with_source(path_str, &plugin_name)
1511        .await?;
1512    let load_elapsed = load_start.elapsed();
1513
1514    tracing::debug!(
1515        "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
1516        plugin_name,
1517        load_elapsed
1518    );
1519
1520    // Store plugin info
1521    plugins.insert(
1522        plugin_name.clone(),
1523        TsPluginInfo {
1524            name: plugin_name.clone(),
1525            path: path.to_path_buf(),
1526            enabled: true,
1527            // `load_plugin_internal` is the hot-reload path (single
1528            // file, no prepare-then-execute split). Skip the emit
1529            // here; the full directory scan picks it up next time.
1530            declarations: None,
1531        },
1532    );
1533
1534    tracing::debug!(
1535        "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
1536        plugin_name,
1537        plugins.len()
1538    );
1539
1540    Ok(())
1541}
1542
1543/// Load all plugins from a directory
1544async fn load_plugins_from_dir_internal(
1545    runtime: Rc<RefCell<QuickJsBackend>>,
1546    plugins: &mut HashMap<String, TsPluginInfo>,
1547    dir: &Path,
1548) -> Vec<String> {
1549    tracing::debug!(
1550        "load_plugins_from_dir_internal: scanning directory {:?}",
1551        dir
1552    );
1553    let mut errors = Vec::new();
1554
1555    if !dir.exists() {
1556        tracing::warn!("Plugin directory does not exist: {:?}", dir);
1557        return errors;
1558    }
1559
1560    // Scan directory for .ts and .js files
1561    match std::fs::read_dir(dir) {
1562        Ok(entries) => {
1563            for entry in entries.flatten() {
1564                let path = entry.path();
1565                let ext = path.extension().and_then(|s| s.to_str());
1566                if ext == Some("ts") || ext == Some("js") {
1567                    tracing::debug!(
1568                        "load_plugins_from_dir_internal: attempting to load {:?}",
1569                        path
1570                    );
1571                    if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
1572                    {
1573                        let err = format!("Failed to load {:?}: {}", path, e);
1574                        tracing::error!("{}", err);
1575                        errors.push(err);
1576                    }
1577                }
1578            }
1579
1580            tracing::debug!(
1581                "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1582                dir,
1583                errors.len()
1584            );
1585        }
1586        Err(e) => {
1587            let err = format!("Failed to read plugin directory: {}", e);
1588            tracing::error!("{}", err);
1589            errors.push(err);
1590        }
1591    }
1592
1593    errors
1594}
1595
1596/// Load all plugins from a directory with config support
1597/// Returns (errors, discovered_plugins) where discovered_plugins contains all
1598/// found plugin files with their configs (respecting enabled state from provided configs)
1599async fn load_plugins_from_dir_with_config_internal(
1600    runtime: Rc<RefCell<QuickJsBackend>>,
1601    plugins: &mut HashMap<String, TsPluginInfo>,
1602    dir: &Path,
1603    plugin_configs: &HashMap<String, PluginConfig>,
1604) -> (Vec<String>, HashMap<String, PluginConfig>) {
1605    tracing::debug!(
1606        "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1607        dir
1608    );
1609    let mut errors = Vec::new();
1610    let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1611
1612    if !dir.exists() {
1613        tracing::warn!("Plugin directory does not exist: {:?}", dir);
1614        return (errors, discovered_plugins);
1615    }
1616
1617    // First pass: scan directory and collect all plugin files
1618    let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1619    match std::fs::read_dir(dir) {
1620        Ok(entries) => {
1621            for entry in entries.flatten() {
1622                let path = entry.path();
1623                let ext = path.extension().and_then(|s| s.to_str());
1624                if ext == Some("ts") || ext == Some("js") {
1625                    // Skip .i18n.json files (they're not plugins)
1626                    if path.to_string_lossy().contains(".i18n.") {
1627                        continue;
1628                    }
1629                    // Get plugin name from filename (without extension)
1630                    let plugin_name = path
1631                        .file_stem()
1632                        .and_then(|s| s.to_str())
1633                        .unwrap_or("unknown")
1634                        .to_string();
1635                    plugin_files.push((plugin_name, path));
1636                }
1637            }
1638        }
1639        Err(e) => {
1640            let err = format!("Failed to read plugin directory: {}", e);
1641            tracing::error!("{}", err);
1642            errors.push(err);
1643            return (errors, discovered_plugins);
1644        }
1645    }
1646
1647    // Second pass: build discovered_plugins map, collect enabled plugins with paths
1648    let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1649    for (plugin_name, path) in plugin_files {
1650        // Check if we have an existing config for this plugin
1651        let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1652            // Use existing config but ensure path is set
1653            PluginConfig {
1654                enabled: existing_config.enabled,
1655                path: Some(path.clone()),
1656                settings: existing_config.settings.clone(),
1657            }
1658        } else {
1659            // Create new config with default enabled = true
1660            PluginConfig::new_with_path(path.clone())
1661        };
1662
1663        // Add to discovered plugins
1664        discovered_plugins.insert(plugin_name.clone(), config.clone());
1665
1666        if config.enabled {
1667            enabled_plugins.push((plugin_name, path));
1668        } else {
1669            tracing::info!(
1670                "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1671                plugin_name
1672            );
1673        }
1674    }
1675
1676    // Phase 1: Parallel preparation — read files, transpile TS→JS, extract deps
1677    // All I/O and CPU-bound work happens here, concurrently across threads.
1678    let prep_start = std::time::Instant::now();
1679    let paths: Vec<std::path::PathBuf> = enabled_plugins.iter().map(|(_, p)| p.clone()).collect();
1680    let prepared_results: Vec<(String, Result<PreparedPlugin>)> = std::thread::scope(|scope| {
1681        let handles: Vec<_> = paths
1682            .iter()
1683            .map(|path| {
1684                let path = path.clone();
1685                scope.spawn(move || {
1686                    let name = path
1687                        .file_stem()
1688                        .and_then(|s| s.to_str())
1689                        .unwrap_or("unknown")
1690                        .to_string();
1691                    let result = prepare_plugin(&path);
1692                    (name, result)
1693                })
1694            })
1695            .collect();
1696        handles.into_iter().map(|h| h.join().unwrap()).collect()
1697    });
1698    let prep_elapsed = prep_start.elapsed();
1699
1700    // Collect successful preparations and errors
1701    let mut prepared_map: std::collections::HashMap<String, PreparedPlugin> =
1702        std::collections::HashMap::new();
1703    for (name, result) in prepared_results {
1704        match result {
1705            Ok(prepared) => {
1706                prepared_map.insert(name, prepared);
1707            }
1708            Err(e) => {
1709                let err = format!("Failed to prepare plugin '{}': {}", name, e);
1710                tracing::error!("{}", err);
1711                errors.push(err);
1712            }
1713        }
1714    }
1715
1716    tracing::info!(
1717        "Parallel plugin preparation completed in {:?} ({} plugins)",
1718        prep_elapsed,
1719        prepared_map.len()
1720    );
1721
1722    // Build dependency map from prepared plugins
1723    let mut dependency_map: std::collections::HashMap<String, Vec<String>> =
1724        std::collections::HashMap::new();
1725    for (name, prepared) in &prepared_map {
1726        if !prepared.dependencies.is_empty() {
1727            tracing::debug!(
1728                "Plugin '{}' declares dependencies: {:?}",
1729                name,
1730                prepared.dependencies
1731            );
1732            dependency_map.insert(name.clone(), prepared.dependencies.clone());
1733        }
1734    }
1735
1736    // Topologically sort by dependencies
1737    let plugin_names: Vec<String> = prepared_map.keys().cloned().collect();
1738    let load_order = match fresh_parser_js::topological_sort_plugins(&plugin_names, &dependency_map)
1739    {
1740        Ok(order) => order,
1741        Err(e) => {
1742            let err = format!("Plugin dependency resolution failed: {}", e);
1743            tracing::error!("{}", err);
1744            errors.push(err);
1745            // Fall back to alphabetical order
1746            let mut names = plugin_names;
1747            names.sort();
1748            names
1749        }
1750    };
1751
1752    // Phase 2: Serial execution — run prepared JS in QuickJS (must be single-threaded)
1753    let exec_start = std::time::Instant::now();
1754    for plugin_name in load_order {
1755        if let Some(prepared) = prepared_map.get(&plugin_name) {
1756            tracing::debug!(
1757                "load_plugins_from_dir_with_config_internal: executing plugin '{}'",
1758                plugin_name
1759            );
1760            if let Err(e) = execute_prepared_plugin(&runtime, plugins, prepared) {
1761                let err = format!("Failed to execute plugin '{}': {}", plugin_name, e);
1762                tracing::error!("{}", err);
1763                errors.push(err);
1764            }
1765        }
1766    }
1767    let exec_elapsed = exec_start.elapsed();
1768
1769    tracing::info!(
1770        "Serial plugin execution completed in {:?} ({} plugins)",
1771        exec_elapsed,
1772        plugins.len()
1773    );
1774
1775    tracing::debug!(
1776        "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors (prep: {:?}, exec: {:?})",
1777        discovered_plugins.len(),
1778        errors.len(),
1779        prep_elapsed,
1780        exec_elapsed
1781    );
1782
1783    (errors, discovered_plugins)
1784}
1785
1786/// Load a plugin from source code directly (no file I/O).
1787///
1788/// If a plugin with the same name is already loaded, it will be unloaded first
1789/// (hot-reload semantics).
1790fn load_plugin_from_source_internal(
1791    runtime: Rc<RefCell<QuickJsBackend>>,
1792    plugins: &mut HashMap<String, TsPluginInfo>,
1793    source: &str,
1794    name: &str,
1795    is_typescript: bool,
1796) -> Result<()> {
1797    // Hot-reload: unload previous version if it exists
1798    if plugins.contains_key(name) {
1799        tracing::info!(
1800            "Hot-reloading buffer plugin '{}' — unloading previous version",
1801            name
1802        );
1803        unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1804    }
1805
1806    tracing::info!("Loading plugin from source: {}", name);
1807
1808    runtime
1809        .borrow_mut()
1810        .execute_source(source, name, is_typescript)?;
1811
1812    // Register in plugins map with a synthetic path
1813    plugins.insert(
1814        name.to_string(),
1815        TsPluginInfo {
1816            name: name.to_string(),
1817            path: PathBuf::from(format!("<buffer:{}>", name)),
1818            enabled: true,
1819            // "Load from buffer" is a developer convenience — the
1820            // source doesn't live on disk, so we skip isolated-
1821            // declarations emit. Users editing real plugin files
1822            // still get types on the next full scan.
1823            declarations: None,
1824        },
1825    );
1826
1827    tracing::info!(
1828        "Buffer plugin '{}' loaded successfully, total plugins: {}",
1829        name,
1830        plugins.len()
1831    );
1832
1833    Ok(())
1834}
1835
1836/// Unload a plugin
1837fn unload_plugin_internal(
1838    runtime: Rc<RefCell<QuickJsBackend>>,
1839    plugins: &mut HashMap<String, TsPluginInfo>,
1840    name: &str,
1841) -> Result<()> {
1842    if plugins.remove(name).is_some() {
1843        tracing::info!("Unloading TypeScript plugin: {}", name);
1844
1845        // Unregister i18n strings
1846        runtime
1847            .borrow_mut()
1848            .services
1849            .unregister_plugin_strings(name);
1850
1851        // Remove all commands registered by this plugin
1852        runtime
1853            .borrow()
1854            .services
1855            .unregister_commands_by_plugin(name);
1856
1857        // Clean up plugin runtime state (context, event handlers, actions, callbacks)
1858        runtime.borrow().cleanup_plugin(name);
1859
1860        Ok(())
1861    } else {
1862        Err(anyhow!("Plugin '{}' not found", name))
1863    }
1864}
1865
1866/// Reload a plugin
1867async fn reload_plugin_internal(
1868    runtime: Rc<RefCell<QuickJsBackend>>,
1869    plugins: &mut HashMap<String, TsPluginInfo>,
1870    name: &str,
1871) -> Result<()> {
1872    let path = plugins
1873        .get(name)
1874        .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1875        .path
1876        .clone();
1877
1878    unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1879    load_plugin_internal(runtime, plugins, &path).await?;
1880
1881    Ok(())
1882}
1883
1884#[cfg(test)]
1885mod tests {
1886    use super::*;
1887    use fresh_core::hooks::hook_args_to_json;
1888
1889    #[test]
1890    fn test_oneshot_channel() {
1891        let (tx, rx) = oneshot::channel::<i32>();
1892        assert!(tx.send(42).is_ok());
1893        assert_eq!(rx.recv().unwrap(), 42);
1894    }
1895
1896    #[test]
1897    fn test_hook_args_to_json_editor_initialized() {
1898        let args = HookArgs::EditorInitialized {};
1899        let json = hook_args_to_json(&args).unwrap();
1900        assert_eq!(json, serde_json::json!({}));
1901    }
1902
1903    #[test]
1904    fn test_hook_args_to_json_prompt_changed() {
1905        let args = HookArgs::PromptChanged {
1906            prompt_type: "search".to_string(),
1907            input: "test".to_string(),
1908        };
1909        let json = hook_args_to_json(&args).unwrap();
1910        assert_eq!(json["prompt_type"], "search");
1911        assert_eq!(json["input"], "test");
1912    }
1913}