1use crate::backend::quickjs_backend::{AsyncResourceOwners, PendingResponses, TsPluginInfo};
13use crate::backend::QuickJsBackend;
14use anyhow::{anyhow, Result};
15use fresh_core::api::{EditorStateSnapshot, JsCallbackId, PluginCommand};
16use fresh_core::hooks::HookArgs;
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::rc::Rc;
21use std::sync::{Arc, RwLock};
22use std::thread::{self, JoinHandle};
23use std::time::Duration;
24
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 CallStreamingCallback {
66 callback_id: fresh_core::api::JsCallbackId,
67 result_json: String,
68 done: bool,
69 },
70
71 LoadPluginsFromDir {
73 dir: PathBuf,
74 response: oneshot::Sender<Vec<String>>,
75 },
76
77 LoadPluginsFromDirWithConfig {
81 dir: PathBuf,
82 plugin_configs: HashMap<String, PluginConfig>,
83 response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
84 },
85
86 LoadPluginFromSource {
88 source: String,
89 name: String,
90 is_typescript: bool,
91 response: oneshot::Sender<Result<()>>,
92 },
93
94 UnloadPlugin {
96 name: String,
97 response: oneshot::Sender<Result<()>>,
98 },
99
100 ReloadPlugin {
102 name: String,
103 response: oneshot::Sender<Result<()>>,
104 },
105
106 ExecuteAction {
108 action_name: String,
109 response: oneshot::Sender<Result<()>>,
110 },
111
112 RunHook { hook_name: String, args: HookArgs },
114
115 HasHookHandlers {
117 hook_name: String,
118 response: oneshot::Sender<bool>,
119 },
120
121 ListPlugins {
123 response: oneshot::Sender<Vec<TsPluginInfo>>,
124 },
125
126 TrackAsyncResource {
129 plugin_name: String,
130 resource: TrackedAsyncResource,
131 },
132
133 Shutdown,
135}
136
137#[derive(Debug)]
140pub enum TrackedAsyncResource {
141 VirtualBuffer(fresh_core::BufferId),
142 CompositeBuffer(fresh_core::BufferId),
143 Terminal(fresh_core::TerminalId),
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
221impl PluginThreadHandle {
222 pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
224 tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
225
226 let (command_sender, command_receiver) = std::sync::mpsc::channel();
228
229 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
231
232 let pending_responses: PendingResponses =
234 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
235 let thread_pending_responses = Arc::clone(&pending_responses);
236
237 let async_resource_owners: AsyncResourceOwners =
239 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
240 let thread_async_resource_owners = Arc::clone(&async_resource_owners);
241
242 let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
244
245 let thread_state_snapshot = Arc::clone(&state_snapshot);
247
248 tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
250 let thread_handle = thread::spawn(move || {
251 tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
252 let rt = match tokio::runtime::Builder::new_current_thread()
254 .enable_all()
255 .build()
256 {
257 Ok(rt) => {
258 tracing::debug!("Plugin thread: tokio runtime created successfully");
259 rt
260 }
261 Err(e) => {
262 tracing::error!("Failed to create plugin thread runtime: {}", e);
263 return;
264 }
265 };
266
267 tracing::debug!("Plugin thread: creating QuickJS runtime");
269 let runtime = match QuickJsBackend::with_state_responses_and_resources(
270 Arc::clone(&thread_state_snapshot),
271 command_sender,
272 thread_pending_responses,
273 services.clone(),
274 thread_async_resource_owners,
275 ) {
276 Ok(rt) => {
277 tracing::debug!("Plugin thread: QuickJS runtime created successfully");
278 rt
279 }
280 Err(e) => {
281 tracing::error!("Failed to create QuickJS runtime: {}", e);
282 return;
283 }
284 };
285
286 let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
288
289 tracing::debug!("Plugin thread: starting event loop with LocalSet");
291 let local = tokio::task::LocalSet::new();
292 local.block_on(&rt, async {
293 let runtime = Rc::new(RefCell::new(runtime));
295 tracing::debug!("Plugin thread: entering plugin_thread_loop");
296 plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
297 });
298
299 tracing::info!("Plugin thread shutting down");
300 });
301
302 tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
303 tracing::info!("Plugin thread spawned");
304
305 Ok(Self {
306 request_sender: Some(request_sender),
307 thread_handle: Some(thread_handle),
308 state_snapshot,
309 pending_responses,
310 command_receiver,
311 async_resource_owners,
312 })
313 }
314
315 pub fn is_alive(&self) -> bool {
317 self.thread_handle
318 .as_ref()
319 .map(|h| !h.is_finished())
320 .unwrap_or(false)
321 }
322
323 pub fn check_thread_health(&mut self) {
327 if let Some(handle) = &self.thread_handle {
328 if handle.is_finished() {
329 tracing::error!(
330 "check_thread_health: plugin thread is finished, checking for panic"
331 );
332 if let Some(handle) = self.thread_handle.take() {
334 match handle.join() {
335 Ok(()) => {
336 tracing::warn!("Plugin thread exited normally (unexpected)");
337 }
338 Err(panic_payload) => {
339 std::panic::resume_unwind(panic_payload);
341 }
342 }
343 }
344 }
345 }
346 }
347
348 pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
352 if respond_to_pending(&self.pending_responses, response.clone()) {
354 return;
355 }
356
357 use fresh_core::api::PluginResponse;
359
360 match response {
361 PluginResponse::VirtualBufferCreated {
362 request_id,
363 buffer_id,
364 split_id,
365 } => {
366 self.track_async_resource(
368 request_id,
369 TrackedAsyncResource::VirtualBuffer(buffer_id),
370 );
371 let result = serde_json::json!({
373 "bufferId": buffer_id.0,
374 "splitId": split_id.map(|s| s.0)
375 });
376 self.resolve_callback(JsCallbackId(request_id), result.to_string());
377 }
378 PluginResponse::LspRequest { request_id, result } => match result {
379 Ok(value) => {
380 self.resolve_callback(JsCallbackId(request_id), value.to_string());
381 }
382 Err(e) => {
383 self.reject_callback(JsCallbackId(request_id), e);
384 }
385 },
386 PluginResponse::HighlightsComputed { request_id, spans } => {
387 self.resolve_json_callback(request_id, &spans, "[]");
388 }
389 PluginResponse::BufferText { request_id, text } => match text {
390 Ok(content) => {
391 let result =
393 serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
394 self.resolve_callback(JsCallbackId(request_id), result);
395 }
396 Err(e) => {
397 self.reject_callback(JsCallbackId(request_id), e);
398 }
399 },
400 PluginResponse::CompositeBufferCreated {
401 request_id,
402 buffer_id,
403 } => {
404 self.track_async_resource(
406 request_id,
407 TrackedAsyncResource::CompositeBuffer(buffer_id),
408 );
409 self.resolve_callback(JsCallbackId(request_id), buffer_id.0.to_string());
411 }
412 PluginResponse::LineStartPosition {
413 request_id,
414 position,
415 } => {
416 self.resolve_json_callback(request_id, position, "null");
417 }
418 PluginResponse::LineEndPosition {
419 request_id,
420 position,
421 } => {
422 self.resolve_json_callback(request_id, position, "null");
423 }
424 PluginResponse::BufferLineCount { request_id, count } => {
425 self.resolve_json_callback(request_id, count, "null");
426 }
427 PluginResponse::TerminalCreated {
428 request_id,
429 buffer_id,
430 terminal_id,
431 split_id,
432 } => {
433 self.track_async_resource(request_id, TrackedAsyncResource::Terminal(terminal_id));
435 let result = serde_json::json!({
436 "bufferId": buffer_id.0,
437 "terminalId": terminal_id.0,
438 "splitId": split_id.map(|s| s.0)
439 });
440 self.resolve_callback(JsCallbackId(request_id), result.to_string());
441 }
442 PluginResponse::SplitByLabel {
443 request_id,
444 split_id,
445 } => {
446 self.resolve_json_callback(request_id, split_id.map(|s| s.0), "null");
447 }
448 }
449 }
450
451 fn resolve_json_callback(&self, request_id: u64, value: impl serde::Serialize, fallback: &str) {
454 let result = serde_json::to_string(&value).unwrap_or_else(|_| fallback.to_string());
455 self.resolve_callback(JsCallbackId(request_id), result);
456 }
457
458 fn track_async_resource(&self, request_id: u64, resource: TrackedAsyncResource) {
461 let plugin_name = self
462 .async_resource_owners
463 .lock()
464 .ok()
465 .and_then(|mut owners| owners.remove(&request_id));
466 if let Some(plugin_name) = plugin_name {
467 if let Some(sender) = self.request_sender.as_ref() {
468 fire_and_forget(sender.send(PluginRequest::TrackAsyncResource {
469 plugin_name,
470 resource,
471 }));
472 }
473 }
474 }
475
476 pub fn load_plugin(&self, path: &Path) -> Result<()> {
478 let (tx, rx) = oneshot::channel();
479 self.request_sender
480 .as_ref()
481 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
482 .send(PluginRequest::LoadPlugin {
483 path: path.to_path_buf(),
484 response: tx,
485 })
486 .map_err(|_| anyhow!("Plugin thread not responding"))?;
487
488 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
489 }
490
491 pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
493 let (tx, rx) = oneshot::channel();
494 let Some(sender) = self.request_sender.as_ref() else {
495 return vec!["Plugin thread shut down".to_string()];
496 };
497 if sender
498 .send(PluginRequest::LoadPluginsFromDir {
499 dir: dir.to_path_buf(),
500 response: tx,
501 })
502 .is_err()
503 {
504 return vec!["Plugin thread not responding".to_string()];
505 }
506
507 rx.recv()
508 .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
509 }
510
511 pub fn load_plugins_from_dir_with_config(
515 &self,
516 dir: &Path,
517 plugin_configs: &HashMap<String, PluginConfig>,
518 ) -> (Vec<String>, HashMap<String, PluginConfig>) {
519 let (tx, rx) = oneshot::channel();
520 let Some(sender) = self.request_sender.as_ref() else {
521 return (vec!["Plugin thread shut down".to_string()], HashMap::new());
522 };
523 if sender
524 .send(PluginRequest::LoadPluginsFromDirWithConfig {
525 dir: dir.to_path_buf(),
526 plugin_configs: plugin_configs.clone(),
527 response: tx,
528 })
529 .is_err()
530 {
531 return (
532 vec!["Plugin thread not responding".to_string()],
533 HashMap::new(),
534 );
535 }
536
537 rx.recv()
538 .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
539 }
540
541 pub fn load_plugin_from_source(
546 &self,
547 source: &str,
548 name: &str,
549 is_typescript: bool,
550 ) -> Result<()> {
551 let (tx, rx) = oneshot::channel();
552 self.request_sender
553 .as_ref()
554 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
555 .send(PluginRequest::LoadPluginFromSource {
556 source: source.to_string(),
557 name: name.to_string(),
558 is_typescript,
559 response: tx,
560 })
561 .map_err(|_| anyhow!("Plugin thread not responding"))?;
562
563 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
564 }
565
566 pub fn unload_plugin(&self, name: &str) -> Result<()> {
568 let (tx, rx) = oneshot::channel();
569 self.request_sender
570 .as_ref()
571 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
572 .send(PluginRequest::UnloadPlugin {
573 name: name.to_string(),
574 response: tx,
575 })
576 .map_err(|_| anyhow!("Plugin thread not responding"))?;
577
578 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
579 }
580
581 pub fn reload_plugin(&self, name: &str) -> Result<()> {
583 let (tx, rx) = oneshot::channel();
584 self.request_sender
585 .as_ref()
586 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
587 .send(PluginRequest::ReloadPlugin {
588 name: name.to_string(),
589 response: tx,
590 })
591 .map_err(|_| anyhow!("Plugin thread not responding"))?;
592
593 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
594 }
595
596 pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
601 tracing::trace!("execute_action_async: starting action '{}'", action_name);
602 let (tx, rx) = oneshot::channel();
603 self.request_sender
604 .as_ref()
605 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
606 .send(PluginRequest::ExecuteAction {
607 action_name: action_name.to_string(),
608 response: tx,
609 })
610 .map_err(|_| anyhow!("Plugin thread not responding"))?;
611
612 tracing::trace!("execute_action_async: request sent for '{}'", action_name);
613 Ok(rx)
614 }
615
616 pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
622 if let Some(sender) = self.request_sender.as_ref() {
623 fire_and_forget(sender.send(PluginRequest::RunHook {
624 hook_name: hook_name.to_string(),
625 args,
626 }));
627 }
628 }
629
630 pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
632 let (tx, rx) = oneshot::channel();
633 let Some(sender) = self.request_sender.as_ref() else {
634 return false;
635 };
636 if sender
637 .send(PluginRequest::HasHookHandlers {
638 hook_name: hook_name.to_string(),
639 response: tx,
640 })
641 .is_err()
642 {
643 return false;
644 }
645
646 rx.recv().unwrap_or(false)
647 }
648
649 pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
651 let (tx, rx) = oneshot::channel();
652 let Some(sender) = self.request_sender.as_ref() else {
653 return vec![];
654 };
655 if sender
656 .send(PluginRequest::ListPlugins { response: tx })
657 .is_err()
658 {
659 return vec![];
660 }
661
662 rx.recv().unwrap_or_default()
663 }
664
665 pub fn load_plugins_from_dir_with_config_request(
669 &self,
670 dir: &Path,
671 plugin_configs: &HashMap<String, PluginConfig>,
672 ) -> Result<oneshot::Receiver<PluginsDirLoadResult>> {
673 let (tx, rx) = oneshot::channel();
674 self.request_sender
675 .as_ref()
676 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
677 .send(PluginRequest::LoadPluginsFromDirWithConfig {
678 dir: dir.to_path_buf(),
679 plugin_configs: plugin_configs.clone(),
680 response: tx,
681 })
682 .map_err(|_| anyhow!("Plugin thread not responding"))?;
683 Ok(rx)
684 }
685
686 pub fn load_plugin_from_source_request(
689 &self,
690 source: &str,
691 name: &str,
692 is_typescript: bool,
693 ) -> Result<oneshot::Receiver<Result<()>>> {
694 let (tx, rx) = oneshot::channel();
695 self.request_sender
696 .as_ref()
697 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
698 .send(PluginRequest::LoadPluginFromSource {
699 source: source.to_string(),
700 name: name.to_string(),
701 is_typescript,
702 response: tx,
703 })
704 .map_err(|_| anyhow!("Plugin thread not responding"))?;
705 Ok(rx)
706 }
707
708 pub fn list_plugins_request(&self) -> Result<oneshot::Receiver<Vec<TsPluginInfo>>> {
713 let (tx, rx) = oneshot::channel();
714 self.request_sender
715 .as_ref()
716 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
717 .send(PluginRequest::ListPlugins { response: tx })
718 .map_err(|_| anyhow!("Plugin thread not responding"))?;
719 Ok(rx)
720 }
721
722 pub fn process_commands(&mut self) -> Vec<PluginCommand> {
727 let mut commands = Vec::new();
728 while let Ok(cmd) = self.command_receiver.try_recv() {
729 commands.push(cmd);
730 }
731 commands
732 }
733
734 pub fn process_commands_until_hook_completed(
744 &mut self,
745 hook_name: &str,
746 timeout: std::time::Duration,
747 ) -> Vec<PluginCommand> {
748 let mut commands = Vec::new();
749 let deadline = std::time::Instant::now() + timeout;
750
751 loop {
752 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
753 if remaining.is_zero() {
754 while let Ok(cmd) = self.command_receiver.try_recv() {
756 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
757 commands.push(cmd);
758 }
759 }
760 break;
761 }
762
763 match self.command_receiver.recv_timeout(remaining) {
764 Ok(PluginCommand::HookCompleted {
765 hook_name: ref name,
766 }) if name == hook_name => {
767 while let Ok(cmd) = self.command_receiver.try_recv() {
769 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
770 commands.push(cmd);
771 }
772 }
773 break;
774 }
775 Ok(PluginCommand::HookCompleted { .. }) => {
776 continue;
778 }
779 Ok(cmd) => {
780 commands.push(cmd);
781 }
782 Err(_) => {
783 break;
785 }
786 }
787 }
788
789 commands
790 }
791
792 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
794 Arc::clone(&self.state_snapshot)
795 }
796
797 pub fn shutdown(&mut self) {
799 tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
800
801 if let Ok(mut pending) = self.pending_responses.lock() {
804 if !pending.is_empty() {
805 tracing::warn!(
806 "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
807 pending.len(),
808 pending.keys().collect::<Vec<_>>()
809 );
810 pending.clear(); }
812 }
813
814 if let Some(sender) = self.request_sender.as_ref() {
816 tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
817 fire_and_forget(sender.send(PluginRequest::Shutdown));
818 }
819
820 tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
823 self.request_sender.take();
824
825 if let Some(handle) = self.thread_handle.take() {
826 tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
827 if handle.join().is_err() {
828 tracing::trace!("plugin thread panicked during join");
829 }
830 tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
831 }
832
833 tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
834 }
835
836 pub fn resolve_callback(
839 &self,
840 callback_id: fresh_core::api::JsCallbackId,
841 result_json: String,
842 ) {
843 if let Some(sender) = self.request_sender.as_ref() {
844 fire_and_forget(sender.send(PluginRequest::ResolveCallback {
845 callback_id,
846 result_json,
847 }));
848 }
849 }
850
851 pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
854 if let Some(sender) = self.request_sender.as_ref() {
855 fire_and_forget(sender.send(PluginRequest::RejectCallback { callback_id, error }));
856 }
857 }
858
859 pub fn call_streaming_callback(
862 &self,
863 callback_id: fresh_core::api::JsCallbackId,
864 result_json: String,
865 done: bool,
866 ) {
867 if let Some(sender) = self.request_sender.as_ref() {
868 fire_and_forget(sender.send(PluginRequest::CallStreamingCallback {
869 callback_id,
870 result_json,
871 done,
872 }));
873 }
874 }
875}
876
877impl Drop for PluginThreadHandle {
878 fn drop(&mut self) {
879 self.shutdown();
880 }
881}
882
883fn respond_to_pending(
884 pending_responses: &PendingResponses,
885 response: fresh_core::api::PluginResponse,
886) -> bool {
887 let request_id = response.request_id();
888 let sender = {
889 let mut pending = pending_responses.lock().unwrap();
890 pending.remove(&request_id)
891 };
892
893 if let Some(tx) = sender {
894 fire_and_forget(tx.send(response));
895 true
896 } else {
897 false
898 }
899}
900
901#[cfg(test)]
902mod plugin_thread_tests {
903 use super::*;
904 use fresh_core::api::PluginResponse;
905 use serde_json::json;
906 use std::collections::HashMap;
907 use std::sync::{Arc, Mutex};
908 use tokio::sync::oneshot;
909
910 #[test]
911 fn respond_to_pending_sends_lsp_response() {
912 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
913 let (tx, mut rx) = oneshot::channel();
914 pending.lock().unwrap().insert(123, tx);
915
916 respond_to_pending(
917 &pending,
918 PluginResponse::LspRequest {
919 request_id: 123,
920 result: Ok(json!({ "key": "value" })),
921 },
922 );
923
924 let response = rx.try_recv().expect("expected response");
925 match response {
926 PluginResponse::LspRequest { result, .. } => {
927 assert_eq!(result.unwrap(), json!({ "key": "value" }));
928 }
929 _ => panic!("unexpected variant"),
930 }
931
932 assert!(pending.lock().unwrap().is_empty());
933 }
934
935 #[test]
936 fn respond_to_pending_handles_virtual_buffer_created() {
937 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
938 let (tx, mut rx) = oneshot::channel();
939 pending.lock().unwrap().insert(456, tx);
940
941 respond_to_pending(
942 &pending,
943 PluginResponse::VirtualBufferCreated {
944 request_id: 456,
945 buffer_id: fresh_core::BufferId(7),
946 split_id: Some(fresh_core::SplitId(1)),
947 },
948 );
949
950 let response = rx.try_recv().expect("expected response");
951 match response {
952 PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
953 assert_eq!(buffer_id.0, 7);
954 }
955 _ => panic!("unexpected variant"),
956 }
957
958 assert!(pending.lock().unwrap().is_empty());
959 }
960}
961
962async fn plugin_thread_loop(
968 runtime: Rc<RefCell<QuickJsBackend>>,
969 plugins: &mut HashMap<String, TsPluginInfo>,
970 mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
971) {
972 tracing::info!("Plugin thread event loop started");
973
974 let poll_interval = Duration::from_millis(1);
976 let mut has_pending_work = false;
977
978 loop {
979 if crate::backend::has_fatal_js_error() {
983 if let Some(error_msg) = crate::backend::take_fatal_js_error() {
984 tracing::error!(
985 "Fatal JS error detected, terminating plugin thread: {}",
986 error_msg
987 );
988 panic!("Fatal plugin error: {}", error_msg);
989 }
990 }
991
992 tokio::select! {
993 biased; request = request_receiver.recv() => {
996 match request {
997 Some(PluginRequest::ExecuteAction {
998 action_name,
999 response,
1000 }) => {
1001 let result = runtime.borrow_mut().start_action(&action_name);
1004 fire_and_forget(response.send(result));
1005 has_pending_work = true; }
1007 Some(request) => {
1008 let should_shutdown =
1009 handle_request(request, Rc::clone(&runtime), plugins).await;
1010
1011 if should_shutdown {
1012 break;
1013 }
1014 has_pending_work = true; }
1016 None => {
1017 tracing::info!("Plugin thread request channel closed");
1019 break;
1020 }
1021 }
1022 }
1023
1024 _ = tokio::time::sleep(poll_interval), if has_pending_work => {
1026 has_pending_work = runtime.borrow_mut().poll_event_loop_once();
1027 }
1028 }
1029 }
1030}
1031
1032#[allow(clippy::await_holding_refcell_ref)]
1040async fn run_hook_internal_rc(
1041 runtime: Rc<RefCell<QuickJsBackend>>,
1042 hook_name: &str,
1043 args: &HookArgs,
1044) -> Result<()> {
1045 let json_start = std::time::Instant::now();
1048 let json_data = fresh_core::hooks::hook_args_to_json(args)?;
1049 tracing::trace!(
1050 hook = hook_name,
1051 json_us = json_start.elapsed().as_micros(),
1052 "hook args serialized"
1053 );
1054
1055 let emit_start = std::time::Instant::now();
1057 runtime.borrow_mut().emit(hook_name, &json_data).await?;
1058 tracing::trace!(
1059 hook = hook_name,
1060 emit_ms = emit_start.elapsed().as_millis(),
1061 "emit completed"
1062 );
1063
1064 Ok(())
1065}
1066
1067#[allow(clippy::await_holding_refcell_ref)]
1069async fn handle_request(
1070 request: PluginRequest,
1071 runtime: Rc<RefCell<QuickJsBackend>>,
1072 plugins: &mut HashMap<String, TsPluginInfo>,
1073) -> bool {
1074 match request {
1075 PluginRequest::LoadPlugin { path, response } => {
1076 let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
1077 fire_and_forget(response.send(result));
1078 }
1079
1080 PluginRequest::LoadPluginsFromDir { dir, response } => {
1081 let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
1082 fire_and_forget(response.send(errors));
1083 }
1084
1085 PluginRequest::LoadPluginsFromDirWithConfig {
1086 dir,
1087 plugin_configs,
1088 response,
1089 } => {
1090 let (errors, discovered) = load_plugins_from_dir_with_config_internal(
1091 Rc::clone(&runtime),
1092 plugins,
1093 &dir,
1094 &plugin_configs,
1095 )
1096 .await;
1097 fire_and_forget(response.send((errors, discovered)));
1098 }
1099
1100 PluginRequest::LoadPluginFromSource {
1101 source,
1102 name,
1103 is_typescript,
1104 response,
1105 } => {
1106 let result = load_plugin_from_source_internal(
1107 Rc::clone(&runtime),
1108 plugins,
1109 &source,
1110 &name,
1111 is_typescript,
1112 );
1113 fire_and_forget(response.send(result));
1114 }
1115
1116 PluginRequest::UnloadPlugin { name, response } => {
1117 let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
1118 fire_and_forget(response.send(result));
1119 }
1120
1121 PluginRequest::ReloadPlugin { name, response } => {
1122 let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
1123 fire_and_forget(response.send(result));
1124 }
1125
1126 PluginRequest::ExecuteAction {
1127 action_name,
1128 response,
1129 } => {
1130 tracing::error!(
1133 "ExecuteAction should be handled in main loop, not here: {}",
1134 action_name
1135 );
1136 fire_and_forget(response.send(Err(anyhow::anyhow!(
1137 "Internal error: ExecuteAction in wrong handler"
1138 ))));
1139 }
1140
1141 PluginRequest::RunHook { hook_name, args } => {
1142 let hook_start = std::time::Instant::now();
1144 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1146 tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
1147 } else {
1148 tracing::trace!(hook = %hook_name, "RunHook request received");
1149 }
1150 if let Err(e) = run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args).await {
1151 let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
1152 tracing::error!("{}", error_msg);
1153 runtime.borrow_mut().send_status(error_msg);
1155 }
1156 runtime.borrow().send_hook_completed(hook_name.clone());
1159 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1160 tracing::info!(
1161 hook = %hook_name,
1162 elapsed_ms = hook_start.elapsed().as_millis(),
1163 "RunHook completed (prompt hook)"
1164 );
1165 } else {
1166 tracing::trace!(
1167 hook = %hook_name,
1168 elapsed_ms = hook_start.elapsed().as_millis(),
1169 "RunHook completed"
1170 );
1171 }
1172 }
1173
1174 PluginRequest::HasHookHandlers {
1175 hook_name,
1176 response,
1177 } => {
1178 let has_handlers = runtime.borrow().has_handlers(&hook_name);
1179 fire_and_forget(response.send(has_handlers));
1180 }
1181
1182 PluginRequest::ListPlugins { response } => {
1183 let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
1184 fire_and_forget(response.send(plugin_list));
1185 }
1186
1187 PluginRequest::ResolveCallback {
1188 callback_id,
1189 result_json,
1190 } => {
1191 tracing::info!(
1192 "ResolveCallback: resolving callback_id={} with result_json={}",
1193 callback_id,
1194 result_json
1195 );
1196 runtime
1197 .borrow_mut()
1198 .resolve_callback(callback_id, &result_json);
1199 tracing::info!(
1201 "ResolveCallback: done resolving callback_id={}",
1202 callback_id
1203 );
1204 }
1205
1206 PluginRequest::RejectCallback { callback_id, error } => {
1207 runtime.borrow_mut().reject_callback(callback_id, &error);
1208 }
1210
1211 PluginRequest::CallStreamingCallback {
1212 callback_id,
1213 result_json,
1214 done,
1215 } => {
1216 runtime
1217 .borrow_mut()
1218 .call_streaming_callback(callback_id, &result_json, done);
1219 }
1220
1221 PluginRequest::TrackAsyncResource {
1222 plugin_name,
1223 resource,
1224 } => {
1225 let rt = runtime.borrow();
1226 let mut tracked = rt.plugin_tracked_state.borrow_mut();
1227 let state = tracked.entry(plugin_name).or_default();
1228 match resource {
1229 TrackedAsyncResource::VirtualBuffer(buffer_id) => {
1230 state.virtual_buffer_ids.push(buffer_id);
1231 }
1232 TrackedAsyncResource::CompositeBuffer(buffer_id) => {
1233 state.composite_buffer_ids.push(buffer_id);
1234 }
1235 TrackedAsyncResource::Terminal(terminal_id) => {
1236 state.terminal_ids.push(terminal_id);
1237 }
1238 }
1239 }
1240
1241 PluginRequest::Shutdown => {
1242 tracing::info!("Plugin thread received shutdown request");
1243 return true;
1244 }
1245 }
1246
1247 false
1248}
1249
1250struct PreparedPlugin {
1253 name: String,
1254 path: PathBuf,
1255 js_code: String,
1256 i18n: Option<HashMap<String, HashMap<String, String>>>,
1257 dependencies: Vec<String>,
1258 declarations: Option<String>,
1266}
1267
1268fn prepare_plugin(path: &Path) -> Result<PreparedPlugin> {
1273 let plugin_name = path
1274 .file_stem()
1275 .and_then(|s| s.to_str())
1276 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1277 .to_string();
1278
1279 let source = std::fs::read_to_string(path)
1280 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path.display(), e))?;
1281
1282 let filename = path
1283 .file_name()
1284 .and_then(|s| s.to_str())
1285 .unwrap_or("plugin.ts");
1286
1287 let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1289
1290 let declarations = if filename.ends_with(".ts") {
1297 match fresh_parser_js::emit_isolated_declarations(&source, filename) {
1298 Ok(dts) => Some(dts),
1299 Err(e) => {
1300 tracing::warn!(
1301 "Plugin {} isolated-declarations emit failed: {}",
1302 path.display(),
1303 e
1304 );
1305 None
1306 }
1307 }
1308 } else {
1309 None
1310 };
1311
1312 let js_code = if fresh_parser_js::has_es_imports(&source) {
1314 match fresh_parser_js::bundle_module(path) {
1315 Ok(bundled) => bundled,
1316 Err(e) => {
1317 tracing::warn!(
1318 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
1319 path.display(),
1320 e
1321 );
1322 return Err(anyhow!("Bundling failed for {}: {}", plugin_name, e));
1323 }
1324 }
1325 } else if fresh_parser_js::has_es_module_syntax(&source) {
1326 let stripped = fresh_parser_js::strip_imports_and_exports(&source);
1327 if filename.ends_with(".ts") {
1328 fresh_parser_js::transpile_typescript(&stripped, filename)?
1329 } else {
1330 stripped
1331 }
1332 } else if filename.ends_with(".ts") {
1333 fresh_parser_js::transpile_typescript(&source, filename)?
1334 } else {
1335 source
1336 };
1337
1338 let i18n_path = path.with_extension("i18n.json");
1340 let i18n = if i18n_path.exists() {
1341 std::fs::read_to_string(&i18n_path)
1342 .ok()
1343 .and_then(|content| serde_json::from_str(&content).ok())
1344 } else {
1345 None
1346 };
1347
1348 Ok(PreparedPlugin {
1349 name: plugin_name,
1350 path: path.to_path_buf(),
1351 js_code,
1352 i18n,
1353 dependencies,
1354 declarations,
1355 })
1356}
1357
1358fn execute_prepared_plugin(
1361 runtime: &Rc<RefCell<QuickJsBackend>>,
1362 plugins: &mut HashMap<String, TsPluginInfo>,
1363 prepared: &PreparedPlugin,
1364) -> Result<()> {
1365 if let Some(ref i18n) = prepared.i18n {
1367 runtime
1368 .borrow_mut()
1369 .services
1370 .register_plugin_strings(&prepared.name, i18n.clone());
1371 tracing::debug!("Loaded i18n strings for plugin '{}'", prepared.name);
1372 }
1373
1374 let path_str = prepared
1375 .path
1376 .to_str()
1377 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1378
1379 let exec_start = std::time::Instant::now();
1380 runtime
1381 .borrow_mut()
1382 .execute_js(&prepared.js_code, path_str)?;
1383 let exec_elapsed = exec_start.elapsed();
1384
1385 tracing::debug!(
1386 "execute_prepared_plugin: plugin '{}' executed in {:?}",
1387 prepared.name,
1388 exec_elapsed
1389 );
1390
1391 plugins.insert(
1392 prepared.name.clone(),
1393 TsPluginInfo {
1394 name: prepared.name.clone(),
1395 path: prepared.path.clone(),
1396 enabled: true,
1397 declarations: prepared.declarations.clone(),
1398 },
1399 );
1400
1401 Ok(())
1402}
1403
1404#[allow(clippy::await_holding_refcell_ref)]
1405async fn load_plugin_internal(
1406 runtime: Rc<RefCell<QuickJsBackend>>,
1407 plugins: &mut HashMap<String, TsPluginInfo>,
1408 path: &Path,
1409) -> Result<()> {
1410 let plugin_name = path
1411 .file_stem()
1412 .and_then(|s| s.to_str())
1413 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1414 .to_string();
1415
1416 tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
1417 tracing::debug!(
1418 "load_plugin_internal: starting module load for plugin '{}'",
1419 plugin_name
1420 );
1421
1422 let path_str = path
1424 .to_str()
1425 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1426
1427 let i18n_path = path.with_extension("i18n.json");
1429 if i18n_path.exists() {
1430 if let Ok(content) = std::fs::read_to_string(&i18n_path) {
1431 if let Ok(strings) = serde_json::from_str::<
1432 std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1433 >(&content)
1434 {
1435 runtime
1436 .borrow_mut()
1437 .services
1438 .register_plugin_strings(&plugin_name, strings);
1439 tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
1440 }
1441 }
1442 }
1443
1444 let load_start = std::time::Instant::now();
1445 runtime
1446 .borrow_mut()
1447 .load_module_with_source(path_str, &plugin_name)
1448 .await?;
1449 let load_elapsed = load_start.elapsed();
1450
1451 tracing::debug!(
1452 "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
1453 plugin_name,
1454 load_elapsed
1455 );
1456
1457 plugins.insert(
1459 plugin_name.clone(),
1460 TsPluginInfo {
1461 name: plugin_name.clone(),
1462 path: path.to_path_buf(),
1463 enabled: true,
1464 declarations: None,
1468 },
1469 );
1470
1471 tracing::debug!(
1472 "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
1473 plugin_name,
1474 plugins.len()
1475 );
1476
1477 Ok(())
1478}
1479
1480async fn load_plugins_from_dir_internal(
1482 runtime: Rc<RefCell<QuickJsBackend>>,
1483 plugins: &mut HashMap<String, TsPluginInfo>,
1484 dir: &Path,
1485) -> Vec<String> {
1486 tracing::debug!(
1487 "load_plugins_from_dir_internal: scanning directory {:?}",
1488 dir
1489 );
1490 let mut errors = Vec::new();
1491
1492 if !dir.exists() {
1493 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1494 return errors;
1495 }
1496
1497 match std::fs::read_dir(dir) {
1499 Ok(entries) => {
1500 for entry in entries.flatten() {
1501 let path = entry.path();
1502 let ext = path.extension().and_then(|s| s.to_str());
1503 if ext == Some("ts") || ext == Some("js") {
1504 tracing::debug!(
1505 "load_plugins_from_dir_internal: attempting to load {:?}",
1506 path
1507 );
1508 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
1509 {
1510 let err = format!("Failed to load {:?}: {}", path, e);
1511 tracing::error!("{}", err);
1512 errors.push(err);
1513 }
1514 }
1515 }
1516
1517 tracing::debug!(
1518 "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1519 dir,
1520 errors.len()
1521 );
1522 }
1523 Err(e) => {
1524 let err = format!("Failed to read plugin directory: {}", e);
1525 tracing::error!("{}", err);
1526 errors.push(err);
1527 }
1528 }
1529
1530 errors
1531}
1532
1533async fn load_plugins_from_dir_with_config_internal(
1537 runtime: Rc<RefCell<QuickJsBackend>>,
1538 plugins: &mut HashMap<String, TsPluginInfo>,
1539 dir: &Path,
1540 plugin_configs: &HashMap<String, PluginConfig>,
1541) -> (Vec<String>, HashMap<String, PluginConfig>) {
1542 tracing::debug!(
1543 "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1544 dir
1545 );
1546 let mut errors = Vec::new();
1547 let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1548
1549 if !dir.exists() {
1550 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1551 return (errors, discovered_plugins);
1552 }
1553
1554 let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1556 match std::fs::read_dir(dir) {
1557 Ok(entries) => {
1558 for entry in entries.flatten() {
1559 let path = entry.path();
1560 let ext = path.extension().and_then(|s| s.to_str());
1561 if ext == Some("ts") || ext == Some("js") {
1562 if path.to_string_lossy().contains(".i18n.") {
1564 continue;
1565 }
1566 let plugin_name = path
1568 .file_stem()
1569 .and_then(|s| s.to_str())
1570 .unwrap_or("unknown")
1571 .to_string();
1572 plugin_files.push((plugin_name, path));
1573 }
1574 }
1575 }
1576 Err(e) => {
1577 let err = format!("Failed to read plugin directory: {}", e);
1578 tracing::error!("{}", err);
1579 errors.push(err);
1580 return (errors, discovered_plugins);
1581 }
1582 }
1583
1584 let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1586 for (plugin_name, path) in plugin_files {
1587 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1589 PluginConfig {
1591 enabled: existing_config.enabled,
1592 path: Some(path.clone()),
1593 }
1594 } else {
1595 PluginConfig::new_with_path(path.clone())
1597 };
1598
1599 discovered_plugins.insert(plugin_name.clone(), config.clone());
1601
1602 if config.enabled {
1603 enabled_plugins.push((plugin_name, path));
1604 } else {
1605 tracing::info!(
1606 "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1607 plugin_name
1608 );
1609 }
1610 }
1611
1612 let prep_start = std::time::Instant::now();
1615 let paths: Vec<std::path::PathBuf> = enabled_plugins.iter().map(|(_, p)| p.clone()).collect();
1616 let prepared_results: Vec<(String, Result<PreparedPlugin>)> = std::thread::scope(|scope| {
1617 let handles: Vec<_> = paths
1618 .iter()
1619 .map(|path| {
1620 let path = path.clone();
1621 scope.spawn(move || {
1622 let name = path
1623 .file_stem()
1624 .and_then(|s| s.to_str())
1625 .unwrap_or("unknown")
1626 .to_string();
1627 let result = prepare_plugin(&path);
1628 (name, result)
1629 })
1630 })
1631 .collect();
1632 handles.into_iter().map(|h| h.join().unwrap()).collect()
1633 });
1634 let prep_elapsed = prep_start.elapsed();
1635
1636 let mut prepared_map: std::collections::HashMap<String, PreparedPlugin> =
1638 std::collections::HashMap::new();
1639 for (name, result) in prepared_results {
1640 match result {
1641 Ok(prepared) => {
1642 prepared_map.insert(name, prepared);
1643 }
1644 Err(e) => {
1645 let err = format!("Failed to prepare plugin '{}': {}", name, e);
1646 tracing::error!("{}", err);
1647 errors.push(err);
1648 }
1649 }
1650 }
1651
1652 tracing::info!(
1653 "Parallel plugin preparation completed in {:?} ({} plugins)",
1654 prep_elapsed,
1655 prepared_map.len()
1656 );
1657
1658 let mut dependency_map: std::collections::HashMap<String, Vec<String>> =
1660 std::collections::HashMap::new();
1661 for (name, prepared) in &prepared_map {
1662 if !prepared.dependencies.is_empty() {
1663 tracing::debug!(
1664 "Plugin '{}' declares dependencies: {:?}",
1665 name,
1666 prepared.dependencies
1667 );
1668 dependency_map.insert(name.clone(), prepared.dependencies.clone());
1669 }
1670 }
1671
1672 let plugin_names: Vec<String> = prepared_map.keys().cloned().collect();
1674 let load_order = match fresh_parser_js::topological_sort_plugins(&plugin_names, &dependency_map)
1675 {
1676 Ok(order) => order,
1677 Err(e) => {
1678 let err = format!("Plugin dependency resolution failed: {}", e);
1679 tracing::error!("{}", err);
1680 errors.push(err);
1681 let mut names = plugin_names;
1683 names.sort();
1684 names
1685 }
1686 };
1687
1688 let exec_start = std::time::Instant::now();
1690 for plugin_name in load_order {
1691 if let Some(prepared) = prepared_map.get(&plugin_name) {
1692 tracing::debug!(
1693 "load_plugins_from_dir_with_config_internal: executing plugin '{}'",
1694 plugin_name
1695 );
1696 if let Err(e) = execute_prepared_plugin(&runtime, plugins, prepared) {
1697 let err = format!("Failed to execute plugin '{}': {}", plugin_name, e);
1698 tracing::error!("{}", err);
1699 errors.push(err);
1700 }
1701 }
1702 }
1703 let exec_elapsed = exec_start.elapsed();
1704
1705 tracing::info!(
1706 "Serial plugin execution completed in {:?} ({} plugins)",
1707 exec_elapsed,
1708 plugins.len()
1709 );
1710
1711 tracing::debug!(
1712 "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors (prep: {:?}, exec: {:?})",
1713 discovered_plugins.len(),
1714 errors.len(),
1715 prep_elapsed,
1716 exec_elapsed
1717 );
1718
1719 (errors, discovered_plugins)
1720}
1721
1722fn load_plugin_from_source_internal(
1727 runtime: Rc<RefCell<QuickJsBackend>>,
1728 plugins: &mut HashMap<String, TsPluginInfo>,
1729 source: &str,
1730 name: &str,
1731 is_typescript: bool,
1732) -> Result<()> {
1733 if plugins.contains_key(name) {
1735 tracing::info!(
1736 "Hot-reloading buffer plugin '{}' — unloading previous version",
1737 name
1738 );
1739 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1740 }
1741
1742 tracing::info!("Loading plugin from source: {}", name);
1743
1744 runtime
1745 .borrow_mut()
1746 .execute_source(source, name, is_typescript)?;
1747
1748 plugins.insert(
1750 name.to_string(),
1751 TsPluginInfo {
1752 name: name.to_string(),
1753 path: PathBuf::from(format!("<buffer:{}>", name)),
1754 enabled: true,
1755 declarations: None,
1760 },
1761 );
1762
1763 tracing::info!(
1764 "Buffer plugin '{}' loaded successfully, total plugins: {}",
1765 name,
1766 plugins.len()
1767 );
1768
1769 Ok(())
1770}
1771
1772fn unload_plugin_internal(
1774 runtime: Rc<RefCell<QuickJsBackend>>,
1775 plugins: &mut HashMap<String, TsPluginInfo>,
1776 name: &str,
1777) -> Result<()> {
1778 if plugins.remove(name).is_some() {
1779 tracing::info!("Unloading TypeScript plugin: {}", name);
1780
1781 runtime
1783 .borrow_mut()
1784 .services
1785 .unregister_plugin_strings(name);
1786
1787 runtime
1789 .borrow()
1790 .services
1791 .unregister_commands_by_plugin(name);
1792
1793 runtime.borrow().cleanup_plugin(name);
1795
1796 Ok(())
1797 } else {
1798 Err(anyhow!("Plugin '{}' not found", name))
1799 }
1800}
1801
1802async fn reload_plugin_internal(
1804 runtime: Rc<RefCell<QuickJsBackend>>,
1805 plugins: &mut HashMap<String, TsPluginInfo>,
1806 name: &str,
1807) -> Result<()> {
1808 let path = plugins
1809 .get(name)
1810 .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1811 .path
1812 .clone();
1813
1814 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1815 load_plugin_internal(runtime, plugins, &path).await?;
1816
1817 Ok(())
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::*;
1823 use fresh_core::hooks::hook_args_to_json;
1824
1825 #[test]
1826 fn test_oneshot_channel() {
1827 let (tx, rx) = oneshot::channel::<i32>();
1828 assert!(tx.send(42).is_ok());
1829 assert_eq!(rx.recv().unwrap(), 42);
1830 }
1831
1832 #[test]
1833 fn test_hook_args_to_json_editor_initialized() {
1834 let args = HookArgs::EditorInitialized {};
1835 let json = hook_args_to_json(&args).unwrap();
1836 assert_eq!(json, serde_json::json!({}));
1837 }
1838
1839 #[test]
1840 fn test_hook_args_to_json_prompt_changed() {
1841 let args = HookArgs::PromptChanged {
1842 prompt_type: "search".to_string(),
1843 input: "test".to_string(),
1844 };
1845 let json = hook_args_to_json(&args).unwrap();
1846 assert_eq!(json["prompt_type"], "search");
1847 assert_eq!(json["input"], "test");
1848 }
1849}