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 { hook_name: String, args: HookArgs },
107
108 HasHookHandlers {
110 hook_name: String,
111 response: oneshot::Sender<bool>,
112 },
113
114 ListPlugins {
116 response: oneshot::Sender<Vec<TsPluginInfo>>,
117 },
118
119 TrackAsyncResource {
122 plugin_name: String,
123 resource: TrackedAsyncResource,
124 },
125
126 Shutdown,
128}
129
130#[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
140pub 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
191pub struct PluginThreadHandle {
193 request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
196
197 thread_handle: Option<JoinHandle<()>>,
199
200 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
202
203 pending_responses: PendingResponses,
205
206 command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
208
209 async_resource_owners: AsyncResourceOwners,
213
214 search_handles: SearchHandleRegistry,
220
221 event_handlers: crate::backend::quickjs_backend::EventHandlerRegistry,
226}
227
228impl PluginThreadHandle {
229 pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
231 tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
232
233 let (command_sender, command_receiver) = std::sync::mpsc::channel();
235
236 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
238
239 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 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 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 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 let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
263
264 let thread_state_snapshot = Arc::clone(&state_snapshot);
266
267 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 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 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 let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
309
310 tracing::debug!("Plugin thread: starting event loop with LocalSet");
312 let local = tokio::task::LocalSet::new();
313 local.block_on(&rt, async {
314 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 pub fn search_handles_handle(&self) -> SearchHandleRegistry {
340 Arc::clone(&self.search_handles)
341 }
342
343 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 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 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 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 std::panic::resume_unwind(panic_payload);
381 }
382 }
383 }
384 }
385 }
386 }
387
388 pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
392 if respond_to_pending(&self.pending_responses, response.clone()) {
394 return;
395 }
396
397 use fresh_core::api::PluginResponse;
399
400 match response {
401 PluginResponse::VirtualBufferCreated {
402 request_id,
403 buffer_id,
404 split_id,
405 } => {
406 self.track_async_resource(
408 request_id,
409 TrackedAsyncResource::VirtualBuffer(buffer_id),
410 );
411 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 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 self.track_async_resource(
446 request_id,
447 TrackedAsyncResource::CompositeBuffer(buffer_id),
448 );
449 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 continue;
830 }
831 Ok(cmd) => {
832 commands.push(cmd);
833 }
834 Err(_) => {
835 break;
837 }
838 }
839 }
840
841 commands
842 }
843
844 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
846 Arc::clone(&self.state_snapshot)
847 }
848
849 pub fn shutdown(&mut self) {
851 tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
852
853 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(); }
864 }
865
866 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 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 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 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
997async 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 let poll_interval = Duration::from_millis(1);
1011 let mut has_pending_work = false;
1012
1013 loop {
1014 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; request = request_receiver.recv() => {
1031 match request {
1032 Some(PluginRequest::ExecuteAction {
1033 action_name,
1034 response,
1035 }) => {
1036 let result = runtime.borrow_mut().start_action(&action_name);
1039 fire_and_forget(response.send(result));
1040 has_pending_work = true; }
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; }
1051 None => {
1052 tracing::info!("Plugin thread request channel closed");
1054 break;
1055 }
1056 }
1057 }
1058
1059 _ = 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#[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 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 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#[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 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 let hook_start = std::time::Instant::now();
1179 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 runtime.borrow_mut().send_status(error_msg);
1190 }
1191 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 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 }
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
1278struct PreparedPlugin {
1281 name: String,
1282 path: PathBuf,
1283 js_code: String,
1284 i18n: Option<HashMap<String, HashMap<String, String>>>,
1285 dependencies: Vec<String>,
1286 declarations: Option<String>,
1294}
1295
1296fn 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 let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1317
1318 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 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 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
1386fn execute_prepared_plugin(
1389 runtime: &Rc<RefCell<QuickJsBackend>>,
1390 plugins: &mut HashMap<String, TsPluginInfo>,
1391 prepared: &PreparedPlugin,
1392) -> Result<()> {
1393 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 let path_str = path
1452 .to_str()
1453 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1454
1455 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 plugins.insert(
1487 plugin_name.clone(),
1488 TsPluginInfo {
1489 name: plugin_name.clone(),
1490 path: path.to_path_buf(),
1491 enabled: true,
1492 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
1508async 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 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
1561async 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 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 if path.to_string_lossy().contains(".i18n.") {
1592 continue;
1593 }
1594 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 let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1614 for (plugin_name, path) in plugin_files {
1615 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1617 PluginConfig {
1619 enabled: existing_config.enabled,
1620 path: Some(path.clone()),
1621 settings: existing_config.settings.clone(),
1622 }
1623 } else {
1624 PluginConfig::new_with_path(path.clone())
1626 };
1627
1628 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 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 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 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 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 let mut names = plugin_names;
1712 names.sort();
1713 names
1714 }
1715 };
1716
1717 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
1751fn 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 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 plugins.insert(
1779 name.to_string(),
1780 TsPluginInfo {
1781 name: name.to_string(),
1782 path: PathBuf::from(format!("<buffer:{}>", name)),
1783 enabled: true,
1784 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
1801fn 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 runtime
1812 .borrow_mut()
1813 .services
1814 .unregister_plugin_strings(name);
1815
1816 runtime
1818 .borrow()
1819 .services
1820 .unregister_commands_by_plugin(name);
1821
1822 runtime.borrow().cleanup_plugin(name);
1824
1825 Ok(())
1826 } else {
1827 Err(anyhow!("Plugin '{}' not found", name))
1828 }
1829}
1830
1831async 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}