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