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::{PendingResponses, TsPluginInfo};
13use crate::backend::QuickJsBackend;
14use anyhow::{anyhow, Result};
15use fresh_core::api::{EditorStateSnapshot, 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/// Request messages sent to the plugin thread
29#[derive(Debug)]
30pub enum PluginRequest {
31    /// Load a plugin from a file
32    LoadPlugin {
33        path: PathBuf,
34        response: oneshot::Sender<Result<()>>,
35    },
36
37    /// Resolve an async callback with a result (for async operations like SpawnProcess, Delay)
38    ResolveCallback {
39        callback_id: fresh_core::api::JsCallbackId,
40        result_json: String,
41    },
42
43    /// Reject an async callback with an error
44    RejectCallback {
45        callback_id: fresh_core::api::JsCallbackId,
46        error: String,
47    },
48
49    /// Load all plugins from a directory
50    LoadPluginsFromDir {
51        dir: PathBuf,
52        response: oneshot::Sender<Vec<String>>,
53    },
54
55    /// Load all plugins from a directory with config support
56    /// Returns (errors, discovered_plugins) where discovered_plugins contains
57    /// all found plugins with their paths and enabled status
58    LoadPluginsFromDirWithConfig {
59        dir: PathBuf,
60        plugin_configs: HashMap<String, PluginConfig>,
61        response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
62    },
63
64    /// Unload a plugin by name
65    UnloadPlugin {
66        name: String,
67        response: oneshot::Sender<Result<()>>,
68    },
69
70    /// Reload a plugin by name
71    ReloadPlugin {
72        name: String,
73        response: oneshot::Sender<Result<()>>,
74    },
75
76    /// Execute a plugin action
77    ExecuteAction {
78        action_name: String,
79        response: oneshot::Sender<Result<()>>,
80    },
81
82    /// Run a hook (fire-and-forget, no response needed)
83    RunHook { hook_name: String, args: HookArgs },
84
85    /// Check if any handlers are registered for a hook
86    HasHookHandlers {
87        hook_name: String,
88        response: oneshot::Sender<bool>,
89    },
90
91    /// List all loaded plugins
92    ListPlugins {
93        response: oneshot::Sender<Vec<TsPluginInfo>>,
94    },
95
96    /// Shutdown the plugin thread
97    Shutdown,
98}
99
100/// Simple oneshot channel implementation
101pub mod oneshot {
102    use std::fmt;
103    use std::sync::mpsc;
104
105    pub struct Sender<T>(mpsc::SyncSender<T>);
106    pub struct Receiver<T>(mpsc::Receiver<T>);
107
108    use anyhow::Result;
109
110    impl<T> fmt::Debug for Sender<T> {
111        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112            f.debug_tuple("Sender").finish()
113        }
114    }
115
116    impl<T> fmt::Debug for Receiver<T> {
117        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118            f.debug_tuple("Receiver").finish()
119        }
120    }
121
122    impl<T> Sender<T> {
123        pub fn send(self, value: T) -> Result<(), T> {
124            self.0.send(value).map_err(|e| e.0)
125        }
126    }
127
128    impl<T> Receiver<T> {
129        pub fn recv(self) -> Result<T, mpsc::RecvError> {
130            self.0.recv()
131        }
132
133        pub fn recv_timeout(
134            self,
135            timeout: std::time::Duration,
136        ) -> Result<T, mpsc::RecvTimeoutError> {
137            self.0.recv_timeout(timeout)
138        }
139
140        pub fn try_recv(&self) -> Result<T, mpsc::TryRecvError> {
141            self.0.try_recv()
142        }
143    }
144
145    pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
146        let (tx, rx) = mpsc::sync_channel(1);
147        (Sender(tx), Receiver(rx))
148    }
149}
150
151/// Handle to the plugin thread for sending requests
152pub struct PluginThreadHandle {
153    /// Channel to send requests to the plugin thread
154    /// Wrapped in Option so we can drop it to signal shutdown
155    request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
156
157    /// Thread join handle
158    thread_handle: Option<JoinHandle<()>>,
159
160    /// State snapshot handle for editor to update
161    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
162
163    /// Pending response senders for async operations (shared with runtime)
164    pending_responses: PendingResponses,
165
166    /// Receiver for plugin commands (polled by editor directly)
167    command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
168}
169
170impl PluginThreadHandle {
171    /// Create a new plugin thread and return its handle
172    pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
173        tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
174
175        // Create channel for plugin commands
176        let (command_sender, command_receiver) = std::sync::mpsc::channel();
177
178        // Create editor state snapshot for query API
179        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
180
181        // Create pending responses map (shared between handle and runtime)
182        let pending_responses: PendingResponses =
183            Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
184        let thread_pending_responses = Arc::clone(&pending_responses);
185
186        // Create channel for requests (unbounded allows sync send, async recv)
187        let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
188
189        // Clone state snapshot for the thread
190        let thread_state_snapshot = Arc::clone(&state_snapshot);
191
192        // Spawn the plugin thread
193        tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
194        let thread_handle = thread::spawn(move || {
195            tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
196            // Create tokio runtime for the plugin thread
197            let rt = match tokio::runtime::Builder::new_current_thread()
198                .enable_all()
199                .build()
200            {
201                Ok(rt) => {
202                    tracing::debug!("Plugin thread: tokio runtime created successfully");
203                    rt
204                }
205                Err(e) => {
206                    tracing::error!("Failed to create plugin thread runtime: {}", e);
207                    return;
208                }
209            };
210
211            // Create QuickJS runtime with state
212            tracing::debug!("Plugin thread: creating QuickJS runtime");
213            let runtime = match QuickJsBackend::with_state_and_responses(
214                Arc::clone(&thread_state_snapshot),
215                command_sender,
216                thread_pending_responses,
217                services.clone(),
218            ) {
219                Ok(rt) => {
220                    tracing::debug!("Plugin thread: QuickJS runtime created successfully");
221                    rt
222                }
223                Err(e) => {
224                    tracing::error!("Failed to create QuickJS runtime: {}", e);
225                    return;
226                }
227            };
228
229            // Create internal manager state
230            let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
231
232            // Run the event loop with a LocalSet to allow concurrent task execution
233            tracing::debug!("Plugin thread: starting event loop with LocalSet");
234            let local = tokio::task::LocalSet::new();
235            local.block_on(&rt, async {
236                // Wrap runtime in RefCell for interior mutability during concurrent operations
237                let runtime = Rc::new(RefCell::new(runtime));
238                tracing::debug!("Plugin thread: entering plugin_thread_loop");
239                plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
240            });
241
242            tracing::info!("Plugin thread shutting down");
243        });
244
245        tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
246        tracing::info!("Plugin thread spawned");
247
248        Ok(Self {
249            request_sender: Some(request_sender),
250            thread_handle: Some(thread_handle),
251            state_snapshot,
252            pending_responses,
253            command_receiver,
254        })
255    }
256
257    /// Deliver a response to a pending async operation in the plugin
258    ///
259    /// This is called by the editor after processing a command that requires a response.
260    pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
261        // First try to find a pending Rust request (oneshot channel)
262        if respond_to_pending(&self.pending_responses, response.clone()) {
263            return;
264        }
265
266        // If not found, it must be a JS callback
267        use fresh_core::api::{JsCallbackId, PluginResponse};
268        use serde_json::json;
269
270        match response {
271            PluginResponse::VirtualBufferCreated {
272                request_id,
273                buffer_id,
274                split_id,
275            } => {
276                let result = json!({
277                    "buffer_id": buffer_id,
278                    "split_id": split_id
279                });
280                self.resolve_callback(JsCallbackId(request_id), result.to_string());
281            }
282            PluginResponse::LspRequest { request_id, result } => match result {
283                Ok(value) => {
284                    self.resolve_callback(JsCallbackId(request_id), value.to_string());
285                }
286                Err(e) => {
287                    self.reject_callback(JsCallbackId(request_id), e);
288                }
289            },
290            PluginResponse::HighlightsComputed { request_id, spans } => {
291                let result = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".to_string());
292                self.resolve_callback(JsCallbackId(request_id), result);
293            }
294            PluginResponse::BufferText { request_id, text } => match text {
295                Ok(content) => {
296                    // JSON stringify the content string
297                    let result =
298                        serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
299                    self.resolve_callback(JsCallbackId(request_id), result);
300                }
301                Err(e) => {
302                    self.reject_callback(JsCallbackId(request_id), e);
303                }
304            },
305            PluginResponse::CompositeBufferCreated {
306                request_id,
307                buffer_id,
308            } => {
309                let result = json!({
310                    "buffer_id": buffer_id
311                });
312                self.resolve_callback(JsCallbackId(request_id), result.to_string());
313            }
314        }
315    }
316
317    /// Load a plugin from a file (blocking)
318    pub fn load_plugin(&self, path: &Path) -> Result<()> {
319        let (tx, rx) = oneshot::channel();
320        self.request_sender
321            .as_ref()
322            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
323            .send(PluginRequest::LoadPlugin {
324                path: path.to_path_buf(),
325                response: tx,
326            })
327            .map_err(|_| anyhow!("Plugin thread not responding"))?;
328
329        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
330    }
331
332    /// Load all plugins from a directory (blocking)
333    pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
334        let (tx, rx) = oneshot::channel();
335        let Some(sender) = self.request_sender.as_ref() else {
336            return vec!["Plugin thread shut down".to_string()];
337        };
338        if sender
339            .send(PluginRequest::LoadPluginsFromDir {
340                dir: dir.to_path_buf(),
341                response: tx,
342            })
343            .is_err()
344        {
345            return vec!["Plugin thread not responding".to_string()];
346        }
347
348        rx.recv()
349            .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
350    }
351
352    /// Load all plugins from a directory with config support (blocking)
353    /// Returns (errors, discovered_plugins) where discovered_plugins is a map of
354    /// plugin name -> PluginConfig with paths populated.
355    pub fn load_plugins_from_dir_with_config(
356        &self,
357        dir: &Path,
358        plugin_configs: &HashMap<String, PluginConfig>,
359    ) -> (Vec<String>, HashMap<String, PluginConfig>) {
360        let (tx, rx) = oneshot::channel();
361        let Some(sender) = self.request_sender.as_ref() else {
362            return (vec!["Plugin thread shut down".to_string()], HashMap::new());
363        };
364        if sender
365            .send(PluginRequest::LoadPluginsFromDirWithConfig {
366                dir: dir.to_path_buf(),
367                plugin_configs: plugin_configs.clone(),
368                response: tx,
369            })
370            .is_err()
371        {
372            return (
373                vec!["Plugin thread not responding".to_string()],
374                HashMap::new(),
375            );
376        }
377
378        rx.recv()
379            .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
380    }
381
382    /// Unload a plugin (blocking)
383    pub fn unload_plugin(&self, name: &str) -> Result<()> {
384        let (tx, rx) = oneshot::channel();
385        self.request_sender
386            .as_ref()
387            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
388            .send(PluginRequest::UnloadPlugin {
389                name: name.to_string(),
390                response: tx,
391            })
392            .map_err(|_| anyhow!("Plugin thread not responding"))?;
393
394        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
395    }
396
397    /// Reload a plugin (blocking)
398    pub fn reload_plugin(&self, name: &str) -> Result<()> {
399        let (tx, rx) = oneshot::channel();
400        self.request_sender
401            .as_ref()
402            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
403            .send(PluginRequest::ReloadPlugin {
404                name: name.to_string(),
405                response: tx,
406            })
407            .map_err(|_| anyhow!("Plugin thread not responding"))?;
408
409        rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
410    }
411
412    /// Execute a plugin action (non-blocking)
413    ///
414    /// Returns a receiver that will receive the result when the action completes.
415    /// The caller should poll this while processing commands to avoid deadlock.
416    pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
417        tracing::trace!("execute_action_async: starting action '{}'", action_name);
418        let (tx, rx) = oneshot::channel();
419        self.request_sender
420            .as_ref()
421            .ok_or_else(|| anyhow!("Plugin thread shut down"))?
422            .send(PluginRequest::ExecuteAction {
423                action_name: action_name.to_string(),
424                response: tx,
425            })
426            .map_err(|_| anyhow!("Plugin thread not responding"))?;
427
428        tracing::trace!("execute_action_async: request sent for '{}'", action_name);
429        Ok(rx)
430    }
431
432    /// Run a hook (non-blocking, fire-and-forget)
433    ///
434    /// This is the key improvement: hooks are now non-blocking.
435    /// The plugin thread will execute them asynchronously and
436    /// any results will come back via the PluginCommand channel.
437    pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
438        if let Some(sender) = self.request_sender.as_ref() {
439            let _ = sender.send(PluginRequest::RunHook {
440                hook_name: hook_name.to_string(),
441                args,
442            });
443        }
444    }
445
446    /// Check if any handlers are registered for a hook (blocking)
447    pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
448        let (tx, rx) = oneshot::channel();
449        let Some(sender) = self.request_sender.as_ref() else {
450            return false;
451        };
452        if sender
453            .send(PluginRequest::HasHookHandlers {
454                hook_name: hook_name.to_string(),
455                response: tx,
456            })
457            .is_err()
458        {
459            return false;
460        }
461
462        rx.recv().unwrap_or(false)
463    }
464
465    /// List all loaded plugins (blocking)
466    pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
467        let (tx, rx) = oneshot::channel();
468        let Some(sender) = self.request_sender.as_ref() else {
469            return vec![];
470        };
471        if sender
472            .send(PluginRequest::ListPlugins { response: tx })
473            .is_err()
474        {
475            return vec![];
476        }
477
478        rx.recv().unwrap_or_default()
479    }
480
481    /// Process pending plugin commands (non-blocking)
482    ///
483    /// Returns immediately with any pending commands by polling the command queue directly.
484    /// This does not require the plugin thread to respond, avoiding deadlocks.
485    pub fn process_commands(&mut self) -> Vec<PluginCommand> {
486        let mut commands = Vec::new();
487        while let Ok(cmd) = self.command_receiver.try_recv() {
488            commands.push(cmd);
489        }
490        commands
491    }
492
493    /// Get the state snapshot handle for editor to update
494    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
495        Arc::clone(&self.state_snapshot)
496    }
497
498    /// Shutdown the plugin thread
499    pub fn shutdown(&mut self) {
500        tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
501
502        // Drop all pending response senders - this wakes up any plugin code waiting for responses
503        // by causing their oneshot receivers to return an error
504        if let Ok(mut pending) = self.pending_responses.lock() {
505            if !pending.is_empty() {
506                tracing::warn!(
507                    "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
508                    pending.len(),
509                    pending.keys().collect::<Vec<_>>()
510                );
511                pending.clear(); // Drop all senders, waking up waiting receivers
512            }
513        }
514
515        // First send a Shutdown request to allow clean processing of pending work
516        if let Some(sender) = self.request_sender.as_ref() {
517            tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
518            let _ = sender.send(PluginRequest::Shutdown);
519        }
520
521        // Then drop the sender to close the channel - this reliably wakes the receiver
522        // even when it's parked in a tokio LocalSet (the Shutdown message above may not wake it)
523        tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
524        self.request_sender.take();
525
526        if let Some(handle) = self.thread_handle.take() {
527            tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
528            let _ = handle.join();
529            tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
530        }
531
532        tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
533    }
534
535    /// Resolve an async callback in the plugin runtime
536    /// Called by the app when async operations (SpawnProcess, Delay) complete
537    pub fn resolve_callback(
538        &self,
539        callback_id: fresh_core::api::JsCallbackId,
540        result_json: String,
541    ) {
542        if let Some(sender) = self.request_sender.as_ref() {
543            let _ = sender.send(PluginRequest::ResolveCallback {
544                callback_id,
545                result_json,
546            });
547        }
548    }
549
550    /// Reject an async callback in the plugin runtime
551    /// Called by the app when async operations fail
552    pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
553        if let Some(sender) = self.request_sender.as_ref() {
554            let _ = sender.send(PluginRequest::RejectCallback { callback_id, error });
555        }
556    }
557}
558
559impl Drop for PluginThreadHandle {
560    fn drop(&mut self) {
561        self.shutdown();
562    }
563}
564
565fn respond_to_pending(
566    pending_responses: &PendingResponses,
567    response: fresh_core::api::PluginResponse,
568) -> bool {
569    let request_id = match &response {
570        fresh_core::api::PluginResponse::VirtualBufferCreated { request_id, .. } => *request_id,
571        fresh_core::api::PluginResponse::LspRequest { request_id, .. } => *request_id,
572        fresh_core::api::PluginResponse::HighlightsComputed { request_id, .. } => *request_id,
573        fresh_core::api::PluginResponse::BufferText { request_id, .. } => *request_id,
574        fresh_core::api::PluginResponse::CompositeBufferCreated { request_id, .. } => *request_id,
575    };
576
577    let sender = {
578        let mut pending = pending_responses.lock().unwrap();
579        pending.remove(&request_id)
580    };
581
582    if let Some(tx) = sender {
583        let _ = tx.send(response);
584        true
585    } else {
586        false
587    }
588}
589
590#[cfg(test)]
591mod plugin_thread_tests {
592    use super::*;
593    use fresh_core::api::PluginResponse;
594    use serde_json::json;
595    use std::collections::HashMap;
596    use std::sync::{Arc, Mutex};
597    use tokio::sync::oneshot;
598
599    #[test]
600    fn respond_to_pending_sends_lsp_response() {
601        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
602        let (tx, mut rx) = oneshot::channel();
603        pending.lock().unwrap().insert(123, tx);
604
605        respond_to_pending(
606            &pending,
607            PluginResponse::LspRequest {
608                request_id: 123,
609                result: Ok(json!({ "key": "value" })),
610            },
611        );
612
613        let response = rx.try_recv().expect("expected response");
614        match response {
615            PluginResponse::LspRequest { result, .. } => {
616                assert_eq!(result.unwrap(), json!({ "key": "value" }));
617            }
618            _ => panic!("unexpected variant"),
619        }
620
621        assert!(pending.lock().unwrap().is_empty());
622    }
623
624    #[test]
625    fn respond_to_pending_handles_virtual_buffer_created() {
626        let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
627        let (tx, mut rx) = oneshot::channel();
628        pending.lock().unwrap().insert(456, tx);
629
630        respond_to_pending(
631            &pending,
632            PluginResponse::VirtualBufferCreated {
633                request_id: 456,
634                buffer_id: fresh_core::BufferId(7),
635                split_id: Some(fresh_core::SplitId(1)),
636            },
637        );
638
639        let response = rx.try_recv().expect("expected response");
640        match response {
641            PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
642                assert_eq!(buffer_id.0, 7);
643            }
644            _ => panic!("unexpected variant"),
645        }
646
647        assert!(pending.lock().unwrap().is_empty());
648    }
649}
650
651/// Main loop for the plugin thread
652///
653/// Uses `tokio::select!` to interleave request handling with periodic event loop
654/// polling. This allows long-running promises (like process spawns) to make progress
655/// even when no requests are coming in, preventing the UI from getting stuck.
656async fn plugin_thread_loop(
657    runtime: Rc<RefCell<QuickJsBackend>>,
658    plugins: &mut HashMap<String, TsPluginInfo>,
659    mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
660) {
661    tracing::info!("Plugin thread event loop started");
662
663    // Interval for polling the JS event loop when there's pending work
664    let poll_interval = Duration::from_millis(1);
665    let mut has_pending_work = false;
666
667    loop {
668        tokio::select! {
669            biased; // Prefer handling requests over polling
670
671            request = request_receiver.recv() => {
672                match request {
673                    Some(PluginRequest::ExecuteAction {
674                        action_name,
675                        response,
676                    }) => {
677                        // Start the action without blocking - this allows us to process
678                        // ResolveCallback requests that the action may be waiting for.
679                        let result = runtime.borrow_mut().start_action(&action_name);
680                        let _ = response.send(result);
681                        has_pending_work = true; // Action may have started async work
682                    }
683                    Some(request) => {
684                        let should_shutdown =
685                            handle_request(request, Rc::clone(&runtime), plugins).await;
686
687                        if should_shutdown {
688                            break;
689                        }
690                        has_pending_work = true; // Request may have started async work
691                    }
692                    None => {
693                        // Channel closed
694                        tracing::info!("Plugin thread request channel closed");
695                        break;
696                    }
697                }
698            }
699
700            // Poll the JS event loop periodically to make progress on pending promises
701            _ = tokio::time::sleep(poll_interval), if has_pending_work => {
702                has_pending_work = runtime.borrow_mut().poll_event_loop_once();
703            }
704        }
705    }
706}
707
708/// Execute an action while processing incoming hook requests concurrently.
709///
710/// This prevents deadlock when an action awaits a response from the main thread
711/// while the main thread is waiting for a blocking hook to complete.
712///
713/// # Safety (clippy::await_holding_refcell_ref)
714/// The RefCell borrow held across await is safe because:
715/// - This runs on a single-threaded tokio runtime (no parallel task execution)
716/// - No spawn_local calls exist that could create concurrent access to `runtime`
717/// - The runtime Rc<RefCell<>> is never shared with other concurrent tasks
718
719/// Run a hook with Rc<RefCell<QuickJsBackend>>
720///
721/// # Safety (clippy::await_holding_refcell_ref)
722/// The RefCell borrow held across await is safe because:
723/// - This runs on a single-threaded tokio runtime (no parallel task execution)
724/// - No spawn_local calls exist that could create concurrent access to `runtime`
725/// - The runtime Rc<RefCell<>> is never shared with other concurrent tasks
726#[allow(clippy::await_holding_refcell_ref)]
727async fn run_hook_internal_rc(
728    runtime: Rc<RefCell<QuickJsBackend>>,
729    hook_name: &str,
730    args: &HookArgs,
731) -> Result<()> {
732    // Convert HookArgs to JSON using hook_args_to_json which produces flat JSON
733    // (not enum-tagged JSON from serde's default Serialize)
734    let json_start = std::time::Instant::now();
735    let json_string = fresh_core::hooks::hook_args_to_json(args)?;
736    let json_data: serde_json::Value = serde_json::from_str(&json_string)?;
737    tracing::trace!(
738        hook = hook_name,
739        json_ms = json_start.elapsed().as_micros(),
740        "hook args serialized"
741    );
742
743    // Emit to TypeScript handlers
744    let emit_start = std::time::Instant::now();
745    runtime.borrow_mut().emit(hook_name, &json_data).await?;
746    tracing::trace!(
747        hook = hook_name,
748        emit_ms = emit_start.elapsed().as_millis(),
749        "emit completed"
750    );
751
752    Ok(())
753}
754
755/// Handle a single request in the plugin thread
756async fn handle_request(
757    request: PluginRequest,
758    runtime: Rc<RefCell<QuickJsBackend>>,
759    plugins: &mut HashMap<String, TsPluginInfo>,
760) -> bool {
761    match request {
762        PluginRequest::LoadPlugin { path, response } => {
763            let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
764            let _ = response.send(result);
765        }
766
767        PluginRequest::LoadPluginsFromDir { dir, response } => {
768            let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
769            let _ = response.send(errors);
770        }
771
772        PluginRequest::LoadPluginsFromDirWithConfig {
773            dir,
774            plugin_configs,
775            response,
776        } => {
777            let (errors, discovered) = load_plugins_from_dir_with_config_internal(
778                Rc::clone(&runtime),
779                plugins,
780                &dir,
781                &plugin_configs,
782            )
783            .await;
784            let _ = response.send((errors, discovered));
785        }
786
787        PluginRequest::UnloadPlugin { name, response } => {
788            let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
789            let _ = response.send(result);
790        }
791
792        PluginRequest::ReloadPlugin { name, response } => {
793            let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
794            let _ = response.send(result);
795        }
796
797        PluginRequest::ExecuteAction {
798            action_name,
799            response,
800        } => {
801            // This is handled in plugin_thread_loop with select! for concurrent processing
802            // If we get here, it's an unexpected state
803            tracing::error!(
804                "ExecuteAction should be handled in main loop, not here: {}",
805                action_name
806            );
807            let _ = response.send(Err(anyhow::anyhow!(
808                "Internal error: ExecuteAction in wrong handler"
809            )));
810        }
811
812        PluginRequest::RunHook { hook_name, args } => {
813            // Fire-and-forget hook execution
814            let hook_start = std::time::Instant::now();
815            // Use info level for prompt hooks to aid debugging
816            if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
817                tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
818            } else {
819                tracing::trace!(hook = %hook_name, "RunHook request received");
820            }
821            if let Err(e) = run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args).await {
822                let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
823                tracing::error!("{}", error_msg);
824                // Surface the error to the UI
825                runtime.borrow_mut().send_status(error_msg);
826            }
827            if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
828                tracing::info!(
829                    hook = %hook_name,
830                    elapsed_ms = hook_start.elapsed().as_millis(),
831                    "RunHook completed (prompt hook)"
832                );
833            } else {
834                tracing::trace!(
835                    hook = %hook_name,
836                    elapsed_ms = hook_start.elapsed().as_millis(),
837                    "RunHook completed"
838                );
839            }
840        }
841
842        PluginRequest::HasHookHandlers {
843            hook_name,
844            response,
845        } => {
846            let has_handlers = runtime.borrow().has_handlers(&hook_name);
847            let _ = response.send(has_handlers);
848        }
849
850        PluginRequest::ListPlugins { response } => {
851            let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
852            let _ = response.send(plugin_list);
853        }
854
855        PluginRequest::ResolveCallback {
856            callback_id,
857            result_json,
858        } => {
859            tracing::info!(
860                "ResolveCallback: resolving callback_id={} with result_json={}",
861                callback_id,
862                result_json
863            );
864            runtime
865                .borrow_mut()
866                .resolve_callback(callback_id, &result_json);
867            // resolve_callback now runs execute_pending_job() internally
868            tracing::info!(
869                "ResolveCallback: done resolving callback_id={}",
870                callback_id
871            );
872        }
873
874        PluginRequest::RejectCallback { callback_id, error } => {
875            runtime.borrow_mut().reject_callback(callback_id, &error);
876            // reject_callback now runs execute_pending_job() internally
877        }
878
879        PluginRequest::Shutdown => {
880            tracing::info!("Plugin thread received shutdown request");
881            return true;
882        }
883    }
884
885    false
886}
887
888/// Load a plugin from a file
889///
890/// # Safety (clippy::await_holding_refcell_ref)
891/// The RefCell borrow held across await is safe because:
892/// - This runs on a single-threaded tokio runtime (no parallel task execution)
893/// - No spawn_local calls exist that could create concurrent access to `runtime`
894/// - The runtime Rc<RefCell<>> is never shared with other concurrent tasks
895#[allow(clippy::await_holding_refcell_ref)]
896async fn load_plugin_internal(
897    runtime: Rc<RefCell<QuickJsBackend>>,
898    plugins: &mut HashMap<String, TsPluginInfo>,
899    path: &Path,
900) -> Result<()> {
901    let plugin_name = path
902        .file_stem()
903        .and_then(|s| s.to_str())
904        .ok_or_else(|| anyhow!("Invalid plugin filename"))?
905        .to_string();
906
907    tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
908    tracing::debug!(
909        "load_plugin_internal: starting module load for plugin '{}'",
910        plugin_name
911    );
912
913    // Load and execute the module, passing plugin name for command registration
914    let path_str = path
915        .to_str()
916        .ok_or_else(|| anyhow!("Invalid path encoding"))?;
917
918    // Try to load accompanying .i18n.json file
919    let i18n_path = path.with_extension("i18n.json");
920    if i18n_path.exists() {
921        if let Ok(content) = std::fs::read_to_string(&i18n_path) {
922            if let Ok(strings) = serde_json::from_str::<
923                std::collections::HashMap<String, std::collections::HashMap<String, String>>,
924            >(&content)
925            {
926                runtime
927                    .borrow_mut()
928                    .services
929                    .register_plugin_strings(&plugin_name, strings);
930                tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
931            }
932        }
933    }
934
935    let load_start = std::time::Instant::now();
936    runtime
937        .borrow_mut()
938        .load_module_with_source(path_str, &plugin_name)
939        .await?;
940    let load_elapsed = load_start.elapsed();
941
942    tracing::debug!(
943        "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
944        plugin_name,
945        load_elapsed
946    );
947
948    // Store plugin info
949    plugins.insert(
950        plugin_name.clone(),
951        TsPluginInfo {
952            name: plugin_name.clone(),
953            path: path.to_path_buf(),
954            enabled: true,
955        },
956    );
957
958    tracing::debug!(
959        "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
960        plugin_name,
961        plugins.len()
962    );
963
964    Ok(())
965}
966
967/// Load all plugins from a directory
968async fn load_plugins_from_dir_internal(
969    runtime: Rc<RefCell<QuickJsBackend>>,
970    plugins: &mut HashMap<String, TsPluginInfo>,
971    dir: &Path,
972) -> Vec<String> {
973    tracing::debug!(
974        "load_plugins_from_dir_internal: scanning directory {:?}",
975        dir
976    );
977    let mut errors = Vec::new();
978
979    if !dir.exists() {
980        tracing::warn!("Plugin directory does not exist: {:?}", dir);
981        return errors;
982    }
983
984    // Scan directory for .ts and .js files
985    match std::fs::read_dir(dir) {
986        Ok(entries) => {
987            for entry in entries.flatten() {
988                let path = entry.path();
989                let ext = path.extension().and_then(|s| s.to_str());
990                if ext == Some("ts") || ext == Some("js") {
991                    tracing::debug!(
992                        "load_plugins_from_dir_internal: attempting to load {:?}",
993                        path
994                    );
995                    if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
996                    {
997                        let err = format!("Failed to load {:?}: {}", path, e);
998                        tracing::error!("{}", err);
999                        errors.push(err);
1000                    }
1001                }
1002            }
1003
1004            tracing::debug!(
1005                "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1006                dir,
1007                errors.len()
1008            );
1009        }
1010        Err(e) => {
1011            let err = format!("Failed to read plugin directory: {}", e);
1012            tracing::error!("{}", err);
1013            errors.push(err);
1014        }
1015    }
1016
1017    errors
1018}
1019
1020/// Load all plugins from a directory with config support
1021/// Returns (errors, discovered_plugins) where discovered_plugins contains all
1022/// found plugin files with their configs (respecting enabled state from provided configs)
1023async fn load_plugins_from_dir_with_config_internal(
1024    runtime: Rc<RefCell<QuickJsBackend>>,
1025    plugins: &mut HashMap<String, TsPluginInfo>,
1026    dir: &Path,
1027    plugin_configs: &HashMap<String, PluginConfig>,
1028) -> (Vec<String>, HashMap<String, PluginConfig>) {
1029    tracing::debug!(
1030        "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1031        dir
1032    );
1033    let mut errors = Vec::new();
1034    let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1035
1036    if !dir.exists() {
1037        tracing::warn!("Plugin directory does not exist: {:?}", dir);
1038        return (errors, discovered_plugins);
1039    }
1040
1041    // First pass: scan directory and collect all plugin files
1042    let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1043    match std::fs::read_dir(dir) {
1044        Ok(entries) => {
1045            for entry in entries.flatten() {
1046                let path = entry.path();
1047                let ext = path.extension().and_then(|s| s.to_str());
1048                if ext == Some("ts") || ext == Some("js") {
1049                    // Skip .i18n.json files (they're not plugins)
1050                    if path.to_string_lossy().contains(".i18n.") {
1051                        continue;
1052                    }
1053                    // Get plugin name from filename (without extension)
1054                    let plugin_name = path
1055                        .file_stem()
1056                        .and_then(|s| s.to_str())
1057                        .unwrap_or("unknown")
1058                        .to_string();
1059                    plugin_files.push((plugin_name, path));
1060                }
1061            }
1062        }
1063        Err(e) => {
1064            let err = format!("Failed to read plugin directory: {}", e);
1065            tracing::error!("{}", err);
1066            errors.push(err);
1067            return (errors, discovered_plugins);
1068        }
1069    }
1070
1071    // Second pass: build discovered_plugins map and load enabled plugins
1072    for (plugin_name, path) in plugin_files {
1073        // Check if we have an existing config for this plugin
1074        let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1075            // Use existing config but ensure path is set
1076            PluginConfig {
1077                enabled: existing_config.enabled,
1078                path: Some(path.clone()),
1079            }
1080        } else {
1081            // Create new config with default enabled = true
1082            PluginConfig::new_with_path(path.clone())
1083        };
1084
1085        // Add to discovered plugins
1086        discovered_plugins.insert(plugin_name.clone(), config.clone());
1087
1088        // Only load if enabled
1089        if config.enabled {
1090            tracing::debug!(
1091                "load_plugins_from_dir_with_config_internal: loading enabled plugin '{}'",
1092                plugin_name
1093            );
1094            if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await {
1095                let err = format!("Failed to load {:?}: {}", path, e);
1096                tracing::error!("{}", err);
1097                errors.push(err);
1098            }
1099        } else {
1100            tracing::info!(
1101                "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1102                plugin_name
1103            );
1104        }
1105    }
1106
1107    tracing::debug!(
1108        "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors",
1109        discovered_plugins.len(),
1110        errors.len()
1111    );
1112
1113    (errors, discovered_plugins)
1114}
1115
1116/// Unload a plugin
1117fn unload_plugin_internal(
1118    runtime: Rc<RefCell<QuickJsBackend>>,
1119    plugins: &mut HashMap<String, TsPluginInfo>,
1120    name: &str,
1121) -> Result<()> {
1122    if plugins.remove(name).is_some() {
1123        tracing::info!("Unloading TypeScript plugin: {}", name);
1124
1125        // Unregister i18n strings
1126        runtime
1127            .borrow_mut()
1128            .services
1129            .unregister_plugin_strings(name);
1130
1131        // Remove plugin's commands (assuming they're prefixed with plugin name)
1132        let prefix = format!("{}:", name);
1133        runtime
1134            .borrow()
1135            .services
1136            .unregister_commands_by_prefix(&prefix);
1137
1138        Ok(())
1139    } else {
1140        Err(anyhow!("Plugin '{}' not found", name))
1141    }
1142}
1143
1144/// Reload a plugin
1145async fn reload_plugin_internal(
1146    runtime: Rc<RefCell<QuickJsBackend>>,
1147    plugins: &mut HashMap<String, TsPluginInfo>,
1148    name: &str,
1149) -> Result<()> {
1150    let path = plugins
1151        .get(name)
1152        .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1153        .path
1154        .clone();
1155
1156    unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1157    load_plugin_internal(runtime, plugins, &path).await?;
1158
1159    Ok(())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165    use fresh_core::hooks::hook_args_to_json;
1166
1167    #[test]
1168    fn test_oneshot_channel() {
1169        let (tx, rx) = oneshot::channel::<i32>();
1170        assert!(tx.send(42).is_ok());
1171        assert_eq!(rx.recv().unwrap(), 42);
1172    }
1173
1174    #[test]
1175    fn test_hook_args_to_json_editor_initialized() {
1176        let args = HookArgs::EditorInitialized;
1177        let json = hook_args_to_json(&args).unwrap();
1178        assert_eq!(json, "{}");
1179    }
1180
1181    #[test]
1182    fn test_hook_args_to_json_prompt_changed() {
1183        let args = HookArgs::PromptChanged {
1184            prompt_type: "search".to_string(),
1185            input: "test".to_string(),
1186        };
1187        let json = hook_args_to_json(&args).unwrap();
1188        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1189        assert_eq!(parsed["prompt_type"], "search");
1190        assert_eq!(parsed["input"], "test");
1191    }
1192}