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