1use anyhow::{anyhow, Result};
90use fresh_core::api::{
91 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
92 JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions, PluginCommand,
93 PluginResponse,
94};
95use fresh_core::command::Command;
96use fresh_core::overlay::OverlayNamespace;
97use fresh_core::text_property::TextPropertyEntry;
98use fresh_core::{BufferId, SplitId};
99use fresh_parser_js::{
100 bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
101 transpile_typescript,
102};
103use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
104use rquickjs::{Context, Function, Object, Runtime, Value};
105use std::cell::RefCell;
106use std::collections::HashMap;
107use std::path::{Path, PathBuf};
108use std::rc::Rc;
109use std::sync::{mpsc, Arc, RwLock};
110
111fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
113 use rquickjs::Type;
114 match val.type_of() {
115 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
116 Type::Bool => val
117 .as_bool()
118 .map(serde_json::Value::Bool)
119 .unwrap_or(serde_json::Value::Null),
120 Type::Int => val
121 .as_int()
122 .map(|n| serde_json::Value::Number(n.into()))
123 .unwrap_or(serde_json::Value::Null),
124 Type::Float => val
125 .as_float()
126 .and_then(serde_json::Number::from_f64)
127 .map(serde_json::Value::Number)
128 .unwrap_or(serde_json::Value::Null),
129 Type::String => val
130 .as_string()
131 .and_then(|s| s.to_string().ok())
132 .map(serde_json::Value::String)
133 .unwrap_or(serde_json::Value::Null),
134 Type::Array => {
135 if let Some(arr) = val.as_array() {
136 let items: Vec<serde_json::Value> = arr
137 .iter()
138 .filter_map(|item| item.ok())
139 .map(|item| js_to_json(ctx, item))
140 .collect();
141 serde_json::Value::Array(items)
142 } else {
143 serde_json::Value::Null
144 }
145 }
146 Type::Object | Type::Constructor | Type::Function => {
147 if let Some(obj) = val.as_object() {
148 let mut map = serde_json::Map::new();
149 for key in obj.keys::<String>().flatten() {
150 if let Ok(v) = obj.get::<_, Value>(&key) {
151 map.insert(key, js_to_json(ctx, v));
152 }
153 }
154 serde_json::Value::Object(map)
155 } else {
156 serde_json::Value::Null
157 }
158 }
159 _ => serde_json::Value::Null,
160 }
161}
162
163fn get_text_properties_at_cursor_typed(
165 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
166 buffer_id: u32,
167) -> fresh_core::api::TextPropertiesAtCursor {
168 use fresh_core::api::TextPropertiesAtCursor;
169
170 let snap = match snapshot.read() {
171 Ok(s) => s,
172 Err(_) => return TextPropertiesAtCursor(Vec::new()),
173 };
174 let buffer_id_typed = BufferId(buffer_id as usize);
175 let cursor_pos = match snap
176 .buffer_cursor_positions
177 .get(&buffer_id_typed)
178 .copied()
179 .or_else(|| {
180 if snap.active_buffer_id == buffer_id_typed {
181 snap.primary_cursor.as_ref().map(|c| c.position)
182 } else {
183 None
184 }
185 }) {
186 Some(pos) => pos,
187 None => return TextPropertiesAtCursor(Vec::new()),
188 };
189
190 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
191 Some(p) => p,
192 None => return TextPropertiesAtCursor(Vec::new()),
193 };
194
195 let result: Vec<_> = properties
197 .iter()
198 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
199 .map(|prop| prop.properties.clone())
200 .collect();
201
202 TextPropertiesAtCursor(result)
203}
204
205fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
207 use rquickjs::Type;
208 match val.type_of() {
209 Type::Null => "null".to_string(),
210 Type::Undefined => "undefined".to_string(),
211 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
212 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
213 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
214 Type::String => val
215 .as_string()
216 .and_then(|s| s.to_string().ok())
217 .unwrap_or_default(),
218 Type::Object | Type::Exception => {
219 if let Some(obj) = val.as_object() {
221 let name: Option<String> = obj.get("name").ok();
223 let message: Option<String> = obj.get("message").ok();
224 let stack: Option<String> = obj.get("stack").ok();
225
226 if message.is_some() || name.is_some() {
227 let name = name.unwrap_or_else(|| "Error".to_string());
229 let message = message.unwrap_or_default();
230 if let Some(stack) = stack {
231 return format!("{}: {}\n{}", name, message, stack);
232 } else {
233 return format!("{}: {}", name, message);
234 }
235 }
236
237 let json = js_to_json(ctx, val.clone());
239 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
240 } else {
241 "[object]".to_string()
242 }
243 }
244 Type::Array => {
245 let json = js_to_json(ctx, val.clone());
246 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
247 }
248 Type::Function | Type::Constructor => "[function]".to_string(),
249 Type::Symbol => "[symbol]".to_string(),
250 Type::BigInt => val
251 .as_big_int()
252 .and_then(|b| b.clone().to_i64().ok())
253 .map(|n| n.to_string())
254 .unwrap_or_else(|| "[bigint]".to_string()),
255 _ => format!("[{}]", val.type_name()),
256 }
257}
258
259fn format_js_error(
261 ctx: &rquickjs::Ctx<'_>,
262 err: rquickjs::Error,
263 source_name: &str,
264) -> anyhow::Error {
265 if err.is_exception() {
267 let exc = ctx.catch();
269 if !exc.is_undefined() && !exc.is_null() {
270 if let Some(exc_obj) = exc.as_object() {
272 let message: String = exc_obj
273 .get::<_, String>("message")
274 .unwrap_or_else(|_| "Unknown error".to_string());
275 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
276 let name: String = exc_obj
277 .get::<_, String>("name")
278 .unwrap_or_else(|_| "Error".to_string());
279
280 if !stack.is_empty() {
281 return anyhow::anyhow!(
282 "JS error in {}: {}: {}\nStack trace:\n{}",
283 source_name,
284 name,
285 message,
286 stack
287 );
288 } else {
289 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
290 }
291 } else {
292 let exc_str: String = exc
294 .as_string()
295 .and_then(|s: &rquickjs::String| s.to_string().ok())
296 .unwrap_or_else(|| format!("{:?}", exc));
297 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
298 }
299 }
300 }
301
302 anyhow::anyhow!("JS error in {}: {}", source_name, err)
304}
305
306fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
309 let error = format_js_error(ctx, err, context);
310 tracing::error!("{}", error);
311
312 if should_panic_on_js_errors() {
314 panic!("JavaScript error in {}: {}", context, error);
315 }
316}
317
318static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
320 std::sync::atomic::AtomicBool::new(false);
321
322pub fn set_panic_on_js_errors(enabled: bool) {
324 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
325}
326
327fn should_panic_on_js_errors() -> bool {
329 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
330}
331
332static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
336
337static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
339
340fn set_fatal_js_error(msg: String) {
342 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
343 if guard.is_none() {
344 *guard = Some(msg);
346 }
347 }
348 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
349}
350
351pub fn has_fatal_js_error() -> bool {
353 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
354}
355
356pub fn take_fatal_js_error() -> Option<String> {
358 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
359 return None;
360 }
361 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
362 guard.take()
363 } else {
364 Some("Fatal JS error (message unavailable)".to_string())
365 }
366}
367
368fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
371 let mut count = 0;
372 loop {
373 let exc: rquickjs::Value = ctx.catch();
375 if exc.is_exception() {
377 let error_msg = if let Some(err) = exc.as_exception() {
378 format!(
379 "{}: {}",
380 err.message().unwrap_or_default(),
381 err.stack().unwrap_or_default()
382 )
383 } else {
384 format!("{:?}", exc)
385 };
386 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
387 if should_panic_on_js_errors() {
388 panic!("Unhandled JS exception during {}: {}", context, error_msg);
389 }
390 }
391
392 if !ctx.execute_pending_job() {
393 break;
394 }
395 count += 1;
396 }
397
398 let exc: rquickjs::Value = ctx.catch();
400 if exc.is_exception() {
401 let error_msg = if let Some(err) = exc.as_exception() {
402 format!(
403 "{}: {}",
404 err.message().unwrap_or_default(),
405 err.stack().unwrap_or_default()
406 )
407 } else {
408 format!("{:?}", exc)
409 };
410 tracing::error!(
411 "Unhandled JS exception after running jobs in {}: {}",
412 context,
413 error_msg
414 );
415 if should_panic_on_js_errors() {
416 panic!(
417 "Unhandled JS exception after running jobs in {}: {}",
418 context, error_msg
419 );
420 }
421 }
422
423 count
424}
425
426fn parse_text_property_entry(
428 ctx: &rquickjs::Ctx<'_>,
429 obj: &Object<'_>,
430) -> Option<TextPropertyEntry> {
431 let text: String = obj.get("text").ok()?;
432 let properties: HashMap<String, serde_json::Value> = obj
433 .get::<_, Object>("properties")
434 .ok()
435 .map(|props_obj| {
436 let mut map = HashMap::new();
437 for key in props_obj.keys::<String>().flatten() {
438 if let Ok(v) = props_obj.get::<_, Value>(&key) {
439 map.insert(key, js_to_json(ctx, v));
440 }
441 }
442 map
443 })
444 .unwrap_or_default();
445 Some(TextPropertyEntry { text, properties })
446}
447
448pub type PendingResponses =
450 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
451
452#[derive(Debug, Clone)]
454pub struct TsPluginInfo {
455 pub name: String,
456 pub path: PathBuf,
457 pub enabled: bool,
458}
459
460#[derive(Debug, Clone)]
462pub struct PluginHandler {
463 pub plugin_name: String,
464 pub handler_name: String,
465}
466
467#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
470#[rquickjs::class]
471pub struct JsEditorApi {
472 #[qjs(skip_trace)]
473 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
474 #[qjs(skip_trace)]
475 command_sender: mpsc::Sender<PluginCommand>,
476 #[qjs(skip_trace)]
477 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
478 #[qjs(skip_trace)]
479 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
480 #[qjs(skip_trace)]
481 next_request_id: Rc<RefCell<u64>>,
482 #[qjs(skip_trace)]
483 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
484 #[qjs(skip_trace)]
485 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
486 pub plugin_name: String,
487}
488
489#[plugin_api_impl]
490#[rquickjs::methods(rename_all = "camelCase")]
491impl JsEditorApi {
492 pub fn get_active_buffer_id(&self) -> u32 {
496 self.state_snapshot
497 .read()
498 .map(|s| s.active_buffer_id.0 as u32)
499 .unwrap_or(0)
500 }
501
502 pub fn get_active_split_id(&self) -> u32 {
504 self.state_snapshot
505 .read()
506 .map(|s| s.active_split_id as u32)
507 .unwrap_or(0)
508 }
509
510 #[plugin_api(ts_return = "BufferInfo[]")]
512 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
513 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
514 s.buffers.values().cloned().collect()
515 } else {
516 Vec::new()
517 };
518 rquickjs_serde::to_value(ctx, &buffers)
519 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
520 }
521
522 pub fn debug(&self, msg: String) {
525 tracing::info!("Plugin.debug: {}", msg);
526 }
527
528 pub fn info(&self, msg: String) {
529 tracing::info!("Plugin: {}", msg);
530 }
531
532 pub fn warn(&self, msg: String) {
533 tracing::warn!("Plugin: {}", msg);
534 }
535
536 pub fn error(&self, msg: String) {
537 tracing::error!("Plugin: {}", msg);
538 }
539
540 pub fn set_status(&self, msg: String) {
543 let _ = self
544 .command_sender
545 .send(PluginCommand::SetStatus { message: msg });
546 }
547
548 pub fn copy_to_clipboard(&self, text: String) {
551 let _ = self
552 .command_sender
553 .send(PluginCommand::SetClipboard { text });
554 }
555
556 pub fn set_clipboard(&self, text: String) {
557 let _ = self
558 .command_sender
559 .send(PluginCommand::SetClipboard { text });
560 }
561
562 pub fn register_command<'js>(
567 &self,
568 _ctx: rquickjs::Ctx<'js>,
569 name: String,
570 description: String,
571 handler_name: String,
572 context: rquickjs::function::Opt<rquickjs::Value<'js>>,
573 ) -> rquickjs::Result<bool> {
574 let plugin_name = self.plugin_name.clone();
576 let context_str: Option<String> = context.0.and_then(|v| {
578 if v.is_null() || v.is_undefined() {
579 None
580 } else {
581 v.as_string().and_then(|s| s.to_string().ok())
582 }
583 });
584
585 tracing::debug!(
586 "registerCommand: plugin='{}', name='{}', handler='{}'",
587 plugin_name,
588 name,
589 handler_name
590 );
591
592 self.registered_actions.borrow_mut().insert(
594 handler_name.clone(),
595 PluginHandler {
596 plugin_name: self.plugin_name.clone(),
597 handler_name: handler_name.clone(),
598 },
599 );
600
601 let command = Command {
603 name: name.clone(),
604 description,
605 action_name: handler_name,
606 plugin_name,
607 custom_contexts: context_str.into_iter().collect(),
608 };
609
610 Ok(self
611 .command_sender
612 .send(PluginCommand::RegisterCommand { command })
613 .is_ok())
614 }
615
616 pub fn unregister_command(&self, name: String) -> bool {
618 self.command_sender
619 .send(PluginCommand::UnregisterCommand { name })
620 .is_ok()
621 }
622
623 pub fn set_context(&self, name: String, active: bool) -> bool {
625 self.command_sender
626 .send(PluginCommand::SetContext { name, active })
627 .is_ok()
628 }
629
630 pub fn execute_action(&self, action_name: String) -> bool {
632 self.command_sender
633 .send(PluginCommand::ExecuteAction { action_name })
634 .is_ok()
635 }
636
637 pub fn t<'js>(
642 &self,
643 _ctx: rquickjs::Ctx<'js>,
644 key: String,
645 args: rquickjs::function::Rest<Value<'js>>,
646 ) -> String {
647 let plugin_name = self.plugin_name.clone();
649 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
651 if let Some(obj) = first_arg.as_object() {
652 let mut map = HashMap::new();
653 for k in obj.keys::<String>().flatten() {
654 if let Ok(v) = obj.get::<_, String>(&k) {
655 map.insert(k, v);
656 }
657 }
658 map
659 } else {
660 HashMap::new()
661 }
662 } else {
663 HashMap::new()
664 };
665 let res = self.services.translate(&plugin_name, &key, &args_map);
666
667 tracing::info!(
668 "Translating: key={}, plugin={}, args={:?} => res='{}'",
669 key,
670 plugin_name,
671 args_map,
672 res
673 );
674 res
675 }
676
677 pub fn get_cursor_position(&self) -> u32 {
681 self.state_snapshot
682 .read()
683 .ok()
684 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
685 .unwrap_or(0)
686 }
687
688 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
690 if let Ok(s) = self.state_snapshot.read() {
691 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
692 if let Some(p) = &b.path {
693 return p.to_string_lossy().to_string();
694 }
695 }
696 }
697 String::new()
698 }
699
700 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
702 if let Ok(s) = self.state_snapshot.read() {
703 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
704 return b.length as u32;
705 }
706 }
707 0
708 }
709
710 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
712 if let Ok(s) = self.state_snapshot.read() {
713 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
714 return b.modified;
715 }
716 }
717 false
718 }
719
720 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
723 self.command_sender
724 .send(PluginCommand::SaveBufferToPath {
725 buffer_id: BufferId(buffer_id as usize),
726 path: std::path::PathBuf::from(path),
727 })
728 .is_ok()
729 }
730
731 #[plugin_api(ts_return = "BufferInfo | null")]
733 pub fn get_buffer_info<'js>(
734 &self,
735 ctx: rquickjs::Ctx<'js>,
736 buffer_id: u32,
737 ) -> rquickjs::Result<Value<'js>> {
738 let info = if let Ok(s) = self.state_snapshot.read() {
739 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
740 } else {
741 None
742 };
743 rquickjs_serde::to_value(ctx, &info)
744 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
745 }
746
747 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
749 let cursor = if let Ok(s) = self.state_snapshot.read() {
750 s.primary_cursor.clone()
751 } else {
752 None
753 };
754 rquickjs_serde::to_value(ctx, &cursor)
755 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
756 }
757
758 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
760 let cursors = if let Ok(s) = self.state_snapshot.read() {
761 s.all_cursors.clone()
762 } else {
763 Vec::new()
764 };
765 rquickjs_serde::to_value(ctx, &cursors)
766 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
767 }
768
769 pub fn get_all_cursor_positions<'js>(
771 &self,
772 ctx: rquickjs::Ctx<'js>,
773 ) -> rquickjs::Result<Value<'js>> {
774 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
775 s.all_cursors.iter().map(|c| c.position as u32).collect()
776 } else {
777 Vec::new()
778 };
779 rquickjs_serde::to_value(ctx, &positions)
780 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
781 }
782
783 #[plugin_api(ts_return = "ViewportInfo | null")]
785 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
786 let viewport = if let Ok(s) = self.state_snapshot.read() {
787 s.viewport.clone()
788 } else {
789 None
790 };
791 rquickjs_serde::to_value(ctx, &viewport)
792 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
793 }
794
795 pub fn get_cursor_line(&self) -> u32 {
797 0
801 }
802
803 #[plugin_api(
806 async_promise,
807 js_name = "getLineStartPosition",
808 ts_return = "number | null"
809 )]
810 #[qjs(rename = "_getLineStartPositionStart")]
811 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
812 let id = {
813 let mut id_ref = self.next_request_id.borrow_mut();
814 let id = *id_ref;
815 *id_ref += 1;
816 self.callback_contexts
818 .borrow_mut()
819 .insert(id, self.plugin_name.clone());
820 id
821 };
822 let _ = self
824 .command_sender
825 .send(PluginCommand::GetLineStartPosition {
826 buffer_id: BufferId(0),
827 line,
828 request_id: id,
829 });
830 id
831 }
832
833 pub fn find_buffer_by_path(&self, path: String) -> u32 {
835 let path_buf = std::path::PathBuf::from(&path);
836 if let Ok(s) = self.state_snapshot.read() {
837 for (id, info) in &s.buffers {
838 if let Some(buf_path) = &info.path {
839 if buf_path == &path_buf {
840 return id.0 as u32;
841 }
842 }
843 }
844 }
845 0
846 }
847
848 #[plugin_api(ts_return = "BufferSavedDiff | null")]
850 pub fn get_buffer_saved_diff<'js>(
851 &self,
852 ctx: rquickjs::Ctx<'js>,
853 buffer_id: u32,
854 ) -> rquickjs::Result<Value<'js>> {
855 let diff = if let Ok(s) = self.state_snapshot.read() {
856 s.buffer_saved_diffs
857 .get(&BufferId(buffer_id as usize))
858 .cloned()
859 } else {
860 None
861 };
862 rquickjs_serde::to_value(ctx, &diff)
863 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
864 }
865
866 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
870 self.command_sender
871 .send(PluginCommand::InsertText {
872 buffer_id: BufferId(buffer_id as usize),
873 position: position as usize,
874 text,
875 })
876 .is_ok()
877 }
878
879 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
881 self.command_sender
882 .send(PluginCommand::DeleteRange {
883 buffer_id: BufferId(buffer_id as usize),
884 range: (start as usize)..(end as usize),
885 })
886 .is_ok()
887 }
888
889 pub fn insert_at_cursor(&self, text: String) -> bool {
891 self.command_sender
892 .send(PluginCommand::InsertAtCursor { text })
893 .is_ok()
894 }
895
896 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
900 self.command_sender
901 .send(PluginCommand::OpenFileAtLocation {
902 path: PathBuf::from(path),
903 line: line.map(|l| l as usize),
904 column: column.map(|c| c as usize),
905 })
906 .is_ok()
907 }
908
909 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
911 self.command_sender
912 .send(PluginCommand::OpenFileInSplit {
913 split_id: split_id as usize,
914 path: PathBuf::from(path),
915 line: Some(line as usize),
916 column: Some(column as usize),
917 })
918 .is_ok()
919 }
920
921 pub fn show_buffer(&self, buffer_id: u32) -> bool {
923 self.command_sender
924 .send(PluginCommand::ShowBuffer {
925 buffer_id: BufferId(buffer_id as usize),
926 })
927 .is_ok()
928 }
929
930 pub fn close_buffer(&self, buffer_id: u32) -> bool {
932 self.command_sender
933 .send(PluginCommand::CloseBuffer {
934 buffer_id: BufferId(buffer_id as usize),
935 })
936 .is_ok()
937 }
938
939 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
943 self.event_handlers
944 .borrow_mut()
945 .entry(event_name)
946 .or_default()
947 .push(PluginHandler {
948 plugin_name: self.plugin_name.clone(),
949 handler_name,
950 });
951 }
952
953 pub fn off(&self, event_name: String, handler_name: String) {
955 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
956 list.retain(|h| h.handler_name != handler_name);
957 }
958 }
959
960 pub fn get_env(&self, name: String) -> Option<String> {
964 std::env::var(&name).ok()
965 }
966
967 pub fn get_cwd(&self) -> String {
969 self.state_snapshot
970 .read()
971 .map(|s| s.working_dir.to_string_lossy().to_string())
972 .unwrap_or_else(|_| ".".to_string())
973 }
974
975 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
980 let mut result_parts: Vec<String> = Vec::new();
981 let mut has_leading_slash = false;
982
983 for part in &parts.0 {
984 let normalized = part.replace('\\', "/");
986
987 let is_absolute = normalized.starts_with('/')
989 || (normalized.len() >= 2
990 && normalized
991 .chars()
992 .next()
993 .map(|c| c.is_ascii_alphabetic())
994 .unwrap_or(false)
995 && normalized.chars().nth(1) == Some(':'));
996
997 if is_absolute {
998 result_parts.clear();
1000 has_leading_slash = normalized.starts_with('/');
1001 }
1002
1003 for segment in normalized.split('/') {
1005 if !segment.is_empty() && segment != "." {
1006 if segment == ".." {
1007 result_parts.pop();
1008 } else {
1009 result_parts.push(segment.to_string());
1010 }
1011 }
1012 }
1013 }
1014
1015 let joined = result_parts.join("/");
1017
1018 if has_leading_slash && !joined.is_empty() {
1020 format!("/{}", joined)
1021 } else {
1022 joined
1023 }
1024 }
1025
1026 pub fn path_dirname(&self, path: String) -> String {
1028 Path::new(&path)
1029 .parent()
1030 .map(|p| p.to_string_lossy().to_string())
1031 .unwrap_or_default()
1032 }
1033
1034 pub fn path_basename(&self, path: String) -> String {
1036 Path::new(&path)
1037 .file_name()
1038 .map(|s| s.to_string_lossy().to_string())
1039 .unwrap_or_default()
1040 }
1041
1042 pub fn path_extname(&self, path: String) -> String {
1044 Path::new(&path)
1045 .extension()
1046 .map(|s| format!(".{}", s.to_string_lossy()))
1047 .unwrap_or_default()
1048 }
1049
1050 pub fn path_is_absolute(&self, path: String) -> bool {
1052 Path::new(&path).is_absolute()
1053 }
1054
1055 pub fn file_exists(&self, path: String) -> bool {
1059 Path::new(&path).exists()
1060 }
1061
1062 pub fn read_file(&self, path: String) -> Option<String> {
1064 std::fs::read_to_string(&path).ok()
1065 }
1066
1067 pub fn write_file(&self, path: String, content: String) -> bool {
1069 std::fs::write(&path, content).is_ok()
1070 }
1071
1072 #[plugin_api(ts_return = "DirEntry[]")]
1074 pub fn read_dir<'js>(
1075 &self,
1076 ctx: rquickjs::Ctx<'js>,
1077 path: String,
1078 ) -> rquickjs::Result<Value<'js>> {
1079 use fresh_core::api::DirEntry;
1080
1081 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1082 Ok(entries) => entries
1083 .filter_map(|e| e.ok())
1084 .map(|entry| {
1085 let file_type = entry.file_type().ok();
1086 DirEntry {
1087 name: entry.file_name().to_string_lossy().to_string(),
1088 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1089 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1090 }
1091 })
1092 .collect(),
1093 Err(e) => {
1094 tracing::warn!("readDir failed for '{}': {}", path, e);
1095 Vec::new()
1096 }
1097 };
1098
1099 rquickjs_serde::to_value(ctx, &entries)
1100 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1101 }
1102
1103 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1107 let config: serde_json::Value = self
1108 .state_snapshot
1109 .read()
1110 .map(|s| s.config.clone())
1111 .unwrap_or_else(|_| serde_json::json!({}));
1112
1113 rquickjs_serde::to_value(ctx, &config)
1114 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1115 }
1116
1117 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1119 let config: serde_json::Value = self
1120 .state_snapshot
1121 .read()
1122 .map(|s| s.user_config.clone())
1123 .unwrap_or_else(|_| serde_json::json!({}));
1124
1125 rquickjs_serde::to_value(ctx, &config)
1126 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1127 }
1128
1129 pub fn reload_config(&self) {
1131 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1132 }
1133
1134 pub fn reload_themes(&self) {
1137 let _ = self.command_sender.send(PluginCommand::ReloadThemes);
1138 }
1139
1140 pub fn register_grammar(
1143 &self,
1144 language: String,
1145 grammar_path: String,
1146 extensions: Vec<String>,
1147 ) -> bool {
1148 self.command_sender
1149 .send(PluginCommand::RegisterGrammar {
1150 language,
1151 grammar_path,
1152 extensions,
1153 })
1154 .is_ok()
1155 }
1156
1157 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1159 self.command_sender
1160 .send(PluginCommand::RegisterLanguageConfig { language, config })
1161 .is_ok()
1162 }
1163
1164 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1166 self.command_sender
1167 .send(PluginCommand::RegisterLspServer { language, config })
1168 .is_ok()
1169 }
1170
1171 pub fn reload_grammars(&self) {
1174 let _ = self.command_sender.send(PluginCommand::ReloadGrammars);
1175 }
1176
1177 pub fn get_config_dir(&self) -> String {
1179 self.services.config_dir().to_string_lossy().to_string()
1180 }
1181
1182 pub fn get_themes_dir(&self) -> String {
1184 self.services
1185 .config_dir()
1186 .join("themes")
1187 .to_string_lossy()
1188 .to_string()
1189 }
1190
1191 pub fn apply_theme(&self, theme_name: String) -> bool {
1193 self.command_sender
1194 .send(PluginCommand::ApplyTheme { theme_name })
1195 .is_ok()
1196 }
1197
1198 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1200 let schema = self.services.get_theme_schema();
1201 rquickjs_serde::to_value(ctx, &schema)
1202 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1203 }
1204
1205 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1207 let themes = self.services.get_builtin_themes();
1208 rquickjs_serde::to_value(ctx, &themes)
1209 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1210 }
1211
1212 #[qjs(rename = "_deleteThemeSync")]
1214 pub fn delete_theme_sync(&self, name: String) -> bool {
1215 let themes_dir = self.services.config_dir().join("themes");
1217 let theme_path = themes_dir.join(format!("{}.json", name));
1218
1219 if let Ok(canonical) = theme_path.canonicalize() {
1221 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1222 if canonical.starts_with(&themes_canonical) {
1223 return std::fs::remove_file(&canonical).is_ok();
1224 }
1225 }
1226 }
1227 false
1228 }
1229
1230 pub fn delete_theme(&self, name: String) -> bool {
1232 self.delete_theme_sync(name)
1233 }
1234
1235 pub fn file_stat<'js>(
1239 &self,
1240 ctx: rquickjs::Ctx<'js>,
1241 path: String,
1242 ) -> rquickjs::Result<Value<'js>> {
1243 let metadata = std::fs::metadata(&path).ok();
1244 let stat = metadata.map(|m| {
1245 serde_json::json!({
1246 "isFile": m.is_file(),
1247 "isDir": m.is_dir(),
1248 "size": m.len(),
1249 "readonly": m.permissions().readonly(),
1250 })
1251 });
1252 rquickjs_serde::to_value(ctx, &stat)
1253 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1254 }
1255
1256 pub fn is_process_running(&self, _process_id: u64) -> bool {
1260 false
1263 }
1264
1265 pub fn kill_process(&self, process_id: u64) -> bool {
1267 self.command_sender
1268 .send(PluginCommand::KillBackgroundProcess { process_id })
1269 .is_ok()
1270 }
1271
1272 pub fn plugin_translate<'js>(
1276 &self,
1277 _ctx: rquickjs::Ctx<'js>,
1278 plugin_name: String,
1279 key: String,
1280 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1281 ) -> String {
1282 let args_map: HashMap<String, String> = args
1283 .0
1284 .map(|obj| {
1285 let mut map = HashMap::new();
1286 for (k, v) in obj.props::<String, String>().flatten() {
1287 map.insert(k, v);
1288 }
1289 map
1290 })
1291 .unwrap_or_default();
1292
1293 self.services.translate(&plugin_name, &key, &args_map)
1294 }
1295
1296 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1303 #[qjs(rename = "_createCompositeBufferStart")]
1304 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1305 let id = {
1306 let mut id_ref = self.next_request_id.borrow_mut();
1307 let id = *id_ref;
1308 *id_ref += 1;
1309 self.callback_contexts
1311 .borrow_mut()
1312 .insert(id, self.plugin_name.clone());
1313 id
1314 };
1315
1316 let _ = self
1317 .command_sender
1318 .send(PluginCommand::CreateCompositeBuffer {
1319 name: opts.name,
1320 mode: opts.mode,
1321 layout: opts.layout,
1322 sources: opts.sources,
1323 hunks: opts.hunks,
1324 request_id: Some(id),
1325 });
1326
1327 id
1328 }
1329
1330 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1334 self.command_sender
1335 .send(PluginCommand::UpdateCompositeAlignment {
1336 buffer_id: BufferId(buffer_id as usize),
1337 hunks,
1338 })
1339 .is_ok()
1340 }
1341
1342 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1344 self.command_sender
1345 .send(PluginCommand::CloseCompositeBuffer {
1346 buffer_id: BufferId(buffer_id as usize),
1347 })
1348 .is_ok()
1349 }
1350
1351 #[plugin_api(
1355 async_promise,
1356 js_name = "getHighlights",
1357 ts_return = "TsHighlightSpan[]"
1358 )]
1359 #[qjs(rename = "_getHighlightsStart")]
1360 pub fn get_highlights_start<'js>(
1361 &self,
1362 _ctx: rquickjs::Ctx<'js>,
1363 buffer_id: u32,
1364 start: u32,
1365 end: u32,
1366 ) -> rquickjs::Result<u64> {
1367 let id = {
1368 let mut id_ref = self.next_request_id.borrow_mut();
1369 let id = *id_ref;
1370 *id_ref += 1;
1371 self.callback_contexts
1373 .borrow_mut()
1374 .insert(id, self.plugin_name.clone());
1375 id
1376 };
1377
1378 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1379 buffer_id: BufferId(buffer_id as usize),
1380 range: (start as usize)..(end as usize),
1381 request_id: id,
1382 });
1383
1384 Ok(id)
1385 }
1386
1387 pub fn add_overlay<'js>(
1405 &self,
1406 _ctx: rquickjs::Ctx<'js>,
1407 buffer_id: u32,
1408 namespace: String,
1409 start: u32,
1410 end: u32,
1411 options: rquickjs::Object<'js>,
1412 ) -> rquickjs::Result<bool> {
1413 use fresh_core::api::OverlayColorSpec;
1414
1415 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1417 if let Ok(theme_key) = obj.get::<_, String>(key) {
1419 if !theme_key.is_empty() {
1420 return Some(OverlayColorSpec::ThemeKey(theme_key));
1421 }
1422 }
1423 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1425 if arr.len() >= 3 {
1426 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1427 }
1428 }
1429 None
1430 }
1431
1432 let fg = parse_color_spec("fg", &options);
1433 let bg = parse_color_spec("bg", &options);
1434 let underline: bool = options.get("underline").unwrap_or(false);
1435 let bold: bool = options.get("bold").unwrap_or(false);
1436 let italic: bool = options.get("italic").unwrap_or(false);
1437 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1438
1439 let options = OverlayOptions {
1440 fg,
1441 bg,
1442 underline,
1443 bold,
1444 italic,
1445 extend_to_line_end,
1446 };
1447
1448 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1449 buffer_id: BufferId(buffer_id as usize),
1450 namespace: Some(OverlayNamespace::from_string(namespace)),
1451 range: (start as usize)..(end as usize),
1452 options,
1453 });
1454
1455 Ok(true)
1456 }
1457
1458 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1460 self.command_sender
1461 .send(PluginCommand::ClearNamespace {
1462 buffer_id: BufferId(buffer_id as usize),
1463 namespace: OverlayNamespace::from_string(namespace),
1464 })
1465 .is_ok()
1466 }
1467
1468 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1470 self.command_sender
1471 .send(PluginCommand::ClearAllOverlays {
1472 buffer_id: BufferId(buffer_id as usize),
1473 })
1474 .is_ok()
1475 }
1476
1477 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1479 self.command_sender
1480 .send(PluginCommand::ClearOverlaysInRange {
1481 buffer_id: BufferId(buffer_id as usize),
1482 start: start as usize,
1483 end: end as usize,
1484 })
1485 .is_ok()
1486 }
1487
1488 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1490 use fresh_core::overlay::OverlayHandle;
1491 self.command_sender
1492 .send(PluginCommand::RemoveOverlay {
1493 buffer_id: BufferId(buffer_id as usize),
1494 handle: OverlayHandle(handle),
1495 })
1496 .is_ok()
1497 }
1498
1499 #[allow(clippy::too_many_arguments)]
1506 pub fn submit_view_transform<'js>(
1507 &self,
1508 _ctx: rquickjs::Ctx<'js>,
1509 buffer_id: u32,
1510 split_id: Option<u32>,
1511 start: u32,
1512 end: u32,
1513 tokens: Vec<rquickjs::Object<'js>>,
1514 _layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1515 ) -> rquickjs::Result<bool> {
1516 use fresh_core::api::{
1517 ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewTransformPayload,
1518 };
1519
1520 let tokens: Vec<ViewTokenWire> = tokens
1521 .into_iter()
1522 .map(|obj| {
1523 let kind_str: String = obj.get("kind").unwrap_or_default();
1524 let text: String = obj.get("text").unwrap_or_default();
1525 let source_offset: Option<usize> = obj.get("sourceOffset").ok();
1526
1527 let kind = match kind_str.as_str() {
1528 "text" => ViewTokenWireKind::Text(text),
1529 "newline" => ViewTokenWireKind::Newline,
1530 "space" => ViewTokenWireKind::Space,
1531 "break" => ViewTokenWireKind::Break,
1532 _ => ViewTokenWireKind::Text(text),
1533 };
1534
1535 let style = obj.get::<_, rquickjs::Object>("style").ok().map(|s| {
1536 let fg: Option<Vec<u8>> = s.get("fg").ok();
1537 let bg: Option<Vec<u8>> = s.get("bg").ok();
1538 ViewTokenStyle {
1539 fg: fg.and_then(|c| {
1540 if c.len() >= 3 {
1541 Some((c[0], c[1], c[2]))
1542 } else {
1543 None
1544 }
1545 }),
1546 bg: bg.and_then(|c| {
1547 if c.len() >= 3 {
1548 Some((c[0], c[1], c[2]))
1549 } else {
1550 None
1551 }
1552 }),
1553 bold: s.get("bold").unwrap_or(false),
1554 italic: s.get("italic").unwrap_or(false),
1555 }
1556 });
1557
1558 ViewTokenWire {
1559 source_offset,
1560 kind,
1561 style,
1562 }
1563 })
1564 .collect();
1565
1566 let payload = ViewTransformPayload {
1567 range: (start as usize)..(end as usize),
1568 tokens,
1569 layout_hints: None,
1570 };
1571
1572 Ok(self
1573 .command_sender
1574 .send(PluginCommand::SubmitViewTransform {
1575 buffer_id: BufferId(buffer_id as usize),
1576 split_id: split_id.map(|id| SplitId(id as usize)),
1577 payload,
1578 })
1579 .is_ok())
1580 }
1581
1582 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1584 self.command_sender
1585 .send(PluginCommand::ClearViewTransform {
1586 buffer_id: BufferId(buffer_id as usize),
1587 split_id: split_id.map(|id| SplitId(id as usize)),
1588 })
1589 .is_ok()
1590 }
1591
1592 pub fn set_file_explorer_decorations<'js>(
1596 &self,
1597 _ctx: rquickjs::Ctx<'js>,
1598 namespace: String,
1599 decorations: Vec<rquickjs::Object<'js>>,
1600 ) -> rquickjs::Result<bool> {
1601 use fresh_core::file_explorer::FileExplorerDecoration;
1602
1603 let decorations: Vec<FileExplorerDecoration> = decorations
1604 .into_iter()
1605 .map(|obj| {
1606 let path: String = obj.get("path")?;
1607 let symbol: String = obj.get("symbol")?;
1608 let color: Vec<u8> = obj.get("color")?;
1609 let priority: i32 = obj.get("priority").unwrap_or(0);
1610
1611 if color.len() < 3 {
1612 return Err(rquickjs::Error::FromJs {
1613 from: "array",
1614 to: "color",
1615 message: Some(format!(
1616 "color array must have at least 3 elements, got {}",
1617 color.len()
1618 )),
1619 });
1620 }
1621
1622 Ok(FileExplorerDecoration {
1623 path: std::path::PathBuf::from(path),
1624 symbol,
1625 color: [color[0], color[1], color[2]],
1626 priority,
1627 })
1628 })
1629 .collect::<rquickjs::Result<Vec<_>>>()?;
1630
1631 Ok(self
1632 .command_sender
1633 .send(PluginCommand::SetFileExplorerDecorations {
1634 namespace,
1635 decorations,
1636 })
1637 .is_ok())
1638 }
1639
1640 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1642 self.command_sender
1643 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1644 .is_ok()
1645 }
1646
1647 #[allow(clippy::too_many_arguments)]
1651 pub fn add_virtual_text(
1652 &self,
1653 buffer_id: u32,
1654 virtual_text_id: String,
1655 position: u32,
1656 text: String,
1657 r: u8,
1658 g: u8,
1659 b: u8,
1660 before: bool,
1661 use_bg: bool,
1662 ) -> bool {
1663 self.command_sender
1664 .send(PluginCommand::AddVirtualText {
1665 buffer_id: BufferId(buffer_id as usize),
1666 virtual_text_id,
1667 position: position as usize,
1668 text,
1669 color: (r, g, b),
1670 use_bg,
1671 before,
1672 })
1673 .is_ok()
1674 }
1675
1676 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1678 self.command_sender
1679 .send(PluginCommand::RemoveVirtualText {
1680 buffer_id: BufferId(buffer_id as usize),
1681 virtual_text_id,
1682 })
1683 .is_ok()
1684 }
1685
1686 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1688 self.command_sender
1689 .send(PluginCommand::RemoveVirtualTextsByPrefix {
1690 buffer_id: BufferId(buffer_id as usize),
1691 prefix,
1692 })
1693 .is_ok()
1694 }
1695
1696 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1698 self.command_sender
1699 .send(PluginCommand::ClearVirtualTexts {
1700 buffer_id: BufferId(buffer_id as usize),
1701 })
1702 .is_ok()
1703 }
1704
1705 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1707 self.command_sender
1708 .send(PluginCommand::ClearVirtualTextNamespace {
1709 buffer_id: BufferId(buffer_id as usize),
1710 namespace,
1711 })
1712 .is_ok()
1713 }
1714
1715 #[allow(clippy::too_many_arguments)]
1717 pub fn add_virtual_line(
1718 &self,
1719 buffer_id: u32,
1720 position: u32,
1721 text: String,
1722 fg_r: u8,
1723 fg_g: u8,
1724 fg_b: u8,
1725 bg_r: u8,
1726 bg_g: u8,
1727 bg_b: u8,
1728 above: bool,
1729 namespace: String,
1730 priority: i32,
1731 ) -> bool {
1732 self.command_sender
1733 .send(PluginCommand::AddVirtualLine {
1734 buffer_id: BufferId(buffer_id as usize),
1735 position: position as usize,
1736 text,
1737 fg_color: (fg_r, fg_g, fg_b),
1738 bg_color: Some((bg_r, bg_g, bg_b)),
1739 above,
1740 namespace,
1741 priority,
1742 })
1743 .is_ok()
1744 }
1745
1746 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1751 #[qjs(rename = "_promptStart")]
1752 pub fn prompt_start(
1753 &self,
1754 _ctx: rquickjs::Ctx<'_>,
1755 label: String,
1756 initial_value: String,
1757 ) -> u64 {
1758 let id = {
1759 let mut id_ref = self.next_request_id.borrow_mut();
1760 let id = *id_ref;
1761 *id_ref += 1;
1762 self.callback_contexts
1764 .borrow_mut()
1765 .insert(id, self.plugin_name.clone());
1766 id
1767 };
1768
1769 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1770 label,
1771 initial_value,
1772 callback_id: JsCallbackId::new(id),
1773 });
1774
1775 id
1776 }
1777
1778 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1780 self.command_sender
1781 .send(PluginCommand::StartPrompt { label, prompt_type })
1782 .is_ok()
1783 }
1784
1785 pub fn start_prompt_with_initial(
1787 &self,
1788 label: String,
1789 prompt_type: String,
1790 initial_value: String,
1791 ) -> bool {
1792 self.command_sender
1793 .send(PluginCommand::StartPromptWithInitial {
1794 label,
1795 prompt_type,
1796 initial_value,
1797 })
1798 .is_ok()
1799 }
1800
1801 pub fn set_prompt_suggestions(
1805 &self,
1806 suggestions: Vec<fresh_core::command::Suggestion>,
1807 ) -> bool {
1808 self.command_sender
1809 .send(PluginCommand::SetPromptSuggestions { suggestions })
1810 .is_ok()
1811 }
1812
1813 pub fn define_mode(
1817 &self,
1818 name: String,
1819 parent: Option<String>,
1820 bindings_arr: Vec<Vec<String>>,
1821 read_only: rquickjs::function::Opt<bool>,
1822 ) -> bool {
1823 let bindings: Vec<(String, String)> = bindings_arr
1824 .into_iter()
1825 .filter_map(|arr| {
1826 if arr.len() >= 2 {
1827 Some((arr[0].clone(), arr[1].clone()))
1828 } else {
1829 None
1830 }
1831 })
1832 .collect();
1833
1834 {
1837 let mut registered = self.registered_actions.borrow_mut();
1838 for (_, cmd_name) in &bindings {
1839 registered.insert(
1840 cmd_name.clone(),
1841 PluginHandler {
1842 plugin_name: self.plugin_name.clone(),
1843 handler_name: cmd_name.clone(),
1844 },
1845 );
1846 }
1847 }
1848
1849 self.command_sender
1850 .send(PluginCommand::DefineMode {
1851 name,
1852 parent,
1853 bindings,
1854 read_only: read_only.0.unwrap_or(false),
1855 })
1856 .is_ok()
1857 }
1858
1859 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1861 self.command_sender
1862 .send(PluginCommand::SetEditorMode { mode })
1863 .is_ok()
1864 }
1865
1866 pub fn get_editor_mode(&self) -> Option<String> {
1868 self.state_snapshot
1869 .read()
1870 .ok()
1871 .and_then(|s| s.editor_mode.clone())
1872 }
1873
1874 pub fn close_split(&self, split_id: u32) -> bool {
1878 self.command_sender
1879 .send(PluginCommand::CloseSplit {
1880 split_id: SplitId(split_id as usize),
1881 })
1882 .is_ok()
1883 }
1884
1885 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1887 self.command_sender
1888 .send(PluginCommand::SetSplitBuffer {
1889 split_id: SplitId(split_id as usize),
1890 buffer_id: BufferId(buffer_id as usize),
1891 })
1892 .is_ok()
1893 }
1894
1895 pub fn focus_split(&self, split_id: u32) -> bool {
1897 self.command_sender
1898 .send(PluginCommand::FocusSplit {
1899 split_id: SplitId(split_id as usize),
1900 })
1901 .is_ok()
1902 }
1903
1904 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1906 self.command_sender
1907 .send(PluginCommand::SetSplitScroll {
1908 split_id: SplitId(split_id as usize),
1909 top_byte: top_byte as usize,
1910 })
1911 .is_ok()
1912 }
1913
1914 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1916 self.command_sender
1917 .send(PluginCommand::SetSplitRatio {
1918 split_id: SplitId(split_id as usize),
1919 ratio,
1920 })
1921 .is_ok()
1922 }
1923
1924 pub fn distribute_splits_evenly(&self) -> bool {
1926 self.command_sender
1928 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1929 .is_ok()
1930 }
1931
1932 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1934 self.command_sender
1935 .send(PluginCommand::SetBufferCursor {
1936 buffer_id: BufferId(buffer_id as usize),
1937 position: position as usize,
1938 })
1939 .is_ok()
1940 }
1941
1942 #[allow(clippy::too_many_arguments)]
1946 pub fn set_line_indicator(
1947 &self,
1948 buffer_id: u32,
1949 line: u32,
1950 namespace: String,
1951 symbol: String,
1952 r: u8,
1953 g: u8,
1954 b: u8,
1955 priority: i32,
1956 ) -> bool {
1957 self.command_sender
1958 .send(PluginCommand::SetLineIndicator {
1959 buffer_id: BufferId(buffer_id as usize),
1960 line: line as usize,
1961 namespace,
1962 symbol,
1963 color: (r, g, b),
1964 priority,
1965 })
1966 .is_ok()
1967 }
1968
1969 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
1971 self.command_sender
1972 .send(PluginCommand::ClearLineIndicators {
1973 buffer_id: BufferId(buffer_id as usize),
1974 namespace,
1975 })
1976 .is_ok()
1977 }
1978
1979 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
1981 self.command_sender
1982 .send(PluginCommand::SetLineNumbers {
1983 buffer_id: BufferId(buffer_id as usize),
1984 enabled,
1985 })
1986 .is_ok()
1987 }
1988
1989 pub fn create_scroll_sync_group(
1993 &self,
1994 group_id: u32,
1995 left_split: u32,
1996 right_split: u32,
1997 ) -> bool {
1998 self.command_sender
1999 .send(PluginCommand::CreateScrollSyncGroup {
2000 group_id,
2001 left_split: SplitId(left_split as usize),
2002 right_split: SplitId(right_split as usize),
2003 })
2004 .is_ok()
2005 }
2006
2007 pub fn set_scroll_sync_anchors<'js>(
2009 &self,
2010 _ctx: rquickjs::Ctx<'js>,
2011 group_id: u32,
2012 anchors: Vec<Vec<u32>>,
2013 ) -> bool {
2014 let anchors: Vec<(usize, usize)> = anchors
2015 .into_iter()
2016 .filter_map(|pair| {
2017 if pair.len() >= 2 {
2018 Some((pair[0] as usize, pair[1] as usize))
2019 } else {
2020 None
2021 }
2022 })
2023 .collect();
2024 self.command_sender
2025 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2026 .is_ok()
2027 }
2028
2029 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2031 self.command_sender
2032 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2033 .is_ok()
2034 }
2035
2036 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2042 self.command_sender
2043 .send(PluginCommand::ExecuteActions { actions })
2044 .is_ok()
2045 }
2046
2047 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2051 self.command_sender
2052 .send(PluginCommand::ShowActionPopup {
2053 popup_id: opts.id,
2054 title: opts.title,
2055 message: opts.message,
2056 actions: opts.actions,
2057 })
2058 .is_ok()
2059 }
2060
2061 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2063 self.command_sender
2064 .send(PluginCommand::DisableLspForLanguage { language })
2065 .is_ok()
2066 }
2067
2068 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2071 self.command_sender
2072 .send(PluginCommand::SetLspRootUri { language, uri })
2073 .is_ok()
2074 }
2075
2076 #[plugin_api(ts_return = "JsDiagnostic[]")]
2078 pub fn get_all_diagnostics<'js>(
2079 &self,
2080 ctx: rquickjs::Ctx<'js>,
2081 ) -> rquickjs::Result<Value<'js>> {
2082 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2083
2084 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2085 let mut result: Vec<JsDiagnostic> = Vec::new();
2087 for (uri, diags) in &s.diagnostics {
2088 for diag in diags {
2089 result.push(JsDiagnostic {
2090 uri: uri.clone(),
2091 message: diag.message.clone(),
2092 severity: diag.severity.map(|s| match s {
2093 lsp_types::DiagnosticSeverity::ERROR => 1,
2094 lsp_types::DiagnosticSeverity::WARNING => 2,
2095 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2096 lsp_types::DiagnosticSeverity::HINT => 4,
2097 _ => 0,
2098 }),
2099 range: JsRange {
2100 start: JsPosition {
2101 line: diag.range.start.line,
2102 character: diag.range.start.character,
2103 },
2104 end: JsPosition {
2105 line: diag.range.end.line,
2106 character: diag.range.end.character,
2107 },
2108 },
2109 source: diag.source.clone(),
2110 });
2111 }
2112 }
2113 result
2114 } else {
2115 Vec::new()
2116 };
2117 rquickjs_serde::to_value(ctx, &diagnostics)
2118 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2119 }
2120
2121 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2123 self.event_handlers
2124 .borrow()
2125 .get(&event_name)
2126 .cloned()
2127 .unwrap_or_default()
2128 .into_iter()
2129 .map(|h| h.handler_name)
2130 .collect()
2131 }
2132
2133 #[plugin_api(
2137 async_promise,
2138 js_name = "createVirtualBuffer",
2139 ts_return = "VirtualBufferResult"
2140 )]
2141 #[qjs(rename = "_createVirtualBufferStart")]
2142 pub fn create_virtual_buffer_start(
2143 &self,
2144 _ctx: rquickjs::Ctx<'_>,
2145 opts: fresh_core::api::CreateVirtualBufferOptions,
2146 ) -> rquickjs::Result<u64> {
2147 let id = {
2148 let mut id_ref = self.next_request_id.borrow_mut();
2149 let id = *id_ref;
2150 *id_ref += 1;
2151 self.callback_contexts
2153 .borrow_mut()
2154 .insert(id, self.plugin_name.clone());
2155 id
2156 };
2157
2158 let entries: Vec<TextPropertyEntry> = opts
2160 .entries
2161 .unwrap_or_default()
2162 .into_iter()
2163 .map(|e| TextPropertyEntry {
2164 text: e.text,
2165 properties: e.properties.unwrap_or_default(),
2166 })
2167 .collect();
2168
2169 tracing::debug!(
2170 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2171 id
2172 );
2173 let _ = self
2174 .command_sender
2175 .send(PluginCommand::CreateVirtualBufferWithContent {
2176 name: opts.name,
2177 mode: opts.mode.unwrap_or_default(),
2178 read_only: opts.read_only.unwrap_or(false),
2179 entries,
2180 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2181 show_cursors: opts.show_cursors.unwrap_or(true),
2182 editing_disabled: opts.editing_disabled.unwrap_or(false),
2183 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2184 request_id: Some(id),
2185 });
2186 Ok(id)
2187 }
2188
2189 #[plugin_api(
2191 async_promise,
2192 js_name = "createVirtualBufferInSplit",
2193 ts_return = "VirtualBufferResult"
2194 )]
2195 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2196 pub fn create_virtual_buffer_in_split_start(
2197 &self,
2198 _ctx: rquickjs::Ctx<'_>,
2199 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2200 ) -> rquickjs::Result<u64> {
2201 let id = {
2202 let mut id_ref = self.next_request_id.borrow_mut();
2203 let id = *id_ref;
2204 *id_ref += 1;
2205 self.callback_contexts
2207 .borrow_mut()
2208 .insert(id, self.plugin_name.clone());
2209 id
2210 };
2211
2212 let entries: Vec<TextPropertyEntry> = opts
2214 .entries
2215 .unwrap_or_default()
2216 .into_iter()
2217 .map(|e| TextPropertyEntry {
2218 text: e.text,
2219 properties: e.properties.unwrap_or_default(),
2220 })
2221 .collect();
2222
2223 let _ = self
2224 .command_sender
2225 .send(PluginCommand::CreateVirtualBufferInSplit {
2226 name: opts.name,
2227 mode: opts.mode.unwrap_or_default(),
2228 read_only: opts.read_only.unwrap_or(false),
2229 entries,
2230 ratio: opts.ratio.unwrap_or(0.5),
2231 direction: opts.direction,
2232 panel_id: opts.panel_id,
2233 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2234 show_cursors: opts.show_cursors.unwrap_or(true),
2235 editing_disabled: opts.editing_disabled.unwrap_or(false),
2236 line_wrap: opts.line_wrap,
2237 request_id: Some(id),
2238 });
2239 Ok(id)
2240 }
2241
2242 #[plugin_api(
2244 async_promise,
2245 js_name = "createVirtualBufferInExistingSplit",
2246 ts_return = "VirtualBufferResult"
2247 )]
2248 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2249 pub fn create_virtual_buffer_in_existing_split_start(
2250 &self,
2251 _ctx: rquickjs::Ctx<'_>,
2252 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2253 ) -> rquickjs::Result<u64> {
2254 let id = {
2255 let mut id_ref = self.next_request_id.borrow_mut();
2256 let id = *id_ref;
2257 *id_ref += 1;
2258 self.callback_contexts
2260 .borrow_mut()
2261 .insert(id, self.plugin_name.clone());
2262 id
2263 };
2264
2265 let entries: Vec<TextPropertyEntry> = opts
2267 .entries
2268 .unwrap_or_default()
2269 .into_iter()
2270 .map(|e| TextPropertyEntry {
2271 text: e.text,
2272 properties: e.properties.unwrap_or_default(),
2273 })
2274 .collect();
2275
2276 let _ = self
2277 .command_sender
2278 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2279 name: opts.name,
2280 mode: opts.mode.unwrap_or_default(),
2281 read_only: opts.read_only.unwrap_or(false),
2282 entries,
2283 split_id: SplitId(opts.split_id),
2284 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2285 show_cursors: opts.show_cursors.unwrap_or(true),
2286 editing_disabled: opts.editing_disabled.unwrap_or(false),
2287 line_wrap: opts.line_wrap,
2288 request_id: Some(id),
2289 });
2290 Ok(id)
2291 }
2292
2293 pub fn set_virtual_buffer_content<'js>(
2297 &self,
2298 ctx: rquickjs::Ctx<'js>,
2299 buffer_id: u32,
2300 entries_arr: Vec<rquickjs::Object<'js>>,
2301 ) -> rquickjs::Result<bool> {
2302 let entries: Vec<TextPropertyEntry> = entries_arr
2303 .iter()
2304 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2305 .collect();
2306 Ok(self
2307 .command_sender
2308 .send(PluginCommand::SetVirtualBufferContent {
2309 buffer_id: BufferId(buffer_id as usize),
2310 entries,
2311 })
2312 .is_ok())
2313 }
2314
2315 pub fn get_text_properties_at_cursor(
2317 &self,
2318 buffer_id: u32,
2319 ) -> fresh_core::api::TextPropertiesAtCursor {
2320 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2321 }
2322
2323 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2327 #[qjs(rename = "_spawnProcessStart")]
2328 pub fn spawn_process_start(
2329 &self,
2330 _ctx: rquickjs::Ctx<'_>,
2331 command: String,
2332 args: Vec<String>,
2333 cwd: rquickjs::function::Opt<String>,
2334 ) -> u64 {
2335 let id = {
2336 let mut id_ref = self.next_request_id.borrow_mut();
2337 let id = *id_ref;
2338 *id_ref += 1;
2339 self.callback_contexts
2341 .borrow_mut()
2342 .insert(id, self.plugin_name.clone());
2343 id
2344 };
2345 let effective_cwd = cwd.0.or_else(|| {
2347 self.state_snapshot
2348 .read()
2349 .ok()
2350 .map(|s| s.working_dir.to_string_lossy().to_string())
2351 });
2352 tracing::info!(
2353 "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2354 command,
2355 args,
2356 effective_cwd,
2357 id
2358 );
2359 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2360 callback_id: JsCallbackId::new(id),
2361 command,
2362 args,
2363 cwd: effective_cwd,
2364 });
2365 id
2366 }
2367
2368 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2370 #[qjs(rename = "_spawnProcessWaitStart")]
2371 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2372 let id = {
2373 let mut id_ref = self.next_request_id.borrow_mut();
2374 let id = *id_ref;
2375 *id_ref += 1;
2376 self.callback_contexts
2378 .borrow_mut()
2379 .insert(id, self.plugin_name.clone());
2380 id
2381 };
2382 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2383 process_id,
2384 callback_id: JsCallbackId::new(id),
2385 });
2386 id
2387 }
2388
2389 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2391 #[qjs(rename = "_getBufferTextStart")]
2392 pub fn get_buffer_text_start(
2393 &self,
2394 _ctx: rquickjs::Ctx<'_>,
2395 buffer_id: u32,
2396 start: u32,
2397 end: u32,
2398 ) -> u64 {
2399 let id = {
2400 let mut id_ref = self.next_request_id.borrow_mut();
2401 let id = *id_ref;
2402 *id_ref += 1;
2403 self.callback_contexts
2405 .borrow_mut()
2406 .insert(id, self.plugin_name.clone());
2407 id
2408 };
2409 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2410 buffer_id: BufferId(buffer_id as usize),
2411 start: start as usize,
2412 end: end as usize,
2413 request_id: id,
2414 });
2415 id
2416 }
2417
2418 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2420 #[qjs(rename = "_delayStart")]
2421 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2422 let id = {
2423 let mut id_ref = self.next_request_id.borrow_mut();
2424 let id = *id_ref;
2425 *id_ref += 1;
2426 self.callback_contexts
2428 .borrow_mut()
2429 .insert(id, self.plugin_name.clone());
2430 id
2431 };
2432 let _ = self.command_sender.send(PluginCommand::Delay {
2433 callback_id: JsCallbackId::new(id),
2434 duration_ms,
2435 });
2436 id
2437 }
2438
2439 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2441 #[qjs(rename = "_sendLspRequestStart")]
2442 pub fn send_lsp_request_start<'js>(
2443 &self,
2444 ctx: rquickjs::Ctx<'js>,
2445 language: String,
2446 method: String,
2447 params: Option<rquickjs::Object<'js>>,
2448 ) -> rquickjs::Result<u64> {
2449 let id = {
2450 let mut id_ref = self.next_request_id.borrow_mut();
2451 let id = *id_ref;
2452 *id_ref += 1;
2453 self.callback_contexts
2455 .borrow_mut()
2456 .insert(id, self.plugin_name.clone());
2457 id
2458 };
2459 let params_json: Option<serde_json::Value> = params.map(|obj| {
2461 let val = obj.into_value();
2462 js_to_json(&ctx, val)
2463 });
2464 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2465 request_id: id,
2466 language,
2467 method,
2468 params: params_json,
2469 });
2470 Ok(id)
2471 }
2472
2473 #[plugin_api(
2475 async_thenable,
2476 js_name = "spawnBackgroundProcess",
2477 ts_return = "BackgroundProcessResult"
2478 )]
2479 #[qjs(rename = "_spawnBackgroundProcessStart")]
2480 pub fn spawn_background_process_start(
2481 &self,
2482 _ctx: rquickjs::Ctx<'_>,
2483 command: String,
2484 args: Vec<String>,
2485 cwd: rquickjs::function::Opt<String>,
2486 ) -> u64 {
2487 let id = {
2488 let mut id_ref = self.next_request_id.borrow_mut();
2489 let id = *id_ref;
2490 *id_ref += 1;
2491 self.callback_contexts
2493 .borrow_mut()
2494 .insert(id, self.plugin_name.clone());
2495 id
2496 };
2497 let process_id = id;
2499 let _ = self
2500 .command_sender
2501 .send(PluginCommand::SpawnBackgroundProcess {
2502 process_id,
2503 command,
2504 args,
2505 cwd: cwd.0,
2506 callback_id: JsCallbackId::new(id),
2507 });
2508 id
2509 }
2510
2511 pub fn kill_background_process(&self, process_id: u64) -> bool {
2513 self.command_sender
2514 .send(PluginCommand::KillBackgroundProcess { process_id })
2515 .is_ok()
2516 }
2517
2518 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2522 self.command_sender
2523 .send(PluginCommand::RefreshLines {
2524 buffer_id: BufferId(buffer_id as usize),
2525 })
2526 .is_ok()
2527 }
2528
2529 pub fn get_current_locale(&self) -> String {
2531 self.services.current_locale()
2532 }
2533
2534 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2538 #[qjs(rename = "_loadPluginStart")]
2539 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2540 let id = {
2541 let mut id_ref = self.next_request_id.borrow_mut();
2542 let id = *id_ref;
2543 *id_ref += 1;
2544 self.callback_contexts
2545 .borrow_mut()
2546 .insert(id, self.plugin_name.clone());
2547 id
2548 };
2549 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2550 path: std::path::PathBuf::from(path),
2551 callback_id: JsCallbackId::new(id),
2552 });
2553 id
2554 }
2555
2556 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2558 #[qjs(rename = "_unloadPluginStart")]
2559 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2560 let id = {
2561 let mut id_ref = self.next_request_id.borrow_mut();
2562 let id = *id_ref;
2563 *id_ref += 1;
2564 self.callback_contexts
2565 .borrow_mut()
2566 .insert(id, self.plugin_name.clone());
2567 id
2568 };
2569 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2570 name,
2571 callback_id: JsCallbackId::new(id),
2572 });
2573 id
2574 }
2575
2576 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
2578 #[qjs(rename = "_reloadPluginStart")]
2579 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2580 let id = {
2581 let mut id_ref = self.next_request_id.borrow_mut();
2582 let id = *id_ref;
2583 *id_ref += 1;
2584 self.callback_contexts
2585 .borrow_mut()
2586 .insert(id, self.plugin_name.clone());
2587 id
2588 };
2589 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
2590 name,
2591 callback_id: JsCallbackId::new(id),
2592 });
2593 id
2594 }
2595
2596 #[plugin_api(
2599 async_promise,
2600 js_name = "listPlugins",
2601 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
2602 )]
2603 #[qjs(rename = "_listPluginsStart")]
2604 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2605 let id = {
2606 let mut id_ref = self.next_request_id.borrow_mut();
2607 let id = *id_ref;
2608 *id_ref += 1;
2609 self.callback_contexts
2610 .borrow_mut()
2611 .insert(id, self.plugin_name.clone());
2612 id
2613 };
2614 let _ = self.command_sender.send(PluginCommand::ListPlugins {
2615 callback_id: JsCallbackId::new(id),
2616 });
2617 id
2618 }
2619}
2620
2621pub struct QuickJsBackend {
2623 runtime: Runtime,
2624 main_context: Context,
2626 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2628 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2630 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2632 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2634 command_sender: mpsc::Sender<PluginCommand>,
2636 #[allow(dead_code)]
2638 pending_responses: PendingResponses,
2639 next_request_id: Rc<RefCell<u64>>,
2641 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2643 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2645}
2646
2647impl QuickJsBackend {
2648 pub fn new() -> Result<Self> {
2650 let (tx, _rx) = mpsc::channel();
2651 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2652 let services = Arc::new(fresh_core::services::NoopServiceBridge);
2653 Self::with_state(state_snapshot, tx, services)
2654 }
2655
2656 pub fn with_state(
2658 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2659 command_sender: mpsc::Sender<PluginCommand>,
2660 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2661 ) -> Result<Self> {
2662 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2663 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2664 }
2665
2666 pub fn with_state_and_responses(
2668 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2669 command_sender: mpsc::Sender<PluginCommand>,
2670 pending_responses: PendingResponses,
2671 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2672 ) -> Result<Self> {
2673 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2674
2675 let runtime =
2676 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2677
2678 runtime.set_host_promise_rejection_tracker(Some(Box::new(
2680 |_ctx, _promise, reason, is_handled| {
2681 if !is_handled {
2682 let error_msg = if let Some(exc) = reason.as_exception() {
2684 format!(
2685 "{}: {}",
2686 exc.message().unwrap_or_default(),
2687 exc.stack().unwrap_or_default()
2688 )
2689 } else {
2690 format!("{:?}", reason)
2691 };
2692
2693 tracing::error!("Unhandled Promise rejection: {}", error_msg);
2694
2695 if should_panic_on_js_errors() {
2696 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2699 set_fatal_js_error(full_msg);
2700 }
2701 }
2702 },
2703 )));
2704
2705 let main_context = Context::full(&runtime)
2706 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2707
2708 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2709 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2710 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2711 let next_request_id = Rc::new(RefCell::new(1u64));
2712 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2713
2714 let backend = Self {
2715 runtime,
2716 main_context,
2717 plugin_contexts,
2718 event_handlers,
2719 registered_actions,
2720 state_snapshot,
2721 command_sender,
2722 pending_responses,
2723 next_request_id,
2724 callback_contexts,
2725 services,
2726 };
2727
2728 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2730
2731 tracing::debug!("QuickJsBackend::new: runtime created successfully");
2732 Ok(backend)
2733 }
2734
2735 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2737 let state_snapshot = Arc::clone(&self.state_snapshot);
2738 let command_sender = self.command_sender.clone();
2739 let event_handlers = Rc::clone(&self.event_handlers);
2740 let registered_actions = Rc::clone(&self.registered_actions);
2741 let next_request_id = Rc::clone(&self.next_request_id);
2742
2743 context.with(|ctx| {
2744 let globals = ctx.globals();
2745
2746 globals.set("__pluginName__", plugin_name)?;
2748
2749 let js_api = JsEditorApi {
2752 state_snapshot: Arc::clone(&state_snapshot),
2753 command_sender: command_sender.clone(),
2754 registered_actions: Rc::clone(®istered_actions),
2755 event_handlers: Rc::clone(&event_handlers),
2756 next_request_id: Rc::clone(&next_request_id),
2757 callback_contexts: Rc::clone(&self.callback_contexts),
2758 services: self.services.clone(),
2759 plugin_name: plugin_name.to_string(),
2760 };
2761 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2762
2763 globals.set("editor", editor)?;
2765
2766 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2768
2769 let console = Object::new(ctx.clone())?;
2772 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2773 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2774 tracing::info!("console.log: {}", parts.join(" "));
2775 })?)?;
2776 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2777 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2778 tracing::warn!("console.warn: {}", parts.join(" "));
2779 })?)?;
2780 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2781 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2782 tracing::error!("console.error: {}", parts.join(" "));
2783 })?)?;
2784 globals.set("console", console)?;
2785
2786 ctx.eval::<(), _>(r#"
2788 // Pending promise callbacks: callbackId -> { resolve, reject }
2789 globalThis._pendingCallbacks = new Map();
2790
2791 // Resolve a pending callback (called from Rust)
2792 globalThis._resolveCallback = function(callbackId, result) {
2793 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2794 const cb = globalThis._pendingCallbacks.get(callbackId);
2795 if (cb) {
2796 console.log('[JS] _resolveCallback: found callback, calling resolve()');
2797 globalThis._pendingCallbacks.delete(callbackId);
2798 cb.resolve(result);
2799 console.log('[JS] _resolveCallback: resolve() called');
2800 } else {
2801 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2802 }
2803 };
2804
2805 // Reject a pending callback (called from Rust)
2806 globalThis._rejectCallback = function(callbackId, error) {
2807 const cb = globalThis._pendingCallbacks.get(callbackId);
2808 if (cb) {
2809 globalThis._pendingCallbacks.delete(callbackId);
2810 cb.reject(new Error(error));
2811 }
2812 };
2813
2814 // Generic async wrapper decorator
2815 // Wraps a function that returns a callbackId into a promise-returning function
2816 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2817 // NOTE: We pass the method name as a string and call via bracket notation
2818 // to preserve rquickjs's automatic Ctx injection for methods
2819 globalThis._wrapAsync = function(methodName, fnName) {
2820 const startFn = editor[methodName];
2821 if (typeof startFn !== 'function') {
2822 // Return a function that always throws - catches missing implementations
2823 return function(...args) {
2824 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2825 editor.debug(`[ASYNC ERROR] ${error.message}`);
2826 throw error;
2827 };
2828 }
2829 return function(...args) {
2830 // Call via bracket notation to preserve method binding and Ctx injection
2831 const callbackId = editor[methodName](...args);
2832 return new Promise((resolve, reject) => {
2833 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2834 // TODO: Implement setTimeout polyfill using editor.delay() or similar
2835 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2836 });
2837 };
2838 };
2839
2840 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2841 // The returned object has .result promise and is itself thenable
2842 globalThis._wrapAsyncThenable = function(methodName, fnName) {
2843 const startFn = editor[methodName];
2844 if (typeof startFn !== 'function') {
2845 // Return a function that always throws - catches missing implementations
2846 return function(...args) {
2847 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2848 editor.debug(`[ASYNC ERROR] ${error.message}`);
2849 throw error;
2850 };
2851 }
2852 return function(...args) {
2853 // Call via bracket notation to preserve method binding and Ctx injection
2854 const callbackId = editor[methodName](...args);
2855 const resultPromise = new Promise((resolve, reject) => {
2856 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2857 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2858 });
2859 return {
2860 get result() { return resultPromise; },
2861 then(onFulfilled, onRejected) {
2862 return resultPromise.then(onFulfilled, onRejected);
2863 },
2864 catch(onRejected) {
2865 return resultPromise.catch(onRejected);
2866 }
2867 };
2868 };
2869 };
2870
2871 // Apply wrappers to async functions on editor
2872 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
2873 editor.delay = _wrapAsync("_delayStart", "delay");
2874 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
2875 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
2876 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
2877 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
2878 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
2879 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
2880 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
2881 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
2882 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
2883 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
2884 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
2885 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
2886 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
2887
2888 // Wrapper for deleteTheme - wraps sync function in Promise
2889 editor.deleteTheme = function(name) {
2890 return new Promise(function(resolve, reject) {
2891 const success = editor._deleteThemeSync(name);
2892 if (success) {
2893 resolve();
2894 } else {
2895 reject(new Error("Failed to delete theme: " + name));
2896 }
2897 });
2898 };
2899 "#.as_bytes())?;
2900
2901 Ok::<_, rquickjs::Error>(())
2902 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
2903
2904 Ok(())
2905 }
2906
2907 pub async fn load_module_with_source(
2909 &mut self,
2910 path: &str,
2911 _plugin_source: &str,
2912 ) -> Result<()> {
2913 let path_buf = PathBuf::from(path);
2914 let source = std::fs::read_to_string(&path_buf)
2915 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
2916
2917 let filename = path_buf
2918 .file_name()
2919 .and_then(|s| s.to_str())
2920 .unwrap_or("plugin.ts");
2921
2922 if has_es_imports(&source) {
2924 match bundle_module(&path_buf) {
2926 Ok(bundled) => {
2927 self.execute_js(&bundled, path)?;
2928 }
2929 Err(e) => {
2930 tracing::warn!(
2931 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
2932 path,
2933 e
2934 );
2935 return Ok(()); }
2937 }
2938 } else if has_es_module_syntax(&source) {
2939 let stripped = strip_imports_and_exports(&source);
2941 let js_code = if filename.ends_with(".ts") {
2942 transpile_typescript(&stripped, filename)?
2943 } else {
2944 stripped
2945 };
2946 self.execute_js(&js_code, path)?;
2947 } else {
2948 let js_code = if filename.ends_with(".ts") {
2950 transpile_typescript(&source, filename)?
2951 } else {
2952 source
2953 };
2954 self.execute_js(&js_code, path)?;
2955 }
2956
2957 Ok(())
2958 }
2959
2960 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
2962 let plugin_name = Path::new(source_name)
2964 .file_stem()
2965 .and_then(|s| s.to_str())
2966 .unwrap_or("unknown");
2967
2968 tracing::debug!(
2969 "execute_js: starting for plugin '{}' from '{}'",
2970 plugin_name,
2971 source_name
2972 );
2973
2974 let context = {
2976 let mut contexts = self.plugin_contexts.borrow_mut();
2977 if let Some(ctx) = contexts.get(plugin_name) {
2978 ctx.clone()
2979 } else {
2980 let ctx = Context::full(&self.runtime).map_err(|e| {
2981 anyhow!(
2982 "Failed to create QuickJS context for plugin {}: {}",
2983 plugin_name,
2984 e
2985 )
2986 })?;
2987 self.setup_context_api(&ctx, plugin_name)?;
2988 contexts.insert(plugin_name.to_string(), ctx.clone());
2989 ctx
2990 }
2991 };
2992
2993 let wrapped_code = format!("(function() {{ {} }})();", code);
2997 let wrapped = wrapped_code.as_str();
2998
2999 context.with(|ctx| {
3000 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3001
3002 let mut eval_options = rquickjs::context::EvalOptions::default();
3004 eval_options.global = true;
3005 eval_options.filename = Some(source_name.to_string());
3006 let result = ctx
3007 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3008 .map_err(|e| format_js_error(&ctx, e, source_name));
3009
3010 tracing::debug!(
3011 "execute_js: plugin code execution finished for '{}', result: {:?}",
3012 plugin_name,
3013 result.is_ok()
3014 );
3015
3016 result
3017 })
3018 }
3019
3020 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3022 let _event_data_str = event_data.to_string();
3023 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3024
3025 self.services
3027 .set_js_execution_state(format!("hook '{}'", event_name));
3028
3029 let handlers = self.event_handlers.borrow().get(event_name).cloned();
3030
3031 if let Some(handler_pairs) = handlers {
3032 if handler_pairs.is_empty() {
3033 self.services.clear_js_execution_state();
3034 return Ok(true);
3035 }
3036
3037 let plugin_contexts = self.plugin_contexts.borrow();
3038 for handler in handler_pairs {
3039 let context_opt = plugin_contexts.get(&handler.plugin_name);
3040 if let Some(context) = context_opt {
3041 let handler_name = &handler.handler_name;
3042 let json_string = serde_json::to_string(event_data)?;
3048 let js_string_literal = serde_json::to_string(&json_string)?;
3049 let code = format!(
3050 r#"
3051 (function() {{
3052 try {{
3053 const data = JSON.parse({});
3054 if (typeof globalThis["{}"] === 'function') {{
3055 const result = globalThis["{}"](data);
3056 // If handler returns a Promise, catch rejections
3057 if (result && typeof result.then === 'function') {{
3058 result.catch(function(e) {{
3059 console.error('Handler {} async error:', e);
3060 // Re-throw to make it an unhandled rejection for the runtime to catch
3061 throw e;
3062 }});
3063 }}
3064 }}
3065 }} catch (e) {{
3066 console.error('Handler {} sync error:', e);
3067 throw e;
3068 }}
3069 }})();
3070 "#,
3071 js_string_literal, handler_name, handler_name, handler_name, handler_name
3072 );
3073
3074 context.with(|ctx| {
3075 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3076 log_js_error(&ctx, e, &format!("handler {}", handler_name));
3077 }
3078 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3080 });
3081 }
3082 }
3083 }
3084
3085 self.services.clear_js_execution_state();
3086 Ok(true)
3087 }
3088
3089 pub fn has_handlers(&self, event_name: &str) -> bool {
3091 self.event_handlers
3092 .borrow()
3093 .get(event_name)
3094 .map(|v| !v.is_empty())
3095 .unwrap_or(false)
3096 }
3097
3098 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3102 let pair = self.registered_actions.borrow().get(action_name).cloned();
3103 let (plugin_name, function_name) = match pair {
3104 Some(handler) => (handler.plugin_name, handler.handler_name),
3105 None => ("main".to_string(), action_name.to_string()),
3106 };
3107
3108 let plugin_contexts = self.plugin_contexts.borrow();
3109 let context = plugin_contexts
3110 .get(&plugin_name)
3111 .unwrap_or(&self.main_context);
3112
3113 self.services
3115 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3116
3117 tracing::info!(
3118 "start_action: BEGIN '{}' -> function '{}'",
3119 action_name,
3120 function_name
3121 );
3122
3123 let code = format!(
3125 r#"
3126 (function() {{
3127 console.log('[JS] start_action: calling {fn}');
3128 try {{
3129 if (typeof globalThis.{fn} === 'function') {{
3130 console.log('[JS] start_action: {fn} is a function, invoking...');
3131 globalThis.{fn}();
3132 console.log('[JS] start_action: {fn} invoked (may be async)');
3133 }} else {{
3134 console.error('[JS] Action {action} is not defined as a global function');
3135 }}
3136 }} catch (e) {{
3137 console.error('[JS] Action {action} error:', e);
3138 }}
3139 }})();
3140 "#,
3141 fn = function_name,
3142 action = action_name
3143 );
3144
3145 tracing::info!("start_action: evaluating JS code");
3146 context.with(|ctx| {
3147 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3148 log_js_error(&ctx, e, &format!("action {}", action_name));
3149 }
3150 tracing::info!("start_action: running pending microtasks");
3151 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3153 tracing::info!("start_action: executed {} pending jobs", count);
3154 });
3155
3156 tracing::info!("start_action: END '{}'", action_name);
3157
3158 self.services.clear_js_execution_state();
3160
3161 Ok(())
3162 }
3163
3164 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3166 let pair = self.registered_actions.borrow().get(action_name).cloned();
3168 let (plugin_name, function_name) = match pair {
3169 Some(handler) => (handler.plugin_name, handler.handler_name),
3170 None => ("main".to_string(), action_name.to_string()),
3171 };
3172
3173 let plugin_contexts = self.plugin_contexts.borrow();
3174 let context = plugin_contexts
3175 .get(&plugin_name)
3176 .unwrap_or(&self.main_context);
3177
3178 tracing::debug!(
3179 "execute_action: '{}' -> function '{}'",
3180 action_name,
3181 function_name
3182 );
3183
3184 let code = format!(
3187 r#"
3188 (async function() {{
3189 try {{
3190 if (typeof globalThis.{fn} === 'function') {{
3191 const result = globalThis.{fn}();
3192 // If it's a Promise, await it
3193 if (result && typeof result.then === 'function') {{
3194 await result;
3195 }}
3196 }} else {{
3197 console.error('Action {action} is not defined as a global function');
3198 }}
3199 }} catch (e) {{
3200 console.error('Action {action} error:', e);
3201 }}
3202 }})();
3203 "#,
3204 fn = function_name,
3205 action = action_name
3206 );
3207
3208 context.with(|ctx| {
3209 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3211 Ok(value) => {
3212 if value.is_object() {
3214 if let Some(obj) = value.as_object() {
3215 if obj.get::<_, rquickjs::Function>("then").is_ok() {
3217 run_pending_jobs_checked(
3220 &ctx,
3221 &format!("execute_action {} promise", action_name),
3222 );
3223 }
3224 }
3225 }
3226 }
3227 Err(e) => {
3228 log_js_error(&ctx, e, &format!("action {}", action_name));
3229 }
3230 }
3231 });
3232
3233 Ok(())
3234 }
3235
3236 pub fn poll_event_loop_once(&mut self) -> bool {
3238 let mut had_work = false;
3239
3240 self.main_context.with(|ctx| {
3242 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3243 if count > 0 {
3244 had_work = true;
3245 }
3246 });
3247
3248 let contexts = self.plugin_contexts.borrow().clone();
3250 for (name, context) in contexts {
3251 context.with(|ctx| {
3252 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3253 if count > 0 {
3254 had_work = true;
3255 }
3256 });
3257 }
3258 had_work
3259 }
3260
3261 pub fn send_status(&self, message: String) {
3263 let _ = self
3264 .command_sender
3265 .send(PluginCommand::SetStatus { message });
3266 }
3267
3268 pub fn resolve_callback(
3273 &mut self,
3274 callback_id: fresh_core::api::JsCallbackId,
3275 result_json: &str,
3276 ) {
3277 let id = callback_id.as_u64();
3278 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3279
3280 let plugin_name = {
3282 let mut contexts = self.callback_contexts.borrow_mut();
3283 contexts.remove(&id)
3284 };
3285
3286 let Some(name) = plugin_name else {
3287 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3288 return;
3289 };
3290
3291 let plugin_contexts = self.plugin_contexts.borrow();
3292 let Some(context) = plugin_contexts.get(&name) else {
3293 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3294 return;
3295 };
3296
3297 context.with(|ctx| {
3298 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3300 Ok(v) => v,
3301 Err(e) => {
3302 tracing::error!(
3303 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3304 id,
3305 e
3306 );
3307 return;
3308 }
3309 };
3310
3311 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3313 Ok(v) => v,
3314 Err(e) => {
3315 tracing::error!(
3316 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3317 id,
3318 e
3319 );
3320 return;
3321 }
3322 };
3323
3324 let globals = ctx.globals();
3326 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3327 Ok(f) => f,
3328 Err(e) => {
3329 tracing::error!(
3330 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3331 id,
3332 e
3333 );
3334 return;
3335 }
3336 };
3337
3338 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3340 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3341 }
3342
3343 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3345 tracing::info!(
3346 "resolve_callback: executed {} pending jobs for callback_id={}",
3347 job_count,
3348 id
3349 );
3350 });
3351 }
3352
3353 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3355 let id = callback_id.as_u64();
3356
3357 let plugin_name = {
3359 let mut contexts = self.callback_contexts.borrow_mut();
3360 contexts.remove(&id)
3361 };
3362
3363 let Some(name) = plugin_name else {
3364 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3365 return;
3366 };
3367
3368 let plugin_contexts = self.plugin_contexts.borrow();
3369 let Some(context) = plugin_contexts.get(&name) else {
3370 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3371 return;
3372 };
3373
3374 context.with(|ctx| {
3375 let globals = ctx.globals();
3377 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3378 Ok(f) => f,
3379 Err(e) => {
3380 tracing::error!(
3381 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3382 id,
3383 e
3384 );
3385 return;
3386 }
3387 };
3388
3389 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3391 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3392 }
3393
3394 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3396 });
3397 }
3398}
3399
3400#[cfg(test)]
3401mod tests {
3402 use super::*;
3403 use fresh_core::api::{BufferInfo, CursorInfo};
3404 use std::sync::mpsc;
3405
3406 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3408 let (tx, rx) = mpsc::channel();
3409 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3410 let services = Arc::new(TestServiceBridge::new());
3411 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3412 (backend, rx)
3413 }
3414
3415 struct TestServiceBridge {
3416 en_strings: std::sync::Mutex<HashMap<String, String>>,
3417 }
3418
3419 impl TestServiceBridge {
3420 fn new() -> Self {
3421 Self {
3422 en_strings: std::sync::Mutex::new(HashMap::new()),
3423 }
3424 }
3425 }
3426
3427 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3428 fn as_any(&self) -> &dyn std::any::Any {
3429 self
3430 }
3431 fn translate(
3432 &self,
3433 _plugin_name: &str,
3434 key: &str,
3435 _args: &HashMap<String, String>,
3436 ) -> String {
3437 self.en_strings
3438 .lock()
3439 .unwrap()
3440 .get(key)
3441 .cloned()
3442 .unwrap_or_else(|| key.to_string())
3443 }
3444 fn current_locale(&self) -> String {
3445 "en".to_string()
3446 }
3447 fn set_js_execution_state(&self, _state: String) {}
3448 fn clear_js_execution_state(&self) {}
3449 fn get_theme_schema(&self) -> serde_json::Value {
3450 serde_json::json!({})
3451 }
3452 fn get_builtin_themes(&self) -> serde_json::Value {
3453 serde_json::json!([])
3454 }
3455 fn register_command(&self, _command: fresh_core::command::Command) {}
3456 fn unregister_command(&self, _name: &str) {}
3457 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3458 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
3459 fn plugins_dir(&self) -> std::path::PathBuf {
3460 std::path::PathBuf::from("/tmp/plugins")
3461 }
3462 fn config_dir(&self) -> std::path::PathBuf {
3463 std::path::PathBuf::from("/tmp/config")
3464 }
3465 }
3466
3467 #[test]
3468 fn test_quickjs_backend_creation() {
3469 let backend = QuickJsBackend::new();
3470 assert!(backend.is_ok());
3471 }
3472
3473 #[test]
3474 fn test_execute_simple_js() {
3475 let mut backend = QuickJsBackend::new().unwrap();
3476 let result = backend.execute_js("const x = 1 + 2;", "test.js");
3477 assert!(result.is_ok());
3478 }
3479
3480 #[test]
3481 fn test_event_handler_registration() {
3482 let backend = QuickJsBackend::new().unwrap();
3483
3484 assert!(!backend.has_handlers("test_event"));
3486
3487 backend
3489 .event_handlers
3490 .borrow_mut()
3491 .entry("test_event".to_string())
3492 .or_default()
3493 .push(PluginHandler {
3494 plugin_name: "test".to_string(),
3495 handler_name: "testHandler".to_string(),
3496 });
3497
3498 assert!(backend.has_handlers("test_event"));
3500 }
3501
3502 #[test]
3505 fn test_api_set_status() {
3506 let (mut backend, rx) = create_test_backend();
3507
3508 backend
3509 .execute_js(
3510 r#"
3511 const editor = getEditor();
3512 editor.setStatus("Hello from test");
3513 "#,
3514 "test.js",
3515 )
3516 .unwrap();
3517
3518 let cmd = rx.try_recv().unwrap();
3519 match cmd {
3520 PluginCommand::SetStatus { message } => {
3521 assert_eq!(message, "Hello from test");
3522 }
3523 _ => panic!("Expected SetStatus command, got {:?}", cmd),
3524 }
3525 }
3526
3527 #[test]
3528 fn test_api_register_command() {
3529 let (mut backend, rx) = create_test_backend();
3530
3531 backend
3532 .execute_js(
3533 r#"
3534 const editor = getEditor();
3535 globalThis.myTestHandler = function() { };
3536 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3537 "#,
3538 "test_plugin.js",
3539 )
3540 .unwrap();
3541
3542 let cmd = rx.try_recv().unwrap();
3543 match cmd {
3544 PluginCommand::RegisterCommand { command } => {
3545 assert_eq!(command.name, "Test Command");
3546 assert_eq!(command.description, "A test command");
3547 assert_eq!(command.plugin_name, "test_plugin");
3549 }
3550 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3551 }
3552 }
3553
3554 #[test]
3555 fn test_api_define_mode() {
3556 let (mut backend, rx) = create_test_backend();
3557
3558 backend
3559 .execute_js(
3560 r#"
3561 const editor = getEditor();
3562 editor.defineMode("test-mode", null, [
3563 ["a", "action_a"],
3564 ["b", "action_b"]
3565 ]);
3566 "#,
3567 "test.js",
3568 )
3569 .unwrap();
3570
3571 let cmd = rx.try_recv().unwrap();
3572 match cmd {
3573 PluginCommand::DefineMode {
3574 name,
3575 parent,
3576 bindings,
3577 read_only,
3578 } => {
3579 assert_eq!(name, "test-mode");
3580 assert!(parent.is_none());
3581 assert_eq!(bindings.len(), 2);
3582 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3583 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3584 assert!(!read_only);
3585 }
3586 _ => panic!("Expected DefineMode, got {:?}", cmd),
3587 }
3588 }
3589
3590 #[test]
3591 fn test_api_set_editor_mode() {
3592 let (mut backend, rx) = create_test_backend();
3593
3594 backend
3595 .execute_js(
3596 r#"
3597 const editor = getEditor();
3598 editor.setEditorMode("vi-normal");
3599 "#,
3600 "test.js",
3601 )
3602 .unwrap();
3603
3604 let cmd = rx.try_recv().unwrap();
3605 match cmd {
3606 PluginCommand::SetEditorMode { mode } => {
3607 assert_eq!(mode, Some("vi-normal".to_string()));
3608 }
3609 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3610 }
3611 }
3612
3613 #[test]
3614 fn test_api_clear_editor_mode() {
3615 let (mut backend, rx) = create_test_backend();
3616
3617 backend
3618 .execute_js(
3619 r#"
3620 const editor = getEditor();
3621 editor.setEditorMode(null);
3622 "#,
3623 "test.js",
3624 )
3625 .unwrap();
3626
3627 let cmd = rx.try_recv().unwrap();
3628 match cmd {
3629 PluginCommand::SetEditorMode { mode } => {
3630 assert!(mode.is_none());
3631 }
3632 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3633 }
3634 }
3635
3636 #[test]
3637 fn test_api_insert_at_cursor() {
3638 let (mut backend, rx) = create_test_backend();
3639
3640 backend
3641 .execute_js(
3642 r#"
3643 const editor = getEditor();
3644 editor.insertAtCursor("Hello, World!");
3645 "#,
3646 "test.js",
3647 )
3648 .unwrap();
3649
3650 let cmd = rx.try_recv().unwrap();
3651 match cmd {
3652 PluginCommand::InsertAtCursor { text } => {
3653 assert_eq!(text, "Hello, World!");
3654 }
3655 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3656 }
3657 }
3658
3659 #[test]
3660 fn test_api_set_context() {
3661 let (mut backend, rx) = create_test_backend();
3662
3663 backend
3664 .execute_js(
3665 r#"
3666 const editor = getEditor();
3667 editor.setContext("myContext", true);
3668 "#,
3669 "test.js",
3670 )
3671 .unwrap();
3672
3673 let cmd = rx.try_recv().unwrap();
3674 match cmd {
3675 PluginCommand::SetContext { name, active } => {
3676 assert_eq!(name, "myContext");
3677 assert!(active);
3678 }
3679 _ => panic!("Expected SetContext, got {:?}", cmd),
3680 }
3681 }
3682
3683 #[tokio::test]
3684 async fn test_execute_action_sync_function() {
3685 let (mut backend, rx) = create_test_backend();
3686
3687 backend.registered_actions.borrow_mut().insert(
3689 "my_sync_action".to_string(),
3690 PluginHandler {
3691 plugin_name: "test".to_string(),
3692 handler_name: "my_sync_action".to_string(),
3693 },
3694 );
3695
3696 backend
3698 .execute_js(
3699 r#"
3700 const editor = getEditor();
3701 globalThis.my_sync_action = function() {
3702 editor.setStatus("sync action executed");
3703 };
3704 "#,
3705 "test.js",
3706 )
3707 .unwrap();
3708
3709 while rx.try_recv().is_ok() {}
3711
3712 backend.execute_action("my_sync_action").await.unwrap();
3714
3715 let cmd = rx.try_recv().unwrap();
3717 match cmd {
3718 PluginCommand::SetStatus { message } => {
3719 assert_eq!(message, "sync action executed");
3720 }
3721 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3722 }
3723 }
3724
3725 #[tokio::test]
3726 async fn test_execute_action_async_function() {
3727 let (mut backend, rx) = create_test_backend();
3728
3729 backend.registered_actions.borrow_mut().insert(
3731 "my_async_action".to_string(),
3732 PluginHandler {
3733 plugin_name: "test".to_string(),
3734 handler_name: "my_async_action".to_string(),
3735 },
3736 );
3737
3738 backend
3740 .execute_js(
3741 r#"
3742 const editor = getEditor();
3743 globalThis.my_async_action = async function() {
3744 await Promise.resolve();
3745 editor.setStatus("async action executed");
3746 };
3747 "#,
3748 "test.js",
3749 )
3750 .unwrap();
3751
3752 while rx.try_recv().is_ok() {}
3754
3755 backend.execute_action("my_async_action").await.unwrap();
3757
3758 let cmd = rx.try_recv().unwrap();
3760 match cmd {
3761 PluginCommand::SetStatus { message } => {
3762 assert_eq!(message, "async action executed");
3763 }
3764 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3765 }
3766 }
3767
3768 #[tokio::test]
3769 async fn test_execute_action_with_registered_handler() {
3770 let (mut backend, rx) = create_test_backend();
3771
3772 backend.registered_actions.borrow_mut().insert(
3774 "my_action".to_string(),
3775 PluginHandler {
3776 plugin_name: "test".to_string(),
3777 handler_name: "actual_handler_function".to_string(),
3778 },
3779 );
3780
3781 backend
3782 .execute_js(
3783 r#"
3784 const editor = getEditor();
3785 globalThis.actual_handler_function = function() {
3786 editor.setStatus("handler executed");
3787 };
3788 "#,
3789 "test.js",
3790 )
3791 .unwrap();
3792
3793 while rx.try_recv().is_ok() {}
3795
3796 backend.execute_action("my_action").await.unwrap();
3798
3799 let cmd = rx.try_recv().unwrap();
3800 match cmd {
3801 PluginCommand::SetStatus { message } => {
3802 assert_eq!(message, "handler executed");
3803 }
3804 _ => panic!("Expected SetStatus, got {:?}", cmd),
3805 }
3806 }
3807
3808 #[test]
3809 fn test_api_on_event_registration() {
3810 let (mut backend, _rx) = create_test_backend();
3811
3812 backend
3813 .execute_js(
3814 r#"
3815 const editor = getEditor();
3816 globalThis.myEventHandler = function() { };
3817 editor.on("bufferSave", "myEventHandler");
3818 "#,
3819 "test.js",
3820 )
3821 .unwrap();
3822
3823 assert!(backend.has_handlers("bufferSave"));
3824 }
3825
3826 #[test]
3827 fn test_api_off_event_unregistration() {
3828 let (mut backend, _rx) = create_test_backend();
3829
3830 backend
3831 .execute_js(
3832 r#"
3833 const editor = getEditor();
3834 globalThis.myEventHandler = function() { };
3835 editor.on("bufferSave", "myEventHandler");
3836 editor.off("bufferSave", "myEventHandler");
3837 "#,
3838 "test.js",
3839 )
3840 .unwrap();
3841
3842 assert!(!backend.has_handlers("bufferSave"));
3844 }
3845
3846 #[tokio::test]
3847 async fn test_emit_event() {
3848 let (mut backend, rx) = create_test_backend();
3849
3850 backend
3851 .execute_js(
3852 r#"
3853 const editor = getEditor();
3854 globalThis.onSaveHandler = function(data) {
3855 editor.setStatus("saved: " + JSON.stringify(data));
3856 };
3857 editor.on("bufferSave", "onSaveHandler");
3858 "#,
3859 "test.js",
3860 )
3861 .unwrap();
3862
3863 while rx.try_recv().is_ok() {}
3865
3866 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
3868 backend.emit("bufferSave", &event_data).await.unwrap();
3869
3870 let cmd = rx.try_recv().unwrap();
3871 match cmd {
3872 PluginCommand::SetStatus { message } => {
3873 assert!(message.contains("/test.txt"));
3874 }
3875 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
3876 }
3877 }
3878
3879 #[test]
3880 fn test_api_copy_to_clipboard() {
3881 let (mut backend, rx) = create_test_backend();
3882
3883 backend
3884 .execute_js(
3885 r#"
3886 const editor = getEditor();
3887 editor.copyToClipboard("clipboard text");
3888 "#,
3889 "test.js",
3890 )
3891 .unwrap();
3892
3893 let cmd = rx.try_recv().unwrap();
3894 match cmd {
3895 PluginCommand::SetClipboard { text } => {
3896 assert_eq!(text, "clipboard text");
3897 }
3898 _ => panic!("Expected SetClipboard, got {:?}", cmd),
3899 }
3900 }
3901
3902 #[test]
3903 fn test_api_open_file() {
3904 let (mut backend, rx) = create_test_backend();
3905
3906 backend
3908 .execute_js(
3909 r#"
3910 const editor = getEditor();
3911 editor.openFile("/path/to/file.txt", null, null);
3912 "#,
3913 "test.js",
3914 )
3915 .unwrap();
3916
3917 let cmd = rx.try_recv().unwrap();
3918 match cmd {
3919 PluginCommand::OpenFileAtLocation { path, line, column } => {
3920 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
3921 assert!(line.is_none());
3922 assert!(column.is_none());
3923 }
3924 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
3925 }
3926 }
3927
3928 #[test]
3929 fn test_api_delete_range() {
3930 let (mut backend, rx) = create_test_backend();
3931
3932 backend
3934 .execute_js(
3935 r#"
3936 const editor = getEditor();
3937 editor.deleteRange(0, 10, 20);
3938 "#,
3939 "test.js",
3940 )
3941 .unwrap();
3942
3943 let cmd = rx.try_recv().unwrap();
3944 match cmd {
3945 PluginCommand::DeleteRange { range, .. } => {
3946 assert_eq!(range.start, 10);
3947 assert_eq!(range.end, 20);
3948 }
3949 _ => panic!("Expected DeleteRange, got {:?}", cmd),
3950 }
3951 }
3952
3953 #[test]
3954 fn test_api_insert_text() {
3955 let (mut backend, rx) = create_test_backend();
3956
3957 backend
3959 .execute_js(
3960 r#"
3961 const editor = getEditor();
3962 editor.insertText(0, 5, "inserted");
3963 "#,
3964 "test.js",
3965 )
3966 .unwrap();
3967
3968 let cmd = rx.try_recv().unwrap();
3969 match cmd {
3970 PluginCommand::InsertText { position, text, .. } => {
3971 assert_eq!(position, 5);
3972 assert_eq!(text, "inserted");
3973 }
3974 _ => panic!("Expected InsertText, got {:?}", cmd),
3975 }
3976 }
3977
3978 #[test]
3979 fn test_api_set_buffer_cursor() {
3980 let (mut backend, rx) = create_test_backend();
3981
3982 backend
3984 .execute_js(
3985 r#"
3986 const editor = getEditor();
3987 editor.setBufferCursor(0, 100);
3988 "#,
3989 "test.js",
3990 )
3991 .unwrap();
3992
3993 let cmd = rx.try_recv().unwrap();
3994 match cmd {
3995 PluginCommand::SetBufferCursor { position, .. } => {
3996 assert_eq!(position, 100);
3997 }
3998 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
3999 }
4000 }
4001
4002 #[test]
4003 fn test_api_get_cursor_position_from_state() {
4004 let (tx, _rx) = mpsc::channel();
4005 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4006
4007 {
4009 let mut state = state_snapshot.write().unwrap();
4010 state.primary_cursor = Some(CursorInfo {
4011 position: 42,
4012 selection: None,
4013 });
4014 }
4015
4016 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4017 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4018
4019 backend
4021 .execute_js(
4022 r#"
4023 const editor = getEditor();
4024 const pos = editor.getCursorPosition();
4025 globalThis._testResult = pos;
4026 "#,
4027 "test.js",
4028 )
4029 .unwrap();
4030
4031 backend
4033 .plugin_contexts
4034 .borrow()
4035 .get("test")
4036 .unwrap()
4037 .clone()
4038 .with(|ctx| {
4039 let global = ctx.globals();
4040 let result: u32 = global.get("_testResult").unwrap();
4041 assert_eq!(result, 42);
4042 });
4043 }
4044
4045 #[test]
4046 fn test_api_path_functions() {
4047 let (mut backend, _rx) = create_test_backend();
4048
4049 #[cfg(windows)]
4052 let absolute_path = r#"C:\\foo\\bar"#;
4053 #[cfg(not(windows))]
4054 let absolute_path = "/foo/bar";
4055
4056 let js_code = format!(
4058 r#"
4059 const editor = getEditor();
4060 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4061 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4062 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4063 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4064 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4065 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4066 "#,
4067 absolute_path
4068 );
4069 backend.execute_js(&js_code, "test.js").unwrap();
4070
4071 backend
4072 .plugin_contexts
4073 .borrow()
4074 .get("test")
4075 .unwrap()
4076 .clone()
4077 .with(|ctx| {
4078 let global = ctx.globals();
4079 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4080 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4081 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4082 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4083 assert!(!global.get::<_, bool>("_isRelative").unwrap());
4084 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4085 });
4086 }
4087
4088 #[test]
4089 fn test_typescript_transpilation() {
4090 use fresh_parser_js::transpile_typescript;
4091
4092 let (mut backend, rx) = create_test_backend();
4093
4094 let ts_code = r#"
4096 const editor = getEditor();
4097 function greet(name: string): string {
4098 return "Hello, " + name;
4099 }
4100 editor.setStatus(greet("TypeScript"));
4101 "#;
4102
4103 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4105
4106 backend.execute_js(&js_code, "test.js").unwrap();
4108
4109 let cmd = rx.try_recv().unwrap();
4110 match cmd {
4111 PluginCommand::SetStatus { message } => {
4112 assert_eq!(message, "Hello, TypeScript");
4113 }
4114 _ => panic!("Expected SetStatus, got {:?}", cmd),
4115 }
4116 }
4117
4118 #[test]
4119 fn test_api_get_buffer_text_sends_command() {
4120 let (mut backend, rx) = create_test_backend();
4121
4122 backend
4124 .execute_js(
4125 r#"
4126 const editor = getEditor();
4127 // Store the promise for later
4128 globalThis._textPromise = editor.getBufferText(0, 10, 20);
4129 "#,
4130 "test.js",
4131 )
4132 .unwrap();
4133
4134 let cmd = rx.try_recv().unwrap();
4136 match cmd {
4137 PluginCommand::GetBufferText {
4138 buffer_id,
4139 start,
4140 end,
4141 request_id,
4142 } => {
4143 assert_eq!(buffer_id.0, 0);
4144 assert_eq!(start, 10);
4145 assert_eq!(end, 20);
4146 assert!(request_id > 0); }
4148 _ => panic!("Expected GetBufferText, got {:?}", cmd),
4149 }
4150 }
4151
4152 #[test]
4153 fn test_api_get_buffer_text_resolves_callback() {
4154 let (mut backend, rx) = create_test_backend();
4155
4156 backend
4158 .execute_js(
4159 r#"
4160 const editor = getEditor();
4161 globalThis._resolvedText = null;
4162 editor.getBufferText(0, 0, 100).then(text => {
4163 globalThis._resolvedText = text;
4164 });
4165 "#,
4166 "test.js",
4167 )
4168 .unwrap();
4169
4170 let request_id = match rx.try_recv().unwrap() {
4172 PluginCommand::GetBufferText { request_id, .. } => request_id,
4173 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4174 };
4175
4176 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4178
4179 backend
4181 .plugin_contexts
4182 .borrow()
4183 .get("test")
4184 .unwrap()
4185 .clone()
4186 .with(|ctx| {
4187 run_pending_jobs_checked(&ctx, "test async getText");
4188 });
4189
4190 backend
4192 .plugin_contexts
4193 .borrow()
4194 .get("test")
4195 .unwrap()
4196 .clone()
4197 .with(|ctx| {
4198 let global = ctx.globals();
4199 let result: String = global.get("_resolvedText").unwrap();
4200 assert_eq!(result, "hello world");
4201 });
4202 }
4203
4204 #[test]
4205 fn test_plugin_translation() {
4206 let (mut backend, _rx) = create_test_backend();
4207
4208 backend
4210 .execute_js(
4211 r#"
4212 const editor = getEditor();
4213 globalThis._translated = editor.t("test.key");
4214 "#,
4215 "test.js",
4216 )
4217 .unwrap();
4218
4219 backend
4220 .plugin_contexts
4221 .borrow()
4222 .get("test")
4223 .unwrap()
4224 .clone()
4225 .with(|ctx| {
4226 let global = ctx.globals();
4227 let result: String = global.get("_translated").unwrap();
4229 assert_eq!(result, "test.key");
4230 });
4231 }
4232
4233 #[test]
4234 fn test_plugin_translation_with_registered_strings() {
4235 let (mut backend, _rx) = create_test_backend();
4236
4237 let mut en_strings = std::collections::HashMap::new();
4239 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4240 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4241
4242 let mut strings = std::collections::HashMap::new();
4243 strings.insert("en".to_string(), en_strings);
4244
4245 if let Some(bridge) = backend
4247 .services
4248 .as_any()
4249 .downcast_ref::<TestServiceBridge>()
4250 {
4251 let mut en = bridge.en_strings.lock().unwrap();
4252 en.insert("greeting".to_string(), "Hello, World!".to_string());
4253 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4254 }
4255
4256 backend
4258 .execute_js(
4259 r#"
4260 const editor = getEditor();
4261 globalThis._greeting = editor.t("greeting");
4262 globalThis._prompt = editor.t("prompt.find_file");
4263 globalThis._missing = editor.t("nonexistent.key");
4264 "#,
4265 "test.js",
4266 )
4267 .unwrap();
4268
4269 backend
4270 .plugin_contexts
4271 .borrow()
4272 .get("test")
4273 .unwrap()
4274 .clone()
4275 .with(|ctx| {
4276 let global = ctx.globals();
4277 let greeting: String = global.get("_greeting").unwrap();
4278 assert_eq!(greeting, "Hello, World!");
4279
4280 let prompt: String = global.get("_prompt").unwrap();
4281 assert_eq!(prompt, "Find file: ");
4282
4283 let missing: String = global.get("_missing").unwrap();
4285 assert_eq!(missing, "nonexistent.key");
4286 });
4287 }
4288
4289 #[test]
4292 fn test_api_set_line_indicator() {
4293 let (mut backend, rx) = create_test_backend();
4294
4295 backend
4296 .execute_js(
4297 r#"
4298 const editor = getEditor();
4299 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4300 "#,
4301 "test.js",
4302 )
4303 .unwrap();
4304
4305 let cmd = rx.try_recv().unwrap();
4306 match cmd {
4307 PluginCommand::SetLineIndicator {
4308 buffer_id,
4309 line,
4310 namespace,
4311 symbol,
4312 color,
4313 priority,
4314 } => {
4315 assert_eq!(buffer_id.0, 1);
4316 assert_eq!(line, 5);
4317 assert_eq!(namespace, "test-ns");
4318 assert_eq!(symbol, "●");
4319 assert_eq!(color, (255, 0, 0));
4320 assert_eq!(priority, 10);
4321 }
4322 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4323 }
4324 }
4325
4326 #[test]
4327 fn test_api_clear_line_indicators() {
4328 let (mut backend, rx) = create_test_backend();
4329
4330 backend
4331 .execute_js(
4332 r#"
4333 const editor = getEditor();
4334 editor.clearLineIndicators(1, "test-ns");
4335 "#,
4336 "test.js",
4337 )
4338 .unwrap();
4339
4340 let cmd = rx.try_recv().unwrap();
4341 match cmd {
4342 PluginCommand::ClearLineIndicators {
4343 buffer_id,
4344 namespace,
4345 } => {
4346 assert_eq!(buffer_id.0, 1);
4347 assert_eq!(namespace, "test-ns");
4348 }
4349 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4350 }
4351 }
4352
4353 #[test]
4356 fn test_api_create_virtual_buffer_sends_command() {
4357 let (mut backend, rx) = create_test_backend();
4358
4359 backend
4360 .execute_js(
4361 r#"
4362 const editor = getEditor();
4363 editor.createVirtualBuffer({
4364 name: "*Test Buffer*",
4365 mode: "test-mode",
4366 readOnly: true,
4367 entries: [
4368 { text: "Line 1\n", properties: { type: "header" } },
4369 { text: "Line 2\n", properties: { type: "content" } }
4370 ],
4371 showLineNumbers: false,
4372 showCursors: true,
4373 editingDisabled: true
4374 });
4375 "#,
4376 "test.js",
4377 )
4378 .unwrap();
4379
4380 let cmd = rx.try_recv().unwrap();
4381 match cmd {
4382 PluginCommand::CreateVirtualBufferWithContent {
4383 name,
4384 mode,
4385 read_only,
4386 entries,
4387 show_line_numbers,
4388 show_cursors,
4389 editing_disabled,
4390 ..
4391 } => {
4392 assert_eq!(name, "*Test Buffer*");
4393 assert_eq!(mode, "test-mode");
4394 assert!(read_only);
4395 assert_eq!(entries.len(), 2);
4396 assert_eq!(entries[0].text, "Line 1\n");
4397 assert!(!show_line_numbers);
4398 assert!(show_cursors);
4399 assert!(editing_disabled);
4400 }
4401 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4402 }
4403 }
4404
4405 #[test]
4406 fn test_api_set_virtual_buffer_content() {
4407 let (mut backend, rx) = create_test_backend();
4408
4409 backend
4410 .execute_js(
4411 r#"
4412 const editor = getEditor();
4413 editor.setVirtualBufferContent(5, [
4414 { text: "New content\n", properties: { type: "updated" } }
4415 ]);
4416 "#,
4417 "test.js",
4418 )
4419 .unwrap();
4420
4421 let cmd = rx.try_recv().unwrap();
4422 match cmd {
4423 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4424 assert_eq!(buffer_id.0, 5);
4425 assert_eq!(entries.len(), 1);
4426 assert_eq!(entries[0].text, "New content\n");
4427 }
4428 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4429 }
4430 }
4431
4432 #[test]
4435 fn test_api_add_overlay() {
4436 let (mut backend, rx) = create_test_backend();
4437
4438 backend
4439 .execute_js(
4440 r#"
4441 const editor = getEditor();
4442 editor.addOverlay(1, "highlight", 10, 20, {
4443 fg: [255, 128, 0],
4444 bg: [50, 50, 50],
4445 bold: true,
4446 });
4447 "#,
4448 "test.js",
4449 )
4450 .unwrap();
4451
4452 let cmd = rx.try_recv().unwrap();
4453 match cmd {
4454 PluginCommand::AddOverlay {
4455 buffer_id,
4456 namespace,
4457 range,
4458 options,
4459 } => {
4460 use fresh_core::api::OverlayColorSpec;
4461 assert_eq!(buffer_id.0, 1);
4462 assert!(namespace.is_some());
4463 assert_eq!(namespace.unwrap().as_str(), "highlight");
4464 assert_eq!(range, 10..20);
4465 assert!(matches!(
4466 options.fg,
4467 Some(OverlayColorSpec::Rgb(255, 128, 0))
4468 ));
4469 assert!(matches!(
4470 options.bg,
4471 Some(OverlayColorSpec::Rgb(50, 50, 50))
4472 ));
4473 assert!(!options.underline);
4474 assert!(options.bold);
4475 assert!(!options.italic);
4476 assert!(!options.extend_to_line_end);
4477 }
4478 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4479 }
4480 }
4481
4482 #[test]
4483 fn test_api_add_overlay_with_theme_keys() {
4484 let (mut backend, rx) = create_test_backend();
4485
4486 backend
4487 .execute_js(
4488 r#"
4489 const editor = getEditor();
4490 // Test with theme keys for colors
4491 editor.addOverlay(1, "themed", 0, 10, {
4492 fg: "ui.status_bar_fg",
4493 bg: "editor.selection_bg",
4494 });
4495 "#,
4496 "test.js",
4497 )
4498 .unwrap();
4499
4500 let cmd = rx.try_recv().unwrap();
4501 match cmd {
4502 PluginCommand::AddOverlay {
4503 buffer_id,
4504 namespace,
4505 range,
4506 options,
4507 } => {
4508 use fresh_core::api::OverlayColorSpec;
4509 assert_eq!(buffer_id.0, 1);
4510 assert!(namespace.is_some());
4511 assert_eq!(namespace.unwrap().as_str(), "themed");
4512 assert_eq!(range, 0..10);
4513 assert!(matches!(
4514 &options.fg,
4515 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
4516 ));
4517 assert!(matches!(
4518 &options.bg,
4519 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
4520 ));
4521 assert!(!options.underline);
4522 assert!(!options.bold);
4523 assert!(!options.italic);
4524 assert!(!options.extend_to_line_end);
4525 }
4526 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4527 }
4528 }
4529
4530 #[test]
4531 fn test_api_clear_namespace() {
4532 let (mut backend, rx) = create_test_backend();
4533
4534 backend
4535 .execute_js(
4536 r#"
4537 const editor = getEditor();
4538 editor.clearNamespace(1, "highlight");
4539 "#,
4540 "test.js",
4541 )
4542 .unwrap();
4543
4544 let cmd = rx.try_recv().unwrap();
4545 match cmd {
4546 PluginCommand::ClearNamespace {
4547 buffer_id,
4548 namespace,
4549 } => {
4550 assert_eq!(buffer_id.0, 1);
4551 assert_eq!(namespace.as_str(), "highlight");
4552 }
4553 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4554 }
4555 }
4556
4557 #[test]
4560 fn test_api_get_theme_schema() {
4561 let (mut backend, _rx) = create_test_backend();
4562
4563 backend
4564 .execute_js(
4565 r#"
4566 const editor = getEditor();
4567 const schema = editor.getThemeSchema();
4568 globalThis._isObject = typeof schema === 'object' && schema !== null;
4569 "#,
4570 "test.js",
4571 )
4572 .unwrap();
4573
4574 backend
4575 .plugin_contexts
4576 .borrow()
4577 .get("test")
4578 .unwrap()
4579 .clone()
4580 .with(|ctx| {
4581 let global = ctx.globals();
4582 let is_object: bool = global.get("_isObject").unwrap();
4583 assert!(is_object);
4585 });
4586 }
4587
4588 #[test]
4589 fn test_api_get_builtin_themes() {
4590 let (mut backend, _rx) = create_test_backend();
4591
4592 backend
4593 .execute_js(
4594 r#"
4595 const editor = getEditor();
4596 const themes = editor.getBuiltinThemes();
4597 globalThis._isObject = typeof themes === 'object' && themes !== null;
4598 "#,
4599 "test.js",
4600 )
4601 .unwrap();
4602
4603 backend
4604 .plugin_contexts
4605 .borrow()
4606 .get("test")
4607 .unwrap()
4608 .clone()
4609 .with(|ctx| {
4610 let global = ctx.globals();
4611 let is_object: bool = global.get("_isObject").unwrap();
4612 assert!(is_object);
4614 });
4615 }
4616
4617 #[test]
4618 fn test_api_apply_theme() {
4619 let (mut backend, rx) = create_test_backend();
4620
4621 backend
4622 .execute_js(
4623 r#"
4624 const editor = getEditor();
4625 editor.applyTheme("dark");
4626 "#,
4627 "test.js",
4628 )
4629 .unwrap();
4630
4631 let cmd = rx.try_recv().unwrap();
4632 match cmd {
4633 PluginCommand::ApplyTheme { theme_name } => {
4634 assert_eq!(theme_name, "dark");
4635 }
4636 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4637 }
4638 }
4639
4640 #[test]
4643 fn test_api_close_buffer() {
4644 let (mut backend, rx) = create_test_backend();
4645
4646 backend
4647 .execute_js(
4648 r#"
4649 const editor = getEditor();
4650 editor.closeBuffer(3);
4651 "#,
4652 "test.js",
4653 )
4654 .unwrap();
4655
4656 let cmd = rx.try_recv().unwrap();
4657 match cmd {
4658 PluginCommand::CloseBuffer { buffer_id } => {
4659 assert_eq!(buffer_id.0, 3);
4660 }
4661 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4662 }
4663 }
4664
4665 #[test]
4666 fn test_api_focus_split() {
4667 let (mut backend, rx) = create_test_backend();
4668
4669 backend
4670 .execute_js(
4671 r#"
4672 const editor = getEditor();
4673 editor.focusSplit(2);
4674 "#,
4675 "test.js",
4676 )
4677 .unwrap();
4678
4679 let cmd = rx.try_recv().unwrap();
4680 match cmd {
4681 PluginCommand::FocusSplit { split_id } => {
4682 assert_eq!(split_id.0, 2);
4683 }
4684 _ => panic!("Expected FocusSplit, got {:?}", cmd),
4685 }
4686 }
4687
4688 #[test]
4689 fn test_api_list_buffers() {
4690 let (tx, _rx) = mpsc::channel();
4691 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4692
4693 {
4695 let mut state = state_snapshot.write().unwrap();
4696 state.buffers.insert(
4697 BufferId(0),
4698 BufferInfo {
4699 id: BufferId(0),
4700 path: Some(PathBuf::from("/test1.txt")),
4701 modified: false,
4702 length: 100,
4703 },
4704 );
4705 state.buffers.insert(
4706 BufferId(1),
4707 BufferInfo {
4708 id: BufferId(1),
4709 path: Some(PathBuf::from("/test2.txt")),
4710 modified: true,
4711 length: 200,
4712 },
4713 );
4714 }
4715
4716 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4717 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4718
4719 backend
4720 .execute_js(
4721 r#"
4722 const editor = getEditor();
4723 const buffers = editor.listBuffers();
4724 globalThis._isArray = Array.isArray(buffers);
4725 globalThis._length = buffers.length;
4726 "#,
4727 "test.js",
4728 )
4729 .unwrap();
4730
4731 backend
4732 .plugin_contexts
4733 .borrow()
4734 .get("test")
4735 .unwrap()
4736 .clone()
4737 .with(|ctx| {
4738 let global = ctx.globals();
4739 let is_array: bool = global.get("_isArray").unwrap();
4740 let length: u32 = global.get("_length").unwrap();
4741 assert!(is_array);
4742 assert_eq!(length, 2);
4743 });
4744 }
4745
4746 #[test]
4749 fn test_api_start_prompt() {
4750 let (mut backend, rx) = create_test_backend();
4751
4752 backend
4753 .execute_js(
4754 r#"
4755 const editor = getEditor();
4756 editor.startPrompt("Enter value:", "test-prompt");
4757 "#,
4758 "test.js",
4759 )
4760 .unwrap();
4761
4762 let cmd = rx.try_recv().unwrap();
4763 match cmd {
4764 PluginCommand::StartPrompt { label, prompt_type } => {
4765 assert_eq!(label, "Enter value:");
4766 assert_eq!(prompt_type, "test-prompt");
4767 }
4768 _ => panic!("Expected StartPrompt, got {:?}", cmd),
4769 }
4770 }
4771
4772 #[test]
4773 fn test_api_start_prompt_with_initial() {
4774 let (mut backend, rx) = create_test_backend();
4775
4776 backend
4777 .execute_js(
4778 r#"
4779 const editor = getEditor();
4780 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4781 "#,
4782 "test.js",
4783 )
4784 .unwrap();
4785
4786 let cmd = rx.try_recv().unwrap();
4787 match cmd {
4788 PluginCommand::StartPromptWithInitial {
4789 label,
4790 prompt_type,
4791 initial_value,
4792 } => {
4793 assert_eq!(label, "Enter value:");
4794 assert_eq!(prompt_type, "test-prompt");
4795 assert_eq!(initial_value, "default");
4796 }
4797 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4798 }
4799 }
4800
4801 #[test]
4802 fn test_api_set_prompt_suggestions() {
4803 let (mut backend, rx) = create_test_backend();
4804
4805 backend
4806 .execute_js(
4807 r#"
4808 const editor = getEditor();
4809 editor.setPromptSuggestions([
4810 { text: "Option 1", value: "opt1" },
4811 { text: "Option 2", value: "opt2" }
4812 ]);
4813 "#,
4814 "test.js",
4815 )
4816 .unwrap();
4817
4818 let cmd = rx.try_recv().unwrap();
4819 match cmd {
4820 PluginCommand::SetPromptSuggestions { suggestions } => {
4821 assert_eq!(suggestions.len(), 2);
4822 assert_eq!(suggestions[0].text, "Option 1");
4823 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4824 }
4825 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4826 }
4827 }
4828
4829 #[test]
4832 fn test_api_get_active_buffer_id() {
4833 let (tx, _rx) = mpsc::channel();
4834 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4835
4836 {
4837 let mut state = state_snapshot.write().unwrap();
4838 state.active_buffer_id = BufferId(42);
4839 }
4840
4841 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4842 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4843
4844 backend
4845 .execute_js(
4846 r#"
4847 const editor = getEditor();
4848 globalThis._activeId = editor.getActiveBufferId();
4849 "#,
4850 "test.js",
4851 )
4852 .unwrap();
4853
4854 backend
4855 .plugin_contexts
4856 .borrow()
4857 .get("test")
4858 .unwrap()
4859 .clone()
4860 .with(|ctx| {
4861 let global = ctx.globals();
4862 let result: u32 = global.get("_activeId").unwrap();
4863 assert_eq!(result, 42);
4864 });
4865 }
4866
4867 #[test]
4868 fn test_api_get_active_split_id() {
4869 let (tx, _rx) = mpsc::channel();
4870 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4871
4872 {
4873 let mut state = state_snapshot.write().unwrap();
4874 state.active_split_id = 7;
4875 }
4876
4877 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4878 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4879
4880 backend
4881 .execute_js(
4882 r#"
4883 const editor = getEditor();
4884 globalThis._splitId = editor.getActiveSplitId();
4885 "#,
4886 "test.js",
4887 )
4888 .unwrap();
4889
4890 backend
4891 .plugin_contexts
4892 .borrow()
4893 .get("test")
4894 .unwrap()
4895 .clone()
4896 .with(|ctx| {
4897 let global = ctx.globals();
4898 let result: u32 = global.get("_splitId").unwrap();
4899 assert_eq!(result, 7);
4900 });
4901 }
4902
4903 #[test]
4906 fn test_api_file_exists() {
4907 let (mut backend, _rx) = create_test_backend();
4908
4909 backend
4910 .execute_js(
4911 r#"
4912 const editor = getEditor();
4913 // Test with a path that definitely exists
4914 globalThis._exists = editor.fileExists("/");
4915 "#,
4916 "test.js",
4917 )
4918 .unwrap();
4919
4920 backend
4921 .plugin_contexts
4922 .borrow()
4923 .get("test")
4924 .unwrap()
4925 .clone()
4926 .with(|ctx| {
4927 let global = ctx.globals();
4928 let result: bool = global.get("_exists").unwrap();
4929 assert!(result);
4930 });
4931 }
4932
4933 #[test]
4934 fn test_api_get_cwd() {
4935 let (mut backend, _rx) = create_test_backend();
4936
4937 backend
4938 .execute_js(
4939 r#"
4940 const editor = getEditor();
4941 globalThis._cwd = editor.getCwd();
4942 "#,
4943 "test.js",
4944 )
4945 .unwrap();
4946
4947 backend
4948 .plugin_contexts
4949 .borrow()
4950 .get("test")
4951 .unwrap()
4952 .clone()
4953 .with(|ctx| {
4954 let global = ctx.globals();
4955 let result: String = global.get("_cwd").unwrap();
4956 assert!(!result.is_empty());
4958 });
4959 }
4960
4961 #[test]
4962 fn test_api_get_env() {
4963 let (mut backend, _rx) = create_test_backend();
4964
4965 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
4967
4968 backend
4969 .execute_js(
4970 r#"
4971 const editor = getEditor();
4972 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
4973 "#,
4974 "test.js",
4975 )
4976 .unwrap();
4977
4978 backend
4979 .plugin_contexts
4980 .borrow()
4981 .get("test")
4982 .unwrap()
4983 .clone()
4984 .with(|ctx| {
4985 let global = ctx.globals();
4986 let result: Option<String> = global.get("_envVal").unwrap();
4987 assert_eq!(result, Some("test_value".to_string()));
4988 });
4989
4990 std::env::remove_var("TEST_PLUGIN_VAR");
4991 }
4992
4993 #[test]
4994 fn test_api_get_config() {
4995 let (mut backend, _rx) = create_test_backend();
4996
4997 backend
4998 .execute_js(
4999 r#"
5000 const editor = getEditor();
5001 const config = editor.getConfig();
5002 globalThis._isObject = typeof config === 'object';
5003 "#,
5004 "test.js",
5005 )
5006 .unwrap();
5007
5008 backend
5009 .plugin_contexts
5010 .borrow()
5011 .get("test")
5012 .unwrap()
5013 .clone()
5014 .with(|ctx| {
5015 let global = ctx.globals();
5016 let is_object: bool = global.get("_isObject").unwrap();
5017 assert!(is_object);
5019 });
5020 }
5021
5022 #[test]
5023 fn test_api_get_themes_dir() {
5024 let (mut backend, _rx) = create_test_backend();
5025
5026 backend
5027 .execute_js(
5028 r#"
5029 const editor = getEditor();
5030 globalThis._themesDir = editor.getThemesDir();
5031 "#,
5032 "test.js",
5033 )
5034 .unwrap();
5035
5036 backend
5037 .plugin_contexts
5038 .borrow()
5039 .get("test")
5040 .unwrap()
5041 .clone()
5042 .with(|ctx| {
5043 let global = ctx.globals();
5044 let result: String = global.get("_themesDir").unwrap();
5045 assert!(!result.is_empty());
5047 });
5048 }
5049
5050 #[test]
5053 fn test_api_read_dir() {
5054 let (mut backend, _rx) = create_test_backend();
5055
5056 backend
5057 .execute_js(
5058 r#"
5059 const editor = getEditor();
5060 const entries = editor.readDir("/tmp");
5061 globalThis._isArray = Array.isArray(entries);
5062 globalThis._length = entries.length;
5063 "#,
5064 "test.js",
5065 )
5066 .unwrap();
5067
5068 backend
5069 .plugin_contexts
5070 .borrow()
5071 .get("test")
5072 .unwrap()
5073 .clone()
5074 .with(|ctx| {
5075 let global = ctx.globals();
5076 let is_array: bool = global.get("_isArray").unwrap();
5077 let length: u32 = global.get("_length").unwrap();
5078 assert!(is_array);
5080 let _ = length;
5082 });
5083 }
5084
5085 #[test]
5088 fn test_api_execute_action() {
5089 let (mut backend, rx) = create_test_backend();
5090
5091 backend
5092 .execute_js(
5093 r#"
5094 const editor = getEditor();
5095 editor.executeAction("move_cursor_up");
5096 "#,
5097 "test.js",
5098 )
5099 .unwrap();
5100
5101 let cmd = rx.try_recv().unwrap();
5102 match cmd {
5103 PluginCommand::ExecuteAction { action_name } => {
5104 assert_eq!(action_name, "move_cursor_up");
5105 }
5106 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5107 }
5108 }
5109
5110 #[test]
5113 fn test_api_debug() {
5114 let (mut backend, _rx) = create_test_backend();
5115
5116 backend
5118 .execute_js(
5119 r#"
5120 const editor = getEditor();
5121 editor.debug("Test debug message");
5122 editor.debug("Another message with special chars: <>&\"'");
5123 "#,
5124 "test.js",
5125 )
5126 .unwrap();
5127 }
5129
5130 #[test]
5133 fn test_typescript_preamble_generated() {
5134 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5136 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5137 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5138 println!(
5139 "Generated {} bytes of TypeScript preamble",
5140 JSEDITORAPI_TS_PREAMBLE.len()
5141 );
5142 }
5143
5144 #[test]
5145 fn test_typescript_editor_api_generated() {
5146 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5148 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5149 println!(
5150 "Generated {} bytes of EditorAPI interface",
5151 JSEDITORAPI_TS_EDITOR_API.len()
5152 );
5153 }
5154
5155 #[test]
5156 fn test_js_methods_list() {
5157 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5159 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5160 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5162 if i < 20 {
5163 println!(" - {}", method);
5164 }
5165 }
5166 if JSEDITORAPI_JS_METHODS.len() > 20 {
5167 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5168 }
5169 }
5170
5171 #[test]
5174 fn test_api_load_plugin_sends_command() {
5175 let (mut backend, rx) = create_test_backend();
5176
5177 backend
5179 .execute_js(
5180 r#"
5181 const editor = getEditor();
5182 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5183 "#,
5184 "test.js",
5185 )
5186 .unwrap();
5187
5188 let cmd = rx.try_recv().unwrap();
5190 match cmd {
5191 PluginCommand::LoadPlugin { path, callback_id } => {
5192 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5193 assert!(callback_id.0 > 0); }
5195 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5196 }
5197 }
5198
5199 #[test]
5200 fn test_api_unload_plugin_sends_command() {
5201 let (mut backend, rx) = create_test_backend();
5202
5203 backend
5205 .execute_js(
5206 r#"
5207 const editor = getEditor();
5208 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5209 "#,
5210 "test.js",
5211 )
5212 .unwrap();
5213
5214 let cmd = rx.try_recv().unwrap();
5216 match cmd {
5217 PluginCommand::UnloadPlugin { name, callback_id } => {
5218 assert_eq!(name, "my-plugin");
5219 assert!(callback_id.0 > 0); }
5221 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5222 }
5223 }
5224
5225 #[test]
5226 fn test_api_reload_plugin_sends_command() {
5227 let (mut backend, rx) = create_test_backend();
5228
5229 backend
5231 .execute_js(
5232 r#"
5233 const editor = getEditor();
5234 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5235 "#,
5236 "test.js",
5237 )
5238 .unwrap();
5239
5240 let cmd = rx.try_recv().unwrap();
5242 match cmd {
5243 PluginCommand::ReloadPlugin { name, callback_id } => {
5244 assert_eq!(name, "my-plugin");
5245 assert!(callback_id.0 > 0); }
5247 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5248 }
5249 }
5250
5251 #[test]
5252 fn test_api_load_plugin_resolves_callback() {
5253 let (mut backend, rx) = create_test_backend();
5254
5255 backend
5257 .execute_js(
5258 r#"
5259 const editor = getEditor();
5260 globalThis._loadResult = null;
5261 editor.loadPlugin("/path/to/plugin.ts").then(result => {
5262 globalThis._loadResult = result;
5263 });
5264 "#,
5265 "test.js",
5266 )
5267 .unwrap();
5268
5269 let callback_id = match rx.try_recv().unwrap() {
5271 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5272 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5273 };
5274
5275 backend.resolve_callback(callback_id, "true");
5277
5278 backend
5280 .plugin_contexts
5281 .borrow()
5282 .get("test")
5283 .unwrap()
5284 .clone()
5285 .with(|ctx| {
5286 run_pending_jobs_checked(&ctx, "test async loadPlugin");
5287 });
5288
5289 backend
5291 .plugin_contexts
5292 .borrow()
5293 .get("test")
5294 .unwrap()
5295 .clone()
5296 .with(|ctx| {
5297 let global = ctx.globals();
5298 let result: bool = global.get("_loadResult").unwrap();
5299 assert!(result);
5300 });
5301 }
5302
5303 #[test]
5304 fn test_api_unload_plugin_rejects_on_error() {
5305 let (mut backend, rx) = create_test_backend();
5306
5307 backend
5309 .execute_js(
5310 r#"
5311 const editor = getEditor();
5312 globalThis._unloadError = null;
5313 editor.unloadPlugin("nonexistent-plugin").catch(err => {
5314 globalThis._unloadError = err.message || String(err);
5315 });
5316 "#,
5317 "test.js",
5318 )
5319 .unwrap();
5320
5321 let callback_id = match rx.try_recv().unwrap() {
5323 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5324 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5325 };
5326
5327 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5329
5330 backend
5332 .plugin_contexts
5333 .borrow()
5334 .get("test")
5335 .unwrap()
5336 .clone()
5337 .with(|ctx| {
5338 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5339 });
5340
5341 backend
5343 .plugin_contexts
5344 .borrow()
5345 .get("test")
5346 .unwrap()
5347 .clone()
5348 .with(|ctx| {
5349 let global = ctx.globals();
5350 let error: String = global.get("_unloadError").unwrap();
5351 assert!(error.contains("nonexistent-plugin"));
5352 });
5353 }
5354}