1use 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
25pub use fresh_core::config::PluginConfig;
27
28fn 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
38pub type PluginsDirLoadResult = (Vec<String>, HashMap<String, PluginConfig>);
42
43#[derive(Debug)]
45pub enum PluginRequest {
46 LoadPlugin {
48 path: PathBuf,
49 response: oneshot::Sender<Result<()>>,
50 },
51
52 ResolveCallback {
54 callback_id: fresh_core::api::JsCallbackId,
55 result_json: String,
56 },
57
58 RejectCallback {
60 callback_id: fresh_core::api::JsCallbackId,
61 error: String,
62 },
63
64 LoadPluginsFromDir {
66 dir: PathBuf,
67 response: oneshot::Sender<Vec<String>>,
68 },
69
70 LoadPluginsFromDirWithConfig {
74 dir: PathBuf,
75 plugin_configs: HashMap<String, PluginConfig>,
76 response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
77 },
78
79 LoadPluginFromSource {
81 source: String,
82 name: String,
83 is_typescript: bool,
84 response: oneshot::Sender<Result<()>>,
85 },
86
87 UnloadPlugin {
89 name: String,
90 response: oneshot::Sender<Result<()>>,
91 },
92
93 ReloadPlugin {
95 name: String,
96 response: oneshot::Sender<Result<()>>,
97 },
98
99 ExecuteAction {
101 action_name: String,
102 response: oneshot::Sender<Result<()>>,
103 },
104
105 RunHook {
109 hook_name: String,
110 args: HookArgs,
111 target: Option<String>,
112 },
113
114 HasHookHandlers {
116 hook_name: String,
117 response: oneshot::Sender<bool>,
118 },
119
120 ListPlugins {
122 response: oneshot::Sender<Vec<TsPluginInfo>>,
123 },
124
125 TrackAsyncResource {
128 plugin_name: String,
129 resource: TrackedAsyncResource,
130 },
131
132 Shutdown,
134}
135
136#[derive(Debug)]
139pub enum TrackedAsyncResource {
140 VirtualBuffer(fresh_core::BufferId),
141 CompositeBuffer(fresh_core::BufferId),
142 Terminal(fresh_core::TerminalId),
143 WatchHandle(u64),
144}
145
146pub mod oneshot {
148 use std::fmt;
149 use std::sync::mpsc;
150
151 pub struct Sender<T>(mpsc::SyncSender<T>);
152 pub struct Receiver<T>(mpsc::Receiver<T>);
153
154 use anyhow::Result;
155
156 impl<T> fmt::Debug for Sender<T> {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.debug_tuple("Sender").finish()
159 }
160 }
161
162 impl<T> fmt::Debug for Receiver<T> {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.debug_tuple("Receiver").finish()
165 }
166 }
167
168 impl<T> Sender<T> {
169 pub fn send(self, value: T) -> Result<(), T> {
170 self.0.send(value).map_err(|e| e.0)
171 }
172 }
173
174 impl<T> Receiver<T> {
175 pub fn recv(self) -> Result<T, mpsc::RecvError> {
176 self.0.recv()
177 }
178
179 pub fn recv_timeout(
180 self,
181 timeout: std::time::Duration,
182 ) -> Result<T, mpsc::RecvTimeoutError> {
183 self.0.recv_timeout(timeout)
184 }
185
186 pub fn try_recv(&self) -> Result<T, mpsc::TryRecvError> {
187 self.0.try_recv()
188 }
189 }
190
191 pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
192 let (tx, rx) = mpsc::sync_channel(1);
193 (Sender(tx), Receiver(rx))
194 }
195}
196
197pub struct PluginThreadHandle {
199 request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
202
203 thread_handle: Option<JoinHandle<()>>,
205
206 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
208
209 pending_responses: PendingResponses,
211
212 command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
214
215 async_resource_owners: AsyncResourceOwners,
219
220 search_handles: SearchHandleRegistry,
226
227 event_handlers: crate::backend::quickjs_backend::EventHandlerRegistry,
232}
233
234impl PluginThreadHandle {
235 pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
237 tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
238
239 let (command_sender, command_receiver) = std::sync::mpsc::channel();
241
242 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
244
245 let pending_responses: PendingResponses =
247 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
248 let thread_pending_responses = Arc::clone(&pending_responses);
249
250 let async_resource_owners: AsyncResourceOwners =
252 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
253 let thread_async_resource_owners = Arc::clone(&async_resource_owners);
254
255 let search_handles: SearchHandleRegistry =
257 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
258 let thread_search_handles = Arc::clone(&search_handles);
259
260 let event_handlers: crate::backend::quickjs_backend::EventHandlerRegistry =
264 Arc::new(RwLock::new(std::collections::HashMap::new()));
265 let thread_event_handlers = Arc::clone(&event_handlers);
266
267 let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
269
270 let thread_state_snapshot = Arc::clone(&state_snapshot);
272
273 tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
275 let thread_handle = thread::spawn(move || {
276 tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
277 let rt = match tokio::runtime::Builder::new_current_thread()
279 .enable_all()
280 .build()
281 {
282 Ok(rt) => {
283 tracing::debug!("Plugin thread: tokio runtime created successfully");
284 rt
285 }
286 Err(e) => {
287 tracing::error!("Failed to create plugin thread runtime: {}", e);
288 return;
289 }
290 };
291
292 tracing::debug!("Plugin thread: creating QuickJS runtime");
294 let runtime = match QuickJsBackend::with_state_responses_and_resources(
295 Arc::clone(&thread_state_snapshot),
296 command_sender,
297 thread_pending_responses,
298 services.clone(),
299 thread_async_resource_owners,
300 thread_search_handles,
301 thread_event_handlers,
302 ) {
303 Ok(rt) => {
304 tracing::debug!("Plugin thread: QuickJS runtime created successfully");
305 rt
306 }
307 Err(e) => {
308 tracing::error!("Failed to create QuickJS runtime: {}", e);
309 return;
310 }
311 };
312
313 let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
315
316 tracing::debug!("Plugin thread: starting event loop with LocalSet");
318 let local = tokio::task::LocalSet::new();
319 local.block_on(&rt, async {
320 let runtime = Rc::new(RefCell::new(runtime));
322 tracing::debug!("Plugin thread: entering plugin_thread_loop");
323 plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
324 });
325
326 tracing::info!("Plugin thread shutting down");
327 });
328
329 tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
330 tracing::info!("Plugin thread spawned");
331
332 Ok(Self {
333 request_sender: Some(request_sender),
334 thread_handle: Some(thread_handle),
335 state_snapshot,
336 pending_responses,
337 command_receiver,
338 async_resource_owners,
339 search_handles,
340 event_handlers,
341 })
342 }
343
344 pub fn search_handles_handle(&self) -> SearchHandleRegistry {
346 Arc::clone(&self.search_handles)
347 }
348
349 pub fn has_subscribers(&self, hook_name: &str) -> bool {
355 self.event_handlers
356 .read()
357 .map(|h| h.get(hook_name).is_some_and(|v| !v.is_empty()))
358 .unwrap_or(false)
359 }
360
361 pub fn is_alive(&self) -> bool {
363 self.thread_handle
364 .as_ref()
365 .map(|h| !h.is_finished())
366 .unwrap_or(false)
367 }
368
369 pub fn check_thread_health(&mut self) {
373 if let Some(handle) = &self.thread_handle {
374 if handle.is_finished() {
375 tracing::error!(
376 "check_thread_health: plugin thread is finished, checking for panic"
377 );
378 if let Some(handle) = self.thread_handle.take() {
380 match handle.join() {
381 Ok(()) => {
382 tracing::warn!("Plugin thread exited normally (unexpected)");
383 }
384 Err(panic_payload) => {
385 std::panic::resume_unwind(panic_payload);
387 }
388 }
389 }
390 }
391 }
392 }
393
394 pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
398 if respond_to_pending(&self.pending_responses, response.clone()) {
400 return;
401 }
402
403 use fresh_core::api::PluginResponse;
405
406 match response {
407 PluginResponse::VirtualBufferCreated {
408 request_id,
409 buffer_id,
410 split_id,
411 } => {
412 self.track_async_resource(
414 request_id,
415 TrackedAsyncResource::VirtualBuffer(buffer_id),
416 );
417 let result = serde_json::json!({
419 "bufferId": buffer_id.0,
420 "splitId": split_id.map(|s| s.0)
421 });
422 self.resolve_callback(JsCallbackId(request_id), result.to_string());
423 }
424 PluginResponse::LspRequest { request_id, result } => match result {
425 Ok(value) => {
426 self.resolve_callback(JsCallbackId(request_id), value.to_string());
427 }
428 Err(e) => {
429 self.reject_callback(JsCallbackId(request_id), e);
430 }
431 },
432 PluginResponse::HighlightsComputed { request_id, spans } => {
433 self.resolve_json_callback(request_id, &spans, "[]");
434 }
435 PluginResponse::BufferText { request_id, text } => match text {
436 Ok(content) => {
437 let result =
439 serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
440 self.resolve_callback(JsCallbackId(request_id), result);
441 }
442 Err(e) => {
443 self.reject_callback(JsCallbackId(request_id), e);
444 }
445 },
446 PluginResponse::CompositeBufferCreated {
447 request_id,
448 buffer_id,
449 } => {
450 self.track_async_resource(
452 request_id,
453 TrackedAsyncResource::CompositeBuffer(buffer_id),
454 );
455 self.resolve_callback(JsCallbackId(request_id), buffer_id.0.to_string());
457 }
458 PluginResponse::LineStartPosition {
459 request_id,
460 position,
461 } => {
462 self.resolve_json_callback(request_id, position, "null");
463 }
464 PluginResponse::LineEndPosition {
465 request_id,
466 position,
467 } => {
468 self.resolve_json_callback(request_id, position, "null");
469 }
470 PluginResponse::BufferLineCount { request_id, count } => {
471 self.resolve_json_callback(request_id, count, "null");
472 }
473 PluginResponse::TerminalCreated {
474 request_id,
475 buffer_id,
476 terminal_id,
477 split_id,
478 } => {
479 self.track_async_resource(request_id, TrackedAsyncResource::Terminal(terminal_id));
481 let result = serde_json::json!({
482 "bufferId": buffer_id.0,
483 "terminalId": terminal_id.0,
484 "splitId": split_id.map(|s| s.0)
485 });
486 self.resolve_callback(JsCallbackId(request_id), result.to_string());
487 }
488 PluginResponse::SplitByLabel {
489 request_id,
490 split_id,
491 } => {
492 self.resolve_json_callback(request_id, split_id.map(|s| s.0), "null");
493 }
494 PluginResponse::WatchPathRegistered { request_id, result } => match result {
495 Ok(handle) => {
496 self.track_async_resource(
497 request_id,
498 TrackedAsyncResource::WatchHandle(handle),
499 );
500 self.resolve_callback(JsCallbackId(request_id), handle.to_string());
501 }
502 Err(e) => {
503 self.reject_callback(JsCallbackId(request_id), e);
504 }
505 },
506 }
507 }
508
509 fn resolve_json_callback(&self, request_id: u64, value: impl serde::Serialize, fallback: &str) {
512 let result = serde_json::to_string(&value).unwrap_or_else(|_| fallback.to_string());
513 self.resolve_callback(JsCallbackId(request_id), result);
514 }
515
516 fn track_async_resource(&self, request_id: u64, resource: TrackedAsyncResource) {
519 let plugin_name = self
520 .async_resource_owners
521 .lock()
522 .ok()
523 .and_then(|mut owners| owners.remove(&request_id));
524 if let Some(plugin_name) = plugin_name {
525 if let Some(sender) = self.request_sender.as_ref() {
526 fire_and_forget(sender.send(PluginRequest::TrackAsyncResource {
527 plugin_name,
528 resource,
529 }));
530 }
531 }
532 }
533
534 pub fn load_plugin(&self, path: &Path) -> Result<()> {
536 let (tx, rx) = oneshot::channel();
537 self.request_sender
538 .as_ref()
539 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
540 .send(PluginRequest::LoadPlugin {
541 path: path.to_path_buf(),
542 response: tx,
543 })
544 .map_err(|_| anyhow!("Plugin thread not responding"))?;
545
546 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
547 }
548
549 pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
551 let (tx, rx) = oneshot::channel();
552 let Some(sender) = self.request_sender.as_ref() else {
553 return vec!["Plugin thread shut down".to_string()];
554 };
555 if sender
556 .send(PluginRequest::LoadPluginsFromDir {
557 dir: dir.to_path_buf(),
558 response: tx,
559 })
560 .is_err()
561 {
562 return vec!["Plugin thread not responding".to_string()];
563 }
564
565 rx.recv()
566 .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
567 }
568
569 pub fn load_plugins_from_dir_with_config(
573 &self,
574 dir: &Path,
575 plugin_configs: &HashMap<String, PluginConfig>,
576 ) -> (Vec<String>, HashMap<String, PluginConfig>) {
577 let (tx, rx) = oneshot::channel();
578 let Some(sender) = self.request_sender.as_ref() else {
579 return (vec!["Plugin thread shut down".to_string()], HashMap::new());
580 };
581 if sender
582 .send(PluginRequest::LoadPluginsFromDirWithConfig {
583 dir: dir.to_path_buf(),
584 plugin_configs: plugin_configs.clone(),
585 response: tx,
586 })
587 .is_err()
588 {
589 return (
590 vec!["Plugin thread not responding".to_string()],
591 HashMap::new(),
592 );
593 }
594
595 rx.recv()
596 .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
597 }
598
599 pub fn load_plugin_from_source(
604 &self,
605 source: &str,
606 name: &str,
607 is_typescript: bool,
608 ) -> Result<()> {
609 let (tx, rx) = oneshot::channel();
610 self.request_sender
611 .as_ref()
612 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
613 .send(PluginRequest::LoadPluginFromSource {
614 source: source.to_string(),
615 name: name.to_string(),
616 is_typescript,
617 response: tx,
618 })
619 .map_err(|_| anyhow!("Plugin thread not responding"))?;
620
621 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
622 }
623
624 pub fn unload_plugin(&self, name: &str) -> Result<()> {
626 let (tx, rx) = oneshot::channel();
627 self.request_sender
628 .as_ref()
629 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
630 .send(PluginRequest::UnloadPlugin {
631 name: name.to_string(),
632 response: tx,
633 })
634 .map_err(|_| anyhow!("Plugin thread not responding"))?;
635
636 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
637 }
638
639 pub fn reload_plugin(&self, name: &str) -> Result<()> {
641 let (tx, rx) = oneshot::channel();
642 self.request_sender
643 .as_ref()
644 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
645 .send(PluginRequest::ReloadPlugin {
646 name: name.to_string(),
647 response: tx,
648 })
649 .map_err(|_| anyhow!("Plugin thread not responding"))?;
650
651 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
652 }
653
654 pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
659 tracing::trace!("execute_action_async: starting action '{}'", action_name);
660 let (tx, rx) = oneshot::channel();
661 self.request_sender
662 .as_ref()
663 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
664 .send(PluginRequest::ExecuteAction {
665 action_name: action_name.to_string(),
666 response: tx,
667 })
668 .map_err(|_| anyhow!("Plugin thread not responding"))?;
669
670 tracing::trace!("execute_action_async: request sent for '{}'", action_name);
671 Ok(rx)
672 }
673
674 pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
680 if let Some(sender) = self.request_sender.as_ref() {
681 fire_and_forget(sender.send(PluginRequest::RunHook {
682 hook_name: hook_name.to_string(),
683 args,
684 target: None,
685 }));
686 }
687 }
688
689 pub fn run_hook_for_plugin(&self, plugin: &str, hook_name: &str, args: HookArgs) {
693 if let Some(sender) = self.request_sender.as_ref() {
694 fire_and_forget(sender.send(PluginRequest::RunHook {
695 hook_name: hook_name.to_string(),
696 args,
697 target: Some(plugin.to_string()),
698 }));
699 }
700 }
701
702 pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
704 let (tx, rx) = oneshot::channel();
705 let Some(sender) = self.request_sender.as_ref() else {
706 return false;
707 };
708 if sender
709 .send(PluginRequest::HasHookHandlers {
710 hook_name: hook_name.to_string(),
711 response: tx,
712 })
713 .is_err()
714 {
715 return false;
716 }
717
718 rx.recv().unwrap_or(false)
719 }
720
721 pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
723 let (tx, rx) = oneshot::channel();
724 let Some(sender) = self.request_sender.as_ref() else {
725 return vec![];
726 };
727 if sender
728 .send(PluginRequest::ListPlugins { response: tx })
729 .is_err()
730 {
731 return vec![];
732 }
733
734 rx.recv().unwrap_or_default()
735 }
736
737 pub fn load_plugins_from_dir_with_config_request(
741 &self,
742 dir: &Path,
743 plugin_configs: &HashMap<String, PluginConfig>,
744 ) -> Result<oneshot::Receiver<PluginsDirLoadResult>> {
745 let (tx, rx) = oneshot::channel();
746 self.request_sender
747 .as_ref()
748 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
749 .send(PluginRequest::LoadPluginsFromDirWithConfig {
750 dir: dir.to_path_buf(),
751 plugin_configs: plugin_configs.clone(),
752 response: tx,
753 })
754 .map_err(|_| anyhow!("Plugin thread not responding"))?;
755 Ok(rx)
756 }
757
758 pub fn load_plugin_from_source_request(
761 &self,
762 source: &str,
763 name: &str,
764 is_typescript: bool,
765 ) -> Result<oneshot::Receiver<Result<()>>> {
766 let (tx, rx) = oneshot::channel();
767 self.request_sender
768 .as_ref()
769 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
770 .send(PluginRequest::LoadPluginFromSource {
771 source: source.to_string(),
772 name: name.to_string(),
773 is_typescript,
774 response: tx,
775 })
776 .map_err(|_| anyhow!("Plugin thread not responding"))?;
777 Ok(rx)
778 }
779
780 pub fn list_plugins_request(&self) -> Result<oneshot::Receiver<Vec<TsPluginInfo>>> {
785 let (tx, rx) = oneshot::channel();
786 self.request_sender
787 .as_ref()
788 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
789 .send(PluginRequest::ListPlugins { response: tx })
790 .map_err(|_| anyhow!("Plugin thread not responding"))?;
791 Ok(rx)
792 }
793
794 pub fn process_commands(&mut self) -> Vec<PluginCommand> {
799 let mut commands = Vec::new();
800 while let Ok(cmd) = self.command_receiver.try_recv() {
801 commands.push(cmd);
802 }
803 commands
804 }
805
806 pub fn process_commands_until_hook_completed(
816 &mut self,
817 hook_name: &str,
818 timeout: std::time::Duration,
819 ) -> Vec<PluginCommand> {
820 let mut commands = Vec::new();
821 let deadline = std::time::Instant::now() + timeout;
822
823 loop {
824 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
825 if remaining.is_zero() {
826 while let Ok(cmd) = self.command_receiver.try_recv() {
828 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
829 commands.push(cmd);
830 }
831 }
832 break;
833 }
834
835 match self.command_receiver.recv_timeout(remaining) {
836 Ok(PluginCommand::HookCompleted {
837 hook_name: ref name,
838 }) if name == hook_name => {
839 while let Ok(cmd) = self.command_receiver.try_recv() {
841 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
842 commands.push(cmd);
843 }
844 }
845 break;
846 }
847 Ok(PluginCommand::HookCompleted { .. }) => {
848 continue;
850 }
851 Ok(cmd) => {
852 commands.push(cmd);
853 }
854 Err(_) => {
855 break;
857 }
858 }
859 }
860
861 commands
862 }
863
864 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
866 Arc::clone(&self.state_snapshot)
867 }
868
869 pub fn shutdown(&mut self) {
871 tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
872
873 if let Ok(mut pending) = self.pending_responses.lock() {
876 if !pending.is_empty() {
877 tracing::warn!(
878 "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
879 pending.len(),
880 pending.keys().collect::<Vec<_>>()
881 );
882 pending.clear(); }
884 }
885
886 if let Some(sender) = self.request_sender.as_ref() {
888 tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
889 fire_and_forget(sender.send(PluginRequest::Shutdown));
890 }
891
892 tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
895 self.request_sender.take();
896
897 if let Some(handle) = self.thread_handle.take() {
898 tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
899 if handle.join().is_err() {
900 tracing::trace!("plugin thread panicked during join");
901 }
902 tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
903 }
904
905 tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
906 }
907
908 pub fn resolve_callback(
911 &self,
912 callback_id: fresh_core::api::JsCallbackId,
913 result_json: String,
914 ) {
915 if let Some(sender) = self.request_sender.as_ref() {
916 fire_and_forget(sender.send(PluginRequest::ResolveCallback {
917 callback_id,
918 result_json,
919 }));
920 }
921 }
922
923 pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
926 if let Some(sender) = self.request_sender.as_ref() {
927 fire_and_forget(sender.send(PluginRequest::RejectCallback { callback_id, error }));
928 }
929 }
930}
931
932impl Drop for PluginThreadHandle {
933 fn drop(&mut self) {
934 self.shutdown();
935 }
936}
937
938fn respond_to_pending(
939 pending_responses: &PendingResponses,
940 response: fresh_core::api::PluginResponse,
941) -> bool {
942 let request_id = response.request_id();
943 let sender = {
944 let mut pending = pending_responses.lock().unwrap();
945 pending.remove(&request_id)
946 };
947
948 if let Some(tx) = sender {
949 fire_and_forget(tx.send(response));
950 true
951 } else {
952 false
953 }
954}
955
956#[cfg(test)]
957mod plugin_thread_tests {
958 use super::*;
959 use fresh_core::api::PluginResponse;
960 use serde_json::json;
961 use std::collections::HashMap;
962 use std::sync::{Arc, Mutex};
963 use tokio::sync::oneshot;
964
965 #[test]
966 fn respond_to_pending_sends_lsp_response() {
967 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
968 let (tx, mut rx) = oneshot::channel();
969 pending.lock().unwrap().insert(123, tx);
970
971 respond_to_pending(
972 &pending,
973 PluginResponse::LspRequest {
974 request_id: 123,
975 result: Ok(json!({ "key": "value" })),
976 },
977 );
978
979 let response = rx.try_recv().expect("expected response");
980 match response {
981 PluginResponse::LspRequest { result, .. } => {
982 assert_eq!(result.unwrap(), json!({ "key": "value" }));
983 }
984 _ => panic!("unexpected variant"),
985 }
986
987 assert!(pending.lock().unwrap().is_empty());
988 }
989
990 #[test]
991 fn respond_to_pending_handles_virtual_buffer_created() {
992 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
993 let (tx, mut rx) = oneshot::channel();
994 pending.lock().unwrap().insert(456, tx);
995
996 respond_to_pending(
997 &pending,
998 PluginResponse::VirtualBufferCreated {
999 request_id: 456,
1000 buffer_id: fresh_core::BufferId(7),
1001 split_id: Some(fresh_core::SplitId(1)),
1002 },
1003 );
1004
1005 let response = rx.try_recv().expect("expected response");
1006 match response {
1007 PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
1008 assert_eq!(buffer_id.0, 7);
1009 }
1010 _ => panic!("unexpected variant"),
1011 }
1012
1013 assert!(pending.lock().unwrap().is_empty());
1014 }
1015}
1016
1017async fn plugin_thread_loop(
1023 runtime: Rc<RefCell<QuickJsBackend>>,
1024 plugins: &mut HashMap<String, TsPluginInfo>,
1025 mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
1026) {
1027 tracing::info!("Plugin thread event loop started");
1028
1029 let poll_interval = Duration::from_millis(1);
1031 let mut has_pending_work = false;
1032
1033 loop {
1034 if crate::backend::has_fatal_js_error() {
1038 if let Some(error_msg) = crate::backend::take_fatal_js_error() {
1039 tracing::error!(
1040 "Fatal JS error detected, terminating plugin thread: {}",
1041 error_msg
1042 );
1043 panic!("Fatal plugin error: {}", error_msg);
1044 }
1045 }
1046
1047 tokio::select! {
1048 biased; request = request_receiver.recv() => {
1051 match request {
1052 Some(PluginRequest::ExecuteAction {
1053 action_name,
1054 response,
1055 }) => {
1056 let result = runtime.borrow_mut().start_action(&action_name);
1059 fire_and_forget(response.send(result));
1060 has_pending_work = true; }
1062 Some(request) => {
1063 let should_shutdown =
1064 handle_request(request, Rc::clone(&runtime), plugins).await;
1065
1066 if should_shutdown {
1067 break;
1068 }
1069 has_pending_work = true; }
1071 None => {
1072 tracing::info!("Plugin thread request channel closed");
1074 break;
1075 }
1076 }
1077 }
1078
1079 _ = tokio::time::sleep(poll_interval), if has_pending_work => {
1081 has_pending_work = runtime.borrow_mut().poll_event_loop_once();
1082 }
1083 }
1084 }
1085}
1086
1087#[allow(clippy::await_holding_refcell_ref)]
1095async fn run_hook_internal_rc(
1096 runtime: Rc<RefCell<QuickJsBackend>>,
1097 hook_name: &str,
1098 args: &HookArgs,
1099 target: Option<&str>,
1100) -> Result<()> {
1101 let json_start = std::time::Instant::now();
1104 let json_data = fresh_core::hooks::hook_args_to_json(args)?;
1105 tracing::trace!(
1106 hook = hook_name,
1107 json_us = json_start.elapsed().as_micros(),
1108 "hook args serialized"
1109 );
1110
1111 let emit_start = std::time::Instant::now();
1113 runtime
1114 .borrow_mut()
1115 .emit_to(hook_name, &json_data, target)
1116 .await?;
1117 tracing::trace!(
1118 hook = hook_name,
1119 emit_ms = emit_start.elapsed().as_millis(),
1120 "emit completed"
1121 );
1122
1123 Ok(())
1124}
1125
1126#[allow(clippy::await_holding_refcell_ref)]
1128async fn handle_request(
1129 request: PluginRequest,
1130 runtime: Rc<RefCell<QuickJsBackend>>,
1131 plugins: &mut HashMap<String, TsPluginInfo>,
1132) -> bool {
1133 match request {
1134 PluginRequest::LoadPlugin { path, response } => {
1135 let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
1136 fire_and_forget(response.send(result));
1137 }
1138
1139 PluginRequest::LoadPluginsFromDir { dir, response } => {
1140 let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
1141 fire_and_forget(response.send(errors));
1142 }
1143
1144 PluginRequest::LoadPluginsFromDirWithConfig {
1145 dir,
1146 plugin_configs,
1147 response,
1148 } => {
1149 let (errors, discovered) = load_plugins_from_dir_with_config_internal(
1150 Rc::clone(&runtime),
1151 plugins,
1152 &dir,
1153 &plugin_configs,
1154 )
1155 .await;
1156 fire_and_forget(response.send((errors, discovered)));
1157 }
1158
1159 PluginRequest::LoadPluginFromSource {
1160 source,
1161 name,
1162 is_typescript,
1163 response,
1164 } => {
1165 let result = load_plugin_from_source_internal(
1166 Rc::clone(&runtime),
1167 plugins,
1168 &source,
1169 &name,
1170 is_typescript,
1171 );
1172 fire_and_forget(response.send(result));
1173 }
1174
1175 PluginRequest::UnloadPlugin { name, response } => {
1176 let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
1177 fire_and_forget(response.send(result));
1178 }
1179
1180 PluginRequest::ReloadPlugin { name, response } => {
1181 let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
1182 fire_and_forget(response.send(result));
1183 }
1184
1185 PluginRequest::ExecuteAction {
1186 action_name,
1187 response,
1188 } => {
1189 tracing::error!(
1192 "ExecuteAction should be handled in main loop, not here: {}",
1193 action_name
1194 );
1195 fire_and_forget(response.send(Err(anyhow::anyhow!(
1196 "Internal error: ExecuteAction in wrong handler"
1197 ))));
1198 }
1199
1200 PluginRequest::RunHook {
1201 hook_name,
1202 args,
1203 target,
1204 } => {
1205 let hook_start = std::time::Instant::now();
1207 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1209 tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
1210 } else {
1211 tracing::trace!(hook = %hook_name, "RunHook request received");
1212 }
1213 if let Err(e) =
1214 run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args, target.as_deref())
1215 .await
1216 {
1217 let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
1218 tracing::error!("{}", error_msg);
1219 runtime.borrow_mut().send_status(error_msg);
1221 }
1222 runtime.borrow().send_hook_completed(hook_name.clone());
1225 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1226 tracing::info!(
1227 hook = %hook_name,
1228 elapsed_ms = hook_start.elapsed().as_millis(),
1229 "RunHook completed (prompt hook)"
1230 );
1231 } else {
1232 tracing::trace!(
1233 hook = %hook_name,
1234 elapsed_ms = hook_start.elapsed().as_millis(),
1235 "RunHook completed"
1236 );
1237 }
1238 }
1239
1240 PluginRequest::HasHookHandlers {
1241 hook_name,
1242 response,
1243 } => {
1244 let has_handlers = runtime.borrow().has_handlers(&hook_name);
1245 fire_and_forget(response.send(has_handlers));
1246 }
1247
1248 PluginRequest::ListPlugins { response } => {
1249 let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
1250 fire_and_forget(response.send(plugin_list));
1251 }
1252
1253 PluginRequest::ResolveCallback {
1254 callback_id,
1255 result_json,
1256 } => {
1257 tracing::trace!(
1262 "ResolveCallback: resolving callback_id={} with result_json={}",
1263 callback_id,
1264 result_json
1265 );
1266 runtime
1267 .borrow_mut()
1268 .resolve_callback(callback_id, &result_json);
1269 tracing::trace!(
1271 "ResolveCallback: done resolving callback_id={}",
1272 callback_id
1273 );
1274 }
1275
1276 PluginRequest::RejectCallback { callback_id, error } => {
1277 runtime.borrow_mut().reject_callback(callback_id, &error);
1278 }
1280
1281 PluginRequest::TrackAsyncResource {
1282 plugin_name,
1283 resource,
1284 } => {
1285 let rt = runtime.borrow();
1286 let mut tracked = rt.plugin_tracked_state.borrow_mut();
1287 let state = tracked.entry(plugin_name).or_default();
1288 match resource {
1289 TrackedAsyncResource::VirtualBuffer(buffer_id) => {
1290 state.virtual_buffer_ids.push(buffer_id);
1291 }
1292 TrackedAsyncResource::CompositeBuffer(buffer_id) => {
1293 state.composite_buffer_ids.push(buffer_id);
1294 }
1295 TrackedAsyncResource::Terminal(terminal_id) => {
1296 state.terminal_ids.push(terminal_id);
1297 }
1298 TrackedAsyncResource::WatchHandle(handle) => {
1299 state.watch_handles.push(handle);
1300 }
1301 }
1302 }
1303
1304 PluginRequest::Shutdown => {
1305 tracing::info!("Plugin thread received shutdown request");
1306 return true;
1307 }
1308 }
1309
1310 false
1311}
1312
1313struct PreparedPlugin {
1316 name: String,
1317 path: PathBuf,
1318 js_code: String,
1319 i18n: Option<HashMap<String, HashMap<String, String>>>,
1320 dependencies: Vec<String>,
1321 declarations: Option<String>,
1329}
1330
1331fn prepare_plugin(path: &Path) -> Result<PreparedPlugin> {
1336 let plugin_name = path
1337 .file_stem()
1338 .and_then(|s| s.to_str())
1339 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1340 .to_string();
1341
1342 let source = std::fs::read_to_string(path)
1343 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path.display(), e))?;
1344
1345 let filename = path
1346 .file_name()
1347 .and_then(|s| s.to_str())
1348 .unwrap_or("plugin.ts");
1349
1350 let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1352
1353 let declarations = if filename.ends_with(".ts") {
1360 match fresh_parser_js::emit_isolated_declarations(&source, filename) {
1361 Ok(dts) => Some(dts),
1362 Err(e) => {
1363 tracing::warn!(
1364 "Plugin {} isolated-declarations emit failed: {}",
1365 path.display(),
1366 e
1367 );
1368 None
1369 }
1370 }
1371 } else {
1372 None
1373 };
1374
1375 let js_code = if fresh_parser_js::has_es_imports(&source) {
1377 match fresh_parser_js::bundle_module(path) {
1378 Ok(bundled) => bundled,
1379 Err(e) => {
1380 tracing::warn!(
1381 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
1382 path.display(),
1383 e
1384 );
1385 return Err(anyhow!("Bundling failed for {}: {}", plugin_name, e));
1386 }
1387 }
1388 } else if fresh_parser_js::has_es_module_syntax(&source) {
1389 let stripped = fresh_parser_js::strip_imports_and_exports(&source);
1390 if filename.ends_with(".ts") {
1391 fresh_parser_js::transpile_typescript(&stripped, filename)?
1392 } else {
1393 stripped
1394 }
1395 } else if filename.ends_with(".ts") {
1396 fresh_parser_js::transpile_typescript(&source, filename)?
1397 } else {
1398 source
1399 };
1400
1401 let i18n_path = path.with_extension("i18n.json");
1403 let i18n = if i18n_path.exists() {
1404 std::fs::read_to_string(&i18n_path)
1405 .ok()
1406 .and_then(|content| serde_json::from_str(&content).ok())
1407 } else {
1408 None
1409 };
1410
1411 Ok(PreparedPlugin {
1412 name: plugin_name,
1413 path: path.to_path_buf(),
1414 js_code,
1415 i18n,
1416 dependencies,
1417 declarations,
1418 })
1419}
1420
1421fn execute_prepared_plugin(
1424 runtime: &Rc<RefCell<QuickJsBackend>>,
1425 plugins: &mut HashMap<String, TsPluginInfo>,
1426 prepared: &PreparedPlugin,
1427) -> Result<()> {
1428 if let Some(ref i18n) = prepared.i18n {
1430 runtime
1431 .borrow_mut()
1432 .services
1433 .register_plugin_strings(&prepared.name, i18n.clone());
1434 tracing::debug!("Loaded i18n strings for plugin '{}'", prepared.name);
1435 }
1436
1437 let path_str = prepared
1438 .path
1439 .to_str()
1440 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1441
1442 let exec_start = std::time::Instant::now();
1443 runtime
1444 .borrow_mut()
1445 .execute_js(&prepared.js_code, path_str)?;
1446 let exec_elapsed = exec_start.elapsed();
1447
1448 tracing::debug!(
1449 "execute_prepared_plugin: plugin '{}' executed in {:?}",
1450 prepared.name,
1451 exec_elapsed
1452 );
1453
1454 plugins.insert(
1455 prepared.name.clone(),
1456 TsPluginInfo {
1457 name: prepared.name.clone(),
1458 path: prepared.path.clone(),
1459 enabled: true,
1460 declarations: prepared.declarations.clone(),
1461 },
1462 );
1463
1464 Ok(())
1465}
1466
1467#[allow(clippy::await_holding_refcell_ref)]
1468async fn load_plugin_internal(
1469 runtime: Rc<RefCell<QuickJsBackend>>,
1470 plugins: &mut HashMap<String, TsPluginInfo>,
1471 path: &Path,
1472) -> Result<()> {
1473 let plugin_name = path
1474 .file_stem()
1475 .and_then(|s| s.to_str())
1476 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1477 .to_string();
1478
1479 tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
1480 tracing::debug!(
1481 "load_plugin_internal: starting module load for plugin '{}'",
1482 plugin_name
1483 );
1484
1485 let path_str = path
1487 .to_str()
1488 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1489
1490 let i18n_path = path.with_extension("i18n.json");
1492 if i18n_path.exists() {
1493 if let Ok(content) = std::fs::read_to_string(&i18n_path) {
1494 if let Ok(strings) = serde_json::from_str::<
1495 std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1496 >(&content)
1497 {
1498 runtime
1499 .borrow_mut()
1500 .services
1501 .register_plugin_strings(&plugin_name, strings);
1502 tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
1503 }
1504 }
1505 }
1506
1507 let load_start = std::time::Instant::now();
1508 runtime
1509 .borrow_mut()
1510 .load_module_with_source(path_str, &plugin_name)
1511 .await?;
1512 let load_elapsed = load_start.elapsed();
1513
1514 tracing::debug!(
1515 "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
1516 plugin_name,
1517 load_elapsed
1518 );
1519
1520 plugins.insert(
1522 plugin_name.clone(),
1523 TsPluginInfo {
1524 name: plugin_name.clone(),
1525 path: path.to_path_buf(),
1526 enabled: true,
1527 declarations: None,
1531 },
1532 );
1533
1534 tracing::debug!(
1535 "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
1536 plugin_name,
1537 plugins.len()
1538 );
1539
1540 Ok(())
1541}
1542
1543async fn load_plugins_from_dir_internal(
1545 runtime: Rc<RefCell<QuickJsBackend>>,
1546 plugins: &mut HashMap<String, TsPluginInfo>,
1547 dir: &Path,
1548) -> Vec<String> {
1549 tracing::debug!(
1550 "load_plugins_from_dir_internal: scanning directory {:?}",
1551 dir
1552 );
1553 let mut errors = Vec::new();
1554
1555 if !dir.exists() {
1556 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1557 return errors;
1558 }
1559
1560 match std::fs::read_dir(dir) {
1562 Ok(entries) => {
1563 for entry in entries.flatten() {
1564 let path = entry.path();
1565 let ext = path.extension().and_then(|s| s.to_str());
1566 if ext == Some("ts") || ext == Some("js") {
1567 tracing::debug!(
1568 "load_plugins_from_dir_internal: attempting to load {:?}",
1569 path
1570 );
1571 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
1572 {
1573 let err = format!("Failed to load {:?}: {}", path, e);
1574 tracing::error!("{}", err);
1575 errors.push(err);
1576 }
1577 }
1578 }
1579
1580 tracing::debug!(
1581 "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1582 dir,
1583 errors.len()
1584 );
1585 }
1586 Err(e) => {
1587 let err = format!("Failed to read plugin directory: {}", e);
1588 tracing::error!("{}", err);
1589 errors.push(err);
1590 }
1591 }
1592
1593 errors
1594}
1595
1596async fn load_plugins_from_dir_with_config_internal(
1600 runtime: Rc<RefCell<QuickJsBackend>>,
1601 plugins: &mut HashMap<String, TsPluginInfo>,
1602 dir: &Path,
1603 plugin_configs: &HashMap<String, PluginConfig>,
1604) -> (Vec<String>, HashMap<String, PluginConfig>) {
1605 tracing::debug!(
1606 "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1607 dir
1608 );
1609 let mut errors = Vec::new();
1610 let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1611
1612 if !dir.exists() {
1613 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1614 return (errors, discovered_plugins);
1615 }
1616
1617 let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1619 match std::fs::read_dir(dir) {
1620 Ok(entries) => {
1621 for entry in entries.flatten() {
1622 let path = entry.path();
1623 let ext = path.extension().and_then(|s| s.to_str());
1624 if ext == Some("ts") || ext == Some("js") {
1625 if path.to_string_lossy().contains(".i18n.") {
1627 continue;
1628 }
1629 let plugin_name = path
1631 .file_stem()
1632 .and_then(|s| s.to_str())
1633 .unwrap_or("unknown")
1634 .to_string();
1635 plugin_files.push((plugin_name, path));
1636 }
1637 }
1638 }
1639 Err(e) => {
1640 let err = format!("Failed to read plugin directory: {}", e);
1641 tracing::error!("{}", err);
1642 errors.push(err);
1643 return (errors, discovered_plugins);
1644 }
1645 }
1646
1647 let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1649 for (plugin_name, path) in plugin_files {
1650 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1652 PluginConfig {
1654 enabled: existing_config.enabled,
1655 path: Some(path.clone()),
1656 settings: existing_config.settings.clone(),
1657 }
1658 } else {
1659 PluginConfig::new_with_path(path.clone())
1661 };
1662
1663 discovered_plugins.insert(plugin_name.clone(), config.clone());
1665
1666 if config.enabled {
1667 enabled_plugins.push((plugin_name, path));
1668 } else {
1669 tracing::info!(
1670 "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1671 plugin_name
1672 );
1673 }
1674 }
1675
1676 let prep_start = std::time::Instant::now();
1679 let paths: Vec<std::path::PathBuf> = enabled_plugins.iter().map(|(_, p)| p.clone()).collect();
1680 let prepared_results: Vec<(String, Result<PreparedPlugin>)> = std::thread::scope(|scope| {
1681 let handles: Vec<_> = paths
1682 .iter()
1683 .map(|path| {
1684 let path = path.clone();
1685 scope.spawn(move || {
1686 let name = path
1687 .file_stem()
1688 .and_then(|s| s.to_str())
1689 .unwrap_or("unknown")
1690 .to_string();
1691 let result = prepare_plugin(&path);
1692 (name, result)
1693 })
1694 })
1695 .collect();
1696 handles.into_iter().map(|h| h.join().unwrap()).collect()
1697 });
1698 let prep_elapsed = prep_start.elapsed();
1699
1700 let mut prepared_map: std::collections::HashMap<String, PreparedPlugin> =
1702 std::collections::HashMap::new();
1703 for (name, result) in prepared_results {
1704 match result {
1705 Ok(prepared) => {
1706 prepared_map.insert(name, prepared);
1707 }
1708 Err(e) => {
1709 let err = format!("Failed to prepare plugin '{}': {}", name, e);
1710 tracing::error!("{}", err);
1711 errors.push(err);
1712 }
1713 }
1714 }
1715
1716 tracing::info!(
1717 "Parallel plugin preparation completed in {:?} ({} plugins)",
1718 prep_elapsed,
1719 prepared_map.len()
1720 );
1721
1722 let mut dependency_map: std::collections::HashMap<String, Vec<String>> =
1724 std::collections::HashMap::new();
1725 for (name, prepared) in &prepared_map {
1726 if !prepared.dependencies.is_empty() {
1727 tracing::debug!(
1728 "Plugin '{}' declares dependencies: {:?}",
1729 name,
1730 prepared.dependencies
1731 );
1732 dependency_map.insert(name.clone(), prepared.dependencies.clone());
1733 }
1734 }
1735
1736 let plugin_names: Vec<String> = prepared_map.keys().cloned().collect();
1738 let load_order = match fresh_parser_js::topological_sort_plugins(&plugin_names, &dependency_map)
1739 {
1740 Ok(order) => order,
1741 Err(e) => {
1742 let err = format!("Plugin dependency resolution failed: {}", e);
1743 tracing::error!("{}", err);
1744 errors.push(err);
1745 let mut names = plugin_names;
1747 names.sort();
1748 names
1749 }
1750 };
1751
1752 let exec_start = std::time::Instant::now();
1754 for plugin_name in load_order {
1755 if let Some(prepared) = prepared_map.get(&plugin_name) {
1756 tracing::debug!(
1757 "load_plugins_from_dir_with_config_internal: executing plugin '{}'",
1758 plugin_name
1759 );
1760 if let Err(e) = execute_prepared_plugin(&runtime, plugins, prepared) {
1761 let err = format!("Failed to execute plugin '{}': {}", plugin_name, e);
1762 tracing::error!("{}", err);
1763 errors.push(err);
1764 }
1765 }
1766 }
1767 let exec_elapsed = exec_start.elapsed();
1768
1769 tracing::info!(
1770 "Serial plugin execution completed in {:?} ({} plugins)",
1771 exec_elapsed,
1772 plugins.len()
1773 );
1774
1775 tracing::debug!(
1776 "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors (prep: {:?}, exec: {:?})",
1777 discovered_plugins.len(),
1778 errors.len(),
1779 prep_elapsed,
1780 exec_elapsed
1781 );
1782
1783 (errors, discovered_plugins)
1784}
1785
1786fn load_plugin_from_source_internal(
1791 runtime: Rc<RefCell<QuickJsBackend>>,
1792 plugins: &mut HashMap<String, TsPluginInfo>,
1793 source: &str,
1794 name: &str,
1795 is_typescript: bool,
1796) -> Result<()> {
1797 if plugins.contains_key(name) {
1799 tracing::info!(
1800 "Hot-reloading buffer plugin '{}' — unloading previous version",
1801 name
1802 );
1803 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1804 }
1805
1806 tracing::info!("Loading plugin from source: {}", name);
1807
1808 runtime
1809 .borrow_mut()
1810 .execute_source(source, name, is_typescript)?;
1811
1812 plugins.insert(
1814 name.to_string(),
1815 TsPluginInfo {
1816 name: name.to_string(),
1817 path: PathBuf::from(format!("<buffer:{}>", name)),
1818 enabled: true,
1819 declarations: None,
1824 },
1825 );
1826
1827 tracing::info!(
1828 "Buffer plugin '{}' loaded successfully, total plugins: {}",
1829 name,
1830 plugins.len()
1831 );
1832
1833 Ok(())
1834}
1835
1836fn unload_plugin_internal(
1838 runtime: Rc<RefCell<QuickJsBackend>>,
1839 plugins: &mut HashMap<String, TsPluginInfo>,
1840 name: &str,
1841) -> Result<()> {
1842 if plugins.remove(name).is_some() {
1843 tracing::info!("Unloading TypeScript plugin: {}", name);
1844
1845 runtime
1847 .borrow_mut()
1848 .services
1849 .unregister_plugin_strings(name);
1850
1851 runtime
1853 .borrow()
1854 .services
1855 .unregister_commands_by_plugin(name);
1856
1857 runtime.borrow().cleanup_plugin(name);
1859
1860 Ok(())
1861 } else {
1862 Err(anyhow!("Plugin '{}' not found", name))
1863 }
1864}
1865
1866async fn reload_plugin_internal(
1868 runtime: Rc<RefCell<QuickJsBackend>>,
1869 plugins: &mut HashMap<String, TsPluginInfo>,
1870 name: &str,
1871) -> Result<()> {
1872 let path = plugins
1873 .get(name)
1874 .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1875 .path
1876 .clone();
1877
1878 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1879 load_plugin_internal(runtime, plugins, &path).await?;
1880
1881 Ok(())
1882}
1883
1884#[cfg(test)]
1885mod tests {
1886 use super::*;
1887 use fresh_core::hooks::hook_args_to_json;
1888
1889 #[test]
1890 fn test_oneshot_channel() {
1891 let (tx, rx) = oneshot::channel::<i32>();
1892 assert!(tx.send(42).is_ok());
1893 assert_eq!(rx.recv().unwrap(), 42);
1894 }
1895
1896 #[test]
1897 fn test_hook_args_to_json_editor_initialized() {
1898 let args = HookArgs::EditorInitialized {};
1899 let json = hook_args_to_json(&args).unwrap();
1900 assert_eq!(json, serde_json::json!({}));
1901 }
1902
1903 #[test]
1904 fn test_hook_args_to_json_prompt_changed() {
1905 let args = HookArgs::PromptChanged {
1906 prompt_type: "search".to_string(),
1907 input: "test".to_string(),
1908 };
1909 let json = hook_args_to_json(&args).unwrap();
1910 assert_eq!(json["prompt_type"], "search");
1911 assert_eq!(json["input"], "test");
1912 }
1913}