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 get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
739 if let Some(mode_name) = mode {
740 let key = format!("{}\0{}", action, mode_name);
741 if let Ok(snapshot) = self.state_snapshot.read() {
742 return snapshot.keybinding_labels.get(&key).cloned();
743 }
744 }
745 None
746 }
747
748 pub fn register_command<'js>(
759 &self,
760 _ctx: rquickjs::Ctx<'js>,
761 name: String,
762 description: String,
763 handler_name: String,
764 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
765 rquickjs::Value<'js>,
766 >,
767 ) -> rquickjs::Result<bool> {
768 let plugin_name = self.plugin_name.clone();
770 let context_str: Option<String> = context.0.and_then(|v| {
772 if v.is_null() || v.is_undefined() {
773 None
774 } else {
775 v.as_string().and_then(|s| s.to_string().ok())
776 }
777 });
778
779 tracing::debug!(
780 "registerCommand: plugin='{}', name='{}', handler='{}'",
781 plugin_name,
782 name,
783 handler_name
784 );
785
786 self.registered_actions.borrow_mut().insert(
788 handler_name.clone(),
789 PluginHandler {
790 plugin_name: self.plugin_name.clone(),
791 handler_name: handler_name.clone(),
792 },
793 );
794
795 let command = Command {
797 name: name.clone(),
798 description,
799 action_name: handler_name,
800 plugin_name,
801 custom_contexts: context_str.into_iter().collect(),
802 };
803
804 Ok(self
805 .command_sender
806 .send(PluginCommand::RegisterCommand { command })
807 .is_ok())
808 }
809
810 pub fn unregister_command(&self, name: String) -> bool {
812 self.command_sender
813 .send(PluginCommand::UnregisterCommand { name })
814 .is_ok()
815 }
816
817 pub fn set_context(&self, name: String, active: bool) -> bool {
819 if active {
821 self.plugin_tracked_state
822 .borrow_mut()
823 .entry(self.plugin_name.clone())
824 .or_default()
825 .contexts_set
826 .push(name.clone());
827 }
828 self.command_sender
829 .send(PluginCommand::SetContext { name, active })
830 .is_ok()
831 }
832
833 pub fn execute_action(&self, action_name: String) -> bool {
835 self.command_sender
836 .send(PluginCommand::ExecuteAction { action_name })
837 .is_ok()
838 }
839
840 pub fn t<'js>(
845 &self,
846 _ctx: rquickjs::Ctx<'js>,
847 key: String,
848 args: rquickjs::function::Rest<Value<'js>>,
849 ) -> String {
850 let plugin_name = self.plugin_name.clone();
852 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
854 if let Some(obj) = first_arg.as_object() {
855 let mut map = HashMap::new();
856 for k in obj.keys::<String>().flatten() {
857 if let Ok(v) = obj.get::<_, String>(&k) {
858 map.insert(k, v);
859 }
860 }
861 map
862 } else {
863 HashMap::new()
864 }
865 } else {
866 HashMap::new()
867 };
868 let res = self.services.translate(&plugin_name, &key, &args_map);
869
870 tracing::info!(
871 "Translating: key={}, plugin={}, args={:?} => res='{}'",
872 key,
873 plugin_name,
874 args_map,
875 res
876 );
877 res
878 }
879
880 pub fn get_cursor_position(&self) -> u32 {
884 self.state_snapshot
885 .read()
886 .ok()
887 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
888 .unwrap_or(0)
889 }
890
891 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
893 if let Ok(s) = self.state_snapshot.read() {
894 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
895 if let Some(p) = &b.path {
896 return p.to_string_lossy().to_string();
897 }
898 }
899 }
900 String::new()
901 }
902
903 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
905 if let Ok(s) = self.state_snapshot.read() {
906 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
907 return b.length as u32;
908 }
909 }
910 0
911 }
912
913 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
915 if let Ok(s) = self.state_snapshot.read() {
916 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
917 return b.modified;
918 }
919 }
920 false
921 }
922
923 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
926 self.command_sender
927 .send(PluginCommand::SaveBufferToPath {
928 buffer_id: BufferId(buffer_id as usize),
929 path: std::path::PathBuf::from(path),
930 })
931 .is_ok()
932 }
933
934 #[plugin_api(ts_return = "BufferInfo | null")]
936 pub fn get_buffer_info<'js>(
937 &self,
938 ctx: rquickjs::Ctx<'js>,
939 buffer_id: u32,
940 ) -> rquickjs::Result<Value<'js>> {
941 let info = if let Ok(s) = self.state_snapshot.read() {
942 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
943 } else {
944 None
945 };
946 rquickjs_serde::to_value(ctx, &info)
947 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
948 }
949
950 #[plugin_api(ts_return = "CursorInfo | null")]
952 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
953 let cursor = if let Ok(s) = self.state_snapshot.read() {
954 s.primary_cursor.clone()
955 } else {
956 None
957 };
958 rquickjs_serde::to_value(ctx, &cursor)
959 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
960 }
961
962 #[plugin_api(ts_return = "CursorInfo[]")]
964 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
965 let cursors = if let Ok(s) = self.state_snapshot.read() {
966 s.all_cursors.clone()
967 } else {
968 Vec::new()
969 };
970 rquickjs_serde::to_value(ctx, &cursors)
971 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
972 }
973
974 #[plugin_api(ts_return = "number[]")]
976 pub fn get_all_cursor_positions<'js>(
977 &self,
978 ctx: rquickjs::Ctx<'js>,
979 ) -> rquickjs::Result<Value<'js>> {
980 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
981 s.all_cursors.iter().map(|c| c.position as u32).collect()
982 } else {
983 Vec::new()
984 };
985 rquickjs_serde::to_value(ctx, &positions)
986 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
987 }
988
989 #[plugin_api(ts_return = "ViewportInfo | null")]
991 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
992 let viewport = if let Ok(s) = self.state_snapshot.read() {
993 s.viewport.clone()
994 } else {
995 None
996 };
997 rquickjs_serde::to_value(ctx, &viewport)
998 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
999 }
1000
1001 pub fn get_cursor_line(&self) -> u32 {
1003 0
1007 }
1008
1009 #[plugin_api(
1012 async_promise,
1013 js_name = "getLineStartPosition",
1014 ts_return = "number | null"
1015 )]
1016 #[qjs(rename = "_getLineStartPositionStart")]
1017 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1018 let id = {
1019 let mut id_ref = self.next_request_id.borrow_mut();
1020 let id = *id_ref;
1021 *id_ref += 1;
1022 self.callback_contexts
1024 .borrow_mut()
1025 .insert(id, self.plugin_name.clone());
1026 id
1027 };
1028 let _ = self
1030 .command_sender
1031 .send(PluginCommand::GetLineStartPosition {
1032 buffer_id: BufferId(0),
1033 line,
1034 request_id: id,
1035 });
1036 id
1037 }
1038
1039 #[plugin_api(
1043 async_promise,
1044 js_name = "getLineEndPosition",
1045 ts_return = "number | null"
1046 )]
1047 #[qjs(rename = "_getLineEndPositionStart")]
1048 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1049 let id = {
1050 let mut id_ref = self.next_request_id.borrow_mut();
1051 let id = *id_ref;
1052 *id_ref += 1;
1053 self.callback_contexts
1054 .borrow_mut()
1055 .insert(id, self.plugin_name.clone());
1056 id
1057 };
1058 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1060 buffer_id: BufferId(0),
1061 line,
1062 request_id: id,
1063 });
1064 id
1065 }
1066
1067 #[plugin_api(
1070 async_promise,
1071 js_name = "getBufferLineCount",
1072 ts_return = "number | null"
1073 )]
1074 #[qjs(rename = "_getBufferLineCountStart")]
1075 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1076 let id = {
1077 let mut id_ref = self.next_request_id.borrow_mut();
1078 let id = *id_ref;
1079 *id_ref += 1;
1080 self.callback_contexts
1081 .borrow_mut()
1082 .insert(id, self.plugin_name.clone());
1083 id
1084 };
1085 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1087 buffer_id: BufferId(0),
1088 request_id: id,
1089 });
1090 id
1091 }
1092
1093 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1096 self.command_sender
1097 .send(PluginCommand::ScrollToLineCenter {
1098 split_id: SplitId(split_id as usize),
1099 buffer_id: BufferId(buffer_id as usize),
1100 line: line as usize,
1101 })
1102 .is_ok()
1103 }
1104
1105 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1107 let path_buf = std::path::PathBuf::from(&path);
1108 if let Ok(s) = self.state_snapshot.read() {
1109 for (id, info) in &s.buffers {
1110 if let Some(buf_path) = &info.path {
1111 if buf_path == &path_buf {
1112 return id.0 as u32;
1113 }
1114 }
1115 }
1116 }
1117 0
1118 }
1119
1120 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1122 pub fn get_buffer_saved_diff<'js>(
1123 &self,
1124 ctx: rquickjs::Ctx<'js>,
1125 buffer_id: u32,
1126 ) -> rquickjs::Result<Value<'js>> {
1127 let diff = if let Ok(s) = self.state_snapshot.read() {
1128 s.buffer_saved_diffs
1129 .get(&BufferId(buffer_id as usize))
1130 .cloned()
1131 } else {
1132 None
1133 };
1134 rquickjs_serde::to_value(ctx, &diff)
1135 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1136 }
1137
1138 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1142 self.command_sender
1143 .send(PluginCommand::InsertText {
1144 buffer_id: BufferId(buffer_id as usize),
1145 position: position as usize,
1146 text,
1147 })
1148 .is_ok()
1149 }
1150
1151 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1153 self.command_sender
1154 .send(PluginCommand::DeleteRange {
1155 buffer_id: BufferId(buffer_id as usize),
1156 range: (start as usize)..(end as usize),
1157 })
1158 .is_ok()
1159 }
1160
1161 pub fn insert_at_cursor(&self, text: String) -> bool {
1163 self.command_sender
1164 .send(PluginCommand::InsertAtCursor { text })
1165 .is_ok()
1166 }
1167
1168 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1172 self.command_sender
1173 .send(PluginCommand::OpenFileAtLocation {
1174 path: PathBuf::from(path),
1175 line: line.map(|l| l as usize),
1176 column: column.map(|c| c as usize),
1177 })
1178 .is_ok()
1179 }
1180
1181 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1183 self.command_sender
1184 .send(PluginCommand::OpenFileInSplit {
1185 split_id: split_id as usize,
1186 path: PathBuf::from(path),
1187 line: Some(line as usize),
1188 column: Some(column as usize),
1189 })
1190 .is_ok()
1191 }
1192
1193 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1195 self.command_sender
1196 .send(PluginCommand::ShowBuffer {
1197 buffer_id: BufferId(buffer_id as usize),
1198 })
1199 .is_ok()
1200 }
1201
1202 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1204 self.command_sender
1205 .send(PluginCommand::CloseBuffer {
1206 buffer_id: BufferId(buffer_id as usize),
1207 })
1208 .is_ok()
1209 }
1210
1211 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1215 if event_name == "lines_changed" {
1219 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1220 }
1221 self.event_handlers
1222 .borrow_mut()
1223 .entry(event_name)
1224 .or_default()
1225 .push(PluginHandler {
1226 plugin_name: self.plugin_name.clone(),
1227 handler_name,
1228 });
1229 }
1230
1231 pub fn off(&self, event_name: String, handler_name: String) {
1233 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1234 list.retain(|h| h.handler_name != handler_name);
1235 }
1236 }
1237
1238 pub fn get_env(&self, name: String) -> Option<String> {
1242 std::env::var(&name).ok()
1243 }
1244
1245 pub fn get_cwd(&self) -> String {
1247 self.state_snapshot
1248 .read()
1249 .map(|s| s.working_dir.to_string_lossy().to_string())
1250 .unwrap_or_else(|_| ".".to_string())
1251 }
1252
1253 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1258 let mut result_parts: Vec<String> = Vec::new();
1259 let mut has_leading_slash = false;
1260
1261 for part in &parts.0 {
1262 let normalized = part.replace('\\', "/");
1264
1265 let is_absolute = normalized.starts_with('/')
1267 || (normalized.len() >= 2
1268 && normalized
1269 .chars()
1270 .next()
1271 .map(|c| c.is_ascii_alphabetic())
1272 .unwrap_or(false)
1273 && normalized.chars().nth(1) == Some(':'));
1274
1275 if is_absolute {
1276 result_parts.clear();
1278 has_leading_slash = normalized.starts_with('/');
1279 }
1280
1281 for segment in normalized.split('/') {
1283 if !segment.is_empty() && segment != "." {
1284 if segment == ".." {
1285 result_parts.pop();
1286 } else {
1287 result_parts.push(segment.to_string());
1288 }
1289 }
1290 }
1291 }
1292
1293 let joined = result_parts.join("/");
1295
1296 if has_leading_slash && !joined.is_empty() {
1298 format!("/{}", joined)
1299 } else {
1300 joined
1301 }
1302 }
1303
1304 pub fn path_dirname(&self, path: String) -> String {
1306 Path::new(&path)
1307 .parent()
1308 .map(|p| p.to_string_lossy().to_string())
1309 .unwrap_or_default()
1310 }
1311
1312 pub fn path_basename(&self, path: String) -> String {
1314 Path::new(&path)
1315 .file_name()
1316 .map(|s| s.to_string_lossy().to_string())
1317 .unwrap_or_default()
1318 }
1319
1320 pub fn path_extname(&self, path: String) -> String {
1322 Path::new(&path)
1323 .extension()
1324 .map(|s| format!(".{}", s.to_string_lossy()))
1325 .unwrap_or_default()
1326 }
1327
1328 pub fn path_is_absolute(&self, path: String) -> bool {
1330 Path::new(&path).is_absolute()
1331 }
1332
1333 pub fn file_uri_to_path(&self, uri: String) -> String {
1337 url::Url::parse(&uri)
1338 .ok()
1339 .and_then(|u| u.to_file_path().ok())
1340 .map(|p| p.to_string_lossy().to_string())
1341 .unwrap_or_default()
1342 }
1343
1344 pub fn path_to_file_uri(&self, path: String) -> String {
1348 url::Url::from_file_path(&path)
1349 .map(|u| u.to_string())
1350 .unwrap_or_default()
1351 }
1352
1353 pub fn utf8_byte_length(&self, text: String) -> u32 {
1361 text.len() as u32
1362 }
1363
1364 pub fn file_exists(&self, path: String) -> bool {
1368 Path::new(&path).exists()
1369 }
1370
1371 pub fn read_file(&self, path: String) -> Option<String> {
1373 std::fs::read_to_string(&path).ok()
1374 }
1375
1376 pub fn write_file(&self, path: String, content: String) -> bool {
1378 let p = Path::new(&path);
1379 if let Some(parent) = p.parent() {
1380 if !parent.exists() {
1381 if std::fs::create_dir_all(parent).is_err() {
1382 return false;
1383 }
1384 }
1385 }
1386 std::fs::write(p, content).is_ok()
1387 }
1388
1389 #[plugin_api(ts_return = "DirEntry[]")]
1391 pub fn read_dir<'js>(
1392 &self,
1393 ctx: rquickjs::Ctx<'js>,
1394 path: String,
1395 ) -> rquickjs::Result<Value<'js>> {
1396 use fresh_core::api::DirEntry;
1397
1398 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1399 Ok(entries) => entries
1400 .filter_map(|e| e.ok())
1401 .map(|entry| {
1402 let file_type = entry.file_type().ok();
1403 DirEntry {
1404 name: entry.file_name().to_string_lossy().to_string(),
1405 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1406 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1407 }
1408 })
1409 .collect(),
1410 Err(e) => {
1411 tracing::warn!("readDir failed for '{}': {}", path, e);
1412 Vec::new()
1413 }
1414 };
1415
1416 rquickjs_serde::to_value(ctx, &entries)
1417 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1418 }
1419
1420 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1424 let config: serde_json::Value = self
1425 .state_snapshot
1426 .read()
1427 .map(|s| s.config.clone())
1428 .unwrap_or_else(|_| serde_json::json!({}));
1429
1430 rquickjs_serde::to_value(ctx, &config)
1431 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1432 }
1433
1434 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1436 let config: serde_json::Value = self
1437 .state_snapshot
1438 .read()
1439 .map(|s| s.user_config.clone())
1440 .unwrap_or_else(|_| serde_json::json!({}));
1441
1442 rquickjs_serde::to_value(ctx, &config)
1443 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1444 }
1445
1446 pub fn reload_config(&self) {
1448 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1449 }
1450
1451 pub fn reload_themes(&self) {
1454 let _ = self
1455 .command_sender
1456 .send(PluginCommand::ReloadThemes { apply_theme: None });
1457 }
1458
1459 pub fn reload_and_apply_theme(&self, theme_name: String) {
1461 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1462 apply_theme: Some(theme_name),
1463 });
1464 }
1465
1466 pub fn register_grammar(
1469 &self,
1470 language: String,
1471 grammar_path: String,
1472 extensions: Vec<String>,
1473 ) -> bool {
1474 self.command_sender
1475 .send(PluginCommand::RegisterGrammar {
1476 language,
1477 grammar_path,
1478 extensions,
1479 })
1480 .is_ok()
1481 }
1482
1483 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1485 self.command_sender
1486 .send(PluginCommand::RegisterLanguageConfig { language, config })
1487 .is_ok()
1488 }
1489
1490 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1492 self.command_sender
1493 .send(PluginCommand::RegisterLspServer { language, config })
1494 .is_ok()
1495 }
1496
1497 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1501 #[qjs(rename = "_reloadGrammarsStart")]
1502 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1503 let id = {
1504 let mut id_ref = self.next_request_id.borrow_mut();
1505 let id = *id_ref;
1506 *id_ref += 1;
1507 self.callback_contexts
1508 .borrow_mut()
1509 .insert(id, self.plugin_name.clone());
1510 id
1511 };
1512 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1513 callback_id: fresh_core::api::JsCallbackId::new(id),
1514 });
1515 id
1516 }
1517
1518 pub fn get_config_dir(&self) -> String {
1520 self.services.config_dir().to_string_lossy().to_string()
1521 }
1522
1523 pub fn get_themes_dir(&self) -> String {
1525 self.services
1526 .config_dir()
1527 .join("themes")
1528 .to_string_lossy()
1529 .to_string()
1530 }
1531
1532 pub fn apply_theme(&self, theme_name: String) -> bool {
1534 self.command_sender
1535 .send(PluginCommand::ApplyTheme { theme_name })
1536 .is_ok()
1537 }
1538
1539 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1541 let schema = self.services.get_theme_schema();
1542 rquickjs_serde::to_value(ctx, &schema)
1543 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1544 }
1545
1546 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1548 let themes = self.services.get_builtin_themes();
1549 rquickjs_serde::to_value(ctx, &themes)
1550 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1551 }
1552
1553 #[qjs(rename = "_deleteThemeSync")]
1555 pub fn delete_theme_sync(&self, name: String) -> bool {
1556 let themes_dir = self.services.config_dir().join("themes");
1558 let theme_path = themes_dir.join(format!("{}.json", name));
1559
1560 if let Ok(canonical) = theme_path.canonicalize() {
1562 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1563 if canonical.starts_with(&themes_canonical) {
1564 return std::fs::remove_file(&canonical).is_ok();
1565 }
1566 }
1567 }
1568 false
1569 }
1570
1571 pub fn delete_theme(&self, name: String) -> bool {
1573 self.delete_theme_sync(name)
1574 }
1575
1576 pub fn get_theme_data<'js>(
1578 &self,
1579 ctx: rquickjs::Ctx<'js>,
1580 name: String,
1581 ) -> rquickjs::Result<Value<'js>> {
1582 match self.services.get_theme_data(&name) {
1583 Some(data) => rquickjs_serde::to_value(ctx, &data)
1584 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1585 None => Ok(Value::new_null(ctx)),
1586 }
1587 }
1588
1589 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1591 self.services
1592 .save_theme_file(&name, &content)
1593 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1594 }
1595
1596 pub fn theme_file_exists(&self, name: String) -> bool {
1598 self.services.theme_file_exists(&name)
1599 }
1600
1601 pub fn file_stat<'js>(
1605 &self,
1606 ctx: rquickjs::Ctx<'js>,
1607 path: String,
1608 ) -> rquickjs::Result<Value<'js>> {
1609 let metadata = std::fs::metadata(&path).ok();
1610 let stat = metadata.map(|m| {
1611 serde_json::json!({
1612 "isFile": m.is_file(),
1613 "isDir": m.is_dir(),
1614 "size": m.len(),
1615 "readonly": m.permissions().readonly(),
1616 })
1617 });
1618 rquickjs_serde::to_value(ctx, &stat)
1619 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1620 }
1621
1622 pub fn is_process_running(&self, _process_id: u64) -> bool {
1626 false
1629 }
1630
1631 pub fn kill_process(&self, process_id: u64) -> bool {
1633 self.command_sender
1634 .send(PluginCommand::KillBackgroundProcess { process_id })
1635 .is_ok()
1636 }
1637
1638 pub fn plugin_translate<'js>(
1642 &self,
1643 _ctx: rquickjs::Ctx<'js>,
1644 plugin_name: String,
1645 key: String,
1646 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1647 ) -> String {
1648 let args_map: HashMap<String, String> = args
1649 .0
1650 .map(|obj| {
1651 let mut map = HashMap::new();
1652 for (k, v) in obj.props::<String, String>().flatten() {
1653 map.insert(k, v);
1654 }
1655 map
1656 })
1657 .unwrap_or_default();
1658
1659 self.services.translate(&plugin_name, &key, &args_map)
1660 }
1661
1662 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1669 #[qjs(rename = "_createCompositeBufferStart")]
1670 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1671 let id = {
1672 let mut id_ref = self.next_request_id.borrow_mut();
1673 let id = *id_ref;
1674 *id_ref += 1;
1675 self.callback_contexts
1677 .borrow_mut()
1678 .insert(id, self.plugin_name.clone());
1679 id
1680 };
1681
1682 if let Ok(mut owners) = self.async_resource_owners.lock() {
1684 owners.insert(id, self.plugin_name.clone());
1685 }
1686 let _ = self
1687 .command_sender
1688 .send(PluginCommand::CreateCompositeBuffer {
1689 name: opts.name,
1690 mode: opts.mode,
1691 layout: opts.layout,
1692 sources: opts.sources,
1693 hunks: opts.hunks,
1694 request_id: Some(id),
1695 });
1696
1697 id
1698 }
1699
1700 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1704 self.command_sender
1705 .send(PluginCommand::UpdateCompositeAlignment {
1706 buffer_id: BufferId(buffer_id as usize),
1707 hunks,
1708 })
1709 .is_ok()
1710 }
1711
1712 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1714 self.command_sender
1715 .send(PluginCommand::CloseCompositeBuffer {
1716 buffer_id: BufferId(buffer_id as usize),
1717 })
1718 .is_ok()
1719 }
1720
1721 #[plugin_api(
1725 async_promise,
1726 js_name = "getHighlights",
1727 ts_return = "TsHighlightSpan[]"
1728 )]
1729 #[qjs(rename = "_getHighlightsStart")]
1730 pub fn get_highlights_start<'js>(
1731 &self,
1732 _ctx: rquickjs::Ctx<'js>,
1733 buffer_id: u32,
1734 start: u32,
1735 end: u32,
1736 ) -> rquickjs::Result<u64> {
1737 let id = {
1738 let mut id_ref = self.next_request_id.borrow_mut();
1739 let id = *id_ref;
1740 *id_ref += 1;
1741 self.callback_contexts
1743 .borrow_mut()
1744 .insert(id, self.plugin_name.clone());
1745 id
1746 };
1747
1748 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1749 buffer_id: BufferId(buffer_id as usize),
1750 range: (start as usize)..(end as usize),
1751 request_id: id,
1752 });
1753
1754 Ok(id)
1755 }
1756
1757 pub fn add_overlay<'js>(
1779 &self,
1780 _ctx: rquickjs::Ctx<'js>,
1781 buffer_id: u32,
1782 namespace: String,
1783 start: u32,
1784 end: u32,
1785 options: rquickjs::Object<'js>,
1786 ) -> rquickjs::Result<bool> {
1787 use fresh_core::api::OverlayColorSpec;
1788
1789 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1791 if let Ok(theme_key) = obj.get::<_, String>(key) {
1793 if !theme_key.is_empty() {
1794 return Some(OverlayColorSpec::ThemeKey(theme_key));
1795 }
1796 }
1797 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1799 if arr.len() >= 3 {
1800 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1801 }
1802 }
1803 None
1804 }
1805
1806 let fg = parse_color_spec("fg", &options);
1807 let bg = parse_color_spec("bg", &options);
1808 let underline: bool = options.get("underline").unwrap_or(false);
1809 let bold: bool = options.get("bold").unwrap_or(false);
1810 let italic: bool = options.get("italic").unwrap_or(false);
1811 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
1812 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1813 let url: Option<String> = options.get("url").ok();
1814
1815 let options = OverlayOptions {
1816 fg,
1817 bg,
1818 underline,
1819 bold,
1820 italic,
1821 strikethrough,
1822 extend_to_line_end,
1823 url,
1824 };
1825
1826 self.plugin_tracked_state
1828 .borrow_mut()
1829 .entry(self.plugin_name.clone())
1830 .or_default()
1831 .overlay_namespaces
1832 .push((BufferId(buffer_id as usize), namespace.clone()));
1833
1834 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1835 buffer_id: BufferId(buffer_id as usize),
1836 namespace: Some(OverlayNamespace::from_string(namespace)),
1837 range: (start as usize)..(end as usize),
1838 options,
1839 });
1840
1841 Ok(true)
1842 }
1843
1844 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1846 self.command_sender
1847 .send(PluginCommand::ClearNamespace {
1848 buffer_id: BufferId(buffer_id as usize),
1849 namespace: OverlayNamespace::from_string(namespace),
1850 })
1851 .is_ok()
1852 }
1853
1854 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1856 self.command_sender
1857 .send(PluginCommand::ClearAllOverlays {
1858 buffer_id: BufferId(buffer_id as usize),
1859 })
1860 .is_ok()
1861 }
1862
1863 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1865 self.command_sender
1866 .send(PluginCommand::ClearOverlaysInRange {
1867 buffer_id: BufferId(buffer_id as usize),
1868 start: start as usize,
1869 end: end as usize,
1870 })
1871 .is_ok()
1872 }
1873
1874 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1876 use fresh_core::overlay::OverlayHandle;
1877 self.command_sender
1878 .send(PluginCommand::RemoveOverlay {
1879 buffer_id: BufferId(buffer_id as usize),
1880 handle: OverlayHandle(handle),
1881 })
1882 .is_ok()
1883 }
1884
1885 pub fn add_conceal(
1889 &self,
1890 buffer_id: u32,
1891 namespace: String,
1892 start: u32,
1893 end: u32,
1894 replacement: Option<String>,
1895 ) -> bool {
1896 self.plugin_tracked_state
1898 .borrow_mut()
1899 .entry(self.plugin_name.clone())
1900 .or_default()
1901 .overlay_namespaces
1902 .push((BufferId(buffer_id as usize), namespace.clone()));
1903
1904 self.command_sender
1905 .send(PluginCommand::AddConceal {
1906 buffer_id: BufferId(buffer_id as usize),
1907 namespace: OverlayNamespace::from_string(namespace),
1908 start: start as usize,
1909 end: end as usize,
1910 replacement,
1911 })
1912 .is_ok()
1913 }
1914
1915 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1917 self.command_sender
1918 .send(PluginCommand::ClearConcealNamespace {
1919 buffer_id: BufferId(buffer_id as usize),
1920 namespace: OverlayNamespace::from_string(namespace),
1921 })
1922 .is_ok()
1923 }
1924
1925 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1927 self.command_sender
1928 .send(PluginCommand::ClearConcealsInRange {
1929 buffer_id: BufferId(buffer_id as usize),
1930 start: start as usize,
1931 end: end as usize,
1932 })
1933 .is_ok()
1934 }
1935
1936 pub fn add_soft_break(
1940 &self,
1941 buffer_id: u32,
1942 namespace: String,
1943 position: u32,
1944 indent: u32,
1945 ) -> bool {
1946 self.plugin_tracked_state
1948 .borrow_mut()
1949 .entry(self.plugin_name.clone())
1950 .or_default()
1951 .overlay_namespaces
1952 .push((BufferId(buffer_id as usize), namespace.clone()));
1953
1954 self.command_sender
1955 .send(PluginCommand::AddSoftBreak {
1956 buffer_id: BufferId(buffer_id as usize),
1957 namespace: OverlayNamespace::from_string(namespace),
1958 position: position as usize,
1959 indent: indent as u16,
1960 })
1961 .is_ok()
1962 }
1963
1964 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1966 self.command_sender
1967 .send(PluginCommand::ClearSoftBreakNamespace {
1968 buffer_id: BufferId(buffer_id as usize),
1969 namespace: OverlayNamespace::from_string(namespace),
1970 })
1971 .is_ok()
1972 }
1973
1974 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1976 self.command_sender
1977 .send(PluginCommand::ClearSoftBreaksInRange {
1978 buffer_id: BufferId(buffer_id as usize),
1979 start: start as usize,
1980 end: end as usize,
1981 })
1982 .is_ok()
1983 }
1984
1985 #[allow(clippy::too_many_arguments)]
1995 pub fn submit_view_transform<'js>(
1996 &self,
1997 _ctx: rquickjs::Ctx<'js>,
1998 buffer_id: u32,
1999 split_id: Option<u32>,
2000 start: u32,
2001 end: u32,
2002 tokens: Vec<rquickjs::Object<'js>>,
2003 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2004 ) -> rquickjs::Result<bool> {
2005 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2006
2007 let tokens: Vec<ViewTokenWire> = tokens
2008 .into_iter()
2009 .enumerate()
2010 .map(|(idx, obj)| {
2011 parse_view_token(&obj, idx)
2013 })
2014 .collect::<rquickjs::Result<Vec<_>>>()?;
2015
2016 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2018 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2019 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2020 Some(LayoutHints {
2021 compose_width,
2022 column_guides,
2023 })
2024 } else {
2025 None
2026 };
2027
2028 let payload = ViewTransformPayload {
2029 range: (start as usize)..(end as usize),
2030 tokens,
2031 layout_hints: parsed_layout_hints,
2032 };
2033
2034 Ok(self
2035 .command_sender
2036 .send(PluginCommand::SubmitViewTransform {
2037 buffer_id: BufferId(buffer_id as usize),
2038 split_id: split_id.map(|id| SplitId(id as usize)),
2039 payload,
2040 })
2041 .is_ok())
2042 }
2043
2044 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2046 self.command_sender
2047 .send(PluginCommand::ClearViewTransform {
2048 buffer_id: BufferId(buffer_id as usize),
2049 split_id: split_id.map(|id| SplitId(id as usize)),
2050 })
2051 .is_ok()
2052 }
2053
2054 pub fn set_layout_hints<'js>(
2057 &self,
2058 buffer_id: u32,
2059 split_id: Option<u32>,
2060 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2061 ) -> rquickjs::Result<bool> {
2062 use fresh_core::api::LayoutHints;
2063
2064 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2065 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2066 let parsed_hints = LayoutHints {
2067 compose_width,
2068 column_guides,
2069 };
2070
2071 Ok(self
2072 .command_sender
2073 .send(PluginCommand::SetLayoutHints {
2074 buffer_id: BufferId(buffer_id as usize),
2075 split_id: split_id.map(|id| SplitId(id as usize)),
2076 range: 0..0,
2077 hints: parsed_hints,
2078 })
2079 .is_ok())
2080 }
2081
2082 pub fn set_file_explorer_decorations<'js>(
2086 &self,
2087 _ctx: rquickjs::Ctx<'js>,
2088 namespace: String,
2089 decorations: Vec<rquickjs::Object<'js>>,
2090 ) -> rquickjs::Result<bool> {
2091 use fresh_core::file_explorer::FileExplorerDecoration;
2092
2093 let decorations: Vec<FileExplorerDecoration> = decorations
2094 .into_iter()
2095 .map(|obj| {
2096 let path: String = obj.get("path")?;
2097 let symbol: String = obj.get("symbol")?;
2098 let color: Vec<u8> = obj.get("color")?;
2099 let priority: i32 = obj.get("priority").unwrap_or(0);
2100
2101 if color.len() < 3 {
2102 return Err(rquickjs::Error::FromJs {
2103 from: "array",
2104 to: "color",
2105 message: Some(format!(
2106 "color array must have at least 3 elements, got {}",
2107 color.len()
2108 )),
2109 });
2110 }
2111
2112 Ok(FileExplorerDecoration {
2113 path: std::path::PathBuf::from(path),
2114 symbol,
2115 color: [color[0], color[1], color[2]],
2116 priority,
2117 })
2118 })
2119 .collect::<rquickjs::Result<Vec<_>>>()?;
2120
2121 self.plugin_tracked_state
2123 .borrow_mut()
2124 .entry(self.plugin_name.clone())
2125 .or_default()
2126 .file_explorer_namespaces
2127 .push(namespace.clone());
2128
2129 Ok(self
2130 .command_sender
2131 .send(PluginCommand::SetFileExplorerDecorations {
2132 namespace,
2133 decorations,
2134 })
2135 .is_ok())
2136 }
2137
2138 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2140 self.command_sender
2141 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2142 .is_ok()
2143 }
2144
2145 #[allow(clippy::too_many_arguments)]
2149 pub fn add_virtual_text(
2150 &self,
2151 buffer_id: u32,
2152 virtual_text_id: String,
2153 position: u32,
2154 text: String,
2155 r: u8,
2156 g: u8,
2157 b: u8,
2158 before: bool,
2159 use_bg: bool,
2160 ) -> bool {
2161 self.plugin_tracked_state
2163 .borrow_mut()
2164 .entry(self.plugin_name.clone())
2165 .or_default()
2166 .virtual_text_ids
2167 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2168
2169 self.command_sender
2170 .send(PluginCommand::AddVirtualText {
2171 buffer_id: BufferId(buffer_id as usize),
2172 virtual_text_id,
2173 position: position as usize,
2174 text,
2175 color: (r, g, b),
2176 use_bg,
2177 before,
2178 })
2179 .is_ok()
2180 }
2181
2182 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2184 self.command_sender
2185 .send(PluginCommand::RemoveVirtualText {
2186 buffer_id: BufferId(buffer_id as usize),
2187 virtual_text_id,
2188 })
2189 .is_ok()
2190 }
2191
2192 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2194 self.command_sender
2195 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2196 buffer_id: BufferId(buffer_id as usize),
2197 prefix,
2198 })
2199 .is_ok()
2200 }
2201
2202 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2204 self.command_sender
2205 .send(PluginCommand::ClearVirtualTexts {
2206 buffer_id: BufferId(buffer_id as usize),
2207 })
2208 .is_ok()
2209 }
2210
2211 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2213 self.command_sender
2214 .send(PluginCommand::ClearVirtualTextNamespace {
2215 buffer_id: BufferId(buffer_id as usize),
2216 namespace,
2217 })
2218 .is_ok()
2219 }
2220
2221 #[allow(clippy::too_many_arguments)]
2223 pub fn add_virtual_line(
2224 &self,
2225 buffer_id: u32,
2226 position: u32,
2227 text: String,
2228 fg_r: u8,
2229 fg_g: u8,
2230 fg_b: u8,
2231 bg_r: u8,
2232 bg_g: u8,
2233 bg_b: u8,
2234 above: bool,
2235 namespace: String,
2236 priority: i32,
2237 ) -> bool {
2238 self.plugin_tracked_state
2240 .borrow_mut()
2241 .entry(self.plugin_name.clone())
2242 .or_default()
2243 .virtual_line_namespaces
2244 .push((BufferId(buffer_id as usize), namespace.clone()));
2245
2246 self.command_sender
2247 .send(PluginCommand::AddVirtualLine {
2248 buffer_id: BufferId(buffer_id as usize),
2249 position: position as usize,
2250 text,
2251 fg_color: (fg_r, fg_g, fg_b),
2252 bg_color: Some((bg_r, bg_g, bg_b)),
2253 above,
2254 namespace,
2255 priority,
2256 })
2257 .is_ok()
2258 }
2259
2260 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2265 #[qjs(rename = "_promptStart")]
2266 pub fn prompt_start(
2267 &self,
2268 _ctx: rquickjs::Ctx<'_>,
2269 label: String,
2270 initial_value: String,
2271 ) -> u64 {
2272 let id = {
2273 let mut id_ref = self.next_request_id.borrow_mut();
2274 let id = *id_ref;
2275 *id_ref += 1;
2276 self.callback_contexts
2278 .borrow_mut()
2279 .insert(id, self.plugin_name.clone());
2280 id
2281 };
2282
2283 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2284 label,
2285 initial_value,
2286 callback_id: JsCallbackId::new(id),
2287 });
2288
2289 id
2290 }
2291
2292 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2294 self.command_sender
2295 .send(PluginCommand::StartPrompt { label, prompt_type })
2296 .is_ok()
2297 }
2298
2299 pub fn start_prompt_with_initial(
2301 &self,
2302 label: String,
2303 prompt_type: String,
2304 initial_value: String,
2305 ) -> bool {
2306 self.command_sender
2307 .send(PluginCommand::StartPromptWithInitial {
2308 label,
2309 prompt_type,
2310 initial_value,
2311 })
2312 .is_ok()
2313 }
2314
2315 pub fn set_prompt_suggestions(
2319 &self,
2320 suggestions: Vec<fresh_core::command::Suggestion>,
2321 ) -> bool {
2322 self.command_sender
2323 .send(PluginCommand::SetPromptSuggestions { suggestions })
2324 .is_ok()
2325 }
2326
2327 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2328 self.command_sender
2329 .send(PluginCommand::SetPromptInputSync { sync })
2330 .is_ok()
2331 }
2332
2333 pub fn define_mode(
2337 &self,
2338 name: String,
2339 bindings_arr: Vec<Vec<String>>,
2340 read_only: rquickjs::function::Opt<bool>,
2341 allow_text_input: rquickjs::function::Opt<bool>,
2342 ) -> bool {
2343 let bindings: Vec<(String, String)> = bindings_arr
2344 .into_iter()
2345 .filter_map(|arr| {
2346 if arr.len() >= 2 {
2347 Some((arr[0].clone(), arr[1].clone()))
2348 } else {
2349 None
2350 }
2351 })
2352 .collect();
2353
2354 {
2357 let mut registered = self.registered_actions.borrow_mut();
2358 for (_, cmd_name) in &bindings {
2359 registered.insert(
2360 cmd_name.clone(),
2361 PluginHandler {
2362 plugin_name: self.plugin_name.clone(),
2363 handler_name: cmd_name.clone(),
2364 },
2365 );
2366 }
2367 }
2368
2369 let allow_text = allow_text_input.0.unwrap_or(false);
2372 if allow_text {
2373 let mut registered = self.registered_actions.borrow_mut();
2374 registered.insert(
2375 "mode_text_input".to_string(),
2376 PluginHandler {
2377 plugin_name: self.plugin_name.clone(),
2378 handler_name: "mode_text_input".to_string(),
2379 },
2380 );
2381 }
2382
2383 self.command_sender
2384 .send(PluginCommand::DefineMode {
2385 name,
2386 bindings,
2387 read_only: read_only.0.unwrap_or(false),
2388 allow_text_input: allow_text,
2389 plugin_name: Some(self.plugin_name.clone()),
2390 })
2391 .is_ok()
2392 }
2393
2394 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2396 self.command_sender
2397 .send(PluginCommand::SetEditorMode { mode })
2398 .is_ok()
2399 }
2400
2401 pub fn get_editor_mode(&self) -> Option<String> {
2403 self.state_snapshot
2404 .read()
2405 .ok()
2406 .and_then(|s| s.editor_mode.clone())
2407 }
2408
2409 pub fn close_split(&self, split_id: u32) -> bool {
2413 self.command_sender
2414 .send(PluginCommand::CloseSplit {
2415 split_id: SplitId(split_id as usize),
2416 })
2417 .is_ok()
2418 }
2419
2420 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2422 self.command_sender
2423 .send(PluginCommand::SetSplitBuffer {
2424 split_id: SplitId(split_id as usize),
2425 buffer_id: BufferId(buffer_id as usize),
2426 })
2427 .is_ok()
2428 }
2429
2430 pub fn focus_split(&self, split_id: u32) -> bool {
2432 self.command_sender
2433 .send(PluginCommand::FocusSplit {
2434 split_id: SplitId(split_id as usize),
2435 })
2436 .is_ok()
2437 }
2438
2439 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2441 self.command_sender
2442 .send(PluginCommand::SetSplitScroll {
2443 split_id: SplitId(split_id as usize),
2444 top_byte: top_byte as usize,
2445 })
2446 .is_ok()
2447 }
2448
2449 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2451 self.command_sender
2452 .send(PluginCommand::SetSplitRatio {
2453 split_id: SplitId(split_id as usize),
2454 ratio,
2455 })
2456 .is_ok()
2457 }
2458
2459 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2461 self.command_sender
2462 .send(PluginCommand::SetSplitLabel {
2463 split_id: SplitId(split_id as usize),
2464 label,
2465 })
2466 .is_ok()
2467 }
2468
2469 pub fn clear_split_label(&self, split_id: u32) -> bool {
2471 self.command_sender
2472 .send(PluginCommand::ClearSplitLabel {
2473 split_id: SplitId(split_id as usize),
2474 })
2475 .is_ok()
2476 }
2477
2478 #[plugin_api(
2480 async_promise,
2481 js_name = "getSplitByLabel",
2482 ts_return = "number | null"
2483 )]
2484 #[qjs(rename = "_getSplitByLabelStart")]
2485 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2486 let id = {
2487 let mut id_ref = self.next_request_id.borrow_mut();
2488 let id = *id_ref;
2489 *id_ref += 1;
2490 self.callback_contexts
2491 .borrow_mut()
2492 .insert(id, self.plugin_name.clone());
2493 id
2494 };
2495 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2496 label,
2497 request_id: id,
2498 });
2499 id
2500 }
2501
2502 pub fn distribute_splits_evenly(&self) -> bool {
2504 self.command_sender
2506 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2507 .is_ok()
2508 }
2509
2510 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2512 self.command_sender
2513 .send(PluginCommand::SetBufferCursor {
2514 buffer_id: BufferId(buffer_id as usize),
2515 position: position as usize,
2516 })
2517 .is_ok()
2518 }
2519
2520 #[allow(clippy::too_many_arguments)]
2524 pub fn set_line_indicator(
2525 &self,
2526 buffer_id: u32,
2527 line: u32,
2528 namespace: String,
2529 symbol: String,
2530 r: u8,
2531 g: u8,
2532 b: u8,
2533 priority: i32,
2534 ) -> bool {
2535 self.plugin_tracked_state
2537 .borrow_mut()
2538 .entry(self.plugin_name.clone())
2539 .or_default()
2540 .line_indicator_namespaces
2541 .push((BufferId(buffer_id as usize), namespace.clone()));
2542
2543 self.command_sender
2544 .send(PluginCommand::SetLineIndicator {
2545 buffer_id: BufferId(buffer_id as usize),
2546 line: line as usize,
2547 namespace,
2548 symbol,
2549 color: (r, g, b),
2550 priority,
2551 })
2552 .is_ok()
2553 }
2554
2555 #[allow(clippy::too_many_arguments)]
2557 pub fn set_line_indicators(
2558 &self,
2559 buffer_id: u32,
2560 lines: Vec<u32>,
2561 namespace: String,
2562 symbol: String,
2563 r: u8,
2564 g: u8,
2565 b: u8,
2566 priority: i32,
2567 ) -> bool {
2568 self.plugin_tracked_state
2570 .borrow_mut()
2571 .entry(self.plugin_name.clone())
2572 .or_default()
2573 .line_indicator_namespaces
2574 .push((BufferId(buffer_id as usize), namespace.clone()));
2575
2576 self.command_sender
2577 .send(PluginCommand::SetLineIndicators {
2578 buffer_id: BufferId(buffer_id as usize),
2579 lines: lines.into_iter().map(|l| l as usize).collect(),
2580 namespace,
2581 symbol,
2582 color: (r, g, b),
2583 priority,
2584 })
2585 .is_ok()
2586 }
2587
2588 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2590 self.command_sender
2591 .send(PluginCommand::ClearLineIndicators {
2592 buffer_id: BufferId(buffer_id as usize),
2593 namespace,
2594 })
2595 .is_ok()
2596 }
2597
2598 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2600 self.command_sender
2601 .send(PluginCommand::SetLineNumbers {
2602 buffer_id: BufferId(buffer_id as usize),
2603 enabled,
2604 })
2605 .is_ok()
2606 }
2607
2608 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2610 self.command_sender
2611 .send(PluginCommand::SetViewMode {
2612 buffer_id: BufferId(buffer_id as usize),
2613 mode,
2614 })
2615 .is_ok()
2616 }
2617
2618 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2620 self.command_sender
2621 .send(PluginCommand::SetLineWrap {
2622 buffer_id: BufferId(buffer_id as usize),
2623 split_id: split_id.map(|s| SplitId(s as usize)),
2624 enabled,
2625 })
2626 .is_ok()
2627 }
2628
2629 pub fn set_view_state<'js>(
2633 &self,
2634 ctx: rquickjs::Ctx<'js>,
2635 buffer_id: u32,
2636 key: String,
2637 value: Value<'js>,
2638 ) -> bool {
2639 let bid = BufferId(buffer_id as usize);
2640
2641 let json_value = if value.is_undefined() || value.is_null() {
2643 None
2644 } else {
2645 Some(js_to_json(&ctx, value))
2646 };
2647
2648 if let Ok(mut snapshot) = self.state_snapshot.write() {
2650 if let Some(ref json_val) = json_value {
2651 snapshot
2652 .plugin_view_states
2653 .entry(bid)
2654 .or_default()
2655 .insert(key.clone(), json_val.clone());
2656 } else {
2657 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2659 map.remove(&key);
2660 if map.is_empty() {
2661 snapshot.plugin_view_states.remove(&bid);
2662 }
2663 }
2664 }
2665 }
2666
2667 self.command_sender
2669 .send(PluginCommand::SetViewState {
2670 buffer_id: bid,
2671 key,
2672 value: json_value,
2673 })
2674 .is_ok()
2675 }
2676
2677 pub fn get_view_state<'js>(
2679 &self,
2680 ctx: rquickjs::Ctx<'js>,
2681 buffer_id: u32,
2682 key: String,
2683 ) -> rquickjs::Result<Value<'js>> {
2684 let bid = BufferId(buffer_id as usize);
2685 if let Ok(snapshot) = self.state_snapshot.read() {
2686 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2687 if let Some(json_val) = map.get(&key) {
2688 return json_to_js_value(&ctx, json_val);
2689 }
2690 }
2691 }
2692 Ok(Value::new_undefined(ctx.clone()))
2693 }
2694
2695 pub fn set_global_state<'js>(
2701 &self,
2702 ctx: rquickjs::Ctx<'js>,
2703 key: String,
2704 value: Value<'js>,
2705 ) -> bool {
2706 let json_value = if value.is_undefined() || value.is_null() {
2708 None
2709 } else {
2710 Some(js_to_json(&ctx, value))
2711 };
2712
2713 if let Ok(mut snapshot) = self.state_snapshot.write() {
2715 if let Some(ref json_val) = json_value {
2716 snapshot
2717 .plugin_global_states
2718 .entry(self.plugin_name.clone())
2719 .or_default()
2720 .insert(key.clone(), json_val.clone());
2721 } else {
2722 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
2724 map.remove(&key);
2725 if map.is_empty() {
2726 snapshot.plugin_global_states.remove(&self.plugin_name);
2727 }
2728 }
2729 }
2730 }
2731
2732 self.command_sender
2734 .send(PluginCommand::SetGlobalState {
2735 plugin_name: self.plugin_name.clone(),
2736 key,
2737 value: json_value,
2738 })
2739 .is_ok()
2740 }
2741
2742 pub fn get_global_state<'js>(
2746 &self,
2747 ctx: rquickjs::Ctx<'js>,
2748 key: String,
2749 ) -> rquickjs::Result<Value<'js>> {
2750 if let Ok(snapshot) = self.state_snapshot.read() {
2751 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
2752 if let Some(json_val) = map.get(&key) {
2753 return json_to_js_value(&ctx, json_val);
2754 }
2755 }
2756 }
2757 Ok(Value::new_undefined(ctx.clone()))
2758 }
2759
2760 pub fn create_scroll_sync_group(
2764 &self,
2765 group_id: u32,
2766 left_split: u32,
2767 right_split: u32,
2768 ) -> bool {
2769 self.plugin_tracked_state
2771 .borrow_mut()
2772 .entry(self.plugin_name.clone())
2773 .or_default()
2774 .scroll_sync_group_ids
2775 .push(group_id);
2776 self.command_sender
2777 .send(PluginCommand::CreateScrollSyncGroup {
2778 group_id,
2779 left_split: SplitId(left_split as usize),
2780 right_split: SplitId(right_split as usize),
2781 })
2782 .is_ok()
2783 }
2784
2785 pub fn set_scroll_sync_anchors<'js>(
2787 &self,
2788 _ctx: rquickjs::Ctx<'js>,
2789 group_id: u32,
2790 anchors: Vec<Vec<u32>>,
2791 ) -> bool {
2792 let anchors: Vec<(usize, usize)> = anchors
2793 .into_iter()
2794 .filter_map(|pair| {
2795 if pair.len() >= 2 {
2796 Some((pair[0] as usize, pair[1] as usize))
2797 } else {
2798 None
2799 }
2800 })
2801 .collect();
2802 self.command_sender
2803 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2804 .is_ok()
2805 }
2806
2807 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2809 self.command_sender
2810 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2811 .is_ok()
2812 }
2813
2814 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2820 self.command_sender
2821 .send(PluginCommand::ExecuteActions { actions })
2822 .is_ok()
2823 }
2824
2825 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2829 self.command_sender
2830 .send(PluginCommand::ShowActionPopup {
2831 popup_id: opts.id,
2832 title: opts.title,
2833 message: opts.message,
2834 actions: opts.actions,
2835 })
2836 .is_ok()
2837 }
2838
2839 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2841 self.command_sender
2842 .send(PluginCommand::DisableLspForLanguage { language })
2843 .is_ok()
2844 }
2845
2846 pub fn restart_lsp_for_language(&self, language: String) -> bool {
2848 self.command_sender
2849 .send(PluginCommand::RestartLspForLanguage { language })
2850 .is_ok()
2851 }
2852
2853 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2856 self.command_sender
2857 .send(PluginCommand::SetLspRootUri { language, uri })
2858 .is_ok()
2859 }
2860
2861 #[plugin_api(ts_return = "JsDiagnostic[]")]
2863 pub fn get_all_diagnostics<'js>(
2864 &self,
2865 ctx: rquickjs::Ctx<'js>,
2866 ) -> rquickjs::Result<Value<'js>> {
2867 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2868
2869 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2870 let mut result: Vec<JsDiagnostic> = Vec::new();
2872 for (uri, diags) in &s.diagnostics {
2873 for diag in diags {
2874 result.push(JsDiagnostic {
2875 uri: uri.clone(),
2876 message: diag.message.clone(),
2877 severity: diag.severity.map(|s| match s {
2878 lsp_types::DiagnosticSeverity::ERROR => 1,
2879 lsp_types::DiagnosticSeverity::WARNING => 2,
2880 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2881 lsp_types::DiagnosticSeverity::HINT => 4,
2882 _ => 0,
2883 }),
2884 range: JsRange {
2885 start: JsPosition {
2886 line: diag.range.start.line,
2887 character: diag.range.start.character,
2888 },
2889 end: JsPosition {
2890 line: diag.range.end.line,
2891 character: diag.range.end.character,
2892 },
2893 },
2894 source: diag.source.clone(),
2895 });
2896 }
2897 }
2898 result
2899 } else {
2900 Vec::new()
2901 };
2902 rquickjs_serde::to_value(ctx, &diagnostics)
2903 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2904 }
2905
2906 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2908 self.event_handlers
2909 .borrow()
2910 .get(&event_name)
2911 .cloned()
2912 .unwrap_or_default()
2913 .into_iter()
2914 .map(|h| h.handler_name)
2915 .collect()
2916 }
2917
2918 #[plugin_api(
2922 async_promise,
2923 js_name = "createVirtualBuffer",
2924 ts_return = "VirtualBufferResult"
2925 )]
2926 #[qjs(rename = "_createVirtualBufferStart")]
2927 pub fn create_virtual_buffer_start(
2928 &self,
2929 _ctx: rquickjs::Ctx<'_>,
2930 opts: fresh_core::api::CreateVirtualBufferOptions,
2931 ) -> rquickjs::Result<u64> {
2932 let id = {
2933 let mut id_ref = self.next_request_id.borrow_mut();
2934 let id = *id_ref;
2935 *id_ref += 1;
2936 self.callback_contexts
2938 .borrow_mut()
2939 .insert(id, self.plugin_name.clone());
2940 id
2941 };
2942
2943 let entries: Vec<TextPropertyEntry> = opts
2945 .entries
2946 .unwrap_or_default()
2947 .into_iter()
2948 .map(|e| TextPropertyEntry {
2949 text: e.text,
2950 properties: e.properties.unwrap_or_default(),
2951 style: e.style,
2952 inline_overlays: e.inline_overlays.unwrap_or_default(),
2953 })
2954 .collect();
2955
2956 tracing::debug!(
2957 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2958 id
2959 );
2960 if let Ok(mut owners) = self.async_resource_owners.lock() {
2962 owners.insert(id, self.plugin_name.clone());
2963 }
2964 let _ = self
2965 .command_sender
2966 .send(PluginCommand::CreateVirtualBufferWithContent {
2967 name: opts.name,
2968 mode: opts.mode.unwrap_or_default(),
2969 read_only: opts.read_only.unwrap_or(false),
2970 entries,
2971 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2972 show_cursors: opts.show_cursors.unwrap_or(true),
2973 editing_disabled: opts.editing_disabled.unwrap_or(false),
2974 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2975 request_id: Some(id),
2976 });
2977 Ok(id)
2978 }
2979
2980 #[plugin_api(
2982 async_promise,
2983 js_name = "createVirtualBufferInSplit",
2984 ts_return = "VirtualBufferResult"
2985 )]
2986 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2987 pub fn create_virtual_buffer_in_split_start(
2988 &self,
2989 _ctx: rquickjs::Ctx<'_>,
2990 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2991 ) -> rquickjs::Result<u64> {
2992 let id = {
2993 let mut id_ref = self.next_request_id.borrow_mut();
2994 let id = *id_ref;
2995 *id_ref += 1;
2996 self.callback_contexts
2998 .borrow_mut()
2999 .insert(id, self.plugin_name.clone());
3000 id
3001 };
3002
3003 let entries: Vec<TextPropertyEntry> = opts
3005 .entries
3006 .unwrap_or_default()
3007 .into_iter()
3008 .map(|e| TextPropertyEntry {
3009 text: e.text,
3010 properties: e.properties.unwrap_or_default(),
3011 style: e.style,
3012 inline_overlays: e.inline_overlays.unwrap_or_default(),
3013 })
3014 .collect();
3015
3016 if let Ok(mut owners) = self.async_resource_owners.lock() {
3018 owners.insert(id, self.plugin_name.clone());
3019 }
3020 let _ = self
3021 .command_sender
3022 .send(PluginCommand::CreateVirtualBufferInSplit {
3023 name: opts.name,
3024 mode: opts.mode.unwrap_or_default(),
3025 read_only: opts.read_only.unwrap_or(false),
3026 entries,
3027 ratio: opts.ratio.unwrap_or(0.5),
3028 direction: opts.direction,
3029 panel_id: opts.panel_id,
3030 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3031 show_cursors: opts.show_cursors.unwrap_or(true),
3032 editing_disabled: opts.editing_disabled.unwrap_or(false),
3033 line_wrap: opts.line_wrap,
3034 before: opts.before.unwrap_or(false),
3035 request_id: Some(id),
3036 });
3037 Ok(id)
3038 }
3039
3040 #[plugin_api(
3042 async_promise,
3043 js_name = "createVirtualBufferInExistingSplit",
3044 ts_return = "VirtualBufferResult"
3045 )]
3046 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3047 pub fn create_virtual_buffer_in_existing_split_start(
3048 &self,
3049 _ctx: rquickjs::Ctx<'_>,
3050 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3051 ) -> rquickjs::Result<u64> {
3052 let id = {
3053 let mut id_ref = self.next_request_id.borrow_mut();
3054 let id = *id_ref;
3055 *id_ref += 1;
3056 self.callback_contexts
3058 .borrow_mut()
3059 .insert(id, self.plugin_name.clone());
3060 id
3061 };
3062
3063 let entries: Vec<TextPropertyEntry> = opts
3065 .entries
3066 .unwrap_or_default()
3067 .into_iter()
3068 .map(|e| TextPropertyEntry {
3069 text: e.text,
3070 properties: e.properties.unwrap_or_default(),
3071 style: e.style,
3072 inline_overlays: e.inline_overlays.unwrap_or_default(),
3073 })
3074 .collect();
3075
3076 if let Ok(mut owners) = self.async_resource_owners.lock() {
3078 owners.insert(id, self.plugin_name.clone());
3079 }
3080 let _ = self
3081 .command_sender
3082 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3083 name: opts.name,
3084 mode: opts.mode.unwrap_or_default(),
3085 read_only: opts.read_only.unwrap_or(false),
3086 entries,
3087 split_id: SplitId(opts.split_id),
3088 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3089 show_cursors: opts.show_cursors.unwrap_or(true),
3090 editing_disabled: opts.editing_disabled.unwrap_or(false),
3091 line_wrap: opts.line_wrap,
3092 request_id: Some(id),
3093 });
3094 Ok(id)
3095 }
3096
3097 pub fn set_virtual_buffer_content<'js>(
3101 &self,
3102 ctx: rquickjs::Ctx<'js>,
3103 buffer_id: u32,
3104 entries_arr: Vec<rquickjs::Object<'js>>,
3105 ) -> rquickjs::Result<bool> {
3106 let entries: Vec<TextPropertyEntry> = entries_arr
3107 .iter()
3108 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3109 .collect();
3110 Ok(self
3111 .command_sender
3112 .send(PluginCommand::SetVirtualBufferContent {
3113 buffer_id: BufferId(buffer_id as usize),
3114 entries,
3115 })
3116 .is_ok())
3117 }
3118
3119 pub fn get_text_properties_at_cursor(
3121 &self,
3122 buffer_id: u32,
3123 ) -> fresh_core::api::TextPropertiesAtCursor {
3124 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3125 }
3126
3127 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3131 #[qjs(rename = "_spawnProcessStart")]
3132 pub fn spawn_process_start(
3133 &self,
3134 _ctx: rquickjs::Ctx<'_>,
3135 command: String,
3136 args: Vec<String>,
3137 cwd: rquickjs::function::Opt<String>,
3138 ) -> u64 {
3139 let id = {
3140 let mut id_ref = self.next_request_id.borrow_mut();
3141 let id = *id_ref;
3142 *id_ref += 1;
3143 self.callback_contexts
3145 .borrow_mut()
3146 .insert(id, self.plugin_name.clone());
3147 id
3148 };
3149 let effective_cwd = cwd.0.or_else(|| {
3151 self.state_snapshot
3152 .read()
3153 .ok()
3154 .map(|s| s.working_dir.to_string_lossy().to_string())
3155 });
3156 tracing::info!(
3157 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3158 self.plugin_name,
3159 command,
3160 args,
3161 effective_cwd,
3162 id
3163 );
3164 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3165 callback_id: JsCallbackId::new(id),
3166 command,
3167 args,
3168 cwd: effective_cwd,
3169 });
3170 id
3171 }
3172
3173 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3175 #[qjs(rename = "_spawnProcessWaitStart")]
3176 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3177 let id = {
3178 let mut id_ref = self.next_request_id.borrow_mut();
3179 let id = *id_ref;
3180 *id_ref += 1;
3181 self.callback_contexts
3183 .borrow_mut()
3184 .insert(id, self.plugin_name.clone());
3185 id
3186 };
3187 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3188 process_id,
3189 callback_id: JsCallbackId::new(id),
3190 });
3191 id
3192 }
3193
3194 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3196 #[qjs(rename = "_getBufferTextStart")]
3197 pub fn get_buffer_text_start(
3198 &self,
3199 _ctx: rquickjs::Ctx<'_>,
3200 buffer_id: u32,
3201 start: u32,
3202 end: u32,
3203 ) -> u64 {
3204 let id = {
3205 let mut id_ref = self.next_request_id.borrow_mut();
3206 let id = *id_ref;
3207 *id_ref += 1;
3208 self.callback_contexts
3210 .borrow_mut()
3211 .insert(id, self.plugin_name.clone());
3212 id
3213 };
3214 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3215 buffer_id: BufferId(buffer_id as usize),
3216 start: start as usize,
3217 end: end as usize,
3218 request_id: id,
3219 });
3220 id
3221 }
3222
3223 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3225 #[qjs(rename = "_delayStart")]
3226 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3227 let id = {
3228 let mut id_ref = self.next_request_id.borrow_mut();
3229 let id = *id_ref;
3230 *id_ref += 1;
3231 self.callback_contexts
3233 .borrow_mut()
3234 .insert(id, self.plugin_name.clone());
3235 id
3236 };
3237 let _ = self.command_sender.send(PluginCommand::Delay {
3238 callback_id: JsCallbackId::new(id),
3239 duration_ms,
3240 });
3241 id
3242 }
3243
3244 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3248 #[qjs(rename = "_grepProjectStart")]
3249 pub fn grep_project_start(
3250 &self,
3251 _ctx: rquickjs::Ctx<'_>,
3252 pattern: String,
3253 fixed_string: Option<bool>,
3254 case_sensitive: Option<bool>,
3255 max_results: Option<u32>,
3256 whole_words: Option<bool>,
3257 ) -> u64 {
3258 let id = {
3259 let mut id_ref = self.next_request_id.borrow_mut();
3260 let id = *id_ref;
3261 *id_ref += 1;
3262 self.callback_contexts
3263 .borrow_mut()
3264 .insert(id, self.plugin_name.clone());
3265 id
3266 };
3267 let _ = self.command_sender.send(PluginCommand::GrepProject {
3268 pattern,
3269 fixed_string: fixed_string.unwrap_or(true),
3270 case_sensitive: case_sensitive.unwrap_or(true),
3271 max_results: max_results.unwrap_or(200) as usize,
3272 whole_words: whole_words.unwrap_or(false),
3273 callback_id: JsCallbackId::new(id),
3274 });
3275 id
3276 }
3277
3278 #[plugin_api(
3282 js_name = "grepProjectStreaming",
3283 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3284 )]
3285 #[qjs(rename = "_grepProjectStreamingStart")]
3286 pub fn grep_project_streaming_start(
3287 &self,
3288 _ctx: rquickjs::Ctx<'_>,
3289 pattern: String,
3290 fixed_string: bool,
3291 case_sensitive: bool,
3292 max_results: u32,
3293 whole_words: bool,
3294 ) -> u64 {
3295 let id = {
3296 let mut id_ref = self.next_request_id.borrow_mut();
3297 let id = *id_ref;
3298 *id_ref += 1;
3299 self.callback_contexts
3300 .borrow_mut()
3301 .insert(id, self.plugin_name.clone());
3302 id
3303 };
3304 let _ = self
3305 .command_sender
3306 .send(PluginCommand::GrepProjectStreaming {
3307 pattern,
3308 fixed_string,
3309 case_sensitive,
3310 max_results: max_results as usize,
3311 whole_words,
3312 search_id: id,
3313 callback_id: JsCallbackId::new(id),
3314 });
3315 id
3316 }
3317
3318 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3322 #[qjs(rename = "_replaceInFileStart")]
3323 pub fn replace_in_file_start(
3324 &self,
3325 _ctx: rquickjs::Ctx<'_>,
3326 file_path: String,
3327 matches: Vec<Vec<u32>>,
3328 replacement: String,
3329 ) -> u64 {
3330 let id = {
3331 let mut id_ref = self.next_request_id.borrow_mut();
3332 let id = *id_ref;
3333 *id_ref += 1;
3334 self.callback_contexts
3335 .borrow_mut()
3336 .insert(id, self.plugin_name.clone());
3337 id
3338 };
3339 let match_pairs: Vec<(usize, usize)> = matches
3341 .iter()
3342 .map(|m| (m[0] as usize, m[1] as usize))
3343 .collect();
3344 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3345 file_path: PathBuf::from(file_path),
3346 matches: match_pairs,
3347 replacement,
3348 callback_id: JsCallbackId::new(id),
3349 });
3350 id
3351 }
3352
3353 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3355 #[qjs(rename = "_sendLspRequestStart")]
3356 pub fn send_lsp_request_start<'js>(
3357 &self,
3358 ctx: rquickjs::Ctx<'js>,
3359 language: String,
3360 method: String,
3361 params: Option<rquickjs::Object<'js>>,
3362 ) -> rquickjs::Result<u64> {
3363 let id = {
3364 let mut id_ref = self.next_request_id.borrow_mut();
3365 let id = *id_ref;
3366 *id_ref += 1;
3367 self.callback_contexts
3369 .borrow_mut()
3370 .insert(id, self.plugin_name.clone());
3371 id
3372 };
3373 let params_json: Option<serde_json::Value> = params.map(|obj| {
3375 let val = obj.into_value();
3376 js_to_json(&ctx, val)
3377 });
3378 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3379 request_id: id,
3380 language,
3381 method,
3382 params: params_json,
3383 });
3384 Ok(id)
3385 }
3386
3387 #[plugin_api(
3389 async_thenable,
3390 js_name = "spawnBackgroundProcess",
3391 ts_return = "BackgroundProcessResult"
3392 )]
3393 #[qjs(rename = "_spawnBackgroundProcessStart")]
3394 pub fn spawn_background_process_start(
3395 &self,
3396 _ctx: rquickjs::Ctx<'_>,
3397 command: String,
3398 args: Vec<String>,
3399 cwd: rquickjs::function::Opt<String>,
3400 ) -> u64 {
3401 let id = {
3402 let mut id_ref = self.next_request_id.borrow_mut();
3403 let id = *id_ref;
3404 *id_ref += 1;
3405 self.callback_contexts
3407 .borrow_mut()
3408 .insert(id, self.plugin_name.clone());
3409 id
3410 };
3411 let process_id = id;
3413 self.plugin_tracked_state
3415 .borrow_mut()
3416 .entry(self.plugin_name.clone())
3417 .or_default()
3418 .background_process_ids
3419 .push(process_id);
3420 let _ = self
3421 .command_sender
3422 .send(PluginCommand::SpawnBackgroundProcess {
3423 process_id,
3424 command,
3425 args,
3426 cwd: cwd.0,
3427 callback_id: JsCallbackId::new(id),
3428 });
3429 id
3430 }
3431
3432 pub fn kill_background_process(&self, process_id: u64) -> bool {
3434 self.command_sender
3435 .send(PluginCommand::KillBackgroundProcess { process_id })
3436 .is_ok()
3437 }
3438
3439 #[plugin_api(
3443 async_promise,
3444 js_name = "createTerminal",
3445 ts_return = "TerminalResult"
3446 )]
3447 #[qjs(rename = "_createTerminalStart")]
3448 pub fn create_terminal_start(
3449 &self,
3450 _ctx: rquickjs::Ctx<'_>,
3451 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3452 ) -> rquickjs::Result<u64> {
3453 let id = {
3454 let mut id_ref = self.next_request_id.borrow_mut();
3455 let id = *id_ref;
3456 *id_ref += 1;
3457 self.callback_contexts
3458 .borrow_mut()
3459 .insert(id, self.plugin_name.clone());
3460 id
3461 };
3462
3463 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3464 cwd: None,
3465 direction: None,
3466 ratio: None,
3467 focus: None,
3468 });
3469
3470 if let Ok(mut owners) = self.async_resource_owners.lock() {
3472 owners.insert(id, self.plugin_name.clone());
3473 }
3474 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3475 cwd: opts.cwd,
3476 direction: opts.direction,
3477 ratio: opts.ratio,
3478 focus: opts.focus,
3479 request_id: id,
3480 });
3481 Ok(id)
3482 }
3483
3484 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3486 self.command_sender
3487 .send(PluginCommand::SendTerminalInput {
3488 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3489 data,
3490 })
3491 .is_ok()
3492 }
3493
3494 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3496 self.command_sender
3497 .send(PluginCommand::CloseTerminal {
3498 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3499 })
3500 .is_ok()
3501 }
3502
3503 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
3507 self.command_sender
3508 .send(PluginCommand::RefreshLines {
3509 buffer_id: BufferId(buffer_id as usize),
3510 })
3511 .is_ok()
3512 }
3513
3514 pub fn get_current_locale(&self) -> String {
3516 self.services.current_locale()
3517 }
3518
3519 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
3523 #[qjs(rename = "_loadPluginStart")]
3524 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
3525 let id = {
3526 let mut id_ref = self.next_request_id.borrow_mut();
3527 let id = *id_ref;
3528 *id_ref += 1;
3529 self.callback_contexts
3530 .borrow_mut()
3531 .insert(id, self.plugin_name.clone());
3532 id
3533 };
3534 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
3535 path: std::path::PathBuf::from(path),
3536 callback_id: JsCallbackId::new(id),
3537 });
3538 id
3539 }
3540
3541 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
3543 #[qjs(rename = "_unloadPluginStart")]
3544 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3545 let id = {
3546 let mut id_ref = self.next_request_id.borrow_mut();
3547 let id = *id_ref;
3548 *id_ref += 1;
3549 self.callback_contexts
3550 .borrow_mut()
3551 .insert(id, self.plugin_name.clone());
3552 id
3553 };
3554 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
3555 name,
3556 callback_id: JsCallbackId::new(id),
3557 });
3558 id
3559 }
3560
3561 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3563 #[qjs(rename = "_reloadPluginStart")]
3564 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3565 let id = {
3566 let mut id_ref = self.next_request_id.borrow_mut();
3567 let id = *id_ref;
3568 *id_ref += 1;
3569 self.callback_contexts
3570 .borrow_mut()
3571 .insert(id, self.plugin_name.clone());
3572 id
3573 };
3574 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3575 name,
3576 callback_id: JsCallbackId::new(id),
3577 });
3578 id
3579 }
3580
3581 #[plugin_api(
3584 async_promise,
3585 js_name = "listPlugins",
3586 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3587 )]
3588 #[qjs(rename = "_listPluginsStart")]
3589 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3590 let id = {
3591 let mut id_ref = self.next_request_id.borrow_mut();
3592 let id = *id_ref;
3593 *id_ref += 1;
3594 self.callback_contexts
3595 .borrow_mut()
3596 .insert(id, self.plugin_name.clone());
3597 id
3598 };
3599 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3600 callback_id: JsCallbackId::new(id),
3601 });
3602 id
3603 }
3604}
3605
3606fn parse_view_token(
3613 obj: &rquickjs::Object<'_>,
3614 idx: usize,
3615) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3616 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3617
3618 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3620 from: "object",
3621 to: "ViewTokenWire",
3622 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3623 })?;
3624
3625 let source_offset: Option<usize> = obj
3627 .get("sourceOffset")
3628 .ok()
3629 .or_else(|| obj.get("source_offset").ok());
3630
3631 let kind = if kind_value.is_string() {
3633 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3636 from: "value",
3637 to: "string",
3638 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3639 })?;
3640
3641 match kind_str.to_lowercase().as_str() {
3642 "text" => {
3643 let text: String = obj.get("text").unwrap_or_default();
3644 ViewTokenWireKind::Text(text)
3645 }
3646 "newline" => ViewTokenWireKind::Newline,
3647 "space" => ViewTokenWireKind::Space,
3648 "break" => ViewTokenWireKind::Break,
3649 _ => {
3650 tracing::warn!(
3652 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3653 idx, kind_str
3654 );
3655 return Err(rquickjs::Error::FromJs {
3656 from: "string",
3657 to: "ViewTokenWireKind",
3658 message: Some(format!(
3659 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3660 idx, kind_str
3661 )),
3662 });
3663 }
3664 }
3665 } else if kind_value.is_object() {
3666 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3668 from: "value",
3669 to: "object",
3670 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3671 })?;
3672
3673 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3674 ViewTokenWireKind::Text(text)
3675 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3676 ViewTokenWireKind::BinaryByte(byte)
3677 } else {
3678 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3680 tracing::warn!(
3681 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3682 idx,
3683 keys
3684 );
3685 return Err(rquickjs::Error::FromJs {
3686 from: "object",
3687 to: "ViewTokenWireKind",
3688 message: Some(format!(
3689 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3690 idx, keys
3691 )),
3692 });
3693 }
3694 } else {
3695 tracing::warn!(
3696 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3697 idx,
3698 kind_value.type_of()
3699 );
3700 return Err(rquickjs::Error::FromJs {
3701 from: "value",
3702 to: "ViewTokenWireKind",
3703 message: Some(format!(
3704 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3705 idx
3706 )),
3707 });
3708 };
3709
3710 let style = parse_view_token_style(obj, idx)?;
3712
3713 Ok(ViewTokenWire {
3714 source_offset,
3715 kind,
3716 style,
3717 })
3718}
3719
3720fn parse_view_token_style(
3722 obj: &rquickjs::Object<'_>,
3723 idx: usize,
3724) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
3725 use fresh_core::api::ViewTokenStyle;
3726
3727 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
3728 let Some(s) = style_obj else {
3729 return Ok(None);
3730 };
3731
3732 let fg: Option<Vec<u8>> = s.get("fg").ok();
3733 let bg: Option<Vec<u8>> = s.get("bg").ok();
3734
3735 let fg_color = if let Some(ref c) = fg {
3737 if c.len() < 3 {
3738 tracing::warn!(
3739 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
3740 idx,
3741 c.len()
3742 );
3743 None
3744 } else {
3745 Some((c[0], c[1], c[2]))
3746 }
3747 } else {
3748 None
3749 };
3750
3751 let bg_color = if let Some(ref c) = bg {
3752 if c.len() < 3 {
3753 tracing::warn!(
3754 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
3755 idx,
3756 c.len()
3757 );
3758 None
3759 } else {
3760 Some((c[0], c[1], c[2]))
3761 }
3762 } else {
3763 None
3764 };
3765
3766 Ok(Some(ViewTokenStyle {
3767 fg: fg_color,
3768 bg: bg_color,
3769 bold: s.get("bold").unwrap_or(false),
3770 italic: s.get("italic").unwrap_or(false),
3771 }))
3772}
3773
3774pub struct QuickJsBackend {
3776 runtime: Runtime,
3777 main_context: Context,
3779 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
3781 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
3783 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
3785 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3787 command_sender: mpsc::Sender<PluginCommand>,
3789 #[allow(dead_code)]
3791 pending_responses: PendingResponses,
3792 next_request_id: Rc<RefCell<u64>>,
3794 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
3796 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3798 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
3800 async_resource_owners: AsyncResourceOwners,
3803}
3804
3805impl QuickJsBackend {
3806 pub fn new() -> Result<Self> {
3808 let (tx, _rx) = mpsc::channel();
3809 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3810 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3811 Self::with_state(state_snapshot, tx, services)
3812 }
3813
3814 pub fn with_state(
3816 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3817 command_sender: mpsc::Sender<PluginCommand>,
3818 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3819 ) -> Result<Self> {
3820 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
3821 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
3822 }
3823
3824 pub fn with_state_and_responses(
3826 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3827 command_sender: mpsc::Sender<PluginCommand>,
3828 pending_responses: PendingResponses,
3829 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3830 ) -> Result<Self> {
3831 let async_resource_owners: AsyncResourceOwners =
3832 Arc::new(std::sync::Mutex::new(HashMap::new()));
3833 Self::with_state_responses_and_resources(
3834 state_snapshot,
3835 command_sender,
3836 pending_responses,
3837 services,
3838 async_resource_owners,
3839 )
3840 }
3841
3842 pub fn with_state_responses_and_resources(
3845 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3846 command_sender: mpsc::Sender<PluginCommand>,
3847 pending_responses: PendingResponses,
3848 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3849 async_resource_owners: AsyncResourceOwners,
3850 ) -> Result<Self> {
3851 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
3852
3853 let runtime =
3854 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
3855
3856 runtime.set_host_promise_rejection_tracker(Some(Box::new(
3858 |_ctx, _promise, reason, is_handled| {
3859 if !is_handled {
3860 let error_msg = if let Some(exc) = reason.as_exception() {
3862 format!(
3863 "{}: {}",
3864 exc.message().unwrap_or_default(),
3865 exc.stack().unwrap_or_default()
3866 )
3867 } else {
3868 format!("{:?}", reason)
3869 };
3870
3871 tracing::error!("Unhandled Promise rejection: {}", error_msg);
3872
3873 if should_panic_on_js_errors() {
3874 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
3877 set_fatal_js_error(full_msg);
3878 }
3879 }
3880 },
3881 )));
3882
3883 let main_context = Context::full(&runtime)
3884 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
3885
3886 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
3887 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
3888 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
3889 let next_request_id = Rc::new(RefCell::new(1u64));
3890 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
3891 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
3892
3893 let backend = Self {
3894 runtime,
3895 main_context,
3896 plugin_contexts,
3897 event_handlers,
3898 registered_actions,
3899 state_snapshot,
3900 command_sender,
3901 pending_responses,
3902 next_request_id,
3903 callback_contexts,
3904 services,
3905 plugin_tracked_state,
3906 async_resource_owners,
3907 };
3908
3909 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
3911
3912 tracing::debug!("QuickJsBackend::new: runtime created successfully");
3913 Ok(backend)
3914 }
3915
3916 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
3918 let state_snapshot = Arc::clone(&self.state_snapshot);
3919 let command_sender = self.command_sender.clone();
3920 let event_handlers = Rc::clone(&self.event_handlers);
3921 let registered_actions = Rc::clone(&self.registered_actions);
3922 let next_request_id = Rc::clone(&self.next_request_id);
3923
3924 context.with(|ctx| {
3925 let globals = ctx.globals();
3926
3927 globals.set("__pluginName__", plugin_name)?;
3929
3930 let js_api = JsEditorApi {
3933 state_snapshot: Arc::clone(&state_snapshot),
3934 command_sender: command_sender.clone(),
3935 registered_actions: Rc::clone(®istered_actions),
3936 event_handlers: Rc::clone(&event_handlers),
3937 next_request_id: Rc::clone(&next_request_id),
3938 callback_contexts: Rc::clone(&self.callback_contexts),
3939 services: self.services.clone(),
3940 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
3941 async_resource_owners: Arc::clone(&self.async_resource_owners),
3942 plugin_name: plugin_name.to_string(),
3943 };
3944 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
3945
3946 globals.set("editor", editor)?;
3948
3949 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
3951
3952 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
3954
3955 let console = Object::new(ctx.clone())?;
3958 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3959 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3960 tracing::info!("console.log: {}", parts.join(" "));
3961 })?)?;
3962 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3963 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3964 tracing::warn!("console.warn: {}", parts.join(" "));
3965 })?)?;
3966 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3967 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3968 tracing::error!("console.error: {}", parts.join(" "));
3969 })?)?;
3970 globals.set("console", console)?;
3971
3972 ctx.eval::<(), _>(r#"
3974 // Pending promise callbacks: callbackId -> { resolve, reject }
3975 globalThis._pendingCallbacks = new Map();
3976
3977 // Resolve a pending callback (called from Rust)
3978 globalThis._resolveCallback = function(callbackId, result) {
3979 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
3980 const cb = globalThis._pendingCallbacks.get(callbackId);
3981 if (cb) {
3982 console.log('[JS] _resolveCallback: found callback, calling resolve()');
3983 globalThis._pendingCallbacks.delete(callbackId);
3984 cb.resolve(result);
3985 console.log('[JS] _resolveCallback: resolve() called');
3986 } else {
3987 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
3988 }
3989 };
3990
3991 // Reject a pending callback (called from Rust)
3992 globalThis._rejectCallback = function(callbackId, error) {
3993 const cb = globalThis._pendingCallbacks.get(callbackId);
3994 if (cb) {
3995 globalThis._pendingCallbacks.delete(callbackId);
3996 cb.reject(new Error(error));
3997 }
3998 };
3999
4000 // Streaming callbacks: called multiple times with partial results
4001 globalThis._streamingCallbacks = new Map();
4002
4003 // Called from Rust with partial data. When done=true, cleans up.
4004 globalThis._callStreamingCallback = function(callbackId, result, done) {
4005 const cb = globalThis._streamingCallbacks.get(callbackId);
4006 if (cb) {
4007 cb(result, done);
4008 if (done) {
4009 globalThis._streamingCallbacks.delete(callbackId);
4010 }
4011 }
4012 };
4013
4014 // Generic async wrapper decorator
4015 // Wraps a function that returns a callbackId into a promise-returning function
4016 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4017 // NOTE: We pass the method name as a string and call via bracket notation
4018 // to preserve rquickjs's automatic Ctx injection for methods
4019 globalThis._wrapAsync = function(methodName, fnName) {
4020 const startFn = editor[methodName];
4021 if (typeof startFn !== 'function') {
4022 // Return a function that always throws - catches missing implementations
4023 return function(...args) {
4024 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4025 editor.debug(`[ASYNC ERROR] ${error.message}`);
4026 throw error;
4027 };
4028 }
4029 return function(...args) {
4030 // Call via bracket notation to preserve method binding and Ctx injection
4031 const callbackId = editor[methodName](...args);
4032 return new Promise((resolve, reject) => {
4033 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4034 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4035 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4036 });
4037 };
4038 };
4039
4040 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4041 // The returned object has .result promise and is itself thenable
4042 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4043 const startFn = editor[methodName];
4044 if (typeof startFn !== 'function') {
4045 // Return a function that always throws - catches missing implementations
4046 return function(...args) {
4047 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4048 editor.debug(`[ASYNC ERROR] ${error.message}`);
4049 throw error;
4050 };
4051 }
4052 return function(...args) {
4053 // Call via bracket notation to preserve method binding and Ctx injection
4054 const callbackId = editor[methodName](...args);
4055 const resultPromise = new Promise((resolve, reject) => {
4056 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4057 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4058 });
4059 return {
4060 get result() { return resultPromise; },
4061 then(onFulfilled, onRejected) {
4062 return resultPromise.then(onFulfilled, onRejected);
4063 },
4064 catch(onRejected) {
4065 return resultPromise.catch(onRejected);
4066 }
4067 };
4068 };
4069 };
4070
4071 // Apply wrappers to async functions on editor
4072 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4073 editor.delay = _wrapAsync("_delayStart", "delay");
4074 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4075 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4076 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4077 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4078 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4079 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4080 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4081 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4082 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4083 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4084 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4085 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4086 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4087 editor.prompt = _wrapAsync("_promptStart", "prompt");
4088 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4089 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4090 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4091 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4092 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4093 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4094
4095 // Streaming grep: takes a progress callback, returns a thenable with searchId
4096 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4097 opts = opts || {};
4098 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4099 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4100 const maxResults = opts.maxResults || 10000;
4101 const wholeWords = opts.wholeWords || false;
4102
4103 const searchId = editor._grepProjectStreamingStart(
4104 pattern, fixedString, caseSensitive, maxResults, wholeWords
4105 );
4106
4107 // Register streaming callback
4108 if (progressCallback) {
4109 globalThis._streamingCallbacks.set(searchId, progressCallback);
4110 }
4111
4112 // Create completion promise (resolved via _resolveCallback when search finishes)
4113 const resultPromise = new Promise(function(resolve, reject) {
4114 globalThis._pendingCallbacks.set(searchId, {
4115 resolve: function(result) {
4116 globalThis._streamingCallbacks.delete(searchId);
4117 resolve(result);
4118 },
4119 reject: function(err) {
4120 globalThis._streamingCallbacks.delete(searchId);
4121 reject(err);
4122 }
4123 });
4124 });
4125
4126 return {
4127 searchId: searchId,
4128 get result() { return resultPromise; },
4129 then: function(f, r) { return resultPromise.then(f, r); },
4130 catch: function(r) { return resultPromise.catch(r); }
4131 };
4132 };
4133
4134 // Wrapper for deleteTheme - wraps sync function in Promise
4135 editor.deleteTheme = function(name) {
4136 return new Promise(function(resolve, reject) {
4137 const success = editor._deleteThemeSync(name);
4138 if (success) {
4139 resolve();
4140 } else {
4141 reject(new Error("Failed to delete theme: " + name));
4142 }
4143 });
4144 };
4145 "#.as_bytes())?;
4146
4147 Ok::<_, rquickjs::Error>(())
4148 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4149
4150 Ok(())
4151 }
4152
4153 pub async fn load_module_with_source(
4155 &mut self,
4156 path: &str,
4157 _plugin_source: &str,
4158 ) -> Result<()> {
4159 let path_buf = PathBuf::from(path);
4160 let source = std::fs::read_to_string(&path_buf)
4161 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4162
4163 let filename = path_buf
4164 .file_name()
4165 .and_then(|s| s.to_str())
4166 .unwrap_or("plugin.ts");
4167
4168 if has_es_imports(&source) {
4170 match bundle_module(&path_buf) {
4172 Ok(bundled) => {
4173 self.execute_js(&bundled, path)?;
4174 }
4175 Err(e) => {
4176 tracing::warn!(
4177 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4178 path,
4179 e
4180 );
4181 return Ok(()); }
4183 }
4184 } else if has_es_module_syntax(&source) {
4185 let stripped = strip_imports_and_exports(&source);
4187 let js_code = if filename.ends_with(".ts") {
4188 transpile_typescript(&stripped, filename)?
4189 } else {
4190 stripped
4191 };
4192 self.execute_js(&js_code, path)?;
4193 } else {
4194 let js_code = if filename.ends_with(".ts") {
4196 transpile_typescript(&source, filename)?
4197 } else {
4198 source
4199 };
4200 self.execute_js(&js_code, path)?;
4201 }
4202
4203 Ok(())
4204 }
4205
4206 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4208 let plugin_name = Path::new(source_name)
4210 .file_stem()
4211 .and_then(|s| s.to_str())
4212 .unwrap_or("unknown");
4213
4214 tracing::debug!(
4215 "execute_js: starting for plugin '{}' from '{}'",
4216 plugin_name,
4217 source_name
4218 );
4219
4220 let context = {
4222 let mut contexts = self.plugin_contexts.borrow_mut();
4223 if let Some(ctx) = contexts.get(plugin_name) {
4224 ctx.clone()
4225 } else {
4226 let ctx = Context::full(&self.runtime).map_err(|e| {
4227 anyhow!(
4228 "Failed to create QuickJS context for plugin {}: {}",
4229 plugin_name,
4230 e
4231 )
4232 })?;
4233 self.setup_context_api(&ctx, plugin_name)?;
4234 contexts.insert(plugin_name.to_string(), ctx.clone());
4235 ctx
4236 }
4237 };
4238
4239 let wrapped_code = format!("(function() {{ {} }})();", code);
4243 let wrapped = wrapped_code.as_str();
4244
4245 context.with(|ctx| {
4246 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4247
4248 let mut eval_options = rquickjs::context::EvalOptions::default();
4250 eval_options.global = true;
4251 eval_options.filename = Some(source_name.to_string());
4252 let result = ctx
4253 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4254 .map_err(|e| format_js_error(&ctx, e, source_name));
4255
4256 tracing::debug!(
4257 "execute_js: plugin code execution finished for '{}', result: {:?}",
4258 plugin_name,
4259 result.is_ok()
4260 );
4261
4262 result
4263 })
4264 }
4265
4266 pub fn execute_source(
4272 &mut self,
4273 source: &str,
4274 plugin_name: &str,
4275 is_typescript: bool,
4276 ) -> Result<()> {
4277 use fresh_parser_js::{
4278 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4279 };
4280
4281 if has_es_imports(source) {
4282 tracing::warn!(
4283 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4284 plugin_name
4285 );
4286 }
4287
4288 let js_code = if has_es_module_syntax(source) {
4289 let stripped = strip_imports_and_exports(source);
4290 if is_typescript {
4291 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4292 } else {
4293 stripped
4294 }
4295 } else if is_typescript {
4296 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4297 } else {
4298 source.to_string()
4299 };
4300
4301 let source_name = format!(
4303 "{}.{}",
4304 plugin_name,
4305 if is_typescript { "ts" } else { "js" }
4306 );
4307 self.execute_js(&js_code, &source_name)
4308 }
4309
4310 pub fn cleanup_plugin(&self, plugin_name: &str) {
4316 self.plugin_contexts.borrow_mut().remove(plugin_name);
4318
4319 for handlers in self.event_handlers.borrow_mut().values_mut() {
4321 handlers.retain(|h| h.plugin_name != plugin_name);
4322 }
4323
4324 self.registered_actions
4326 .borrow_mut()
4327 .retain(|_, h| h.plugin_name != plugin_name);
4328
4329 self.callback_contexts
4331 .borrow_mut()
4332 .retain(|_, pname| pname != plugin_name);
4333
4334 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4336 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4338 std::collections::HashSet::new();
4339 for (buf_id, ns) in &tracked.overlay_namespaces {
4340 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4341 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4343 buffer_id: *buf_id,
4344 namespace: OverlayNamespace::from_string(ns.clone()),
4345 });
4346 let _ = self
4348 .command_sender
4349 .send(PluginCommand::ClearConcealNamespace {
4350 buffer_id: *buf_id,
4351 namespace: OverlayNamespace::from_string(ns.clone()),
4352 });
4353 let _ = self
4354 .command_sender
4355 .send(PluginCommand::ClearSoftBreakNamespace {
4356 buffer_id: *buf_id,
4357 namespace: OverlayNamespace::from_string(ns.clone()),
4358 });
4359 }
4360 }
4361
4362 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4368 std::collections::HashSet::new();
4369 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4370 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4371 let _ = self
4372 .command_sender
4373 .send(PluginCommand::ClearLineIndicators {
4374 buffer_id: *buf_id,
4375 namespace: ns.clone(),
4376 });
4377 }
4378 }
4379
4380 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4382 std::collections::HashSet::new();
4383 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4384 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4385 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4386 buffer_id: *buf_id,
4387 virtual_text_id: vt_id.clone(),
4388 });
4389 }
4390 }
4391
4392 let mut seen_fe_ns: std::collections::HashSet<String> =
4394 std::collections::HashSet::new();
4395 for ns in &tracked.file_explorer_namespaces {
4396 if seen_fe_ns.insert(ns.clone()) {
4397 let _ = self
4398 .command_sender
4399 .send(PluginCommand::ClearFileExplorerDecorations {
4400 namespace: ns.clone(),
4401 });
4402 }
4403 }
4404
4405 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4407 for ctx_name in &tracked.contexts_set {
4408 if seen_ctx.insert(ctx_name.clone()) {
4409 let _ = self.command_sender.send(PluginCommand::SetContext {
4410 name: ctx_name.clone(),
4411 active: false,
4412 });
4413 }
4414 }
4415
4416 for process_id in &tracked.background_process_ids {
4420 let _ = self
4421 .command_sender
4422 .send(PluginCommand::KillBackgroundProcess {
4423 process_id: *process_id,
4424 });
4425 }
4426
4427 for group_id in &tracked.scroll_sync_group_ids {
4429 let _ = self
4430 .command_sender
4431 .send(PluginCommand::RemoveScrollSyncGroup {
4432 group_id: *group_id,
4433 });
4434 }
4435
4436 for buffer_id in &tracked.virtual_buffer_ids {
4438 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4439 buffer_id: *buffer_id,
4440 });
4441 }
4442
4443 for buffer_id in &tracked.composite_buffer_ids {
4445 let _ = self
4446 .command_sender
4447 .send(PluginCommand::CloseCompositeBuffer {
4448 buffer_id: *buffer_id,
4449 });
4450 }
4451
4452 for terminal_id in &tracked.terminal_ids {
4454 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4455 terminal_id: *terminal_id,
4456 });
4457 }
4458 }
4459
4460 if let Ok(mut owners) = self.async_resource_owners.lock() {
4462 owners.retain(|_, name| name != plugin_name);
4463 }
4464
4465 tracing::debug!(
4466 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
4467 plugin_name
4468 );
4469 }
4470
4471 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
4473 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
4474
4475 self.services
4476 .set_js_execution_state(format!("hook '{}'", event_name));
4477
4478 let handlers = self.event_handlers.borrow().get(event_name).cloned();
4479 if let Some(handler_pairs) = handlers {
4480 let plugin_contexts = self.plugin_contexts.borrow();
4481 for handler in &handler_pairs {
4482 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
4483 continue;
4484 };
4485 context.with(|ctx| {
4486 call_handler(&ctx, &handler.handler_name, event_data);
4487 });
4488 }
4489 }
4490
4491 self.services.clear_js_execution_state();
4492 Ok(true)
4493 }
4494
4495 pub fn has_handlers(&self, event_name: &str) -> bool {
4497 self.event_handlers
4498 .borrow()
4499 .get(event_name)
4500 .map(|v| !v.is_empty())
4501 .unwrap_or(false)
4502 }
4503
4504 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
4508 let (lookup_name, text_input_char) =
4511 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
4512 ("mode_text_input", Some(ch.to_string()))
4513 } else {
4514 (action_name, None)
4515 };
4516
4517 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
4518 let (plugin_name, function_name) = match pair {
4519 Some(handler) => (handler.plugin_name, handler.handler_name),
4520 None => ("main".to_string(), lookup_name.to_string()),
4521 };
4522
4523 let plugin_contexts = self.plugin_contexts.borrow();
4524 let context = plugin_contexts
4525 .get(&plugin_name)
4526 .unwrap_or(&self.main_context);
4527
4528 self.services
4530 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
4531
4532 tracing::info!(
4533 "start_action: BEGIN '{}' -> function '{}'",
4534 action_name,
4535 function_name
4536 );
4537
4538 let call_args = if let Some(ref ch) = text_input_char {
4541 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
4542 format!("({{text:\"{}\"}})", escaped)
4543 } else {
4544 "()".to_string()
4545 };
4546
4547 let code = format!(
4548 r#"
4549 (function() {{
4550 console.log('[JS] start_action: calling {fn}');
4551 try {{
4552 if (typeof globalThis.{fn} === 'function') {{
4553 console.log('[JS] start_action: {fn} is a function, invoking...');
4554 globalThis.{fn}{args};
4555 console.log('[JS] start_action: {fn} invoked (may be async)');
4556 }} else {{
4557 console.error('[JS] Action {action} is not defined as a global function');
4558 }}
4559 }} catch (e) {{
4560 console.error('[JS] Action {action} error:', e);
4561 }}
4562 }})();
4563 "#,
4564 fn = function_name,
4565 action = action_name,
4566 args = call_args
4567 );
4568
4569 tracing::info!("start_action: evaluating JS code");
4570 context.with(|ctx| {
4571 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4572 log_js_error(&ctx, e, &format!("action {}", action_name));
4573 }
4574 tracing::info!("start_action: running pending microtasks");
4575 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
4577 tracing::info!("start_action: executed {} pending jobs", count);
4578 });
4579
4580 tracing::info!("start_action: END '{}'", action_name);
4581
4582 self.services.clear_js_execution_state();
4584
4585 Ok(())
4586 }
4587
4588 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
4590 let pair = self.registered_actions.borrow().get(action_name).cloned();
4592 let (plugin_name, function_name) = match pair {
4593 Some(handler) => (handler.plugin_name, handler.handler_name),
4594 None => ("main".to_string(), action_name.to_string()),
4595 };
4596
4597 let plugin_contexts = self.plugin_contexts.borrow();
4598 let context = plugin_contexts
4599 .get(&plugin_name)
4600 .unwrap_or(&self.main_context);
4601
4602 tracing::debug!(
4603 "execute_action: '{}' -> function '{}'",
4604 action_name,
4605 function_name
4606 );
4607
4608 let code = format!(
4611 r#"
4612 (async function() {{
4613 try {{
4614 if (typeof globalThis.{fn} === 'function') {{
4615 const result = globalThis.{fn}();
4616 // If it's a Promise, await it
4617 if (result && typeof result.then === 'function') {{
4618 await result;
4619 }}
4620 }} else {{
4621 console.error('Action {action} is not defined as a global function');
4622 }}
4623 }} catch (e) {{
4624 console.error('Action {action} error:', e);
4625 }}
4626 }})();
4627 "#,
4628 fn = function_name,
4629 action = action_name
4630 );
4631
4632 context.with(|ctx| {
4633 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4635 Ok(value) => {
4636 if value.is_object() {
4638 if let Some(obj) = value.as_object() {
4639 if obj.get::<_, rquickjs::Function>("then").is_ok() {
4641 run_pending_jobs_checked(
4644 &ctx,
4645 &format!("execute_action {} promise", action_name),
4646 );
4647 }
4648 }
4649 }
4650 }
4651 Err(e) => {
4652 log_js_error(&ctx, e, &format!("action {}", action_name));
4653 }
4654 }
4655 });
4656
4657 Ok(())
4658 }
4659
4660 pub fn poll_event_loop_once(&mut self) -> bool {
4662 let mut had_work = false;
4663
4664 self.main_context.with(|ctx| {
4666 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
4667 if count > 0 {
4668 had_work = true;
4669 }
4670 });
4671
4672 let contexts = self.plugin_contexts.borrow().clone();
4674 for (name, context) in contexts {
4675 context.with(|ctx| {
4676 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
4677 if count > 0 {
4678 had_work = true;
4679 }
4680 });
4681 }
4682 had_work
4683 }
4684
4685 pub fn send_status(&self, message: String) {
4687 let _ = self
4688 .command_sender
4689 .send(PluginCommand::SetStatus { message });
4690 }
4691
4692 pub fn send_hook_completed(&self, hook_name: String) {
4696 let _ = self
4697 .command_sender
4698 .send(PluginCommand::HookCompleted { hook_name });
4699 }
4700
4701 pub fn resolve_callback(
4706 &mut self,
4707 callback_id: fresh_core::api::JsCallbackId,
4708 result_json: &str,
4709 ) {
4710 let id = callback_id.as_u64();
4711 tracing::debug!("resolve_callback: starting for callback_id={}", id);
4712
4713 let plugin_name = {
4715 let mut contexts = self.callback_contexts.borrow_mut();
4716 contexts.remove(&id)
4717 };
4718
4719 let Some(name) = plugin_name else {
4720 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
4721 return;
4722 };
4723
4724 let plugin_contexts = self.plugin_contexts.borrow();
4725 let Some(context) = plugin_contexts.get(&name) else {
4726 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
4727 return;
4728 };
4729
4730 context.with(|ctx| {
4731 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
4733 Ok(v) => v,
4734 Err(e) => {
4735 tracing::error!(
4736 "resolve_callback: failed to parse JSON for callback_id={}: {}",
4737 id,
4738 e
4739 );
4740 return;
4741 }
4742 };
4743
4744 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
4746 Ok(v) => v,
4747 Err(e) => {
4748 tracing::error!(
4749 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
4750 id,
4751 e
4752 );
4753 return;
4754 }
4755 };
4756
4757 let globals = ctx.globals();
4759 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
4760 Ok(f) => f,
4761 Err(e) => {
4762 tracing::error!(
4763 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
4764 id,
4765 e
4766 );
4767 return;
4768 }
4769 };
4770
4771 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
4773 log_js_error(&ctx, e, &format!("resolving callback {}", id));
4774 }
4775
4776 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
4778 tracing::info!(
4779 "resolve_callback: executed {} pending jobs for callback_id={}",
4780 job_count,
4781 id
4782 );
4783 });
4784 }
4785
4786 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
4788 let id = callback_id.as_u64();
4789
4790 let plugin_name = {
4792 let mut contexts = self.callback_contexts.borrow_mut();
4793 contexts.remove(&id)
4794 };
4795
4796 let Some(name) = plugin_name else {
4797 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
4798 return;
4799 };
4800
4801 let plugin_contexts = self.plugin_contexts.borrow();
4802 let Some(context) = plugin_contexts.get(&name) else {
4803 tracing::warn!("reject_callback: Context lost for plugin {}", name);
4804 return;
4805 };
4806
4807 context.with(|ctx| {
4808 let globals = ctx.globals();
4810 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
4811 Ok(f) => f,
4812 Err(e) => {
4813 tracing::error!(
4814 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
4815 id,
4816 e
4817 );
4818 return;
4819 }
4820 };
4821
4822 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
4824 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
4825 }
4826
4827 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
4829 });
4830 }
4831
4832 pub fn call_streaming_callback(
4836 &mut self,
4837 callback_id: fresh_core::api::JsCallbackId,
4838 result_json: &str,
4839 done: bool,
4840 ) {
4841 let id = callback_id.as_u64();
4842
4843 let plugin_name = {
4845 let contexts = self.callback_contexts.borrow();
4846 contexts.get(&id).cloned()
4847 };
4848
4849 let Some(name) = plugin_name else {
4850 tracing::warn!(
4851 "call_streaming_callback: No plugin found for callback_id={}",
4852 id
4853 );
4854 return;
4855 };
4856
4857 if done {
4859 self.callback_contexts.borrow_mut().remove(&id);
4860 }
4861
4862 let plugin_contexts = self.plugin_contexts.borrow();
4863 let Some(context) = plugin_contexts.get(&name) else {
4864 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
4865 return;
4866 };
4867
4868 context.with(|ctx| {
4869 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
4870 Ok(v) => v,
4871 Err(e) => {
4872 tracing::error!(
4873 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
4874 id,
4875 e
4876 );
4877 return;
4878 }
4879 };
4880
4881 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
4882 Ok(v) => v,
4883 Err(e) => {
4884 tracing::error!(
4885 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
4886 id,
4887 e
4888 );
4889 return;
4890 }
4891 };
4892
4893 let globals = ctx.globals();
4894 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
4895 Ok(f) => f,
4896 Err(e) => {
4897 tracing::error!(
4898 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
4899 id,
4900 e
4901 );
4902 return;
4903 }
4904 };
4905
4906 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
4907 log_js_error(
4908 &ctx,
4909 e,
4910 &format!("calling streaming callback {}", id),
4911 );
4912 }
4913
4914 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
4915 });
4916 }
4917}
4918
4919#[cfg(test)]
4920mod tests {
4921 use super::*;
4922 use fresh_core::api::{BufferInfo, CursorInfo};
4923 use std::sync::mpsc;
4924
4925 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
4927 let (tx, rx) = mpsc::channel();
4928 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4929 let services = Arc::new(TestServiceBridge::new());
4930 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4931 (backend, rx)
4932 }
4933
4934 struct TestServiceBridge {
4935 en_strings: std::sync::Mutex<HashMap<String, String>>,
4936 }
4937
4938 impl TestServiceBridge {
4939 fn new() -> Self {
4940 Self {
4941 en_strings: std::sync::Mutex::new(HashMap::new()),
4942 }
4943 }
4944 }
4945
4946 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
4947 fn as_any(&self) -> &dyn std::any::Any {
4948 self
4949 }
4950 fn translate(
4951 &self,
4952 _plugin_name: &str,
4953 key: &str,
4954 _args: &HashMap<String, String>,
4955 ) -> String {
4956 self.en_strings
4957 .lock()
4958 .unwrap()
4959 .get(key)
4960 .cloned()
4961 .unwrap_or_else(|| key.to_string())
4962 }
4963 fn current_locale(&self) -> String {
4964 "en".to_string()
4965 }
4966 fn set_js_execution_state(&self, _state: String) {}
4967 fn clear_js_execution_state(&self) {}
4968 fn get_theme_schema(&self) -> serde_json::Value {
4969 serde_json::json!({})
4970 }
4971 fn get_builtin_themes(&self) -> serde_json::Value {
4972 serde_json::json!([])
4973 }
4974 fn register_command(&self, _command: fresh_core::command::Command) {}
4975 fn unregister_command(&self, _name: &str) {}
4976 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
4977 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
4978 fn plugins_dir(&self) -> std::path::PathBuf {
4979 std::path::PathBuf::from("/tmp/plugins")
4980 }
4981 fn config_dir(&self) -> std::path::PathBuf {
4982 std::path::PathBuf::from("/tmp/config")
4983 }
4984 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
4985 None
4986 }
4987 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
4988 Err("not implemented in test".to_string())
4989 }
4990 fn theme_file_exists(&self, _name: &str) -> bool {
4991 false
4992 }
4993 }
4994
4995 #[test]
4996 fn test_quickjs_backend_creation() {
4997 let backend = QuickJsBackend::new();
4998 assert!(backend.is_ok());
4999 }
5000
5001 #[test]
5002 fn test_execute_simple_js() {
5003 let mut backend = QuickJsBackend::new().unwrap();
5004 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5005 assert!(result.is_ok());
5006 }
5007
5008 #[test]
5009 fn test_event_handler_registration() {
5010 let backend = QuickJsBackend::new().unwrap();
5011
5012 assert!(!backend.has_handlers("test_event"));
5014
5015 backend
5017 .event_handlers
5018 .borrow_mut()
5019 .entry("test_event".to_string())
5020 .or_default()
5021 .push(PluginHandler {
5022 plugin_name: "test".to_string(),
5023 handler_name: "testHandler".to_string(),
5024 });
5025
5026 assert!(backend.has_handlers("test_event"));
5028 }
5029
5030 #[test]
5033 fn test_api_set_status() {
5034 let (mut backend, rx) = create_test_backend();
5035
5036 backend
5037 .execute_js(
5038 r#"
5039 const editor = getEditor();
5040 editor.setStatus("Hello from test");
5041 "#,
5042 "test.js",
5043 )
5044 .unwrap();
5045
5046 let cmd = rx.try_recv().unwrap();
5047 match cmd {
5048 PluginCommand::SetStatus { message } => {
5049 assert_eq!(message, "Hello from test");
5050 }
5051 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5052 }
5053 }
5054
5055 #[test]
5056 fn test_api_register_command() {
5057 let (mut backend, rx) = create_test_backend();
5058
5059 backend
5060 .execute_js(
5061 r#"
5062 const editor = getEditor();
5063 globalThis.myTestHandler = function() { };
5064 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5065 "#,
5066 "test_plugin.js",
5067 )
5068 .unwrap();
5069
5070 let cmd = rx.try_recv().unwrap();
5071 match cmd {
5072 PluginCommand::RegisterCommand { command } => {
5073 assert_eq!(command.name, "Test Command");
5074 assert_eq!(command.description, "A test command");
5075 assert_eq!(command.plugin_name, "test_plugin");
5077 }
5078 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5079 }
5080 }
5081
5082 #[test]
5083 fn test_api_define_mode() {
5084 let (mut backend, rx) = create_test_backend();
5085
5086 backend
5087 .execute_js(
5088 r#"
5089 const editor = getEditor();
5090 editor.defineMode("test-mode", [
5091 ["a", "action_a"],
5092 ["b", "action_b"]
5093 ]);
5094 "#,
5095 "test.js",
5096 )
5097 .unwrap();
5098
5099 let cmd = rx.try_recv().unwrap();
5100 match cmd {
5101 PluginCommand::DefineMode {
5102 name,
5103 bindings,
5104 read_only,
5105 allow_text_input,
5106 plugin_name,
5107 } => {
5108 assert_eq!(name, "test-mode");
5109 assert_eq!(bindings.len(), 2);
5110 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5111 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5112 assert!(!read_only);
5113 assert!(!allow_text_input);
5114 assert!(plugin_name.is_some());
5115 }
5116 _ => panic!("Expected DefineMode, got {:?}", cmd),
5117 }
5118 }
5119
5120 #[test]
5121 fn test_api_set_editor_mode() {
5122 let (mut backend, rx) = create_test_backend();
5123
5124 backend
5125 .execute_js(
5126 r#"
5127 const editor = getEditor();
5128 editor.setEditorMode("vi-normal");
5129 "#,
5130 "test.js",
5131 )
5132 .unwrap();
5133
5134 let cmd = rx.try_recv().unwrap();
5135 match cmd {
5136 PluginCommand::SetEditorMode { mode } => {
5137 assert_eq!(mode, Some("vi-normal".to_string()));
5138 }
5139 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5140 }
5141 }
5142
5143 #[test]
5144 fn test_api_clear_editor_mode() {
5145 let (mut backend, rx) = create_test_backend();
5146
5147 backend
5148 .execute_js(
5149 r#"
5150 const editor = getEditor();
5151 editor.setEditorMode(null);
5152 "#,
5153 "test.js",
5154 )
5155 .unwrap();
5156
5157 let cmd = rx.try_recv().unwrap();
5158 match cmd {
5159 PluginCommand::SetEditorMode { mode } => {
5160 assert!(mode.is_none());
5161 }
5162 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5163 }
5164 }
5165
5166 #[test]
5167 fn test_api_insert_at_cursor() {
5168 let (mut backend, rx) = create_test_backend();
5169
5170 backend
5171 .execute_js(
5172 r#"
5173 const editor = getEditor();
5174 editor.insertAtCursor("Hello, World!");
5175 "#,
5176 "test.js",
5177 )
5178 .unwrap();
5179
5180 let cmd = rx.try_recv().unwrap();
5181 match cmd {
5182 PluginCommand::InsertAtCursor { text } => {
5183 assert_eq!(text, "Hello, World!");
5184 }
5185 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5186 }
5187 }
5188
5189 #[test]
5190 fn test_api_set_context() {
5191 let (mut backend, rx) = create_test_backend();
5192
5193 backend
5194 .execute_js(
5195 r#"
5196 const editor = getEditor();
5197 editor.setContext("myContext", true);
5198 "#,
5199 "test.js",
5200 )
5201 .unwrap();
5202
5203 let cmd = rx.try_recv().unwrap();
5204 match cmd {
5205 PluginCommand::SetContext { name, active } => {
5206 assert_eq!(name, "myContext");
5207 assert!(active);
5208 }
5209 _ => panic!("Expected SetContext, got {:?}", cmd),
5210 }
5211 }
5212
5213 #[tokio::test]
5214 async fn test_execute_action_sync_function() {
5215 let (mut backend, rx) = create_test_backend();
5216
5217 backend.registered_actions.borrow_mut().insert(
5219 "my_sync_action".to_string(),
5220 PluginHandler {
5221 plugin_name: "test".to_string(),
5222 handler_name: "my_sync_action".to_string(),
5223 },
5224 );
5225
5226 backend
5228 .execute_js(
5229 r#"
5230 const editor = getEditor();
5231 globalThis.my_sync_action = function() {
5232 editor.setStatus("sync action executed");
5233 };
5234 "#,
5235 "test.js",
5236 )
5237 .unwrap();
5238
5239 while rx.try_recv().is_ok() {}
5241
5242 backend.execute_action("my_sync_action").await.unwrap();
5244
5245 let cmd = rx.try_recv().unwrap();
5247 match cmd {
5248 PluginCommand::SetStatus { message } => {
5249 assert_eq!(message, "sync action executed");
5250 }
5251 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5252 }
5253 }
5254
5255 #[tokio::test]
5256 async fn test_execute_action_async_function() {
5257 let (mut backend, rx) = create_test_backend();
5258
5259 backend.registered_actions.borrow_mut().insert(
5261 "my_async_action".to_string(),
5262 PluginHandler {
5263 plugin_name: "test".to_string(),
5264 handler_name: "my_async_action".to_string(),
5265 },
5266 );
5267
5268 backend
5270 .execute_js(
5271 r#"
5272 const editor = getEditor();
5273 globalThis.my_async_action = async function() {
5274 await Promise.resolve();
5275 editor.setStatus("async action executed");
5276 };
5277 "#,
5278 "test.js",
5279 )
5280 .unwrap();
5281
5282 while rx.try_recv().is_ok() {}
5284
5285 backend.execute_action("my_async_action").await.unwrap();
5287
5288 let cmd = rx.try_recv().unwrap();
5290 match cmd {
5291 PluginCommand::SetStatus { message } => {
5292 assert_eq!(message, "async action executed");
5293 }
5294 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5295 }
5296 }
5297
5298 #[tokio::test]
5299 async fn test_execute_action_with_registered_handler() {
5300 let (mut backend, rx) = create_test_backend();
5301
5302 backend.registered_actions.borrow_mut().insert(
5304 "my_action".to_string(),
5305 PluginHandler {
5306 plugin_name: "test".to_string(),
5307 handler_name: "actual_handler_function".to_string(),
5308 },
5309 );
5310
5311 backend
5312 .execute_js(
5313 r#"
5314 const editor = getEditor();
5315 globalThis.actual_handler_function = function() {
5316 editor.setStatus("handler executed");
5317 };
5318 "#,
5319 "test.js",
5320 )
5321 .unwrap();
5322
5323 while rx.try_recv().is_ok() {}
5325
5326 backend.execute_action("my_action").await.unwrap();
5328
5329 let cmd = rx.try_recv().unwrap();
5330 match cmd {
5331 PluginCommand::SetStatus { message } => {
5332 assert_eq!(message, "handler executed");
5333 }
5334 _ => panic!("Expected SetStatus, got {:?}", cmd),
5335 }
5336 }
5337
5338 #[test]
5339 fn test_api_on_event_registration() {
5340 let (mut backend, _rx) = create_test_backend();
5341
5342 backend
5343 .execute_js(
5344 r#"
5345 const editor = getEditor();
5346 globalThis.myEventHandler = function() { };
5347 editor.on("bufferSave", "myEventHandler");
5348 "#,
5349 "test.js",
5350 )
5351 .unwrap();
5352
5353 assert!(backend.has_handlers("bufferSave"));
5354 }
5355
5356 #[test]
5357 fn test_api_off_event_unregistration() {
5358 let (mut backend, _rx) = create_test_backend();
5359
5360 backend
5361 .execute_js(
5362 r#"
5363 const editor = getEditor();
5364 globalThis.myEventHandler = function() { };
5365 editor.on("bufferSave", "myEventHandler");
5366 editor.off("bufferSave", "myEventHandler");
5367 "#,
5368 "test.js",
5369 )
5370 .unwrap();
5371
5372 assert!(!backend.has_handlers("bufferSave"));
5374 }
5375
5376 #[tokio::test]
5377 async fn test_emit_event() {
5378 let (mut backend, rx) = create_test_backend();
5379
5380 backend
5381 .execute_js(
5382 r#"
5383 const editor = getEditor();
5384 globalThis.onSaveHandler = function(data) {
5385 editor.setStatus("saved: " + JSON.stringify(data));
5386 };
5387 editor.on("bufferSave", "onSaveHandler");
5388 "#,
5389 "test.js",
5390 )
5391 .unwrap();
5392
5393 while rx.try_recv().is_ok() {}
5395
5396 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5398 backend.emit("bufferSave", &event_data).await.unwrap();
5399
5400 let cmd = rx.try_recv().unwrap();
5401 match cmd {
5402 PluginCommand::SetStatus { message } => {
5403 assert!(message.contains("/test.txt"));
5404 }
5405 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5406 }
5407 }
5408
5409 #[test]
5410 fn test_api_copy_to_clipboard() {
5411 let (mut backend, rx) = create_test_backend();
5412
5413 backend
5414 .execute_js(
5415 r#"
5416 const editor = getEditor();
5417 editor.copyToClipboard("clipboard text");
5418 "#,
5419 "test.js",
5420 )
5421 .unwrap();
5422
5423 let cmd = rx.try_recv().unwrap();
5424 match cmd {
5425 PluginCommand::SetClipboard { text } => {
5426 assert_eq!(text, "clipboard text");
5427 }
5428 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5429 }
5430 }
5431
5432 #[test]
5433 fn test_api_open_file() {
5434 let (mut backend, rx) = create_test_backend();
5435
5436 backend
5438 .execute_js(
5439 r#"
5440 const editor = getEditor();
5441 editor.openFile("/path/to/file.txt", null, null);
5442 "#,
5443 "test.js",
5444 )
5445 .unwrap();
5446
5447 let cmd = rx.try_recv().unwrap();
5448 match cmd {
5449 PluginCommand::OpenFileAtLocation { path, line, column } => {
5450 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5451 assert!(line.is_none());
5452 assert!(column.is_none());
5453 }
5454 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5455 }
5456 }
5457
5458 #[test]
5459 fn test_api_delete_range() {
5460 let (mut backend, rx) = create_test_backend();
5461
5462 backend
5464 .execute_js(
5465 r#"
5466 const editor = getEditor();
5467 editor.deleteRange(0, 10, 20);
5468 "#,
5469 "test.js",
5470 )
5471 .unwrap();
5472
5473 let cmd = rx.try_recv().unwrap();
5474 match cmd {
5475 PluginCommand::DeleteRange { range, .. } => {
5476 assert_eq!(range.start, 10);
5477 assert_eq!(range.end, 20);
5478 }
5479 _ => panic!("Expected DeleteRange, got {:?}", cmd),
5480 }
5481 }
5482
5483 #[test]
5484 fn test_api_insert_text() {
5485 let (mut backend, rx) = create_test_backend();
5486
5487 backend
5489 .execute_js(
5490 r#"
5491 const editor = getEditor();
5492 editor.insertText(0, 5, "inserted");
5493 "#,
5494 "test.js",
5495 )
5496 .unwrap();
5497
5498 let cmd = rx.try_recv().unwrap();
5499 match cmd {
5500 PluginCommand::InsertText { position, text, .. } => {
5501 assert_eq!(position, 5);
5502 assert_eq!(text, "inserted");
5503 }
5504 _ => panic!("Expected InsertText, got {:?}", cmd),
5505 }
5506 }
5507
5508 #[test]
5509 fn test_api_set_buffer_cursor() {
5510 let (mut backend, rx) = create_test_backend();
5511
5512 backend
5514 .execute_js(
5515 r#"
5516 const editor = getEditor();
5517 editor.setBufferCursor(0, 100);
5518 "#,
5519 "test.js",
5520 )
5521 .unwrap();
5522
5523 let cmd = rx.try_recv().unwrap();
5524 match cmd {
5525 PluginCommand::SetBufferCursor { position, .. } => {
5526 assert_eq!(position, 100);
5527 }
5528 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
5529 }
5530 }
5531
5532 #[test]
5533 fn test_api_get_cursor_position_from_state() {
5534 let (tx, _rx) = mpsc::channel();
5535 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5536
5537 {
5539 let mut state = state_snapshot.write().unwrap();
5540 state.primary_cursor = Some(CursorInfo {
5541 position: 42,
5542 selection: None,
5543 });
5544 }
5545
5546 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5547 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5548
5549 backend
5551 .execute_js(
5552 r#"
5553 const editor = getEditor();
5554 const pos = editor.getCursorPosition();
5555 globalThis._testResult = pos;
5556 "#,
5557 "test.js",
5558 )
5559 .unwrap();
5560
5561 backend
5563 .plugin_contexts
5564 .borrow()
5565 .get("test")
5566 .unwrap()
5567 .clone()
5568 .with(|ctx| {
5569 let global = ctx.globals();
5570 let result: u32 = global.get("_testResult").unwrap();
5571 assert_eq!(result, 42);
5572 });
5573 }
5574
5575 #[test]
5576 fn test_api_path_functions() {
5577 let (mut backend, _rx) = create_test_backend();
5578
5579 #[cfg(windows)]
5582 let absolute_path = r#"C:\\foo\\bar"#;
5583 #[cfg(not(windows))]
5584 let absolute_path = "/foo/bar";
5585
5586 let js_code = format!(
5588 r#"
5589 const editor = getEditor();
5590 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
5591 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
5592 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
5593 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
5594 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
5595 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
5596 "#,
5597 absolute_path
5598 );
5599 backend.execute_js(&js_code, "test.js").unwrap();
5600
5601 backend
5602 .plugin_contexts
5603 .borrow()
5604 .get("test")
5605 .unwrap()
5606 .clone()
5607 .with(|ctx| {
5608 let global = ctx.globals();
5609 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
5610 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
5611 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
5612 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
5613 assert!(!global.get::<_, bool>("_isRelative").unwrap());
5614 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
5615 });
5616 }
5617
5618 #[test]
5619 fn test_file_uri_to_path_and_back() {
5620 let (mut backend, _rx) = create_test_backend();
5621
5622 #[cfg(not(windows))]
5624 let js_code = r#"
5625 const editor = getEditor();
5626 // Basic file URI to path
5627 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
5628 // Percent-encoded characters
5629 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
5630 // Invalid URI returns empty string
5631 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5632 // Path to file URI
5633 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
5634 // Round-trip
5635 globalThis._roundtrip = editor.fileUriToPath(
5636 editor.pathToFileUri("/home/user/file.txt")
5637 );
5638 "#;
5639
5640 #[cfg(windows)]
5641 let js_code = r#"
5642 const editor = getEditor();
5643 // Windows URI with encoded colon (the bug from issue #1071)
5644 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
5645 // Windows URI with normal colon
5646 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
5647 // Invalid URI returns empty string
5648 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5649 // Path to file URI
5650 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
5651 // Round-trip
5652 globalThis._roundtrip = editor.fileUriToPath(
5653 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
5654 );
5655 "#;
5656
5657 backend.execute_js(js_code, "test.js").unwrap();
5658
5659 backend
5660 .plugin_contexts
5661 .borrow()
5662 .get("test")
5663 .unwrap()
5664 .clone()
5665 .with(|ctx| {
5666 let global = ctx.globals();
5667
5668 #[cfg(not(windows))]
5669 {
5670 assert_eq!(
5671 global.get::<_, String>("_path1").unwrap(),
5672 "/home/user/file.txt"
5673 );
5674 assert_eq!(
5675 global.get::<_, String>("_path2").unwrap(),
5676 "/home/user/my file.txt"
5677 );
5678 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5679 assert_eq!(
5680 global.get::<_, String>("_uri1").unwrap(),
5681 "file:///home/user/file.txt"
5682 );
5683 assert_eq!(
5684 global.get::<_, String>("_roundtrip").unwrap(),
5685 "/home/user/file.txt"
5686 );
5687 }
5688
5689 #[cfg(windows)]
5690 {
5691 assert_eq!(
5693 global.get::<_, String>("_path1").unwrap(),
5694 "C:\\Users\\admin\\Repos\\file.cs"
5695 );
5696 assert_eq!(
5697 global.get::<_, String>("_path2").unwrap(),
5698 "C:\\Users\\admin\\Repos\\file.cs"
5699 );
5700 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5701 assert_eq!(
5702 global.get::<_, String>("_uri1").unwrap(),
5703 "file:///C:/Users/admin/Repos/file.cs"
5704 );
5705 assert_eq!(
5706 global.get::<_, String>("_roundtrip").unwrap(),
5707 "C:\\Users\\admin\\Repos\\file.cs"
5708 );
5709 }
5710 });
5711 }
5712
5713 #[test]
5714 fn test_typescript_transpilation() {
5715 use fresh_parser_js::transpile_typescript;
5716
5717 let (mut backend, rx) = create_test_backend();
5718
5719 let ts_code = r#"
5721 const editor = getEditor();
5722 function greet(name: string): string {
5723 return "Hello, " + name;
5724 }
5725 editor.setStatus(greet("TypeScript"));
5726 "#;
5727
5728 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
5730
5731 backend.execute_js(&js_code, "test.js").unwrap();
5733
5734 let cmd = rx.try_recv().unwrap();
5735 match cmd {
5736 PluginCommand::SetStatus { message } => {
5737 assert_eq!(message, "Hello, TypeScript");
5738 }
5739 _ => panic!("Expected SetStatus, got {:?}", cmd),
5740 }
5741 }
5742
5743 #[test]
5744 fn test_api_get_buffer_text_sends_command() {
5745 let (mut backend, rx) = create_test_backend();
5746
5747 backend
5749 .execute_js(
5750 r#"
5751 const editor = getEditor();
5752 // Store the promise for later
5753 globalThis._textPromise = editor.getBufferText(0, 10, 20);
5754 "#,
5755 "test.js",
5756 )
5757 .unwrap();
5758
5759 let cmd = rx.try_recv().unwrap();
5761 match cmd {
5762 PluginCommand::GetBufferText {
5763 buffer_id,
5764 start,
5765 end,
5766 request_id,
5767 } => {
5768 assert_eq!(buffer_id.0, 0);
5769 assert_eq!(start, 10);
5770 assert_eq!(end, 20);
5771 assert!(request_id > 0); }
5773 _ => panic!("Expected GetBufferText, got {:?}", cmd),
5774 }
5775 }
5776
5777 #[test]
5778 fn test_api_get_buffer_text_resolves_callback() {
5779 let (mut backend, rx) = create_test_backend();
5780
5781 backend
5783 .execute_js(
5784 r#"
5785 const editor = getEditor();
5786 globalThis._resolvedText = null;
5787 editor.getBufferText(0, 0, 100).then(text => {
5788 globalThis._resolvedText = text;
5789 });
5790 "#,
5791 "test.js",
5792 )
5793 .unwrap();
5794
5795 let request_id = match rx.try_recv().unwrap() {
5797 PluginCommand::GetBufferText { request_id, .. } => request_id,
5798 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
5799 };
5800
5801 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
5803
5804 backend
5806 .plugin_contexts
5807 .borrow()
5808 .get("test")
5809 .unwrap()
5810 .clone()
5811 .with(|ctx| {
5812 run_pending_jobs_checked(&ctx, "test async getText");
5813 });
5814
5815 backend
5817 .plugin_contexts
5818 .borrow()
5819 .get("test")
5820 .unwrap()
5821 .clone()
5822 .with(|ctx| {
5823 let global = ctx.globals();
5824 let result: String = global.get("_resolvedText").unwrap();
5825 assert_eq!(result, "hello world");
5826 });
5827 }
5828
5829 #[test]
5830 fn test_plugin_translation() {
5831 let (mut backend, _rx) = create_test_backend();
5832
5833 backend
5835 .execute_js(
5836 r#"
5837 const editor = getEditor();
5838 globalThis._translated = editor.t("test.key");
5839 "#,
5840 "test.js",
5841 )
5842 .unwrap();
5843
5844 backend
5845 .plugin_contexts
5846 .borrow()
5847 .get("test")
5848 .unwrap()
5849 .clone()
5850 .with(|ctx| {
5851 let global = ctx.globals();
5852 let result: String = global.get("_translated").unwrap();
5854 assert_eq!(result, "test.key");
5855 });
5856 }
5857
5858 #[test]
5859 fn test_plugin_translation_with_registered_strings() {
5860 let (mut backend, _rx) = create_test_backend();
5861
5862 let mut en_strings = std::collections::HashMap::new();
5864 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
5865 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
5866
5867 let mut strings = std::collections::HashMap::new();
5868 strings.insert("en".to_string(), en_strings);
5869
5870 if let Some(bridge) = backend
5872 .services
5873 .as_any()
5874 .downcast_ref::<TestServiceBridge>()
5875 {
5876 let mut en = bridge.en_strings.lock().unwrap();
5877 en.insert("greeting".to_string(), "Hello, World!".to_string());
5878 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
5879 }
5880
5881 backend
5883 .execute_js(
5884 r#"
5885 const editor = getEditor();
5886 globalThis._greeting = editor.t("greeting");
5887 globalThis._prompt = editor.t("prompt.find_file");
5888 globalThis._missing = editor.t("nonexistent.key");
5889 "#,
5890 "test.js",
5891 )
5892 .unwrap();
5893
5894 backend
5895 .plugin_contexts
5896 .borrow()
5897 .get("test")
5898 .unwrap()
5899 .clone()
5900 .with(|ctx| {
5901 let global = ctx.globals();
5902 let greeting: String = global.get("_greeting").unwrap();
5903 assert_eq!(greeting, "Hello, World!");
5904
5905 let prompt: String = global.get("_prompt").unwrap();
5906 assert_eq!(prompt, "Find file: ");
5907
5908 let missing: String = global.get("_missing").unwrap();
5910 assert_eq!(missing, "nonexistent.key");
5911 });
5912 }
5913
5914 #[test]
5917 fn test_api_set_line_indicator() {
5918 let (mut backend, rx) = create_test_backend();
5919
5920 backend
5921 .execute_js(
5922 r#"
5923 const editor = getEditor();
5924 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
5925 "#,
5926 "test.js",
5927 )
5928 .unwrap();
5929
5930 let cmd = rx.try_recv().unwrap();
5931 match cmd {
5932 PluginCommand::SetLineIndicator {
5933 buffer_id,
5934 line,
5935 namespace,
5936 symbol,
5937 color,
5938 priority,
5939 } => {
5940 assert_eq!(buffer_id.0, 1);
5941 assert_eq!(line, 5);
5942 assert_eq!(namespace, "test-ns");
5943 assert_eq!(symbol, "●");
5944 assert_eq!(color, (255, 0, 0));
5945 assert_eq!(priority, 10);
5946 }
5947 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
5948 }
5949 }
5950
5951 #[test]
5952 fn test_api_clear_line_indicators() {
5953 let (mut backend, rx) = create_test_backend();
5954
5955 backend
5956 .execute_js(
5957 r#"
5958 const editor = getEditor();
5959 editor.clearLineIndicators(1, "test-ns");
5960 "#,
5961 "test.js",
5962 )
5963 .unwrap();
5964
5965 let cmd = rx.try_recv().unwrap();
5966 match cmd {
5967 PluginCommand::ClearLineIndicators {
5968 buffer_id,
5969 namespace,
5970 } => {
5971 assert_eq!(buffer_id.0, 1);
5972 assert_eq!(namespace, "test-ns");
5973 }
5974 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
5975 }
5976 }
5977
5978 #[test]
5981 fn test_api_create_virtual_buffer_sends_command() {
5982 let (mut backend, rx) = create_test_backend();
5983
5984 backend
5985 .execute_js(
5986 r#"
5987 const editor = getEditor();
5988 editor.createVirtualBuffer({
5989 name: "*Test Buffer*",
5990 mode: "test-mode",
5991 readOnly: true,
5992 entries: [
5993 { text: "Line 1\n", properties: { type: "header" } },
5994 { text: "Line 2\n", properties: { type: "content" } }
5995 ],
5996 showLineNumbers: false,
5997 showCursors: true,
5998 editingDisabled: true
5999 });
6000 "#,
6001 "test.js",
6002 )
6003 .unwrap();
6004
6005 let cmd = rx.try_recv().unwrap();
6006 match cmd {
6007 PluginCommand::CreateVirtualBufferWithContent {
6008 name,
6009 mode,
6010 read_only,
6011 entries,
6012 show_line_numbers,
6013 show_cursors,
6014 editing_disabled,
6015 ..
6016 } => {
6017 assert_eq!(name, "*Test Buffer*");
6018 assert_eq!(mode, "test-mode");
6019 assert!(read_only);
6020 assert_eq!(entries.len(), 2);
6021 assert_eq!(entries[0].text, "Line 1\n");
6022 assert!(!show_line_numbers);
6023 assert!(show_cursors);
6024 assert!(editing_disabled);
6025 }
6026 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6027 }
6028 }
6029
6030 #[test]
6031 fn test_api_set_virtual_buffer_content() {
6032 let (mut backend, rx) = create_test_backend();
6033
6034 backend
6035 .execute_js(
6036 r#"
6037 const editor = getEditor();
6038 editor.setVirtualBufferContent(5, [
6039 { text: "New content\n", properties: { type: "updated" } }
6040 ]);
6041 "#,
6042 "test.js",
6043 )
6044 .unwrap();
6045
6046 let cmd = rx.try_recv().unwrap();
6047 match cmd {
6048 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6049 assert_eq!(buffer_id.0, 5);
6050 assert_eq!(entries.len(), 1);
6051 assert_eq!(entries[0].text, "New content\n");
6052 }
6053 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6054 }
6055 }
6056
6057 #[test]
6060 fn test_api_add_overlay() {
6061 let (mut backend, rx) = create_test_backend();
6062
6063 backend
6064 .execute_js(
6065 r#"
6066 const editor = getEditor();
6067 editor.addOverlay(1, "highlight", 10, 20, {
6068 fg: [255, 128, 0],
6069 bg: [50, 50, 50],
6070 bold: true,
6071 });
6072 "#,
6073 "test.js",
6074 )
6075 .unwrap();
6076
6077 let cmd = rx.try_recv().unwrap();
6078 match cmd {
6079 PluginCommand::AddOverlay {
6080 buffer_id,
6081 namespace,
6082 range,
6083 options,
6084 } => {
6085 use fresh_core::api::OverlayColorSpec;
6086 assert_eq!(buffer_id.0, 1);
6087 assert!(namespace.is_some());
6088 assert_eq!(namespace.unwrap().as_str(), "highlight");
6089 assert_eq!(range, 10..20);
6090 assert!(matches!(
6091 options.fg,
6092 Some(OverlayColorSpec::Rgb(255, 128, 0))
6093 ));
6094 assert!(matches!(
6095 options.bg,
6096 Some(OverlayColorSpec::Rgb(50, 50, 50))
6097 ));
6098 assert!(!options.underline);
6099 assert!(options.bold);
6100 assert!(!options.italic);
6101 assert!(!options.extend_to_line_end);
6102 }
6103 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6104 }
6105 }
6106
6107 #[test]
6108 fn test_api_add_overlay_with_theme_keys() {
6109 let (mut backend, rx) = create_test_backend();
6110
6111 backend
6112 .execute_js(
6113 r#"
6114 const editor = getEditor();
6115 // Test with theme keys for colors
6116 editor.addOverlay(1, "themed", 0, 10, {
6117 fg: "ui.status_bar_fg",
6118 bg: "editor.selection_bg",
6119 });
6120 "#,
6121 "test.js",
6122 )
6123 .unwrap();
6124
6125 let cmd = rx.try_recv().unwrap();
6126 match cmd {
6127 PluginCommand::AddOverlay {
6128 buffer_id,
6129 namespace,
6130 range,
6131 options,
6132 } => {
6133 use fresh_core::api::OverlayColorSpec;
6134 assert_eq!(buffer_id.0, 1);
6135 assert!(namespace.is_some());
6136 assert_eq!(namespace.unwrap().as_str(), "themed");
6137 assert_eq!(range, 0..10);
6138 assert!(matches!(
6139 &options.fg,
6140 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6141 ));
6142 assert!(matches!(
6143 &options.bg,
6144 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6145 ));
6146 assert!(!options.underline);
6147 assert!(!options.bold);
6148 assert!(!options.italic);
6149 assert!(!options.extend_to_line_end);
6150 }
6151 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6152 }
6153 }
6154
6155 #[test]
6156 fn test_api_clear_namespace() {
6157 let (mut backend, rx) = create_test_backend();
6158
6159 backend
6160 .execute_js(
6161 r#"
6162 const editor = getEditor();
6163 editor.clearNamespace(1, "highlight");
6164 "#,
6165 "test.js",
6166 )
6167 .unwrap();
6168
6169 let cmd = rx.try_recv().unwrap();
6170 match cmd {
6171 PluginCommand::ClearNamespace {
6172 buffer_id,
6173 namespace,
6174 } => {
6175 assert_eq!(buffer_id.0, 1);
6176 assert_eq!(namespace.as_str(), "highlight");
6177 }
6178 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6179 }
6180 }
6181
6182 #[test]
6185 fn test_api_get_theme_schema() {
6186 let (mut backend, _rx) = create_test_backend();
6187
6188 backend
6189 .execute_js(
6190 r#"
6191 const editor = getEditor();
6192 const schema = editor.getThemeSchema();
6193 globalThis._isObject = typeof schema === 'object' && schema !== null;
6194 "#,
6195 "test.js",
6196 )
6197 .unwrap();
6198
6199 backend
6200 .plugin_contexts
6201 .borrow()
6202 .get("test")
6203 .unwrap()
6204 .clone()
6205 .with(|ctx| {
6206 let global = ctx.globals();
6207 let is_object: bool = global.get("_isObject").unwrap();
6208 assert!(is_object);
6210 });
6211 }
6212
6213 #[test]
6214 fn test_api_get_builtin_themes() {
6215 let (mut backend, _rx) = create_test_backend();
6216
6217 backend
6218 .execute_js(
6219 r#"
6220 const editor = getEditor();
6221 const themes = editor.getBuiltinThemes();
6222 globalThis._isObject = typeof themes === 'object' && themes !== null;
6223 "#,
6224 "test.js",
6225 )
6226 .unwrap();
6227
6228 backend
6229 .plugin_contexts
6230 .borrow()
6231 .get("test")
6232 .unwrap()
6233 .clone()
6234 .with(|ctx| {
6235 let global = ctx.globals();
6236 let is_object: bool = global.get("_isObject").unwrap();
6237 assert!(is_object);
6239 });
6240 }
6241
6242 #[test]
6243 fn test_api_apply_theme() {
6244 let (mut backend, rx) = create_test_backend();
6245
6246 backend
6247 .execute_js(
6248 r#"
6249 const editor = getEditor();
6250 editor.applyTheme("dark");
6251 "#,
6252 "test.js",
6253 )
6254 .unwrap();
6255
6256 let cmd = rx.try_recv().unwrap();
6257 match cmd {
6258 PluginCommand::ApplyTheme { theme_name } => {
6259 assert_eq!(theme_name, "dark");
6260 }
6261 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6262 }
6263 }
6264
6265 #[test]
6266 fn test_api_get_theme_data_missing() {
6267 let (mut backend, _rx) = create_test_backend();
6268
6269 backend
6270 .execute_js(
6271 r#"
6272 const editor = getEditor();
6273 const data = editor.getThemeData("nonexistent");
6274 globalThis._isNull = data === null;
6275 "#,
6276 "test.js",
6277 )
6278 .unwrap();
6279
6280 backend
6281 .plugin_contexts
6282 .borrow()
6283 .get("test")
6284 .unwrap()
6285 .clone()
6286 .with(|ctx| {
6287 let global = ctx.globals();
6288 let is_null: bool = global.get("_isNull").unwrap();
6289 assert!(is_null);
6291 });
6292 }
6293
6294 #[test]
6295 fn test_api_get_theme_data_present() {
6296 let (tx, _rx) = mpsc::channel();
6298 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6299 let services = Arc::new(ThemeCacheTestBridge {
6300 inner: TestServiceBridge::new(),
6301 });
6302 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6303
6304 backend
6305 .execute_js(
6306 r#"
6307 const editor = getEditor();
6308 const data = editor.getThemeData("test-theme");
6309 globalThis._hasData = data !== null && typeof data === 'object';
6310 globalThis._name = data ? data.name : null;
6311 "#,
6312 "test.js",
6313 )
6314 .unwrap();
6315
6316 backend
6317 .plugin_contexts
6318 .borrow()
6319 .get("test")
6320 .unwrap()
6321 .clone()
6322 .with(|ctx| {
6323 let global = ctx.globals();
6324 let has_data: bool = global.get("_hasData").unwrap();
6325 assert!(has_data, "getThemeData should return theme object");
6326 let name: String = global.get("_name").unwrap();
6327 assert_eq!(name, "test-theme");
6328 });
6329 }
6330
6331 #[test]
6332 fn test_api_theme_file_exists() {
6333 let (mut backend, _rx) = create_test_backend();
6334
6335 backend
6336 .execute_js(
6337 r#"
6338 const editor = getEditor();
6339 globalThis._exists = editor.themeFileExists("anything");
6340 "#,
6341 "test.js",
6342 )
6343 .unwrap();
6344
6345 backend
6346 .plugin_contexts
6347 .borrow()
6348 .get("test")
6349 .unwrap()
6350 .clone()
6351 .with(|ctx| {
6352 let global = ctx.globals();
6353 let exists: bool = global.get("_exists").unwrap();
6354 assert!(!exists);
6356 });
6357 }
6358
6359 #[test]
6360 fn test_api_save_theme_file_error() {
6361 let (mut backend, _rx) = create_test_backend();
6362
6363 backend
6364 .execute_js(
6365 r#"
6366 const editor = getEditor();
6367 let threw = false;
6368 try {
6369 editor.saveThemeFile("test", "{}");
6370 } catch (e) {
6371 threw = true;
6372 }
6373 globalThis._threw = threw;
6374 "#,
6375 "test.js",
6376 )
6377 .unwrap();
6378
6379 backend
6380 .plugin_contexts
6381 .borrow()
6382 .get("test")
6383 .unwrap()
6384 .clone()
6385 .with(|ctx| {
6386 let global = ctx.globals();
6387 let threw: bool = global.get("_threw").unwrap();
6388 assert!(threw);
6390 });
6391 }
6392
6393 struct ThemeCacheTestBridge {
6395 inner: TestServiceBridge,
6396 }
6397
6398 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6399 fn as_any(&self) -> &dyn std::any::Any {
6400 self
6401 }
6402 fn translate(
6403 &self,
6404 plugin_name: &str,
6405 key: &str,
6406 args: &HashMap<String, String>,
6407 ) -> String {
6408 self.inner.translate(plugin_name, key, args)
6409 }
6410 fn current_locale(&self) -> String {
6411 self.inner.current_locale()
6412 }
6413 fn set_js_execution_state(&self, state: String) {
6414 self.inner.set_js_execution_state(state);
6415 }
6416 fn clear_js_execution_state(&self) {
6417 self.inner.clear_js_execution_state();
6418 }
6419 fn get_theme_schema(&self) -> serde_json::Value {
6420 self.inner.get_theme_schema()
6421 }
6422 fn get_builtin_themes(&self) -> serde_json::Value {
6423 self.inner.get_builtin_themes()
6424 }
6425 fn register_command(&self, command: fresh_core::command::Command) {
6426 self.inner.register_command(command);
6427 }
6428 fn unregister_command(&self, name: &str) {
6429 self.inner.unregister_command(name);
6430 }
6431 fn unregister_commands_by_prefix(&self, prefix: &str) {
6432 self.inner.unregister_commands_by_prefix(prefix);
6433 }
6434 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6435 self.inner.unregister_commands_by_plugin(plugin_name);
6436 }
6437 fn plugins_dir(&self) -> std::path::PathBuf {
6438 self.inner.plugins_dir()
6439 }
6440 fn config_dir(&self) -> std::path::PathBuf {
6441 self.inner.config_dir()
6442 }
6443 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6444 if name == "test-theme" {
6445 Some(serde_json::json!({
6446 "name": "test-theme",
6447 "editor": {},
6448 "ui": {},
6449 "syntax": {}
6450 }))
6451 } else {
6452 None
6453 }
6454 }
6455 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6456 Err("test bridge does not support save".to_string())
6457 }
6458 fn theme_file_exists(&self, name: &str) -> bool {
6459 name == "test-theme"
6460 }
6461 }
6462
6463 #[test]
6466 fn test_api_close_buffer() {
6467 let (mut backend, rx) = create_test_backend();
6468
6469 backend
6470 .execute_js(
6471 r#"
6472 const editor = getEditor();
6473 editor.closeBuffer(3);
6474 "#,
6475 "test.js",
6476 )
6477 .unwrap();
6478
6479 let cmd = rx.try_recv().unwrap();
6480 match cmd {
6481 PluginCommand::CloseBuffer { buffer_id } => {
6482 assert_eq!(buffer_id.0, 3);
6483 }
6484 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
6485 }
6486 }
6487
6488 #[test]
6489 fn test_api_focus_split() {
6490 let (mut backend, rx) = create_test_backend();
6491
6492 backend
6493 .execute_js(
6494 r#"
6495 const editor = getEditor();
6496 editor.focusSplit(2);
6497 "#,
6498 "test.js",
6499 )
6500 .unwrap();
6501
6502 let cmd = rx.try_recv().unwrap();
6503 match cmd {
6504 PluginCommand::FocusSplit { split_id } => {
6505 assert_eq!(split_id.0, 2);
6506 }
6507 _ => panic!("Expected FocusSplit, got {:?}", cmd),
6508 }
6509 }
6510
6511 #[test]
6512 fn test_api_list_buffers() {
6513 let (tx, _rx) = mpsc::channel();
6514 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6515
6516 {
6518 let mut state = state_snapshot.write().unwrap();
6519 state.buffers.insert(
6520 BufferId(0),
6521 BufferInfo {
6522 id: BufferId(0),
6523 path: Some(PathBuf::from("/test1.txt")),
6524 modified: false,
6525 length: 100,
6526 is_virtual: false,
6527 view_mode: "source".to_string(),
6528 is_composing_in_any_split: false,
6529 compose_width: None,
6530 language: "text".to_string(),
6531 },
6532 );
6533 state.buffers.insert(
6534 BufferId(1),
6535 BufferInfo {
6536 id: BufferId(1),
6537 path: Some(PathBuf::from("/test2.txt")),
6538 modified: true,
6539 length: 200,
6540 is_virtual: false,
6541 view_mode: "source".to_string(),
6542 is_composing_in_any_split: false,
6543 compose_width: None,
6544 language: "text".to_string(),
6545 },
6546 );
6547 }
6548
6549 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6550 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6551
6552 backend
6553 .execute_js(
6554 r#"
6555 const editor = getEditor();
6556 const buffers = editor.listBuffers();
6557 globalThis._isArray = Array.isArray(buffers);
6558 globalThis._length = buffers.length;
6559 "#,
6560 "test.js",
6561 )
6562 .unwrap();
6563
6564 backend
6565 .plugin_contexts
6566 .borrow()
6567 .get("test")
6568 .unwrap()
6569 .clone()
6570 .with(|ctx| {
6571 let global = ctx.globals();
6572 let is_array: bool = global.get("_isArray").unwrap();
6573 let length: u32 = global.get("_length").unwrap();
6574 assert!(is_array);
6575 assert_eq!(length, 2);
6576 });
6577 }
6578
6579 #[test]
6582 fn test_api_start_prompt() {
6583 let (mut backend, rx) = create_test_backend();
6584
6585 backend
6586 .execute_js(
6587 r#"
6588 const editor = getEditor();
6589 editor.startPrompt("Enter value:", "test-prompt");
6590 "#,
6591 "test.js",
6592 )
6593 .unwrap();
6594
6595 let cmd = rx.try_recv().unwrap();
6596 match cmd {
6597 PluginCommand::StartPrompt { label, prompt_type } => {
6598 assert_eq!(label, "Enter value:");
6599 assert_eq!(prompt_type, "test-prompt");
6600 }
6601 _ => panic!("Expected StartPrompt, got {:?}", cmd),
6602 }
6603 }
6604
6605 #[test]
6606 fn test_api_start_prompt_with_initial() {
6607 let (mut backend, rx) = create_test_backend();
6608
6609 backend
6610 .execute_js(
6611 r#"
6612 const editor = getEditor();
6613 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
6614 "#,
6615 "test.js",
6616 )
6617 .unwrap();
6618
6619 let cmd = rx.try_recv().unwrap();
6620 match cmd {
6621 PluginCommand::StartPromptWithInitial {
6622 label,
6623 prompt_type,
6624 initial_value,
6625 } => {
6626 assert_eq!(label, "Enter value:");
6627 assert_eq!(prompt_type, "test-prompt");
6628 assert_eq!(initial_value, "default");
6629 }
6630 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
6631 }
6632 }
6633
6634 #[test]
6635 fn test_api_set_prompt_suggestions() {
6636 let (mut backend, rx) = create_test_backend();
6637
6638 backend
6639 .execute_js(
6640 r#"
6641 const editor = getEditor();
6642 editor.setPromptSuggestions([
6643 { text: "Option 1", value: "opt1" },
6644 { text: "Option 2", value: "opt2" }
6645 ]);
6646 "#,
6647 "test.js",
6648 )
6649 .unwrap();
6650
6651 let cmd = rx.try_recv().unwrap();
6652 match cmd {
6653 PluginCommand::SetPromptSuggestions { suggestions } => {
6654 assert_eq!(suggestions.len(), 2);
6655 assert_eq!(suggestions[0].text, "Option 1");
6656 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
6657 }
6658 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
6659 }
6660 }
6661
6662 #[test]
6665 fn test_api_get_active_buffer_id() {
6666 let (tx, _rx) = mpsc::channel();
6667 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6668
6669 {
6670 let mut state = state_snapshot.write().unwrap();
6671 state.active_buffer_id = BufferId(42);
6672 }
6673
6674 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6675 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6676
6677 backend
6678 .execute_js(
6679 r#"
6680 const editor = getEditor();
6681 globalThis._activeId = editor.getActiveBufferId();
6682 "#,
6683 "test.js",
6684 )
6685 .unwrap();
6686
6687 backend
6688 .plugin_contexts
6689 .borrow()
6690 .get("test")
6691 .unwrap()
6692 .clone()
6693 .with(|ctx| {
6694 let global = ctx.globals();
6695 let result: u32 = global.get("_activeId").unwrap();
6696 assert_eq!(result, 42);
6697 });
6698 }
6699
6700 #[test]
6701 fn test_api_get_active_split_id() {
6702 let (tx, _rx) = mpsc::channel();
6703 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6704
6705 {
6706 let mut state = state_snapshot.write().unwrap();
6707 state.active_split_id = 7;
6708 }
6709
6710 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6711 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6712
6713 backend
6714 .execute_js(
6715 r#"
6716 const editor = getEditor();
6717 globalThis._splitId = editor.getActiveSplitId();
6718 "#,
6719 "test.js",
6720 )
6721 .unwrap();
6722
6723 backend
6724 .plugin_contexts
6725 .borrow()
6726 .get("test")
6727 .unwrap()
6728 .clone()
6729 .with(|ctx| {
6730 let global = ctx.globals();
6731 let result: u32 = global.get("_splitId").unwrap();
6732 assert_eq!(result, 7);
6733 });
6734 }
6735
6736 #[test]
6739 fn test_api_file_exists() {
6740 let (mut backend, _rx) = create_test_backend();
6741
6742 backend
6743 .execute_js(
6744 r#"
6745 const editor = getEditor();
6746 // Test with a path that definitely exists
6747 globalThis._exists = editor.fileExists("/");
6748 "#,
6749 "test.js",
6750 )
6751 .unwrap();
6752
6753 backend
6754 .plugin_contexts
6755 .borrow()
6756 .get("test")
6757 .unwrap()
6758 .clone()
6759 .with(|ctx| {
6760 let global = ctx.globals();
6761 let result: bool = global.get("_exists").unwrap();
6762 assert!(result);
6763 });
6764 }
6765
6766 #[test]
6767 fn test_api_get_cwd() {
6768 let (mut backend, _rx) = create_test_backend();
6769
6770 backend
6771 .execute_js(
6772 r#"
6773 const editor = getEditor();
6774 globalThis._cwd = editor.getCwd();
6775 "#,
6776 "test.js",
6777 )
6778 .unwrap();
6779
6780 backend
6781 .plugin_contexts
6782 .borrow()
6783 .get("test")
6784 .unwrap()
6785 .clone()
6786 .with(|ctx| {
6787 let global = ctx.globals();
6788 let result: String = global.get("_cwd").unwrap();
6789 assert!(!result.is_empty());
6791 });
6792 }
6793
6794 #[test]
6795 fn test_api_get_env() {
6796 let (mut backend, _rx) = create_test_backend();
6797
6798 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
6800
6801 backend
6802 .execute_js(
6803 r#"
6804 const editor = getEditor();
6805 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
6806 "#,
6807 "test.js",
6808 )
6809 .unwrap();
6810
6811 backend
6812 .plugin_contexts
6813 .borrow()
6814 .get("test")
6815 .unwrap()
6816 .clone()
6817 .with(|ctx| {
6818 let global = ctx.globals();
6819 let result: Option<String> = global.get("_envVal").unwrap();
6820 assert_eq!(result, Some("test_value".to_string()));
6821 });
6822
6823 std::env::remove_var("TEST_PLUGIN_VAR");
6824 }
6825
6826 #[test]
6827 fn test_api_get_config() {
6828 let (mut backend, _rx) = create_test_backend();
6829
6830 backend
6831 .execute_js(
6832 r#"
6833 const editor = getEditor();
6834 const config = editor.getConfig();
6835 globalThis._isObject = typeof config === 'object';
6836 "#,
6837 "test.js",
6838 )
6839 .unwrap();
6840
6841 backend
6842 .plugin_contexts
6843 .borrow()
6844 .get("test")
6845 .unwrap()
6846 .clone()
6847 .with(|ctx| {
6848 let global = ctx.globals();
6849 let is_object: bool = global.get("_isObject").unwrap();
6850 assert!(is_object);
6852 });
6853 }
6854
6855 #[test]
6856 fn test_api_get_themes_dir() {
6857 let (mut backend, _rx) = create_test_backend();
6858
6859 backend
6860 .execute_js(
6861 r#"
6862 const editor = getEditor();
6863 globalThis._themesDir = editor.getThemesDir();
6864 "#,
6865 "test.js",
6866 )
6867 .unwrap();
6868
6869 backend
6870 .plugin_contexts
6871 .borrow()
6872 .get("test")
6873 .unwrap()
6874 .clone()
6875 .with(|ctx| {
6876 let global = ctx.globals();
6877 let result: String = global.get("_themesDir").unwrap();
6878 assert!(!result.is_empty());
6880 });
6881 }
6882
6883 #[test]
6886 fn test_api_read_dir() {
6887 let (mut backend, _rx) = create_test_backend();
6888
6889 backend
6890 .execute_js(
6891 r#"
6892 const editor = getEditor();
6893 const entries = editor.readDir("/tmp");
6894 globalThis._isArray = Array.isArray(entries);
6895 globalThis._length = entries.length;
6896 "#,
6897 "test.js",
6898 )
6899 .unwrap();
6900
6901 backend
6902 .plugin_contexts
6903 .borrow()
6904 .get("test")
6905 .unwrap()
6906 .clone()
6907 .with(|ctx| {
6908 let global = ctx.globals();
6909 let is_array: bool = global.get("_isArray").unwrap();
6910 let length: u32 = global.get("_length").unwrap();
6911 assert!(is_array);
6913 let _ = length;
6915 });
6916 }
6917
6918 #[test]
6921 fn test_api_execute_action() {
6922 let (mut backend, rx) = create_test_backend();
6923
6924 backend
6925 .execute_js(
6926 r#"
6927 const editor = getEditor();
6928 editor.executeAction("move_cursor_up");
6929 "#,
6930 "test.js",
6931 )
6932 .unwrap();
6933
6934 let cmd = rx.try_recv().unwrap();
6935 match cmd {
6936 PluginCommand::ExecuteAction { action_name } => {
6937 assert_eq!(action_name, "move_cursor_up");
6938 }
6939 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
6940 }
6941 }
6942
6943 #[test]
6946 fn test_api_debug() {
6947 let (mut backend, _rx) = create_test_backend();
6948
6949 backend
6951 .execute_js(
6952 r#"
6953 const editor = getEditor();
6954 editor.debug("Test debug message");
6955 editor.debug("Another message with special chars: <>&\"'");
6956 "#,
6957 "test.js",
6958 )
6959 .unwrap();
6960 }
6962
6963 #[test]
6966 fn test_typescript_preamble_generated() {
6967 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
6969 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
6970 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
6971 println!(
6972 "Generated {} bytes of TypeScript preamble",
6973 JSEDITORAPI_TS_PREAMBLE.len()
6974 );
6975 }
6976
6977 #[test]
6978 fn test_typescript_editor_api_generated() {
6979 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
6981 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
6982 println!(
6983 "Generated {} bytes of EditorAPI interface",
6984 JSEDITORAPI_TS_EDITOR_API.len()
6985 );
6986 }
6987
6988 #[test]
6989 fn test_js_methods_list() {
6990 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
6992 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
6993 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
6995 if i < 20 {
6996 println!(" - {}", method);
6997 }
6998 }
6999 if JSEDITORAPI_JS_METHODS.len() > 20 {
7000 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7001 }
7002 }
7003
7004 #[test]
7007 fn test_api_load_plugin_sends_command() {
7008 let (mut backend, rx) = create_test_backend();
7009
7010 backend
7012 .execute_js(
7013 r#"
7014 const editor = getEditor();
7015 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7016 "#,
7017 "test.js",
7018 )
7019 .unwrap();
7020
7021 let cmd = rx.try_recv().unwrap();
7023 match cmd {
7024 PluginCommand::LoadPlugin { path, callback_id } => {
7025 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7026 assert!(callback_id.0 > 0); }
7028 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7029 }
7030 }
7031
7032 #[test]
7033 fn test_api_unload_plugin_sends_command() {
7034 let (mut backend, rx) = create_test_backend();
7035
7036 backend
7038 .execute_js(
7039 r#"
7040 const editor = getEditor();
7041 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7042 "#,
7043 "test.js",
7044 )
7045 .unwrap();
7046
7047 let cmd = rx.try_recv().unwrap();
7049 match cmd {
7050 PluginCommand::UnloadPlugin { name, callback_id } => {
7051 assert_eq!(name, "my-plugin");
7052 assert!(callback_id.0 > 0); }
7054 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7055 }
7056 }
7057
7058 #[test]
7059 fn test_api_reload_plugin_sends_command() {
7060 let (mut backend, rx) = create_test_backend();
7061
7062 backend
7064 .execute_js(
7065 r#"
7066 const editor = getEditor();
7067 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7068 "#,
7069 "test.js",
7070 )
7071 .unwrap();
7072
7073 let cmd = rx.try_recv().unwrap();
7075 match cmd {
7076 PluginCommand::ReloadPlugin { name, callback_id } => {
7077 assert_eq!(name, "my-plugin");
7078 assert!(callback_id.0 > 0); }
7080 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7081 }
7082 }
7083
7084 #[test]
7085 fn test_api_load_plugin_resolves_callback() {
7086 let (mut backend, rx) = create_test_backend();
7087
7088 backend
7090 .execute_js(
7091 r#"
7092 const editor = getEditor();
7093 globalThis._loadResult = null;
7094 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7095 globalThis._loadResult = result;
7096 });
7097 "#,
7098 "test.js",
7099 )
7100 .unwrap();
7101
7102 let callback_id = match rx.try_recv().unwrap() {
7104 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7105 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7106 };
7107
7108 backend.resolve_callback(callback_id, "true");
7110
7111 backend
7113 .plugin_contexts
7114 .borrow()
7115 .get("test")
7116 .unwrap()
7117 .clone()
7118 .with(|ctx| {
7119 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7120 });
7121
7122 backend
7124 .plugin_contexts
7125 .borrow()
7126 .get("test")
7127 .unwrap()
7128 .clone()
7129 .with(|ctx| {
7130 let global = ctx.globals();
7131 let result: bool = global.get("_loadResult").unwrap();
7132 assert!(result);
7133 });
7134 }
7135
7136 #[test]
7137 fn test_api_version() {
7138 let (mut backend, _rx) = create_test_backend();
7139
7140 backend
7141 .execute_js(
7142 r#"
7143 const editor = getEditor();
7144 globalThis._apiVersion = editor.apiVersion();
7145 "#,
7146 "test.js",
7147 )
7148 .unwrap();
7149
7150 backend
7151 .plugin_contexts
7152 .borrow()
7153 .get("test")
7154 .unwrap()
7155 .clone()
7156 .with(|ctx| {
7157 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7158 assert_eq!(version, 2);
7159 });
7160 }
7161
7162 #[test]
7163 fn test_api_unload_plugin_rejects_on_error() {
7164 let (mut backend, rx) = create_test_backend();
7165
7166 backend
7168 .execute_js(
7169 r#"
7170 const editor = getEditor();
7171 globalThis._unloadError = null;
7172 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7173 globalThis._unloadError = err.message || String(err);
7174 });
7175 "#,
7176 "test.js",
7177 )
7178 .unwrap();
7179
7180 let callback_id = match rx.try_recv().unwrap() {
7182 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7183 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7184 };
7185
7186 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7188
7189 backend
7191 .plugin_contexts
7192 .borrow()
7193 .get("test")
7194 .unwrap()
7195 .clone()
7196 .with(|ctx| {
7197 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7198 });
7199
7200 backend
7202 .plugin_contexts
7203 .borrow()
7204 .get("test")
7205 .unwrap()
7206 .clone()
7207 .with(|ctx| {
7208 let global = ctx.globals();
7209 let error: String = global.get("_unloadError").unwrap();
7210 assert!(error.contains("nonexistent-plugin"));
7211 });
7212 }
7213
7214 #[test]
7215 fn test_api_set_global_state() {
7216 let (mut backend, rx) = create_test_backend();
7217
7218 backend
7219 .execute_js(
7220 r#"
7221 const editor = getEditor();
7222 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7223 "#,
7224 "test_plugin.js",
7225 )
7226 .unwrap();
7227
7228 let cmd = rx.try_recv().unwrap();
7229 match cmd {
7230 PluginCommand::SetGlobalState {
7231 plugin_name,
7232 key,
7233 value,
7234 } => {
7235 assert_eq!(plugin_name, "test_plugin");
7236 assert_eq!(key, "myKey");
7237 let v = value.unwrap();
7238 assert_eq!(v["enabled"], serde_json::json!(true));
7239 assert_eq!(v["count"], serde_json::json!(42));
7240 }
7241 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7242 }
7243 }
7244
7245 #[test]
7246 fn test_api_set_global_state_delete() {
7247 let (mut backend, rx) = create_test_backend();
7248
7249 backend
7250 .execute_js(
7251 r#"
7252 const editor = getEditor();
7253 editor.setGlobalState("myKey", null);
7254 "#,
7255 "test_plugin.js",
7256 )
7257 .unwrap();
7258
7259 let cmd = rx.try_recv().unwrap();
7260 match cmd {
7261 PluginCommand::SetGlobalState {
7262 plugin_name,
7263 key,
7264 value,
7265 } => {
7266 assert_eq!(plugin_name, "test_plugin");
7267 assert_eq!(key, "myKey");
7268 assert!(value.is_none(), "null should delete the key");
7269 }
7270 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7271 }
7272 }
7273
7274 #[test]
7275 fn test_api_get_global_state_roundtrip() {
7276 let (mut backend, _rx) = create_test_backend();
7277
7278 backend
7280 .execute_js(
7281 r#"
7282 const editor = getEditor();
7283 editor.setGlobalState("flag", true);
7284 globalThis._result = editor.getGlobalState("flag");
7285 "#,
7286 "test_plugin.js",
7287 )
7288 .unwrap();
7289
7290 backend
7291 .plugin_contexts
7292 .borrow()
7293 .get("test_plugin")
7294 .unwrap()
7295 .clone()
7296 .with(|ctx| {
7297 let global = ctx.globals();
7298 let result: bool = global.get("_result").unwrap();
7299 assert!(
7300 result,
7301 "getGlobalState should return the value set by setGlobalState"
7302 );
7303 });
7304 }
7305
7306 #[test]
7307 fn test_api_get_global_state_missing_key() {
7308 let (mut backend, _rx) = create_test_backend();
7309
7310 backend
7311 .execute_js(
7312 r#"
7313 const editor = getEditor();
7314 globalThis._result = editor.getGlobalState("nonexistent");
7315 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7316 "#,
7317 "test_plugin.js",
7318 )
7319 .unwrap();
7320
7321 backend
7322 .plugin_contexts
7323 .borrow()
7324 .get("test_plugin")
7325 .unwrap()
7326 .clone()
7327 .with(|ctx| {
7328 let global = ctx.globals();
7329 let is_undefined: bool = global.get("_isUndefined").unwrap();
7330 assert!(
7331 is_undefined,
7332 "getGlobalState for missing key should return undefined"
7333 );
7334 });
7335 }
7336
7337 #[test]
7338 fn test_api_global_state_isolation_between_plugins() {
7339 let (tx, _rx) = mpsc::channel();
7341 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7342 let services = Arc::new(TestServiceBridge::new());
7343
7344 let mut backend_a =
7346 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7347 .unwrap();
7348 backend_a
7349 .execute_js(
7350 r#"
7351 const editor = getEditor();
7352 editor.setGlobalState("flag", "from_plugin_a");
7353 "#,
7354 "plugin_a.js",
7355 )
7356 .unwrap();
7357
7358 let mut backend_b =
7360 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7361 .unwrap();
7362 backend_b
7363 .execute_js(
7364 r#"
7365 const editor = getEditor();
7366 editor.setGlobalState("flag", "from_plugin_b");
7367 "#,
7368 "plugin_b.js",
7369 )
7370 .unwrap();
7371
7372 backend_a
7374 .execute_js(
7375 r#"
7376 const editor = getEditor();
7377 globalThis._aValue = editor.getGlobalState("flag");
7378 "#,
7379 "plugin_a.js",
7380 )
7381 .unwrap();
7382
7383 backend_a
7384 .plugin_contexts
7385 .borrow()
7386 .get("plugin_a")
7387 .unwrap()
7388 .clone()
7389 .with(|ctx| {
7390 let global = ctx.globals();
7391 let a_value: String = global.get("_aValue").unwrap();
7392 assert_eq!(
7393 a_value, "from_plugin_a",
7394 "Plugin A should see its own value, not plugin B's"
7395 );
7396 });
7397
7398 backend_b
7400 .execute_js(
7401 r#"
7402 const editor = getEditor();
7403 globalThis._bValue = editor.getGlobalState("flag");
7404 "#,
7405 "plugin_b.js",
7406 )
7407 .unwrap();
7408
7409 backend_b
7410 .plugin_contexts
7411 .borrow()
7412 .get("plugin_b")
7413 .unwrap()
7414 .clone()
7415 .with(|ctx| {
7416 let global = ctx.globals();
7417 let b_value: String = global.get("_bValue").unwrap();
7418 assert_eq!(
7419 b_value, "from_plugin_b",
7420 "Plugin B should see its own value, not plugin A's"
7421 );
7422 });
7423 }
7424}