Skip to main content

fresh_plugin_runtime/
thread.rs

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