1use crate::backend::quickjs_backend::{AsyncResourceOwners, PendingResponses, TsPluginInfo};
13use crate::backend::QuickJsBackend;
14use anyhow::{anyhow, Result};
15use fresh_core::api::{EditorStateSnapshot, PluginCommand};
16use fresh_core::hooks::HookArgs;
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::rc::Rc;
21use std::sync::{Arc, RwLock};
22use std::thread::{self, JoinHandle};
23use std::time::Duration;
24
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
38#[derive(Debug)]
40pub enum PluginRequest {
41 LoadPlugin {
43 path: PathBuf,
44 response: oneshot::Sender<Result<()>>,
45 },
46
47 ResolveCallback {
49 callback_id: fresh_core::api::JsCallbackId,
50 result_json: String,
51 },
52
53 RejectCallback {
55 callback_id: fresh_core::api::JsCallbackId,
56 error: String,
57 },
58
59 CallStreamingCallback {
61 callback_id: fresh_core::api::JsCallbackId,
62 result_json: String,
63 done: bool,
64 },
65
66 LoadPluginsFromDir {
68 dir: PathBuf,
69 response: oneshot::Sender<Vec<String>>,
70 },
71
72 LoadPluginsFromDirWithConfig {
76 dir: PathBuf,
77 plugin_configs: HashMap<String, PluginConfig>,
78 response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
79 },
80
81 LoadPluginFromSource {
83 source: String,
84 name: String,
85 is_typescript: bool,
86 response: oneshot::Sender<Result<()>>,
87 },
88
89 UnloadPlugin {
91 name: String,
92 response: oneshot::Sender<Result<()>>,
93 },
94
95 ReloadPlugin {
97 name: String,
98 response: oneshot::Sender<Result<()>>,
99 },
100
101 ExecuteAction {
103 action_name: String,
104 response: oneshot::Sender<Result<()>>,
105 },
106
107 RunHook { hook_name: String, args: HookArgs },
109
110 HasHookHandlers {
112 hook_name: String,
113 response: oneshot::Sender<bool>,
114 },
115
116 ListPlugins {
118 response: oneshot::Sender<Vec<TsPluginInfo>>,
119 },
120
121 TrackAsyncResource {
124 plugin_name: String,
125 resource: TrackedAsyncResource,
126 },
127
128 Shutdown,
130}
131
132#[derive(Debug)]
135pub enum TrackedAsyncResource {
136 VirtualBuffer(fresh_core::BufferId),
137 CompositeBuffer(fresh_core::BufferId),
138 Terminal(fresh_core::TerminalId),
139}
140
141pub mod oneshot {
143 use std::fmt;
144 use std::sync::mpsc;
145
146 pub struct Sender<T>(mpsc::SyncSender<T>);
147 pub struct Receiver<T>(mpsc::Receiver<T>);
148
149 use anyhow::Result;
150
151 impl<T> fmt::Debug for Sender<T> {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.debug_tuple("Sender").finish()
154 }
155 }
156
157 impl<T> fmt::Debug for Receiver<T> {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 f.debug_tuple("Receiver").finish()
160 }
161 }
162
163 impl<T> Sender<T> {
164 pub fn send(self, value: T) -> Result<(), T> {
165 self.0.send(value).map_err(|e| e.0)
166 }
167 }
168
169 impl<T> Receiver<T> {
170 pub fn recv(self) -> Result<T, mpsc::RecvError> {
171 self.0.recv()
172 }
173
174 pub fn recv_timeout(
175 self,
176 timeout: std::time::Duration,
177 ) -> Result<T, mpsc::RecvTimeoutError> {
178 self.0.recv_timeout(timeout)
179 }
180
181 pub fn try_recv(&self) -> Result<T, mpsc::TryRecvError> {
182 self.0.try_recv()
183 }
184 }
185
186 pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
187 let (tx, rx) = mpsc::sync_channel(1);
188 (Sender(tx), Receiver(rx))
189 }
190}
191
192pub struct PluginThreadHandle {
194 request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
197
198 thread_handle: Option<JoinHandle<()>>,
200
201 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
203
204 pending_responses: PendingResponses,
206
207 command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
209
210 async_resource_owners: AsyncResourceOwners,
214}
215
216impl PluginThreadHandle {
217 pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
219 tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
220
221 let (command_sender, command_receiver) = std::sync::mpsc::channel();
223
224 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
226
227 let pending_responses: PendingResponses =
229 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
230 let thread_pending_responses = Arc::clone(&pending_responses);
231
232 let async_resource_owners: AsyncResourceOwners =
234 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
235 let thread_async_resource_owners = Arc::clone(&async_resource_owners);
236
237 let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
239
240 let thread_state_snapshot = Arc::clone(&state_snapshot);
242
243 tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
245 let thread_handle = thread::spawn(move || {
246 tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
247 let rt = match tokio::runtime::Builder::new_current_thread()
249 .enable_all()
250 .build()
251 {
252 Ok(rt) => {
253 tracing::debug!("Plugin thread: tokio runtime created successfully");
254 rt
255 }
256 Err(e) => {
257 tracing::error!("Failed to create plugin thread runtime: {}", e);
258 return;
259 }
260 };
261
262 tracing::debug!("Plugin thread: creating QuickJS runtime");
264 let runtime = match QuickJsBackend::with_state_responses_and_resources(
265 Arc::clone(&thread_state_snapshot),
266 command_sender,
267 thread_pending_responses,
268 services.clone(),
269 thread_async_resource_owners,
270 ) {
271 Ok(rt) => {
272 tracing::debug!("Plugin thread: QuickJS runtime created successfully");
273 rt
274 }
275 Err(e) => {
276 tracing::error!("Failed to create QuickJS runtime: {}", e);
277 return;
278 }
279 };
280
281 let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
283
284 tracing::debug!("Plugin thread: starting event loop with LocalSet");
286 let local = tokio::task::LocalSet::new();
287 local.block_on(&rt, async {
288 let runtime = Rc::new(RefCell::new(runtime));
290 tracing::debug!("Plugin thread: entering plugin_thread_loop");
291 plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
292 });
293
294 tracing::info!("Plugin thread shutting down");
295 });
296
297 tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
298 tracing::info!("Plugin thread spawned");
299
300 Ok(Self {
301 request_sender: Some(request_sender),
302 thread_handle: Some(thread_handle),
303 state_snapshot,
304 pending_responses,
305 command_receiver,
306 async_resource_owners,
307 })
308 }
309
310 pub fn is_alive(&self) -> bool {
312 self.thread_handle
313 .as_ref()
314 .map(|h| !h.is_finished())
315 .unwrap_or(false)
316 }
317
318 pub fn check_thread_health(&mut self) {
322 if let Some(handle) = &self.thread_handle {
323 if handle.is_finished() {
324 tracing::error!(
325 "check_thread_health: plugin thread is finished, checking for panic"
326 );
327 if let Some(handle) = self.thread_handle.take() {
329 match handle.join() {
330 Ok(()) => {
331 tracing::warn!("Plugin thread exited normally (unexpected)");
332 }
333 Err(panic_payload) => {
334 std::panic::resume_unwind(panic_payload);
336 }
337 }
338 }
339 }
340 }
341 }
342
343 pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
347 if respond_to_pending(&self.pending_responses, response.clone()) {
349 return;
350 }
351
352 use fresh_core::api::{JsCallbackId, PluginResponse};
354
355 match response {
356 PluginResponse::VirtualBufferCreated {
357 request_id,
358 buffer_id,
359 split_id,
360 } => {
361 self.track_async_resource(
363 request_id,
364 TrackedAsyncResource::VirtualBuffer(buffer_id),
365 );
366 let result = serde_json::json!({
368 "bufferId": buffer_id.0,
369 "splitId": split_id.map(|s| s.0)
370 });
371 self.resolve_callback(JsCallbackId(request_id), result.to_string());
372 }
373 PluginResponse::LspRequest { request_id, result } => match result {
374 Ok(value) => {
375 self.resolve_callback(JsCallbackId(request_id), value.to_string());
376 }
377 Err(e) => {
378 self.reject_callback(JsCallbackId(request_id), e);
379 }
380 },
381 PluginResponse::HighlightsComputed { request_id, spans } => {
382 let result = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".to_string());
383 self.resolve_callback(JsCallbackId(request_id), result);
384 }
385 PluginResponse::BufferText { request_id, text } => match text {
386 Ok(content) => {
387 let result =
389 serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
390 self.resolve_callback(JsCallbackId(request_id), result);
391 }
392 Err(e) => {
393 self.reject_callback(JsCallbackId(request_id), e);
394 }
395 },
396 PluginResponse::CompositeBufferCreated {
397 request_id,
398 buffer_id,
399 } => {
400 self.track_async_resource(
402 request_id,
403 TrackedAsyncResource::CompositeBuffer(buffer_id),
404 );
405 self.resolve_callback(JsCallbackId(request_id), buffer_id.0.to_string());
407 }
408 PluginResponse::LineStartPosition {
409 request_id,
410 position,
411 } => {
412 let result =
414 serde_json::to_string(&position).unwrap_or_else(|_| "null".to_string());
415 self.resolve_callback(JsCallbackId(request_id), result);
416 }
417 PluginResponse::LineEndPosition {
418 request_id,
419 position,
420 } => {
421 let result =
423 serde_json::to_string(&position).unwrap_or_else(|_| "null".to_string());
424 self.resolve_callback(JsCallbackId(request_id), result);
425 }
426 PluginResponse::BufferLineCount { request_id, count } => {
427 let result = serde_json::to_string(&count).unwrap_or_else(|_| "null".to_string());
429 self.resolve_callback(JsCallbackId(request_id), result);
430 }
431 PluginResponse::TerminalCreated {
432 request_id,
433 buffer_id,
434 terminal_id,
435 split_id,
436 } => {
437 self.track_async_resource(request_id, TrackedAsyncResource::Terminal(terminal_id));
439 let result = serde_json::json!({
440 "bufferId": buffer_id.0,
441 "terminalId": terminal_id.0,
442 "splitId": split_id.map(|s| s.0)
443 });
444 self.resolve_callback(JsCallbackId(request_id), result.to_string());
445 }
446 PluginResponse::SplitByLabel {
447 request_id,
448 split_id,
449 } => {
450 let result = serde_json::to_string(&split_id.map(|s| s.0))
451 .unwrap_or_else(|_| "null".to_string());
452 self.resolve_callback(JsCallbackId(request_id), result);
453 }
454 }
455 }
456
457 fn track_async_resource(&self, request_id: u64, resource: TrackedAsyncResource) {
460 let plugin_name = self
461 .async_resource_owners
462 .lock()
463 .ok()
464 .and_then(|mut owners| owners.remove(&request_id));
465 if let Some(plugin_name) = plugin_name {
466 if let Some(sender) = self.request_sender.as_ref() {
467 fire_and_forget(sender.send(PluginRequest::TrackAsyncResource {
468 plugin_name,
469 resource,
470 }));
471 }
472 }
473 }
474
475 pub fn load_plugin(&self, path: &Path) -> Result<()> {
477 let (tx, rx) = oneshot::channel();
478 self.request_sender
479 .as_ref()
480 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
481 .send(PluginRequest::LoadPlugin {
482 path: path.to_path_buf(),
483 response: tx,
484 })
485 .map_err(|_| anyhow!("Plugin thread not responding"))?;
486
487 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
488 }
489
490 pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
492 let (tx, rx) = oneshot::channel();
493 let Some(sender) = self.request_sender.as_ref() else {
494 return vec!["Plugin thread shut down".to_string()];
495 };
496 if sender
497 .send(PluginRequest::LoadPluginsFromDir {
498 dir: dir.to_path_buf(),
499 response: tx,
500 })
501 .is_err()
502 {
503 return vec!["Plugin thread not responding".to_string()];
504 }
505
506 rx.recv()
507 .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
508 }
509
510 pub fn load_plugins_from_dir_with_config(
514 &self,
515 dir: &Path,
516 plugin_configs: &HashMap<String, PluginConfig>,
517 ) -> (Vec<String>, HashMap<String, PluginConfig>) {
518 let (tx, rx) = oneshot::channel();
519 let Some(sender) = self.request_sender.as_ref() else {
520 return (vec!["Plugin thread shut down".to_string()], HashMap::new());
521 };
522 if sender
523 .send(PluginRequest::LoadPluginsFromDirWithConfig {
524 dir: dir.to_path_buf(),
525 plugin_configs: plugin_configs.clone(),
526 response: tx,
527 })
528 .is_err()
529 {
530 return (
531 vec!["Plugin thread not responding".to_string()],
532 HashMap::new(),
533 );
534 }
535
536 rx.recv()
537 .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
538 }
539
540 pub fn load_plugin_from_source(
545 &self,
546 source: &str,
547 name: &str,
548 is_typescript: bool,
549 ) -> Result<()> {
550 let (tx, rx) = oneshot::channel();
551 self.request_sender
552 .as_ref()
553 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
554 .send(PluginRequest::LoadPluginFromSource {
555 source: source.to_string(),
556 name: name.to_string(),
557 is_typescript,
558 response: tx,
559 })
560 .map_err(|_| anyhow!("Plugin thread not responding"))?;
561
562 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
563 }
564
565 pub fn unload_plugin(&self, name: &str) -> Result<()> {
567 let (tx, rx) = oneshot::channel();
568 self.request_sender
569 .as_ref()
570 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
571 .send(PluginRequest::UnloadPlugin {
572 name: name.to_string(),
573 response: tx,
574 })
575 .map_err(|_| anyhow!("Plugin thread not responding"))?;
576
577 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
578 }
579
580 pub fn reload_plugin(&self, name: &str) -> Result<()> {
582 let (tx, rx) = oneshot::channel();
583 self.request_sender
584 .as_ref()
585 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
586 .send(PluginRequest::ReloadPlugin {
587 name: name.to_string(),
588 response: tx,
589 })
590 .map_err(|_| anyhow!("Plugin thread not responding"))?;
591
592 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
593 }
594
595 pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
600 tracing::trace!("execute_action_async: starting action '{}'", action_name);
601 let (tx, rx) = oneshot::channel();
602 self.request_sender
603 .as_ref()
604 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
605 .send(PluginRequest::ExecuteAction {
606 action_name: action_name.to_string(),
607 response: tx,
608 })
609 .map_err(|_| anyhow!("Plugin thread not responding"))?;
610
611 tracing::trace!("execute_action_async: request sent for '{}'", action_name);
612 Ok(rx)
613 }
614
615 pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
621 if let Some(sender) = self.request_sender.as_ref() {
622 fire_and_forget(sender.send(PluginRequest::RunHook {
623 hook_name: hook_name.to_string(),
624 args,
625 }));
626 }
627 }
628
629 pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
631 let (tx, rx) = oneshot::channel();
632 let Some(sender) = self.request_sender.as_ref() else {
633 return false;
634 };
635 if sender
636 .send(PluginRequest::HasHookHandlers {
637 hook_name: hook_name.to_string(),
638 response: tx,
639 })
640 .is_err()
641 {
642 return false;
643 }
644
645 rx.recv().unwrap_or(false)
646 }
647
648 pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
650 let (tx, rx) = oneshot::channel();
651 let Some(sender) = self.request_sender.as_ref() else {
652 return vec![];
653 };
654 if sender
655 .send(PluginRequest::ListPlugins { response: tx })
656 .is_err()
657 {
658 return vec![];
659 }
660
661 rx.recv().unwrap_or_default()
662 }
663
664 pub fn process_commands(&mut self) -> Vec<PluginCommand> {
669 let mut commands = Vec::new();
670 while let Ok(cmd) = self.command_receiver.try_recv() {
671 commands.push(cmd);
672 }
673 commands
674 }
675
676 pub fn process_commands_until_hook_completed(
686 &mut self,
687 hook_name: &str,
688 timeout: std::time::Duration,
689 ) -> Vec<PluginCommand> {
690 let mut commands = Vec::new();
691 let deadline = std::time::Instant::now() + timeout;
692
693 loop {
694 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
695 if remaining.is_zero() {
696 while let Ok(cmd) = self.command_receiver.try_recv() {
698 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
699 commands.push(cmd);
700 }
701 }
702 break;
703 }
704
705 match self.command_receiver.recv_timeout(remaining) {
706 Ok(PluginCommand::HookCompleted {
707 hook_name: ref name,
708 }) if name == hook_name => {
709 while let Ok(cmd) = self.command_receiver.try_recv() {
711 if !matches!(&cmd, PluginCommand::HookCompleted { .. }) {
712 commands.push(cmd);
713 }
714 }
715 break;
716 }
717 Ok(PluginCommand::HookCompleted { .. }) => {
718 continue;
720 }
721 Ok(cmd) => {
722 commands.push(cmd);
723 }
724 Err(_) => {
725 break;
727 }
728 }
729 }
730
731 commands
732 }
733
734 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
736 Arc::clone(&self.state_snapshot)
737 }
738
739 pub fn shutdown(&mut self) {
741 tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
742
743 if let Ok(mut pending) = self.pending_responses.lock() {
746 if !pending.is_empty() {
747 tracing::warn!(
748 "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
749 pending.len(),
750 pending.keys().collect::<Vec<_>>()
751 );
752 pending.clear(); }
754 }
755
756 if let Some(sender) = self.request_sender.as_ref() {
758 tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
759 fire_and_forget(sender.send(PluginRequest::Shutdown));
760 }
761
762 tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
765 self.request_sender.take();
766
767 if let Some(handle) = self.thread_handle.take() {
768 tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
769 if handle.join().is_err() {
770 tracing::trace!("plugin thread panicked during join");
771 }
772 tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
773 }
774
775 tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
776 }
777
778 pub fn resolve_callback(
781 &self,
782 callback_id: fresh_core::api::JsCallbackId,
783 result_json: String,
784 ) {
785 if let Some(sender) = self.request_sender.as_ref() {
786 fire_and_forget(sender.send(PluginRequest::ResolveCallback {
787 callback_id,
788 result_json,
789 }));
790 }
791 }
792
793 pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
796 if let Some(sender) = self.request_sender.as_ref() {
797 fire_and_forget(sender.send(PluginRequest::RejectCallback { callback_id, error }));
798 }
799 }
800
801 pub fn call_streaming_callback(
804 &self,
805 callback_id: fresh_core::api::JsCallbackId,
806 result_json: String,
807 done: bool,
808 ) {
809 if let Some(sender) = self.request_sender.as_ref() {
810 fire_and_forget(sender.send(PluginRequest::CallStreamingCallback {
811 callback_id,
812 result_json,
813 done,
814 }));
815 }
816 }
817}
818
819impl Drop for PluginThreadHandle {
820 fn drop(&mut self) {
821 self.shutdown();
822 }
823}
824
825fn respond_to_pending(
826 pending_responses: &PendingResponses,
827 response: fresh_core::api::PluginResponse,
828) -> bool {
829 let request_id = match &response {
830 fresh_core::api::PluginResponse::VirtualBufferCreated { request_id, .. } => *request_id,
831 fresh_core::api::PluginResponse::LspRequest { request_id, .. } => *request_id,
832 fresh_core::api::PluginResponse::HighlightsComputed { request_id, .. } => *request_id,
833 fresh_core::api::PluginResponse::BufferText { request_id, .. } => *request_id,
834 fresh_core::api::PluginResponse::CompositeBufferCreated { request_id, .. } => *request_id,
835 fresh_core::api::PluginResponse::LineStartPosition { request_id, .. } => *request_id,
836 fresh_core::api::PluginResponse::LineEndPosition { request_id, .. } => *request_id,
837 fresh_core::api::PluginResponse::BufferLineCount { request_id, .. } => *request_id,
838 fresh_core::api::PluginResponse::TerminalCreated { request_id, .. } => *request_id,
839 fresh_core::api::PluginResponse::SplitByLabel { request_id, .. } => *request_id,
840 };
841
842 let sender = {
843 let mut pending = pending_responses.lock().unwrap();
844 pending.remove(&request_id)
845 };
846
847 if let Some(tx) = sender {
848 fire_and_forget(tx.send(response));
849 true
850 } else {
851 false
852 }
853}
854
855#[cfg(test)]
856mod plugin_thread_tests {
857 use super::*;
858 use fresh_core::api::PluginResponse;
859 use serde_json::json;
860 use std::collections::HashMap;
861 use std::sync::{Arc, Mutex};
862 use tokio::sync::oneshot;
863
864 #[test]
865 fn respond_to_pending_sends_lsp_response() {
866 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
867 let (tx, mut rx) = oneshot::channel();
868 pending.lock().unwrap().insert(123, tx);
869
870 respond_to_pending(
871 &pending,
872 PluginResponse::LspRequest {
873 request_id: 123,
874 result: Ok(json!({ "key": "value" })),
875 },
876 );
877
878 let response = rx.try_recv().expect("expected response");
879 match response {
880 PluginResponse::LspRequest { result, .. } => {
881 assert_eq!(result.unwrap(), json!({ "key": "value" }));
882 }
883 _ => panic!("unexpected variant"),
884 }
885
886 assert!(pending.lock().unwrap().is_empty());
887 }
888
889 #[test]
890 fn respond_to_pending_handles_virtual_buffer_created() {
891 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
892 let (tx, mut rx) = oneshot::channel();
893 pending.lock().unwrap().insert(456, tx);
894
895 respond_to_pending(
896 &pending,
897 PluginResponse::VirtualBufferCreated {
898 request_id: 456,
899 buffer_id: fresh_core::BufferId(7),
900 split_id: Some(fresh_core::SplitId(1)),
901 },
902 );
903
904 let response = rx.try_recv().expect("expected response");
905 match response {
906 PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
907 assert_eq!(buffer_id.0, 7);
908 }
909 _ => panic!("unexpected variant"),
910 }
911
912 assert!(pending.lock().unwrap().is_empty());
913 }
914}
915
916async fn plugin_thread_loop(
922 runtime: Rc<RefCell<QuickJsBackend>>,
923 plugins: &mut HashMap<String, TsPluginInfo>,
924 mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
925) {
926 tracing::info!("Plugin thread event loop started");
927
928 let poll_interval = Duration::from_millis(1);
930 let mut has_pending_work = false;
931
932 loop {
933 if crate::backend::has_fatal_js_error() {
937 if let Some(error_msg) = crate::backend::take_fatal_js_error() {
938 tracing::error!(
939 "Fatal JS error detected, terminating plugin thread: {}",
940 error_msg
941 );
942 panic!("Fatal plugin error: {}", error_msg);
943 }
944 }
945
946 tokio::select! {
947 biased; request = request_receiver.recv() => {
950 match request {
951 Some(PluginRequest::ExecuteAction {
952 action_name,
953 response,
954 }) => {
955 let result = runtime.borrow_mut().start_action(&action_name);
958 fire_and_forget(response.send(result));
959 has_pending_work = true; }
961 Some(request) => {
962 let should_shutdown =
963 handle_request(request, Rc::clone(&runtime), plugins).await;
964
965 if should_shutdown {
966 break;
967 }
968 has_pending_work = true; }
970 None => {
971 tracing::info!("Plugin thread request channel closed");
973 break;
974 }
975 }
976 }
977
978 _ = tokio::time::sleep(poll_interval), if has_pending_work => {
980 has_pending_work = runtime.borrow_mut().poll_event_loop_once();
981 }
982 }
983 }
984}
985
986#[allow(clippy::await_holding_refcell_ref)]
994async fn run_hook_internal_rc(
995 runtime: Rc<RefCell<QuickJsBackend>>,
996 hook_name: &str,
997 args: &HookArgs,
998) -> Result<()> {
999 let json_start = std::time::Instant::now();
1002 let json_data = fresh_core::hooks::hook_args_to_json(args)?;
1003 tracing::trace!(
1004 hook = hook_name,
1005 json_us = json_start.elapsed().as_micros(),
1006 "hook args serialized"
1007 );
1008
1009 let emit_start = std::time::Instant::now();
1011 runtime.borrow_mut().emit(hook_name, &json_data).await?;
1012 tracing::trace!(
1013 hook = hook_name,
1014 emit_ms = emit_start.elapsed().as_millis(),
1015 "emit completed"
1016 );
1017
1018 Ok(())
1019}
1020
1021#[allow(clippy::await_holding_refcell_ref)]
1023async fn handle_request(
1024 request: PluginRequest,
1025 runtime: Rc<RefCell<QuickJsBackend>>,
1026 plugins: &mut HashMap<String, TsPluginInfo>,
1027) -> bool {
1028 match request {
1029 PluginRequest::LoadPlugin { path, response } => {
1030 let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
1031 fire_and_forget(response.send(result));
1032 }
1033
1034 PluginRequest::LoadPluginsFromDir { dir, response } => {
1035 let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
1036 fire_and_forget(response.send(errors));
1037 }
1038
1039 PluginRequest::LoadPluginsFromDirWithConfig {
1040 dir,
1041 plugin_configs,
1042 response,
1043 } => {
1044 let (errors, discovered) = load_plugins_from_dir_with_config_internal(
1045 Rc::clone(&runtime),
1046 plugins,
1047 &dir,
1048 &plugin_configs,
1049 )
1050 .await;
1051 fire_and_forget(response.send((errors, discovered)));
1052 }
1053
1054 PluginRequest::LoadPluginFromSource {
1055 source,
1056 name,
1057 is_typescript,
1058 response,
1059 } => {
1060 let result = load_plugin_from_source_internal(
1061 Rc::clone(&runtime),
1062 plugins,
1063 &source,
1064 &name,
1065 is_typescript,
1066 );
1067 fire_and_forget(response.send(result));
1068 }
1069
1070 PluginRequest::UnloadPlugin { name, response } => {
1071 let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
1072 fire_and_forget(response.send(result));
1073 }
1074
1075 PluginRequest::ReloadPlugin { name, response } => {
1076 let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
1077 fire_and_forget(response.send(result));
1078 }
1079
1080 PluginRequest::ExecuteAction {
1081 action_name,
1082 response,
1083 } => {
1084 tracing::error!(
1087 "ExecuteAction should be handled in main loop, not here: {}",
1088 action_name
1089 );
1090 fire_and_forget(response.send(Err(anyhow::anyhow!(
1091 "Internal error: ExecuteAction in wrong handler"
1092 ))));
1093 }
1094
1095 PluginRequest::RunHook { hook_name, args } => {
1096 let hook_start = std::time::Instant::now();
1098 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1100 tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
1101 } else {
1102 tracing::trace!(hook = %hook_name, "RunHook request received");
1103 }
1104 if let Err(e) = run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args).await {
1105 let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
1106 tracing::error!("{}", error_msg);
1107 runtime.borrow_mut().send_status(error_msg);
1109 }
1110 runtime.borrow().send_hook_completed(hook_name.clone());
1113 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
1114 tracing::info!(
1115 hook = %hook_name,
1116 elapsed_ms = hook_start.elapsed().as_millis(),
1117 "RunHook completed (prompt hook)"
1118 );
1119 } else {
1120 tracing::trace!(
1121 hook = %hook_name,
1122 elapsed_ms = hook_start.elapsed().as_millis(),
1123 "RunHook completed"
1124 );
1125 }
1126 }
1127
1128 PluginRequest::HasHookHandlers {
1129 hook_name,
1130 response,
1131 } => {
1132 let has_handlers = runtime.borrow().has_handlers(&hook_name);
1133 fire_and_forget(response.send(has_handlers));
1134 }
1135
1136 PluginRequest::ListPlugins { response } => {
1137 let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
1138 fire_and_forget(response.send(plugin_list));
1139 }
1140
1141 PluginRequest::ResolveCallback {
1142 callback_id,
1143 result_json,
1144 } => {
1145 tracing::info!(
1146 "ResolveCallback: resolving callback_id={} with result_json={}",
1147 callback_id,
1148 result_json
1149 );
1150 runtime
1151 .borrow_mut()
1152 .resolve_callback(callback_id, &result_json);
1153 tracing::info!(
1155 "ResolveCallback: done resolving callback_id={}",
1156 callback_id
1157 );
1158 }
1159
1160 PluginRequest::RejectCallback { callback_id, error } => {
1161 runtime.borrow_mut().reject_callback(callback_id, &error);
1162 }
1164
1165 PluginRequest::CallStreamingCallback {
1166 callback_id,
1167 result_json,
1168 done,
1169 } => {
1170 runtime
1171 .borrow_mut()
1172 .call_streaming_callback(callback_id, &result_json, done);
1173 }
1174
1175 PluginRequest::TrackAsyncResource {
1176 plugin_name,
1177 resource,
1178 } => {
1179 let rt = runtime.borrow();
1180 let mut tracked = rt.plugin_tracked_state.borrow_mut();
1181 let state = tracked.entry(plugin_name).or_default();
1182 match resource {
1183 TrackedAsyncResource::VirtualBuffer(buffer_id) => {
1184 state.virtual_buffer_ids.push(buffer_id);
1185 }
1186 TrackedAsyncResource::CompositeBuffer(buffer_id) => {
1187 state.composite_buffer_ids.push(buffer_id);
1188 }
1189 TrackedAsyncResource::Terminal(terminal_id) => {
1190 state.terminal_ids.push(terminal_id);
1191 }
1192 }
1193 }
1194
1195 PluginRequest::Shutdown => {
1196 tracing::info!("Plugin thread received shutdown request");
1197 return true;
1198 }
1199 }
1200
1201 false
1202}
1203
1204struct PreparedPlugin {
1207 name: String,
1208 path: PathBuf,
1209 js_code: String,
1210 i18n: Option<HashMap<String, HashMap<String, String>>>,
1211 dependencies: Vec<String>,
1212 declarations: Option<String>,
1220}
1221
1222fn prepare_plugin(path: &Path) -> Result<PreparedPlugin> {
1227 let plugin_name = path
1228 .file_stem()
1229 .and_then(|s| s.to_str())
1230 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1231 .to_string();
1232
1233 let source = std::fs::read_to_string(path)
1234 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path.display(), e))?;
1235
1236 let filename = path
1237 .file_name()
1238 .and_then(|s| s.to_str())
1239 .unwrap_or("plugin.ts");
1240
1241 let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1243
1244 let declarations = if filename.ends_with(".ts") {
1251 match fresh_parser_js::emit_isolated_declarations(&source, filename) {
1252 Ok(dts) => Some(dts),
1253 Err(e) => {
1254 tracing::warn!(
1255 "Plugin {} isolated-declarations emit failed: {}",
1256 path.display(),
1257 e
1258 );
1259 None
1260 }
1261 }
1262 } else {
1263 None
1264 };
1265
1266 let js_code = if fresh_parser_js::has_es_imports(&source) {
1268 match fresh_parser_js::bundle_module(path) {
1269 Ok(bundled) => bundled,
1270 Err(e) => {
1271 tracing::warn!(
1272 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
1273 path.display(),
1274 e
1275 );
1276 return Err(anyhow!("Bundling failed for {}: {}", plugin_name, e));
1277 }
1278 }
1279 } else if fresh_parser_js::has_es_module_syntax(&source) {
1280 let stripped = fresh_parser_js::strip_imports_and_exports(&source);
1281 if filename.ends_with(".ts") {
1282 fresh_parser_js::transpile_typescript(&stripped, filename)?
1283 } else {
1284 stripped
1285 }
1286 } else if filename.ends_with(".ts") {
1287 fresh_parser_js::transpile_typescript(&source, filename)?
1288 } else {
1289 source
1290 };
1291
1292 let i18n_path = path.with_extension("i18n.json");
1294 let i18n = if i18n_path.exists() {
1295 std::fs::read_to_string(&i18n_path)
1296 .ok()
1297 .and_then(|content| serde_json::from_str(&content).ok())
1298 } else {
1299 None
1300 };
1301
1302 Ok(PreparedPlugin {
1303 name: plugin_name,
1304 path: path.to_path_buf(),
1305 js_code,
1306 i18n,
1307 dependencies,
1308 declarations,
1309 })
1310}
1311
1312fn execute_prepared_plugin(
1315 runtime: &Rc<RefCell<QuickJsBackend>>,
1316 plugins: &mut HashMap<String, TsPluginInfo>,
1317 prepared: &PreparedPlugin,
1318) -> Result<()> {
1319 if let Some(ref i18n) = prepared.i18n {
1321 runtime
1322 .borrow_mut()
1323 .services
1324 .register_plugin_strings(&prepared.name, i18n.clone());
1325 tracing::debug!("Loaded i18n strings for plugin '{}'", prepared.name);
1326 }
1327
1328 let path_str = prepared
1329 .path
1330 .to_str()
1331 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1332
1333 let exec_start = std::time::Instant::now();
1334 runtime
1335 .borrow_mut()
1336 .execute_js(&prepared.js_code, path_str)?;
1337 let exec_elapsed = exec_start.elapsed();
1338
1339 tracing::debug!(
1340 "execute_prepared_plugin: plugin '{}' executed in {:?}",
1341 prepared.name,
1342 exec_elapsed
1343 );
1344
1345 plugins.insert(
1346 prepared.name.clone(),
1347 TsPluginInfo {
1348 name: prepared.name.clone(),
1349 path: prepared.path.clone(),
1350 enabled: true,
1351 declarations: prepared.declarations.clone(),
1352 },
1353 );
1354
1355 Ok(())
1356}
1357
1358#[allow(clippy::await_holding_refcell_ref)]
1359async fn load_plugin_internal(
1360 runtime: Rc<RefCell<QuickJsBackend>>,
1361 plugins: &mut HashMap<String, TsPluginInfo>,
1362 path: &Path,
1363) -> Result<()> {
1364 let plugin_name = path
1365 .file_stem()
1366 .and_then(|s| s.to_str())
1367 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1368 .to_string();
1369
1370 tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
1371 tracing::debug!(
1372 "load_plugin_internal: starting module load for plugin '{}'",
1373 plugin_name
1374 );
1375
1376 let path_str = path
1378 .to_str()
1379 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1380
1381 let i18n_path = path.with_extension("i18n.json");
1383 if i18n_path.exists() {
1384 if let Ok(content) = std::fs::read_to_string(&i18n_path) {
1385 if let Ok(strings) = serde_json::from_str::<
1386 std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1387 >(&content)
1388 {
1389 runtime
1390 .borrow_mut()
1391 .services
1392 .register_plugin_strings(&plugin_name, strings);
1393 tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
1394 }
1395 }
1396 }
1397
1398 let load_start = std::time::Instant::now();
1399 runtime
1400 .borrow_mut()
1401 .load_module_with_source(path_str, &plugin_name)
1402 .await?;
1403 let load_elapsed = load_start.elapsed();
1404
1405 tracing::debug!(
1406 "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
1407 plugin_name,
1408 load_elapsed
1409 );
1410
1411 plugins.insert(
1413 plugin_name.clone(),
1414 TsPluginInfo {
1415 name: plugin_name.clone(),
1416 path: path.to_path_buf(),
1417 enabled: true,
1418 declarations: None,
1422 },
1423 );
1424
1425 tracing::debug!(
1426 "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
1427 plugin_name,
1428 plugins.len()
1429 );
1430
1431 Ok(())
1432}
1433
1434async fn load_plugins_from_dir_internal(
1436 runtime: Rc<RefCell<QuickJsBackend>>,
1437 plugins: &mut HashMap<String, TsPluginInfo>,
1438 dir: &Path,
1439) -> Vec<String> {
1440 tracing::debug!(
1441 "load_plugins_from_dir_internal: scanning directory {:?}",
1442 dir
1443 );
1444 let mut errors = Vec::new();
1445
1446 if !dir.exists() {
1447 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1448 return errors;
1449 }
1450
1451 match std::fs::read_dir(dir) {
1453 Ok(entries) => {
1454 for entry in entries.flatten() {
1455 let path = entry.path();
1456 let ext = path.extension().and_then(|s| s.to_str());
1457 if ext == Some("ts") || ext == Some("js") {
1458 tracing::debug!(
1459 "load_plugins_from_dir_internal: attempting to load {:?}",
1460 path
1461 );
1462 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
1463 {
1464 let err = format!("Failed to load {:?}: {}", path, e);
1465 tracing::error!("{}", err);
1466 errors.push(err);
1467 }
1468 }
1469 }
1470
1471 tracing::debug!(
1472 "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1473 dir,
1474 errors.len()
1475 );
1476 }
1477 Err(e) => {
1478 let err = format!("Failed to read plugin directory: {}", e);
1479 tracing::error!("{}", err);
1480 errors.push(err);
1481 }
1482 }
1483
1484 errors
1485}
1486
1487async fn load_plugins_from_dir_with_config_internal(
1491 runtime: Rc<RefCell<QuickJsBackend>>,
1492 plugins: &mut HashMap<String, TsPluginInfo>,
1493 dir: &Path,
1494 plugin_configs: &HashMap<String, PluginConfig>,
1495) -> (Vec<String>, HashMap<String, PluginConfig>) {
1496 tracing::debug!(
1497 "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1498 dir
1499 );
1500 let mut errors = Vec::new();
1501 let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1502
1503 if !dir.exists() {
1504 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1505 return (errors, discovered_plugins);
1506 }
1507
1508 let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1510 match std::fs::read_dir(dir) {
1511 Ok(entries) => {
1512 for entry in entries.flatten() {
1513 let path = entry.path();
1514 let ext = path.extension().and_then(|s| s.to_str());
1515 if ext == Some("ts") || ext == Some("js") {
1516 if path.to_string_lossy().contains(".i18n.") {
1518 continue;
1519 }
1520 let plugin_name = path
1522 .file_stem()
1523 .and_then(|s| s.to_str())
1524 .unwrap_or("unknown")
1525 .to_string();
1526 plugin_files.push((plugin_name, path));
1527 }
1528 }
1529 }
1530 Err(e) => {
1531 let err = format!("Failed to read plugin directory: {}", e);
1532 tracing::error!("{}", err);
1533 errors.push(err);
1534 return (errors, discovered_plugins);
1535 }
1536 }
1537
1538 let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1540 for (plugin_name, path) in plugin_files {
1541 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1543 PluginConfig {
1545 enabled: existing_config.enabled,
1546 path: Some(path.clone()),
1547 }
1548 } else {
1549 PluginConfig::new_with_path(path.clone())
1551 };
1552
1553 discovered_plugins.insert(plugin_name.clone(), config.clone());
1555
1556 if config.enabled {
1557 enabled_plugins.push((plugin_name, path));
1558 } else {
1559 tracing::info!(
1560 "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1561 plugin_name
1562 );
1563 }
1564 }
1565
1566 let prep_start = std::time::Instant::now();
1569 let paths: Vec<std::path::PathBuf> = enabled_plugins.iter().map(|(_, p)| p.clone()).collect();
1570 let prepared_results: Vec<(String, Result<PreparedPlugin>)> = std::thread::scope(|scope| {
1571 let handles: Vec<_> = paths
1572 .iter()
1573 .map(|path| {
1574 let path = path.clone();
1575 scope.spawn(move || {
1576 let name = path
1577 .file_stem()
1578 .and_then(|s| s.to_str())
1579 .unwrap_or("unknown")
1580 .to_string();
1581 let result = prepare_plugin(&path);
1582 (name, result)
1583 })
1584 })
1585 .collect();
1586 handles.into_iter().map(|h| h.join().unwrap()).collect()
1587 });
1588 let prep_elapsed = prep_start.elapsed();
1589
1590 let mut prepared_map: std::collections::HashMap<String, PreparedPlugin> =
1592 std::collections::HashMap::new();
1593 for (name, result) in prepared_results {
1594 match result {
1595 Ok(prepared) => {
1596 prepared_map.insert(name, prepared);
1597 }
1598 Err(e) => {
1599 let err = format!("Failed to prepare plugin '{}': {}", name, e);
1600 tracing::error!("{}", err);
1601 errors.push(err);
1602 }
1603 }
1604 }
1605
1606 tracing::info!(
1607 "Parallel plugin preparation completed in {:?} ({} plugins)",
1608 prep_elapsed,
1609 prepared_map.len()
1610 );
1611
1612 let mut dependency_map: std::collections::HashMap<String, Vec<String>> =
1614 std::collections::HashMap::new();
1615 for (name, prepared) in &prepared_map {
1616 if !prepared.dependencies.is_empty() {
1617 tracing::debug!(
1618 "Plugin '{}' declares dependencies: {:?}",
1619 name,
1620 prepared.dependencies
1621 );
1622 dependency_map.insert(name.clone(), prepared.dependencies.clone());
1623 }
1624 }
1625
1626 let plugin_names: Vec<String> = prepared_map.keys().cloned().collect();
1628 let load_order = match fresh_parser_js::topological_sort_plugins(&plugin_names, &dependency_map)
1629 {
1630 Ok(order) => order,
1631 Err(e) => {
1632 let err = format!("Plugin dependency resolution failed: {}", e);
1633 tracing::error!("{}", err);
1634 errors.push(err);
1635 let mut names = plugin_names;
1637 names.sort();
1638 names
1639 }
1640 };
1641
1642 let exec_start = std::time::Instant::now();
1644 for plugin_name in load_order {
1645 if let Some(prepared) = prepared_map.get(&plugin_name) {
1646 tracing::debug!(
1647 "load_plugins_from_dir_with_config_internal: executing plugin '{}'",
1648 plugin_name
1649 );
1650 if let Err(e) = execute_prepared_plugin(&runtime, plugins, prepared) {
1651 let err = format!("Failed to execute plugin '{}': {}", plugin_name, e);
1652 tracing::error!("{}", err);
1653 errors.push(err);
1654 }
1655 }
1656 }
1657 let exec_elapsed = exec_start.elapsed();
1658
1659 tracing::info!(
1660 "Serial plugin execution completed in {:?} ({} plugins)",
1661 exec_elapsed,
1662 plugins.len()
1663 );
1664
1665 tracing::debug!(
1666 "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors (prep: {:?}, exec: {:?})",
1667 discovered_plugins.len(),
1668 errors.len(),
1669 prep_elapsed,
1670 exec_elapsed
1671 );
1672
1673 (errors, discovered_plugins)
1674}
1675
1676fn load_plugin_from_source_internal(
1681 runtime: Rc<RefCell<QuickJsBackend>>,
1682 plugins: &mut HashMap<String, TsPluginInfo>,
1683 source: &str,
1684 name: &str,
1685 is_typescript: bool,
1686) -> Result<()> {
1687 if plugins.contains_key(name) {
1689 tracing::info!(
1690 "Hot-reloading buffer plugin '{}' — unloading previous version",
1691 name
1692 );
1693 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1694 }
1695
1696 tracing::info!("Loading plugin from source: {}", name);
1697
1698 runtime
1699 .borrow_mut()
1700 .execute_source(source, name, is_typescript)?;
1701
1702 plugins.insert(
1704 name.to_string(),
1705 TsPluginInfo {
1706 name: name.to_string(),
1707 path: PathBuf::from(format!("<buffer:{}>", name)),
1708 enabled: true,
1709 declarations: None,
1714 },
1715 );
1716
1717 tracing::info!(
1718 "Buffer plugin '{}' loaded successfully, total plugins: {}",
1719 name,
1720 plugins.len()
1721 );
1722
1723 Ok(())
1724}
1725
1726fn unload_plugin_internal(
1728 runtime: Rc<RefCell<QuickJsBackend>>,
1729 plugins: &mut HashMap<String, TsPluginInfo>,
1730 name: &str,
1731) -> Result<()> {
1732 if plugins.remove(name).is_some() {
1733 tracing::info!("Unloading TypeScript plugin: {}", name);
1734
1735 runtime
1737 .borrow_mut()
1738 .services
1739 .unregister_plugin_strings(name);
1740
1741 runtime
1743 .borrow()
1744 .services
1745 .unregister_commands_by_plugin(name);
1746
1747 runtime.borrow().cleanup_plugin(name);
1749
1750 Ok(())
1751 } else {
1752 Err(anyhow!("Plugin '{}' not found", name))
1753 }
1754}
1755
1756async fn reload_plugin_internal(
1758 runtime: Rc<RefCell<QuickJsBackend>>,
1759 plugins: &mut HashMap<String, TsPluginInfo>,
1760 name: &str,
1761) -> Result<()> {
1762 let path = plugins
1763 .get(name)
1764 .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1765 .path
1766 .clone();
1767
1768 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1769 load_plugin_internal(runtime, plugins, &path).await?;
1770
1771 Ok(())
1772}
1773
1774#[cfg(test)]
1775mod tests {
1776 use super::*;
1777 use fresh_core::hooks::hook_args_to_json;
1778
1779 #[test]
1780 fn test_oneshot_channel() {
1781 let (tx, rx) = oneshot::channel::<i32>();
1782 assert!(tx.send(42).is_ok());
1783 assert_eq!(rx.recv().unwrap(), 42);
1784 }
1785
1786 #[test]
1787 fn test_hook_args_to_json_editor_initialized() {
1788 let args = HookArgs::EditorInitialized;
1789 let json = hook_args_to_json(&args).unwrap();
1790 assert_eq!(json, serde_json::json!({}));
1791 }
1792
1793 #[test]
1794 fn test_hook_args_to_json_prompt_changed() {
1795 let args = HookArgs::PromptChanged {
1796 prompt_type: "search".to_string(),
1797 input: "test".to_string(),
1798 };
1799 let json = hook_args_to_json(&args).unwrap();
1800 assert_eq!(json["prompt_type"], "search");
1801 assert_eq!(json["input"], "test");
1802 }
1803}