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