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}
1213
1214fn prepare_plugin(path: &Path) -> Result<PreparedPlugin> {
1219 let plugin_name = path
1220 .file_stem()
1221 .and_then(|s| s.to_str())
1222 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1223 .to_string();
1224
1225 let source = std::fs::read_to_string(path)
1226 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path.display(), e))?;
1227
1228 let filename = path
1229 .file_name()
1230 .and_then(|s| s.to_str())
1231 .unwrap_or("plugin.ts");
1232
1233 let dependencies = fresh_parser_js::extract_plugin_dependencies(&source);
1235
1236 let js_code = if fresh_parser_js::has_es_imports(&source) {
1238 match fresh_parser_js::bundle_module(path) {
1239 Ok(bundled) => bundled,
1240 Err(e) => {
1241 tracing::warn!(
1242 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
1243 path.display(),
1244 e
1245 );
1246 return Err(anyhow!("Bundling failed for {}: {}", plugin_name, e));
1247 }
1248 }
1249 } else if fresh_parser_js::has_es_module_syntax(&source) {
1250 let stripped = fresh_parser_js::strip_imports_and_exports(&source);
1251 if filename.ends_with(".ts") {
1252 fresh_parser_js::transpile_typescript(&stripped, filename)?
1253 } else {
1254 stripped
1255 }
1256 } else if filename.ends_with(".ts") {
1257 fresh_parser_js::transpile_typescript(&source, filename)?
1258 } else {
1259 source
1260 };
1261
1262 let i18n_path = path.with_extension("i18n.json");
1264 let i18n = if i18n_path.exists() {
1265 std::fs::read_to_string(&i18n_path)
1266 .ok()
1267 .and_then(|content| serde_json::from_str(&content).ok())
1268 } else {
1269 None
1270 };
1271
1272 Ok(PreparedPlugin {
1273 name: plugin_name,
1274 path: path.to_path_buf(),
1275 js_code,
1276 i18n,
1277 dependencies,
1278 })
1279}
1280
1281fn execute_prepared_plugin(
1284 runtime: &Rc<RefCell<QuickJsBackend>>,
1285 plugins: &mut HashMap<String, TsPluginInfo>,
1286 prepared: &PreparedPlugin,
1287) -> Result<()> {
1288 if let Some(ref i18n) = prepared.i18n {
1290 runtime
1291 .borrow_mut()
1292 .services
1293 .register_plugin_strings(&prepared.name, i18n.clone());
1294 tracing::debug!("Loaded i18n strings for plugin '{}'", prepared.name);
1295 }
1296
1297 let path_str = prepared
1298 .path
1299 .to_str()
1300 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1301
1302 let exec_start = std::time::Instant::now();
1303 runtime
1304 .borrow_mut()
1305 .execute_js(&prepared.js_code, path_str)?;
1306 let exec_elapsed = exec_start.elapsed();
1307
1308 tracing::debug!(
1309 "execute_prepared_plugin: plugin '{}' executed in {:?}",
1310 prepared.name,
1311 exec_elapsed
1312 );
1313
1314 plugins.insert(
1315 prepared.name.clone(),
1316 TsPluginInfo {
1317 name: prepared.name.clone(),
1318 path: prepared.path.clone(),
1319 enabled: true,
1320 },
1321 );
1322
1323 Ok(())
1324}
1325
1326#[allow(clippy::await_holding_refcell_ref)]
1327async fn load_plugin_internal(
1328 runtime: Rc<RefCell<QuickJsBackend>>,
1329 plugins: &mut HashMap<String, TsPluginInfo>,
1330 path: &Path,
1331) -> Result<()> {
1332 let plugin_name = path
1333 .file_stem()
1334 .and_then(|s| s.to_str())
1335 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
1336 .to_string();
1337
1338 tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
1339 tracing::debug!(
1340 "load_plugin_internal: starting module load for plugin '{}'",
1341 plugin_name
1342 );
1343
1344 let path_str = path
1346 .to_str()
1347 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
1348
1349 let i18n_path = path.with_extension("i18n.json");
1351 if i18n_path.exists() {
1352 if let Ok(content) = std::fs::read_to_string(&i18n_path) {
1353 if let Ok(strings) = serde_json::from_str::<
1354 std::collections::HashMap<String, std::collections::HashMap<String, String>>,
1355 >(&content)
1356 {
1357 runtime
1358 .borrow_mut()
1359 .services
1360 .register_plugin_strings(&plugin_name, strings);
1361 tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
1362 }
1363 }
1364 }
1365
1366 let load_start = std::time::Instant::now();
1367 runtime
1368 .borrow_mut()
1369 .load_module_with_source(path_str, &plugin_name)
1370 .await?;
1371 let load_elapsed = load_start.elapsed();
1372
1373 tracing::debug!(
1374 "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
1375 plugin_name,
1376 load_elapsed
1377 );
1378
1379 plugins.insert(
1381 plugin_name.clone(),
1382 TsPluginInfo {
1383 name: plugin_name.clone(),
1384 path: path.to_path_buf(),
1385 enabled: true,
1386 },
1387 );
1388
1389 tracing::debug!(
1390 "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
1391 plugin_name,
1392 plugins.len()
1393 );
1394
1395 Ok(())
1396}
1397
1398async fn load_plugins_from_dir_internal(
1400 runtime: Rc<RefCell<QuickJsBackend>>,
1401 plugins: &mut HashMap<String, TsPluginInfo>,
1402 dir: &Path,
1403) -> Vec<String> {
1404 tracing::debug!(
1405 "load_plugins_from_dir_internal: scanning directory {:?}",
1406 dir
1407 );
1408 let mut errors = Vec::new();
1409
1410 if !dir.exists() {
1411 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1412 return errors;
1413 }
1414
1415 match std::fs::read_dir(dir) {
1417 Ok(entries) => {
1418 for entry in entries.flatten() {
1419 let path = entry.path();
1420 let ext = path.extension().and_then(|s| s.to_str());
1421 if ext == Some("ts") || ext == Some("js") {
1422 tracing::debug!(
1423 "load_plugins_from_dir_internal: attempting to load {:?}",
1424 path
1425 );
1426 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
1427 {
1428 let err = format!("Failed to load {:?}: {}", path, e);
1429 tracing::error!("{}", err);
1430 errors.push(err);
1431 }
1432 }
1433 }
1434
1435 tracing::debug!(
1436 "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1437 dir,
1438 errors.len()
1439 );
1440 }
1441 Err(e) => {
1442 let err = format!("Failed to read plugin directory: {}", e);
1443 tracing::error!("{}", err);
1444 errors.push(err);
1445 }
1446 }
1447
1448 errors
1449}
1450
1451async fn load_plugins_from_dir_with_config_internal(
1455 runtime: Rc<RefCell<QuickJsBackend>>,
1456 plugins: &mut HashMap<String, TsPluginInfo>,
1457 dir: &Path,
1458 plugin_configs: &HashMap<String, PluginConfig>,
1459) -> (Vec<String>, HashMap<String, PluginConfig>) {
1460 tracing::debug!(
1461 "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1462 dir
1463 );
1464 let mut errors = Vec::new();
1465 let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1466
1467 if !dir.exists() {
1468 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1469 return (errors, discovered_plugins);
1470 }
1471
1472 let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1474 match std::fs::read_dir(dir) {
1475 Ok(entries) => {
1476 for entry in entries.flatten() {
1477 let path = entry.path();
1478 let ext = path.extension().and_then(|s| s.to_str());
1479 if ext == Some("ts") || ext == Some("js") {
1480 if path.to_string_lossy().contains(".i18n.") {
1482 continue;
1483 }
1484 let plugin_name = path
1486 .file_stem()
1487 .and_then(|s| s.to_str())
1488 .unwrap_or("unknown")
1489 .to_string();
1490 plugin_files.push((plugin_name, path));
1491 }
1492 }
1493 }
1494 Err(e) => {
1495 let err = format!("Failed to read plugin directory: {}", e);
1496 tracing::error!("{}", err);
1497 errors.push(err);
1498 return (errors, discovered_plugins);
1499 }
1500 }
1501
1502 let mut enabled_plugins: Vec<(String, std::path::PathBuf)> = Vec::new();
1504 for (plugin_name, path) in plugin_files {
1505 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1507 PluginConfig {
1509 enabled: existing_config.enabled,
1510 path: Some(path.clone()),
1511 }
1512 } else {
1513 PluginConfig::new_with_path(path.clone())
1515 };
1516
1517 discovered_plugins.insert(plugin_name.clone(), config.clone());
1519
1520 if config.enabled {
1521 enabled_plugins.push((plugin_name, path));
1522 } else {
1523 tracing::info!(
1524 "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1525 plugin_name
1526 );
1527 }
1528 }
1529
1530 let prep_start = std::time::Instant::now();
1533 let paths: Vec<std::path::PathBuf> = enabled_plugins.iter().map(|(_, p)| p.clone()).collect();
1534 let prepared_results: Vec<(String, Result<PreparedPlugin>)> = std::thread::scope(|scope| {
1535 let handles: Vec<_> = paths
1536 .iter()
1537 .map(|path| {
1538 let path = path.clone();
1539 scope.spawn(move || {
1540 let name = path
1541 .file_stem()
1542 .and_then(|s| s.to_str())
1543 .unwrap_or("unknown")
1544 .to_string();
1545 let result = prepare_plugin(&path);
1546 (name, result)
1547 })
1548 })
1549 .collect();
1550 handles.into_iter().map(|h| h.join().unwrap()).collect()
1551 });
1552 let prep_elapsed = prep_start.elapsed();
1553
1554 let mut prepared_map: std::collections::HashMap<String, PreparedPlugin> =
1556 std::collections::HashMap::new();
1557 for (name, result) in prepared_results {
1558 match result {
1559 Ok(prepared) => {
1560 prepared_map.insert(name, prepared);
1561 }
1562 Err(e) => {
1563 let err = format!("Failed to prepare plugin '{}': {}", name, e);
1564 tracing::error!("{}", err);
1565 errors.push(err);
1566 }
1567 }
1568 }
1569
1570 tracing::info!(
1571 "Parallel plugin preparation completed in {:?} ({} plugins)",
1572 prep_elapsed,
1573 prepared_map.len()
1574 );
1575
1576 let mut dependency_map: std::collections::HashMap<String, Vec<String>> =
1578 std::collections::HashMap::new();
1579 for (name, prepared) in &prepared_map {
1580 if !prepared.dependencies.is_empty() {
1581 tracing::debug!(
1582 "Plugin '{}' declares dependencies: {:?}",
1583 name,
1584 prepared.dependencies
1585 );
1586 dependency_map.insert(name.clone(), prepared.dependencies.clone());
1587 }
1588 }
1589
1590 let plugin_names: Vec<String> = prepared_map.keys().cloned().collect();
1592 let load_order = match fresh_parser_js::topological_sort_plugins(&plugin_names, &dependency_map)
1593 {
1594 Ok(order) => order,
1595 Err(e) => {
1596 let err = format!("Plugin dependency resolution failed: {}", e);
1597 tracing::error!("{}", err);
1598 errors.push(err);
1599 let mut names = plugin_names;
1601 names.sort();
1602 names
1603 }
1604 };
1605
1606 let exec_start = std::time::Instant::now();
1608 for plugin_name in load_order {
1609 if let Some(prepared) = prepared_map.get(&plugin_name) {
1610 tracing::debug!(
1611 "load_plugins_from_dir_with_config_internal: executing plugin '{}'",
1612 plugin_name
1613 );
1614 if let Err(e) = execute_prepared_plugin(&runtime, plugins, prepared) {
1615 let err = format!("Failed to execute plugin '{}': {}", plugin_name, e);
1616 tracing::error!("{}", err);
1617 errors.push(err);
1618 }
1619 }
1620 }
1621 let exec_elapsed = exec_start.elapsed();
1622
1623 tracing::info!(
1624 "Serial plugin execution completed in {:?} ({} plugins)",
1625 exec_elapsed,
1626 plugins.len()
1627 );
1628
1629 tracing::debug!(
1630 "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors (prep: {:?}, exec: {:?})",
1631 discovered_plugins.len(),
1632 errors.len(),
1633 prep_elapsed,
1634 exec_elapsed
1635 );
1636
1637 (errors, discovered_plugins)
1638}
1639
1640fn load_plugin_from_source_internal(
1645 runtime: Rc<RefCell<QuickJsBackend>>,
1646 plugins: &mut HashMap<String, TsPluginInfo>,
1647 source: &str,
1648 name: &str,
1649 is_typescript: bool,
1650) -> Result<()> {
1651 if plugins.contains_key(name) {
1653 tracing::info!(
1654 "Hot-reloading buffer plugin '{}' — unloading previous version",
1655 name
1656 );
1657 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1658 }
1659
1660 tracing::info!("Loading plugin from source: {}", name);
1661
1662 runtime
1663 .borrow_mut()
1664 .execute_source(source, name, is_typescript)?;
1665
1666 plugins.insert(
1668 name.to_string(),
1669 TsPluginInfo {
1670 name: name.to_string(),
1671 path: PathBuf::from(format!("<buffer:{}>", name)),
1672 enabled: true,
1673 },
1674 );
1675
1676 tracing::info!(
1677 "Buffer plugin '{}' loaded successfully, total plugins: {}",
1678 name,
1679 plugins.len()
1680 );
1681
1682 Ok(())
1683}
1684
1685fn unload_plugin_internal(
1687 runtime: Rc<RefCell<QuickJsBackend>>,
1688 plugins: &mut HashMap<String, TsPluginInfo>,
1689 name: &str,
1690) -> Result<()> {
1691 if plugins.remove(name).is_some() {
1692 tracing::info!("Unloading TypeScript plugin: {}", name);
1693
1694 runtime
1696 .borrow_mut()
1697 .services
1698 .unregister_plugin_strings(name);
1699
1700 runtime
1702 .borrow()
1703 .services
1704 .unregister_commands_by_plugin(name);
1705
1706 runtime.borrow().cleanup_plugin(name);
1708
1709 Ok(())
1710 } else {
1711 Err(anyhow!("Plugin '{}' not found", name))
1712 }
1713}
1714
1715async fn reload_plugin_internal(
1717 runtime: Rc<RefCell<QuickJsBackend>>,
1718 plugins: &mut HashMap<String, TsPluginInfo>,
1719 name: &str,
1720) -> Result<()> {
1721 let path = plugins
1722 .get(name)
1723 .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1724 .path
1725 .clone();
1726
1727 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1728 load_plugin_internal(runtime, plugins, &path).await?;
1729
1730 Ok(())
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735 use super::*;
1736 use fresh_core::hooks::hook_args_to_json;
1737
1738 #[test]
1739 fn test_oneshot_channel() {
1740 let (tx, rx) = oneshot::channel::<i32>();
1741 assert!(tx.send(42).is_ok());
1742 assert_eq!(rx.recv().unwrap(), 42);
1743 }
1744
1745 #[test]
1746 fn test_hook_args_to_json_editor_initialized() {
1747 let args = HookArgs::EditorInitialized;
1748 let json = hook_args_to_json(&args).unwrap();
1749 assert_eq!(json, serde_json::json!({}));
1750 }
1751
1752 #[test]
1753 fn test_hook_args_to_json_prompt_changed() {
1754 let args = HookArgs::PromptChanged {
1755 prompt_type: "search".to_string(),
1756 input: "test".to_string(),
1757 };
1758 let json = hook_args_to_json(&args).unwrap();
1759 assert_eq!(json["prompt_type"], "search");
1760 assert_eq!(json["input"], "test");
1761 }
1762}