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 .map(|f| {
127 if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
130 serde_json::Value::Number((f as i64).into())
131 } else {
132 serde_json::Number::from_f64(f)
133 .map(serde_json::Value::Number)
134 .unwrap_or(serde_json::Value::Null)
135 }
136 })
137 .unwrap_or(serde_json::Value::Null),
138 Type::String => val
139 .as_string()
140 .and_then(|s| s.to_string().ok())
141 .map(serde_json::Value::String)
142 .unwrap_or(serde_json::Value::Null),
143 Type::Array => {
144 if let Some(arr) = val.as_array() {
145 let items: Vec<serde_json::Value> = arr
146 .iter()
147 .filter_map(|item| item.ok())
148 .map(|item| js_to_json(ctx, item))
149 .collect();
150 serde_json::Value::Array(items)
151 } else {
152 serde_json::Value::Null
153 }
154 }
155 Type::Object | Type::Constructor | Type::Function => {
156 if let Some(obj) = val.as_object() {
157 let mut map = serde_json::Map::new();
158 for key in obj.keys::<String>().flatten() {
159 if let Ok(v) = obj.get::<_, Value>(&key) {
160 map.insert(key, js_to_json(ctx, v));
161 }
162 }
163 serde_json::Value::Object(map)
164 } else {
165 serde_json::Value::Null
166 }
167 }
168 _ => serde_json::Value::Null,
169 }
170}
171
172fn json_to_js_value<'js>(
174 ctx: &rquickjs::Ctx<'js>,
175 val: &serde_json::Value,
176) -> rquickjs::Result<Value<'js>> {
177 match val {
178 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
179 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
180 serde_json::Value::Number(n) => {
181 if let Some(i) = n.as_i64() {
182 Ok(Value::new_int(ctx.clone(), i as i32))
183 } else if let Some(f) = n.as_f64() {
184 Ok(Value::new_float(ctx.clone(), f))
185 } else {
186 Ok(Value::new_null(ctx.clone()))
187 }
188 }
189 serde_json::Value::String(s) => {
190 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
191 Ok(js_str.into_value())
192 }
193 serde_json::Value::Array(arr) => {
194 let js_arr = rquickjs::Array::new(ctx.clone())?;
195 for (i, item) in arr.iter().enumerate() {
196 let js_val = json_to_js_value(ctx, item)?;
197 js_arr.set(i, js_val)?;
198 }
199 Ok(js_arr.into_value())
200 }
201 serde_json::Value::Object(map) => {
202 let obj = rquickjs::Object::new(ctx.clone())?;
203 for (key, val) in map {
204 let js_val = json_to_js_value(ctx, val)?;
205 obj.set(key.as_str(), js_val)?;
206 }
207 Ok(obj.into_value())
208 }
209 }
210}
211
212fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
215 let js_data = match json_to_js_value(ctx, event_data) {
216 Ok(v) => v,
217 Err(e) => {
218 log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
219 return;
220 }
221 };
222
223 let globals = ctx.globals();
224 let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
225 return;
226 };
227
228 match func.call::<_, rquickjs::Value>((js_data,)) {
229 Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
230 Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
231 }
232
233 run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
234}
235
236fn attach_promise_catch<'js>(
238 ctx: &rquickjs::Ctx<'js>,
239 globals: &rquickjs::Object<'js>,
240 handler_name: &str,
241 result: rquickjs::Value<'js>,
242) {
243 let Some(obj) = result.as_object() else {
244 return;
245 };
246 if obj.get::<_, rquickjs::Function>("then").is_err() {
247 return;
248 }
249 let _ = globals.set("__pendingPromise", result);
250 let catch_code = format!(
251 r#"globalThis.__pendingPromise.catch(function(e) {{
252 console.error('Handler {} async error:', e);
253 throw e;
254 }}); delete globalThis.__pendingPromise;"#,
255 handler_name
256 );
257 let _ = ctx.eval::<(), _>(catch_code.as_bytes());
258}
259
260fn get_text_properties_at_cursor_typed(
262 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
263 buffer_id: u32,
264) -> fresh_core::api::TextPropertiesAtCursor {
265 use fresh_core::api::TextPropertiesAtCursor;
266
267 let snap = match snapshot.read() {
268 Ok(s) => s,
269 Err(_) => return TextPropertiesAtCursor(Vec::new()),
270 };
271 let buffer_id_typed = BufferId(buffer_id as usize);
272 let cursor_pos = match snap
273 .buffer_cursor_positions
274 .get(&buffer_id_typed)
275 .copied()
276 .or_else(|| {
277 if snap.active_buffer_id == buffer_id_typed {
278 snap.primary_cursor.as_ref().map(|c| c.position)
279 } else {
280 None
281 }
282 }) {
283 Some(pos) => pos,
284 None => return TextPropertiesAtCursor(Vec::new()),
285 };
286
287 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
288 Some(p) => p,
289 None => return TextPropertiesAtCursor(Vec::new()),
290 };
291
292 let result: Vec<_> = properties
294 .iter()
295 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
296 .map(|prop| prop.properties.clone())
297 .collect();
298
299 TextPropertiesAtCursor(result)
300}
301
302fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
304 use rquickjs::Type;
305 match val.type_of() {
306 Type::Null => "null".to_string(),
307 Type::Undefined => "undefined".to_string(),
308 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
309 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
310 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
311 Type::String => val
312 .as_string()
313 .and_then(|s| s.to_string().ok())
314 .unwrap_or_default(),
315 Type::Object | Type::Exception => {
316 if let Some(obj) = val.as_object() {
318 let name: Option<String> = obj.get("name").ok();
320 let message: Option<String> = obj.get("message").ok();
321 let stack: Option<String> = obj.get("stack").ok();
322
323 if message.is_some() || name.is_some() {
324 let name = name.unwrap_or_else(|| "Error".to_string());
326 let message = message.unwrap_or_default();
327 if let Some(stack) = stack {
328 return format!("{}: {}\n{}", name, message, stack);
329 } else {
330 return format!("{}: {}", name, message);
331 }
332 }
333
334 let json = js_to_json(ctx, val.clone());
336 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
337 } else {
338 "[object]".to_string()
339 }
340 }
341 Type::Array => {
342 let json = js_to_json(ctx, val.clone());
343 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
344 }
345 Type::Function | Type::Constructor => "[function]".to_string(),
346 Type::Symbol => "[symbol]".to_string(),
347 Type::BigInt => val
348 .as_big_int()
349 .and_then(|b| b.clone().to_i64().ok())
350 .map(|n| n.to_string())
351 .unwrap_or_else(|| "[bigint]".to_string()),
352 _ => format!("[{}]", val.type_name()),
353 }
354}
355
356fn format_js_error(
358 ctx: &rquickjs::Ctx<'_>,
359 err: rquickjs::Error,
360 source_name: &str,
361) -> anyhow::Error {
362 if err.is_exception() {
364 let exc = ctx.catch();
366 if !exc.is_undefined() && !exc.is_null() {
367 if let Some(exc_obj) = exc.as_object() {
369 let message: String = exc_obj
370 .get::<_, String>("message")
371 .unwrap_or_else(|_| "Unknown error".to_string());
372 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
373 let name: String = exc_obj
374 .get::<_, String>("name")
375 .unwrap_or_else(|_| "Error".to_string());
376
377 if !stack.is_empty() {
378 return anyhow::anyhow!(
379 "JS error in {}: {}: {}\nStack trace:\n{}",
380 source_name,
381 name,
382 message,
383 stack
384 );
385 } else {
386 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
387 }
388 } else {
389 let exc_str: String = exc
391 .as_string()
392 .and_then(|s: &rquickjs::String| s.to_string().ok())
393 .unwrap_or_else(|| format!("{:?}", exc));
394 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
395 }
396 }
397 }
398
399 anyhow::anyhow!("JS error in {}: {}", source_name, err)
401}
402
403fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
406 let error = format_js_error(ctx, err, context);
407 tracing::error!("{}", error);
408
409 if should_panic_on_js_errors() {
411 panic!("JavaScript error in {}: {}", context, error);
412 }
413}
414
415static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
417 std::sync::atomic::AtomicBool::new(false);
418
419pub fn set_panic_on_js_errors(enabled: bool) {
421 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
422}
423
424fn should_panic_on_js_errors() -> bool {
426 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
427}
428
429static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
433
434static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
436
437fn set_fatal_js_error(msg: String) {
439 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
440 if guard.is_none() {
441 *guard = Some(msg);
443 }
444 }
445 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
446}
447
448pub fn has_fatal_js_error() -> bool {
450 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
451}
452
453pub fn take_fatal_js_error() -> Option<String> {
455 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
456 return None;
457 }
458 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
459 guard.take()
460 } else {
461 Some("Fatal JS error (message unavailable)".to_string())
462 }
463}
464
465fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
468 let mut count = 0;
469 loop {
470 let exc: rquickjs::Value = ctx.catch();
472 if exc.is_exception() {
474 let error_msg = if let Some(err) = exc.as_exception() {
475 format!(
476 "{}: {}",
477 err.message().unwrap_or_default(),
478 err.stack().unwrap_or_default()
479 )
480 } else {
481 format!("{:?}", exc)
482 };
483 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
484 if should_panic_on_js_errors() {
485 panic!("Unhandled JS exception during {}: {}", context, error_msg);
486 }
487 }
488
489 if !ctx.execute_pending_job() {
490 break;
491 }
492 count += 1;
493 }
494
495 let exc: rquickjs::Value = ctx.catch();
497 if exc.is_exception() {
498 let error_msg = if let Some(err) = exc.as_exception() {
499 format!(
500 "{}: {}",
501 err.message().unwrap_or_default(),
502 err.stack().unwrap_or_default()
503 )
504 } else {
505 format!("{:?}", exc)
506 };
507 tracing::error!(
508 "Unhandled JS exception after running jobs in {}: {}",
509 context,
510 error_msg
511 );
512 if should_panic_on_js_errors() {
513 panic!(
514 "Unhandled JS exception after running jobs in {}: {}",
515 context, error_msg
516 );
517 }
518 }
519
520 count
521}
522
523fn parse_text_property_entry(
525 ctx: &rquickjs::Ctx<'_>,
526 obj: &Object<'_>,
527) -> Option<TextPropertyEntry> {
528 let text: String = obj.get("text").ok()?;
529 let properties: HashMap<String, serde_json::Value> = obj
530 .get::<_, Object>("properties")
531 .ok()
532 .map(|props_obj| {
533 let mut map = HashMap::new();
534 for key in props_obj.keys::<String>().flatten() {
535 if let Ok(v) = props_obj.get::<_, Value>(&key) {
536 map.insert(key, js_to_json(ctx, v));
537 }
538 }
539 map
540 })
541 .unwrap_or_default();
542
543 let style: Option<fresh_core::api::OverlayOptions> =
545 obj.get::<_, Object>("style").ok().and_then(|style_obj| {
546 let json_val = js_to_json(ctx, Value::from_object(style_obj));
547 serde_json::from_value(json_val).ok()
548 });
549
550 let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
552 .get::<_, rquickjs::Array>("inlineOverlays")
553 .ok()
554 .map(|arr| {
555 arr.iter::<Object>()
556 .flatten()
557 .filter_map(|item| {
558 let json_val = js_to_json(ctx, Value::from_object(item));
559 serde_json::from_value(json_val).ok()
560 })
561 .collect()
562 })
563 .unwrap_or_default();
564
565 Some(TextPropertyEntry {
566 text,
567 properties,
568 style,
569 inline_overlays,
570 })
571}
572
573pub type PendingResponses =
575 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
576
577#[derive(Debug, Clone)]
579pub struct TsPluginInfo {
580 pub name: String,
581 pub path: PathBuf,
582 pub enabled: bool,
583}
584
585#[derive(Debug, Clone, Default)]
591pub struct PluginTrackedState {
592 pub overlay_namespaces: Vec<(BufferId, String)>,
594 pub virtual_line_namespaces: Vec<(BufferId, String)>,
596 pub line_indicator_namespaces: Vec<(BufferId, String)>,
598 pub virtual_text_ids: Vec<(BufferId, String)>,
600 pub file_explorer_namespaces: Vec<String>,
602 pub contexts_set: Vec<String>,
604 pub background_process_ids: Vec<u64>,
607 pub scroll_sync_group_ids: Vec<u32>,
609 pub virtual_buffer_ids: Vec<BufferId>,
611 pub composite_buffer_ids: Vec<BufferId>,
613 pub terminal_ids: Vec<fresh_core::TerminalId>,
615}
616
617pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
622
623#[derive(Debug, Clone)]
624pub struct PluginHandler {
625 pub plugin_name: String,
626 pub handler_name: String,
627}
628
629#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
632#[rquickjs::class]
633pub struct JsEditorApi {
634 #[qjs(skip_trace)]
635 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
636 #[qjs(skip_trace)]
637 command_sender: mpsc::Sender<PluginCommand>,
638 #[qjs(skip_trace)]
639 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
640 #[qjs(skip_trace)]
641 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
642 #[qjs(skip_trace)]
643 next_request_id: Rc<RefCell<u64>>,
644 #[qjs(skip_trace)]
645 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
646 #[qjs(skip_trace)]
647 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
648 #[qjs(skip_trace)]
649 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
650 #[qjs(skip_trace)]
651 async_resource_owners: AsyncResourceOwners,
652 pub plugin_name: String,
653}
654
655#[plugin_api_impl]
656#[rquickjs::methods(rename_all = "camelCase")]
657impl JsEditorApi {
658 pub fn api_version(&self) -> u32 {
663 2
664 }
665
666 pub fn get_active_buffer_id(&self) -> u32 {
668 self.state_snapshot
669 .read()
670 .map(|s| s.active_buffer_id.0 as u32)
671 .unwrap_or(0)
672 }
673
674 pub fn get_active_split_id(&self) -> u32 {
676 self.state_snapshot
677 .read()
678 .map(|s| s.active_split_id as u32)
679 .unwrap_or(0)
680 }
681
682 #[plugin_api(ts_return = "BufferInfo[]")]
684 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
685 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
686 s.buffers.values().cloned().collect()
687 } else {
688 Vec::new()
689 };
690 rquickjs_serde::to_value(ctx, &buffers)
691 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
692 }
693
694 pub fn debug(&self, msg: String) {
697 tracing::trace!("Plugin.debug: {}", msg);
698 }
699
700 pub fn info(&self, msg: String) {
701 tracing::info!("Plugin: {}", msg);
702 }
703
704 pub fn warn(&self, msg: String) {
705 tracing::warn!("Plugin: {}", msg);
706 }
707
708 pub fn error(&self, msg: String) {
709 tracing::error!("Plugin: {}", msg);
710 }
711
712 pub fn set_status(&self, msg: String) {
715 let _ = self
716 .command_sender
717 .send(PluginCommand::SetStatus { message: msg });
718 }
719
720 pub fn copy_to_clipboard(&self, text: String) {
723 let _ = self
724 .command_sender
725 .send(PluginCommand::SetClipboard { text });
726 }
727
728 pub fn set_clipboard(&self, text: String) {
729 let _ = self
730 .command_sender
731 .send(PluginCommand::SetClipboard { text });
732 }
733
734 pub fn register_command<'js>(
745 &self,
746 _ctx: rquickjs::Ctx<'js>,
747 name: String,
748 description: String,
749 handler_name: String,
750 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
751 rquickjs::Value<'js>,
752 >,
753 ) -> rquickjs::Result<bool> {
754 let plugin_name = self.plugin_name.clone();
756 let context_str: Option<String> = context.0.and_then(|v| {
758 if v.is_null() || v.is_undefined() {
759 None
760 } else {
761 v.as_string().and_then(|s| s.to_string().ok())
762 }
763 });
764
765 tracing::debug!(
766 "registerCommand: plugin='{}', name='{}', handler='{}'",
767 plugin_name,
768 name,
769 handler_name
770 );
771
772 self.registered_actions.borrow_mut().insert(
774 handler_name.clone(),
775 PluginHandler {
776 plugin_name: self.plugin_name.clone(),
777 handler_name: handler_name.clone(),
778 },
779 );
780
781 let command = Command {
783 name: name.clone(),
784 description,
785 action_name: handler_name,
786 plugin_name,
787 custom_contexts: context_str.into_iter().collect(),
788 };
789
790 Ok(self
791 .command_sender
792 .send(PluginCommand::RegisterCommand { command })
793 .is_ok())
794 }
795
796 pub fn unregister_command(&self, name: String) -> bool {
798 self.command_sender
799 .send(PluginCommand::UnregisterCommand { name })
800 .is_ok()
801 }
802
803 pub fn set_context(&self, name: String, active: bool) -> bool {
805 if active {
807 self.plugin_tracked_state
808 .borrow_mut()
809 .entry(self.plugin_name.clone())
810 .or_default()
811 .contexts_set
812 .push(name.clone());
813 }
814 self.command_sender
815 .send(PluginCommand::SetContext { name, active })
816 .is_ok()
817 }
818
819 pub fn execute_action(&self, action_name: String) -> bool {
821 self.command_sender
822 .send(PluginCommand::ExecuteAction { action_name })
823 .is_ok()
824 }
825
826 pub fn t<'js>(
831 &self,
832 _ctx: rquickjs::Ctx<'js>,
833 key: String,
834 args: rquickjs::function::Rest<Value<'js>>,
835 ) -> String {
836 let plugin_name = self.plugin_name.clone();
838 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
840 if let Some(obj) = first_arg.as_object() {
841 let mut map = HashMap::new();
842 for k in obj.keys::<String>().flatten() {
843 if let Ok(v) = obj.get::<_, String>(&k) {
844 map.insert(k, v);
845 }
846 }
847 map
848 } else {
849 HashMap::new()
850 }
851 } else {
852 HashMap::new()
853 };
854 let res = self.services.translate(&plugin_name, &key, &args_map);
855
856 tracing::info!(
857 "Translating: key={}, plugin={}, args={:?} => res='{}'",
858 key,
859 plugin_name,
860 args_map,
861 res
862 );
863 res
864 }
865
866 pub fn get_cursor_position(&self) -> u32 {
870 self.state_snapshot
871 .read()
872 .ok()
873 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
874 .unwrap_or(0)
875 }
876
877 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
879 if let Ok(s) = self.state_snapshot.read() {
880 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
881 if let Some(p) = &b.path {
882 return p.to_string_lossy().to_string();
883 }
884 }
885 }
886 String::new()
887 }
888
889 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
891 if let Ok(s) = self.state_snapshot.read() {
892 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
893 return b.length as u32;
894 }
895 }
896 0
897 }
898
899 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
901 if let Ok(s) = self.state_snapshot.read() {
902 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
903 return b.modified;
904 }
905 }
906 false
907 }
908
909 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
912 self.command_sender
913 .send(PluginCommand::SaveBufferToPath {
914 buffer_id: BufferId(buffer_id as usize),
915 path: std::path::PathBuf::from(path),
916 })
917 .is_ok()
918 }
919
920 #[plugin_api(ts_return = "BufferInfo | null")]
922 pub fn get_buffer_info<'js>(
923 &self,
924 ctx: rquickjs::Ctx<'js>,
925 buffer_id: u32,
926 ) -> rquickjs::Result<Value<'js>> {
927 let info = if let Ok(s) = self.state_snapshot.read() {
928 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
929 } else {
930 None
931 };
932 rquickjs_serde::to_value(ctx, &info)
933 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
934 }
935
936 #[plugin_api(ts_return = "CursorInfo | null")]
938 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
939 let cursor = if let Ok(s) = self.state_snapshot.read() {
940 s.primary_cursor.clone()
941 } else {
942 None
943 };
944 rquickjs_serde::to_value(ctx, &cursor)
945 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
946 }
947
948 #[plugin_api(ts_return = "CursorInfo[]")]
950 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
951 let cursors = if let Ok(s) = self.state_snapshot.read() {
952 s.all_cursors.clone()
953 } else {
954 Vec::new()
955 };
956 rquickjs_serde::to_value(ctx, &cursors)
957 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
958 }
959
960 #[plugin_api(ts_return = "number[]")]
962 pub fn get_all_cursor_positions<'js>(
963 &self,
964 ctx: rquickjs::Ctx<'js>,
965 ) -> rquickjs::Result<Value<'js>> {
966 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
967 s.all_cursors.iter().map(|c| c.position as u32).collect()
968 } else {
969 Vec::new()
970 };
971 rquickjs_serde::to_value(ctx, &positions)
972 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
973 }
974
975 #[plugin_api(ts_return = "ViewportInfo | null")]
977 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
978 let viewport = if let Ok(s) = self.state_snapshot.read() {
979 s.viewport.clone()
980 } else {
981 None
982 };
983 rquickjs_serde::to_value(ctx, &viewport)
984 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
985 }
986
987 pub fn get_cursor_line(&self) -> u32 {
989 0
993 }
994
995 #[plugin_api(
998 async_promise,
999 js_name = "getLineStartPosition",
1000 ts_return = "number | null"
1001 )]
1002 #[qjs(rename = "_getLineStartPositionStart")]
1003 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1004 let id = {
1005 let mut id_ref = self.next_request_id.borrow_mut();
1006 let id = *id_ref;
1007 *id_ref += 1;
1008 self.callback_contexts
1010 .borrow_mut()
1011 .insert(id, self.plugin_name.clone());
1012 id
1013 };
1014 let _ = self
1016 .command_sender
1017 .send(PluginCommand::GetLineStartPosition {
1018 buffer_id: BufferId(0),
1019 line,
1020 request_id: id,
1021 });
1022 id
1023 }
1024
1025 #[plugin_api(
1029 async_promise,
1030 js_name = "getLineEndPosition",
1031 ts_return = "number | null"
1032 )]
1033 #[qjs(rename = "_getLineEndPositionStart")]
1034 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1035 let id = {
1036 let mut id_ref = self.next_request_id.borrow_mut();
1037 let id = *id_ref;
1038 *id_ref += 1;
1039 self.callback_contexts
1040 .borrow_mut()
1041 .insert(id, self.plugin_name.clone());
1042 id
1043 };
1044 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1046 buffer_id: BufferId(0),
1047 line,
1048 request_id: id,
1049 });
1050 id
1051 }
1052
1053 #[plugin_api(
1056 async_promise,
1057 js_name = "getBufferLineCount",
1058 ts_return = "number | null"
1059 )]
1060 #[qjs(rename = "_getBufferLineCountStart")]
1061 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1062 let id = {
1063 let mut id_ref = self.next_request_id.borrow_mut();
1064 let id = *id_ref;
1065 *id_ref += 1;
1066 self.callback_contexts
1067 .borrow_mut()
1068 .insert(id, self.plugin_name.clone());
1069 id
1070 };
1071 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1073 buffer_id: BufferId(0),
1074 request_id: id,
1075 });
1076 id
1077 }
1078
1079 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1082 self.command_sender
1083 .send(PluginCommand::ScrollToLineCenter {
1084 split_id: SplitId(split_id as usize),
1085 buffer_id: BufferId(buffer_id as usize),
1086 line: line as usize,
1087 })
1088 .is_ok()
1089 }
1090
1091 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1093 let path_buf = std::path::PathBuf::from(&path);
1094 if let Ok(s) = self.state_snapshot.read() {
1095 for (id, info) in &s.buffers {
1096 if let Some(buf_path) = &info.path {
1097 if buf_path == &path_buf {
1098 return id.0 as u32;
1099 }
1100 }
1101 }
1102 }
1103 0
1104 }
1105
1106 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1108 pub fn get_buffer_saved_diff<'js>(
1109 &self,
1110 ctx: rquickjs::Ctx<'js>,
1111 buffer_id: u32,
1112 ) -> rquickjs::Result<Value<'js>> {
1113 let diff = if let Ok(s) = self.state_snapshot.read() {
1114 s.buffer_saved_diffs
1115 .get(&BufferId(buffer_id as usize))
1116 .cloned()
1117 } else {
1118 None
1119 };
1120 rquickjs_serde::to_value(ctx, &diff)
1121 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1122 }
1123
1124 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1128 self.command_sender
1129 .send(PluginCommand::InsertText {
1130 buffer_id: BufferId(buffer_id as usize),
1131 position: position as usize,
1132 text,
1133 })
1134 .is_ok()
1135 }
1136
1137 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1139 self.command_sender
1140 .send(PluginCommand::DeleteRange {
1141 buffer_id: BufferId(buffer_id as usize),
1142 range: (start as usize)..(end as usize),
1143 })
1144 .is_ok()
1145 }
1146
1147 pub fn insert_at_cursor(&self, text: String) -> bool {
1149 self.command_sender
1150 .send(PluginCommand::InsertAtCursor { text })
1151 .is_ok()
1152 }
1153
1154 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1158 self.command_sender
1159 .send(PluginCommand::OpenFileAtLocation {
1160 path: PathBuf::from(path),
1161 line: line.map(|l| l as usize),
1162 column: column.map(|c| c as usize),
1163 })
1164 .is_ok()
1165 }
1166
1167 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1169 self.command_sender
1170 .send(PluginCommand::OpenFileInSplit {
1171 split_id: split_id as usize,
1172 path: PathBuf::from(path),
1173 line: Some(line as usize),
1174 column: Some(column as usize),
1175 })
1176 .is_ok()
1177 }
1178
1179 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1181 self.command_sender
1182 .send(PluginCommand::ShowBuffer {
1183 buffer_id: BufferId(buffer_id as usize),
1184 })
1185 .is_ok()
1186 }
1187
1188 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1190 self.command_sender
1191 .send(PluginCommand::CloseBuffer {
1192 buffer_id: BufferId(buffer_id as usize),
1193 })
1194 .is_ok()
1195 }
1196
1197 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1201 if event_name == "lines_changed" {
1205 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1206 }
1207 self.event_handlers
1208 .borrow_mut()
1209 .entry(event_name)
1210 .or_default()
1211 .push(PluginHandler {
1212 plugin_name: self.plugin_name.clone(),
1213 handler_name,
1214 });
1215 }
1216
1217 pub fn off(&self, event_name: String, handler_name: String) {
1219 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1220 list.retain(|h| h.handler_name != handler_name);
1221 }
1222 }
1223
1224 pub fn get_env(&self, name: String) -> Option<String> {
1228 std::env::var(&name).ok()
1229 }
1230
1231 pub fn get_cwd(&self) -> String {
1233 self.state_snapshot
1234 .read()
1235 .map(|s| s.working_dir.to_string_lossy().to_string())
1236 .unwrap_or_else(|_| ".".to_string())
1237 }
1238
1239 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1244 let mut result_parts: Vec<String> = Vec::new();
1245 let mut has_leading_slash = false;
1246
1247 for part in &parts.0 {
1248 let normalized = part.replace('\\', "/");
1250
1251 let is_absolute = normalized.starts_with('/')
1253 || (normalized.len() >= 2
1254 && normalized
1255 .chars()
1256 .next()
1257 .map(|c| c.is_ascii_alphabetic())
1258 .unwrap_or(false)
1259 && normalized.chars().nth(1) == Some(':'));
1260
1261 if is_absolute {
1262 result_parts.clear();
1264 has_leading_slash = normalized.starts_with('/');
1265 }
1266
1267 for segment in normalized.split('/') {
1269 if !segment.is_empty() && segment != "." {
1270 if segment == ".." {
1271 result_parts.pop();
1272 } else {
1273 result_parts.push(segment.to_string());
1274 }
1275 }
1276 }
1277 }
1278
1279 let joined = result_parts.join("/");
1281
1282 if has_leading_slash && !joined.is_empty() {
1284 format!("/{}", joined)
1285 } else {
1286 joined
1287 }
1288 }
1289
1290 pub fn path_dirname(&self, path: String) -> String {
1292 Path::new(&path)
1293 .parent()
1294 .map(|p| p.to_string_lossy().to_string())
1295 .unwrap_or_default()
1296 }
1297
1298 pub fn path_basename(&self, path: String) -> String {
1300 Path::new(&path)
1301 .file_name()
1302 .map(|s| s.to_string_lossy().to_string())
1303 .unwrap_or_default()
1304 }
1305
1306 pub fn path_extname(&self, path: String) -> String {
1308 Path::new(&path)
1309 .extension()
1310 .map(|s| format!(".{}", s.to_string_lossy()))
1311 .unwrap_or_default()
1312 }
1313
1314 pub fn path_is_absolute(&self, path: String) -> bool {
1316 Path::new(&path).is_absolute()
1317 }
1318
1319 pub fn file_uri_to_path(&self, uri: String) -> String {
1323 url::Url::parse(&uri)
1324 .ok()
1325 .and_then(|u| u.to_file_path().ok())
1326 .map(|p| p.to_string_lossy().to_string())
1327 .unwrap_or_default()
1328 }
1329
1330 pub fn path_to_file_uri(&self, path: String) -> String {
1334 url::Url::from_file_path(&path)
1335 .map(|u| u.to_string())
1336 .unwrap_or_default()
1337 }
1338
1339 pub fn utf8_byte_length(&self, text: String) -> u32 {
1347 text.len() as u32
1348 }
1349
1350 pub fn file_exists(&self, path: String) -> bool {
1354 Path::new(&path).exists()
1355 }
1356
1357 pub fn read_file(&self, path: String) -> Option<String> {
1359 std::fs::read_to_string(&path).ok()
1360 }
1361
1362 pub fn write_file(&self, path: String, content: String) -> bool {
1364 let p = Path::new(&path);
1365 if let Some(parent) = p.parent() {
1366 if !parent.exists() {
1367 if std::fs::create_dir_all(parent).is_err() {
1368 return false;
1369 }
1370 }
1371 }
1372 std::fs::write(p, content).is_ok()
1373 }
1374
1375 #[plugin_api(ts_return = "DirEntry[]")]
1377 pub fn read_dir<'js>(
1378 &self,
1379 ctx: rquickjs::Ctx<'js>,
1380 path: String,
1381 ) -> rquickjs::Result<Value<'js>> {
1382 use fresh_core::api::DirEntry;
1383
1384 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1385 Ok(entries) => entries
1386 .filter_map(|e| e.ok())
1387 .map(|entry| {
1388 let file_type = entry.file_type().ok();
1389 DirEntry {
1390 name: entry.file_name().to_string_lossy().to_string(),
1391 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1392 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1393 }
1394 })
1395 .collect(),
1396 Err(e) => {
1397 tracing::warn!("readDir failed for '{}': {}", path, e);
1398 Vec::new()
1399 }
1400 };
1401
1402 rquickjs_serde::to_value(ctx, &entries)
1403 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1404 }
1405
1406 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1410 let config: serde_json::Value = self
1411 .state_snapshot
1412 .read()
1413 .map(|s| s.config.clone())
1414 .unwrap_or_else(|_| serde_json::json!({}));
1415
1416 rquickjs_serde::to_value(ctx, &config)
1417 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1418 }
1419
1420 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1422 let config: serde_json::Value = self
1423 .state_snapshot
1424 .read()
1425 .map(|s| s.user_config.clone())
1426 .unwrap_or_else(|_| serde_json::json!({}));
1427
1428 rquickjs_serde::to_value(ctx, &config)
1429 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1430 }
1431
1432 pub fn reload_config(&self) {
1434 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1435 }
1436
1437 pub fn reload_themes(&self) {
1440 let _ = self
1441 .command_sender
1442 .send(PluginCommand::ReloadThemes { apply_theme: None });
1443 }
1444
1445 pub fn reload_and_apply_theme(&self, theme_name: String) {
1447 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1448 apply_theme: Some(theme_name),
1449 });
1450 }
1451
1452 pub fn register_grammar(
1455 &self,
1456 language: String,
1457 grammar_path: String,
1458 extensions: Vec<String>,
1459 ) -> bool {
1460 self.command_sender
1461 .send(PluginCommand::RegisterGrammar {
1462 language,
1463 grammar_path,
1464 extensions,
1465 })
1466 .is_ok()
1467 }
1468
1469 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1471 self.command_sender
1472 .send(PluginCommand::RegisterLanguageConfig { language, config })
1473 .is_ok()
1474 }
1475
1476 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1478 self.command_sender
1479 .send(PluginCommand::RegisterLspServer { language, config })
1480 .is_ok()
1481 }
1482
1483 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1487 #[qjs(rename = "_reloadGrammarsStart")]
1488 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1489 let id = {
1490 let mut id_ref = self.next_request_id.borrow_mut();
1491 let id = *id_ref;
1492 *id_ref += 1;
1493 self.callback_contexts
1494 .borrow_mut()
1495 .insert(id, self.plugin_name.clone());
1496 id
1497 };
1498 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1499 callback_id: fresh_core::api::JsCallbackId::new(id),
1500 });
1501 id
1502 }
1503
1504 pub fn get_config_dir(&self) -> String {
1506 self.services.config_dir().to_string_lossy().to_string()
1507 }
1508
1509 pub fn get_themes_dir(&self) -> String {
1511 self.services
1512 .config_dir()
1513 .join("themes")
1514 .to_string_lossy()
1515 .to_string()
1516 }
1517
1518 pub fn apply_theme(&self, theme_name: String) -> bool {
1520 self.command_sender
1521 .send(PluginCommand::ApplyTheme { theme_name })
1522 .is_ok()
1523 }
1524
1525 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1527 let schema = self.services.get_theme_schema();
1528 rquickjs_serde::to_value(ctx, &schema)
1529 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1530 }
1531
1532 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1534 let themes = self.services.get_builtin_themes();
1535 rquickjs_serde::to_value(ctx, &themes)
1536 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1537 }
1538
1539 #[qjs(rename = "_deleteThemeSync")]
1541 pub fn delete_theme_sync(&self, name: String) -> bool {
1542 let themes_dir = self.services.config_dir().join("themes");
1544 let theme_path = themes_dir.join(format!("{}.json", name));
1545
1546 if let Ok(canonical) = theme_path.canonicalize() {
1548 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1549 if canonical.starts_with(&themes_canonical) {
1550 return std::fs::remove_file(&canonical).is_ok();
1551 }
1552 }
1553 }
1554 false
1555 }
1556
1557 pub fn delete_theme(&self, name: String) -> bool {
1559 self.delete_theme_sync(name)
1560 }
1561
1562 pub fn get_theme_data<'js>(
1564 &self,
1565 ctx: rquickjs::Ctx<'js>,
1566 name: String,
1567 ) -> rquickjs::Result<Value<'js>> {
1568 match self.services.get_theme_data(&name) {
1569 Some(data) => rquickjs_serde::to_value(ctx, &data)
1570 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1571 None => Ok(Value::new_null(ctx)),
1572 }
1573 }
1574
1575 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1577 self.services
1578 .save_theme_file(&name, &content)
1579 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1580 }
1581
1582 pub fn theme_file_exists(&self, name: String) -> bool {
1584 self.services.theme_file_exists(&name)
1585 }
1586
1587 pub fn file_stat<'js>(
1591 &self,
1592 ctx: rquickjs::Ctx<'js>,
1593 path: String,
1594 ) -> rquickjs::Result<Value<'js>> {
1595 let metadata = std::fs::metadata(&path).ok();
1596 let stat = metadata.map(|m| {
1597 serde_json::json!({
1598 "isFile": m.is_file(),
1599 "isDir": m.is_dir(),
1600 "size": m.len(),
1601 "readonly": m.permissions().readonly(),
1602 })
1603 });
1604 rquickjs_serde::to_value(ctx, &stat)
1605 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1606 }
1607
1608 pub fn is_process_running(&self, _process_id: u64) -> bool {
1612 false
1615 }
1616
1617 pub fn kill_process(&self, process_id: u64) -> bool {
1619 self.command_sender
1620 .send(PluginCommand::KillBackgroundProcess { process_id })
1621 .is_ok()
1622 }
1623
1624 pub fn plugin_translate<'js>(
1628 &self,
1629 _ctx: rquickjs::Ctx<'js>,
1630 plugin_name: String,
1631 key: String,
1632 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1633 ) -> String {
1634 let args_map: HashMap<String, String> = args
1635 .0
1636 .map(|obj| {
1637 let mut map = HashMap::new();
1638 for (k, v) in obj.props::<String, String>().flatten() {
1639 map.insert(k, v);
1640 }
1641 map
1642 })
1643 .unwrap_or_default();
1644
1645 self.services.translate(&plugin_name, &key, &args_map)
1646 }
1647
1648 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1655 #[qjs(rename = "_createCompositeBufferStart")]
1656 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1657 let id = {
1658 let mut id_ref = self.next_request_id.borrow_mut();
1659 let id = *id_ref;
1660 *id_ref += 1;
1661 self.callback_contexts
1663 .borrow_mut()
1664 .insert(id, self.plugin_name.clone());
1665 id
1666 };
1667
1668 if let Ok(mut owners) = self.async_resource_owners.lock() {
1670 owners.insert(id, self.plugin_name.clone());
1671 }
1672 let _ = self
1673 .command_sender
1674 .send(PluginCommand::CreateCompositeBuffer {
1675 name: opts.name,
1676 mode: opts.mode,
1677 layout: opts.layout,
1678 sources: opts.sources,
1679 hunks: opts.hunks,
1680 request_id: Some(id),
1681 });
1682
1683 id
1684 }
1685
1686 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1690 self.command_sender
1691 .send(PluginCommand::UpdateCompositeAlignment {
1692 buffer_id: BufferId(buffer_id as usize),
1693 hunks,
1694 })
1695 .is_ok()
1696 }
1697
1698 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1700 self.command_sender
1701 .send(PluginCommand::CloseCompositeBuffer {
1702 buffer_id: BufferId(buffer_id as usize),
1703 })
1704 .is_ok()
1705 }
1706
1707 #[plugin_api(
1711 async_promise,
1712 js_name = "getHighlights",
1713 ts_return = "TsHighlightSpan[]"
1714 )]
1715 #[qjs(rename = "_getHighlightsStart")]
1716 pub fn get_highlights_start<'js>(
1717 &self,
1718 _ctx: rquickjs::Ctx<'js>,
1719 buffer_id: u32,
1720 start: u32,
1721 end: u32,
1722 ) -> rquickjs::Result<u64> {
1723 let id = {
1724 let mut id_ref = self.next_request_id.borrow_mut();
1725 let id = *id_ref;
1726 *id_ref += 1;
1727 self.callback_contexts
1729 .borrow_mut()
1730 .insert(id, self.plugin_name.clone());
1731 id
1732 };
1733
1734 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1735 buffer_id: BufferId(buffer_id as usize),
1736 range: (start as usize)..(end as usize),
1737 request_id: id,
1738 });
1739
1740 Ok(id)
1741 }
1742
1743 pub fn add_overlay<'js>(
1765 &self,
1766 _ctx: rquickjs::Ctx<'js>,
1767 buffer_id: u32,
1768 namespace: String,
1769 start: u32,
1770 end: u32,
1771 options: rquickjs::Object<'js>,
1772 ) -> rquickjs::Result<bool> {
1773 use fresh_core::api::OverlayColorSpec;
1774
1775 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1777 if let Ok(theme_key) = obj.get::<_, String>(key) {
1779 if !theme_key.is_empty() {
1780 return Some(OverlayColorSpec::ThemeKey(theme_key));
1781 }
1782 }
1783 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1785 if arr.len() >= 3 {
1786 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1787 }
1788 }
1789 None
1790 }
1791
1792 let fg = parse_color_spec("fg", &options);
1793 let bg = parse_color_spec("bg", &options);
1794 let underline: bool = options.get("underline").unwrap_or(false);
1795 let bold: bool = options.get("bold").unwrap_or(false);
1796 let italic: bool = options.get("italic").unwrap_or(false);
1797 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
1798 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1799 let url: Option<String> = options.get("url").ok();
1800
1801 let options = OverlayOptions {
1802 fg,
1803 bg,
1804 underline,
1805 bold,
1806 italic,
1807 strikethrough,
1808 extend_to_line_end,
1809 url,
1810 };
1811
1812 self.plugin_tracked_state
1814 .borrow_mut()
1815 .entry(self.plugin_name.clone())
1816 .or_default()
1817 .overlay_namespaces
1818 .push((BufferId(buffer_id as usize), namespace.clone()));
1819
1820 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1821 buffer_id: BufferId(buffer_id as usize),
1822 namespace: Some(OverlayNamespace::from_string(namespace)),
1823 range: (start as usize)..(end as usize),
1824 options,
1825 });
1826
1827 Ok(true)
1828 }
1829
1830 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1832 self.command_sender
1833 .send(PluginCommand::ClearNamespace {
1834 buffer_id: BufferId(buffer_id as usize),
1835 namespace: OverlayNamespace::from_string(namespace),
1836 })
1837 .is_ok()
1838 }
1839
1840 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1842 self.command_sender
1843 .send(PluginCommand::ClearAllOverlays {
1844 buffer_id: BufferId(buffer_id as usize),
1845 })
1846 .is_ok()
1847 }
1848
1849 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1851 self.command_sender
1852 .send(PluginCommand::ClearOverlaysInRange {
1853 buffer_id: BufferId(buffer_id as usize),
1854 start: start as usize,
1855 end: end as usize,
1856 })
1857 .is_ok()
1858 }
1859
1860 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1862 use fresh_core::overlay::OverlayHandle;
1863 self.command_sender
1864 .send(PluginCommand::RemoveOverlay {
1865 buffer_id: BufferId(buffer_id as usize),
1866 handle: OverlayHandle(handle),
1867 })
1868 .is_ok()
1869 }
1870
1871 pub fn add_conceal(
1875 &self,
1876 buffer_id: u32,
1877 namespace: String,
1878 start: u32,
1879 end: u32,
1880 replacement: Option<String>,
1881 ) -> bool {
1882 self.plugin_tracked_state
1884 .borrow_mut()
1885 .entry(self.plugin_name.clone())
1886 .or_default()
1887 .overlay_namespaces
1888 .push((BufferId(buffer_id as usize), namespace.clone()));
1889
1890 self.command_sender
1891 .send(PluginCommand::AddConceal {
1892 buffer_id: BufferId(buffer_id as usize),
1893 namespace: OverlayNamespace::from_string(namespace),
1894 start: start as usize,
1895 end: end as usize,
1896 replacement,
1897 })
1898 .is_ok()
1899 }
1900
1901 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1903 self.command_sender
1904 .send(PluginCommand::ClearConcealNamespace {
1905 buffer_id: BufferId(buffer_id as usize),
1906 namespace: OverlayNamespace::from_string(namespace),
1907 })
1908 .is_ok()
1909 }
1910
1911 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1913 self.command_sender
1914 .send(PluginCommand::ClearConcealsInRange {
1915 buffer_id: BufferId(buffer_id as usize),
1916 start: start as usize,
1917 end: end as usize,
1918 })
1919 .is_ok()
1920 }
1921
1922 pub fn add_soft_break(
1926 &self,
1927 buffer_id: u32,
1928 namespace: String,
1929 position: u32,
1930 indent: u32,
1931 ) -> bool {
1932 self.plugin_tracked_state
1934 .borrow_mut()
1935 .entry(self.plugin_name.clone())
1936 .or_default()
1937 .overlay_namespaces
1938 .push((BufferId(buffer_id as usize), namespace.clone()));
1939
1940 self.command_sender
1941 .send(PluginCommand::AddSoftBreak {
1942 buffer_id: BufferId(buffer_id as usize),
1943 namespace: OverlayNamespace::from_string(namespace),
1944 position: position as usize,
1945 indent: indent as u16,
1946 })
1947 .is_ok()
1948 }
1949
1950 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1952 self.command_sender
1953 .send(PluginCommand::ClearSoftBreakNamespace {
1954 buffer_id: BufferId(buffer_id as usize),
1955 namespace: OverlayNamespace::from_string(namespace),
1956 })
1957 .is_ok()
1958 }
1959
1960 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1962 self.command_sender
1963 .send(PluginCommand::ClearSoftBreaksInRange {
1964 buffer_id: BufferId(buffer_id as usize),
1965 start: start as usize,
1966 end: end as usize,
1967 })
1968 .is_ok()
1969 }
1970
1971 #[allow(clippy::too_many_arguments)]
1981 pub fn submit_view_transform<'js>(
1982 &self,
1983 _ctx: rquickjs::Ctx<'js>,
1984 buffer_id: u32,
1985 split_id: Option<u32>,
1986 start: u32,
1987 end: u32,
1988 tokens: Vec<rquickjs::Object<'js>>,
1989 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1990 ) -> rquickjs::Result<bool> {
1991 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1992
1993 let tokens: Vec<ViewTokenWire> = tokens
1994 .into_iter()
1995 .enumerate()
1996 .map(|(idx, obj)| {
1997 parse_view_token(&obj, idx)
1999 })
2000 .collect::<rquickjs::Result<Vec<_>>>()?;
2001
2002 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2004 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2005 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2006 Some(LayoutHints {
2007 compose_width,
2008 column_guides,
2009 })
2010 } else {
2011 None
2012 };
2013
2014 let payload = ViewTransformPayload {
2015 range: (start as usize)..(end as usize),
2016 tokens,
2017 layout_hints: parsed_layout_hints,
2018 };
2019
2020 Ok(self
2021 .command_sender
2022 .send(PluginCommand::SubmitViewTransform {
2023 buffer_id: BufferId(buffer_id as usize),
2024 split_id: split_id.map(|id| SplitId(id as usize)),
2025 payload,
2026 })
2027 .is_ok())
2028 }
2029
2030 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2032 self.command_sender
2033 .send(PluginCommand::ClearViewTransform {
2034 buffer_id: BufferId(buffer_id as usize),
2035 split_id: split_id.map(|id| SplitId(id as usize)),
2036 })
2037 .is_ok()
2038 }
2039
2040 pub fn set_layout_hints<'js>(
2043 &self,
2044 buffer_id: u32,
2045 split_id: Option<u32>,
2046 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2047 ) -> rquickjs::Result<bool> {
2048 use fresh_core::api::LayoutHints;
2049
2050 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2051 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2052 let parsed_hints = LayoutHints {
2053 compose_width,
2054 column_guides,
2055 };
2056
2057 Ok(self
2058 .command_sender
2059 .send(PluginCommand::SetLayoutHints {
2060 buffer_id: BufferId(buffer_id as usize),
2061 split_id: split_id.map(|id| SplitId(id as usize)),
2062 range: 0..0,
2063 hints: parsed_hints,
2064 })
2065 .is_ok())
2066 }
2067
2068 pub fn set_file_explorer_decorations<'js>(
2072 &self,
2073 _ctx: rquickjs::Ctx<'js>,
2074 namespace: String,
2075 decorations: Vec<rquickjs::Object<'js>>,
2076 ) -> rquickjs::Result<bool> {
2077 use fresh_core::file_explorer::FileExplorerDecoration;
2078
2079 let decorations: Vec<FileExplorerDecoration> = decorations
2080 .into_iter()
2081 .map(|obj| {
2082 let path: String = obj.get("path")?;
2083 let symbol: String = obj.get("symbol")?;
2084 let color: Vec<u8> = obj.get("color")?;
2085 let priority: i32 = obj.get("priority").unwrap_or(0);
2086
2087 if color.len() < 3 {
2088 return Err(rquickjs::Error::FromJs {
2089 from: "array",
2090 to: "color",
2091 message: Some(format!(
2092 "color array must have at least 3 elements, got {}",
2093 color.len()
2094 )),
2095 });
2096 }
2097
2098 Ok(FileExplorerDecoration {
2099 path: std::path::PathBuf::from(path),
2100 symbol,
2101 color: [color[0], color[1], color[2]],
2102 priority,
2103 })
2104 })
2105 .collect::<rquickjs::Result<Vec<_>>>()?;
2106
2107 self.plugin_tracked_state
2109 .borrow_mut()
2110 .entry(self.plugin_name.clone())
2111 .or_default()
2112 .file_explorer_namespaces
2113 .push(namespace.clone());
2114
2115 Ok(self
2116 .command_sender
2117 .send(PluginCommand::SetFileExplorerDecorations {
2118 namespace,
2119 decorations,
2120 })
2121 .is_ok())
2122 }
2123
2124 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2126 self.command_sender
2127 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2128 .is_ok()
2129 }
2130
2131 #[allow(clippy::too_many_arguments)]
2135 pub fn add_virtual_text(
2136 &self,
2137 buffer_id: u32,
2138 virtual_text_id: String,
2139 position: u32,
2140 text: String,
2141 r: u8,
2142 g: u8,
2143 b: u8,
2144 before: bool,
2145 use_bg: bool,
2146 ) -> bool {
2147 self.plugin_tracked_state
2149 .borrow_mut()
2150 .entry(self.plugin_name.clone())
2151 .or_default()
2152 .virtual_text_ids
2153 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2154
2155 self.command_sender
2156 .send(PluginCommand::AddVirtualText {
2157 buffer_id: BufferId(buffer_id as usize),
2158 virtual_text_id,
2159 position: position as usize,
2160 text,
2161 color: (r, g, b),
2162 use_bg,
2163 before,
2164 })
2165 .is_ok()
2166 }
2167
2168 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2170 self.command_sender
2171 .send(PluginCommand::RemoveVirtualText {
2172 buffer_id: BufferId(buffer_id as usize),
2173 virtual_text_id,
2174 })
2175 .is_ok()
2176 }
2177
2178 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2180 self.command_sender
2181 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2182 buffer_id: BufferId(buffer_id as usize),
2183 prefix,
2184 })
2185 .is_ok()
2186 }
2187
2188 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2190 self.command_sender
2191 .send(PluginCommand::ClearVirtualTexts {
2192 buffer_id: BufferId(buffer_id as usize),
2193 })
2194 .is_ok()
2195 }
2196
2197 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2199 self.command_sender
2200 .send(PluginCommand::ClearVirtualTextNamespace {
2201 buffer_id: BufferId(buffer_id as usize),
2202 namespace,
2203 })
2204 .is_ok()
2205 }
2206
2207 #[allow(clippy::too_many_arguments)]
2209 pub fn add_virtual_line(
2210 &self,
2211 buffer_id: u32,
2212 position: u32,
2213 text: String,
2214 fg_r: u8,
2215 fg_g: u8,
2216 fg_b: u8,
2217 bg_r: u8,
2218 bg_g: u8,
2219 bg_b: u8,
2220 above: bool,
2221 namespace: String,
2222 priority: i32,
2223 ) -> bool {
2224 self.plugin_tracked_state
2226 .borrow_mut()
2227 .entry(self.plugin_name.clone())
2228 .or_default()
2229 .virtual_line_namespaces
2230 .push((BufferId(buffer_id as usize), namespace.clone()));
2231
2232 self.command_sender
2233 .send(PluginCommand::AddVirtualLine {
2234 buffer_id: BufferId(buffer_id as usize),
2235 position: position as usize,
2236 text,
2237 fg_color: (fg_r, fg_g, fg_b),
2238 bg_color: Some((bg_r, bg_g, bg_b)),
2239 above,
2240 namespace,
2241 priority,
2242 })
2243 .is_ok()
2244 }
2245
2246 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2251 #[qjs(rename = "_promptStart")]
2252 pub fn prompt_start(
2253 &self,
2254 _ctx: rquickjs::Ctx<'_>,
2255 label: String,
2256 initial_value: String,
2257 ) -> u64 {
2258 let id = {
2259 let mut id_ref = self.next_request_id.borrow_mut();
2260 let id = *id_ref;
2261 *id_ref += 1;
2262 self.callback_contexts
2264 .borrow_mut()
2265 .insert(id, self.plugin_name.clone());
2266 id
2267 };
2268
2269 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2270 label,
2271 initial_value,
2272 callback_id: JsCallbackId::new(id),
2273 });
2274
2275 id
2276 }
2277
2278 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2280 self.command_sender
2281 .send(PluginCommand::StartPrompt { label, prompt_type })
2282 .is_ok()
2283 }
2284
2285 pub fn start_prompt_with_initial(
2287 &self,
2288 label: String,
2289 prompt_type: String,
2290 initial_value: String,
2291 ) -> bool {
2292 self.command_sender
2293 .send(PluginCommand::StartPromptWithInitial {
2294 label,
2295 prompt_type,
2296 initial_value,
2297 })
2298 .is_ok()
2299 }
2300
2301 pub fn set_prompt_suggestions(
2305 &self,
2306 suggestions: Vec<fresh_core::command::Suggestion>,
2307 ) -> bool {
2308 self.command_sender
2309 .send(PluginCommand::SetPromptSuggestions { suggestions })
2310 .is_ok()
2311 }
2312
2313 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2314 self.command_sender
2315 .send(PluginCommand::SetPromptInputSync { sync })
2316 .is_ok()
2317 }
2318
2319 pub fn define_mode(
2323 &self,
2324 name: String,
2325 parent: Option<String>,
2326 bindings_arr: Vec<Vec<String>>,
2327 read_only: rquickjs::function::Opt<bool>,
2328 ) -> bool {
2329 let bindings: Vec<(String, String)> = bindings_arr
2330 .into_iter()
2331 .filter_map(|arr| {
2332 if arr.len() >= 2 {
2333 Some((arr[0].clone(), arr[1].clone()))
2334 } else {
2335 None
2336 }
2337 })
2338 .collect();
2339
2340 {
2343 let mut registered = self.registered_actions.borrow_mut();
2344 for (_, cmd_name) in &bindings {
2345 registered.insert(
2346 cmd_name.clone(),
2347 PluginHandler {
2348 plugin_name: self.plugin_name.clone(),
2349 handler_name: cmd_name.clone(),
2350 },
2351 );
2352 }
2353 }
2354
2355 self.command_sender
2356 .send(PluginCommand::DefineMode {
2357 name,
2358 parent,
2359 bindings,
2360 read_only: read_only.0.unwrap_or(false),
2361 })
2362 .is_ok()
2363 }
2364
2365 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2367 self.command_sender
2368 .send(PluginCommand::SetEditorMode { mode })
2369 .is_ok()
2370 }
2371
2372 pub fn get_editor_mode(&self) -> Option<String> {
2374 self.state_snapshot
2375 .read()
2376 .ok()
2377 .and_then(|s| s.editor_mode.clone())
2378 }
2379
2380 pub fn close_split(&self, split_id: u32) -> bool {
2384 self.command_sender
2385 .send(PluginCommand::CloseSplit {
2386 split_id: SplitId(split_id as usize),
2387 })
2388 .is_ok()
2389 }
2390
2391 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2393 self.command_sender
2394 .send(PluginCommand::SetSplitBuffer {
2395 split_id: SplitId(split_id as usize),
2396 buffer_id: BufferId(buffer_id as usize),
2397 })
2398 .is_ok()
2399 }
2400
2401 pub fn focus_split(&self, split_id: u32) -> bool {
2403 self.command_sender
2404 .send(PluginCommand::FocusSplit {
2405 split_id: SplitId(split_id as usize),
2406 })
2407 .is_ok()
2408 }
2409
2410 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2412 self.command_sender
2413 .send(PluginCommand::SetSplitScroll {
2414 split_id: SplitId(split_id as usize),
2415 top_byte: top_byte as usize,
2416 })
2417 .is_ok()
2418 }
2419
2420 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2422 self.command_sender
2423 .send(PluginCommand::SetSplitRatio {
2424 split_id: SplitId(split_id as usize),
2425 ratio,
2426 })
2427 .is_ok()
2428 }
2429
2430 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2432 self.command_sender
2433 .send(PluginCommand::SetSplitLabel {
2434 split_id: SplitId(split_id as usize),
2435 label,
2436 })
2437 .is_ok()
2438 }
2439
2440 pub fn clear_split_label(&self, split_id: u32) -> bool {
2442 self.command_sender
2443 .send(PluginCommand::ClearSplitLabel {
2444 split_id: SplitId(split_id as usize),
2445 })
2446 .is_ok()
2447 }
2448
2449 #[plugin_api(
2451 async_promise,
2452 js_name = "getSplitByLabel",
2453 ts_return = "number | null"
2454 )]
2455 #[qjs(rename = "_getSplitByLabelStart")]
2456 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2457 let id = {
2458 let mut id_ref = self.next_request_id.borrow_mut();
2459 let id = *id_ref;
2460 *id_ref += 1;
2461 self.callback_contexts
2462 .borrow_mut()
2463 .insert(id, self.plugin_name.clone());
2464 id
2465 };
2466 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2467 label,
2468 request_id: id,
2469 });
2470 id
2471 }
2472
2473 pub fn distribute_splits_evenly(&self) -> bool {
2475 self.command_sender
2477 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2478 .is_ok()
2479 }
2480
2481 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2483 self.command_sender
2484 .send(PluginCommand::SetBufferCursor {
2485 buffer_id: BufferId(buffer_id as usize),
2486 position: position as usize,
2487 })
2488 .is_ok()
2489 }
2490
2491 #[allow(clippy::too_many_arguments)]
2495 pub fn set_line_indicator(
2496 &self,
2497 buffer_id: u32,
2498 line: u32,
2499 namespace: String,
2500 symbol: String,
2501 r: u8,
2502 g: u8,
2503 b: u8,
2504 priority: i32,
2505 ) -> bool {
2506 self.plugin_tracked_state
2508 .borrow_mut()
2509 .entry(self.plugin_name.clone())
2510 .or_default()
2511 .line_indicator_namespaces
2512 .push((BufferId(buffer_id as usize), namespace.clone()));
2513
2514 self.command_sender
2515 .send(PluginCommand::SetLineIndicator {
2516 buffer_id: BufferId(buffer_id as usize),
2517 line: line as usize,
2518 namespace,
2519 symbol,
2520 color: (r, g, b),
2521 priority,
2522 })
2523 .is_ok()
2524 }
2525
2526 #[allow(clippy::too_many_arguments)]
2528 pub fn set_line_indicators(
2529 &self,
2530 buffer_id: u32,
2531 lines: Vec<u32>,
2532 namespace: String,
2533 symbol: String,
2534 r: u8,
2535 g: u8,
2536 b: u8,
2537 priority: i32,
2538 ) -> bool {
2539 self.plugin_tracked_state
2541 .borrow_mut()
2542 .entry(self.plugin_name.clone())
2543 .or_default()
2544 .line_indicator_namespaces
2545 .push((BufferId(buffer_id as usize), namespace.clone()));
2546
2547 self.command_sender
2548 .send(PluginCommand::SetLineIndicators {
2549 buffer_id: BufferId(buffer_id as usize),
2550 lines: lines.into_iter().map(|l| l as usize).collect(),
2551 namespace,
2552 symbol,
2553 color: (r, g, b),
2554 priority,
2555 })
2556 .is_ok()
2557 }
2558
2559 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2561 self.command_sender
2562 .send(PluginCommand::ClearLineIndicators {
2563 buffer_id: BufferId(buffer_id as usize),
2564 namespace,
2565 })
2566 .is_ok()
2567 }
2568
2569 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2571 self.command_sender
2572 .send(PluginCommand::SetLineNumbers {
2573 buffer_id: BufferId(buffer_id as usize),
2574 enabled,
2575 })
2576 .is_ok()
2577 }
2578
2579 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2581 self.command_sender
2582 .send(PluginCommand::SetViewMode {
2583 buffer_id: BufferId(buffer_id as usize),
2584 mode,
2585 })
2586 .is_ok()
2587 }
2588
2589 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2591 self.command_sender
2592 .send(PluginCommand::SetLineWrap {
2593 buffer_id: BufferId(buffer_id as usize),
2594 split_id: split_id.map(|s| SplitId(s as usize)),
2595 enabled,
2596 })
2597 .is_ok()
2598 }
2599
2600 pub fn set_view_state<'js>(
2604 &self,
2605 ctx: rquickjs::Ctx<'js>,
2606 buffer_id: u32,
2607 key: String,
2608 value: Value<'js>,
2609 ) -> bool {
2610 let bid = BufferId(buffer_id as usize);
2611
2612 let json_value = if value.is_undefined() || value.is_null() {
2614 None
2615 } else {
2616 Some(js_to_json(&ctx, value))
2617 };
2618
2619 if let Ok(mut snapshot) = self.state_snapshot.write() {
2621 if let Some(ref json_val) = json_value {
2622 snapshot
2623 .plugin_view_states
2624 .entry(bid)
2625 .or_default()
2626 .insert(key.clone(), json_val.clone());
2627 } else {
2628 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2630 map.remove(&key);
2631 if map.is_empty() {
2632 snapshot.plugin_view_states.remove(&bid);
2633 }
2634 }
2635 }
2636 }
2637
2638 self.command_sender
2640 .send(PluginCommand::SetViewState {
2641 buffer_id: bid,
2642 key,
2643 value: json_value,
2644 })
2645 .is_ok()
2646 }
2647
2648 pub fn get_view_state<'js>(
2650 &self,
2651 ctx: rquickjs::Ctx<'js>,
2652 buffer_id: u32,
2653 key: String,
2654 ) -> rquickjs::Result<Value<'js>> {
2655 let bid = BufferId(buffer_id as usize);
2656 if let Ok(snapshot) = self.state_snapshot.read() {
2657 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2658 if let Some(json_val) = map.get(&key) {
2659 return json_to_js_value(&ctx, json_val);
2660 }
2661 }
2662 }
2663 Ok(Value::new_undefined(ctx.clone()))
2664 }
2665
2666 pub fn create_scroll_sync_group(
2670 &self,
2671 group_id: u32,
2672 left_split: u32,
2673 right_split: u32,
2674 ) -> bool {
2675 self.plugin_tracked_state
2677 .borrow_mut()
2678 .entry(self.plugin_name.clone())
2679 .or_default()
2680 .scroll_sync_group_ids
2681 .push(group_id);
2682 self.command_sender
2683 .send(PluginCommand::CreateScrollSyncGroup {
2684 group_id,
2685 left_split: SplitId(left_split as usize),
2686 right_split: SplitId(right_split as usize),
2687 })
2688 .is_ok()
2689 }
2690
2691 pub fn set_scroll_sync_anchors<'js>(
2693 &self,
2694 _ctx: rquickjs::Ctx<'js>,
2695 group_id: u32,
2696 anchors: Vec<Vec<u32>>,
2697 ) -> bool {
2698 let anchors: Vec<(usize, usize)> = anchors
2699 .into_iter()
2700 .filter_map(|pair| {
2701 if pair.len() >= 2 {
2702 Some((pair[0] as usize, pair[1] as usize))
2703 } else {
2704 None
2705 }
2706 })
2707 .collect();
2708 self.command_sender
2709 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2710 .is_ok()
2711 }
2712
2713 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2715 self.command_sender
2716 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2717 .is_ok()
2718 }
2719
2720 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2726 self.command_sender
2727 .send(PluginCommand::ExecuteActions { actions })
2728 .is_ok()
2729 }
2730
2731 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2735 self.command_sender
2736 .send(PluginCommand::ShowActionPopup {
2737 popup_id: opts.id,
2738 title: opts.title,
2739 message: opts.message,
2740 actions: opts.actions,
2741 })
2742 .is_ok()
2743 }
2744
2745 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2747 self.command_sender
2748 .send(PluginCommand::DisableLspForLanguage { language })
2749 .is_ok()
2750 }
2751
2752 pub fn restart_lsp_for_language(&self, language: String) -> bool {
2754 self.command_sender
2755 .send(PluginCommand::RestartLspForLanguage { language })
2756 .is_ok()
2757 }
2758
2759 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2762 self.command_sender
2763 .send(PluginCommand::SetLspRootUri { language, uri })
2764 .is_ok()
2765 }
2766
2767 #[plugin_api(ts_return = "JsDiagnostic[]")]
2769 pub fn get_all_diagnostics<'js>(
2770 &self,
2771 ctx: rquickjs::Ctx<'js>,
2772 ) -> rquickjs::Result<Value<'js>> {
2773 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2774
2775 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2776 let mut result: Vec<JsDiagnostic> = Vec::new();
2778 for (uri, diags) in &s.diagnostics {
2779 for diag in diags {
2780 result.push(JsDiagnostic {
2781 uri: uri.clone(),
2782 message: diag.message.clone(),
2783 severity: diag.severity.map(|s| match s {
2784 lsp_types::DiagnosticSeverity::ERROR => 1,
2785 lsp_types::DiagnosticSeverity::WARNING => 2,
2786 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2787 lsp_types::DiagnosticSeverity::HINT => 4,
2788 _ => 0,
2789 }),
2790 range: JsRange {
2791 start: JsPosition {
2792 line: diag.range.start.line,
2793 character: diag.range.start.character,
2794 },
2795 end: JsPosition {
2796 line: diag.range.end.line,
2797 character: diag.range.end.character,
2798 },
2799 },
2800 source: diag.source.clone(),
2801 });
2802 }
2803 }
2804 result
2805 } else {
2806 Vec::new()
2807 };
2808 rquickjs_serde::to_value(ctx, &diagnostics)
2809 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2810 }
2811
2812 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2814 self.event_handlers
2815 .borrow()
2816 .get(&event_name)
2817 .cloned()
2818 .unwrap_or_default()
2819 .into_iter()
2820 .map(|h| h.handler_name)
2821 .collect()
2822 }
2823
2824 #[plugin_api(
2828 async_promise,
2829 js_name = "createVirtualBuffer",
2830 ts_return = "VirtualBufferResult"
2831 )]
2832 #[qjs(rename = "_createVirtualBufferStart")]
2833 pub fn create_virtual_buffer_start(
2834 &self,
2835 _ctx: rquickjs::Ctx<'_>,
2836 opts: fresh_core::api::CreateVirtualBufferOptions,
2837 ) -> rquickjs::Result<u64> {
2838 let id = {
2839 let mut id_ref = self.next_request_id.borrow_mut();
2840 let id = *id_ref;
2841 *id_ref += 1;
2842 self.callback_contexts
2844 .borrow_mut()
2845 .insert(id, self.plugin_name.clone());
2846 id
2847 };
2848
2849 let entries: Vec<TextPropertyEntry> = opts
2851 .entries
2852 .unwrap_or_default()
2853 .into_iter()
2854 .map(|e| TextPropertyEntry {
2855 text: e.text,
2856 properties: e.properties.unwrap_or_default(),
2857 style: e.style,
2858 inline_overlays: e.inline_overlays.unwrap_or_default(),
2859 })
2860 .collect();
2861
2862 tracing::debug!(
2863 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2864 id
2865 );
2866 if let Ok(mut owners) = self.async_resource_owners.lock() {
2868 owners.insert(id, self.plugin_name.clone());
2869 }
2870 let _ = self
2871 .command_sender
2872 .send(PluginCommand::CreateVirtualBufferWithContent {
2873 name: opts.name,
2874 mode: opts.mode.unwrap_or_default(),
2875 read_only: opts.read_only.unwrap_or(false),
2876 entries,
2877 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2878 show_cursors: opts.show_cursors.unwrap_or(true),
2879 editing_disabled: opts.editing_disabled.unwrap_or(false),
2880 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2881 request_id: Some(id),
2882 });
2883 Ok(id)
2884 }
2885
2886 #[plugin_api(
2888 async_promise,
2889 js_name = "createVirtualBufferInSplit",
2890 ts_return = "VirtualBufferResult"
2891 )]
2892 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2893 pub fn create_virtual_buffer_in_split_start(
2894 &self,
2895 _ctx: rquickjs::Ctx<'_>,
2896 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
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
2904 .borrow_mut()
2905 .insert(id, self.plugin_name.clone());
2906 id
2907 };
2908
2909 let entries: Vec<TextPropertyEntry> = opts
2911 .entries
2912 .unwrap_or_default()
2913 .into_iter()
2914 .map(|e| TextPropertyEntry {
2915 text: e.text,
2916 properties: e.properties.unwrap_or_default(),
2917 style: e.style,
2918 inline_overlays: e.inline_overlays.unwrap_or_default(),
2919 })
2920 .collect();
2921
2922 if let Ok(mut owners) = self.async_resource_owners.lock() {
2924 owners.insert(id, self.plugin_name.clone());
2925 }
2926 let _ = self
2927 .command_sender
2928 .send(PluginCommand::CreateVirtualBufferInSplit {
2929 name: opts.name,
2930 mode: opts.mode.unwrap_or_default(),
2931 read_only: opts.read_only.unwrap_or(false),
2932 entries,
2933 ratio: opts.ratio.unwrap_or(0.5),
2934 direction: opts.direction,
2935 panel_id: opts.panel_id,
2936 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2937 show_cursors: opts.show_cursors.unwrap_or(true),
2938 editing_disabled: opts.editing_disabled.unwrap_or(false),
2939 line_wrap: opts.line_wrap,
2940 before: opts.before.unwrap_or(false),
2941 request_id: Some(id),
2942 });
2943 Ok(id)
2944 }
2945
2946 #[plugin_api(
2948 async_promise,
2949 js_name = "createVirtualBufferInExistingSplit",
2950 ts_return = "VirtualBufferResult"
2951 )]
2952 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2953 pub fn create_virtual_buffer_in_existing_split_start(
2954 &self,
2955 _ctx: rquickjs::Ctx<'_>,
2956 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2957 ) -> rquickjs::Result<u64> {
2958 let id = {
2959 let mut id_ref = self.next_request_id.borrow_mut();
2960 let id = *id_ref;
2961 *id_ref += 1;
2962 self.callback_contexts
2964 .borrow_mut()
2965 .insert(id, self.plugin_name.clone());
2966 id
2967 };
2968
2969 let entries: Vec<TextPropertyEntry> = opts
2971 .entries
2972 .unwrap_or_default()
2973 .into_iter()
2974 .map(|e| TextPropertyEntry {
2975 text: e.text,
2976 properties: e.properties.unwrap_or_default(),
2977 style: e.style,
2978 inline_overlays: e.inline_overlays.unwrap_or_default(),
2979 })
2980 .collect();
2981
2982 if let Ok(mut owners) = self.async_resource_owners.lock() {
2984 owners.insert(id, self.plugin_name.clone());
2985 }
2986 let _ = self
2987 .command_sender
2988 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2989 name: opts.name,
2990 mode: opts.mode.unwrap_or_default(),
2991 read_only: opts.read_only.unwrap_or(false),
2992 entries,
2993 split_id: SplitId(opts.split_id),
2994 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2995 show_cursors: opts.show_cursors.unwrap_or(true),
2996 editing_disabled: opts.editing_disabled.unwrap_or(false),
2997 line_wrap: opts.line_wrap,
2998 request_id: Some(id),
2999 });
3000 Ok(id)
3001 }
3002
3003 pub fn set_virtual_buffer_content<'js>(
3007 &self,
3008 ctx: rquickjs::Ctx<'js>,
3009 buffer_id: u32,
3010 entries_arr: Vec<rquickjs::Object<'js>>,
3011 ) -> rquickjs::Result<bool> {
3012 let entries: Vec<TextPropertyEntry> = entries_arr
3013 .iter()
3014 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3015 .collect();
3016 Ok(self
3017 .command_sender
3018 .send(PluginCommand::SetVirtualBufferContent {
3019 buffer_id: BufferId(buffer_id as usize),
3020 entries,
3021 })
3022 .is_ok())
3023 }
3024
3025 pub fn get_text_properties_at_cursor(
3027 &self,
3028 buffer_id: u32,
3029 ) -> fresh_core::api::TextPropertiesAtCursor {
3030 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3031 }
3032
3033 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3037 #[qjs(rename = "_spawnProcessStart")]
3038 pub fn spawn_process_start(
3039 &self,
3040 _ctx: rquickjs::Ctx<'_>,
3041 command: String,
3042 args: Vec<String>,
3043 cwd: rquickjs::function::Opt<String>,
3044 ) -> u64 {
3045 let id = {
3046 let mut id_ref = self.next_request_id.borrow_mut();
3047 let id = *id_ref;
3048 *id_ref += 1;
3049 self.callback_contexts
3051 .borrow_mut()
3052 .insert(id, self.plugin_name.clone());
3053 id
3054 };
3055 let effective_cwd = cwd.0.or_else(|| {
3057 self.state_snapshot
3058 .read()
3059 .ok()
3060 .map(|s| s.working_dir.to_string_lossy().to_string())
3061 });
3062 tracing::info!(
3063 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3064 self.plugin_name,
3065 command,
3066 args,
3067 effective_cwd,
3068 id
3069 );
3070 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3071 callback_id: JsCallbackId::new(id),
3072 command,
3073 args,
3074 cwd: effective_cwd,
3075 });
3076 id
3077 }
3078
3079 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3081 #[qjs(rename = "_spawnProcessWaitStart")]
3082 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3083 let id = {
3084 let mut id_ref = self.next_request_id.borrow_mut();
3085 let id = *id_ref;
3086 *id_ref += 1;
3087 self.callback_contexts
3089 .borrow_mut()
3090 .insert(id, self.plugin_name.clone());
3091 id
3092 };
3093 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3094 process_id,
3095 callback_id: JsCallbackId::new(id),
3096 });
3097 id
3098 }
3099
3100 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3102 #[qjs(rename = "_getBufferTextStart")]
3103 pub fn get_buffer_text_start(
3104 &self,
3105 _ctx: rquickjs::Ctx<'_>,
3106 buffer_id: u32,
3107 start: u32,
3108 end: u32,
3109 ) -> u64 {
3110 let id = {
3111 let mut id_ref = self.next_request_id.borrow_mut();
3112 let id = *id_ref;
3113 *id_ref += 1;
3114 self.callback_contexts
3116 .borrow_mut()
3117 .insert(id, self.plugin_name.clone());
3118 id
3119 };
3120 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3121 buffer_id: BufferId(buffer_id as usize),
3122 start: start as usize,
3123 end: end as usize,
3124 request_id: id,
3125 });
3126 id
3127 }
3128
3129 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3131 #[qjs(rename = "_delayStart")]
3132 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3133 let id = {
3134 let mut id_ref = self.next_request_id.borrow_mut();
3135 let id = *id_ref;
3136 *id_ref += 1;
3137 self.callback_contexts
3139 .borrow_mut()
3140 .insert(id, self.plugin_name.clone());
3141 id
3142 };
3143 let _ = self.command_sender.send(PluginCommand::Delay {
3144 callback_id: JsCallbackId::new(id),
3145 duration_ms,
3146 });
3147 id
3148 }
3149
3150 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3152 #[qjs(rename = "_sendLspRequestStart")]
3153 pub fn send_lsp_request_start<'js>(
3154 &self,
3155 ctx: rquickjs::Ctx<'js>,
3156 language: String,
3157 method: String,
3158 params: Option<rquickjs::Object<'js>>,
3159 ) -> rquickjs::Result<u64> {
3160 let id = {
3161 let mut id_ref = self.next_request_id.borrow_mut();
3162 let id = *id_ref;
3163 *id_ref += 1;
3164 self.callback_contexts
3166 .borrow_mut()
3167 .insert(id, self.plugin_name.clone());
3168 id
3169 };
3170 let params_json: Option<serde_json::Value> = params.map(|obj| {
3172 let val = obj.into_value();
3173 js_to_json(&ctx, val)
3174 });
3175 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3176 request_id: id,
3177 language,
3178 method,
3179 params: params_json,
3180 });
3181 Ok(id)
3182 }
3183
3184 #[plugin_api(
3186 async_thenable,
3187 js_name = "spawnBackgroundProcess",
3188 ts_return = "BackgroundProcessResult"
3189 )]
3190 #[qjs(rename = "_spawnBackgroundProcessStart")]
3191 pub fn spawn_background_process_start(
3192 &self,
3193 _ctx: rquickjs::Ctx<'_>,
3194 command: String,
3195 args: Vec<String>,
3196 cwd: rquickjs::function::Opt<String>,
3197 ) -> u64 {
3198 let id = {
3199 let mut id_ref = self.next_request_id.borrow_mut();
3200 let id = *id_ref;
3201 *id_ref += 1;
3202 self.callback_contexts
3204 .borrow_mut()
3205 .insert(id, self.plugin_name.clone());
3206 id
3207 };
3208 let process_id = id;
3210 self.plugin_tracked_state
3212 .borrow_mut()
3213 .entry(self.plugin_name.clone())
3214 .or_default()
3215 .background_process_ids
3216 .push(process_id);
3217 let _ = self
3218 .command_sender
3219 .send(PluginCommand::SpawnBackgroundProcess {
3220 process_id,
3221 command,
3222 args,
3223 cwd: cwd.0,
3224 callback_id: JsCallbackId::new(id),
3225 });
3226 id
3227 }
3228
3229 pub fn kill_background_process(&self, process_id: u64) -> bool {
3231 self.command_sender
3232 .send(PluginCommand::KillBackgroundProcess { process_id })
3233 .is_ok()
3234 }
3235
3236 #[plugin_api(
3240 async_promise,
3241 js_name = "createTerminal",
3242 ts_return = "TerminalResult"
3243 )]
3244 #[qjs(rename = "_createTerminalStart")]
3245 pub fn create_terminal_start(
3246 &self,
3247 _ctx: rquickjs::Ctx<'_>,
3248 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3249 ) -> rquickjs::Result<u64> {
3250 let id = {
3251 let mut id_ref = self.next_request_id.borrow_mut();
3252 let id = *id_ref;
3253 *id_ref += 1;
3254 self.callback_contexts
3255 .borrow_mut()
3256 .insert(id, self.plugin_name.clone());
3257 id
3258 };
3259
3260 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3261 cwd: None,
3262 direction: None,
3263 ratio: None,
3264 focus: None,
3265 });
3266
3267 if let Ok(mut owners) = self.async_resource_owners.lock() {
3269 owners.insert(id, self.plugin_name.clone());
3270 }
3271 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3272 cwd: opts.cwd,
3273 direction: opts.direction,
3274 ratio: opts.ratio,
3275 focus: opts.focus,
3276 request_id: id,
3277 });
3278 Ok(id)
3279 }
3280
3281 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3283 self.command_sender
3284 .send(PluginCommand::SendTerminalInput {
3285 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3286 data,
3287 })
3288 .is_ok()
3289 }
3290
3291 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3293 self.command_sender
3294 .send(PluginCommand::CloseTerminal {
3295 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3296 })
3297 .is_ok()
3298 }
3299
3300 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
3304 self.command_sender
3305 .send(PluginCommand::RefreshLines {
3306 buffer_id: BufferId(buffer_id as usize),
3307 })
3308 .is_ok()
3309 }
3310
3311 pub fn get_current_locale(&self) -> String {
3313 self.services.current_locale()
3314 }
3315
3316 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
3320 #[qjs(rename = "_loadPluginStart")]
3321 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
3322 let id = {
3323 let mut id_ref = self.next_request_id.borrow_mut();
3324 let id = *id_ref;
3325 *id_ref += 1;
3326 self.callback_contexts
3327 .borrow_mut()
3328 .insert(id, self.plugin_name.clone());
3329 id
3330 };
3331 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
3332 path: std::path::PathBuf::from(path),
3333 callback_id: JsCallbackId::new(id),
3334 });
3335 id
3336 }
3337
3338 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
3340 #[qjs(rename = "_unloadPluginStart")]
3341 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3342 let id = {
3343 let mut id_ref = self.next_request_id.borrow_mut();
3344 let id = *id_ref;
3345 *id_ref += 1;
3346 self.callback_contexts
3347 .borrow_mut()
3348 .insert(id, self.plugin_name.clone());
3349 id
3350 };
3351 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
3352 name,
3353 callback_id: JsCallbackId::new(id),
3354 });
3355 id
3356 }
3357
3358 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3360 #[qjs(rename = "_reloadPluginStart")]
3361 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3362 let id = {
3363 let mut id_ref = self.next_request_id.borrow_mut();
3364 let id = *id_ref;
3365 *id_ref += 1;
3366 self.callback_contexts
3367 .borrow_mut()
3368 .insert(id, self.plugin_name.clone());
3369 id
3370 };
3371 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3372 name,
3373 callback_id: JsCallbackId::new(id),
3374 });
3375 id
3376 }
3377
3378 #[plugin_api(
3381 async_promise,
3382 js_name = "listPlugins",
3383 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3384 )]
3385 #[qjs(rename = "_listPluginsStart")]
3386 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3387 let id = {
3388 let mut id_ref = self.next_request_id.borrow_mut();
3389 let id = *id_ref;
3390 *id_ref += 1;
3391 self.callback_contexts
3392 .borrow_mut()
3393 .insert(id, self.plugin_name.clone());
3394 id
3395 };
3396 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3397 callback_id: JsCallbackId::new(id),
3398 });
3399 id
3400 }
3401}
3402
3403fn parse_view_token(
3410 obj: &rquickjs::Object<'_>,
3411 idx: usize,
3412) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3413 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3414
3415 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3417 from: "object",
3418 to: "ViewTokenWire",
3419 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3420 })?;
3421
3422 let source_offset: Option<usize> = obj
3424 .get("sourceOffset")
3425 .ok()
3426 .or_else(|| obj.get("source_offset").ok());
3427
3428 let kind = if kind_value.is_string() {
3430 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3433 from: "value",
3434 to: "string",
3435 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3436 })?;
3437
3438 match kind_str.to_lowercase().as_str() {
3439 "text" => {
3440 let text: String = obj.get("text").unwrap_or_default();
3441 ViewTokenWireKind::Text(text)
3442 }
3443 "newline" => ViewTokenWireKind::Newline,
3444 "space" => ViewTokenWireKind::Space,
3445 "break" => ViewTokenWireKind::Break,
3446 _ => {
3447 tracing::warn!(
3449 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3450 idx, kind_str
3451 );
3452 return Err(rquickjs::Error::FromJs {
3453 from: "string",
3454 to: "ViewTokenWireKind",
3455 message: Some(format!(
3456 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3457 idx, kind_str
3458 )),
3459 });
3460 }
3461 }
3462 } else if kind_value.is_object() {
3463 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3465 from: "value",
3466 to: "object",
3467 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3468 })?;
3469
3470 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3471 ViewTokenWireKind::Text(text)
3472 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3473 ViewTokenWireKind::BinaryByte(byte)
3474 } else {
3475 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3477 tracing::warn!(
3478 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3479 idx,
3480 keys
3481 );
3482 return Err(rquickjs::Error::FromJs {
3483 from: "object",
3484 to: "ViewTokenWireKind",
3485 message: Some(format!(
3486 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3487 idx, keys
3488 )),
3489 });
3490 }
3491 } else {
3492 tracing::warn!(
3493 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3494 idx,
3495 kind_value.type_of()
3496 );
3497 return Err(rquickjs::Error::FromJs {
3498 from: "value",
3499 to: "ViewTokenWireKind",
3500 message: Some(format!(
3501 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3502 idx
3503 )),
3504 });
3505 };
3506
3507 let style = parse_view_token_style(obj, idx)?;
3509
3510 Ok(ViewTokenWire {
3511 source_offset,
3512 kind,
3513 style,
3514 })
3515}
3516
3517fn parse_view_token_style(
3519 obj: &rquickjs::Object<'_>,
3520 idx: usize,
3521) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
3522 use fresh_core::api::ViewTokenStyle;
3523
3524 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
3525 let Some(s) = style_obj else {
3526 return Ok(None);
3527 };
3528
3529 let fg: Option<Vec<u8>> = s.get("fg").ok();
3530 let bg: Option<Vec<u8>> = s.get("bg").ok();
3531
3532 let fg_color = if let Some(ref c) = fg {
3534 if c.len() < 3 {
3535 tracing::warn!(
3536 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
3537 idx,
3538 c.len()
3539 );
3540 None
3541 } else {
3542 Some((c[0], c[1], c[2]))
3543 }
3544 } else {
3545 None
3546 };
3547
3548 let bg_color = if let Some(ref c) = bg {
3549 if c.len() < 3 {
3550 tracing::warn!(
3551 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
3552 idx,
3553 c.len()
3554 );
3555 None
3556 } else {
3557 Some((c[0], c[1], c[2]))
3558 }
3559 } else {
3560 None
3561 };
3562
3563 Ok(Some(ViewTokenStyle {
3564 fg: fg_color,
3565 bg: bg_color,
3566 bold: s.get("bold").unwrap_or(false),
3567 italic: s.get("italic").unwrap_or(false),
3568 }))
3569}
3570
3571pub struct QuickJsBackend {
3573 runtime: Runtime,
3574 main_context: Context,
3576 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
3578 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
3580 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
3582 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3584 command_sender: mpsc::Sender<PluginCommand>,
3586 #[allow(dead_code)]
3588 pending_responses: PendingResponses,
3589 next_request_id: Rc<RefCell<u64>>,
3591 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
3593 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3595 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
3597 async_resource_owners: AsyncResourceOwners,
3600}
3601
3602impl QuickJsBackend {
3603 pub fn new() -> Result<Self> {
3605 let (tx, _rx) = mpsc::channel();
3606 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3607 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3608 Self::with_state(state_snapshot, tx, services)
3609 }
3610
3611 pub fn with_state(
3613 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3614 command_sender: mpsc::Sender<PluginCommand>,
3615 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3616 ) -> Result<Self> {
3617 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
3618 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
3619 }
3620
3621 pub fn with_state_and_responses(
3623 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3624 command_sender: mpsc::Sender<PluginCommand>,
3625 pending_responses: PendingResponses,
3626 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3627 ) -> Result<Self> {
3628 let async_resource_owners: AsyncResourceOwners =
3629 Arc::new(std::sync::Mutex::new(HashMap::new()));
3630 Self::with_state_responses_and_resources(
3631 state_snapshot,
3632 command_sender,
3633 pending_responses,
3634 services,
3635 async_resource_owners,
3636 )
3637 }
3638
3639 pub fn with_state_responses_and_resources(
3642 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3643 command_sender: mpsc::Sender<PluginCommand>,
3644 pending_responses: PendingResponses,
3645 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3646 async_resource_owners: AsyncResourceOwners,
3647 ) -> Result<Self> {
3648 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
3649
3650 let runtime =
3651 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
3652
3653 runtime.set_host_promise_rejection_tracker(Some(Box::new(
3655 |_ctx, _promise, reason, is_handled| {
3656 if !is_handled {
3657 let error_msg = if let Some(exc) = reason.as_exception() {
3659 format!(
3660 "{}: {}",
3661 exc.message().unwrap_or_default(),
3662 exc.stack().unwrap_or_default()
3663 )
3664 } else {
3665 format!("{:?}", reason)
3666 };
3667
3668 tracing::error!("Unhandled Promise rejection: {}", error_msg);
3669
3670 if should_panic_on_js_errors() {
3671 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
3674 set_fatal_js_error(full_msg);
3675 }
3676 }
3677 },
3678 )));
3679
3680 let main_context = Context::full(&runtime)
3681 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
3682
3683 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
3684 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
3685 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
3686 let next_request_id = Rc::new(RefCell::new(1u64));
3687 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
3688 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
3689
3690 let backend = Self {
3691 runtime,
3692 main_context,
3693 plugin_contexts,
3694 event_handlers,
3695 registered_actions,
3696 state_snapshot,
3697 command_sender,
3698 pending_responses,
3699 next_request_id,
3700 callback_contexts,
3701 services,
3702 plugin_tracked_state,
3703 async_resource_owners,
3704 };
3705
3706 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
3708
3709 tracing::debug!("QuickJsBackend::new: runtime created successfully");
3710 Ok(backend)
3711 }
3712
3713 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
3715 let state_snapshot = Arc::clone(&self.state_snapshot);
3716 let command_sender = self.command_sender.clone();
3717 let event_handlers = Rc::clone(&self.event_handlers);
3718 let registered_actions = Rc::clone(&self.registered_actions);
3719 let next_request_id = Rc::clone(&self.next_request_id);
3720
3721 context.with(|ctx| {
3722 let globals = ctx.globals();
3723
3724 globals.set("__pluginName__", plugin_name)?;
3726
3727 let js_api = JsEditorApi {
3730 state_snapshot: Arc::clone(&state_snapshot),
3731 command_sender: command_sender.clone(),
3732 registered_actions: Rc::clone(®istered_actions),
3733 event_handlers: Rc::clone(&event_handlers),
3734 next_request_id: Rc::clone(&next_request_id),
3735 callback_contexts: Rc::clone(&self.callback_contexts),
3736 services: self.services.clone(),
3737 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
3738 async_resource_owners: Arc::clone(&self.async_resource_owners),
3739 plugin_name: plugin_name.to_string(),
3740 };
3741 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
3742
3743 globals.set("editor", editor)?;
3745
3746 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
3748
3749 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
3751
3752 let console = Object::new(ctx.clone())?;
3755 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3756 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3757 tracing::info!("console.log: {}", parts.join(" "));
3758 })?)?;
3759 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3760 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3761 tracing::warn!("console.warn: {}", parts.join(" "));
3762 })?)?;
3763 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3764 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3765 tracing::error!("console.error: {}", parts.join(" "));
3766 })?)?;
3767 globals.set("console", console)?;
3768
3769 ctx.eval::<(), _>(r#"
3771 // Pending promise callbacks: callbackId -> { resolve, reject }
3772 globalThis._pendingCallbacks = new Map();
3773
3774 // Resolve a pending callback (called from Rust)
3775 globalThis._resolveCallback = function(callbackId, result) {
3776 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
3777 const cb = globalThis._pendingCallbacks.get(callbackId);
3778 if (cb) {
3779 console.log('[JS] _resolveCallback: found callback, calling resolve()');
3780 globalThis._pendingCallbacks.delete(callbackId);
3781 cb.resolve(result);
3782 console.log('[JS] _resolveCallback: resolve() called');
3783 } else {
3784 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
3785 }
3786 };
3787
3788 // Reject a pending callback (called from Rust)
3789 globalThis._rejectCallback = function(callbackId, error) {
3790 const cb = globalThis._pendingCallbacks.get(callbackId);
3791 if (cb) {
3792 globalThis._pendingCallbacks.delete(callbackId);
3793 cb.reject(new Error(error));
3794 }
3795 };
3796
3797 // Generic async wrapper decorator
3798 // Wraps a function that returns a callbackId into a promise-returning function
3799 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
3800 // NOTE: We pass the method name as a string and call via bracket notation
3801 // to preserve rquickjs's automatic Ctx injection for methods
3802 globalThis._wrapAsync = function(methodName, fnName) {
3803 const startFn = editor[methodName];
3804 if (typeof startFn !== 'function') {
3805 // Return a function that always throws - catches missing implementations
3806 return function(...args) {
3807 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3808 editor.debug(`[ASYNC ERROR] ${error.message}`);
3809 throw error;
3810 };
3811 }
3812 return function(...args) {
3813 // Call via bracket notation to preserve method binding and Ctx injection
3814 const callbackId = editor[methodName](...args);
3815 return new Promise((resolve, reject) => {
3816 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3817 // TODO: Implement setTimeout polyfill using editor.delay() or similar
3818 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3819 });
3820 };
3821 };
3822
3823 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
3824 // The returned object has .result promise and is itself thenable
3825 globalThis._wrapAsyncThenable = function(methodName, fnName) {
3826 const startFn = editor[methodName];
3827 if (typeof startFn !== 'function') {
3828 // Return a function that always throws - catches missing implementations
3829 return function(...args) {
3830 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3831 editor.debug(`[ASYNC ERROR] ${error.message}`);
3832 throw error;
3833 };
3834 }
3835 return function(...args) {
3836 // Call via bracket notation to preserve method binding and Ctx injection
3837 const callbackId = editor[methodName](...args);
3838 const resultPromise = new Promise((resolve, reject) => {
3839 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3840 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3841 });
3842 return {
3843 get result() { return resultPromise; },
3844 then(onFulfilled, onRejected) {
3845 return resultPromise.then(onFulfilled, onRejected);
3846 },
3847 catch(onRejected) {
3848 return resultPromise.catch(onRejected);
3849 }
3850 };
3851 };
3852 };
3853
3854 // Apply wrappers to async functions on editor
3855 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3856 editor.delay = _wrapAsync("_delayStart", "delay");
3857 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3858 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3859 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3860 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3861 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3862 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3863 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3864 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3865 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3866 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3867 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3868 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3869 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3870 editor.prompt = _wrapAsync("_promptStart", "prompt");
3871 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
3872 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
3873 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
3874 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
3875
3876 // Wrapper for deleteTheme - wraps sync function in Promise
3877 editor.deleteTheme = function(name) {
3878 return new Promise(function(resolve, reject) {
3879 const success = editor._deleteThemeSync(name);
3880 if (success) {
3881 resolve();
3882 } else {
3883 reject(new Error("Failed to delete theme: " + name));
3884 }
3885 });
3886 };
3887 "#.as_bytes())?;
3888
3889 Ok::<_, rquickjs::Error>(())
3890 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3891
3892 Ok(())
3893 }
3894
3895 pub async fn load_module_with_source(
3897 &mut self,
3898 path: &str,
3899 _plugin_source: &str,
3900 ) -> Result<()> {
3901 let path_buf = PathBuf::from(path);
3902 let source = std::fs::read_to_string(&path_buf)
3903 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3904
3905 let filename = path_buf
3906 .file_name()
3907 .and_then(|s| s.to_str())
3908 .unwrap_or("plugin.ts");
3909
3910 if has_es_imports(&source) {
3912 match bundle_module(&path_buf) {
3914 Ok(bundled) => {
3915 self.execute_js(&bundled, path)?;
3916 }
3917 Err(e) => {
3918 tracing::warn!(
3919 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3920 path,
3921 e
3922 );
3923 return Ok(()); }
3925 }
3926 } else if has_es_module_syntax(&source) {
3927 let stripped = strip_imports_and_exports(&source);
3929 let js_code = if filename.ends_with(".ts") {
3930 transpile_typescript(&stripped, filename)?
3931 } else {
3932 stripped
3933 };
3934 self.execute_js(&js_code, path)?;
3935 } else {
3936 let js_code = if filename.ends_with(".ts") {
3938 transpile_typescript(&source, filename)?
3939 } else {
3940 source
3941 };
3942 self.execute_js(&js_code, path)?;
3943 }
3944
3945 Ok(())
3946 }
3947
3948 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3950 let plugin_name = Path::new(source_name)
3952 .file_stem()
3953 .and_then(|s| s.to_str())
3954 .unwrap_or("unknown");
3955
3956 tracing::debug!(
3957 "execute_js: starting for plugin '{}' from '{}'",
3958 plugin_name,
3959 source_name
3960 );
3961
3962 let context = {
3964 let mut contexts = self.plugin_contexts.borrow_mut();
3965 if let Some(ctx) = contexts.get(plugin_name) {
3966 ctx.clone()
3967 } else {
3968 let ctx = Context::full(&self.runtime).map_err(|e| {
3969 anyhow!(
3970 "Failed to create QuickJS context for plugin {}: {}",
3971 plugin_name,
3972 e
3973 )
3974 })?;
3975 self.setup_context_api(&ctx, plugin_name)?;
3976 contexts.insert(plugin_name.to_string(), ctx.clone());
3977 ctx
3978 }
3979 };
3980
3981 let wrapped_code = format!("(function() {{ {} }})();", code);
3985 let wrapped = wrapped_code.as_str();
3986
3987 context.with(|ctx| {
3988 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3989
3990 let mut eval_options = rquickjs::context::EvalOptions::default();
3992 eval_options.global = true;
3993 eval_options.filename = Some(source_name.to_string());
3994 let result = ctx
3995 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3996 .map_err(|e| format_js_error(&ctx, e, source_name));
3997
3998 tracing::debug!(
3999 "execute_js: plugin code execution finished for '{}', result: {:?}",
4000 plugin_name,
4001 result.is_ok()
4002 );
4003
4004 result
4005 })
4006 }
4007
4008 pub fn execute_source(
4014 &mut self,
4015 source: &str,
4016 plugin_name: &str,
4017 is_typescript: bool,
4018 ) -> Result<()> {
4019 use fresh_parser_js::{
4020 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4021 };
4022
4023 if has_es_imports(source) {
4024 tracing::warn!(
4025 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4026 plugin_name
4027 );
4028 }
4029
4030 let js_code = if has_es_module_syntax(source) {
4031 let stripped = strip_imports_and_exports(source);
4032 if is_typescript {
4033 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4034 } else {
4035 stripped
4036 }
4037 } else if is_typescript {
4038 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4039 } else {
4040 source.to_string()
4041 };
4042
4043 let source_name = format!(
4045 "{}.{}",
4046 plugin_name,
4047 if is_typescript { "ts" } else { "js" }
4048 );
4049 self.execute_js(&js_code, &source_name)
4050 }
4051
4052 pub fn cleanup_plugin(&self, plugin_name: &str) {
4058 self.plugin_contexts.borrow_mut().remove(plugin_name);
4060
4061 for handlers in self.event_handlers.borrow_mut().values_mut() {
4063 handlers.retain(|h| h.plugin_name != plugin_name);
4064 }
4065
4066 self.registered_actions
4068 .borrow_mut()
4069 .retain(|_, h| h.plugin_name != plugin_name);
4070
4071 self.callback_contexts
4073 .borrow_mut()
4074 .retain(|_, pname| pname != plugin_name);
4075
4076 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4078 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4080 std::collections::HashSet::new();
4081 for (buf_id, ns) in &tracked.overlay_namespaces {
4082 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4083 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4085 buffer_id: *buf_id,
4086 namespace: OverlayNamespace::from_string(ns.clone()),
4087 });
4088 let _ = self
4090 .command_sender
4091 .send(PluginCommand::ClearConcealNamespace {
4092 buffer_id: *buf_id,
4093 namespace: OverlayNamespace::from_string(ns.clone()),
4094 });
4095 let _ = self
4096 .command_sender
4097 .send(PluginCommand::ClearSoftBreakNamespace {
4098 buffer_id: *buf_id,
4099 namespace: OverlayNamespace::from_string(ns.clone()),
4100 });
4101 }
4102 }
4103
4104 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4110 std::collections::HashSet::new();
4111 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4112 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4113 let _ = self
4114 .command_sender
4115 .send(PluginCommand::ClearLineIndicators {
4116 buffer_id: *buf_id,
4117 namespace: ns.clone(),
4118 });
4119 }
4120 }
4121
4122 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4124 std::collections::HashSet::new();
4125 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4126 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4127 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4128 buffer_id: *buf_id,
4129 virtual_text_id: vt_id.clone(),
4130 });
4131 }
4132 }
4133
4134 let mut seen_fe_ns: std::collections::HashSet<String> =
4136 std::collections::HashSet::new();
4137 for ns in &tracked.file_explorer_namespaces {
4138 if seen_fe_ns.insert(ns.clone()) {
4139 let _ = self
4140 .command_sender
4141 .send(PluginCommand::ClearFileExplorerDecorations {
4142 namespace: ns.clone(),
4143 });
4144 }
4145 }
4146
4147 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4149 for ctx_name in &tracked.contexts_set {
4150 if seen_ctx.insert(ctx_name.clone()) {
4151 let _ = self.command_sender.send(PluginCommand::SetContext {
4152 name: ctx_name.clone(),
4153 active: false,
4154 });
4155 }
4156 }
4157
4158 for process_id in &tracked.background_process_ids {
4162 let _ = self
4163 .command_sender
4164 .send(PluginCommand::KillBackgroundProcess {
4165 process_id: *process_id,
4166 });
4167 }
4168
4169 for group_id in &tracked.scroll_sync_group_ids {
4171 let _ = self
4172 .command_sender
4173 .send(PluginCommand::RemoveScrollSyncGroup {
4174 group_id: *group_id,
4175 });
4176 }
4177
4178 for buffer_id in &tracked.virtual_buffer_ids {
4180 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4181 buffer_id: *buffer_id,
4182 });
4183 }
4184
4185 for buffer_id in &tracked.composite_buffer_ids {
4187 let _ = self
4188 .command_sender
4189 .send(PluginCommand::CloseCompositeBuffer {
4190 buffer_id: *buffer_id,
4191 });
4192 }
4193
4194 for terminal_id in &tracked.terminal_ids {
4196 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4197 terminal_id: *terminal_id,
4198 });
4199 }
4200 }
4201
4202 if let Ok(mut owners) = self.async_resource_owners.lock() {
4204 owners.retain(|_, name| name != plugin_name);
4205 }
4206
4207 tracing::debug!(
4208 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
4209 plugin_name
4210 );
4211 }
4212
4213 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
4215 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
4216
4217 self.services
4218 .set_js_execution_state(format!("hook '{}'", event_name));
4219
4220 let handlers = self.event_handlers.borrow().get(event_name).cloned();
4221 if let Some(handler_pairs) = handlers {
4222 let plugin_contexts = self.plugin_contexts.borrow();
4223 for handler in &handler_pairs {
4224 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
4225 continue;
4226 };
4227 context.with(|ctx| {
4228 call_handler(&ctx, &handler.handler_name, event_data);
4229 });
4230 }
4231 }
4232
4233 self.services.clear_js_execution_state();
4234 Ok(true)
4235 }
4236
4237 pub fn has_handlers(&self, event_name: &str) -> bool {
4239 self.event_handlers
4240 .borrow()
4241 .get(event_name)
4242 .map(|v| !v.is_empty())
4243 .unwrap_or(false)
4244 }
4245
4246 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
4250 let pair = self.registered_actions.borrow().get(action_name).cloned();
4251 let (plugin_name, function_name) = match pair {
4252 Some(handler) => (handler.plugin_name, handler.handler_name),
4253 None => ("main".to_string(), action_name.to_string()),
4254 };
4255
4256 let plugin_contexts = self.plugin_contexts.borrow();
4257 let context = plugin_contexts
4258 .get(&plugin_name)
4259 .unwrap_or(&self.main_context);
4260
4261 self.services
4263 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
4264
4265 tracing::info!(
4266 "start_action: BEGIN '{}' -> function '{}'",
4267 action_name,
4268 function_name
4269 );
4270
4271 let code = format!(
4273 r#"
4274 (function() {{
4275 console.log('[JS] start_action: calling {fn}');
4276 try {{
4277 if (typeof globalThis.{fn} === 'function') {{
4278 console.log('[JS] start_action: {fn} is a function, invoking...');
4279 globalThis.{fn}();
4280 console.log('[JS] start_action: {fn} invoked (may be async)');
4281 }} else {{
4282 console.error('[JS] Action {action} is not defined as a global function');
4283 }}
4284 }} catch (e) {{
4285 console.error('[JS] Action {action} error:', e);
4286 }}
4287 }})();
4288 "#,
4289 fn = function_name,
4290 action = action_name
4291 );
4292
4293 tracing::info!("start_action: evaluating JS code");
4294 context.with(|ctx| {
4295 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4296 log_js_error(&ctx, e, &format!("action {}", action_name));
4297 }
4298 tracing::info!("start_action: running pending microtasks");
4299 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
4301 tracing::info!("start_action: executed {} pending jobs", count);
4302 });
4303
4304 tracing::info!("start_action: END '{}'", action_name);
4305
4306 self.services.clear_js_execution_state();
4308
4309 Ok(())
4310 }
4311
4312 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
4314 let pair = self.registered_actions.borrow().get(action_name).cloned();
4316 let (plugin_name, function_name) = match pair {
4317 Some(handler) => (handler.plugin_name, handler.handler_name),
4318 None => ("main".to_string(), action_name.to_string()),
4319 };
4320
4321 let plugin_contexts = self.plugin_contexts.borrow();
4322 let context = plugin_contexts
4323 .get(&plugin_name)
4324 .unwrap_or(&self.main_context);
4325
4326 tracing::debug!(
4327 "execute_action: '{}' -> function '{}'",
4328 action_name,
4329 function_name
4330 );
4331
4332 let code = format!(
4335 r#"
4336 (async function() {{
4337 try {{
4338 if (typeof globalThis.{fn} === 'function') {{
4339 const result = globalThis.{fn}();
4340 // If it's a Promise, await it
4341 if (result && typeof result.then === 'function') {{
4342 await result;
4343 }}
4344 }} else {{
4345 console.error('Action {action} is not defined as a global function');
4346 }}
4347 }} catch (e) {{
4348 console.error('Action {action} error:', e);
4349 }}
4350 }})();
4351 "#,
4352 fn = function_name,
4353 action = action_name
4354 );
4355
4356 context.with(|ctx| {
4357 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4359 Ok(value) => {
4360 if value.is_object() {
4362 if let Some(obj) = value.as_object() {
4363 if obj.get::<_, rquickjs::Function>("then").is_ok() {
4365 run_pending_jobs_checked(
4368 &ctx,
4369 &format!("execute_action {} promise", action_name),
4370 );
4371 }
4372 }
4373 }
4374 }
4375 Err(e) => {
4376 log_js_error(&ctx, e, &format!("action {}", action_name));
4377 }
4378 }
4379 });
4380
4381 Ok(())
4382 }
4383
4384 pub fn poll_event_loop_once(&mut self) -> bool {
4386 let mut had_work = false;
4387
4388 self.main_context.with(|ctx| {
4390 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
4391 if count > 0 {
4392 had_work = true;
4393 }
4394 });
4395
4396 let contexts = self.plugin_contexts.borrow().clone();
4398 for (name, context) in contexts {
4399 context.with(|ctx| {
4400 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
4401 if count > 0 {
4402 had_work = true;
4403 }
4404 });
4405 }
4406 had_work
4407 }
4408
4409 pub fn send_status(&self, message: String) {
4411 let _ = self
4412 .command_sender
4413 .send(PluginCommand::SetStatus { message });
4414 }
4415
4416 pub fn send_hook_completed(&self, hook_name: String) {
4420 let _ = self
4421 .command_sender
4422 .send(PluginCommand::HookCompleted { hook_name });
4423 }
4424
4425 pub fn resolve_callback(
4430 &mut self,
4431 callback_id: fresh_core::api::JsCallbackId,
4432 result_json: &str,
4433 ) {
4434 let id = callback_id.as_u64();
4435 tracing::debug!("resolve_callback: starting for callback_id={}", id);
4436
4437 let plugin_name = {
4439 let mut contexts = self.callback_contexts.borrow_mut();
4440 contexts.remove(&id)
4441 };
4442
4443 let Some(name) = plugin_name else {
4444 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
4445 return;
4446 };
4447
4448 let plugin_contexts = self.plugin_contexts.borrow();
4449 let Some(context) = plugin_contexts.get(&name) else {
4450 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
4451 return;
4452 };
4453
4454 context.with(|ctx| {
4455 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
4457 Ok(v) => v,
4458 Err(e) => {
4459 tracing::error!(
4460 "resolve_callback: failed to parse JSON for callback_id={}: {}",
4461 id,
4462 e
4463 );
4464 return;
4465 }
4466 };
4467
4468 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
4470 Ok(v) => v,
4471 Err(e) => {
4472 tracing::error!(
4473 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
4474 id,
4475 e
4476 );
4477 return;
4478 }
4479 };
4480
4481 let globals = ctx.globals();
4483 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
4484 Ok(f) => f,
4485 Err(e) => {
4486 tracing::error!(
4487 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
4488 id,
4489 e
4490 );
4491 return;
4492 }
4493 };
4494
4495 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
4497 log_js_error(&ctx, e, &format!("resolving callback {}", id));
4498 }
4499
4500 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
4502 tracing::info!(
4503 "resolve_callback: executed {} pending jobs for callback_id={}",
4504 job_count,
4505 id
4506 );
4507 });
4508 }
4509
4510 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
4512 let id = callback_id.as_u64();
4513
4514 let plugin_name = {
4516 let mut contexts = self.callback_contexts.borrow_mut();
4517 contexts.remove(&id)
4518 };
4519
4520 let Some(name) = plugin_name else {
4521 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
4522 return;
4523 };
4524
4525 let plugin_contexts = self.plugin_contexts.borrow();
4526 let Some(context) = plugin_contexts.get(&name) else {
4527 tracing::warn!("reject_callback: Context lost for plugin {}", name);
4528 return;
4529 };
4530
4531 context.with(|ctx| {
4532 let globals = ctx.globals();
4534 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
4535 Ok(f) => f,
4536 Err(e) => {
4537 tracing::error!(
4538 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
4539 id,
4540 e
4541 );
4542 return;
4543 }
4544 };
4545
4546 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
4548 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
4549 }
4550
4551 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
4553 });
4554 }
4555}
4556
4557#[cfg(test)]
4558mod tests {
4559 use super::*;
4560 use fresh_core::api::{BufferInfo, CursorInfo};
4561 use std::sync::mpsc;
4562
4563 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
4565 let (tx, rx) = mpsc::channel();
4566 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4567 let services = Arc::new(TestServiceBridge::new());
4568 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4569 (backend, rx)
4570 }
4571
4572 struct TestServiceBridge {
4573 en_strings: std::sync::Mutex<HashMap<String, String>>,
4574 }
4575
4576 impl TestServiceBridge {
4577 fn new() -> Self {
4578 Self {
4579 en_strings: std::sync::Mutex::new(HashMap::new()),
4580 }
4581 }
4582 }
4583
4584 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
4585 fn as_any(&self) -> &dyn std::any::Any {
4586 self
4587 }
4588 fn translate(
4589 &self,
4590 _plugin_name: &str,
4591 key: &str,
4592 _args: &HashMap<String, String>,
4593 ) -> String {
4594 self.en_strings
4595 .lock()
4596 .unwrap()
4597 .get(key)
4598 .cloned()
4599 .unwrap_or_else(|| key.to_string())
4600 }
4601 fn current_locale(&self) -> String {
4602 "en".to_string()
4603 }
4604 fn set_js_execution_state(&self, _state: String) {}
4605 fn clear_js_execution_state(&self) {}
4606 fn get_theme_schema(&self) -> serde_json::Value {
4607 serde_json::json!({})
4608 }
4609 fn get_builtin_themes(&self) -> serde_json::Value {
4610 serde_json::json!([])
4611 }
4612 fn register_command(&self, _command: fresh_core::command::Command) {}
4613 fn unregister_command(&self, _name: &str) {}
4614 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
4615 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
4616 fn plugins_dir(&self) -> std::path::PathBuf {
4617 std::path::PathBuf::from("/tmp/plugins")
4618 }
4619 fn config_dir(&self) -> std::path::PathBuf {
4620 std::path::PathBuf::from("/tmp/config")
4621 }
4622 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
4623 None
4624 }
4625 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
4626 Err("not implemented in test".to_string())
4627 }
4628 fn theme_file_exists(&self, _name: &str) -> bool {
4629 false
4630 }
4631 }
4632
4633 #[test]
4634 fn test_quickjs_backend_creation() {
4635 let backend = QuickJsBackend::new();
4636 assert!(backend.is_ok());
4637 }
4638
4639 #[test]
4640 fn test_execute_simple_js() {
4641 let mut backend = QuickJsBackend::new().unwrap();
4642 let result = backend.execute_js("const x = 1 + 2;", "test.js");
4643 assert!(result.is_ok());
4644 }
4645
4646 #[test]
4647 fn test_event_handler_registration() {
4648 let backend = QuickJsBackend::new().unwrap();
4649
4650 assert!(!backend.has_handlers("test_event"));
4652
4653 backend
4655 .event_handlers
4656 .borrow_mut()
4657 .entry("test_event".to_string())
4658 .or_default()
4659 .push(PluginHandler {
4660 plugin_name: "test".to_string(),
4661 handler_name: "testHandler".to_string(),
4662 });
4663
4664 assert!(backend.has_handlers("test_event"));
4666 }
4667
4668 #[test]
4671 fn test_api_set_status() {
4672 let (mut backend, rx) = create_test_backend();
4673
4674 backend
4675 .execute_js(
4676 r#"
4677 const editor = getEditor();
4678 editor.setStatus("Hello from test");
4679 "#,
4680 "test.js",
4681 )
4682 .unwrap();
4683
4684 let cmd = rx.try_recv().unwrap();
4685 match cmd {
4686 PluginCommand::SetStatus { message } => {
4687 assert_eq!(message, "Hello from test");
4688 }
4689 _ => panic!("Expected SetStatus command, got {:?}", cmd),
4690 }
4691 }
4692
4693 #[test]
4694 fn test_api_register_command() {
4695 let (mut backend, rx) = create_test_backend();
4696
4697 backend
4698 .execute_js(
4699 r#"
4700 const editor = getEditor();
4701 globalThis.myTestHandler = function() { };
4702 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
4703 "#,
4704 "test_plugin.js",
4705 )
4706 .unwrap();
4707
4708 let cmd = rx.try_recv().unwrap();
4709 match cmd {
4710 PluginCommand::RegisterCommand { command } => {
4711 assert_eq!(command.name, "Test Command");
4712 assert_eq!(command.description, "A test command");
4713 assert_eq!(command.plugin_name, "test_plugin");
4715 }
4716 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
4717 }
4718 }
4719
4720 #[test]
4721 fn test_api_define_mode() {
4722 let (mut backend, rx) = create_test_backend();
4723
4724 backend
4725 .execute_js(
4726 r#"
4727 const editor = getEditor();
4728 editor.defineMode("test-mode", null, [
4729 ["a", "action_a"],
4730 ["b", "action_b"]
4731 ]);
4732 "#,
4733 "test.js",
4734 )
4735 .unwrap();
4736
4737 let cmd = rx.try_recv().unwrap();
4738 match cmd {
4739 PluginCommand::DefineMode {
4740 name,
4741 parent,
4742 bindings,
4743 read_only,
4744 } => {
4745 assert_eq!(name, "test-mode");
4746 assert!(parent.is_none());
4747 assert_eq!(bindings.len(), 2);
4748 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
4749 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
4750 assert!(!read_only);
4751 }
4752 _ => panic!("Expected DefineMode, got {:?}", cmd),
4753 }
4754 }
4755
4756 #[test]
4757 fn test_api_set_editor_mode() {
4758 let (mut backend, rx) = create_test_backend();
4759
4760 backend
4761 .execute_js(
4762 r#"
4763 const editor = getEditor();
4764 editor.setEditorMode("vi-normal");
4765 "#,
4766 "test.js",
4767 )
4768 .unwrap();
4769
4770 let cmd = rx.try_recv().unwrap();
4771 match cmd {
4772 PluginCommand::SetEditorMode { mode } => {
4773 assert_eq!(mode, Some("vi-normal".to_string()));
4774 }
4775 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
4776 }
4777 }
4778
4779 #[test]
4780 fn test_api_clear_editor_mode() {
4781 let (mut backend, rx) = create_test_backend();
4782
4783 backend
4784 .execute_js(
4785 r#"
4786 const editor = getEditor();
4787 editor.setEditorMode(null);
4788 "#,
4789 "test.js",
4790 )
4791 .unwrap();
4792
4793 let cmd = rx.try_recv().unwrap();
4794 match cmd {
4795 PluginCommand::SetEditorMode { mode } => {
4796 assert!(mode.is_none());
4797 }
4798 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
4799 }
4800 }
4801
4802 #[test]
4803 fn test_api_insert_at_cursor() {
4804 let (mut backend, rx) = create_test_backend();
4805
4806 backend
4807 .execute_js(
4808 r#"
4809 const editor = getEditor();
4810 editor.insertAtCursor("Hello, World!");
4811 "#,
4812 "test.js",
4813 )
4814 .unwrap();
4815
4816 let cmd = rx.try_recv().unwrap();
4817 match cmd {
4818 PluginCommand::InsertAtCursor { text } => {
4819 assert_eq!(text, "Hello, World!");
4820 }
4821 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
4822 }
4823 }
4824
4825 #[test]
4826 fn test_api_set_context() {
4827 let (mut backend, rx) = create_test_backend();
4828
4829 backend
4830 .execute_js(
4831 r#"
4832 const editor = getEditor();
4833 editor.setContext("myContext", true);
4834 "#,
4835 "test.js",
4836 )
4837 .unwrap();
4838
4839 let cmd = rx.try_recv().unwrap();
4840 match cmd {
4841 PluginCommand::SetContext { name, active } => {
4842 assert_eq!(name, "myContext");
4843 assert!(active);
4844 }
4845 _ => panic!("Expected SetContext, got {:?}", cmd),
4846 }
4847 }
4848
4849 #[tokio::test]
4850 async fn test_execute_action_sync_function() {
4851 let (mut backend, rx) = create_test_backend();
4852
4853 backend.registered_actions.borrow_mut().insert(
4855 "my_sync_action".to_string(),
4856 PluginHandler {
4857 plugin_name: "test".to_string(),
4858 handler_name: "my_sync_action".to_string(),
4859 },
4860 );
4861
4862 backend
4864 .execute_js(
4865 r#"
4866 const editor = getEditor();
4867 globalThis.my_sync_action = function() {
4868 editor.setStatus("sync action executed");
4869 };
4870 "#,
4871 "test.js",
4872 )
4873 .unwrap();
4874
4875 while rx.try_recv().is_ok() {}
4877
4878 backend.execute_action("my_sync_action").await.unwrap();
4880
4881 let cmd = rx.try_recv().unwrap();
4883 match cmd {
4884 PluginCommand::SetStatus { message } => {
4885 assert_eq!(message, "sync action executed");
4886 }
4887 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
4888 }
4889 }
4890
4891 #[tokio::test]
4892 async fn test_execute_action_async_function() {
4893 let (mut backend, rx) = create_test_backend();
4894
4895 backend.registered_actions.borrow_mut().insert(
4897 "my_async_action".to_string(),
4898 PluginHandler {
4899 plugin_name: "test".to_string(),
4900 handler_name: "my_async_action".to_string(),
4901 },
4902 );
4903
4904 backend
4906 .execute_js(
4907 r#"
4908 const editor = getEditor();
4909 globalThis.my_async_action = async function() {
4910 await Promise.resolve();
4911 editor.setStatus("async action executed");
4912 };
4913 "#,
4914 "test.js",
4915 )
4916 .unwrap();
4917
4918 while rx.try_recv().is_ok() {}
4920
4921 backend.execute_action("my_async_action").await.unwrap();
4923
4924 let cmd = rx.try_recv().unwrap();
4926 match cmd {
4927 PluginCommand::SetStatus { message } => {
4928 assert_eq!(message, "async action executed");
4929 }
4930 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
4931 }
4932 }
4933
4934 #[tokio::test]
4935 async fn test_execute_action_with_registered_handler() {
4936 let (mut backend, rx) = create_test_backend();
4937
4938 backend.registered_actions.borrow_mut().insert(
4940 "my_action".to_string(),
4941 PluginHandler {
4942 plugin_name: "test".to_string(),
4943 handler_name: "actual_handler_function".to_string(),
4944 },
4945 );
4946
4947 backend
4948 .execute_js(
4949 r#"
4950 const editor = getEditor();
4951 globalThis.actual_handler_function = function() {
4952 editor.setStatus("handler executed");
4953 };
4954 "#,
4955 "test.js",
4956 )
4957 .unwrap();
4958
4959 while rx.try_recv().is_ok() {}
4961
4962 backend.execute_action("my_action").await.unwrap();
4964
4965 let cmd = rx.try_recv().unwrap();
4966 match cmd {
4967 PluginCommand::SetStatus { message } => {
4968 assert_eq!(message, "handler executed");
4969 }
4970 _ => panic!("Expected SetStatus, got {:?}", cmd),
4971 }
4972 }
4973
4974 #[test]
4975 fn test_api_on_event_registration() {
4976 let (mut backend, _rx) = create_test_backend();
4977
4978 backend
4979 .execute_js(
4980 r#"
4981 const editor = getEditor();
4982 globalThis.myEventHandler = function() { };
4983 editor.on("bufferSave", "myEventHandler");
4984 "#,
4985 "test.js",
4986 )
4987 .unwrap();
4988
4989 assert!(backend.has_handlers("bufferSave"));
4990 }
4991
4992 #[test]
4993 fn test_api_off_event_unregistration() {
4994 let (mut backend, _rx) = create_test_backend();
4995
4996 backend
4997 .execute_js(
4998 r#"
4999 const editor = getEditor();
5000 globalThis.myEventHandler = function() { };
5001 editor.on("bufferSave", "myEventHandler");
5002 editor.off("bufferSave", "myEventHandler");
5003 "#,
5004 "test.js",
5005 )
5006 .unwrap();
5007
5008 assert!(!backend.has_handlers("bufferSave"));
5010 }
5011
5012 #[tokio::test]
5013 async fn test_emit_event() {
5014 let (mut backend, rx) = create_test_backend();
5015
5016 backend
5017 .execute_js(
5018 r#"
5019 const editor = getEditor();
5020 globalThis.onSaveHandler = function(data) {
5021 editor.setStatus("saved: " + JSON.stringify(data));
5022 };
5023 editor.on("bufferSave", "onSaveHandler");
5024 "#,
5025 "test.js",
5026 )
5027 .unwrap();
5028
5029 while rx.try_recv().is_ok() {}
5031
5032 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5034 backend.emit("bufferSave", &event_data).await.unwrap();
5035
5036 let cmd = rx.try_recv().unwrap();
5037 match cmd {
5038 PluginCommand::SetStatus { message } => {
5039 assert!(message.contains("/test.txt"));
5040 }
5041 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5042 }
5043 }
5044
5045 #[test]
5046 fn test_api_copy_to_clipboard() {
5047 let (mut backend, rx) = create_test_backend();
5048
5049 backend
5050 .execute_js(
5051 r#"
5052 const editor = getEditor();
5053 editor.copyToClipboard("clipboard text");
5054 "#,
5055 "test.js",
5056 )
5057 .unwrap();
5058
5059 let cmd = rx.try_recv().unwrap();
5060 match cmd {
5061 PluginCommand::SetClipboard { text } => {
5062 assert_eq!(text, "clipboard text");
5063 }
5064 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5065 }
5066 }
5067
5068 #[test]
5069 fn test_api_open_file() {
5070 let (mut backend, rx) = create_test_backend();
5071
5072 backend
5074 .execute_js(
5075 r#"
5076 const editor = getEditor();
5077 editor.openFile("/path/to/file.txt", null, null);
5078 "#,
5079 "test.js",
5080 )
5081 .unwrap();
5082
5083 let cmd = rx.try_recv().unwrap();
5084 match cmd {
5085 PluginCommand::OpenFileAtLocation { path, line, column } => {
5086 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5087 assert!(line.is_none());
5088 assert!(column.is_none());
5089 }
5090 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5091 }
5092 }
5093
5094 #[test]
5095 fn test_api_delete_range() {
5096 let (mut backend, rx) = create_test_backend();
5097
5098 backend
5100 .execute_js(
5101 r#"
5102 const editor = getEditor();
5103 editor.deleteRange(0, 10, 20);
5104 "#,
5105 "test.js",
5106 )
5107 .unwrap();
5108
5109 let cmd = rx.try_recv().unwrap();
5110 match cmd {
5111 PluginCommand::DeleteRange { range, .. } => {
5112 assert_eq!(range.start, 10);
5113 assert_eq!(range.end, 20);
5114 }
5115 _ => panic!("Expected DeleteRange, got {:?}", cmd),
5116 }
5117 }
5118
5119 #[test]
5120 fn test_api_insert_text() {
5121 let (mut backend, rx) = create_test_backend();
5122
5123 backend
5125 .execute_js(
5126 r#"
5127 const editor = getEditor();
5128 editor.insertText(0, 5, "inserted");
5129 "#,
5130 "test.js",
5131 )
5132 .unwrap();
5133
5134 let cmd = rx.try_recv().unwrap();
5135 match cmd {
5136 PluginCommand::InsertText { position, text, .. } => {
5137 assert_eq!(position, 5);
5138 assert_eq!(text, "inserted");
5139 }
5140 _ => panic!("Expected InsertText, got {:?}", cmd),
5141 }
5142 }
5143
5144 #[test]
5145 fn test_api_set_buffer_cursor() {
5146 let (mut backend, rx) = create_test_backend();
5147
5148 backend
5150 .execute_js(
5151 r#"
5152 const editor = getEditor();
5153 editor.setBufferCursor(0, 100);
5154 "#,
5155 "test.js",
5156 )
5157 .unwrap();
5158
5159 let cmd = rx.try_recv().unwrap();
5160 match cmd {
5161 PluginCommand::SetBufferCursor { position, .. } => {
5162 assert_eq!(position, 100);
5163 }
5164 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
5165 }
5166 }
5167
5168 #[test]
5169 fn test_api_get_cursor_position_from_state() {
5170 let (tx, _rx) = mpsc::channel();
5171 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5172
5173 {
5175 let mut state = state_snapshot.write().unwrap();
5176 state.primary_cursor = Some(CursorInfo {
5177 position: 42,
5178 selection: None,
5179 });
5180 }
5181
5182 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5183 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5184
5185 backend
5187 .execute_js(
5188 r#"
5189 const editor = getEditor();
5190 const pos = editor.getCursorPosition();
5191 globalThis._testResult = pos;
5192 "#,
5193 "test.js",
5194 )
5195 .unwrap();
5196
5197 backend
5199 .plugin_contexts
5200 .borrow()
5201 .get("test")
5202 .unwrap()
5203 .clone()
5204 .with(|ctx| {
5205 let global = ctx.globals();
5206 let result: u32 = global.get("_testResult").unwrap();
5207 assert_eq!(result, 42);
5208 });
5209 }
5210
5211 #[test]
5212 fn test_api_path_functions() {
5213 let (mut backend, _rx) = create_test_backend();
5214
5215 #[cfg(windows)]
5218 let absolute_path = r#"C:\\foo\\bar"#;
5219 #[cfg(not(windows))]
5220 let absolute_path = "/foo/bar";
5221
5222 let js_code = format!(
5224 r#"
5225 const editor = getEditor();
5226 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
5227 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
5228 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
5229 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
5230 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
5231 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
5232 "#,
5233 absolute_path
5234 );
5235 backend.execute_js(&js_code, "test.js").unwrap();
5236
5237 backend
5238 .plugin_contexts
5239 .borrow()
5240 .get("test")
5241 .unwrap()
5242 .clone()
5243 .with(|ctx| {
5244 let global = ctx.globals();
5245 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
5246 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
5247 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
5248 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
5249 assert!(!global.get::<_, bool>("_isRelative").unwrap());
5250 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
5251 });
5252 }
5253
5254 #[test]
5255 fn test_file_uri_to_path_and_back() {
5256 let (mut backend, _rx) = create_test_backend();
5257
5258 #[cfg(not(windows))]
5260 let js_code = r#"
5261 const editor = getEditor();
5262 // Basic file URI to path
5263 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
5264 // Percent-encoded characters
5265 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
5266 // Invalid URI returns empty string
5267 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5268 // Path to file URI
5269 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
5270 // Round-trip
5271 globalThis._roundtrip = editor.fileUriToPath(
5272 editor.pathToFileUri("/home/user/file.txt")
5273 );
5274 "#;
5275
5276 #[cfg(windows)]
5277 let js_code = r#"
5278 const editor = getEditor();
5279 // Windows URI with encoded colon (the bug from issue #1071)
5280 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
5281 // Windows URI with normal colon
5282 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
5283 // Invalid URI returns empty string
5284 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5285 // Path to file URI
5286 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
5287 // Round-trip
5288 globalThis._roundtrip = editor.fileUriToPath(
5289 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
5290 );
5291 "#;
5292
5293 backend.execute_js(js_code, "test.js").unwrap();
5294
5295 backend
5296 .plugin_contexts
5297 .borrow()
5298 .get("test")
5299 .unwrap()
5300 .clone()
5301 .with(|ctx| {
5302 let global = ctx.globals();
5303
5304 #[cfg(not(windows))]
5305 {
5306 assert_eq!(
5307 global.get::<_, String>("_path1").unwrap(),
5308 "/home/user/file.txt"
5309 );
5310 assert_eq!(
5311 global.get::<_, String>("_path2").unwrap(),
5312 "/home/user/my file.txt"
5313 );
5314 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5315 assert_eq!(
5316 global.get::<_, String>("_uri1").unwrap(),
5317 "file:///home/user/file.txt"
5318 );
5319 assert_eq!(
5320 global.get::<_, String>("_roundtrip").unwrap(),
5321 "/home/user/file.txt"
5322 );
5323 }
5324
5325 #[cfg(windows)]
5326 {
5327 assert_eq!(
5329 global.get::<_, String>("_path1").unwrap(),
5330 "C:\\Users\\admin\\Repos\\file.cs"
5331 );
5332 assert_eq!(
5333 global.get::<_, String>("_path2").unwrap(),
5334 "C:\\Users\\admin\\Repos\\file.cs"
5335 );
5336 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5337 assert_eq!(
5338 global.get::<_, String>("_uri1").unwrap(),
5339 "file:///C:/Users/admin/Repos/file.cs"
5340 );
5341 assert_eq!(
5342 global.get::<_, String>("_roundtrip").unwrap(),
5343 "C:\\Users\\admin\\Repos\\file.cs"
5344 );
5345 }
5346 });
5347 }
5348
5349 #[test]
5350 fn test_typescript_transpilation() {
5351 use fresh_parser_js::transpile_typescript;
5352
5353 let (mut backend, rx) = create_test_backend();
5354
5355 let ts_code = r#"
5357 const editor = getEditor();
5358 function greet(name: string): string {
5359 return "Hello, " + name;
5360 }
5361 editor.setStatus(greet("TypeScript"));
5362 "#;
5363
5364 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
5366
5367 backend.execute_js(&js_code, "test.js").unwrap();
5369
5370 let cmd = rx.try_recv().unwrap();
5371 match cmd {
5372 PluginCommand::SetStatus { message } => {
5373 assert_eq!(message, "Hello, TypeScript");
5374 }
5375 _ => panic!("Expected SetStatus, got {:?}", cmd),
5376 }
5377 }
5378
5379 #[test]
5380 fn test_api_get_buffer_text_sends_command() {
5381 let (mut backend, rx) = create_test_backend();
5382
5383 backend
5385 .execute_js(
5386 r#"
5387 const editor = getEditor();
5388 // Store the promise for later
5389 globalThis._textPromise = editor.getBufferText(0, 10, 20);
5390 "#,
5391 "test.js",
5392 )
5393 .unwrap();
5394
5395 let cmd = rx.try_recv().unwrap();
5397 match cmd {
5398 PluginCommand::GetBufferText {
5399 buffer_id,
5400 start,
5401 end,
5402 request_id,
5403 } => {
5404 assert_eq!(buffer_id.0, 0);
5405 assert_eq!(start, 10);
5406 assert_eq!(end, 20);
5407 assert!(request_id > 0); }
5409 _ => panic!("Expected GetBufferText, got {:?}", cmd),
5410 }
5411 }
5412
5413 #[test]
5414 fn test_api_get_buffer_text_resolves_callback() {
5415 let (mut backend, rx) = create_test_backend();
5416
5417 backend
5419 .execute_js(
5420 r#"
5421 const editor = getEditor();
5422 globalThis._resolvedText = null;
5423 editor.getBufferText(0, 0, 100).then(text => {
5424 globalThis._resolvedText = text;
5425 });
5426 "#,
5427 "test.js",
5428 )
5429 .unwrap();
5430
5431 let request_id = match rx.try_recv().unwrap() {
5433 PluginCommand::GetBufferText { request_id, .. } => request_id,
5434 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
5435 };
5436
5437 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
5439
5440 backend
5442 .plugin_contexts
5443 .borrow()
5444 .get("test")
5445 .unwrap()
5446 .clone()
5447 .with(|ctx| {
5448 run_pending_jobs_checked(&ctx, "test async getText");
5449 });
5450
5451 backend
5453 .plugin_contexts
5454 .borrow()
5455 .get("test")
5456 .unwrap()
5457 .clone()
5458 .with(|ctx| {
5459 let global = ctx.globals();
5460 let result: String = global.get("_resolvedText").unwrap();
5461 assert_eq!(result, "hello world");
5462 });
5463 }
5464
5465 #[test]
5466 fn test_plugin_translation() {
5467 let (mut backend, _rx) = create_test_backend();
5468
5469 backend
5471 .execute_js(
5472 r#"
5473 const editor = getEditor();
5474 globalThis._translated = editor.t("test.key");
5475 "#,
5476 "test.js",
5477 )
5478 .unwrap();
5479
5480 backend
5481 .plugin_contexts
5482 .borrow()
5483 .get("test")
5484 .unwrap()
5485 .clone()
5486 .with(|ctx| {
5487 let global = ctx.globals();
5488 let result: String = global.get("_translated").unwrap();
5490 assert_eq!(result, "test.key");
5491 });
5492 }
5493
5494 #[test]
5495 fn test_plugin_translation_with_registered_strings() {
5496 let (mut backend, _rx) = create_test_backend();
5497
5498 let mut en_strings = std::collections::HashMap::new();
5500 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
5501 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
5502
5503 let mut strings = std::collections::HashMap::new();
5504 strings.insert("en".to_string(), en_strings);
5505
5506 if let Some(bridge) = backend
5508 .services
5509 .as_any()
5510 .downcast_ref::<TestServiceBridge>()
5511 {
5512 let mut en = bridge.en_strings.lock().unwrap();
5513 en.insert("greeting".to_string(), "Hello, World!".to_string());
5514 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
5515 }
5516
5517 backend
5519 .execute_js(
5520 r#"
5521 const editor = getEditor();
5522 globalThis._greeting = editor.t("greeting");
5523 globalThis._prompt = editor.t("prompt.find_file");
5524 globalThis._missing = editor.t("nonexistent.key");
5525 "#,
5526 "test.js",
5527 )
5528 .unwrap();
5529
5530 backend
5531 .plugin_contexts
5532 .borrow()
5533 .get("test")
5534 .unwrap()
5535 .clone()
5536 .with(|ctx| {
5537 let global = ctx.globals();
5538 let greeting: String = global.get("_greeting").unwrap();
5539 assert_eq!(greeting, "Hello, World!");
5540
5541 let prompt: String = global.get("_prompt").unwrap();
5542 assert_eq!(prompt, "Find file: ");
5543
5544 let missing: String = global.get("_missing").unwrap();
5546 assert_eq!(missing, "nonexistent.key");
5547 });
5548 }
5549
5550 #[test]
5553 fn test_api_set_line_indicator() {
5554 let (mut backend, rx) = create_test_backend();
5555
5556 backend
5557 .execute_js(
5558 r#"
5559 const editor = getEditor();
5560 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
5561 "#,
5562 "test.js",
5563 )
5564 .unwrap();
5565
5566 let cmd = rx.try_recv().unwrap();
5567 match cmd {
5568 PluginCommand::SetLineIndicator {
5569 buffer_id,
5570 line,
5571 namespace,
5572 symbol,
5573 color,
5574 priority,
5575 } => {
5576 assert_eq!(buffer_id.0, 1);
5577 assert_eq!(line, 5);
5578 assert_eq!(namespace, "test-ns");
5579 assert_eq!(symbol, "●");
5580 assert_eq!(color, (255, 0, 0));
5581 assert_eq!(priority, 10);
5582 }
5583 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
5584 }
5585 }
5586
5587 #[test]
5588 fn test_api_clear_line_indicators() {
5589 let (mut backend, rx) = create_test_backend();
5590
5591 backend
5592 .execute_js(
5593 r#"
5594 const editor = getEditor();
5595 editor.clearLineIndicators(1, "test-ns");
5596 "#,
5597 "test.js",
5598 )
5599 .unwrap();
5600
5601 let cmd = rx.try_recv().unwrap();
5602 match cmd {
5603 PluginCommand::ClearLineIndicators {
5604 buffer_id,
5605 namespace,
5606 } => {
5607 assert_eq!(buffer_id.0, 1);
5608 assert_eq!(namespace, "test-ns");
5609 }
5610 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
5611 }
5612 }
5613
5614 #[test]
5617 fn test_api_create_virtual_buffer_sends_command() {
5618 let (mut backend, rx) = create_test_backend();
5619
5620 backend
5621 .execute_js(
5622 r#"
5623 const editor = getEditor();
5624 editor.createVirtualBuffer({
5625 name: "*Test Buffer*",
5626 mode: "test-mode",
5627 readOnly: true,
5628 entries: [
5629 { text: "Line 1\n", properties: { type: "header" } },
5630 { text: "Line 2\n", properties: { type: "content" } }
5631 ],
5632 showLineNumbers: false,
5633 showCursors: true,
5634 editingDisabled: true
5635 });
5636 "#,
5637 "test.js",
5638 )
5639 .unwrap();
5640
5641 let cmd = rx.try_recv().unwrap();
5642 match cmd {
5643 PluginCommand::CreateVirtualBufferWithContent {
5644 name,
5645 mode,
5646 read_only,
5647 entries,
5648 show_line_numbers,
5649 show_cursors,
5650 editing_disabled,
5651 ..
5652 } => {
5653 assert_eq!(name, "*Test Buffer*");
5654 assert_eq!(mode, "test-mode");
5655 assert!(read_only);
5656 assert_eq!(entries.len(), 2);
5657 assert_eq!(entries[0].text, "Line 1\n");
5658 assert!(!show_line_numbers);
5659 assert!(show_cursors);
5660 assert!(editing_disabled);
5661 }
5662 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
5663 }
5664 }
5665
5666 #[test]
5667 fn test_api_set_virtual_buffer_content() {
5668 let (mut backend, rx) = create_test_backend();
5669
5670 backend
5671 .execute_js(
5672 r#"
5673 const editor = getEditor();
5674 editor.setVirtualBufferContent(5, [
5675 { text: "New content\n", properties: { type: "updated" } }
5676 ]);
5677 "#,
5678 "test.js",
5679 )
5680 .unwrap();
5681
5682 let cmd = rx.try_recv().unwrap();
5683 match cmd {
5684 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
5685 assert_eq!(buffer_id.0, 5);
5686 assert_eq!(entries.len(), 1);
5687 assert_eq!(entries[0].text, "New content\n");
5688 }
5689 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
5690 }
5691 }
5692
5693 #[test]
5696 fn test_api_add_overlay() {
5697 let (mut backend, rx) = create_test_backend();
5698
5699 backend
5700 .execute_js(
5701 r#"
5702 const editor = getEditor();
5703 editor.addOverlay(1, "highlight", 10, 20, {
5704 fg: [255, 128, 0],
5705 bg: [50, 50, 50],
5706 bold: true,
5707 });
5708 "#,
5709 "test.js",
5710 )
5711 .unwrap();
5712
5713 let cmd = rx.try_recv().unwrap();
5714 match cmd {
5715 PluginCommand::AddOverlay {
5716 buffer_id,
5717 namespace,
5718 range,
5719 options,
5720 } => {
5721 use fresh_core::api::OverlayColorSpec;
5722 assert_eq!(buffer_id.0, 1);
5723 assert!(namespace.is_some());
5724 assert_eq!(namespace.unwrap().as_str(), "highlight");
5725 assert_eq!(range, 10..20);
5726 assert!(matches!(
5727 options.fg,
5728 Some(OverlayColorSpec::Rgb(255, 128, 0))
5729 ));
5730 assert!(matches!(
5731 options.bg,
5732 Some(OverlayColorSpec::Rgb(50, 50, 50))
5733 ));
5734 assert!(!options.underline);
5735 assert!(options.bold);
5736 assert!(!options.italic);
5737 assert!(!options.extend_to_line_end);
5738 }
5739 _ => panic!("Expected AddOverlay, got {:?}", cmd),
5740 }
5741 }
5742
5743 #[test]
5744 fn test_api_add_overlay_with_theme_keys() {
5745 let (mut backend, rx) = create_test_backend();
5746
5747 backend
5748 .execute_js(
5749 r#"
5750 const editor = getEditor();
5751 // Test with theme keys for colors
5752 editor.addOverlay(1, "themed", 0, 10, {
5753 fg: "ui.status_bar_fg",
5754 bg: "editor.selection_bg",
5755 });
5756 "#,
5757 "test.js",
5758 )
5759 .unwrap();
5760
5761 let cmd = rx.try_recv().unwrap();
5762 match cmd {
5763 PluginCommand::AddOverlay {
5764 buffer_id,
5765 namespace,
5766 range,
5767 options,
5768 } => {
5769 use fresh_core::api::OverlayColorSpec;
5770 assert_eq!(buffer_id.0, 1);
5771 assert!(namespace.is_some());
5772 assert_eq!(namespace.unwrap().as_str(), "themed");
5773 assert_eq!(range, 0..10);
5774 assert!(matches!(
5775 &options.fg,
5776 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
5777 ));
5778 assert!(matches!(
5779 &options.bg,
5780 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
5781 ));
5782 assert!(!options.underline);
5783 assert!(!options.bold);
5784 assert!(!options.italic);
5785 assert!(!options.extend_to_line_end);
5786 }
5787 _ => panic!("Expected AddOverlay, got {:?}", cmd),
5788 }
5789 }
5790
5791 #[test]
5792 fn test_api_clear_namespace() {
5793 let (mut backend, rx) = create_test_backend();
5794
5795 backend
5796 .execute_js(
5797 r#"
5798 const editor = getEditor();
5799 editor.clearNamespace(1, "highlight");
5800 "#,
5801 "test.js",
5802 )
5803 .unwrap();
5804
5805 let cmd = rx.try_recv().unwrap();
5806 match cmd {
5807 PluginCommand::ClearNamespace {
5808 buffer_id,
5809 namespace,
5810 } => {
5811 assert_eq!(buffer_id.0, 1);
5812 assert_eq!(namespace.as_str(), "highlight");
5813 }
5814 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
5815 }
5816 }
5817
5818 #[test]
5821 fn test_api_get_theme_schema() {
5822 let (mut backend, _rx) = create_test_backend();
5823
5824 backend
5825 .execute_js(
5826 r#"
5827 const editor = getEditor();
5828 const schema = editor.getThemeSchema();
5829 globalThis._isObject = typeof schema === 'object' && schema !== null;
5830 "#,
5831 "test.js",
5832 )
5833 .unwrap();
5834
5835 backend
5836 .plugin_contexts
5837 .borrow()
5838 .get("test")
5839 .unwrap()
5840 .clone()
5841 .with(|ctx| {
5842 let global = ctx.globals();
5843 let is_object: bool = global.get("_isObject").unwrap();
5844 assert!(is_object);
5846 });
5847 }
5848
5849 #[test]
5850 fn test_api_get_builtin_themes() {
5851 let (mut backend, _rx) = create_test_backend();
5852
5853 backend
5854 .execute_js(
5855 r#"
5856 const editor = getEditor();
5857 const themes = editor.getBuiltinThemes();
5858 globalThis._isObject = typeof themes === 'object' && themes !== null;
5859 "#,
5860 "test.js",
5861 )
5862 .unwrap();
5863
5864 backend
5865 .plugin_contexts
5866 .borrow()
5867 .get("test")
5868 .unwrap()
5869 .clone()
5870 .with(|ctx| {
5871 let global = ctx.globals();
5872 let is_object: bool = global.get("_isObject").unwrap();
5873 assert!(is_object);
5875 });
5876 }
5877
5878 #[test]
5879 fn test_api_apply_theme() {
5880 let (mut backend, rx) = create_test_backend();
5881
5882 backend
5883 .execute_js(
5884 r#"
5885 const editor = getEditor();
5886 editor.applyTheme("dark");
5887 "#,
5888 "test.js",
5889 )
5890 .unwrap();
5891
5892 let cmd = rx.try_recv().unwrap();
5893 match cmd {
5894 PluginCommand::ApplyTheme { theme_name } => {
5895 assert_eq!(theme_name, "dark");
5896 }
5897 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
5898 }
5899 }
5900
5901 #[test]
5902 fn test_api_get_theme_data_missing() {
5903 let (mut backend, _rx) = create_test_backend();
5904
5905 backend
5906 .execute_js(
5907 r#"
5908 const editor = getEditor();
5909 const data = editor.getThemeData("nonexistent");
5910 globalThis._isNull = data === null;
5911 "#,
5912 "test.js",
5913 )
5914 .unwrap();
5915
5916 backend
5917 .plugin_contexts
5918 .borrow()
5919 .get("test")
5920 .unwrap()
5921 .clone()
5922 .with(|ctx| {
5923 let global = ctx.globals();
5924 let is_null: bool = global.get("_isNull").unwrap();
5925 assert!(is_null);
5927 });
5928 }
5929
5930 #[test]
5931 fn test_api_get_theme_data_present() {
5932 let (tx, _rx) = mpsc::channel();
5934 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5935 let services = Arc::new(ThemeCacheTestBridge {
5936 inner: TestServiceBridge::new(),
5937 });
5938 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5939
5940 backend
5941 .execute_js(
5942 r#"
5943 const editor = getEditor();
5944 const data = editor.getThemeData("test-theme");
5945 globalThis._hasData = data !== null && typeof data === 'object';
5946 globalThis._name = data ? data.name : null;
5947 "#,
5948 "test.js",
5949 )
5950 .unwrap();
5951
5952 backend
5953 .plugin_contexts
5954 .borrow()
5955 .get("test")
5956 .unwrap()
5957 .clone()
5958 .with(|ctx| {
5959 let global = ctx.globals();
5960 let has_data: bool = global.get("_hasData").unwrap();
5961 assert!(has_data, "getThemeData should return theme object");
5962 let name: String = global.get("_name").unwrap();
5963 assert_eq!(name, "test-theme");
5964 });
5965 }
5966
5967 #[test]
5968 fn test_api_theme_file_exists() {
5969 let (mut backend, _rx) = create_test_backend();
5970
5971 backend
5972 .execute_js(
5973 r#"
5974 const editor = getEditor();
5975 globalThis._exists = editor.themeFileExists("anything");
5976 "#,
5977 "test.js",
5978 )
5979 .unwrap();
5980
5981 backend
5982 .plugin_contexts
5983 .borrow()
5984 .get("test")
5985 .unwrap()
5986 .clone()
5987 .with(|ctx| {
5988 let global = ctx.globals();
5989 let exists: bool = global.get("_exists").unwrap();
5990 assert!(!exists);
5992 });
5993 }
5994
5995 #[test]
5996 fn test_api_save_theme_file_error() {
5997 let (mut backend, _rx) = create_test_backend();
5998
5999 backend
6000 .execute_js(
6001 r#"
6002 const editor = getEditor();
6003 let threw = false;
6004 try {
6005 editor.saveThemeFile("test", "{}");
6006 } catch (e) {
6007 threw = true;
6008 }
6009 globalThis._threw = threw;
6010 "#,
6011 "test.js",
6012 )
6013 .unwrap();
6014
6015 backend
6016 .plugin_contexts
6017 .borrow()
6018 .get("test")
6019 .unwrap()
6020 .clone()
6021 .with(|ctx| {
6022 let global = ctx.globals();
6023 let threw: bool = global.get("_threw").unwrap();
6024 assert!(threw);
6026 });
6027 }
6028
6029 struct ThemeCacheTestBridge {
6031 inner: TestServiceBridge,
6032 }
6033
6034 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6035 fn as_any(&self) -> &dyn std::any::Any {
6036 self
6037 }
6038 fn translate(
6039 &self,
6040 plugin_name: &str,
6041 key: &str,
6042 args: &HashMap<String, String>,
6043 ) -> String {
6044 self.inner.translate(plugin_name, key, args)
6045 }
6046 fn current_locale(&self) -> String {
6047 self.inner.current_locale()
6048 }
6049 fn set_js_execution_state(&self, state: String) {
6050 self.inner.set_js_execution_state(state);
6051 }
6052 fn clear_js_execution_state(&self) {
6053 self.inner.clear_js_execution_state();
6054 }
6055 fn get_theme_schema(&self) -> serde_json::Value {
6056 self.inner.get_theme_schema()
6057 }
6058 fn get_builtin_themes(&self) -> serde_json::Value {
6059 self.inner.get_builtin_themes()
6060 }
6061 fn register_command(&self, command: fresh_core::command::Command) {
6062 self.inner.register_command(command);
6063 }
6064 fn unregister_command(&self, name: &str) {
6065 self.inner.unregister_command(name);
6066 }
6067 fn unregister_commands_by_prefix(&self, prefix: &str) {
6068 self.inner.unregister_commands_by_prefix(prefix);
6069 }
6070 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6071 self.inner.unregister_commands_by_plugin(plugin_name);
6072 }
6073 fn plugins_dir(&self) -> std::path::PathBuf {
6074 self.inner.plugins_dir()
6075 }
6076 fn config_dir(&self) -> std::path::PathBuf {
6077 self.inner.config_dir()
6078 }
6079 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6080 if name == "test-theme" {
6081 Some(serde_json::json!({
6082 "name": "test-theme",
6083 "editor": {},
6084 "ui": {},
6085 "syntax": {}
6086 }))
6087 } else {
6088 None
6089 }
6090 }
6091 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6092 Err("test bridge does not support save".to_string())
6093 }
6094 fn theme_file_exists(&self, name: &str) -> bool {
6095 name == "test-theme"
6096 }
6097 }
6098
6099 #[test]
6102 fn test_api_close_buffer() {
6103 let (mut backend, rx) = create_test_backend();
6104
6105 backend
6106 .execute_js(
6107 r#"
6108 const editor = getEditor();
6109 editor.closeBuffer(3);
6110 "#,
6111 "test.js",
6112 )
6113 .unwrap();
6114
6115 let cmd = rx.try_recv().unwrap();
6116 match cmd {
6117 PluginCommand::CloseBuffer { buffer_id } => {
6118 assert_eq!(buffer_id.0, 3);
6119 }
6120 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
6121 }
6122 }
6123
6124 #[test]
6125 fn test_api_focus_split() {
6126 let (mut backend, rx) = create_test_backend();
6127
6128 backend
6129 .execute_js(
6130 r#"
6131 const editor = getEditor();
6132 editor.focusSplit(2);
6133 "#,
6134 "test.js",
6135 )
6136 .unwrap();
6137
6138 let cmd = rx.try_recv().unwrap();
6139 match cmd {
6140 PluginCommand::FocusSplit { split_id } => {
6141 assert_eq!(split_id.0, 2);
6142 }
6143 _ => panic!("Expected FocusSplit, got {:?}", cmd),
6144 }
6145 }
6146
6147 #[test]
6148 fn test_api_list_buffers() {
6149 let (tx, _rx) = mpsc::channel();
6150 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6151
6152 {
6154 let mut state = state_snapshot.write().unwrap();
6155 state.buffers.insert(
6156 BufferId(0),
6157 BufferInfo {
6158 id: BufferId(0),
6159 path: Some(PathBuf::from("/test1.txt")),
6160 modified: false,
6161 length: 100,
6162 is_virtual: false,
6163 view_mode: "source".to_string(),
6164 is_composing_in_any_split: false,
6165 compose_width: None,
6166 language: "text".to_string(),
6167 },
6168 );
6169 state.buffers.insert(
6170 BufferId(1),
6171 BufferInfo {
6172 id: BufferId(1),
6173 path: Some(PathBuf::from("/test2.txt")),
6174 modified: true,
6175 length: 200,
6176 is_virtual: false,
6177 view_mode: "source".to_string(),
6178 is_composing_in_any_split: false,
6179 compose_width: None,
6180 language: "text".to_string(),
6181 },
6182 );
6183 }
6184
6185 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6186 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6187
6188 backend
6189 .execute_js(
6190 r#"
6191 const editor = getEditor();
6192 const buffers = editor.listBuffers();
6193 globalThis._isArray = Array.isArray(buffers);
6194 globalThis._length = buffers.length;
6195 "#,
6196 "test.js",
6197 )
6198 .unwrap();
6199
6200 backend
6201 .plugin_contexts
6202 .borrow()
6203 .get("test")
6204 .unwrap()
6205 .clone()
6206 .with(|ctx| {
6207 let global = ctx.globals();
6208 let is_array: bool = global.get("_isArray").unwrap();
6209 let length: u32 = global.get("_length").unwrap();
6210 assert!(is_array);
6211 assert_eq!(length, 2);
6212 });
6213 }
6214
6215 #[test]
6218 fn test_api_start_prompt() {
6219 let (mut backend, rx) = create_test_backend();
6220
6221 backend
6222 .execute_js(
6223 r#"
6224 const editor = getEditor();
6225 editor.startPrompt("Enter value:", "test-prompt");
6226 "#,
6227 "test.js",
6228 )
6229 .unwrap();
6230
6231 let cmd = rx.try_recv().unwrap();
6232 match cmd {
6233 PluginCommand::StartPrompt { label, prompt_type } => {
6234 assert_eq!(label, "Enter value:");
6235 assert_eq!(prompt_type, "test-prompt");
6236 }
6237 _ => panic!("Expected StartPrompt, got {:?}", cmd),
6238 }
6239 }
6240
6241 #[test]
6242 fn test_api_start_prompt_with_initial() {
6243 let (mut backend, rx) = create_test_backend();
6244
6245 backend
6246 .execute_js(
6247 r#"
6248 const editor = getEditor();
6249 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
6250 "#,
6251 "test.js",
6252 )
6253 .unwrap();
6254
6255 let cmd = rx.try_recv().unwrap();
6256 match cmd {
6257 PluginCommand::StartPromptWithInitial {
6258 label,
6259 prompt_type,
6260 initial_value,
6261 } => {
6262 assert_eq!(label, "Enter value:");
6263 assert_eq!(prompt_type, "test-prompt");
6264 assert_eq!(initial_value, "default");
6265 }
6266 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
6267 }
6268 }
6269
6270 #[test]
6271 fn test_api_set_prompt_suggestions() {
6272 let (mut backend, rx) = create_test_backend();
6273
6274 backend
6275 .execute_js(
6276 r#"
6277 const editor = getEditor();
6278 editor.setPromptSuggestions([
6279 { text: "Option 1", value: "opt1" },
6280 { text: "Option 2", value: "opt2" }
6281 ]);
6282 "#,
6283 "test.js",
6284 )
6285 .unwrap();
6286
6287 let cmd = rx.try_recv().unwrap();
6288 match cmd {
6289 PluginCommand::SetPromptSuggestions { suggestions } => {
6290 assert_eq!(suggestions.len(), 2);
6291 assert_eq!(suggestions[0].text, "Option 1");
6292 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
6293 }
6294 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
6295 }
6296 }
6297
6298 #[test]
6301 fn test_api_get_active_buffer_id() {
6302 let (tx, _rx) = mpsc::channel();
6303 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6304
6305 {
6306 let mut state = state_snapshot.write().unwrap();
6307 state.active_buffer_id = BufferId(42);
6308 }
6309
6310 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6311 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6312
6313 backend
6314 .execute_js(
6315 r#"
6316 const editor = getEditor();
6317 globalThis._activeId = editor.getActiveBufferId();
6318 "#,
6319 "test.js",
6320 )
6321 .unwrap();
6322
6323 backend
6324 .plugin_contexts
6325 .borrow()
6326 .get("test")
6327 .unwrap()
6328 .clone()
6329 .with(|ctx| {
6330 let global = ctx.globals();
6331 let result: u32 = global.get("_activeId").unwrap();
6332 assert_eq!(result, 42);
6333 });
6334 }
6335
6336 #[test]
6337 fn test_api_get_active_split_id() {
6338 let (tx, _rx) = mpsc::channel();
6339 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6340
6341 {
6342 let mut state = state_snapshot.write().unwrap();
6343 state.active_split_id = 7;
6344 }
6345
6346 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6347 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6348
6349 backend
6350 .execute_js(
6351 r#"
6352 const editor = getEditor();
6353 globalThis._splitId = editor.getActiveSplitId();
6354 "#,
6355 "test.js",
6356 )
6357 .unwrap();
6358
6359 backend
6360 .plugin_contexts
6361 .borrow()
6362 .get("test")
6363 .unwrap()
6364 .clone()
6365 .with(|ctx| {
6366 let global = ctx.globals();
6367 let result: u32 = global.get("_splitId").unwrap();
6368 assert_eq!(result, 7);
6369 });
6370 }
6371
6372 #[test]
6375 fn test_api_file_exists() {
6376 let (mut backend, _rx) = create_test_backend();
6377
6378 backend
6379 .execute_js(
6380 r#"
6381 const editor = getEditor();
6382 // Test with a path that definitely exists
6383 globalThis._exists = editor.fileExists("/");
6384 "#,
6385 "test.js",
6386 )
6387 .unwrap();
6388
6389 backend
6390 .plugin_contexts
6391 .borrow()
6392 .get("test")
6393 .unwrap()
6394 .clone()
6395 .with(|ctx| {
6396 let global = ctx.globals();
6397 let result: bool = global.get("_exists").unwrap();
6398 assert!(result);
6399 });
6400 }
6401
6402 #[test]
6403 fn test_api_get_cwd() {
6404 let (mut backend, _rx) = create_test_backend();
6405
6406 backend
6407 .execute_js(
6408 r#"
6409 const editor = getEditor();
6410 globalThis._cwd = editor.getCwd();
6411 "#,
6412 "test.js",
6413 )
6414 .unwrap();
6415
6416 backend
6417 .plugin_contexts
6418 .borrow()
6419 .get("test")
6420 .unwrap()
6421 .clone()
6422 .with(|ctx| {
6423 let global = ctx.globals();
6424 let result: String = global.get("_cwd").unwrap();
6425 assert!(!result.is_empty());
6427 });
6428 }
6429
6430 #[test]
6431 fn test_api_get_env() {
6432 let (mut backend, _rx) = create_test_backend();
6433
6434 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
6436
6437 backend
6438 .execute_js(
6439 r#"
6440 const editor = getEditor();
6441 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
6442 "#,
6443 "test.js",
6444 )
6445 .unwrap();
6446
6447 backend
6448 .plugin_contexts
6449 .borrow()
6450 .get("test")
6451 .unwrap()
6452 .clone()
6453 .with(|ctx| {
6454 let global = ctx.globals();
6455 let result: Option<String> = global.get("_envVal").unwrap();
6456 assert_eq!(result, Some("test_value".to_string()));
6457 });
6458
6459 std::env::remove_var("TEST_PLUGIN_VAR");
6460 }
6461
6462 #[test]
6463 fn test_api_get_config() {
6464 let (mut backend, _rx) = create_test_backend();
6465
6466 backend
6467 .execute_js(
6468 r#"
6469 const editor = getEditor();
6470 const config = editor.getConfig();
6471 globalThis._isObject = typeof config === 'object';
6472 "#,
6473 "test.js",
6474 )
6475 .unwrap();
6476
6477 backend
6478 .plugin_contexts
6479 .borrow()
6480 .get("test")
6481 .unwrap()
6482 .clone()
6483 .with(|ctx| {
6484 let global = ctx.globals();
6485 let is_object: bool = global.get("_isObject").unwrap();
6486 assert!(is_object);
6488 });
6489 }
6490
6491 #[test]
6492 fn test_api_get_themes_dir() {
6493 let (mut backend, _rx) = create_test_backend();
6494
6495 backend
6496 .execute_js(
6497 r#"
6498 const editor = getEditor();
6499 globalThis._themesDir = editor.getThemesDir();
6500 "#,
6501 "test.js",
6502 )
6503 .unwrap();
6504
6505 backend
6506 .plugin_contexts
6507 .borrow()
6508 .get("test")
6509 .unwrap()
6510 .clone()
6511 .with(|ctx| {
6512 let global = ctx.globals();
6513 let result: String = global.get("_themesDir").unwrap();
6514 assert!(!result.is_empty());
6516 });
6517 }
6518
6519 #[test]
6522 fn test_api_read_dir() {
6523 let (mut backend, _rx) = create_test_backend();
6524
6525 backend
6526 .execute_js(
6527 r#"
6528 const editor = getEditor();
6529 const entries = editor.readDir("/tmp");
6530 globalThis._isArray = Array.isArray(entries);
6531 globalThis._length = entries.length;
6532 "#,
6533 "test.js",
6534 )
6535 .unwrap();
6536
6537 backend
6538 .plugin_contexts
6539 .borrow()
6540 .get("test")
6541 .unwrap()
6542 .clone()
6543 .with(|ctx| {
6544 let global = ctx.globals();
6545 let is_array: bool = global.get("_isArray").unwrap();
6546 let length: u32 = global.get("_length").unwrap();
6547 assert!(is_array);
6549 let _ = length;
6551 });
6552 }
6553
6554 #[test]
6557 fn test_api_execute_action() {
6558 let (mut backend, rx) = create_test_backend();
6559
6560 backend
6561 .execute_js(
6562 r#"
6563 const editor = getEditor();
6564 editor.executeAction("move_cursor_up");
6565 "#,
6566 "test.js",
6567 )
6568 .unwrap();
6569
6570 let cmd = rx.try_recv().unwrap();
6571 match cmd {
6572 PluginCommand::ExecuteAction { action_name } => {
6573 assert_eq!(action_name, "move_cursor_up");
6574 }
6575 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
6576 }
6577 }
6578
6579 #[test]
6582 fn test_api_debug() {
6583 let (mut backend, _rx) = create_test_backend();
6584
6585 backend
6587 .execute_js(
6588 r#"
6589 const editor = getEditor();
6590 editor.debug("Test debug message");
6591 editor.debug("Another message with special chars: <>&\"'");
6592 "#,
6593 "test.js",
6594 )
6595 .unwrap();
6596 }
6598
6599 #[test]
6602 fn test_typescript_preamble_generated() {
6603 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
6605 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
6606 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
6607 println!(
6608 "Generated {} bytes of TypeScript preamble",
6609 JSEDITORAPI_TS_PREAMBLE.len()
6610 );
6611 }
6612
6613 #[test]
6614 fn test_typescript_editor_api_generated() {
6615 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
6617 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
6618 println!(
6619 "Generated {} bytes of EditorAPI interface",
6620 JSEDITORAPI_TS_EDITOR_API.len()
6621 );
6622 }
6623
6624 #[test]
6625 fn test_js_methods_list() {
6626 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
6628 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
6629 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
6631 if i < 20 {
6632 println!(" - {}", method);
6633 }
6634 }
6635 if JSEDITORAPI_JS_METHODS.len() > 20 {
6636 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
6637 }
6638 }
6639
6640 #[test]
6643 fn test_api_load_plugin_sends_command() {
6644 let (mut backend, rx) = create_test_backend();
6645
6646 backend
6648 .execute_js(
6649 r#"
6650 const editor = getEditor();
6651 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
6652 "#,
6653 "test.js",
6654 )
6655 .unwrap();
6656
6657 let cmd = rx.try_recv().unwrap();
6659 match cmd {
6660 PluginCommand::LoadPlugin { path, callback_id } => {
6661 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
6662 assert!(callback_id.0 > 0); }
6664 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
6665 }
6666 }
6667
6668 #[test]
6669 fn test_api_unload_plugin_sends_command() {
6670 let (mut backend, rx) = create_test_backend();
6671
6672 backend
6674 .execute_js(
6675 r#"
6676 const editor = getEditor();
6677 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
6678 "#,
6679 "test.js",
6680 )
6681 .unwrap();
6682
6683 let cmd = rx.try_recv().unwrap();
6685 match cmd {
6686 PluginCommand::UnloadPlugin { name, callback_id } => {
6687 assert_eq!(name, "my-plugin");
6688 assert!(callback_id.0 > 0); }
6690 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
6691 }
6692 }
6693
6694 #[test]
6695 fn test_api_reload_plugin_sends_command() {
6696 let (mut backend, rx) = create_test_backend();
6697
6698 backend
6700 .execute_js(
6701 r#"
6702 const editor = getEditor();
6703 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
6704 "#,
6705 "test.js",
6706 )
6707 .unwrap();
6708
6709 let cmd = rx.try_recv().unwrap();
6711 match cmd {
6712 PluginCommand::ReloadPlugin { name, callback_id } => {
6713 assert_eq!(name, "my-plugin");
6714 assert!(callback_id.0 > 0); }
6716 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
6717 }
6718 }
6719
6720 #[test]
6721 fn test_api_load_plugin_resolves_callback() {
6722 let (mut backend, rx) = create_test_backend();
6723
6724 backend
6726 .execute_js(
6727 r#"
6728 const editor = getEditor();
6729 globalThis._loadResult = null;
6730 editor.loadPlugin("/path/to/plugin.ts").then(result => {
6731 globalThis._loadResult = result;
6732 });
6733 "#,
6734 "test.js",
6735 )
6736 .unwrap();
6737
6738 let callback_id = match rx.try_recv().unwrap() {
6740 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
6741 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
6742 };
6743
6744 backend.resolve_callback(callback_id, "true");
6746
6747 backend
6749 .plugin_contexts
6750 .borrow()
6751 .get("test")
6752 .unwrap()
6753 .clone()
6754 .with(|ctx| {
6755 run_pending_jobs_checked(&ctx, "test async loadPlugin");
6756 });
6757
6758 backend
6760 .plugin_contexts
6761 .borrow()
6762 .get("test")
6763 .unwrap()
6764 .clone()
6765 .with(|ctx| {
6766 let global = ctx.globals();
6767 let result: bool = global.get("_loadResult").unwrap();
6768 assert!(result);
6769 });
6770 }
6771
6772 #[test]
6773 fn test_api_version() {
6774 let (mut backend, _rx) = create_test_backend();
6775
6776 backend
6777 .execute_js(
6778 r#"
6779 const editor = getEditor();
6780 globalThis._apiVersion = editor.apiVersion();
6781 "#,
6782 "test.js",
6783 )
6784 .unwrap();
6785
6786 backend
6787 .plugin_contexts
6788 .borrow()
6789 .get("test")
6790 .unwrap()
6791 .clone()
6792 .with(|ctx| {
6793 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
6794 assert_eq!(version, 2);
6795 });
6796 }
6797
6798 #[test]
6799 fn test_api_unload_plugin_rejects_on_error() {
6800 let (mut backend, rx) = create_test_backend();
6801
6802 backend
6804 .execute_js(
6805 r#"
6806 const editor = getEditor();
6807 globalThis._unloadError = null;
6808 editor.unloadPlugin("nonexistent-plugin").catch(err => {
6809 globalThis._unloadError = err.message || String(err);
6810 });
6811 "#,
6812 "test.js",
6813 )
6814 .unwrap();
6815
6816 let callback_id = match rx.try_recv().unwrap() {
6818 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
6819 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
6820 };
6821
6822 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
6824
6825 backend
6827 .plugin_contexts
6828 .borrow()
6829 .get("test")
6830 .unwrap()
6831 .clone()
6832 .with(|ctx| {
6833 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
6834 });
6835
6836 backend
6838 .plugin_contexts
6839 .borrow()
6840 .get("test")
6841 .unwrap()
6842 .clone()
6843 .with(|ctx| {
6844 let global = ctx.globals();
6845 let error: String = global.get("_unloadError").unwrap();
6846 assert!(error.contains("nonexistent-plugin"));
6847 });
6848 }
6849}