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