1use crate::backend::quickjs_backend::{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
28#[derive(Debug)]
30pub enum PluginRequest {
31 LoadPlugin {
33 path: PathBuf,
34 response: oneshot::Sender<Result<()>>,
35 },
36
37 ResolveCallback {
39 callback_id: fresh_core::api::JsCallbackId,
40 result_json: String,
41 },
42
43 RejectCallback {
45 callback_id: fresh_core::api::JsCallbackId,
46 error: String,
47 },
48
49 LoadPluginsFromDir {
51 dir: PathBuf,
52 response: oneshot::Sender<Vec<String>>,
53 },
54
55 LoadPluginsFromDirWithConfig {
59 dir: PathBuf,
60 plugin_configs: HashMap<String, PluginConfig>,
61 response: oneshot::Sender<(Vec<String>, HashMap<String, PluginConfig>)>,
62 },
63
64 UnloadPlugin {
66 name: String,
67 response: oneshot::Sender<Result<()>>,
68 },
69
70 ReloadPlugin {
72 name: String,
73 response: oneshot::Sender<Result<()>>,
74 },
75
76 ExecuteAction {
78 action_name: String,
79 response: oneshot::Sender<Result<()>>,
80 },
81
82 RunHook { hook_name: String, args: HookArgs },
84
85 HasHookHandlers {
87 hook_name: String,
88 response: oneshot::Sender<bool>,
89 },
90
91 ListPlugins {
93 response: oneshot::Sender<Vec<TsPluginInfo>>,
94 },
95
96 Shutdown,
98}
99
100pub mod oneshot {
102 use std::fmt;
103 use std::sync::mpsc;
104
105 pub struct Sender<T>(mpsc::SyncSender<T>);
106 pub struct Receiver<T>(mpsc::Receiver<T>);
107
108 use anyhow::Result;
109
110 impl<T> fmt::Debug for Sender<T> {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.debug_tuple("Sender").finish()
113 }
114 }
115
116 impl<T> fmt::Debug for Receiver<T> {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.debug_tuple("Receiver").finish()
119 }
120 }
121
122 impl<T> Sender<T> {
123 pub fn send(self, value: T) -> Result<(), T> {
124 self.0.send(value).map_err(|e| e.0)
125 }
126 }
127
128 impl<T> Receiver<T> {
129 pub fn recv(self) -> Result<T, mpsc::RecvError> {
130 self.0.recv()
131 }
132
133 pub fn recv_timeout(
134 self,
135 timeout: std::time::Duration,
136 ) -> Result<T, mpsc::RecvTimeoutError> {
137 self.0.recv_timeout(timeout)
138 }
139
140 pub fn try_recv(&self) -> Result<T, mpsc::TryRecvError> {
141 self.0.try_recv()
142 }
143 }
144
145 pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
146 let (tx, rx) = mpsc::sync_channel(1);
147 (Sender(tx), Receiver(rx))
148 }
149}
150
151pub struct PluginThreadHandle {
153 request_sender: Option<tokio::sync::mpsc::UnboundedSender<PluginRequest>>,
156
157 thread_handle: Option<JoinHandle<()>>,
159
160 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
162
163 pending_responses: PendingResponses,
165
166 command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
168}
169
170impl PluginThreadHandle {
171 pub fn spawn(services: Arc<dyn fresh_core::services::PluginServiceBridge>) -> Result<Self> {
173 tracing::debug!("PluginThreadHandle::spawn: starting plugin thread creation");
174
175 let (command_sender, command_receiver) = std::sync::mpsc::channel();
177
178 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
180
181 let pending_responses: PendingResponses =
183 Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
184 let thread_pending_responses = Arc::clone(&pending_responses);
185
186 let (request_sender, request_receiver) = tokio::sync::mpsc::unbounded_channel();
188
189 let thread_state_snapshot = Arc::clone(&state_snapshot);
191
192 tracing::debug!("PluginThreadHandle::spawn: spawning OS thread for plugin runtime");
194 let thread_handle = thread::spawn(move || {
195 tracing::debug!("Plugin thread: OS thread started, creating tokio runtime");
196 let rt = match tokio::runtime::Builder::new_current_thread()
198 .enable_all()
199 .build()
200 {
201 Ok(rt) => {
202 tracing::debug!("Plugin thread: tokio runtime created successfully");
203 rt
204 }
205 Err(e) => {
206 tracing::error!("Failed to create plugin thread runtime: {}", e);
207 return;
208 }
209 };
210
211 tracing::debug!("Plugin thread: creating QuickJS runtime");
213 let runtime = match QuickJsBackend::with_state_and_responses(
214 Arc::clone(&thread_state_snapshot),
215 command_sender,
216 thread_pending_responses,
217 services.clone(),
218 ) {
219 Ok(rt) => {
220 tracing::debug!("Plugin thread: QuickJS runtime created successfully");
221 rt
222 }
223 Err(e) => {
224 tracing::error!("Failed to create QuickJS runtime: {}", e);
225 return;
226 }
227 };
228
229 let mut plugins: HashMap<String, TsPluginInfo> = HashMap::new();
231
232 tracing::debug!("Plugin thread: starting event loop with LocalSet");
234 let local = tokio::task::LocalSet::new();
235 local.block_on(&rt, async {
236 let runtime = Rc::new(RefCell::new(runtime));
238 tracing::debug!("Plugin thread: entering plugin_thread_loop");
239 plugin_thread_loop(runtime, &mut plugins, request_receiver).await;
240 });
241
242 tracing::info!("Plugin thread shutting down");
243 });
244
245 tracing::debug!("PluginThreadHandle::spawn: OS thread spawned, returning handle");
246 tracing::info!("Plugin thread spawned");
247
248 Ok(Self {
249 request_sender: Some(request_sender),
250 thread_handle: Some(thread_handle),
251 state_snapshot,
252 pending_responses,
253 command_receiver,
254 })
255 }
256
257 pub fn deliver_response(&self, response: fresh_core::api::PluginResponse) {
261 if respond_to_pending(&self.pending_responses, response.clone()) {
263 return;
264 }
265
266 use fresh_core::api::{JsCallbackId, PluginResponse};
268 use serde_json::json;
269
270 match response {
271 PluginResponse::VirtualBufferCreated {
272 request_id,
273 buffer_id,
274 split_id,
275 } => {
276 let result = json!({
277 "buffer_id": buffer_id,
278 "split_id": split_id
279 });
280 self.resolve_callback(JsCallbackId(request_id), result.to_string());
281 }
282 PluginResponse::LspRequest { request_id, result } => match result {
283 Ok(value) => {
284 self.resolve_callback(JsCallbackId(request_id), value.to_string());
285 }
286 Err(e) => {
287 self.reject_callback(JsCallbackId(request_id), e);
288 }
289 },
290 PluginResponse::HighlightsComputed { request_id, spans } => {
291 let result = serde_json::to_string(&spans).unwrap_or_else(|_| "[]".to_string());
292 self.resolve_callback(JsCallbackId(request_id), result);
293 }
294 PluginResponse::BufferText { request_id, text } => match text {
295 Ok(content) => {
296 let result =
298 serde_json::to_string(&content).unwrap_or_else(|_| "\"\"".to_string());
299 self.resolve_callback(JsCallbackId(request_id), result);
300 }
301 Err(e) => {
302 self.reject_callback(JsCallbackId(request_id), e);
303 }
304 },
305 PluginResponse::CompositeBufferCreated {
306 request_id,
307 buffer_id,
308 } => {
309 let result = json!({
310 "buffer_id": buffer_id
311 });
312 self.resolve_callback(JsCallbackId(request_id), result.to_string());
313 }
314 }
315 }
316
317 pub fn load_plugin(&self, path: &Path) -> Result<()> {
319 let (tx, rx) = oneshot::channel();
320 self.request_sender
321 .as_ref()
322 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
323 .send(PluginRequest::LoadPlugin {
324 path: path.to_path_buf(),
325 response: tx,
326 })
327 .map_err(|_| anyhow!("Plugin thread not responding"))?;
328
329 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
330 }
331
332 pub fn load_plugins_from_dir(&self, dir: &Path) -> Vec<String> {
334 let (tx, rx) = oneshot::channel();
335 let Some(sender) = self.request_sender.as_ref() else {
336 return vec!["Plugin thread shut down".to_string()];
337 };
338 if sender
339 .send(PluginRequest::LoadPluginsFromDir {
340 dir: dir.to_path_buf(),
341 response: tx,
342 })
343 .is_err()
344 {
345 return vec!["Plugin thread not responding".to_string()];
346 }
347
348 rx.recv()
349 .unwrap_or_else(|_| vec!["Plugin thread closed".to_string()])
350 }
351
352 pub fn load_plugins_from_dir_with_config(
356 &self,
357 dir: &Path,
358 plugin_configs: &HashMap<String, PluginConfig>,
359 ) -> (Vec<String>, HashMap<String, PluginConfig>) {
360 let (tx, rx) = oneshot::channel();
361 let Some(sender) = self.request_sender.as_ref() else {
362 return (vec!["Plugin thread shut down".to_string()], HashMap::new());
363 };
364 if sender
365 .send(PluginRequest::LoadPluginsFromDirWithConfig {
366 dir: dir.to_path_buf(),
367 plugin_configs: plugin_configs.clone(),
368 response: tx,
369 })
370 .is_err()
371 {
372 return (
373 vec!["Plugin thread not responding".to_string()],
374 HashMap::new(),
375 );
376 }
377
378 rx.recv()
379 .unwrap_or_else(|_| (vec!["Plugin thread closed".to_string()], HashMap::new()))
380 }
381
382 pub fn unload_plugin(&self, name: &str) -> Result<()> {
384 let (tx, rx) = oneshot::channel();
385 self.request_sender
386 .as_ref()
387 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
388 .send(PluginRequest::UnloadPlugin {
389 name: name.to_string(),
390 response: tx,
391 })
392 .map_err(|_| anyhow!("Plugin thread not responding"))?;
393
394 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
395 }
396
397 pub fn reload_plugin(&self, name: &str) -> Result<()> {
399 let (tx, rx) = oneshot::channel();
400 self.request_sender
401 .as_ref()
402 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
403 .send(PluginRequest::ReloadPlugin {
404 name: name.to_string(),
405 response: tx,
406 })
407 .map_err(|_| anyhow!("Plugin thread not responding"))?;
408
409 rx.recv().map_err(|_| anyhow!("Plugin thread closed"))?
410 }
411
412 pub fn execute_action_async(&self, action_name: &str) -> Result<oneshot::Receiver<Result<()>>> {
417 tracing::trace!("execute_action_async: starting action '{}'", action_name);
418 let (tx, rx) = oneshot::channel();
419 self.request_sender
420 .as_ref()
421 .ok_or_else(|| anyhow!("Plugin thread shut down"))?
422 .send(PluginRequest::ExecuteAction {
423 action_name: action_name.to_string(),
424 response: tx,
425 })
426 .map_err(|_| anyhow!("Plugin thread not responding"))?;
427
428 tracing::trace!("execute_action_async: request sent for '{}'", action_name);
429 Ok(rx)
430 }
431
432 pub fn run_hook(&self, hook_name: &str, args: HookArgs) {
438 if let Some(sender) = self.request_sender.as_ref() {
439 let _ = sender.send(PluginRequest::RunHook {
440 hook_name: hook_name.to_string(),
441 args,
442 });
443 }
444 }
445
446 pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
448 let (tx, rx) = oneshot::channel();
449 let Some(sender) = self.request_sender.as_ref() else {
450 return false;
451 };
452 if sender
453 .send(PluginRequest::HasHookHandlers {
454 hook_name: hook_name.to_string(),
455 response: tx,
456 })
457 .is_err()
458 {
459 return false;
460 }
461
462 rx.recv().unwrap_or(false)
463 }
464
465 pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
467 let (tx, rx) = oneshot::channel();
468 let Some(sender) = self.request_sender.as_ref() else {
469 return vec![];
470 };
471 if sender
472 .send(PluginRequest::ListPlugins { response: tx })
473 .is_err()
474 {
475 return vec![];
476 }
477
478 rx.recv().unwrap_or_default()
479 }
480
481 pub fn process_commands(&mut self) -> Vec<PluginCommand> {
486 let mut commands = Vec::new();
487 while let Ok(cmd) = self.command_receiver.try_recv() {
488 commands.push(cmd);
489 }
490 commands
491 }
492
493 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
495 Arc::clone(&self.state_snapshot)
496 }
497
498 pub fn shutdown(&mut self) {
500 tracing::debug!("PluginThreadHandle::shutdown: starting shutdown");
501
502 if let Ok(mut pending) = self.pending_responses.lock() {
505 if !pending.is_empty() {
506 tracing::warn!(
507 "PluginThreadHandle::shutdown: dropping {} pending responses: {:?}",
508 pending.len(),
509 pending.keys().collect::<Vec<_>>()
510 );
511 pending.clear(); }
513 }
514
515 if let Some(sender) = self.request_sender.as_ref() {
517 tracing::debug!("PluginThreadHandle::shutdown: sending Shutdown request");
518 let _ = sender.send(PluginRequest::Shutdown);
519 }
520
521 tracing::debug!("PluginThreadHandle::shutdown: dropping request_sender to close channel");
524 self.request_sender.take();
525
526 if let Some(handle) = self.thread_handle.take() {
527 tracing::debug!("PluginThreadHandle::shutdown: joining plugin thread");
528 let _ = handle.join();
529 tracing::debug!("PluginThreadHandle::shutdown: plugin thread joined");
530 }
531
532 tracing::debug!("PluginThreadHandle::shutdown: shutdown complete");
533 }
534
535 pub fn resolve_callback(
538 &self,
539 callback_id: fresh_core::api::JsCallbackId,
540 result_json: String,
541 ) {
542 if let Some(sender) = self.request_sender.as_ref() {
543 let _ = sender.send(PluginRequest::ResolveCallback {
544 callback_id,
545 result_json,
546 });
547 }
548 }
549
550 pub fn reject_callback(&self, callback_id: fresh_core::api::JsCallbackId, error: String) {
553 if let Some(sender) = self.request_sender.as_ref() {
554 let _ = sender.send(PluginRequest::RejectCallback { callback_id, error });
555 }
556 }
557}
558
559impl Drop for PluginThreadHandle {
560 fn drop(&mut self) {
561 self.shutdown();
562 }
563}
564
565fn respond_to_pending(
566 pending_responses: &PendingResponses,
567 response: fresh_core::api::PluginResponse,
568) -> bool {
569 let request_id = match &response {
570 fresh_core::api::PluginResponse::VirtualBufferCreated { request_id, .. } => *request_id,
571 fresh_core::api::PluginResponse::LspRequest { request_id, .. } => *request_id,
572 fresh_core::api::PluginResponse::HighlightsComputed { request_id, .. } => *request_id,
573 fresh_core::api::PluginResponse::BufferText { request_id, .. } => *request_id,
574 fresh_core::api::PluginResponse::CompositeBufferCreated { request_id, .. } => *request_id,
575 };
576
577 let sender = {
578 let mut pending = pending_responses.lock().unwrap();
579 pending.remove(&request_id)
580 };
581
582 if let Some(tx) = sender {
583 let _ = tx.send(response);
584 true
585 } else {
586 false
587 }
588}
589
590#[cfg(test)]
591mod plugin_thread_tests {
592 use super::*;
593 use fresh_core::api::PluginResponse;
594 use serde_json::json;
595 use std::collections::HashMap;
596 use std::sync::{Arc, Mutex};
597 use tokio::sync::oneshot;
598
599 #[test]
600 fn respond_to_pending_sends_lsp_response() {
601 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
602 let (tx, mut rx) = oneshot::channel();
603 pending.lock().unwrap().insert(123, tx);
604
605 respond_to_pending(
606 &pending,
607 PluginResponse::LspRequest {
608 request_id: 123,
609 result: Ok(json!({ "key": "value" })),
610 },
611 );
612
613 let response = rx.try_recv().expect("expected response");
614 match response {
615 PluginResponse::LspRequest { result, .. } => {
616 assert_eq!(result.unwrap(), json!({ "key": "value" }));
617 }
618 _ => panic!("unexpected variant"),
619 }
620
621 assert!(pending.lock().unwrap().is_empty());
622 }
623
624 #[test]
625 fn respond_to_pending_handles_virtual_buffer_created() {
626 let pending: PendingResponses = Arc::new(Mutex::new(HashMap::new()));
627 let (tx, mut rx) = oneshot::channel();
628 pending.lock().unwrap().insert(456, tx);
629
630 respond_to_pending(
631 &pending,
632 PluginResponse::VirtualBufferCreated {
633 request_id: 456,
634 buffer_id: fresh_core::BufferId(7),
635 split_id: Some(fresh_core::SplitId(1)),
636 },
637 );
638
639 let response = rx.try_recv().expect("expected response");
640 match response {
641 PluginResponse::VirtualBufferCreated { buffer_id, .. } => {
642 assert_eq!(buffer_id.0, 7);
643 }
644 _ => panic!("unexpected variant"),
645 }
646
647 assert!(pending.lock().unwrap().is_empty());
648 }
649}
650
651async fn plugin_thread_loop(
657 runtime: Rc<RefCell<QuickJsBackend>>,
658 plugins: &mut HashMap<String, TsPluginInfo>,
659 mut request_receiver: tokio::sync::mpsc::UnboundedReceiver<PluginRequest>,
660) {
661 tracing::info!("Plugin thread event loop started");
662
663 let poll_interval = Duration::from_millis(1);
665 let mut has_pending_work = false;
666
667 loop {
668 tokio::select! {
669 biased; request = request_receiver.recv() => {
672 match request {
673 Some(PluginRequest::ExecuteAction {
674 action_name,
675 response,
676 }) => {
677 let result = runtime.borrow_mut().start_action(&action_name);
680 let _ = response.send(result);
681 has_pending_work = true; }
683 Some(request) => {
684 let should_shutdown =
685 handle_request(request, Rc::clone(&runtime), plugins).await;
686
687 if should_shutdown {
688 break;
689 }
690 has_pending_work = true; }
692 None => {
693 tracing::info!("Plugin thread request channel closed");
695 break;
696 }
697 }
698 }
699
700 _ = tokio::time::sleep(poll_interval), if has_pending_work => {
702 has_pending_work = runtime.borrow_mut().poll_event_loop_once();
703 }
704 }
705 }
706}
707
708#[allow(clippy::await_holding_refcell_ref)]
727async fn run_hook_internal_rc(
728 runtime: Rc<RefCell<QuickJsBackend>>,
729 hook_name: &str,
730 args: &HookArgs,
731) -> Result<()> {
732 let json_start = std::time::Instant::now();
735 let json_string = fresh_core::hooks::hook_args_to_json(args)?;
736 let json_data: serde_json::Value = serde_json::from_str(&json_string)?;
737 tracing::trace!(
738 hook = hook_name,
739 json_ms = json_start.elapsed().as_micros(),
740 "hook args serialized"
741 );
742
743 let emit_start = std::time::Instant::now();
745 runtime.borrow_mut().emit(hook_name, &json_data).await?;
746 tracing::trace!(
747 hook = hook_name,
748 emit_ms = emit_start.elapsed().as_millis(),
749 "emit completed"
750 );
751
752 Ok(())
753}
754
755async fn handle_request(
757 request: PluginRequest,
758 runtime: Rc<RefCell<QuickJsBackend>>,
759 plugins: &mut HashMap<String, TsPluginInfo>,
760) -> bool {
761 match request {
762 PluginRequest::LoadPlugin { path, response } => {
763 let result = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await;
764 let _ = response.send(result);
765 }
766
767 PluginRequest::LoadPluginsFromDir { dir, response } => {
768 let errors = load_plugins_from_dir_internal(Rc::clone(&runtime), plugins, &dir).await;
769 let _ = response.send(errors);
770 }
771
772 PluginRequest::LoadPluginsFromDirWithConfig {
773 dir,
774 plugin_configs,
775 response,
776 } => {
777 let (errors, discovered) = load_plugins_from_dir_with_config_internal(
778 Rc::clone(&runtime),
779 plugins,
780 &dir,
781 &plugin_configs,
782 )
783 .await;
784 let _ = response.send((errors, discovered));
785 }
786
787 PluginRequest::UnloadPlugin { name, response } => {
788 let result = unload_plugin_internal(Rc::clone(&runtime), plugins, &name);
789 let _ = response.send(result);
790 }
791
792 PluginRequest::ReloadPlugin { name, response } => {
793 let result = reload_plugin_internal(Rc::clone(&runtime), plugins, &name).await;
794 let _ = response.send(result);
795 }
796
797 PluginRequest::ExecuteAction {
798 action_name,
799 response,
800 } => {
801 tracing::error!(
804 "ExecuteAction should be handled in main loop, not here: {}",
805 action_name
806 );
807 let _ = response.send(Err(anyhow::anyhow!(
808 "Internal error: ExecuteAction in wrong handler"
809 )));
810 }
811
812 PluginRequest::RunHook { hook_name, args } => {
813 let hook_start = std::time::Instant::now();
815 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
817 tracing::info!(hook = %hook_name, ?args, "RunHook request received (prompt hook)");
818 } else {
819 tracing::trace!(hook = %hook_name, "RunHook request received");
820 }
821 if let Err(e) = run_hook_internal_rc(Rc::clone(&runtime), &hook_name, &args).await {
822 let error_msg = format!("Plugin error in '{}': {}", hook_name, e);
823 tracing::error!("{}", error_msg);
824 runtime.borrow_mut().send_status(error_msg);
826 }
827 if hook_name == "prompt_confirmed" || hook_name == "prompt_cancelled" {
828 tracing::info!(
829 hook = %hook_name,
830 elapsed_ms = hook_start.elapsed().as_millis(),
831 "RunHook completed (prompt hook)"
832 );
833 } else {
834 tracing::trace!(
835 hook = %hook_name,
836 elapsed_ms = hook_start.elapsed().as_millis(),
837 "RunHook completed"
838 );
839 }
840 }
841
842 PluginRequest::HasHookHandlers {
843 hook_name,
844 response,
845 } => {
846 let has_handlers = runtime.borrow().has_handlers(&hook_name);
847 let _ = response.send(has_handlers);
848 }
849
850 PluginRequest::ListPlugins { response } => {
851 let plugin_list: Vec<TsPluginInfo> = plugins.values().cloned().collect();
852 let _ = response.send(plugin_list);
853 }
854
855 PluginRequest::ResolveCallback {
856 callback_id,
857 result_json,
858 } => {
859 tracing::info!(
860 "ResolveCallback: resolving callback_id={} with result_json={}",
861 callback_id,
862 result_json
863 );
864 runtime
865 .borrow_mut()
866 .resolve_callback(callback_id, &result_json);
867 tracing::info!(
869 "ResolveCallback: done resolving callback_id={}",
870 callback_id
871 );
872 }
873
874 PluginRequest::RejectCallback { callback_id, error } => {
875 runtime.borrow_mut().reject_callback(callback_id, &error);
876 }
878
879 PluginRequest::Shutdown => {
880 tracing::info!("Plugin thread received shutdown request");
881 return true;
882 }
883 }
884
885 false
886}
887
888#[allow(clippy::await_holding_refcell_ref)]
896async fn load_plugin_internal(
897 runtime: Rc<RefCell<QuickJsBackend>>,
898 plugins: &mut HashMap<String, TsPluginInfo>,
899 path: &Path,
900) -> Result<()> {
901 let plugin_name = path
902 .file_stem()
903 .and_then(|s| s.to_str())
904 .ok_or_else(|| anyhow!("Invalid plugin filename"))?
905 .to_string();
906
907 tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
908 tracing::debug!(
909 "load_plugin_internal: starting module load for plugin '{}'",
910 plugin_name
911 );
912
913 let path_str = path
915 .to_str()
916 .ok_or_else(|| anyhow!("Invalid path encoding"))?;
917
918 let i18n_path = path.with_extension("i18n.json");
920 if i18n_path.exists() {
921 if let Ok(content) = std::fs::read_to_string(&i18n_path) {
922 if let Ok(strings) = serde_json::from_str::<
923 std::collections::HashMap<String, std::collections::HashMap<String, String>>,
924 >(&content)
925 {
926 runtime
927 .borrow_mut()
928 .services
929 .register_plugin_strings(&plugin_name, strings);
930 tracing::debug!("Loaded i18n strings for plugin '{}'", plugin_name);
931 }
932 }
933 }
934
935 let load_start = std::time::Instant::now();
936 runtime
937 .borrow_mut()
938 .load_module_with_source(path_str, &plugin_name)
939 .await?;
940 let load_elapsed = load_start.elapsed();
941
942 tracing::debug!(
943 "load_plugin_internal: plugin '{}' loaded successfully in {:?}",
944 plugin_name,
945 load_elapsed
946 );
947
948 plugins.insert(
950 plugin_name.clone(),
951 TsPluginInfo {
952 name: plugin_name.clone(),
953 path: path.to_path_buf(),
954 enabled: true,
955 },
956 );
957
958 tracing::debug!(
959 "load_plugin_internal: plugin '{}' registered, total plugins loaded: {}",
960 plugin_name,
961 plugins.len()
962 );
963
964 Ok(())
965}
966
967async fn load_plugins_from_dir_internal(
969 runtime: Rc<RefCell<QuickJsBackend>>,
970 plugins: &mut HashMap<String, TsPluginInfo>,
971 dir: &Path,
972) -> Vec<String> {
973 tracing::debug!(
974 "load_plugins_from_dir_internal: scanning directory {:?}",
975 dir
976 );
977 let mut errors = Vec::new();
978
979 if !dir.exists() {
980 tracing::warn!("Plugin directory does not exist: {:?}", dir);
981 return errors;
982 }
983
984 match std::fs::read_dir(dir) {
986 Ok(entries) => {
987 for entry in entries.flatten() {
988 let path = entry.path();
989 let ext = path.extension().and_then(|s| s.to_str());
990 if ext == Some("ts") || ext == Some("js") {
991 tracing::debug!(
992 "load_plugins_from_dir_internal: attempting to load {:?}",
993 path
994 );
995 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await
996 {
997 let err = format!("Failed to load {:?}: {}", path, e);
998 tracing::error!("{}", err);
999 errors.push(err);
1000 }
1001 }
1002 }
1003
1004 tracing::debug!(
1005 "load_plugins_from_dir_internal: finished loading from {:?}, {} errors",
1006 dir,
1007 errors.len()
1008 );
1009 }
1010 Err(e) => {
1011 let err = format!("Failed to read plugin directory: {}", e);
1012 tracing::error!("{}", err);
1013 errors.push(err);
1014 }
1015 }
1016
1017 errors
1018}
1019
1020async fn load_plugins_from_dir_with_config_internal(
1024 runtime: Rc<RefCell<QuickJsBackend>>,
1025 plugins: &mut HashMap<String, TsPluginInfo>,
1026 dir: &Path,
1027 plugin_configs: &HashMap<String, PluginConfig>,
1028) -> (Vec<String>, HashMap<String, PluginConfig>) {
1029 tracing::debug!(
1030 "load_plugins_from_dir_with_config_internal: scanning directory {:?}",
1031 dir
1032 );
1033 let mut errors = Vec::new();
1034 let mut discovered_plugins: HashMap<String, PluginConfig> = HashMap::new();
1035
1036 if !dir.exists() {
1037 tracing::warn!("Plugin directory does not exist: {:?}", dir);
1038 return (errors, discovered_plugins);
1039 }
1040
1041 let mut plugin_files: Vec<(String, std::path::PathBuf)> = Vec::new();
1043 match std::fs::read_dir(dir) {
1044 Ok(entries) => {
1045 for entry in entries.flatten() {
1046 let path = entry.path();
1047 let ext = path.extension().and_then(|s| s.to_str());
1048 if ext == Some("ts") || ext == Some("js") {
1049 if path.to_string_lossy().contains(".i18n.") {
1051 continue;
1052 }
1053 let plugin_name = path
1055 .file_stem()
1056 .and_then(|s| s.to_str())
1057 .unwrap_or("unknown")
1058 .to_string();
1059 plugin_files.push((plugin_name, path));
1060 }
1061 }
1062 }
1063 Err(e) => {
1064 let err = format!("Failed to read plugin directory: {}", e);
1065 tracing::error!("{}", err);
1066 errors.push(err);
1067 return (errors, discovered_plugins);
1068 }
1069 }
1070
1071 for (plugin_name, path) in plugin_files {
1073 let config = if let Some(existing_config) = plugin_configs.get(&plugin_name) {
1075 PluginConfig {
1077 enabled: existing_config.enabled,
1078 path: Some(path.clone()),
1079 }
1080 } else {
1081 PluginConfig::new_with_path(path.clone())
1083 };
1084
1085 discovered_plugins.insert(plugin_name.clone(), config.clone());
1087
1088 if config.enabled {
1090 tracing::debug!(
1091 "load_plugins_from_dir_with_config_internal: loading enabled plugin '{}'",
1092 plugin_name
1093 );
1094 if let Err(e) = load_plugin_internal(Rc::clone(&runtime), plugins, &path).await {
1095 let err = format!("Failed to load {:?}: {}", path, e);
1096 tracing::error!("{}", err);
1097 errors.push(err);
1098 }
1099 } else {
1100 tracing::info!(
1101 "load_plugins_from_dir_with_config_internal: skipping disabled plugin '{}'",
1102 plugin_name
1103 );
1104 }
1105 }
1106
1107 tracing::debug!(
1108 "load_plugins_from_dir_with_config_internal: finished. Discovered {} plugins, {} errors",
1109 discovered_plugins.len(),
1110 errors.len()
1111 );
1112
1113 (errors, discovered_plugins)
1114}
1115
1116fn unload_plugin_internal(
1118 runtime: Rc<RefCell<QuickJsBackend>>,
1119 plugins: &mut HashMap<String, TsPluginInfo>,
1120 name: &str,
1121) -> Result<()> {
1122 if plugins.remove(name).is_some() {
1123 tracing::info!("Unloading TypeScript plugin: {}", name);
1124
1125 runtime
1127 .borrow_mut()
1128 .services
1129 .unregister_plugin_strings(name);
1130
1131 let prefix = format!("{}:", name);
1133 runtime
1134 .borrow()
1135 .services
1136 .unregister_commands_by_prefix(&prefix);
1137
1138 Ok(())
1139 } else {
1140 Err(anyhow!("Plugin '{}' not found", name))
1141 }
1142}
1143
1144async fn reload_plugin_internal(
1146 runtime: Rc<RefCell<QuickJsBackend>>,
1147 plugins: &mut HashMap<String, TsPluginInfo>,
1148 name: &str,
1149) -> Result<()> {
1150 let path = plugins
1151 .get(name)
1152 .ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
1153 .path
1154 .clone();
1155
1156 unload_plugin_internal(Rc::clone(&runtime), plugins, name)?;
1157 load_plugin_internal(runtime, plugins, &path).await?;
1158
1159 Ok(())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165 use fresh_core::hooks::hook_args_to_json;
1166
1167 #[test]
1168 fn test_oneshot_channel() {
1169 let (tx, rx) = oneshot::channel::<i32>();
1170 assert!(tx.send(42).is_ok());
1171 assert_eq!(rx.recv().unwrap(), 42);
1172 }
1173
1174 #[test]
1175 fn test_hook_args_to_json_editor_initialized() {
1176 let args = HookArgs::EditorInitialized;
1177 let json = hook_args_to_json(&args).unwrap();
1178 assert_eq!(json, "{}");
1179 }
1180
1181 #[test]
1182 fn test_hook_args_to_json_prompt_changed() {
1183 let args = HookArgs::PromptChanged {
1184 prompt_type: "search".to_string(),
1185 input: "test".to_string(),
1186 };
1187 let json = hook_args_to_json(&args).unwrap();
1188 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1189 assert_eq!(parsed["prompt_type"], "search");
1190 assert_eq!(parsed["input"], "test");
1191 }
1192}