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