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