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 json_to_js_value<'js>(
165 ctx: &rquickjs::Ctx<'js>,
166 val: &serde_json::Value,
167) -> rquickjs::Result<Value<'js>> {
168 match val {
169 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
170 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
171 serde_json::Value::Number(n) => {
172 if let Some(i) = n.as_i64() {
173 Ok(Value::new_int(ctx.clone(), i as i32))
174 } else if let Some(f) = n.as_f64() {
175 Ok(Value::new_float(ctx.clone(), f))
176 } else {
177 Ok(Value::new_null(ctx.clone()))
178 }
179 }
180 serde_json::Value::String(s) => {
181 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
182 Ok(js_str.into_value())
183 }
184 serde_json::Value::Array(arr) => {
185 let js_arr = rquickjs::Array::new(ctx.clone())?;
186 for (i, item) in arr.iter().enumerate() {
187 let js_val = json_to_js_value(ctx, item)?;
188 js_arr.set(i, js_val)?;
189 }
190 Ok(js_arr.into_value())
191 }
192 serde_json::Value::Object(map) => {
193 let obj = rquickjs::Object::new(ctx.clone())?;
194 for (key, val) in map {
195 let js_val = json_to_js_value(ctx, val)?;
196 obj.set(key.as_str(), js_val)?;
197 }
198 Ok(obj.into_value())
199 }
200 }
201}
202
203fn get_text_properties_at_cursor_typed(
205 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
206 buffer_id: u32,
207) -> fresh_core::api::TextPropertiesAtCursor {
208 use fresh_core::api::TextPropertiesAtCursor;
209
210 let snap = match snapshot.read() {
211 Ok(s) => s,
212 Err(_) => return TextPropertiesAtCursor(Vec::new()),
213 };
214 let buffer_id_typed = BufferId(buffer_id as usize);
215 let cursor_pos = match snap
216 .buffer_cursor_positions
217 .get(&buffer_id_typed)
218 .copied()
219 .or_else(|| {
220 if snap.active_buffer_id == buffer_id_typed {
221 snap.primary_cursor.as_ref().map(|c| c.position)
222 } else {
223 None
224 }
225 }) {
226 Some(pos) => pos,
227 None => return TextPropertiesAtCursor(Vec::new()),
228 };
229
230 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
231 Some(p) => p,
232 None => return TextPropertiesAtCursor(Vec::new()),
233 };
234
235 let result: Vec<_> = properties
237 .iter()
238 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
239 .map(|prop| prop.properties.clone())
240 .collect();
241
242 TextPropertiesAtCursor(result)
243}
244
245fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
247 use rquickjs::Type;
248 match val.type_of() {
249 Type::Null => "null".to_string(),
250 Type::Undefined => "undefined".to_string(),
251 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
252 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
253 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
254 Type::String => val
255 .as_string()
256 .and_then(|s| s.to_string().ok())
257 .unwrap_or_default(),
258 Type::Object | Type::Exception => {
259 if let Some(obj) = val.as_object() {
261 let name: Option<String> = obj.get("name").ok();
263 let message: Option<String> = obj.get("message").ok();
264 let stack: Option<String> = obj.get("stack").ok();
265
266 if message.is_some() || name.is_some() {
267 let name = name.unwrap_or_else(|| "Error".to_string());
269 let message = message.unwrap_or_default();
270 if let Some(stack) = stack {
271 return format!("{}: {}\n{}", name, message, stack);
272 } else {
273 return format!("{}: {}", name, message);
274 }
275 }
276
277 let json = js_to_json(ctx, val.clone());
279 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
280 } else {
281 "[object]".to_string()
282 }
283 }
284 Type::Array => {
285 let json = js_to_json(ctx, val.clone());
286 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
287 }
288 Type::Function | Type::Constructor => "[function]".to_string(),
289 Type::Symbol => "[symbol]".to_string(),
290 Type::BigInt => val
291 .as_big_int()
292 .and_then(|b| b.clone().to_i64().ok())
293 .map(|n| n.to_string())
294 .unwrap_or_else(|| "[bigint]".to_string()),
295 _ => format!("[{}]", val.type_name()),
296 }
297}
298
299fn format_js_error(
301 ctx: &rquickjs::Ctx<'_>,
302 err: rquickjs::Error,
303 source_name: &str,
304) -> anyhow::Error {
305 if err.is_exception() {
307 let exc = ctx.catch();
309 if !exc.is_undefined() && !exc.is_null() {
310 if let Some(exc_obj) = exc.as_object() {
312 let message: String = exc_obj
313 .get::<_, String>("message")
314 .unwrap_or_else(|_| "Unknown error".to_string());
315 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
316 let name: String = exc_obj
317 .get::<_, String>("name")
318 .unwrap_or_else(|_| "Error".to_string());
319
320 if !stack.is_empty() {
321 return anyhow::anyhow!(
322 "JS error in {}: {}: {}\nStack trace:\n{}",
323 source_name,
324 name,
325 message,
326 stack
327 );
328 } else {
329 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
330 }
331 } else {
332 let exc_str: String = exc
334 .as_string()
335 .and_then(|s: &rquickjs::String| s.to_string().ok())
336 .unwrap_or_else(|| format!("{:?}", exc));
337 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
338 }
339 }
340 }
341
342 anyhow::anyhow!("JS error in {}: {}", source_name, err)
344}
345
346fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
349 let error = format_js_error(ctx, err, context);
350 tracing::error!("{}", error);
351
352 if should_panic_on_js_errors() {
354 panic!("JavaScript error in {}: {}", context, error);
355 }
356}
357
358static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
360 std::sync::atomic::AtomicBool::new(false);
361
362pub fn set_panic_on_js_errors(enabled: bool) {
364 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
365}
366
367fn should_panic_on_js_errors() -> bool {
369 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
370}
371
372static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
376
377static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
379
380fn set_fatal_js_error(msg: String) {
382 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
383 if guard.is_none() {
384 *guard = Some(msg);
386 }
387 }
388 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
389}
390
391pub fn has_fatal_js_error() -> bool {
393 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
394}
395
396pub fn take_fatal_js_error() -> Option<String> {
398 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
399 return None;
400 }
401 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
402 guard.take()
403 } else {
404 Some("Fatal JS error (message unavailable)".to_string())
405 }
406}
407
408fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
411 let mut count = 0;
412 loop {
413 let exc: rquickjs::Value = ctx.catch();
415 if exc.is_exception() {
417 let error_msg = if let Some(err) = exc.as_exception() {
418 format!(
419 "{}: {}",
420 err.message().unwrap_or_default(),
421 err.stack().unwrap_or_default()
422 )
423 } else {
424 format!("{:?}", exc)
425 };
426 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
427 if should_panic_on_js_errors() {
428 panic!("Unhandled JS exception during {}: {}", context, error_msg);
429 }
430 }
431
432 if !ctx.execute_pending_job() {
433 break;
434 }
435 count += 1;
436 }
437
438 let exc: rquickjs::Value = ctx.catch();
440 if exc.is_exception() {
441 let error_msg = if let Some(err) = exc.as_exception() {
442 format!(
443 "{}: {}",
444 err.message().unwrap_or_default(),
445 err.stack().unwrap_or_default()
446 )
447 } else {
448 format!("{:?}", exc)
449 };
450 tracing::error!(
451 "Unhandled JS exception after running jobs in {}: {}",
452 context,
453 error_msg
454 );
455 if should_panic_on_js_errors() {
456 panic!(
457 "Unhandled JS exception after running jobs in {}: {}",
458 context, error_msg
459 );
460 }
461 }
462
463 count
464}
465
466fn parse_text_property_entry(
468 ctx: &rquickjs::Ctx<'_>,
469 obj: &Object<'_>,
470) -> Option<TextPropertyEntry> {
471 let text: String = obj.get("text").ok()?;
472 let properties: HashMap<String, serde_json::Value> = obj
473 .get::<_, Object>("properties")
474 .ok()
475 .map(|props_obj| {
476 let mut map = HashMap::new();
477 for key in props_obj.keys::<String>().flatten() {
478 if let Ok(v) = props_obj.get::<_, Value>(&key) {
479 map.insert(key, js_to_json(ctx, v));
480 }
481 }
482 map
483 })
484 .unwrap_or_default();
485 Some(TextPropertyEntry { text, properties })
486}
487
488pub type PendingResponses =
490 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
491
492#[derive(Debug, Clone)]
494pub struct TsPluginInfo {
495 pub name: String,
496 pub path: PathBuf,
497 pub enabled: bool,
498}
499
500#[derive(Debug, Clone)]
502pub struct PluginHandler {
503 pub plugin_name: String,
504 pub handler_name: String,
505}
506
507#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
510#[rquickjs::class]
511pub struct JsEditorApi {
512 #[qjs(skip_trace)]
513 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
514 #[qjs(skip_trace)]
515 command_sender: mpsc::Sender<PluginCommand>,
516 #[qjs(skip_trace)]
517 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
518 #[qjs(skip_trace)]
519 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
520 #[qjs(skip_trace)]
521 next_request_id: Rc<RefCell<u64>>,
522 #[qjs(skip_trace)]
523 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
524 #[qjs(skip_trace)]
525 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
526 pub plugin_name: String,
527}
528
529#[plugin_api_impl]
530#[rquickjs::methods(rename_all = "camelCase")]
531impl JsEditorApi {
532 pub fn api_version(&self) -> u32 {
537 2
538 }
539
540 pub fn get_active_buffer_id(&self) -> u32 {
542 self.state_snapshot
543 .read()
544 .map(|s| s.active_buffer_id.0 as u32)
545 .unwrap_or(0)
546 }
547
548 pub fn get_active_split_id(&self) -> u32 {
550 self.state_snapshot
551 .read()
552 .map(|s| s.active_split_id as u32)
553 .unwrap_or(0)
554 }
555
556 #[plugin_api(ts_return = "BufferInfo[]")]
558 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
559 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
560 s.buffers.values().cloned().collect()
561 } else {
562 Vec::new()
563 };
564 rquickjs_serde::to_value(ctx, &buffers)
565 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
566 }
567
568 pub fn debug(&self, msg: String) {
571 tracing::info!("Plugin.debug: {}", msg);
572 }
573
574 pub fn info(&self, msg: String) {
575 tracing::info!("Plugin: {}", msg);
576 }
577
578 pub fn warn(&self, msg: String) {
579 tracing::warn!("Plugin: {}", msg);
580 }
581
582 pub fn error(&self, msg: String) {
583 tracing::error!("Plugin: {}", msg);
584 }
585
586 pub fn set_status(&self, msg: String) {
589 let _ = self
590 .command_sender
591 .send(PluginCommand::SetStatus { message: msg });
592 }
593
594 pub fn copy_to_clipboard(&self, text: String) {
597 let _ = self
598 .command_sender
599 .send(PluginCommand::SetClipboard { text });
600 }
601
602 pub fn set_clipboard(&self, text: String) {
603 let _ = self
604 .command_sender
605 .send(PluginCommand::SetClipboard { text });
606 }
607
608 pub fn register_command<'js>(
613 &self,
614 _ctx: rquickjs::Ctx<'js>,
615 name: String,
616 description: String,
617 handler_name: String,
618 context: rquickjs::function::Opt<rquickjs::Value<'js>>,
619 ) -> rquickjs::Result<bool> {
620 let plugin_name = self.plugin_name.clone();
622 let context_str: Option<String> = context.0.and_then(|v| {
624 if v.is_null() || v.is_undefined() {
625 None
626 } else {
627 v.as_string().and_then(|s| s.to_string().ok())
628 }
629 });
630
631 tracing::debug!(
632 "registerCommand: plugin='{}', name='{}', handler='{}'",
633 plugin_name,
634 name,
635 handler_name
636 );
637
638 self.registered_actions.borrow_mut().insert(
640 handler_name.clone(),
641 PluginHandler {
642 plugin_name: self.plugin_name.clone(),
643 handler_name: handler_name.clone(),
644 },
645 );
646
647 let command = Command {
649 name: name.clone(),
650 description,
651 action_name: handler_name,
652 plugin_name,
653 custom_contexts: context_str.into_iter().collect(),
654 };
655
656 Ok(self
657 .command_sender
658 .send(PluginCommand::RegisterCommand { command })
659 .is_ok())
660 }
661
662 pub fn unregister_command(&self, name: String) -> bool {
664 self.command_sender
665 .send(PluginCommand::UnregisterCommand { name })
666 .is_ok()
667 }
668
669 pub fn set_context(&self, name: String, active: bool) -> bool {
671 self.command_sender
672 .send(PluginCommand::SetContext { name, active })
673 .is_ok()
674 }
675
676 pub fn execute_action(&self, action_name: String) -> bool {
678 self.command_sender
679 .send(PluginCommand::ExecuteAction { action_name })
680 .is_ok()
681 }
682
683 pub fn t<'js>(
688 &self,
689 _ctx: rquickjs::Ctx<'js>,
690 key: String,
691 args: rquickjs::function::Rest<Value<'js>>,
692 ) -> String {
693 let plugin_name = self.plugin_name.clone();
695 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
697 if let Some(obj) = first_arg.as_object() {
698 let mut map = HashMap::new();
699 for k in obj.keys::<String>().flatten() {
700 if let Ok(v) = obj.get::<_, String>(&k) {
701 map.insert(k, v);
702 }
703 }
704 map
705 } else {
706 HashMap::new()
707 }
708 } else {
709 HashMap::new()
710 };
711 let res = self.services.translate(&plugin_name, &key, &args_map);
712
713 tracing::info!(
714 "Translating: key={}, plugin={}, args={:?} => res='{}'",
715 key,
716 plugin_name,
717 args_map,
718 res
719 );
720 res
721 }
722
723 pub fn get_cursor_position(&self) -> u32 {
727 self.state_snapshot
728 .read()
729 .ok()
730 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
731 .unwrap_or(0)
732 }
733
734 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
736 if let Ok(s) = self.state_snapshot.read() {
737 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
738 if let Some(p) = &b.path {
739 return p.to_string_lossy().to_string();
740 }
741 }
742 }
743 String::new()
744 }
745
746 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
748 if let Ok(s) = self.state_snapshot.read() {
749 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
750 return b.length as u32;
751 }
752 }
753 0
754 }
755
756 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
758 if let Ok(s) = self.state_snapshot.read() {
759 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
760 return b.modified;
761 }
762 }
763 false
764 }
765
766 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
769 self.command_sender
770 .send(PluginCommand::SaveBufferToPath {
771 buffer_id: BufferId(buffer_id as usize),
772 path: std::path::PathBuf::from(path),
773 })
774 .is_ok()
775 }
776
777 #[plugin_api(ts_return = "BufferInfo | null")]
779 pub fn get_buffer_info<'js>(
780 &self,
781 ctx: rquickjs::Ctx<'js>,
782 buffer_id: u32,
783 ) -> rquickjs::Result<Value<'js>> {
784 let info = if let Ok(s) = self.state_snapshot.read() {
785 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
786 } else {
787 None
788 };
789 rquickjs_serde::to_value(ctx, &info)
790 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
791 }
792
793 #[plugin_api(ts_return = "CursorInfo | null")]
795 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
796 let cursor = if let Ok(s) = self.state_snapshot.read() {
797 s.primary_cursor.clone()
798 } else {
799 None
800 };
801 rquickjs_serde::to_value(ctx, &cursor)
802 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
803 }
804
805 #[plugin_api(ts_return = "CursorInfo[]")]
807 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
808 let cursors = if let Ok(s) = self.state_snapshot.read() {
809 s.all_cursors.clone()
810 } else {
811 Vec::new()
812 };
813 rquickjs_serde::to_value(ctx, &cursors)
814 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
815 }
816
817 #[plugin_api(ts_return = "number[]")]
819 pub fn get_all_cursor_positions<'js>(
820 &self,
821 ctx: rquickjs::Ctx<'js>,
822 ) -> rquickjs::Result<Value<'js>> {
823 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
824 s.all_cursors.iter().map(|c| c.position as u32).collect()
825 } else {
826 Vec::new()
827 };
828 rquickjs_serde::to_value(ctx, &positions)
829 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
830 }
831
832 #[plugin_api(ts_return = "ViewportInfo | null")]
834 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
835 let viewport = if let Ok(s) = self.state_snapshot.read() {
836 s.viewport.clone()
837 } else {
838 None
839 };
840 rquickjs_serde::to_value(ctx, &viewport)
841 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
842 }
843
844 pub fn get_cursor_line(&self) -> u32 {
846 0
850 }
851
852 #[plugin_api(
855 async_promise,
856 js_name = "getLineStartPosition",
857 ts_return = "number | null"
858 )]
859 #[qjs(rename = "_getLineStartPositionStart")]
860 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
861 let id = {
862 let mut id_ref = self.next_request_id.borrow_mut();
863 let id = *id_ref;
864 *id_ref += 1;
865 self.callback_contexts
867 .borrow_mut()
868 .insert(id, self.plugin_name.clone());
869 id
870 };
871 let _ = self
873 .command_sender
874 .send(PluginCommand::GetLineStartPosition {
875 buffer_id: BufferId(0),
876 line,
877 request_id: id,
878 });
879 id
880 }
881
882 #[plugin_api(
886 async_promise,
887 js_name = "getLineEndPosition",
888 ts_return = "number | null"
889 )]
890 #[qjs(rename = "_getLineEndPositionStart")]
891 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
892 let id = {
893 let mut id_ref = self.next_request_id.borrow_mut();
894 let id = *id_ref;
895 *id_ref += 1;
896 self.callback_contexts
897 .borrow_mut()
898 .insert(id, self.plugin_name.clone());
899 id
900 };
901 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
903 buffer_id: BufferId(0),
904 line,
905 request_id: id,
906 });
907 id
908 }
909
910 #[plugin_api(
913 async_promise,
914 js_name = "getBufferLineCount",
915 ts_return = "number | null"
916 )]
917 #[qjs(rename = "_getBufferLineCountStart")]
918 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
919 let id = {
920 let mut id_ref = self.next_request_id.borrow_mut();
921 let id = *id_ref;
922 *id_ref += 1;
923 self.callback_contexts
924 .borrow_mut()
925 .insert(id, self.plugin_name.clone());
926 id
927 };
928 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
930 buffer_id: BufferId(0),
931 request_id: id,
932 });
933 id
934 }
935
936 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
939 self.command_sender
940 .send(PluginCommand::ScrollToLineCenter {
941 split_id: SplitId(split_id as usize),
942 buffer_id: BufferId(buffer_id as usize),
943 line: line as usize,
944 })
945 .is_ok()
946 }
947
948 pub fn find_buffer_by_path(&self, path: String) -> u32 {
950 let path_buf = std::path::PathBuf::from(&path);
951 if let Ok(s) = self.state_snapshot.read() {
952 for (id, info) in &s.buffers {
953 if let Some(buf_path) = &info.path {
954 if buf_path == &path_buf {
955 return id.0 as u32;
956 }
957 }
958 }
959 }
960 0
961 }
962
963 #[plugin_api(ts_return = "BufferSavedDiff | null")]
965 pub fn get_buffer_saved_diff<'js>(
966 &self,
967 ctx: rquickjs::Ctx<'js>,
968 buffer_id: u32,
969 ) -> rquickjs::Result<Value<'js>> {
970 let diff = if let Ok(s) = self.state_snapshot.read() {
971 s.buffer_saved_diffs
972 .get(&BufferId(buffer_id as usize))
973 .cloned()
974 } else {
975 None
976 };
977 rquickjs_serde::to_value(ctx, &diff)
978 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
979 }
980
981 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
985 self.command_sender
986 .send(PluginCommand::InsertText {
987 buffer_id: BufferId(buffer_id as usize),
988 position: position as usize,
989 text,
990 })
991 .is_ok()
992 }
993
994 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
996 self.command_sender
997 .send(PluginCommand::DeleteRange {
998 buffer_id: BufferId(buffer_id as usize),
999 range: (start as usize)..(end as usize),
1000 })
1001 .is_ok()
1002 }
1003
1004 pub fn insert_at_cursor(&self, text: String) -> bool {
1006 self.command_sender
1007 .send(PluginCommand::InsertAtCursor { text })
1008 .is_ok()
1009 }
1010
1011 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1015 self.command_sender
1016 .send(PluginCommand::OpenFileAtLocation {
1017 path: PathBuf::from(path),
1018 line: line.map(|l| l as usize),
1019 column: column.map(|c| c as usize),
1020 })
1021 .is_ok()
1022 }
1023
1024 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1026 self.command_sender
1027 .send(PluginCommand::OpenFileInSplit {
1028 split_id: split_id as usize,
1029 path: PathBuf::from(path),
1030 line: Some(line as usize),
1031 column: Some(column as usize),
1032 })
1033 .is_ok()
1034 }
1035
1036 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1038 self.command_sender
1039 .send(PluginCommand::ShowBuffer {
1040 buffer_id: BufferId(buffer_id as usize),
1041 })
1042 .is_ok()
1043 }
1044
1045 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1047 self.command_sender
1048 .send(PluginCommand::CloseBuffer {
1049 buffer_id: BufferId(buffer_id as usize),
1050 })
1051 .is_ok()
1052 }
1053
1054 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1058 if event_name == "lines_changed" {
1062 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1063 }
1064 self.event_handlers
1065 .borrow_mut()
1066 .entry(event_name)
1067 .or_default()
1068 .push(PluginHandler {
1069 plugin_name: self.plugin_name.clone(),
1070 handler_name,
1071 });
1072 }
1073
1074 pub fn off(&self, event_name: String, handler_name: String) {
1076 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1077 list.retain(|h| h.handler_name != handler_name);
1078 }
1079 }
1080
1081 pub fn get_env(&self, name: String) -> Option<String> {
1085 std::env::var(&name).ok()
1086 }
1087
1088 pub fn get_cwd(&self) -> String {
1090 self.state_snapshot
1091 .read()
1092 .map(|s| s.working_dir.to_string_lossy().to_string())
1093 .unwrap_or_else(|_| ".".to_string())
1094 }
1095
1096 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1101 let mut result_parts: Vec<String> = Vec::new();
1102 let mut has_leading_slash = false;
1103
1104 for part in &parts.0 {
1105 let normalized = part.replace('\\', "/");
1107
1108 let is_absolute = normalized.starts_with('/')
1110 || (normalized.len() >= 2
1111 && normalized
1112 .chars()
1113 .next()
1114 .map(|c| c.is_ascii_alphabetic())
1115 .unwrap_or(false)
1116 && normalized.chars().nth(1) == Some(':'));
1117
1118 if is_absolute {
1119 result_parts.clear();
1121 has_leading_slash = normalized.starts_with('/');
1122 }
1123
1124 for segment in normalized.split('/') {
1126 if !segment.is_empty() && segment != "." {
1127 if segment == ".." {
1128 result_parts.pop();
1129 } else {
1130 result_parts.push(segment.to_string());
1131 }
1132 }
1133 }
1134 }
1135
1136 let joined = result_parts.join("/");
1138
1139 if has_leading_slash && !joined.is_empty() {
1141 format!("/{}", joined)
1142 } else {
1143 joined
1144 }
1145 }
1146
1147 pub fn path_dirname(&self, path: String) -> String {
1149 Path::new(&path)
1150 .parent()
1151 .map(|p| p.to_string_lossy().to_string())
1152 .unwrap_or_default()
1153 }
1154
1155 pub fn path_basename(&self, path: String) -> String {
1157 Path::new(&path)
1158 .file_name()
1159 .map(|s| s.to_string_lossy().to_string())
1160 .unwrap_or_default()
1161 }
1162
1163 pub fn path_extname(&self, path: String) -> String {
1165 Path::new(&path)
1166 .extension()
1167 .map(|s| format!(".{}", s.to_string_lossy()))
1168 .unwrap_or_default()
1169 }
1170
1171 pub fn path_is_absolute(&self, path: String) -> bool {
1173 Path::new(&path).is_absolute()
1174 }
1175
1176 pub fn utf8_byte_length(&self, text: String) -> u32 {
1184 text.len() as u32
1185 }
1186
1187 pub fn file_exists(&self, path: String) -> bool {
1191 Path::new(&path).exists()
1192 }
1193
1194 pub fn read_file(&self, path: String) -> Option<String> {
1196 std::fs::read_to_string(&path).ok()
1197 }
1198
1199 pub fn write_file(&self, path: String, content: String) -> bool {
1201 std::fs::write(&path, content).is_ok()
1202 }
1203
1204 #[plugin_api(ts_return = "DirEntry[]")]
1206 pub fn read_dir<'js>(
1207 &self,
1208 ctx: rquickjs::Ctx<'js>,
1209 path: String,
1210 ) -> rquickjs::Result<Value<'js>> {
1211 use fresh_core::api::DirEntry;
1212
1213 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1214 Ok(entries) => entries
1215 .filter_map(|e| e.ok())
1216 .map(|entry| {
1217 let file_type = entry.file_type().ok();
1218 DirEntry {
1219 name: entry.file_name().to_string_lossy().to_string(),
1220 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1221 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1222 }
1223 })
1224 .collect(),
1225 Err(e) => {
1226 tracing::warn!("readDir failed for '{}': {}", path, e);
1227 Vec::new()
1228 }
1229 };
1230
1231 rquickjs_serde::to_value(ctx, &entries)
1232 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1233 }
1234
1235 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1239 let config: serde_json::Value = self
1240 .state_snapshot
1241 .read()
1242 .map(|s| s.config.clone())
1243 .unwrap_or_else(|_| serde_json::json!({}));
1244
1245 rquickjs_serde::to_value(ctx, &config)
1246 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1247 }
1248
1249 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1251 let config: serde_json::Value = self
1252 .state_snapshot
1253 .read()
1254 .map(|s| s.user_config.clone())
1255 .unwrap_or_else(|_| serde_json::json!({}));
1256
1257 rquickjs_serde::to_value(ctx, &config)
1258 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1259 }
1260
1261 pub fn reload_config(&self) {
1263 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1264 }
1265
1266 pub fn reload_themes(&self) {
1269 let _ = self.command_sender.send(PluginCommand::ReloadThemes);
1270 }
1271
1272 pub fn register_grammar(
1275 &self,
1276 language: String,
1277 grammar_path: String,
1278 extensions: Vec<String>,
1279 ) -> bool {
1280 self.command_sender
1281 .send(PluginCommand::RegisterGrammar {
1282 language,
1283 grammar_path,
1284 extensions,
1285 })
1286 .is_ok()
1287 }
1288
1289 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1291 self.command_sender
1292 .send(PluginCommand::RegisterLanguageConfig { language, config })
1293 .is_ok()
1294 }
1295
1296 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1298 self.command_sender
1299 .send(PluginCommand::RegisterLspServer { language, config })
1300 .is_ok()
1301 }
1302
1303 pub fn reload_grammars(&self) {
1306 let _ = self.command_sender.send(PluginCommand::ReloadGrammars);
1307 }
1308
1309 pub fn get_config_dir(&self) -> String {
1311 self.services.config_dir().to_string_lossy().to_string()
1312 }
1313
1314 pub fn get_themes_dir(&self) -> String {
1316 self.services
1317 .config_dir()
1318 .join("themes")
1319 .to_string_lossy()
1320 .to_string()
1321 }
1322
1323 pub fn apply_theme(&self, theme_name: String) -> bool {
1325 self.command_sender
1326 .send(PluginCommand::ApplyTheme { theme_name })
1327 .is_ok()
1328 }
1329
1330 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1332 let schema = self.services.get_theme_schema();
1333 rquickjs_serde::to_value(ctx, &schema)
1334 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1335 }
1336
1337 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1339 let themes = self.services.get_builtin_themes();
1340 rquickjs_serde::to_value(ctx, &themes)
1341 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1342 }
1343
1344 #[qjs(rename = "_deleteThemeSync")]
1346 pub fn delete_theme_sync(&self, name: String) -> bool {
1347 let themes_dir = self.services.config_dir().join("themes");
1349 let theme_path = themes_dir.join(format!("{}.json", name));
1350
1351 if let Ok(canonical) = theme_path.canonicalize() {
1353 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1354 if canonical.starts_with(&themes_canonical) {
1355 return std::fs::remove_file(&canonical).is_ok();
1356 }
1357 }
1358 }
1359 false
1360 }
1361
1362 pub fn delete_theme(&self, name: String) -> bool {
1364 self.delete_theme_sync(name)
1365 }
1366
1367 pub fn file_stat<'js>(
1371 &self,
1372 ctx: rquickjs::Ctx<'js>,
1373 path: String,
1374 ) -> rquickjs::Result<Value<'js>> {
1375 let metadata = std::fs::metadata(&path).ok();
1376 let stat = metadata.map(|m| {
1377 serde_json::json!({
1378 "isFile": m.is_file(),
1379 "isDir": m.is_dir(),
1380 "size": m.len(),
1381 "readonly": m.permissions().readonly(),
1382 })
1383 });
1384 rquickjs_serde::to_value(ctx, &stat)
1385 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1386 }
1387
1388 pub fn is_process_running(&self, _process_id: u64) -> bool {
1392 false
1395 }
1396
1397 pub fn kill_process(&self, process_id: u64) -> bool {
1399 self.command_sender
1400 .send(PluginCommand::KillBackgroundProcess { process_id })
1401 .is_ok()
1402 }
1403
1404 pub fn plugin_translate<'js>(
1408 &self,
1409 _ctx: rquickjs::Ctx<'js>,
1410 plugin_name: String,
1411 key: String,
1412 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1413 ) -> String {
1414 let args_map: HashMap<String, String> = args
1415 .0
1416 .map(|obj| {
1417 let mut map = HashMap::new();
1418 for (k, v) in obj.props::<String, String>().flatten() {
1419 map.insert(k, v);
1420 }
1421 map
1422 })
1423 .unwrap_or_default();
1424
1425 self.services.translate(&plugin_name, &key, &args_map)
1426 }
1427
1428 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1435 #[qjs(rename = "_createCompositeBufferStart")]
1436 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1437 let id = {
1438 let mut id_ref = self.next_request_id.borrow_mut();
1439 let id = *id_ref;
1440 *id_ref += 1;
1441 self.callback_contexts
1443 .borrow_mut()
1444 .insert(id, self.plugin_name.clone());
1445 id
1446 };
1447
1448 let _ = self
1449 .command_sender
1450 .send(PluginCommand::CreateCompositeBuffer {
1451 name: opts.name,
1452 mode: opts.mode,
1453 layout: opts.layout,
1454 sources: opts.sources,
1455 hunks: opts.hunks,
1456 request_id: Some(id),
1457 });
1458
1459 id
1460 }
1461
1462 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1466 self.command_sender
1467 .send(PluginCommand::UpdateCompositeAlignment {
1468 buffer_id: BufferId(buffer_id as usize),
1469 hunks,
1470 })
1471 .is_ok()
1472 }
1473
1474 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1476 self.command_sender
1477 .send(PluginCommand::CloseCompositeBuffer {
1478 buffer_id: BufferId(buffer_id as usize),
1479 })
1480 .is_ok()
1481 }
1482
1483 #[plugin_api(
1487 async_promise,
1488 js_name = "getHighlights",
1489 ts_return = "TsHighlightSpan[]"
1490 )]
1491 #[qjs(rename = "_getHighlightsStart")]
1492 pub fn get_highlights_start<'js>(
1493 &self,
1494 _ctx: rquickjs::Ctx<'js>,
1495 buffer_id: u32,
1496 start: u32,
1497 end: u32,
1498 ) -> rquickjs::Result<u64> {
1499 let id = {
1500 let mut id_ref = self.next_request_id.borrow_mut();
1501 let id = *id_ref;
1502 *id_ref += 1;
1503 self.callback_contexts
1505 .borrow_mut()
1506 .insert(id, self.plugin_name.clone());
1507 id
1508 };
1509
1510 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1511 buffer_id: BufferId(buffer_id as usize),
1512 range: (start as usize)..(end as usize),
1513 request_id: id,
1514 });
1515
1516 Ok(id)
1517 }
1518
1519 pub fn add_overlay<'js>(
1541 &self,
1542 _ctx: rquickjs::Ctx<'js>,
1543 buffer_id: u32,
1544 namespace: String,
1545 start: u32,
1546 end: u32,
1547 options: rquickjs::Object<'js>,
1548 ) -> rquickjs::Result<bool> {
1549 use fresh_core::api::OverlayColorSpec;
1550
1551 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1553 if let Ok(theme_key) = obj.get::<_, String>(key) {
1555 if !theme_key.is_empty() {
1556 return Some(OverlayColorSpec::ThemeKey(theme_key));
1557 }
1558 }
1559 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1561 if arr.len() >= 3 {
1562 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1563 }
1564 }
1565 None
1566 }
1567
1568 let fg = parse_color_spec("fg", &options);
1569 let bg = parse_color_spec("bg", &options);
1570 let underline: bool = options.get("underline").unwrap_or(false);
1571 let bold: bool = options.get("bold").unwrap_or(false);
1572 let italic: bool = options.get("italic").unwrap_or(false);
1573 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
1574 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1575 let url: Option<String> = options.get("url").ok();
1576
1577 let options = OverlayOptions {
1578 fg,
1579 bg,
1580 underline,
1581 bold,
1582 italic,
1583 strikethrough,
1584 extend_to_line_end,
1585 url,
1586 };
1587
1588 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1589 buffer_id: BufferId(buffer_id as usize),
1590 namespace: Some(OverlayNamespace::from_string(namespace)),
1591 range: (start as usize)..(end as usize),
1592 options,
1593 });
1594
1595 Ok(true)
1596 }
1597
1598 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1600 self.command_sender
1601 .send(PluginCommand::ClearNamespace {
1602 buffer_id: BufferId(buffer_id as usize),
1603 namespace: OverlayNamespace::from_string(namespace),
1604 })
1605 .is_ok()
1606 }
1607
1608 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1610 self.command_sender
1611 .send(PluginCommand::ClearAllOverlays {
1612 buffer_id: BufferId(buffer_id as usize),
1613 })
1614 .is_ok()
1615 }
1616
1617 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1619 self.command_sender
1620 .send(PluginCommand::ClearOverlaysInRange {
1621 buffer_id: BufferId(buffer_id as usize),
1622 start: start as usize,
1623 end: end as usize,
1624 })
1625 .is_ok()
1626 }
1627
1628 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1630 use fresh_core::overlay::OverlayHandle;
1631 self.command_sender
1632 .send(PluginCommand::RemoveOverlay {
1633 buffer_id: BufferId(buffer_id as usize),
1634 handle: OverlayHandle(handle),
1635 })
1636 .is_ok()
1637 }
1638
1639 pub fn add_conceal(
1643 &self,
1644 buffer_id: u32,
1645 namespace: String,
1646 start: u32,
1647 end: u32,
1648 replacement: Option<String>,
1649 ) -> bool {
1650 self.command_sender
1651 .send(PluginCommand::AddConceal {
1652 buffer_id: BufferId(buffer_id as usize),
1653 namespace: OverlayNamespace::from_string(namespace),
1654 start: start as usize,
1655 end: end as usize,
1656 replacement,
1657 })
1658 .is_ok()
1659 }
1660
1661 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1663 self.command_sender
1664 .send(PluginCommand::ClearConcealNamespace {
1665 buffer_id: BufferId(buffer_id as usize),
1666 namespace: OverlayNamespace::from_string(namespace),
1667 })
1668 .is_ok()
1669 }
1670
1671 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1673 self.command_sender
1674 .send(PluginCommand::ClearConcealsInRange {
1675 buffer_id: BufferId(buffer_id as usize),
1676 start: start as usize,
1677 end: end as usize,
1678 })
1679 .is_ok()
1680 }
1681
1682 pub fn add_soft_break(
1686 &self,
1687 buffer_id: u32,
1688 namespace: String,
1689 position: u32,
1690 indent: u32,
1691 ) -> bool {
1692 self.command_sender
1693 .send(PluginCommand::AddSoftBreak {
1694 buffer_id: BufferId(buffer_id as usize),
1695 namespace: OverlayNamespace::from_string(namespace),
1696 position: position as usize,
1697 indent: indent as u16,
1698 })
1699 .is_ok()
1700 }
1701
1702 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1704 self.command_sender
1705 .send(PluginCommand::ClearSoftBreakNamespace {
1706 buffer_id: BufferId(buffer_id as usize),
1707 namespace: OverlayNamespace::from_string(namespace),
1708 })
1709 .is_ok()
1710 }
1711
1712 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1714 self.command_sender
1715 .send(PluginCommand::ClearSoftBreaksInRange {
1716 buffer_id: BufferId(buffer_id as usize),
1717 start: start as usize,
1718 end: end as usize,
1719 })
1720 .is_ok()
1721 }
1722
1723 #[allow(clippy::too_many_arguments)]
1733 pub fn submit_view_transform<'js>(
1734 &self,
1735 _ctx: rquickjs::Ctx<'js>,
1736 buffer_id: u32,
1737 split_id: Option<u32>,
1738 start: u32,
1739 end: u32,
1740 tokens: Vec<rquickjs::Object<'js>>,
1741 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1742 ) -> rquickjs::Result<bool> {
1743 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1744
1745 let tokens: Vec<ViewTokenWire> = tokens
1746 .into_iter()
1747 .enumerate()
1748 .map(|(idx, obj)| {
1749 parse_view_token(&obj, idx)
1751 })
1752 .collect::<rquickjs::Result<Vec<_>>>()?;
1753
1754 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
1756 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
1757 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
1758 Some(LayoutHints {
1759 compose_width,
1760 column_guides,
1761 })
1762 } else {
1763 None
1764 };
1765
1766 let payload = ViewTransformPayload {
1767 range: (start as usize)..(end as usize),
1768 tokens,
1769 layout_hints: parsed_layout_hints,
1770 };
1771
1772 Ok(self
1773 .command_sender
1774 .send(PluginCommand::SubmitViewTransform {
1775 buffer_id: BufferId(buffer_id as usize),
1776 split_id: split_id.map(|id| SplitId(id as usize)),
1777 payload,
1778 })
1779 .is_ok())
1780 }
1781
1782 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1784 self.command_sender
1785 .send(PluginCommand::ClearViewTransform {
1786 buffer_id: BufferId(buffer_id as usize),
1787 split_id: split_id.map(|id| SplitId(id as usize)),
1788 })
1789 .is_ok()
1790 }
1791
1792 pub fn set_layout_hints<'js>(
1795 &self,
1796 buffer_id: u32,
1797 split_id: Option<u32>,
1798 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
1799 ) -> rquickjs::Result<bool> {
1800 use fresh_core::api::LayoutHints;
1801
1802 let compose_width: Option<u16> = hints.get("composeWidth").ok();
1803 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
1804 let parsed_hints = LayoutHints {
1805 compose_width,
1806 column_guides,
1807 };
1808
1809 Ok(self
1810 .command_sender
1811 .send(PluginCommand::SetLayoutHints {
1812 buffer_id: BufferId(buffer_id as usize),
1813 split_id: split_id.map(|id| SplitId(id as usize)),
1814 range: 0..0,
1815 hints: parsed_hints,
1816 })
1817 .is_ok())
1818 }
1819
1820 pub fn set_file_explorer_decorations<'js>(
1824 &self,
1825 _ctx: rquickjs::Ctx<'js>,
1826 namespace: String,
1827 decorations: Vec<rquickjs::Object<'js>>,
1828 ) -> rquickjs::Result<bool> {
1829 use fresh_core::file_explorer::FileExplorerDecoration;
1830
1831 let decorations: Vec<FileExplorerDecoration> = decorations
1832 .into_iter()
1833 .map(|obj| {
1834 let path: String = obj.get("path")?;
1835 let symbol: String = obj.get("symbol")?;
1836 let color: Vec<u8> = obj.get("color")?;
1837 let priority: i32 = obj.get("priority").unwrap_or(0);
1838
1839 if color.len() < 3 {
1840 return Err(rquickjs::Error::FromJs {
1841 from: "array",
1842 to: "color",
1843 message: Some(format!(
1844 "color array must have at least 3 elements, got {}",
1845 color.len()
1846 )),
1847 });
1848 }
1849
1850 Ok(FileExplorerDecoration {
1851 path: std::path::PathBuf::from(path),
1852 symbol,
1853 color: [color[0], color[1], color[2]],
1854 priority,
1855 })
1856 })
1857 .collect::<rquickjs::Result<Vec<_>>>()?;
1858
1859 Ok(self
1860 .command_sender
1861 .send(PluginCommand::SetFileExplorerDecorations {
1862 namespace,
1863 decorations,
1864 })
1865 .is_ok())
1866 }
1867
1868 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1870 self.command_sender
1871 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1872 .is_ok()
1873 }
1874
1875 #[allow(clippy::too_many_arguments)]
1879 pub fn add_virtual_text(
1880 &self,
1881 buffer_id: u32,
1882 virtual_text_id: String,
1883 position: u32,
1884 text: String,
1885 r: u8,
1886 g: u8,
1887 b: u8,
1888 before: bool,
1889 use_bg: bool,
1890 ) -> bool {
1891 self.command_sender
1892 .send(PluginCommand::AddVirtualText {
1893 buffer_id: BufferId(buffer_id as usize),
1894 virtual_text_id,
1895 position: position as usize,
1896 text,
1897 color: (r, g, b),
1898 use_bg,
1899 before,
1900 })
1901 .is_ok()
1902 }
1903
1904 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1906 self.command_sender
1907 .send(PluginCommand::RemoveVirtualText {
1908 buffer_id: BufferId(buffer_id as usize),
1909 virtual_text_id,
1910 })
1911 .is_ok()
1912 }
1913
1914 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1916 self.command_sender
1917 .send(PluginCommand::RemoveVirtualTextsByPrefix {
1918 buffer_id: BufferId(buffer_id as usize),
1919 prefix,
1920 })
1921 .is_ok()
1922 }
1923
1924 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1926 self.command_sender
1927 .send(PluginCommand::ClearVirtualTexts {
1928 buffer_id: BufferId(buffer_id as usize),
1929 })
1930 .is_ok()
1931 }
1932
1933 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1935 self.command_sender
1936 .send(PluginCommand::ClearVirtualTextNamespace {
1937 buffer_id: BufferId(buffer_id as usize),
1938 namespace,
1939 })
1940 .is_ok()
1941 }
1942
1943 #[allow(clippy::too_many_arguments)]
1945 pub fn add_virtual_line(
1946 &self,
1947 buffer_id: u32,
1948 position: u32,
1949 text: String,
1950 fg_r: u8,
1951 fg_g: u8,
1952 fg_b: u8,
1953 bg_r: u8,
1954 bg_g: u8,
1955 bg_b: u8,
1956 above: bool,
1957 namespace: String,
1958 priority: i32,
1959 ) -> bool {
1960 self.command_sender
1961 .send(PluginCommand::AddVirtualLine {
1962 buffer_id: BufferId(buffer_id as usize),
1963 position: position as usize,
1964 text,
1965 fg_color: (fg_r, fg_g, fg_b),
1966 bg_color: Some((bg_r, bg_g, bg_b)),
1967 above,
1968 namespace,
1969 priority,
1970 })
1971 .is_ok()
1972 }
1973
1974 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1979 #[qjs(rename = "_promptStart")]
1980 pub fn prompt_start(
1981 &self,
1982 _ctx: rquickjs::Ctx<'_>,
1983 label: String,
1984 initial_value: String,
1985 ) -> u64 {
1986 let id = {
1987 let mut id_ref = self.next_request_id.borrow_mut();
1988 let id = *id_ref;
1989 *id_ref += 1;
1990 self.callback_contexts
1992 .borrow_mut()
1993 .insert(id, self.plugin_name.clone());
1994 id
1995 };
1996
1997 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1998 label,
1999 initial_value,
2000 callback_id: JsCallbackId::new(id),
2001 });
2002
2003 id
2004 }
2005
2006 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2008 self.command_sender
2009 .send(PluginCommand::StartPrompt { label, prompt_type })
2010 .is_ok()
2011 }
2012
2013 pub fn start_prompt_with_initial(
2015 &self,
2016 label: String,
2017 prompt_type: String,
2018 initial_value: String,
2019 ) -> bool {
2020 self.command_sender
2021 .send(PluginCommand::StartPromptWithInitial {
2022 label,
2023 prompt_type,
2024 initial_value,
2025 })
2026 .is_ok()
2027 }
2028
2029 pub fn set_prompt_suggestions(
2033 &self,
2034 suggestions: Vec<fresh_core::command::Suggestion>,
2035 ) -> bool {
2036 self.command_sender
2037 .send(PluginCommand::SetPromptSuggestions { suggestions })
2038 .is_ok()
2039 }
2040
2041 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2042 self.command_sender
2043 .send(PluginCommand::SetPromptInputSync { sync })
2044 .is_ok()
2045 }
2046
2047 pub fn define_mode(
2051 &self,
2052 name: String,
2053 parent: Option<String>,
2054 bindings_arr: Vec<Vec<String>>,
2055 read_only: rquickjs::function::Opt<bool>,
2056 ) -> bool {
2057 let bindings: Vec<(String, String)> = bindings_arr
2058 .into_iter()
2059 .filter_map(|arr| {
2060 if arr.len() >= 2 {
2061 Some((arr[0].clone(), arr[1].clone()))
2062 } else {
2063 None
2064 }
2065 })
2066 .collect();
2067
2068 {
2071 let mut registered = self.registered_actions.borrow_mut();
2072 for (_, cmd_name) in &bindings {
2073 registered.insert(
2074 cmd_name.clone(),
2075 PluginHandler {
2076 plugin_name: self.plugin_name.clone(),
2077 handler_name: cmd_name.clone(),
2078 },
2079 );
2080 }
2081 }
2082
2083 self.command_sender
2084 .send(PluginCommand::DefineMode {
2085 name,
2086 parent,
2087 bindings,
2088 read_only: read_only.0.unwrap_or(false),
2089 })
2090 .is_ok()
2091 }
2092
2093 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2095 self.command_sender
2096 .send(PluginCommand::SetEditorMode { mode })
2097 .is_ok()
2098 }
2099
2100 pub fn get_editor_mode(&self) -> Option<String> {
2102 self.state_snapshot
2103 .read()
2104 .ok()
2105 .and_then(|s| s.editor_mode.clone())
2106 }
2107
2108 pub fn close_split(&self, split_id: u32) -> bool {
2112 self.command_sender
2113 .send(PluginCommand::CloseSplit {
2114 split_id: SplitId(split_id as usize),
2115 })
2116 .is_ok()
2117 }
2118
2119 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2121 self.command_sender
2122 .send(PluginCommand::SetSplitBuffer {
2123 split_id: SplitId(split_id as usize),
2124 buffer_id: BufferId(buffer_id as usize),
2125 })
2126 .is_ok()
2127 }
2128
2129 pub fn focus_split(&self, split_id: u32) -> bool {
2131 self.command_sender
2132 .send(PluginCommand::FocusSplit {
2133 split_id: SplitId(split_id as usize),
2134 })
2135 .is_ok()
2136 }
2137
2138 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2140 self.command_sender
2141 .send(PluginCommand::SetSplitScroll {
2142 split_id: SplitId(split_id as usize),
2143 top_byte: top_byte as usize,
2144 })
2145 .is_ok()
2146 }
2147
2148 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2150 self.command_sender
2151 .send(PluginCommand::SetSplitRatio {
2152 split_id: SplitId(split_id as usize),
2153 ratio,
2154 })
2155 .is_ok()
2156 }
2157
2158 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2160 self.command_sender
2161 .send(PluginCommand::SetSplitLabel {
2162 split_id: SplitId(split_id as usize),
2163 label,
2164 })
2165 .is_ok()
2166 }
2167
2168 pub fn clear_split_label(&self, split_id: u32) -> bool {
2170 self.command_sender
2171 .send(PluginCommand::ClearSplitLabel {
2172 split_id: SplitId(split_id as usize),
2173 })
2174 .is_ok()
2175 }
2176
2177 #[plugin_api(
2179 async_promise,
2180 js_name = "getSplitByLabel",
2181 ts_return = "number | null"
2182 )]
2183 #[qjs(rename = "_getSplitByLabelStart")]
2184 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2185 let id = {
2186 let mut id_ref = self.next_request_id.borrow_mut();
2187 let id = *id_ref;
2188 *id_ref += 1;
2189 self.callback_contexts
2190 .borrow_mut()
2191 .insert(id, self.plugin_name.clone());
2192 id
2193 };
2194 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2195 label,
2196 request_id: id,
2197 });
2198 id
2199 }
2200
2201 pub fn distribute_splits_evenly(&self) -> bool {
2203 self.command_sender
2205 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2206 .is_ok()
2207 }
2208
2209 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2211 self.command_sender
2212 .send(PluginCommand::SetBufferCursor {
2213 buffer_id: BufferId(buffer_id as usize),
2214 position: position as usize,
2215 })
2216 .is_ok()
2217 }
2218
2219 #[allow(clippy::too_many_arguments)]
2223 pub fn set_line_indicator(
2224 &self,
2225 buffer_id: u32,
2226 line: u32,
2227 namespace: String,
2228 symbol: String,
2229 r: u8,
2230 g: u8,
2231 b: u8,
2232 priority: i32,
2233 ) -> bool {
2234 self.command_sender
2235 .send(PluginCommand::SetLineIndicator {
2236 buffer_id: BufferId(buffer_id as usize),
2237 line: line as usize,
2238 namespace,
2239 symbol,
2240 color: (r, g, b),
2241 priority,
2242 })
2243 .is_ok()
2244 }
2245
2246 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2248 self.command_sender
2249 .send(PluginCommand::ClearLineIndicators {
2250 buffer_id: BufferId(buffer_id as usize),
2251 namespace,
2252 })
2253 .is_ok()
2254 }
2255
2256 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2258 self.command_sender
2259 .send(PluginCommand::SetLineNumbers {
2260 buffer_id: BufferId(buffer_id as usize),
2261 enabled,
2262 })
2263 .is_ok()
2264 }
2265
2266 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2268 self.command_sender
2269 .send(PluginCommand::SetViewMode {
2270 buffer_id: BufferId(buffer_id as usize),
2271 mode,
2272 })
2273 .is_ok()
2274 }
2275
2276 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2278 self.command_sender
2279 .send(PluginCommand::SetLineWrap {
2280 buffer_id: BufferId(buffer_id as usize),
2281 split_id: split_id.map(|s| SplitId(s as usize)),
2282 enabled,
2283 })
2284 .is_ok()
2285 }
2286
2287 pub fn set_view_state<'js>(
2291 &self,
2292 ctx: rquickjs::Ctx<'js>,
2293 buffer_id: u32,
2294 key: String,
2295 value: Value<'js>,
2296 ) -> bool {
2297 let bid = BufferId(buffer_id as usize);
2298
2299 let json_value = if value.is_undefined() || value.is_null() {
2301 None
2302 } else {
2303 Some(js_to_json(&ctx, value))
2304 };
2305
2306 if let Ok(mut snapshot) = self.state_snapshot.write() {
2308 if let Some(ref json_val) = json_value {
2309 snapshot
2310 .plugin_view_states
2311 .entry(bid)
2312 .or_default()
2313 .insert(key.clone(), json_val.clone());
2314 } else {
2315 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2317 map.remove(&key);
2318 if map.is_empty() {
2319 snapshot.plugin_view_states.remove(&bid);
2320 }
2321 }
2322 }
2323 }
2324
2325 self.command_sender
2327 .send(PluginCommand::SetViewState {
2328 buffer_id: bid,
2329 key,
2330 value: json_value,
2331 })
2332 .is_ok()
2333 }
2334
2335 pub fn get_view_state<'js>(
2337 &self,
2338 ctx: rquickjs::Ctx<'js>,
2339 buffer_id: u32,
2340 key: String,
2341 ) -> rquickjs::Result<Value<'js>> {
2342 let bid = BufferId(buffer_id as usize);
2343 if let Ok(snapshot) = self.state_snapshot.read() {
2344 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2345 if let Some(json_val) = map.get(&key) {
2346 return json_to_js_value(&ctx, json_val);
2347 }
2348 }
2349 }
2350 Ok(Value::new_undefined(ctx.clone()))
2351 }
2352
2353 pub fn create_scroll_sync_group(
2357 &self,
2358 group_id: u32,
2359 left_split: u32,
2360 right_split: u32,
2361 ) -> bool {
2362 self.command_sender
2363 .send(PluginCommand::CreateScrollSyncGroup {
2364 group_id,
2365 left_split: SplitId(left_split as usize),
2366 right_split: SplitId(right_split as usize),
2367 })
2368 .is_ok()
2369 }
2370
2371 pub fn set_scroll_sync_anchors<'js>(
2373 &self,
2374 _ctx: rquickjs::Ctx<'js>,
2375 group_id: u32,
2376 anchors: Vec<Vec<u32>>,
2377 ) -> bool {
2378 let anchors: Vec<(usize, usize)> = anchors
2379 .into_iter()
2380 .filter_map(|pair| {
2381 if pair.len() >= 2 {
2382 Some((pair[0] as usize, pair[1] as usize))
2383 } else {
2384 None
2385 }
2386 })
2387 .collect();
2388 self.command_sender
2389 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2390 .is_ok()
2391 }
2392
2393 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2395 self.command_sender
2396 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2397 .is_ok()
2398 }
2399
2400 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2406 self.command_sender
2407 .send(PluginCommand::ExecuteActions { actions })
2408 .is_ok()
2409 }
2410
2411 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2415 self.command_sender
2416 .send(PluginCommand::ShowActionPopup {
2417 popup_id: opts.id,
2418 title: opts.title,
2419 message: opts.message,
2420 actions: opts.actions,
2421 })
2422 .is_ok()
2423 }
2424
2425 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2427 self.command_sender
2428 .send(PluginCommand::DisableLspForLanguage { language })
2429 .is_ok()
2430 }
2431
2432 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2435 self.command_sender
2436 .send(PluginCommand::SetLspRootUri { language, uri })
2437 .is_ok()
2438 }
2439
2440 #[plugin_api(ts_return = "JsDiagnostic[]")]
2442 pub fn get_all_diagnostics<'js>(
2443 &self,
2444 ctx: rquickjs::Ctx<'js>,
2445 ) -> rquickjs::Result<Value<'js>> {
2446 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2447
2448 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2449 let mut result: Vec<JsDiagnostic> = Vec::new();
2451 for (uri, diags) in &s.diagnostics {
2452 for diag in diags {
2453 result.push(JsDiagnostic {
2454 uri: uri.clone(),
2455 message: diag.message.clone(),
2456 severity: diag.severity.map(|s| match s {
2457 lsp_types::DiagnosticSeverity::ERROR => 1,
2458 lsp_types::DiagnosticSeverity::WARNING => 2,
2459 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2460 lsp_types::DiagnosticSeverity::HINT => 4,
2461 _ => 0,
2462 }),
2463 range: JsRange {
2464 start: JsPosition {
2465 line: diag.range.start.line,
2466 character: diag.range.start.character,
2467 },
2468 end: JsPosition {
2469 line: diag.range.end.line,
2470 character: diag.range.end.character,
2471 },
2472 },
2473 source: diag.source.clone(),
2474 });
2475 }
2476 }
2477 result
2478 } else {
2479 Vec::new()
2480 };
2481 rquickjs_serde::to_value(ctx, &diagnostics)
2482 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2483 }
2484
2485 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2487 self.event_handlers
2488 .borrow()
2489 .get(&event_name)
2490 .cloned()
2491 .unwrap_or_default()
2492 .into_iter()
2493 .map(|h| h.handler_name)
2494 .collect()
2495 }
2496
2497 #[plugin_api(
2501 async_promise,
2502 js_name = "createVirtualBuffer",
2503 ts_return = "VirtualBufferResult"
2504 )]
2505 #[qjs(rename = "_createVirtualBufferStart")]
2506 pub fn create_virtual_buffer_start(
2507 &self,
2508 _ctx: rquickjs::Ctx<'_>,
2509 opts: fresh_core::api::CreateVirtualBufferOptions,
2510 ) -> rquickjs::Result<u64> {
2511 let id = {
2512 let mut id_ref = self.next_request_id.borrow_mut();
2513 let id = *id_ref;
2514 *id_ref += 1;
2515 self.callback_contexts
2517 .borrow_mut()
2518 .insert(id, self.plugin_name.clone());
2519 id
2520 };
2521
2522 let entries: Vec<TextPropertyEntry> = opts
2524 .entries
2525 .unwrap_or_default()
2526 .into_iter()
2527 .map(|e| TextPropertyEntry {
2528 text: e.text,
2529 properties: e.properties.unwrap_or_default(),
2530 })
2531 .collect();
2532
2533 tracing::debug!(
2534 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2535 id
2536 );
2537 let _ = self
2538 .command_sender
2539 .send(PluginCommand::CreateVirtualBufferWithContent {
2540 name: opts.name,
2541 mode: opts.mode.unwrap_or_default(),
2542 read_only: opts.read_only.unwrap_or(false),
2543 entries,
2544 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2545 show_cursors: opts.show_cursors.unwrap_or(true),
2546 editing_disabled: opts.editing_disabled.unwrap_or(false),
2547 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2548 request_id: Some(id),
2549 });
2550 Ok(id)
2551 }
2552
2553 #[plugin_api(
2555 async_promise,
2556 js_name = "createVirtualBufferInSplit",
2557 ts_return = "VirtualBufferResult"
2558 )]
2559 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2560 pub fn create_virtual_buffer_in_split_start(
2561 &self,
2562 _ctx: rquickjs::Ctx<'_>,
2563 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2564 ) -> rquickjs::Result<u64> {
2565 let id = {
2566 let mut id_ref = self.next_request_id.borrow_mut();
2567 let id = *id_ref;
2568 *id_ref += 1;
2569 self.callback_contexts
2571 .borrow_mut()
2572 .insert(id, self.plugin_name.clone());
2573 id
2574 };
2575
2576 let entries: Vec<TextPropertyEntry> = opts
2578 .entries
2579 .unwrap_or_default()
2580 .into_iter()
2581 .map(|e| TextPropertyEntry {
2582 text: e.text,
2583 properties: e.properties.unwrap_or_default(),
2584 })
2585 .collect();
2586
2587 let _ = self
2588 .command_sender
2589 .send(PluginCommand::CreateVirtualBufferInSplit {
2590 name: opts.name,
2591 mode: opts.mode.unwrap_or_default(),
2592 read_only: opts.read_only.unwrap_or(false),
2593 entries,
2594 ratio: opts.ratio.unwrap_or(0.5),
2595 direction: opts.direction,
2596 panel_id: opts.panel_id,
2597 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2598 show_cursors: opts.show_cursors.unwrap_or(true),
2599 editing_disabled: opts.editing_disabled.unwrap_or(false),
2600 line_wrap: opts.line_wrap,
2601 before: opts.before.unwrap_or(false),
2602 request_id: Some(id),
2603 });
2604 Ok(id)
2605 }
2606
2607 #[plugin_api(
2609 async_promise,
2610 js_name = "createVirtualBufferInExistingSplit",
2611 ts_return = "VirtualBufferResult"
2612 )]
2613 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2614 pub fn create_virtual_buffer_in_existing_split_start(
2615 &self,
2616 _ctx: rquickjs::Ctx<'_>,
2617 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2618 ) -> rquickjs::Result<u64> {
2619 let id = {
2620 let mut id_ref = self.next_request_id.borrow_mut();
2621 let id = *id_ref;
2622 *id_ref += 1;
2623 self.callback_contexts
2625 .borrow_mut()
2626 .insert(id, self.plugin_name.clone());
2627 id
2628 };
2629
2630 let entries: Vec<TextPropertyEntry> = opts
2632 .entries
2633 .unwrap_or_default()
2634 .into_iter()
2635 .map(|e| TextPropertyEntry {
2636 text: e.text,
2637 properties: e.properties.unwrap_or_default(),
2638 })
2639 .collect();
2640
2641 let _ = self
2642 .command_sender
2643 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2644 name: opts.name,
2645 mode: opts.mode.unwrap_or_default(),
2646 read_only: opts.read_only.unwrap_or(false),
2647 entries,
2648 split_id: SplitId(opts.split_id),
2649 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2650 show_cursors: opts.show_cursors.unwrap_or(true),
2651 editing_disabled: opts.editing_disabled.unwrap_or(false),
2652 line_wrap: opts.line_wrap,
2653 request_id: Some(id),
2654 });
2655 Ok(id)
2656 }
2657
2658 pub fn set_virtual_buffer_content<'js>(
2662 &self,
2663 ctx: rquickjs::Ctx<'js>,
2664 buffer_id: u32,
2665 entries_arr: Vec<rquickjs::Object<'js>>,
2666 ) -> rquickjs::Result<bool> {
2667 let entries: Vec<TextPropertyEntry> = entries_arr
2668 .iter()
2669 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2670 .collect();
2671 Ok(self
2672 .command_sender
2673 .send(PluginCommand::SetVirtualBufferContent {
2674 buffer_id: BufferId(buffer_id as usize),
2675 entries,
2676 })
2677 .is_ok())
2678 }
2679
2680 pub fn get_text_properties_at_cursor(
2682 &self,
2683 buffer_id: u32,
2684 ) -> fresh_core::api::TextPropertiesAtCursor {
2685 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2686 }
2687
2688 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2692 #[qjs(rename = "_spawnProcessStart")]
2693 pub fn spawn_process_start(
2694 &self,
2695 _ctx: rquickjs::Ctx<'_>,
2696 command: String,
2697 args: Vec<String>,
2698 cwd: rquickjs::function::Opt<String>,
2699 ) -> u64 {
2700 let id = {
2701 let mut id_ref = self.next_request_id.borrow_mut();
2702 let id = *id_ref;
2703 *id_ref += 1;
2704 self.callback_contexts
2706 .borrow_mut()
2707 .insert(id, self.plugin_name.clone());
2708 id
2709 };
2710 let effective_cwd = cwd.0.or_else(|| {
2712 self.state_snapshot
2713 .read()
2714 .ok()
2715 .map(|s| s.working_dir.to_string_lossy().to_string())
2716 });
2717 tracing::info!(
2718 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
2719 self.plugin_name,
2720 command,
2721 args,
2722 effective_cwd,
2723 id
2724 );
2725 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2726 callback_id: JsCallbackId::new(id),
2727 command,
2728 args,
2729 cwd: effective_cwd,
2730 });
2731 id
2732 }
2733
2734 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2736 #[qjs(rename = "_spawnProcessWaitStart")]
2737 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2738 let id = {
2739 let mut id_ref = self.next_request_id.borrow_mut();
2740 let id = *id_ref;
2741 *id_ref += 1;
2742 self.callback_contexts
2744 .borrow_mut()
2745 .insert(id, self.plugin_name.clone());
2746 id
2747 };
2748 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2749 process_id,
2750 callback_id: JsCallbackId::new(id),
2751 });
2752 id
2753 }
2754
2755 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2757 #[qjs(rename = "_getBufferTextStart")]
2758 pub fn get_buffer_text_start(
2759 &self,
2760 _ctx: rquickjs::Ctx<'_>,
2761 buffer_id: u32,
2762 start: u32,
2763 end: u32,
2764 ) -> u64 {
2765 let id = {
2766 let mut id_ref = self.next_request_id.borrow_mut();
2767 let id = *id_ref;
2768 *id_ref += 1;
2769 self.callback_contexts
2771 .borrow_mut()
2772 .insert(id, self.plugin_name.clone());
2773 id
2774 };
2775 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2776 buffer_id: BufferId(buffer_id as usize),
2777 start: start as usize,
2778 end: end as usize,
2779 request_id: id,
2780 });
2781 id
2782 }
2783
2784 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2786 #[qjs(rename = "_delayStart")]
2787 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2788 let id = {
2789 let mut id_ref = self.next_request_id.borrow_mut();
2790 let id = *id_ref;
2791 *id_ref += 1;
2792 self.callback_contexts
2794 .borrow_mut()
2795 .insert(id, self.plugin_name.clone());
2796 id
2797 };
2798 let _ = self.command_sender.send(PluginCommand::Delay {
2799 callback_id: JsCallbackId::new(id),
2800 duration_ms,
2801 });
2802 id
2803 }
2804
2805 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2807 #[qjs(rename = "_sendLspRequestStart")]
2808 pub fn send_lsp_request_start<'js>(
2809 &self,
2810 ctx: rquickjs::Ctx<'js>,
2811 language: String,
2812 method: String,
2813 params: Option<rquickjs::Object<'js>>,
2814 ) -> rquickjs::Result<u64> {
2815 let id = {
2816 let mut id_ref = self.next_request_id.borrow_mut();
2817 let id = *id_ref;
2818 *id_ref += 1;
2819 self.callback_contexts
2821 .borrow_mut()
2822 .insert(id, self.plugin_name.clone());
2823 id
2824 };
2825 let params_json: Option<serde_json::Value> = params.map(|obj| {
2827 let val = obj.into_value();
2828 js_to_json(&ctx, val)
2829 });
2830 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2831 request_id: id,
2832 language,
2833 method,
2834 params: params_json,
2835 });
2836 Ok(id)
2837 }
2838
2839 #[plugin_api(
2841 async_thenable,
2842 js_name = "spawnBackgroundProcess",
2843 ts_return = "BackgroundProcessResult"
2844 )]
2845 #[qjs(rename = "_spawnBackgroundProcessStart")]
2846 pub fn spawn_background_process_start(
2847 &self,
2848 _ctx: rquickjs::Ctx<'_>,
2849 command: String,
2850 args: Vec<String>,
2851 cwd: rquickjs::function::Opt<String>,
2852 ) -> u64 {
2853 let id = {
2854 let mut id_ref = self.next_request_id.borrow_mut();
2855 let id = *id_ref;
2856 *id_ref += 1;
2857 self.callback_contexts
2859 .borrow_mut()
2860 .insert(id, self.plugin_name.clone());
2861 id
2862 };
2863 let process_id = id;
2865 let _ = self
2866 .command_sender
2867 .send(PluginCommand::SpawnBackgroundProcess {
2868 process_id,
2869 command,
2870 args,
2871 cwd: cwd.0,
2872 callback_id: JsCallbackId::new(id),
2873 });
2874 id
2875 }
2876
2877 pub fn kill_background_process(&self, process_id: u64) -> bool {
2879 self.command_sender
2880 .send(PluginCommand::KillBackgroundProcess { process_id })
2881 .is_ok()
2882 }
2883
2884 #[plugin_api(
2888 async_promise,
2889 js_name = "createTerminal",
2890 ts_return = "TerminalResult"
2891 )]
2892 #[qjs(rename = "_createTerminalStart")]
2893 pub fn create_terminal_start(
2894 &self,
2895 _ctx: rquickjs::Ctx<'_>,
2896 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
2897 ) -> rquickjs::Result<u64> {
2898 let id = {
2899 let mut id_ref = self.next_request_id.borrow_mut();
2900 let id = *id_ref;
2901 *id_ref += 1;
2902 self.callback_contexts
2903 .borrow_mut()
2904 .insert(id, self.plugin_name.clone());
2905 id
2906 };
2907
2908 let opts = opts
2909 .0
2910 .unwrap_or_else(|| fresh_core::api::CreateTerminalOptions {
2911 cwd: None,
2912 direction: None,
2913 ratio: None,
2914 focus: None,
2915 });
2916
2917 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
2918 cwd: opts.cwd,
2919 direction: opts.direction,
2920 ratio: opts.ratio,
2921 focus: opts.focus,
2922 request_id: id,
2923 });
2924 Ok(id)
2925 }
2926
2927 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
2929 self.command_sender
2930 .send(PluginCommand::SendTerminalInput {
2931 terminal_id: fresh_core::TerminalId(terminal_id as usize),
2932 data,
2933 })
2934 .is_ok()
2935 }
2936
2937 pub fn close_terminal(&self, terminal_id: u64) -> bool {
2939 self.command_sender
2940 .send(PluginCommand::CloseTerminal {
2941 terminal_id: fresh_core::TerminalId(terminal_id as usize),
2942 })
2943 .is_ok()
2944 }
2945
2946 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2950 self.command_sender
2951 .send(PluginCommand::RefreshLines {
2952 buffer_id: BufferId(buffer_id as usize),
2953 })
2954 .is_ok()
2955 }
2956
2957 pub fn get_current_locale(&self) -> String {
2959 self.services.current_locale()
2960 }
2961
2962 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2966 #[qjs(rename = "_loadPluginStart")]
2967 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2968 let id = {
2969 let mut id_ref = self.next_request_id.borrow_mut();
2970 let id = *id_ref;
2971 *id_ref += 1;
2972 self.callback_contexts
2973 .borrow_mut()
2974 .insert(id, self.plugin_name.clone());
2975 id
2976 };
2977 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2978 path: std::path::PathBuf::from(path),
2979 callback_id: JsCallbackId::new(id),
2980 });
2981 id
2982 }
2983
2984 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2986 #[qjs(rename = "_unloadPluginStart")]
2987 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2988 let id = {
2989 let mut id_ref = self.next_request_id.borrow_mut();
2990 let id = *id_ref;
2991 *id_ref += 1;
2992 self.callback_contexts
2993 .borrow_mut()
2994 .insert(id, self.plugin_name.clone());
2995 id
2996 };
2997 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2998 name,
2999 callback_id: JsCallbackId::new(id),
3000 });
3001 id
3002 }
3003
3004 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3006 #[qjs(rename = "_reloadPluginStart")]
3007 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3008 let id = {
3009 let mut id_ref = self.next_request_id.borrow_mut();
3010 let id = *id_ref;
3011 *id_ref += 1;
3012 self.callback_contexts
3013 .borrow_mut()
3014 .insert(id, self.plugin_name.clone());
3015 id
3016 };
3017 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3018 name,
3019 callback_id: JsCallbackId::new(id),
3020 });
3021 id
3022 }
3023
3024 #[plugin_api(
3027 async_promise,
3028 js_name = "listPlugins",
3029 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3030 )]
3031 #[qjs(rename = "_listPluginsStart")]
3032 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3033 let id = {
3034 let mut id_ref = self.next_request_id.borrow_mut();
3035 let id = *id_ref;
3036 *id_ref += 1;
3037 self.callback_contexts
3038 .borrow_mut()
3039 .insert(id, self.plugin_name.clone());
3040 id
3041 };
3042 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3043 callback_id: JsCallbackId::new(id),
3044 });
3045 id
3046 }
3047}
3048
3049fn parse_view_token(
3056 obj: &rquickjs::Object<'_>,
3057 idx: usize,
3058) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3059 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3060
3061 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3063 from: "object",
3064 to: "ViewTokenWire",
3065 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3066 })?;
3067
3068 let source_offset: Option<usize> = obj
3070 .get("sourceOffset")
3071 .ok()
3072 .or_else(|| obj.get("source_offset").ok());
3073
3074 let kind = if kind_value.is_string() {
3076 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3079 from: "value",
3080 to: "string",
3081 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3082 })?;
3083
3084 match kind_str.to_lowercase().as_str() {
3085 "text" => {
3086 let text: String = obj.get("text").unwrap_or_default();
3087 ViewTokenWireKind::Text(text)
3088 }
3089 "newline" => ViewTokenWireKind::Newline,
3090 "space" => ViewTokenWireKind::Space,
3091 "break" => ViewTokenWireKind::Break,
3092 _ => {
3093 tracing::warn!(
3095 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3096 idx, kind_str
3097 );
3098 return Err(rquickjs::Error::FromJs {
3099 from: "string",
3100 to: "ViewTokenWireKind",
3101 message: Some(format!(
3102 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3103 idx, kind_str
3104 )),
3105 });
3106 }
3107 }
3108 } else if kind_value.is_object() {
3109 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3111 from: "value",
3112 to: "object",
3113 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3114 })?;
3115
3116 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3117 ViewTokenWireKind::Text(text)
3118 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3119 ViewTokenWireKind::BinaryByte(byte)
3120 } else {
3121 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3123 tracing::warn!(
3124 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3125 idx,
3126 keys
3127 );
3128 return Err(rquickjs::Error::FromJs {
3129 from: "object",
3130 to: "ViewTokenWireKind",
3131 message: Some(format!(
3132 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3133 idx, keys
3134 )),
3135 });
3136 }
3137 } else {
3138 tracing::warn!(
3139 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3140 idx,
3141 kind_value.type_of()
3142 );
3143 return Err(rquickjs::Error::FromJs {
3144 from: "value",
3145 to: "ViewTokenWireKind",
3146 message: Some(format!(
3147 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3148 idx
3149 )),
3150 });
3151 };
3152
3153 let style = parse_view_token_style(obj, idx)?;
3155
3156 Ok(ViewTokenWire {
3157 source_offset,
3158 kind,
3159 style,
3160 })
3161}
3162
3163fn parse_view_token_style(
3165 obj: &rquickjs::Object<'_>,
3166 idx: usize,
3167) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
3168 use fresh_core::api::ViewTokenStyle;
3169
3170 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
3171 let Some(s) = style_obj else {
3172 return Ok(None);
3173 };
3174
3175 let fg: Option<Vec<u8>> = s.get("fg").ok();
3176 let bg: Option<Vec<u8>> = s.get("bg").ok();
3177
3178 let fg_color = if let Some(ref c) = fg {
3180 if c.len() < 3 {
3181 tracing::warn!(
3182 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
3183 idx,
3184 c.len()
3185 );
3186 None
3187 } else {
3188 Some((c[0], c[1], c[2]))
3189 }
3190 } else {
3191 None
3192 };
3193
3194 let bg_color = if let Some(ref c) = bg {
3195 if c.len() < 3 {
3196 tracing::warn!(
3197 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
3198 idx,
3199 c.len()
3200 );
3201 None
3202 } else {
3203 Some((c[0], c[1], c[2]))
3204 }
3205 } else {
3206 None
3207 };
3208
3209 Ok(Some(ViewTokenStyle {
3210 fg: fg_color,
3211 bg: bg_color,
3212 bold: s.get("bold").unwrap_or(false),
3213 italic: s.get("italic").unwrap_or(false),
3214 }))
3215}
3216
3217pub struct QuickJsBackend {
3219 runtime: Runtime,
3220 main_context: Context,
3222 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
3224 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
3226 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
3228 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3230 command_sender: mpsc::Sender<PluginCommand>,
3232 #[allow(dead_code)]
3234 pending_responses: PendingResponses,
3235 next_request_id: Rc<RefCell<u64>>,
3237 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
3239 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3241}
3242
3243impl QuickJsBackend {
3244 pub fn new() -> Result<Self> {
3246 let (tx, _rx) = mpsc::channel();
3247 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3248 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3249 Self::with_state(state_snapshot, tx, services)
3250 }
3251
3252 pub fn with_state(
3254 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3255 command_sender: mpsc::Sender<PluginCommand>,
3256 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3257 ) -> Result<Self> {
3258 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
3259 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
3260 }
3261
3262 pub fn with_state_and_responses(
3264 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3265 command_sender: mpsc::Sender<PluginCommand>,
3266 pending_responses: PendingResponses,
3267 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3268 ) -> Result<Self> {
3269 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
3270
3271 let runtime =
3272 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
3273
3274 runtime.set_host_promise_rejection_tracker(Some(Box::new(
3276 |_ctx, _promise, reason, is_handled| {
3277 if !is_handled {
3278 let error_msg = if let Some(exc) = reason.as_exception() {
3280 format!(
3281 "{}: {}",
3282 exc.message().unwrap_or_default(),
3283 exc.stack().unwrap_or_default()
3284 )
3285 } else {
3286 format!("{:?}", reason)
3287 };
3288
3289 tracing::error!("Unhandled Promise rejection: {}", error_msg);
3290
3291 if should_panic_on_js_errors() {
3292 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
3295 set_fatal_js_error(full_msg);
3296 }
3297 }
3298 },
3299 )));
3300
3301 let main_context = Context::full(&runtime)
3302 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
3303
3304 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
3305 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
3306 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
3307 let next_request_id = Rc::new(RefCell::new(1u64));
3308 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
3309
3310 let backend = Self {
3311 runtime,
3312 main_context,
3313 plugin_contexts,
3314 event_handlers,
3315 registered_actions,
3316 state_snapshot,
3317 command_sender,
3318 pending_responses,
3319 next_request_id,
3320 callback_contexts,
3321 services,
3322 };
3323
3324 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
3326
3327 tracing::debug!("QuickJsBackend::new: runtime created successfully");
3328 Ok(backend)
3329 }
3330
3331 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
3333 let state_snapshot = Arc::clone(&self.state_snapshot);
3334 let command_sender = self.command_sender.clone();
3335 let event_handlers = Rc::clone(&self.event_handlers);
3336 let registered_actions = Rc::clone(&self.registered_actions);
3337 let next_request_id = Rc::clone(&self.next_request_id);
3338
3339 context.with(|ctx| {
3340 let globals = ctx.globals();
3341
3342 globals.set("__pluginName__", plugin_name)?;
3344
3345 let js_api = JsEditorApi {
3348 state_snapshot: Arc::clone(&state_snapshot),
3349 command_sender: command_sender.clone(),
3350 registered_actions: Rc::clone(®istered_actions),
3351 event_handlers: Rc::clone(&event_handlers),
3352 next_request_id: Rc::clone(&next_request_id),
3353 callback_contexts: Rc::clone(&self.callback_contexts),
3354 services: self.services.clone(),
3355 plugin_name: plugin_name.to_string(),
3356 };
3357 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
3358
3359 globals.set("editor", editor)?;
3361
3362 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
3364
3365 let console = Object::new(ctx.clone())?;
3368 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3369 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3370 tracing::info!("console.log: {}", parts.join(" "));
3371 })?)?;
3372 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3373 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3374 tracing::warn!("console.warn: {}", parts.join(" "));
3375 })?)?;
3376 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3377 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3378 tracing::error!("console.error: {}", parts.join(" "));
3379 })?)?;
3380 globals.set("console", console)?;
3381
3382 ctx.eval::<(), _>(r#"
3384 // Pending promise callbacks: callbackId -> { resolve, reject }
3385 globalThis._pendingCallbacks = new Map();
3386
3387 // Resolve a pending callback (called from Rust)
3388 globalThis._resolveCallback = function(callbackId, result) {
3389 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
3390 const cb = globalThis._pendingCallbacks.get(callbackId);
3391 if (cb) {
3392 console.log('[JS] _resolveCallback: found callback, calling resolve()');
3393 globalThis._pendingCallbacks.delete(callbackId);
3394 cb.resolve(result);
3395 console.log('[JS] _resolveCallback: resolve() called');
3396 } else {
3397 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
3398 }
3399 };
3400
3401 // Reject a pending callback (called from Rust)
3402 globalThis._rejectCallback = function(callbackId, error) {
3403 const cb = globalThis._pendingCallbacks.get(callbackId);
3404 if (cb) {
3405 globalThis._pendingCallbacks.delete(callbackId);
3406 cb.reject(new Error(error));
3407 }
3408 };
3409
3410 // Generic async wrapper decorator
3411 // Wraps a function that returns a callbackId into a promise-returning function
3412 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
3413 // NOTE: We pass the method name as a string and call via bracket notation
3414 // to preserve rquickjs's automatic Ctx injection for methods
3415 globalThis._wrapAsync = function(methodName, fnName) {
3416 const startFn = editor[methodName];
3417 if (typeof startFn !== 'function') {
3418 // Return a function that always throws - catches missing implementations
3419 return function(...args) {
3420 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3421 editor.debug(`[ASYNC ERROR] ${error.message}`);
3422 throw error;
3423 };
3424 }
3425 return function(...args) {
3426 // Call via bracket notation to preserve method binding and Ctx injection
3427 const callbackId = editor[methodName](...args);
3428 return new Promise((resolve, reject) => {
3429 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3430 // TODO: Implement setTimeout polyfill using editor.delay() or similar
3431 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3432 });
3433 };
3434 };
3435
3436 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
3437 // The returned object has .result promise and is itself thenable
3438 globalThis._wrapAsyncThenable = function(methodName, fnName) {
3439 const startFn = editor[methodName];
3440 if (typeof startFn !== 'function') {
3441 // Return a function that always throws - catches missing implementations
3442 return function(...args) {
3443 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3444 editor.debug(`[ASYNC ERROR] ${error.message}`);
3445 throw error;
3446 };
3447 }
3448 return function(...args) {
3449 // Call via bracket notation to preserve method binding and Ctx injection
3450 const callbackId = editor[methodName](...args);
3451 const resultPromise = new Promise((resolve, reject) => {
3452 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3453 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3454 });
3455 return {
3456 get result() { return resultPromise; },
3457 then(onFulfilled, onRejected) {
3458 return resultPromise.then(onFulfilled, onRejected);
3459 },
3460 catch(onRejected) {
3461 return resultPromise.catch(onRejected);
3462 }
3463 };
3464 };
3465 };
3466
3467 // Apply wrappers to async functions on editor
3468 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3469 editor.delay = _wrapAsync("_delayStart", "delay");
3470 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3471 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3472 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3473 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3474 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3475 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3476 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3477 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3478 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3479 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3480 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3481 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3482 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3483 editor.prompt = _wrapAsync("_promptStart", "prompt");
3484 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
3485 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
3486 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
3487
3488 // Wrapper for deleteTheme - wraps sync function in Promise
3489 editor.deleteTheme = function(name) {
3490 return new Promise(function(resolve, reject) {
3491 const success = editor._deleteThemeSync(name);
3492 if (success) {
3493 resolve();
3494 } else {
3495 reject(new Error("Failed to delete theme: " + name));
3496 }
3497 });
3498 };
3499 "#.as_bytes())?;
3500
3501 Ok::<_, rquickjs::Error>(())
3502 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3503
3504 Ok(())
3505 }
3506
3507 pub async fn load_module_with_source(
3509 &mut self,
3510 path: &str,
3511 _plugin_source: &str,
3512 ) -> Result<()> {
3513 let path_buf = PathBuf::from(path);
3514 let source = std::fs::read_to_string(&path_buf)
3515 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3516
3517 let filename = path_buf
3518 .file_name()
3519 .and_then(|s| s.to_str())
3520 .unwrap_or("plugin.ts");
3521
3522 if has_es_imports(&source) {
3524 match bundle_module(&path_buf) {
3526 Ok(bundled) => {
3527 self.execute_js(&bundled, path)?;
3528 }
3529 Err(e) => {
3530 tracing::warn!(
3531 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3532 path,
3533 e
3534 );
3535 return Ok(()); }
3537 }
3538 } else if has_es_module_syntax(&source) {
3539 let stripped = strip_imports_and_exports(&source);
3541 let js_code = if filename.ends_with(".ts") {
3542 transpile_typescript(&stripped, filename)?
3543 } else {
3544 stripped
3545 };
3546 self.execute_js(&js_code, path)?;
3547 } else {
3548 let js_code = if filename.ends_with(".ts") {
3550 transpile_typescript(&source, filename)?
3551 } else {
3552 source
3553 };
3554 self.execute_js(&js_code, path)?;
3555 }
3556
3557 Ok(())
3558 }
3559
3560 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3562 let plugin_name = Path::new(source_name)
3564 .file_stem()
3565 .and_then(|s| s.to_str())
3566 .unwrap_or("unknown");
3567
3568 tracing::debug!(
3569 "execute_js: starting for plugin '{}' from '{}'",
3570 plugin_name,
3571 source_name
3572 );
3573
3574 let context = {
3576 let mut contexts = self.plugin_contexts.borrow_mut();
3577 if let Some(ctx) = contexts.get(plugin_name) {
3578 ctx.clone()
3579 } else {
3580 let ctx = Context::full(&self.runtime).map_err(|e| {
3581 anyhow!(
3582 "Failed to create QuickJS context for plugin {}: {}",
3583 plugin_name,
3584 e
3585 )
3586 })?;
3587 self.setup_context_api(&ctx, plugin_name)?;
3588 contexts.insert(plugin_name.to_string(), ctx.clone());
3589 ctx
3590 }
3591 };
3592
3593 let wrapped_code = format!("(function() {{ {} }})();", code);
3597 let wrapped = wrapped_code.as_str();
3598
3599 context.with(|ctx| {
3600 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3601
3602 let mut eval_options = rquickjs::context::EvalOptions::default();
3604 eval_options.global = true;
3605 eval_options.filename = Some(source_name.to_string());
3606 let result = ctx
3607 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3608 .map_err(|e| format_js_error(&ctx, e, source_name));
3609
3610 tracing::debug!(
3611 "execute_js: plugin code execution finished for '{}', result: {:?}",
3612 plugin_name,
3613 result.is_ok()
3614 );
3615
3616 result
3617 })
3618 }
3619
3620 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3622 let _event_data_str = event_data.to_string();
3623 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3624
3625 self.services
3627 .set_js_execution_state(format!("hook '{}'", event_name));
3628
3629 let handlers = self.event_handlers.borrow().get(event_name).cloned();
3630
3631 if let Some(handler_pairs) = handlers {
3632 if handler_pairs.is_empty() {
3633 self.services.clear_js_execution_state();
3634 return Ok(true);
3635 }
3636
3637 let plugin_contexts = self.plugin_contexts.borrow();
3638 for handler in handler_pairs {
3639 let context_opt = plugin_contexts.get(&handler.plugin_name);
3640 if let Some(context) = context_opt {
3641 let handler_name = &handler.handler_name;
3642 let json_string = serde_json::to_string(event_data)?;
3648 let js_string_literal = serde_json::to_string(&json_string)?;
3649 let code = format!(
3650 r#"
3651 (function() {{
3652 try {{
3653 const data = JSON.parse({});
3654 if (typeof globalThis["{}"] === 'function') {{
3655 const result = globalThis["{}"](data);
3656 // If handler returns a Promise, catch rejections
3657 if (result && typeof result.then === 'function') {{
3658 result.catch(function(e) {{
3659 console.error('Handler {} async error:', e);
3660 // Re-throw to make it an unhandled rejection for the runtime to catch
3661 throw e;
3662 }});
3663 }}
3664 }}
3665 }} catch (e) {{
3666 console.error('Handler {} sync error:', e);
3667 throw e;
3668 }}
3669 }})();
3670 "#,
3671 js_string_literal, handler_name, handler_name, handler_name, handler_name
3672 );
3673
3674 context.with(|ctx| {
3675 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3676 log_js_error(&ctx, e, &format!("handler {}", handler_name));
3677 }
3678 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3680 });
3681 }
3682 }
3683 }
3684
3685 self.services.clear_js_execution_state();
3686 Ok(true)
3687 }
3688
3689 pub fn has_handlers(&self, event_name: &str) -> bool {
3691 self.event_handlers
3692 .borrow()
3693 .get(event_name)
3694 .map(|v| !v.is_empty())
3695 .unwrap_or(false)
3696 }
3697
3698 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3702 let pair = self.registered_actions.borrow().get(action_name).cloned();
3703 let (plugin_name, function_name) = match pair {
3704 Some(handler) => (handler.plugin_name, handler.handler_name),
3705 None => ("main".to_string(), action_name.to_string()),
3706 };
3707
3708 let plugin_contexts = self.plugin_contexts.borrow();
3709 let context = plugin_contexts
3710 .get(&plugin_name)
3711 .unwrap_or(&self.main_context);
3712
3713 self.services
3715 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3716
3717 tracing::info!(
3718 "start_action: BEGIN '{}' -> function '{}'",
3719 action_name,
3720 function_name
3721 );
3722
3723 let code = format!(
3725 r#"
3726 (function() {{
3727 console.log('[JS] start_action: calling {fn}');
3728 try {{
3729 if (typeof globalThis.{fn} === 'function') {{
3730 console.log('[JS] start_action: {fn} is a function, invoking...');
3731 globalThis.{fn}();
3732 console.log('[JS] start_action: {fn} invoked (may be async)');
3733 }} else {{
3734 console.error('[JS] Action {action} is not defined as a global function');
3735 }}
3736 }} catch (e) {{
3737 console.error('[JS] Action {action} error:', e);
3738 }}
3739 }})();
3740 "#,
3741 fn = function_name,
3742 action = action_name
3743 );
3744
3745 tracing::info!("start_action: evaluating JS code");
3746 context.with(|ctx| {
3747 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3748 log_js_error(&ctx, e, &format!("action {}", action_name));
3749 }
3750 tracing::info!("start_action: running pending microtasks");
3751 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3753 tracing::info!("start_action: executed {} pending jobs", count);
3754 });
3755
3756 tracing::info!("start_action: END '{}'", action_name);
3757
3758 self.services.clear_js_execution_state();
3760
3761 Ok(())
3762 }
3763
3764 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3766 let pair = self.registered_actions.borrow().get(action_name).cloned();
3768 let (plugin_name, function_name) = match pair {
3769 Some(handler) => (handler.plugin_name, handler.handler_name),
3770 None => ("main".to_string(), action_name.to_string()),
3771 };
3772
3773 let plugin_contexts = self.plugin_contexts.borrow();
3774 let context = plugin_contexts
3775 .get(&plugin_name)
3776 .unwrap_or(&self.main_context);
3777
3778 tracing::debug!(
3779 "execute_action: '{}' -> function '{}'",
3780 action_name,
3781 function_name
3782 );
3783
3784 let code = format!(
3787 r#"
3788 (async function() {{
3789 try {{
3790 if (typeof globalThis.{fn} === 'function') {{
3791 const result = globalThis.{fn}();
3792 // If it's a Promise, await it
3793 if (result && typeof result.then === 'function') {{
3794 await result;
3795 }}
3796 }} else {{
3797 console.error('Action {action} is not defined as a global function');
3798 }}
3799 }} catch (e) {{
3800 console.error('Action {action} error:', e);
3801 }}
3802 }})();
3803 "#,
3804 fn = function_name,
3805 action = action_name
3806 );
3807
3808 context.with(|ctx| {
3809 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3811 Ok(value) => {
3812 if value.is_object() {
3814 if let Some(obj) = value.as_object() {
3815 if obj.get::<_, rquickjs::Function>("then").is_ok() {
3817 run_pending_jobs_checked(
3820 &ctx,
3821 &format!("execute_action {} promise", action_name),
3822 );
3823 }
3824 }
3825 }
3826 }
3827 Err(e) => {
3828 log_js_error(&ctx, e, &format!("action {}", action_name));
3829 }
3830 }
3831 });
3832
3833 Ok(())
3834 }
3835
3836 pub fn poll_event_loop_once(&mut self) -> bool {
3838 let mut had_work = false;
3839
3840 self.main_context.with(|ctx| {
3842 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3843 if count > 0 {
3844 had_work = true;
3845 }
3846 });
3847
3848 let contexts = self.plugin_contexts.borrow().clone();
3850 for (name, context) in contexts {
3851 context.with(|ctx| {
3852 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3853 if count > 0 {
3854 had_work = true;
3855 }
3856 });
3857 }
3858 had_work
3859 }
3860
3861 pub fn send_status(&self, message: String) {
3863 let _ = self
3864 .command_sender
3865 .send(PluginCommand::SetStatus { message });
3866 }
3867
3868 pub fn send_hook_completed(&self, hook_name: String) {
3872 let _ = self
3873 .command_sender
3874 .send(PluginCommand::HookCompleted { hook_name });
3875 }
3876
3877 pub fn resolve_callback(
3882 &mut self,
3883 callback_id: fresh_core::api::JsCallbackId,
3884 result_json: &str,
3885 ) {
3886 let id = callback_id.as_u64();
3887 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3888
3889 let plugin_name = {
3891 let mut contexts = self.callback_contexts.borrow_mut();
3892 contexts.remove(&id)
3893 };
3894
3895 let Some(name) = plugin_name else {
3896 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3897 return;
3898 };
3899
3900 let plugin_contexts = self.plugin_contexts.borrow();
3901 let Some(context) = plugin_contexts.get(&name) else {
3902 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3903 return;
3904 };
3905
3906 context.with(|ctx| {
3907 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3909 Ok(v) => v,
3910 Err(e) => {
3911 tracing::error!(
3912 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3913 id,
3914 e
3915 );
3916 return;
3917 }
3918 };
3919
3920 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3922 Ok(v) => v,
3923 Err(e) => {
3924 tracing::error!(
3925 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3926 id,
3927 e
3928 );
3929 return;
3930 }
3931 };
3932
3933 let globals = ctx.globals();
3935 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3936 Ok(f) => f,
3937 Err(e) => {
3938 tracing::error!(
3939 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3940 id,
3941 e
3942 );
3943 return;
3944 }
3945 };
3946
3947 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3949 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3950 }
3951
3952 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3954 tracing::info!(
3955 "resolve_callback: executed {} pending jobs for callback_id={}",
3956 job_count,
3957 id
3958 );
3959 });
3960 }
3961
3962 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3964 let id = callback_id.as_u64();
3965
3966 let plugin_name = {
3968 let mut contexts = self.callback_contexts.borrow_mut();
3969 contexts.remove(&id)
3970 };
3971
3972 let Some(name) = plugin_name else {
3973 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3974 return;
3975 };
3976
3977 let plugin_contexts = self.plugin_contexts.borrow();
3978 let Some(context) = plugin_contexts.get(&name) else {
3979 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3980 return;
3981 };
3982
3983 context.with(|ctx| {
3984 let globals = ctx.globals();
3986 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3987 Ok(f) => f,
3988 Err(e) => {
3989 tracing::error!(
3990 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3991 id,
3992 e
3993 );
3994 return;
3995 }
3996 };
3997
3998 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
4000 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
4001 }
4002
4003 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
4005 });
4006 }
4007}
4008
4009#[cfg(test)]
4010mod tests {
4011 use super::*;
4012 use fresh_core::api::{BufferInfo, CursorInfo};
4013 use std::sync::mpsc;
4014
4015 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
4017 let (tx, rx) = mpsc::channel();
4018 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4019 let services = Arc::new(TestServiceBridge::new());
4020 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4021 (backend, rx)
4022 }
4023
4024 struct TestServiceBridge {
4025 en_strings: std::sync::Mutex<HashMap<String, String>>,
4026 }
4027
4028 impl TestServiceBridge {
4029 fn new() -> Self {
4030 Self {
4031 en_strings: std::sync::Mutex::new(HashMap::new()),
4032 }
4033 }
4034 }
4035
4036 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
4037 fn as_any(&self) -> &dyn std::any::Any {
4038 self
4039 }
4040 fn translate(
4041 &self,
4042 _plugin_name: &str,
4043 key: &str,
4044 _args: &HashMap<String, String>,
4045 ) -> String {
4046 self.en_strings
4047 .lock()
4048 .unwrap()
4049 .get(key)
4050 .cloned()
4051 .unwrap_or_else(|| key.to_string())
4052 }
4053 fn current_locale(&self) -> String {
4054 "en".to_string()
4055 }
4056 fn set_js_execution_state(&self, _state: String) {}
4057 fn clear_js_execution_state(&self) {}
4058 fn get_theme_schema(&self) -> serde_json::Value {
4059 serde_json::json!({})
4060 }
4061 fn get_builtin_themes(&self) -> serde_json::Value {
4062 serde_json::json!([])
4063 }
4064 fn register_command(&self, _command: fresh_core::command::Command) {}
4065 fn unregister_command(&self, _name: &str) {}
4066 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
4067 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
4068 fn plugins_dir(&self) -> std::path::PathBuf {
4069 std::path::PathBuf::from("/tmp/plugins")
4070 }
4071 fn config_dir(&self) -> std::path::PathBuf {
4072 std::path::PathBuf::from("/tmp/config")
4073 }
4074 }
4075
4076 #[test]
4077 fn test_quickjs_backend_creation() {
4078 let backend = QuickJsBackend::new();
4079 assert!(backend.is_ok());
4080 }
4081
4082 #[test]
4083 fn test_execute_simple_js() {
4084 let mut backend = QuickJsBackend::new().unwrap();
4085 let result = backend.execute_js("const x = 1 + 2;", "test.js");
4086 assert!(result.is_ok());
4087 }
4088
4089 #[test]
4090 fn test_event_handler_registration() {
4091 let backend = QuickJsBackend::new().unwrap();
4092
4093 assert!(!backend.has_handlers("test_event"));
4095
4096 backend
4098 .event_handlers
4099 .borrow_mut()
4100 .entry("test_event".to_string())
4101 .or_default()
4102 .push(PluginHandler {
4103 plugin_name: "test".to_string(),
4104 handler_name: "testHandler".to_string(),
4105 });
4106
4107 assert!(backend.has_handlers("test_event"));
4109 }
4110
4111 #[test]
4114 fn test_api_set_status() {
4115 let (mut backend, rx) = create_test_backend();
4116
4117 backend
4118 .execute_js(
4119 r#"
4120 const editor = getEditor();
4121 editor.setStatus("Hello from test");
4122 "#,
4123 "test.js",
4124 )
4125 .unwrap();
4126
4127 let cmd = rx.try_recv().unwrap();
4128 match cmd {
4129 PluginCommand::SetStatus { message } => {
4130 assert_eq!(message, "Hello from test");
4131 }
4132 _ => panic!("Expected SetStatus command, got {:?}", cmd),
4133 }
4134 }
4135
4136 #[test]
4137 fn test_api_register_command() {
4138 let (mut backend, rx) = create_test_backend();
4139
4140 backend
4141 .execute_js(
4142 r#"
4143 const editor = getEditor();
4144 globalThis.myTestHandler = function() { };
4145 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
4146 "#,
4147 "test_plugin.js",
4148 )
4149 .unwrap();
4150
4151 let cmd = rx.try_recv().unwrap();
4152 match cmd {
4153 PluginCommand::RegisterCommand { command } => {
4154 assert_eq!(command.name, "Test Command");
4155 assert_eq!(command.description, "A test command");
4156 assert_eq!(command.plugin_name, "test_plugin");
4158 }
4159 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
4160 }
4161 }
4162
4163 #[test]
4164 fn test_api_define_mode() {
4165 let (mut backend, rx) = create_test_backend();
4166
4167 backend
4168 .execute_js(
4169 r#"
4170 const editor = getEditor();
4171 editor.defineMode("test-mode", null, [
4172 ["a", "action_a"],
4173 ["b", "action_b"]
4174 ]);
4175 "#,
4176 "test.js",
4177 )
4178 .unwrap();
4179
4180 let cmd = rx.try_recv().unwrap();
4181 match cmd {
4182 PluginCommand::DefineMode {
4183 name,
4184 parent,
4185 bindings,
4186 read_only,
4187 } => {
4188 assert_eq!(name, "test-mode");
4189 assert!(parent.is_none());
4190 assert_eq!(bindings.len(), 2);
4191 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
4192 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
4193 assert!(!read_only);
4194 }
4195 _ => panic!("Expected DefineMode, got {:?}", cmd),
4196 }
4197 }
4198
4199 #[test]
4200 fn test_api_set_editor_mode() {
4201 let (mut backend, rx) = create_test_backend();
4202
4203 backend
4204 .execute_js(
4205 r#"
4206 const editor = getEditor();
4207 editor.setEditorMode("vi-normal");
4208 "#,
4209 "test.js",
4210 )
4211 .unwrap();
4212
4213 let cmd = rx.try_recv().unwrap();
4214 match cmd {
4215 PluginCommand::SetEditorMode { mode } => {
4216 assert_eq!(mode, Some("vi-normal".to_string()));
4217 }
4218 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
4219 }
4220 }
4221
4222 #[test]
4223 fn test_api_clear_editor_mode() {
4224 let (mut backend, rx) = create_test_backend();
4225
4226 backend
4227 .execute_js(
4228 r#"
4229 const editor = getEditor();
4230 editor.setEditorMode(null);
4231 "#,
4232 "test.js",
4233 )
4234 .unwrap();
4235
4236 let cmd = rx.try_recv().unwrap();
4237 match cmd {
4238 PluginCommand::SetEditorMode { mode } => {
4239 assert!(mode.is_none());
4240 }
4241 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
4242 }
4243 }
4244
4245 #[test]
4246 fn test_api_insert_at_cursor() {
4247 let (mut backend, rx) = create_test_backend();
4248
4249 backend
4250 .execute_js(
4251 r#"
4252 const editor = getEditor();
4253 editor.insertAtCursor("Hello, World!");
4254 "#,
4255 "test.js",
4256 )
4257 .unwrap();
4258
4259 let cmd = rx.try_recv().unwrap();
4260 match cmd {
4261 PluginCommand::InsertAtCursor { text } => {
4262 assert_eq!(text, "Hello, World!");
4263 }
4264 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
4265 }
4266 }
4267
4268 #[test]
4269 fn test_api_set_context() {
4270 let (mut backend, rx) = create_test_backend();
4271
4272 backend
4273 .execute_js(
4274 r#"
4275 const editor = getEditor();
4276 editor.setContext("myContext", true);
4277 "#,
4278 "test.js",
4279 )
4280 .unwrap();
4281
4282 let cmd = rx.try_recv().unwrap();
4283 match cmd {
4284 PluginCommand::SetContext { name, active } => {
4285 assert_eq!(name, "myContext");
4286 assert!(active);
4287 }
4288 _ => panic!("Expected SetContext, got {:?}", cmd),
4289 }
4290 }
4291
4292 #[tokio::test]
4293 async fn test_execute_action_sync_function() {
4294 let (mut backend, rx) = create_test_backend();
4295
4296 backend.registered_actions.borrow_mut().insert(
4298 "my_sync_action".to_string(),
4299 PluginHandler {
4300 plugin_name: "test".to_string(),
4301 handler_name: "my_sync_action".to_string(),
4302 },
4303 );
4304
4305 backend
4307 .execute_js(
4308 r#"
4309 const editor = getEditor();
4310 globalThis.my_sync_action = function() {
4311 editor.setStatus("sync action executed");
4312 };
4313 "#,
4314 "test.js",
4315 )
4316 .unwrap();
4317
4318 while rx.try_recv().is_ok() {}
4320
4321 backend.execute_action("my_sync_action").await.unwrap();
4323
4324 let cmd = rx.try_recv().unwrap();
4326 match cmd {
4327 PluginCommand::SetStatus { message } => {
4328 assert_eq!(message, "sync action executed");
4329 }
4330 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
4331 }
4332 }
4333
4334 #[tokio::test]
4335 async fn test_execute_action_async_function() {
4336 let (mut backend, rx) = create_test_backend();
4337
4338 backend.registered_actions.borrow_mut().insert(
4340 "my_async_action".to_string(),
4341 PluginHandler {
4342 plugin_name: "test".to_string(),
4343 handler_name: "my_async_action".to_string(),
4344 },
4345 );
4346
4347 backend
4349 .execute_js(
4350 r#"
4351 const editor = getEditor();
4352 globalThis.my_async_action = async function() {
4353 await Promise.resolve();
4354 editor.setStatus("async action executed");
4355 };
4356 "#,
4357 "test.js",
4358 )
4359 .unwrap();
4360
4361 while rx.try_recv().is_ok() {}
4363
4364 backend.execute_action("my_async_action").await.unwrap();
4366
4367 let cmd = rx.try_recv().unwrap();
4369 match cmd {
4370 PluginCommand::SetStatus { message } => {
4371 assert_eq!(message, "async action executed");
4372 }
4373 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
4374 }
4375 }
4376
4377 #[tokio::test]
4378 async fn test_execute_action_with_registered_handler() {
4379 let (mut backend, rx) = create_test_backend();
4380
4381 backend.registered_actions.borrow_mut().insert(
4383 "my_action".to_string(),
4384 PluginHandler {
4385 plugin_name: "test".to_string(),
4386 handler_name: "actual_handler_function".to_string(),
4387 },
4388 );
4389
4390 backend
4391 .execute_js(
4392 r#"
4393 const editor = getEditor();
4394 globalThis.actual_handler_function = function() {
4395 editor.setStatus("handler executed");
4396 };
4397 "#,
4398 "test.js",
4399 )
4400 .unwrap();
4401
4402 while rx.try_recv().is_ok() {}
4404
4405 backend.execute_action("my_action").await.unwrap();
4407
4408 let cmd = rx.try_recv().unwrap();
4409 match cmd {
4410 PluginCommand::SetStatus { message } => {
4411 assert_eq!(message, "handler executed");
4412 }
4413 _ => panic!("Expected SetStatus, got {:?}", cmd),
4414 }
4415 }
4416
4417 #[test]
4418 fn test_api_on_event_registration() {
4419 let (mut backend, _rx) = create_test_backend();
4420
4421 backend
4422 .execute_js(
4423 r#"
4424 const editor = getEditor();
4425 globalThis.myEventHandler = function() { };
4426 editor.on("bufferSave", "myEventHandler");
4427 "#,
4428 "test.js",
4429 )
4430 .unwrap();
4431
4432 assert!(backend.has_handlers("bufferSave"));
4433 }
4434
4435 #[test]
4436 fn test_api_off_event_unregistration() {
4437 let (mut backend, _rx) = create_test_backend();
4438
4439 backend
4440 .execute_js(
4441 r#"
4442 const editor = getEditor();
4443 globalThis.myEventHandler = function() { };
4444 editor.on("bufferSave", "myEventHandler");
4445 editor.off("bufferSave", "myEventHandler");
4446 "#,
4447 "test.js",
4448 )
4449 .unwrap();
4450
4451 assert!(!backend.has_handlers("bufferSave"));
4453 }
4454
4455 #[tokio::test]
4456 async fn test_emit_event() {
4457 let (mut backend, rx) = create_test_backend();
4458
4459 backend
4460 .execute_js(
4461 r#"
4462 const editor = getEditor();
4463 globalThis.onSaveHandler = function(data) {
4464 editor.setStatus("saved: " + JSON.stringify(data));
4465 };
4466 editor.on("bufferSave", "onSaveHandler");
4467 "#,
4468 "test.js",
4469 )
4470 .unwrap();
4471
4472 while rx.try_recv().is_ok() {}
4474
4475 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
4477 backend.emit("bufferSave", &event_data).await.unwrap();
4478
4479 let cmd = rx.try_recv().unwrap();
4480 match cmd {
4481 PluginCommand::SetStatus { message } => {
4482 assert!(message.contains("/test.txt"));
4483 }
4484 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
4485 }
4486 }
4487
4488 #[test]
4489 fn test_api_copy_to_clipboard() {
4490 let (mut backend, rx) = create_test_backend();
4491
4492 backend
4493 .execute_js(
4494 r#"
4495 const editor = getEditor();
4496 editor.copyToClipboard("clipboard text");
4497 "#,
4498 "test.js",
4499 )
4500 .unwrap();
4501
4502 let cmd = rx.try_recv().unwrap();
4503 match cmd {
4504 PluginCommand::SetClipboard { text } => {
4505 assert_eq!(text, "clipboard text");
4506 }
4507 _ => panic!("Expected SetClipboard, got {:?}", cmd),
4508 }
4509 }
4510
4511 #[test]
4512 fn test_api_open_file() {
4513 let (mut backend, rx) = create_test_backend();
4514
4515 backend
4517 .execute_js(
4518 r#"
4519 const editor = getEditor();
4520 editor.openFile("/path/to/file.txt", null, null);
4521 "#,
4522 "test.js",
4523 )
4524 .unwrap();
4525
4526 let cmd = rx.try_recv().unwrap();
4527 match cmd {
4528 PluginCommand::OpenFileAtLocation { path, line, column } => {
4529 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
4530 assert!(line.is_none());
4531 assert!(column.is_none());
4532 }
4533 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
4534 }
4535 }
4536
4537 #[test]
4538 fn test_api_delete_range() {
4539 let (mut backend, rx) = create_test_backend();
4540
4541 backend
4543 .execute_js(
4544 r#"
4545 const editor = getEditor();
4546 editor.deleteRange(0, 10, 20);
4547 "#,
4548 "test.js",
4549 )
4550 .unwrap();
4551
4552 let cmd = rx.try_recv().unwrap();
4553 match cmd {
4554 PluginCommand::DeleteRange { range, .. } => {
4555 assert_eq!(range.start, 10);
4556 assert_eq!(range.end, 20);
4557 }
4558 _ => panic!("Expected DeleteRange, got {:?}", cmd),
4559 }
4560 }
4561
4562 #[test]
4563 fn test_api_insert_text() {
4564 let (mut backend, rx) = create_test_backend();
4565
4566 backend
4568 .execute_js(
4569 r#"
4570 const editor = getEditor();
4571 editor.insertText(0, 5, "inserted");
4572 "#,
4573 "test.js",
4574 )
4575 .unwrap();
4576
4577 let cmd = rx.try_recv().unwrap();
4578 match cmd {
4579 PluginCommand::InsertText { position, text, .. } => {
4580 assert_eq!(position, 5);
4581 assert_eq!(text, "inserted");
4582 }
4583 _ => panic!("Expected InsertText, got {:?}", cmd),
4584 }
4585 }
4586
4587 #[test]
4588 fn test_api_set_buffer_cursor() {
4589 let (mut backend, rx) = create_test_backend();
4590
4591 backend
4593 .execute_js(
4594 r#"
4595 const editor = getEditor();
4596 editor.setBufferCursor(0, 100);
4597 "#,
4598 "test.js",
4599 )
4600 .unwrap();
4601
4602 let cmd = rx.try_recv().unwrap();
4603 match cmd {
4604 PluginCommand::SetBufferCursor { position, .. } => {
4605 assert_eq!(position, 100);
4606 }
4607 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
4608 }
4609 }
4610
4611 #[test]
4612 fn test_api_get_cursor_position_from_state() {
4613 let (tx, _rx) = mpsc::channel();
4614 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4615
4616 {
4618 let mut state = state_snapshot.write().unwrap();
4619 state.primary_cursor = Some(CursorInfo {
4620 position: 42,
4621 selection: None,
4622 });
4623 }
4624
4625 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4626 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4627
4628 backend
4630 .execute_js(
4631 r#"
4632 const editor = getEditor();
4633 const pos = editor.getCursorPosition();
4634 globalThis._testResult = pos;
4635 "#,
4636 "test.js",
4637 )
4638 .unwrap();
4639
4640 backend
4642 .plugin_contexts
4643 .borrow()
4644 .get("test")
4645 .unwrap()
4646 .clone()
4647 .with(|ctx| {
4648 let global = ctx.globals();
4649 let result: u32 = global.get("_testResult").unwrap();
4650 assert_eq!(result, 42);
4651 });
4652 }
4653
4654 #[test]
4655 fn test_api_path_functions() {
4656 let (mut backend, _rx) = create_test_backend();
4657
4658 #[cfg(windows)]
4661 let absolute_path = r#"C:\\foo\\bar"#;
4662 #[cfg(not(windows))]
4663 let absolute_path = "/foo/bar";
4664
4665 let js_code = format!(
4667 r#"
4668 const editor = getEditor();
4669 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4670 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4671 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4672 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4673 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4674 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4675 "#,
4676 absolute_path
4677 );
4678 backend.execute_js(&js_code, "test.js").unwrap();
4679
4680 backend
4681 .plugin_contexts
4682 .borrow()
4683 .get("test")
4684 .unwrap()
4685 .clone()
4686 .with(|ctx| {
4687 let global = ctx.globals();
4688 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4689 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4690 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4691 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4692 assert!(!global.get::<_, bool>("_isRelative").unwrap());
4693 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4694 });
4695 }
4696
4697 #[test]
4698 fn test_typescript_transpilation() {
4699 use fresh_parser_js::transpile_typescript;
4700
4701 let (mut backend, rx) = create_test_backend();
4702
4703 let ts_code = r#"
4705 const editor = getEditor();
4706 function greet(name: string): string {
4707 return "Hello, " + name;
4708 }
4709 editor.setStatus(greet("TypeScript"));
4710 "#;
4711
4712 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4714
4715 backend.execute_js(&js_code, "test.js").unwrap();
4717
4718 let cmd = rx.try_recv().unwrap();
4719 match cmd {
4720 PluginCommand::SetStatus { message } => {
4721 assert_eq!(message, "Hello, TypeScript");
4722 }
4723 _ => panic!("Expected SetStatus, got {:?}", cmd),
4724 }
4725 }
4726
4727 #[test]
4728 fn test_api_get_buffer_text_sends_command() {
4729 let (mut backend, rx) = create_test_backend();
4730
4731 backend
4733 .execute_js(
4734 r#"
4735 const editor = getEditor();
4736 // Store the promise for later
4737 globalThis._textPromise = editor.getBufferText(0, 10, 20);
4738 "#,
4739 "test.js",
4740 )
4741 .unwrap();
4742
4743 let cmd = rx.try_recv().unwrap();
4745 match cmd {
4746 PluginCommand::GetBufferText {
4747 buffer_id,
4748 start,
4749 end,
4750 request_id,
4751 } => {
4752 assert_eq!(buffer_id.0, 0);
4753 assert_eq!(start, 10);
4754 assert_eq!(end, 20);
4755 assert!(request_id > 0); }
4757 _ => panic!("Expected GetBufferText, got {:?}", cmd),
4758 }
4759 }
4760
4761 #[test]
4762 fn test_api_get_buffer_text_resolves_callback() {
4763 let (mut backend, rx) = create_test_backend();
4764
4765 backend
4767 .execute_js(
4768 r#"
4769 const editor = getEditor();
4770 globalThis._resolvedText = null;
4771 editor.getBufferText(0, 0, 100).then(text => {
4772 globalThis._resolvedText = text;
4773 });
4774 "#,
4775 "test.js",
4776 )
4777 .unwrap();
4778
4779 let request_id = match rx.try_recv().unwrap() {
4781 PluginCommand::GetBufferText { request_id, .. } => request_id,
4782 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4783 };
4784
4785 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4787
4788 backend
4790 .plugin_contexts
4791 .borrow()
4792 .get("test")
4793 .unwrap()
4794 .clone()
4795 .with(|ctx| {
4796 run_pending_jobs_checked(&ctx, "test async getText");
4797 });
4798
4799 backend
4801 .plugin_contexts
4802 .borrow()
4803 .get("test")
4804 .unwrap()
4805 .clone()
4806 .with(|ctx| {
4807 let global = ctx.globals();
4808 let result: String = global.get("_resolvedText").unwrap();
4809 assert_eq!(result, "hello world");
4810 });
4811 }
4812
4813 #[test]
4814 fn test_plugin_translation() {
4815 let (mut backend, _rx) = create_test_backend();
4816
4817 backend
4819 .execute_js(
4820 r#"
4821 const editor = getEditor();
4822 globalThis._translated = editor.t("test.key");
4823 "#,
4824 "test.js",
4825 )
4826 .unwrap();
4827
4828 backend
4829 .plugin_contexts
4830 .borrow()
4831 .get("test")
4832 .unwrap()
4833 .clone()
4834 .with(|ctx| {
4835 let global = ctx.globals();
4836 let result: String = global.get("_translated").unwrap();
4838 assert_eq!(result, "test.key");
4839 });
4840 }
4841
4842 #[test]
4843 fn test_plugin_translation_with_registered_strings() {
4844 let (mut backend, _rx) = create_test_backend();
4845
4846 let mut en_strings = std::collections::HashMap::new();
4848 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4849 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4850
4851 let mut strings = std::collections::HashMap::new();
4852 strings.insert("en".to_string(), en_strings);
4853
4854 if let Some(bridge) = backend
4856 .services
4857 .as_any()
4858 .downcast_ref::<TestServiceBridge>()
4859 {
4860 let mut en = bridge.en_strings.lock().unwrap();
4861 en.insert("greeting".to_string(), "Hello, World!".to_string());
4862 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4863 }
4864
4865 backend
4867 .execute_js(
4868 r#"
4869 const editor = getEditor();
4870 globalThis._greeting = editor.t("greeting");
4871 globalThis._prompt = editor.t("prompt.find_file");
4872 globalThis._missing = editor.t("nonexistent.key");
4873 "#,
4874 "test.js",
4875 )
4876 .unwrap();
4877
4878 backend
4879 .plugin_contexts
4880 .borrow()
4881 .get("test")
4882 .unwrap()
4883 .clone()
4884 .with(|ctx| {
4885 let global = ctx.globals();
4886 let greeting: String = global.get("_greeting").unwrap();
4887 assert_eq!(greeting, "Hello, World!");
4888
4889 let prompt: String = global.get("_prompt").unwrap();
4890 assert_eq!(prompt, "Find file: ");
4891
4892 let missing: String = global.get("_missing").unwrap();
4894 assert_eq!(missing, "nonexistent.key");
4895 });
4896 }
4897
4898 #[test]
4901 fn test_api_set_line_indicator() {
4902 let (mut backend, rx) = create_test_backend();
4903
4904 backend
4905 .execute_js(
4906 r#"
4907 const editor = getEditor();
4908 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4909 "#,
4910 "test.js",
4911 )
4912 .unwrap();
4913
4914 let cmd = rx.try_recv().unwrap();
4915 match cmd {
4916 PluginCommand::SetLineIndicator {
4917 buffer_id,
4918 line,
4919 namespace,
4920 symbol,
4921 color,
4922 priority,
4923 } => {
4924 assert_eq!(buffer_id.0, 1);
4925 assert_eq!(line, 5);
4926 assert_eq!(namespace, "test-ns");
4927 assert_eq!(symbol, "●");
4928 assert_eq!(color, (255, 0, 0));
4929 assert_eq!(priority, 10);
4930 }
4931 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4932 }
4933 }
4934
4935 #[test]
4936 fn test_api_clear_line_indicators() {
4937 let (mut backend, rx) = create_test_backend();
4938
4939 backend
4940 .execute_js(
4941 r#"
4942 const editor = getEditor();
4943 editor.clearLineIndicators(1, "test-ns");
4944 "#,
4945 "test.js",
4946 )
4947 .unwrap();
4948
4949 let cmd = rx.try_recv().unwrap();
4950 match cmd {
4951 PluginCommand::ClearLineIndicators {
4952 buffer_id,
4953 namespace,
4954 } => {
4955 assert_eq!(buffer_id.0, 1);
4956 assert_eq!(namespace, "test-ns");
4957 }
4958 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4959 }
4960 }
4961
4962 #[test]
4965 fn test_api_create_virtual_buffer_sends_command() {
4966 let (mut backend, rx) = create_test_backend();
4967
4968 backend
4969 .execute_js(
4970 r#"
4971 const editor = getEditor();
4972 editor.createVirtualBuffer({
4973 name: "*Test Buffer*",
4974 mode: "test-mode",
4975 readOnly: true,
4976 entries: [
4977 { text: "Line 1\n", properties: { type: "header" } },
4978 { text: "Line 2\n", properties: { type: "content" } }
4979 ],
4980 showLineNumbers: false,
4981 showCursors: true,
4982 editingDisabled: true
4983 });
4984 "#,
4985 "test.js",
4986 )
4987 .unwrap();
4988
4989 let cmd = rx.try_recv().unwrap();
4990 match cmd {
4991 PluginCommand::CreateVirtualBufferWithContent {
4992 name,
4993 mode,
4994 read_only,
4995 entries,
4996 show_line_numbers,
4997 show_cursors,
4998 editing_disabled,
4999 ..
5000 } => {
5001 assert_eq!(name, "*Test Buffer*");
5002 assert_eq!(mode, "test-mode");
5003 assert!(read_only);
5004 assert_eq!(entries.len(), 2);
5005 assert_eq!(entries[0].text, "Line 1\n");
5006 assert!(!show_line_numbers);
5007 assert!(show_cursors);
5008 assert!(editing_disabled);
5009 }
5010 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
5011 }
5012 }
5013
5014 #[test]
5015 fn test_api_set_virtual_buffer_content() {
5016 let (mut backend, rx) = create_test_backend();
5017
5018 backend
5019 .execute_js(
5020 r#"
5021 const editor = getEditor();
5022 editor.setVirtualBufferContent(5, [
5023 { text: "New content\n", properties: { type: "updated" } }
5024 ]);
5025 "#,
5026 "test.js",
5027 )
5028 .unwrap();
5029
5030 let cmd = rx.try_recv().unwrap();
5031 match cmd {
5032 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
5033 assert_eq!(buffer_id.0, 5);
5034 assert_eq!(entries.len(), 1);
5035 assert_eq!(entries[0].text, "New content\n");
5036 }
5037 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
5038 }
5039 }
5040
5041 #[test]
5044 fn test_api_add_overlay() {
5045 let (mut backend, rx) = create_test_backend();
5046
5047 backend
5048 .execute_js(
5049 r#"
5050 const editor = getEditor();
5051 editor.addOverlay(1, "highlight", 10, 20, {
5052 fg: [255, 128, 0],
5053 bg: [50, 50, 50],
5054 bold: true,
5055 });
5056 "#,
5057 "test.js",
5058 )
5059 .unwrap();
5060
5061 let cmd = rx.try_recv().unwrap();
5062 match cmd {
5063 PluginCommand::AddOverlay {
5064 buffer_id,
5065 namespace,
5066 range,
5067 options,
5068 } => {
5069 use fresh_core::api::OverlayColorSpec;
5070 assert_eq!(buffer_id.0, 1);
5071 assert!(namespace.is_some());
5072 assert_eq!(namespace.unwrap().as_str(), "highlight");
5073 assert_eq!(range, 10..20);
5074 assert!(matches!(
5075 options.fg,
5076 Some(OverlayColorSpec::Rgb(255, 128, 0))
5077 ));
5078 assert!(matches!(
5079 options.bg,
5080 Some(OverlayColorSpec::Rgb(50, 50, 50))
5081 ));
5082 assert!(!options.underline);
5083 assert!(options.bold);
5084 assert!(!options.italic);
5085 assert!(!options.extend_to_line_end);
5086 }
5087 _ => panic!("Expected AddOverlay, got {:?}", cmd),
5088 }
5089 }
5090
5091 #[test]
5092 fn test_api_add_overlay_with_theme_keys() {
5093 let (mut backend, rx) = create_test_backend();
5094
5095 backend
5096 .execute_js(
5097 r#"
5098 const editor = getEditor();
5099 // Test with theme keys for colors
5100 editor.addOverlay(1, "themed", 0, 10, {
5101 fg: "ui.status_bar_fg",
5102 bg: "editor.selection_bg",
5103 });
5104 "#,
5105 "test.js",
5106 )
5107 .unwrap();
5108
5109 let cmd = rx.try_recv().unwrap();
5110 match cmd {
5111 PluginCommand::AddOverlay {
5112 buffer_id,
5113 namespace,
5114 range,
5115 options,
5116 } => {
5117 use fresh_core::api::OverlayColorSpec;
5118 assert_eq!(buffer_id.0, 1);
5119 assert!(namespace.is_some());
5120 assert_eq!(namespace.unwrap().as_str(), "themed");
5121 assert_eq!(range, 0..10);
5122 assert!(matches!(
5123 &options.fg,
5124 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
5125 ));
5126 assert!(matches!(
5127 &options.bg,
5128 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
5129 ));
5130 assert!(!options.underline);
5131 assert!(!options.bold);
5132 assert!(!options.italic);
5133 assert!(!options.extend_to_line_end);
5134 }
5135 _ => panic!("Expected AddOverlay, got {:?}", cmd),
5136 }
5137 }
5138
5139 #[test]
5140 fn test_api_clear_namespace() {
5141 let (mut backend, rx) = create_test_backend();
5142
5143 backend
5144 .execute_js(
5145 r#"
5146 const editor = getEditor();
5147 editor.clearNamespace(1, "highlight");
5148 "#,
5149 "test.js",
5150 )
5151 .unwrap();
5152
5153 let cmd = rx.try_recv().unwrap();
5154 match cmd {
5155 PluginCommand::ClearNamespace {
5156 buffer_id,
5157 namespace,
5158 } => {
5159 assert_eq!(buffer_id.0, 1);
5160 assert_eq!(namespace.as_str(), "highlight");
5161 }
5162 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
5163 }
5164 }
5165
5166 #[test]
5169 fn test_api_get_theme_schema() {
5170 let (mut backend, _rx) = create_test_backend();
5171
5172 backend
5173 .execute_js(
5174 r#"
5175 const editor = getEditor();
5176 const schema = editor.getThemeSchema();
5177 globalThis._isObject = typeof schema === 'object' && schema !== null;
5178 "#,
5179 "test.js",
5180 )
5181 .unwrap();
5182
5183 backend
5184 .plugin_contexts
5185 .borrow()
5186 .get("test")
5187 .unwrap()
5188 .clone()
5189 .with(|ctx| {
5190 let global = ctx.globals();
5191 let is_object: bool = global.get("_isObject").unwrap();
5192 assert!(is_object);
5194 });
5195 }
5196
5197 #[test]
5198 fn test_api_get_builtin_themes() {
5199 let (mut backend, _rx) = create_test_backend();
5200
5201 backend
5202 .execute_js(
5203 r#"
5204 const editor = getEditor();
5205 const themes = editor.getBuiltinThemes();
5206 globalThis._isObject = typeof themes === 'object' && themes !== null;
5207 "#,
5208 "test.js",
5209 )
5210 .unwrap();
5211
5212 backend
5213 .plugin_contexts
5214 .borrow()
5215 .get("test")
5216 .unwrap()
5217 .clone()
5218 .with(|ctx| {
5219 let global = ctx.globals();
5220 let is_object: bool = global.get("_isObject").unwrap();
5221 assert!(is_object);
5223 });
5224 }
5225
5226 #[test]
5227 fn test_api_apply_theme() {
5228 let (mut backend, rx) = create_test_backend();
5229
5230 backend
5231 .execute_js(
5232 r#"
5233 const editor = getEditor();
5234 editor.applyTheme("dark");
5235 "#,
5236 "test.js",
5237 )
5238 .unwrap();
5239
5240 let cmd = rx.try_recv().unwrap();
5241 match cmd {
5242 PluginCommand::ApplyTheme { theme_name } => {
5243 assert_eq!(theme_name, "dark");
5244 }
5245 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
5246 }
5247 }
5248
5249 #[test]
5252 fn test_api_close_buffer() {
5253 let (mut backend, rx) = create_test_backend();
5254
5255 backend
5256 .execute_js(
5257 r#"
5258 const editor = getEditor();
5259 editor.closeBuffer(3);
5260 "#,
5261 "test.js",
5262 )
5263 .unwrap();
5264
5265 let cmd = rx.try_recv().unwrap();
5266 match cmd {
5267 PluginCommand::CloseBuffer { buffer_id } => {
5268 assert_eq!(buffer_id.0, 3);
5269 }
5270 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
5271 }
5272 }
5273
5274 #[test]
5275 fn test_api_focus_split() {
5276 let (mut backend, rx) = create_test_backend();
5277
5278 backend
5279 .execute_js(
5280 r#"
5281 const editor = getEditor();
5282 editor.focusSplit(2);
5283 "#,
5284 "test.js",
5285 )
5286 .unwrap();
5287
5288 let cmd = rx.try_recv().unwrap();
5289 match cmd {
5290 PluginCommand::FocusSplit { split_id } => {
5291 assert_eq!(split_id.0, 2);
5292 }
5293 _ => panic!("Expected FocusSplit, got {:?}", cmd),
5294 }
5295 }
5296
5297 #[test]
5298 fn test_api_list_buffers() {
5299 let (tx, _rx) = mpsc::channel();
5300 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5301
5302 {
5304 let mut state = state_snapshot.write().unwrap();
5305 state.buffers.insert(
5306 BufferId(0),
5307 BufferInfo {
5308 id: BufferId(0),
5309 path: Some(PathBuf::from("/test1.txt")),
5310 modified: false,
5311 length: 100,
5312 is_virtual: false,
5313 view_mode: "source".to_string(),
5314 is_composing_in_any_split: false,
5315 compose_width: None,
5316 },
5317 );
5318 state.buffers.insert(
5319 BufferId(1),
5320 BufferInfo {
5321 id: BufferId(1),
5322 path: Some(PathBuf::from("/test2.txt")),
5323 modified: true,
5324 length: 200,
5325 is_virtual: false,
5326 view_mode: "source".to_string(),
5327 is_composing_in_any_split: false,
5328 compose_width: None,
5329 },
5330 );
5331 }
5332
5333 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5334 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5335
5336 backend
5337 .execute_js(
5338 r#"
5339 const editor = getEditor();
5340 const buffers = editor.listBuffers();
5341 globalThis._isArray = Array.isArray(buffers);
5342 globalThis._length = buffers.length;
5343 "#,
5344 "test.js",
5345 )
5346 .unwrap();
5347
5348 backend
5349 .plugin_contexts
5350 .borrow()
5351 .get("test")
5352 .unwrap()
5353 .clone()
5354 .with(|ctx| {
5355 let global = ctx.globals();
5356 let is_array: bool = global.get("_isArray").unwrap();
5357 let length: u32 = global.get("_length").unwrap();
5358 assert!(is_array);
5359 assert_eq!(length, 2);
5360 });
5361 }
5362
5363 #[test]
5366 fn test_api_start_prompt() {
5367 let (mut backend, rx) = create_test_backend();
5368
5369 backend
5370 .execute_js(
5371 r#"
5372 const editor = getEditor();
5373 editor.startPrompt("Enter value:", "test-prompt");
5374 "#,
5375 "test.js",
5376 )
5377 .unwrap();
5378
5379 let cmd = rx.try_recv().unwrap();
5380 match cmd {
5381 PluginCommand::StartPrompt { label, prompt_type } => {
5382 assert_eq!(label, "Enter value:");
5383 assert_eq!(prompt_type, "test-prompt");
5384 }
5385 _ => panic!("Expected StartPrompt, got {:?}", cmd),
5386 }
5387 }
5388
5389 #[test]
5390 fn test_api_start_prompt_with_initial() {
5391 let (mut backend, rx) = create_test_backend();
5392
5393 backend
5394 .execute_js(
5395 r#"
5396 const editor = getEditor();
5397 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
5398 "#,
5399 "test.js",
5400 )
5401 .unwrap();
5402
5403 let cmd = rx.try_recv().unwrap();
5404 match cmd {
5405 PluginCommand::StartPromptWithInitial {
5406 label,
5407 prompt_type,
5408 initial_value,
5409 } => {
5410 assert_eq!(label, "Enter value:");
5411 assert_eq!(prompt_type, "test-prompt");
5412 assert_eq!(initial_value, "default");
5413 }
5414 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
5415 }
5416 }
5417
5418 #[test]
5419 fn test_api_set_prompt_suggestions() {
5420 let (mut backend, rx) = create_test_backend();
5421
5422 backend
5423 .execute_js(
5424 r#"
5425 const editor = getEditor();
5426 editor.setPromptSuggestions([
5427 { text: "Option 1", value: "opt1" },
5428 { text: "Option 2", value: "opt2" }
5429 ]);
5430 "#,
5431 "test.js",
5432 )
5433 .unwrap();
5434
5435 let cmd = rx.try_recv().unwrap();
5436 match cmd {
5437 PluginCommand::SetPromptSuggestions { suggestions } => {
5438 assert_eq!(suggestions.len(), 2);
5439 assert_eq!(suggestions[0].text, "Option 1");
5440 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
5441 }
5442 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
5443 }
5444 }
5445
5446 #[test]
5449 fn test_api_get_active_buffer_id() {
5450 let (tx, _rx) = mpsc::channel();
5451 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5452
5453 {
5454 let mut state = state_snapshot.write().unwrap();
5455 state.active_buffer_id = BufferId(42);
5456 }
5457
5458 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5459 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5460
5461 backend
5462 .execute_js(
5463 r#"
5464 const editor = getEditor();
5465 globalThis._activeId = editor.getActiveBufferId();
5466 "#,
5467 "test.js",
5468 )
5469 .unwrap();
5470
5471 backend
5472 .plugin_contexts
5473 .borrow()
5474 .get("test")
5475 .unwrap()
5476 .clone()
5477 .with(|ctx| {
5478 let global = ctx.globals();
5479 let result: u32 = global.get("_activeId").unwrap();
5480 assert_eq!(result, 42);
5481 });
5482 }
5483
5484 #[test]
5485 fn test_api_get_active_split_id() {
5486 let (tx, _rx) = mpsc::channel();
5487 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5488
5489 {
5490 let mut state = state_snapshot.write().unwrap();
5491 state.active_split_id = 7;
5492 }
5493
5494 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5495 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5496
5497 backend
5498 .execute_js(
5499 r#"
5500 const editor = getEditor();
5501 globalThis._splitId = editor.getActiveSplitId();
5502 "#,
5503 "test.js",
5504 )
5505 .unwrap();
5506
5507 backend
5508 .plugin_contexts
5509 .borrow()
5510 .get("test")
5511 .unwrap()
5512 .clone()
5513 .with(|ctx| {
5514 let global = ctx.globals();
5515 let result: u32 = global.get("_splitId").unwrap();
5516 assert_eq!(result, 7);
5517 });
5518 }
5519
5520 #[test]
5523 fn test_api_file_exists() {
5524 let (mut backend, _rx) = create_test_backend();
5525
5526 backend
5527 .execute_js(
5528 r#"
5529 const editor = getEditor();
5530 // Test with a path that definitely exists
5531 globalThis._exists = editor.fileExists("/");
5532 "#,
5533 "test.js",
5534 )
5535 .unwrap();
5536
5537 backend
5538 .plugin_contexts
5539 .borrow()
5540 .get("test")
5541 .unwrap()
5542 .clone()
5543 .with(|ctx| {
5544 let global = ctx.globals();
5545 let result: bool = global.get("_exists").unwrap();
5546 assert!(result);
5547 });
5548 }
5549
5550 #[test]
5551 fn test_api_get_cwd() {
5552 let (mut backend, _rx) = create_test_backend();
5553
5554 backend
5555 .execute_js(
5556 r#"
5557 const editor = getEditor();
5558 globalThis._cwd = editor.getCwd();
5559 "#,
5560 "test.js",
5561 )
5562 .unwrap();
5563
5564 backend
5565 .plugin_contexts
5566 .borrow()
5567 .get("test")
5568 .unwrap()
5569 .clone()
5570 .with(|ctx| {
5571 let global = ctx.globals();
5572 let result: String = global.get("_cwd").unwrap();
5573 assert!(!result.is_empty());
5575 });
5576 }
5577
5578 #[test]
5579 fn test_api_get_env() {
5580 let (mut backend, _rx) = create_test_backend();
5581
5582 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
5584
5585 backend
5586 .execute_js(
5587 r#"
5588 const editor = getEditor();
5589 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
5590 "#,
5591 "test.js",
5592 )
5593 .unwrap();
5594
5595 backend
5596 .plugin_contexts
5597 .borrow()
5598 .get("test")
5599 .unwrap()
5600 .clone()
5601 .with(|ctx| {
5602 let global = ctx.globals();
5603 let result: Option<String> = global.get("_envVal").unwrap();
5604 assert_eq!(result, Some("test_value".to_string()));
5605 });
5606
5607 std::env::remove_var("TEST_PLUGIN_VAR");
5608 }
5609
5610 #[test]
5611 fn test_api_get_config() {
5612 let (mut backend, _rx) = create_test_backend();
5613
5614 backend
5615 .execute_js(
5616 r#"
5617 const editor = getEditor();
5618 const config = editor.getConfig();
5619 globalThis._isObject = typeof config === 'object';
5620 "#,
5621 "test.js",
5622 )
5623 .unwrap();
5624
5625 backend
5626 .plugin_contexts
5627 .borrow()
5628 .get("test")
5629 .unwrap()
5630 .clone()
5631 .with(|ctx| {
5632 let global = ctx.globals();
5633 let is_object: bool = global.get("_isObject").unwrap();
5634 assert!(is_object);
5636 });
5637 }
5638
5639 #[test]
5640 fn test_api_get_themes_dir() {
5641 let (mut backend, _rx) = create_test_backend();
5642
5643 backend
5644 .execute_js(
5645 r#"
5646 const editor = getEditor();
5647 globalThis._themesDir = editor.getThemesDir();
5648 "#,
5649 "test.js",
5650 )
5651 .unwrap();
5652
5653 backend
5654 .plugin_contexts
5655 .borrow()
5656 .get("test")
5657 .unwrap()
5658 .clone()
5659 .with(|ctx| {
5660 let global = ctx.globals();
5661 let result: String = global.get("_themesDir").unwrap();
5662 assert!(!result.is_empty());
5664 });
5665 }
5666
5667 #[test]
5670 fn test_api_read_dir() {
5671 let (mut backend, _rx) = create_test_backend();
5672
5673 backend
5674 .execute_js(
5675 r#"
5676 const editor = getEditor();
5677 const entries = editor.readDir("/tmp");
5678 globalThis._isArray = Array.isArray(entries);
5679 globalThis._length = entries.length;
5680 "#,
5681 "test.js",
5682 )
5683 .unwrap();
5684
5685 backend
5686 .plugin_contexts
5687 .borrow()
5688 .get("test")
5689 .unwrap()
5690 .clone()
5691 .with(|ctx| {
5692 let global = ctx.globals();
5693 let is_array: bool = global.get("_isArray").unwrap();
5694 let length: u32 = global.get("_length").unwrap();
5695 assert!(is_array);
5697 let _ = length;
5699 });
5700 }
5701
5702 #[test]
5705 fn test_api_execute_action() {
5706 let (mut backend, rx) = create_test_backend();
5707
5708 backend
5709 .execute_js(
5710 r#"
5711 const editor = getEditor();
5712 editor.executeAction("move_cursor_up");
5713 "#,
5714 "test.js",
5715 )
5716 .unwrap();
5717
5718 let cmd = rx.try_recv().unwrap();
5719 match cmd {
5720 PluginCommand::ExecuteAction { action_name } => {
5721 assert_eq!(action_name, "move_cursor_up");
5722 }
5723 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5724 }
5725 }
5726
5727 #[test]
5730 fn test_api_debug() {
5731 let (mut backend, _rx) = create_test_backend();
5732
5733 backend
5735 .execute_js(
5736 r#"
5737 const editor = getEditor();
5738 editor.debug("Test debug message");
5739 editor.debug("Another message with special chars: <>&\"'");
5740 "#,
5741 "test.js",
5742 )
5743 .unwrap();
5744 }
5746
5747 #[test]
5750 fn test_typescript_preamble_generated() {
5751 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5753 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5754 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5755 println!(
5756 "Generated {} bytes of TypeScript preamble",
5757 JSEDITORAPI_TS_PREAMBLE.len()
5758 );
5759 }
5760
5761 #[test]
5762 fn test_typescript_editor_api_generated() {
5763 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5765 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5766 println!(
5767 "Generated {} bytes of EditorAPI interface",
5768 JSEDITORAPI_TS_EDITOR_API.len()
5769 );
5770 }
5771
5772 #[test]
5773 fn test_js_methods_list() {
5774 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5776 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5777 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5779 if i < 20 {
5780 println!(" - {}", method);
5781 }
5782 }
5783 if JSEDITORAPI_JS_METHODS.len() > 20 {
5784 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5785 }
5786 }
5787
5788 #[test]
5791 fn test_api_load_plugin_sends_command() {
5792 let (mut backend, rx) = create_test_backend();
5793
5794 backend
5796 .execute_js(
5797 r#"
5798 const editor = getEditor();
5799 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5800 "#,
5801 "test.js",
5802 )
5803 .unwrap();
5804
5805 let cmd = rx.try_recv().unwrap();
5807 match cmd {
5808 PluginCommand::LoadPlugin { path, callback_id } => {
5809 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5810 assert!(callback_id.0 > 0); }
5812 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5813 }
5814 }
5815
5816 #[test]
5817 fn test_api_unload_plugin_sends_command() {
5818 let (mut backend, rx) = create_test_backend();
5819
5820 backend
5822 .execute_js(
5823 r#"
5824 const editor = getEditor();
5825 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5826 "#,
5827 "test.js",
5828 )
5829 .unwrap();
5830
5831 let cmd = rx.try_recv().unwrap();
5833 match cmd {
5834 PluginCommand::UnloadPlugin { name, callback_id } => {
5835 assert_eq!(name, "my-plugin");
5836 assert!(callback_id.0 > 0); }
5838 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5839 }
5840 }
5841
5842 #[test]
5843 fn test_api_reload_plugin_sends_command() {
5844 let (mut backend, rx) = create_test_backend();
5845
5846 backend
5848 .execute_js(
5849 r#"
5850 const editor = getEditor();
5851 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5852 "#,
5853 "test.js",
5854 )
5855 .unwrap();
5856
5857 let cmd = rx.try_recv().unwrap();
5859 match cmd {
5860 PluginCommand::ReloadPlugin { name, callback_id } => {
5861 assert_eq!(name, "my-plugin");
5862 assert!(callback_id.0 > 0); }
5864 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5865 }
5866 }
5867
5868 #[test]
5869 fn test_api_load_plugin_resolves_callback() {
5870 let (mut backend, rx) = create_test_backend();
5871
5872 backend
5874 .execute_js(
5875 r#"
5876 const editor = getEditor();
5877 globalThis._loadResult = null;
5878 editor.loadPlugin("/path/to/plugin.ts").then(result => {
5879 globalThis._loadResult = result;
5880 });
5881 "#,
5882 "test.js",
5883 )
5884 .unwrap();
5885
5886 let callback_id = match rx.try_recv().unwrap() {
5888 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5889 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5890 };
5891
5892 backend.resolve_callback(callback_id, "true");
5894
5895 backend
5897 .plugin_contexts
5898 .borrow()
5899 .get("test")
5900 .unwrap()
5901 .clone()
5902 .with(|ctx| {
5903 run_pending_jobs_checked(&ctx, "test async loadPlugin");
5904 });
5905
5906 backend
5908 .plugin_contexts
5909 .borrow()
5910 .get("test")
5911 .unwrap()
5912 .clone()
5913 .with(|ctx| {
5914 let global = ctx.globals();
5915 let result: bool = global.get("_loadResult").unwrap();
5916 assert!(result);
5917 });
5918 }
5919
5920 #[test]
5921 fn test_api_version() {
5922 let (mut backend, _rx) = create_test_backend();
5923
5924 backend
5925 .execute_js(
5926 r#"
5927 const editor = getEditor();
5928 globalThis._apiVersion = editor.apiVersion();
5929 "#,
5930 "test.js",
5931 )
5932 .unwrap();
5933
5934 backend
5935 .plugin_contexts
5936 .borrow()
5937 .get("test")
5938 .unwrap()
5939 .clone()
5940 .with(|ctx| {
5941 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
5942 assert_eq!(version, 2);
5943 });
5944 }
5945
5946 #[test]
5947 fn test_api_unload_plugin_rejects_on_error() {
5948 let (mut backend, rx) = create_test_backend();
5949
5950 backend
5952 .execute_js(
5953 r#"
5954 const editor = getEditor();
5955 globalThis._unloadError = null;
5956 editor.unloadPlugin("nonexistent-plugin").catch(err => {
5957 globalThis._unloadError = err.message || String(err);
5958 });
5959 "#,
5960 "test.js",
5961 )
5962 .unwrap();
5963
5964 let callback_id = match rx.try_recv().unwrap() {
5966 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5967 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5968 };
5969
5970 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5972
5973 backend
5975 .plugin_contexts
5976 .borrow()
5977 .get("test")
5978 .unwrap()
5979 .clone()
5980 .with(|ctx| {
5981 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5982 });
5983
5984 backend
5986 .plugin_contexts
5987 .borrow()
5988 .get("test")
5989 .unwrap()
5990 .clone()
5991 .with(|ctx| {
5992 let global = ctx.globals();
5993 let error: String = global.get("_unloadError").unwrap();
5994 assert!(error.contains("nonexistent-plugin"));
5995 });
5996 }
5997}