1use anyhow::{anyhow, Result};
7use fresh_core::api::{
8 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
9 JsCallbackId, PluginCommand, PluginResponse,
10};
11use fresh_core::command::Command;
12use fresh_core::overlay::OverlayNamespace;
13use fresh_core::text_property::TextPropertyEntry;
14use fresh_core::{BufferId, SplitId};
15use fresh_parser_js::{
16 bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
17 transpile_typescript,
18};
19use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
20use rquickjs::{Context, Function, Object, Runtime, Value};
21use std::cell::RefCell;
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24use std::rc::Rc;
25use std::sync::{mpsc, Arc, RwLock};
26
27fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
29 use rquickjs::Type;
30 match val.type_of() {
31 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
32 Type::Bool => val
33 .as_bool()
34 .map(serde_json::Value::Bool)
35 .unwrap_or(serde_json::Value::Null),
36 Type::Int => val
37 .as_int()
38 .map(|n| serde_json::Value::Number(n.into()))
39 .unwrap_or(serde_json::Value::Null),
40 Type::Float => val
41 .as_float()
42 .and_then(serde_json::Number::from_f64)
43 .map(serde_json::Value::Number)
44 .unwrap_or(serde_json::Value::Null),
45 Type::String => val
46 .as_string()
47 .and_then(|s| s.to_string().ok())
48 .map(serde_json::Value::String)
49 .unwrap_or(serde_json::Value::Null),
50 Type::Array => {
51 if let Some(arr) = val.as_array() {
52 let items: Vec<serde_json::Value> = arr
53 .iter()
54 .filter_map(|item| item.ok())
55 .map(|item| js_to_json(ctx, item))
56 .collect();
57 serde_json::Value::Array(items)
58 } else {
59 serde_json::Value::Null
60 }
61 }
62 Type::Object | Type::Constructor | Type::Function => {
63 if let Some(obj) = val.as_object() {
64 let mut map = serde_json::Map::new();
65 for key in obj.keys::<String>().flatten() {
66 if let Ok(v) = obj.get::<_, Value>(&key) {
67 map.insert(key, js_to_json(ctx, v));
68 }
69 }
70 serde_json::Value::Object(map)
71 } else {
72 serde_json::Value::Null
73 }
74 }
75 _ => serde_json::Value::Null,
76 }
77}
78
79fn get_text_properties_at_cursor_typed(
81 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
82 buffer_id: u32,
83) -> fresh_core::api::TextPropertiesAtCursor {
84 use fresh_core::api::TextPropertiesAtCursor;
85
86 let snap = match snapshot.read() {
87 Ok(s) => s,
88 Err(_) => return TextPropertiesAtCursor(Vec::new()),
89 };
90 let buffer_id_typed = BufferId(buffer_id as usize);
91 let cursor_pos = match snap
92 .buffer_cursor_positions
93 .get(&buffer_id_typed)
94 .copied()
95 .or_else(|| {
96 if snap.active_buffer_id == buffer_id_typed {
97 snap.primary_cursor.as_ref().map(|c| c.position)
98 } else {
99 None
100 }
101 }) {
102 Some(pos) => pos,
103 None => return TextPropertiesAtCursor(Vec::new()),
104 };
105
106 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
107 Some(p) => p,
108 None => return TextPropertiesAtCursor(Vec::new()),
109 };
110
111 let result: Vec<_> = properties
113 .iter()
114 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
115 .map(|prop| prop.properties.clone())
116 .collect();
117
118 TextPropertiesAtCursor(result)
119}
120
121fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
123 use rquickjs::Type;
124 match val.type_of() {
125 Type::Null => "null".to_string(),
126 Type::Undefined => "undefined".to_string(),
127 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
128 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
129 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
130 Type::String => val
131 .as_string()
132 .and_then(|s| s.to_string().ok())
133 .unwrap_or_default(),
134 Type::Object | Type::Exception => {
135 if let Some(obj) = val.as_object() {
137 let name: Option<String> = obj.get("name").ok();
139 let message: Option<String> = obj.get("message").ok();
140 let stack: Option<String> = obj.get("stack").ok();
141
142 if message.is_some() || name.is_some() {
143 let name = name.unwrap_or_else(|| "Error".to_string());
145 let message = message.unwrap_or_default();
146 if let Some(stack) = stack {
147 return format!("{}: {}\n{}", name, message, stack);
148 } else {
149 return format!("{}: {}", name, message);
150 }
151 }
152
153 let json = js_to_json(ctx, val.clone());
155 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
156 } else {
157 "[object]".to_string()
158 }
159 }
160 Type::Array => {
161 let json = js_to_json(ctx, val.clone());
162 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
163 }
164 Type::Function | Type::Constructor => "[function]".to_string(),
165 Type::Symbol => "[symbol]".to_string(),
166 Type::BigInt => val
167 .as_big_int()
168 .and_then(|b| b.clone().to_i64().ok())
169 .map(|n| n.to_string())
170 .unwrap_or_else(|| "[bigint]".to_string()),
171 _ => format!("[{}]", val.type_name()),
172 }
173}
174
175fn format_js_error(
177 ctx: &rquickjs::Ctx<'_>,
178 err: rquickjs::Error,
179 source_name: &str,
180) -> anyhow::Error {
181 if err.is_exception() {
183 let exc = ctx.catch();
185 if !exc.is_undefined() && !exc.is_null() {
186 if let Some(exc_obj) = exc.as_object() {
188 let message: String = exc_obj
189 .get::<_, String>("message")
190 .unwrap_or_else(|_| "Unknown error".to_string());
191 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
192 let name: String = exc_obj
193 .get::<_, String>("name")
194 .unwrap_or_else(|_| "Error".to_string());
195
196 if !stack.is_empty() {
197 return anyhow::anyhow!(
198 "JS error in {}: {}: {}\nStack trace:\n{}",
199 source_name,
200 name,
201 message,
202 stack
203 );
204 } else {
205 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
206 }
207 } else {
208 let exc_str: String = exc
210 .as_string()
211 .and_then(|s: &rquickjs::String| s.to_string().ok())
212 .unwrap_or_else(|| format!("{:?}", exc));
213 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
214 }
215 }
216 }
217
218 anyhow::anyhow!("JS error in {}: {}", source_name, err)
220}
221
222fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
225 let error = format_js_error(ctx, err, context);
226 tracing::error!("{}", error);
227
228 if should_panic_on_js_errors() {
230 panic!("JavaScript error in {}: {}", context, error);
231 }
232}
233
234static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
236 std::sync::atomic::AtomicBool::new(false);
237
238pub fn set_panic_on_js_errors(enabled: bool) {
240 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
241}
242
243fn should_panic_on_js_errors() -> bool {
245 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
246}
247
248static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
252
253static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
255
256fn set_fatal_js_error(msg: String) {
258 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
259 if guard.is_none() {
260 *guard = Some(msg);
262 }
263 }
264 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
265}
266
267pub fn has_fatal_js_error() -> bool {
269 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
270}
271
272pub fn take_fatal_js_error() -> Option<String> {
274 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
275 return None;
276 }
277 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
278 guard.take()
279 } else {
280 Some("Fatal JS error (message unavailable)".to_string())
281 }
282}
283
284fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
287 let mut count = 0;
288 loop {
289 let exc: rquickjs::Value = ctx.catch();
291 if exc.is_exception() {
293 let error_msg = if let Some(err) = exc.as_exception() {
294 format!(
295 "{}: {}",
296 err.message().unwrap_or_default(),
297 err.stack().unwrap_or_default()
298 )
299 } else {
300 format!("{:?}", exc)
301 };
302 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
303 if should_panic_on_js_errors() {
304 panic!("Unhandled JS exception during {}: {}", context, error_msg);
305 }
306 }
307
308 if !ctx.execute_pending_job() {
309 break;
310 }
311 count += 1;
312 }
313
314 let exc: rquickjs::Value = ctx.catch();
316 if exc.is_exception() {
317 let error_msg = if let Some(err) = exc.as_exception() {
318 format!(
319 "{}: {}",
320 err.message().unwrap_or_default(),
321 err.stack().unwrap_or_default()
322 )
323 } else {
324 format!("{:?}", exc)
325 };
326 tracing::error!(
327 "Unhandled JS exception after running jobs in {}: {}",
328 context,
329 error_msg
330 );
331 if should_panic_on_js_errors() {
332 panic!(
333 "Unhandled JS exception after running jobs in {}: {}",
334 context, error_msg
335 );
336 }
337 }
338
339 count
340}
341
342fn parse_text_property_entry(
344 ctx: &rquickjs::Ctx<'_>,
345 obj: &Object<'_>,
346) -> Option<TextPropertyEntry> {
347 let text: String = obj.get("text").ok()?;
348 let properties: HashMap<String, serde_json::Value> = obj
349 .get::<_, Object>("properties")
350 .ok()
351 .map(|props_obj| {
352 let mut map = HashMap::new();
353 for key in props_obj.keys::<String>().flatten() {
354 if let Ok(v) = props_obj.get::<_, Value>(&key) {
355 map.insert(key, js_to_json(ctx, v));
356 }
357 }
358 map
359 })
360 .unwrap_or_default();
361 Some(TextPropertyEntry { text, properties })
362}
363
364pub type PendingResponses =
366 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
367
368#[derive(Debug, Clone)]
370pub struct TsPluginInfo {
371 pub name: String,
372 pub path: PathBuf,
373 pub enabled: bool,
374}
375
376#[derive(Debug, Clone)]
378pub struct PluginHandler {
379 pub plugin_name: String,
380 pub handler_name: String,
381}
382
383#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
386#[rquickjs::class]
387pub struct JsEditorApi {
388 #[qjs(skip_trace)]
389 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
390 #[qjs(skip_trace)]
391 command_sender: mpsc::Sender<PluginCommand>,
392 #[qjs(skip_trace)]
393 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
394 #[qjs(skip_trace)]
395 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
396 #[qjs(skip_trace)]
397 next_request_id: Rc<RefCell<u64>>,
398 #[qjs(skip_trace)]
399 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
400 #[qjs(skip_trace)]
401 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
402 pub plugin_name: String,
403}
404
405#[plugin_api_impl]
406#[rquickjs::methods(rename_all = "camelCase")]
407impl JsEditorApi {
408 pub fn get_active_buffer_id(&self) -> u32 {
412 self.state_snapshot
413 .read()
414 .map(|s| s.active_buffer_id.0 as u32)
415 .unwrap_or(0)
416 }
417
418 pub fn get_active_split_id(&self) -> u32 {
420 self.state_snapshot
421 .read()
422 .map(|s| s.active_split_id as u32)
423 .unwrap_or(0)
424 }
425
426 #[plugin_api(ts_return = "BufferInfo[]")]
428 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
429 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
430 s.buffers.values().cloned().collect()
431 } else {
432 Vec::new()
433 };
434 rquickjs_serde::to_value(ctx, &buffers)
435 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
436 }
437
438 pub fn debug(&self, msg: String) {
441 tracing::info!("Plugin.debug: {}", msg);
442 }
443
444 pub fn info(&self, msg: String) {
445 tracing::info!("Plugin: {}", msg);
446 }
447
448 pub fn warn(&self, msg: String) {
449 tracing::warn!("Plugin: {}", msg);
450 }
451
452 pub fn error(&self, msg: String) {
453 tracing::error!("Plugin: {}", msg);
454 }
455
456 pub fn set_status(&self, msg: String) {
459 let _ = self
460 .command_sender
461 .send(PluginCommand::SetStatus { message: msg });
462 }
463
464 pub fn copy_to_clipboard(&self, text: String) {
467 let _ = self
468 .command_sender
469 .send(PluginCommand::SetClipboard { text });
470 }
471
472 pub fn set_clipboard(&self, text: String) {
473 let _ = self
474 .command_sender
475 .send(PluginCommand::SetClipboard { text });
476 }
477
478 pub fn register_command<'js>(
483 &self,
484 _ctx: rquickjs::Ctx<'js>,
485 name: String,
486 description: String,
487 handler_name: String,
488 context: rquickjs::function::Opt<rquickjs::Value<'js>>,
489 ) -> rquickjs::Result<bool> {
490 let plugin_name = self.plugin_name.clone();
492 let context_str: Option<String> = context.0.and_then(|v| {
494 if v.is_null() || v.is_undefined() {
495 None
496 } else {
497 v.as_string().and_then(|s| s.to_string().ok())
498 }
499 });
500
501 tracing::debug!(
502 "registerCommand: plugin='{}', name='{}', handler='{}'",
503 plugin_name,
504 name,
505 handler_name
506 );
507
508 self.registered_actions.borrow_mut().insert(
510 handler_name.clone(),
511 PluginHandler {
512 plugin_name: self.plugin_name.clone(),
513 handler_name: handler_name.clone(),
514 },
515 );
516
517 let command = Command {
519 name: name.clone(),
520 description,
521 action_name: handler_name,
522 plugin_name,
523 custom_contexts: context_str.into_iter().collect(),
524 };
525
526 Ok(self
527 .command_sender
528 .send(PluginCommand::RegisterCommand { command })
529 .is_ok())
530 }
531
532 pub fn unregister_command(&self, name: String) -> bool {
534 self.command_sender
535 .send(PluginCommand::UnregisterCommand { name })
536 .is_ok()
537 }
538
539 pub fn set_context(&self, name: String, active: bool) -> bool {
541 self.command_sender
542 .send(PluginCommand::SetContext { name, active })
543 .is_ok()
544 }
545
546 pub fn execute_action(&self, action_name: String) -> bool {
548 self.command_sender
549 .send(PluginCommand::ExecuteAction { action_name })
550 .is_ok()
551 }
552
553 pub fn t<'js>(
558 &self,
559 _ctx: rquickjs::Ctx<'js>,
560 key: String,
561 args: rquickjs::function::Rest<Value<'js>>,
562 ) -> String {
563 let plugin_name = self.plugin_name.clone();
565 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
567 if let Some(obj) = first_arg.as_object() {
568 let mut map = HashMap::new();
569 for k in obj.keys::<String>().flatten() {
570 if let Ok(v) = obj.get::<_, String>(&k) {
571 map.insert(k, v);
572 }
573 }
574 map
575 } else {
576 HashMap::new()
577 }
578 } else {
579 HashMap::new()
580 };
581 let res = self.services.translate(&plugin_name, &key, &args_map);
582
583 tracing::info!(
584 "Translating: key={}, plugin={}, args={:?} => res='{}'",
585 key,
586 plugin_name,
587 args_map,
588 res
589 );
590 res
591 }
592
593 pub fn get_cursor_position(&self) -> u32 {
597 self.state_snapshot
598 .read()
599 .ok()
600 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
601 .unwrap_or(0)
602 }
603
604 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
606 if let Ok(s) = self.state_snapshot.read() {
607 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
608 if let Some(p) = &b.path {
609 return p.to_string_lossy().to_string();
610 }
611 }
612 }
613 String::new()
614 }
615
616 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
618 if let Ok(s) = self.state_snapshot.read() {
619 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
620 return b.length as u32;
621 }
622 }
623 0
624 }
625
626 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
628 if let Ok(s) = self.state_snapshot.read() {
629 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
630 return b.modified;
631 }
632 }
633 false
634 }
635
636 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
639 self.command_sender
640 .send(PluginCommand::SaveBufferToPath {
641 buffer_id: BufferId(buffer_id as usize),
642 path: std::path::PathBuf::from(path),
643 })
644 .is_ok()
645 }
646
647 #[plugin_api(ts_return = "BufferInfo | null")]
649 pub fn get_buffer_info<'js>(
650 &self,
651 ctx: rquickjs::Ctx<'js>,
652 buffer_id: u32,
653 ) -> rquickjs::Result<Value<'js>> {
654 let info = if let Ok(s) = self.state_snapshot.read() {
655 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
656 } else {
657 None
658 };
659 rquickjs_serde::to_value(ctx, &info)
660 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
661 }
662
663 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
665 let cursor = if let Ok(s) = self.state_snapshot.read() {
666 s.primary_cursor.clone()
667 } else {
668 None
669 };
670 rquickjs_serde::to_value(ctx, &cursor)
671 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
672 }
673
674 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
676 let cursors = if let Ok(s) = self.state_snapshot.read() {
677 s.all_cursors.clone()
678 } else {
679 Vec::new()
680 };
681 rquickjs_serde::to_value(ctx, &cursors)
682 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
683 }
684
685 pub fn get_all_cursor_positions<'js>(
687 &self,
688 ctx: rquickjs::Ctx<'js>,
689 ) -> rquickjs::Result<Value<'js>> {
690 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
691 s.all_cursors.iter().map(|c| c.position as u32).collect()
692 } else {
693 Vec::new()
694 };
695 rquickjs_serde::to_value(ctx, &positions)
696 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
697 }
698
699 #[plugin_api(ts_return = "ViewportInfo | null")]
701 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
702 let viewport = if let Ok(s) = self.state_snapshot.read() {
703 s.viewport.clone()
704 } else {
705 None
706 };
707 rquickjs_serde::to_value(ctx, &viewport)
708 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
709 }
710
711 pub fn get_cursor_line(&self) -> u32 {
713 0
717 }
718
719 #[plugin_api(
722 async_promise,
723 js_name = "getLineStartPosition",
724 ts_return = "number | null"
725 )]
726 #[qjs(rename = "_getLineStartPositionStart")]
727 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
728 let id = {
729 let mut id_ref = self.next_request_id.borrow_mut();
730 let id = *id_ref;
731 *id_ref += 1;
732 self.callback_contexts
734 .borrow_mut()
735 .insert(id, self.plugin_name.clone());
736 id
737 };
738 let _ = self
740 .command_sender
741 .send(PluginCommand::GetLineStartPosition {
742 buffer_id: BufferId(0),
743 line,
744 request_id: id,
745 });
746 id
747 }
748
749 pub fn find_buffer_by_path(&self, path: String) -> u32 {
751 let path_buf = std::path::PathBuf::from(&path);
752 if let Ok(s) = self.state_snapshot.read() {
753 for (id, info) in &s.buffers {
754 if let Some(buf_path) = &info.path {
755 if buf_path == &path_buf {
756 return id.0 as u32;
757 }
758 }
759 }
760 }
761 0
762 }
763
764 #[plugin_api(ts_return = "BufferSavedDiff | null")]
766 pub fn get_buffer_saved_diff<'js>(
767 &self,
768 ctx: rquickjs::Ctx<'js>,
769 buffer_id: u32,
770 ) -> rquickjs::Result<Value<'js>> {
771 let diff = if let Ok(s) = self.state_snapshot.read() {
772 s.buffer_saved_diffs
773 .get(&BufferId(buffer_id as usize))
774 .cloned()
775 } else {
776 None
777 };
778 rquickjs_serde::to_value(ctx, &diff)
779 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
780 }
781
782 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
786 self.command_sender
787 .send(PluginCommand::InsertText {
788 buffer_id: BufferId(buffer_id as usize),
789 position: position as usize,
790 text,
791 })
792 .is_ok()
793 }
794
795 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
797 self.command_sender
798 .send(PluginCommand::DeleteRange {
799 buffer_id: BufferId(buffer_id as usize),
800 range: (start as usize)..(end as usize),
801 })
802 .is_ok()
803 }
804
805 pub fn insert_at_cursor(&self, text: String) -> bool {
807 self.command_sender
808 .send(PluginCommand::InsertAtCursor { text })
809 .is_ok()
810 }
811
812 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
816 self.command_sender
817 .send(PluginCommand::OpenFileAtLocation {
818 path: PathBuf::from(path),
819 line: line.map(|l| l as usize),
820 column: column.map(|c| c as usize),
821 })
822 .is_ok()
823 }
824
825 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
827 self.command_sender
828 .send(PluginCommand::OpenFileInSplit {
829 split_id: split_id as usize,
830 path: PathBuf::from(path),
831 line: Some(line as usize),
832 column: Some(column as usize),
833 })
834 .is_ok()
835 }
836
837 pub fn show_buffer(&self, buffer_id: u32) -> bool {
839 self.command_sender
840 .send(PluginCommand::ShowBuffer {
841 buffer_id: BufferId(buffer_id as usize),
842 })
843 .is_ok()
844 }
845
846 pub fn close_buffer(&self, buffer_id: u32) -> bool {
848 self.command_sender
849 .send(PluginCommand::CloseBuffer {
850 buffer_id: BufferId(buffer_id as usize),
851 })
852 .is_ok()
853 }
854
855 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
859 self.event_handlers
860 .borrow_mut()
861 .entry(event_name)
862 .or_default()
863 .push(PluginHandler {
864 plugin_name: self.plugin_name.clone(),
865 handler_name,
866 });
867 }
868
869 pub fn off(&self, event_name: String, handler_name: String) {
871 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
872 list.retain(|h| h.handler_name != handler_name);
873 }
874 }
875
876 pub fn get_env(&self, name: String) -> Option<String> {
880 std::env::var(&name).ok()
881 }
882
883 pub fn get_cwd(&self) -> String {
885 self.state_snapshot
886 .read()
887 .map(|s| s.working_dir.to_string_lossy().to_string())
888 .unwrap_or_else(|_| ".".to_string())
889 }
890
891 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
896 let mut result_parts: Vec<String> = Vec::new();
897 let mut has_leading_slash = false;
898
899 for part in &parts.0 {
900 let normalized = part.replace('\\', "/");
902
903 let is_absolute = normalized.starts_with('/')
905 || (normalized.len() >= 2
906 && normalized
907 .chars()
908 .next()
909 .map(|c| c.is_ascii_alphabetic())
910 .unwrap_or(false)
911 && normalized.chars().nth(1) == Some(':'));
912
913 if is_absolute {
914 result_parts.clear();
916 has_leading_slash = normalized.starts_with('/');
917 }
918
919 for segment in normalized.split('/') {
921 if !segment.is_empty() && segment != "." {
922 if segment == ".." {
923 result_parts.pop();
924 } else {
925 result_parts.push(segment.to_string());
926 }
927 }
928 }
929 }
930
931 let joined = result_parts.join("/");
933
934 if has_leading_slash && !joined.is_empty() {
936 format!("/{}", joined)
937 } else {
938 joined
939 }
940 }
941
942 pub fn path_dirname(&self, path: String) -> String {
944 Path::new(&path)
945 .parent()
946 .map(|p| p.to_string_lossy().to_string())
947 .unwrap_or_default()
948 }
949
950 pub fn path_basename(&self, path: String) -> String {
952 Path::new(&path)
953 .file_name()
954 .map(|s| s.to_string_lossy().to_string())
955 .unwrap_or_default()
956 }
957
958 pub fn path_extname(&self, path: String) -> String {
960 Path::new(&path)
961 .extension()
962 .map(|s| format!(".{}", s.to_string_lossy()))
963 .unwrap_or_default()
964 }
965
966 pub fn path_is_absolute(&self, path: String) -> bool {
968 Path::new(&path).is_absolute()
969 }
970
971 pub fn file_exists(&self, path: String) -> bool {
975 Path::new(&path).exists()
976 }
977
978 pub fn read_file(&self, path: String) -> Option<String> {
980 std::fs::read_to_string(&path).ok()
981 }
982
983 pub fn write_file(&self, path: String, content: String) -> bool {
985 std::fs::write(&path, content).is_ok()
986 }
987
988 #[plugin_api(ts_return = "DirEntry[]")]
990 pub fn read_dir<'js>(
991 &self,
992 ctx: rquickjs::Ctx<'js>,
993 path: String,
994 ) -> rquickjs::Result<Value<'js>> {
995 use fresh_core::api::DirEntry;
996
997 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
998 Ok(entries) => entries
999 .filter_map(|e| e.ok())
1000 .map(|entry| {
1001 let file_type = entry.file_type().ok();
1002 DirEntry {
1003 name: entry.file_name().to_string_lossy().to_string(),
1004 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1005 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1006 }
1007 })
1008 .collect(),
1009 Err(e) => {
1010 tracing::warn!("readDir failed for '{}': {}", path, e);
1011 Vec::new()
1012 }
1013 };
1014
1015 rquickjs_serde::to_value(ctx, &entries)
1016 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1017 }
1018
1019 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1023 let config: serde_json::Value = self
1024 .state_snapshot
1025 .read()
1026 .map(|s| s.config.clone())
1027 .unwrap_or_else(|_| serde_json::json!({}));
1028
1029 rquickjs_serde::to_value(ctx, &config)
1030 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1031 }
1032
1033 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1035 let config: serde_json::Value = self
1036 .state_snapshot
1037 .read()
1038 .map(|s| s.user_config.clone())
1039 .unwrap_or_else(|_| serde_json::json!({}));
1040
1041 rquickjs_serde::to_value(ctx, &config)
1042 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1043 }
1044
1045 pub fn reload_config(&self) {
1047 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1048 }
1049
1050 pub fn get_config_dir(&self) -> String {
1052 self.services.config_dir().to_string_lossy().to_string()
1053 }
1054
1055 pub fn get_themes_dir(&self) -> String {
1057 self.services
1058 .config_dir()
1059 .join("themes")
1060 .to_string_lossy()
1061 .to_string()
1062 }
1063
1064 pub fn apply_theme(&self, theme_name: String) -> bool {
1066 self.command_sender
1067 .send(PluginCommand::ApplyTheme { theme_name })
1068 .is_ok()
1069 }
1070
1071 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1073 let schema = self.services.get_theme_schema();
1074 rquickjs_serde::to_value(ctx, &schema)
1075 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1076 }
1077
1078 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1080 let themes = self.services.get_builtin_themes();
1081 rquickjs_serde::to_value(ctx, &themes)
1082 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1083 }
1084
1085 #[qjs(rename = "_deleteThemeSync")]
1087 pub fn delete_theme_sync(&self, name: String) -> bool {
1088 let themes_dir = self.services.config_dir().join("themes");
1090 let theme_path = themes_dir.join(format!("{}.json", name));
1091
1092 if let Ok(canonical) = theme_path.canonicalize() {
1094 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1095 if canonical.starts_with(&themes_canonical) {
1096 return std::fs::remove_file(&canonical).is_ok();
1097 }
1098 }
1099 }
1100 false
1101 }
1102
1103 pub fn delete_theme(&self, name: String) -> bool {
1105 self.delete_theme_sync(name)
1106 }
1107
1108 pub fn file_stat<'js>(
1112 &self,
1113 ctx: rquickjs::Ctx<'js>,
1114 path: String,
1115 ) -> rquickjs::Result<Value<'js>> {
1116 let metadata = std::fs::metadata(&path).ok();
1117 let stat = metadata.map(|m| {
1118 serde_json::json!({
1119 "isFile": m.is_file(),
1120 "isDir": m.is_dir(),
1121 "size": m.len(),
1122 "readonly": m.permissions().readonly(),
1123 })
1124 });
1125 rquickjs_serde::to_value(ctx, &stat)
1126 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1127 }
1128
1129 pub fn is_process_running(&self, _process_id: u64) -> bool {
1133 false
1136 }
1137
1138 pub fn kill_process(&self, process_id: u64) -> bool {
1140 self.command_sender
1141 .send(PluginCommand::KillBackgroundProcess { process_id })
1142 .is_ok()
1143 }
1144
1145 pub fn plugin_translate<'js>(
1149 &self,
1150 _ctx: rquickjs::Ctx<'js>,
1151 plugin_name: String,
1152 key: String,
1153 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1154 ) -> String {
1155 let args_map: HashMap<String, String> = args
1156 .0
1157 .map(|obj| {
1158 let mut map = HashMap::new();
1159 for (k, v) in obj.props::<String, String>().flatten() {
1160 map.insert(k, v);
1161 }
1162 map
1163 })
1164 .unwrap_or_default();
1165
1166 self.services.translate(&plugin_name, &key, &args_map)
1167 }
1168
1169 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1176 #[qjs(rename = "_createCompositeBufferStart")]
1177 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1178 let id = {
1179 let mut id_ref = self.next_request_id.borrow_mut();
1180 let id = *id_ref;
1181 *id_ref += 1;
1182 self.callback_contexts
1184 .borrow_mut()
1185 .insert(id, self.plugin_name.clone());
1186 id
1187 };
1188
1189 let _ = self
1190 .command_sender
1191 .send(PluginCommand::CreateCompositeBuffer {
1192 name: opts.name,
1193 mode: opts.mode,
1194 layout: opts.layout,
1195 sources: opts.sources,
1196 hunks: opts.hunks,
1197 request_id: Some(id),
1198 });
1199
1200 id
1201 }
1202
1203 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1207 self.command_sender
1208 .send(PluginCommand::UpdateCompositeAlignment {
1209 buffer_id: BufferId(buffer_id as usize),
1210 hunks,
1211 })
1212 .is_ok()
1213 }
1214
1215 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1217 self.command_sender
1218 .send(PluginCommand::CloseCompositeBuffer {
1219 buffer_id: BufferId(buffer_id as usize),
1220 })
1221 .is_ok()
1222 }
1223
1224 #[plugin_api(
1228 async_promise,
1229 js_name = "getHighlights",
1230 ts_return = "TsHighlightSpan[]"
1231 )]
1232 #[qjs(rename = "_getHighlightsStart")]
1233 pub fn get_highlights_start<'js>(
1234 &self,
1235 _ctx: rquickjs::Ctx<'js>,
1236 buffer_id: u32,
1237 start: u32,
1238 end: u32,
1239 ) -> rquickjs::Result<u64> {
1240 let id = {
1241 let mut id_ref = self.next_request_id.borrow_mut();
1242 let id = *id_ref;
1243 *id_ref += 1;
1244 self.callback_contexts
1246 .borrow_mut()
1247 .insert(id, self.plugin_name.clone());
1248 id
1249 };
1250
1251 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1252 buffer_id: BufferId(buffer_id as usize),
1253 range: (start as usize)..(end as usize),
1254 request_id: id,
1255 });
1256
1257 Ok(id)
1258 }
1259
1260 #[allow(clippy::too_many_arguments)]
1264 pub fn add_overlay(
1265 &self,
1266 buffer_id: u32,
1267 namespace: String,
1268 start: u32,
1269 end: u32,
1270 r: i32,
1271 g: i32,
1272 b: i32,
1273 underline: rquickjs::function::Opt<bool>,
1274 bold: rquickjs::function::Opt<bool>,
1275 italic: rquickjs::function::Opt<bool>,
1276 bg_r: rquickjs::function::Opt<i32>,
1277 bg_g: rquickjs::function::Opt<i32>,
1278 bg_b: rquickjs::function::Opt<i32>,
1279 extend_to_line_end: rquickjs::function::Opt<bool>,
1280 ) -> bool {
1281 let color = if r >= 0 && g >= 0 && b >= 0 {
1283 (r as u8, g as u8, b as u8)
1284 } else {
1285 (255, 255, 255)
1286 };
1287
1288 let bg_r = bg_r.0.unwrap_or(-1);
1290 let bg_g = bg_g.0.unwrap_or(-1);
1291 let bg_b = bg_b.0.unwrap_or(-1);
1292 let bg_color = if bg_r >= 0 && bg_g >= 0 && bg_b >= 0 {
1293 Some((bg_r as u8, bg_g as u8, bg_b as u8))
1294 } else {
1295 None
1296 };
1297
1298 self.command_sender
1299 .send(PluginCommand::AddOverlay {
1300 buffer_id: BufferId(buffer_id as usize),
1301 namespace: Some(OverlayNamespace::from_string(namespace)),
1302 range: (start as usize)..(end as usize),
1303 color,
1304 bg_color,
1305 underline: underline.0.unwrap_or(false),
1306 bold: bold.0.unwrap_or(false),
1307 italic: italic.0.unwrap_or(false),
1308 extend_to_line_end: extend_to_line_end.0.unwrap_or(false),
1309 })
1310 .is_ok()
1311 }
1312
1313 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1315 self.command_sender
1316 .send(PluginCommand::ClearNamespace {
1317 buffer_id: BufferId(buffer_id as usize),
1318 namespace: OverlayNamespace::from_string(namespace),
1319 })
1320 .is_ok()
1321 }
1322
1323 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1325 self.command_sender
1326 .send(PluginCommand::ClearAllOverlays {
1327 buffer_id: BufferId(buffer_id as usize),
1328 })
1329 .is_ok()
1330 }
1331
1332 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1334 self.command_sender
1335 .send(PluginCommand::ClearOverlaysInRange {
1336 buffer_id: BufferId(buffer_id as usize),
1337 start: start as usize,
1338 end: end as usize,
1339 })
1340 .is_ok()
1341 }
1342
1343 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1345 use fresh_core::overlay::OverlayHandle;
1346 self.command_sender
1347 .send(PluginCommand::RemoveOverlay {
1348 buffer_id: BufferId(buffer_id as usize),
1349 handle: OverlayHandle(handle),
1350 })
1351 .is_ok()
1352 }
1353
1354 #[allow(clippy::too_many_arguments)]
1361 pub fn submit_view_transform<'js>(
1362 &self,
1363 _ctx: rquickjs::Ctx<'js>,
1364 buffer_id: u32,
1365 split_id: Option<u32>,
1366 start: u32,
1367 end: u32,
1368 tokens: Vec<rquickjs::Object<'js>>,
1369 _layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1370 ) -> rquickjs::Result<bool> {
1371 use fresh_core::api::{
1372 ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewTransformPayload,
1373 };
1374
1375 let tokens: Vec<ViewTokenWire> = tokens
1376 .into_iter()
1377 .map(|obj| {
1378 let kind_str: String = obj.get("kind").unwrap_or_default();
1379 let text: String = obj.get("text").unwrap_or_default();
1380 let source_offset: Option<usize> = obj.get("sourceOffset").ok();
1381
1382 let kind = match kind_str.as_str() {
1383 "text" => ViewTokenWireKind::Text(text),
1384 "newline" => ViewTokenWireKind::Newline,
1385 "space" => ViewTokenWireKind::Space,
1386 "break" => ViewTokenWireKind::Break,
1387 _ => ViewTokenWireKind::Text(text),
1388 };
1389
1390 let style = obj.get::<_, rquickjs::Object>("style").ok().map(|s| {
1391 let fg: Option<Vec<u8>> = s.get("fg").ok();
1392 let bg: Option<Vec<u8>> = s.get("bg").ok();
1393 ViewTokenStyle {
1394 fg: fg.and_then(|c| {
1395 if c.len() >= 3 {
1396 Some((c[0], c[1], c[2]))
1397 } else {
1398 None
1399 }
1400 }),
1401 bg: bg.and_then(|c| {
1402 if c.len() >= 3 {
1403 Some((c[0], c[1], c[2]))
1404 } else {
1405 None
1406 }
1407 }),
1408 bold: s.get("bold").unwrap_or(false),
1409 italic: s.get("italic").unwrap_or(false),
1410 }
1411 });
1412
1413 ViewTokenWire {
1414 source_offset,
1415 kind,
1416 style,
1417 }
1418 })
1419 .collect();
1420
1421 let payload = ViewTransformPayload {
1422 range: (start as usize)..(end as usize),
1423 tokens,
1424 layout_hints: None,
1425 };
1426
1427 Ok(self
1428 .command_sender
1429 .send(PluginCommand::SubmitViewTransform {
1430 buffer_id: BufferId(buffer_id as usize),
1431 split_id: split_id.map(|id| SplitId(id as usize)),
1432 payload,
1433 })
1434 .is_ok())
1435 }
1436
1437 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1439 self.command_sender
1440 .send(PluginCommand::ClearViewTransform {
1441 buffer_id: BufferId(buffer_id as usize),
1442 split_id: split_id.map(|id| SplitId(id as usize)),
1443 })
1444 .is_ok()
1445 }
1446
1447 pub fn set_file_explorer_decorations<'js>(
1451 &self,
1452 _ctx: rquickjs::Ctx<'js>,
1453 namespace: String,
1454 decorations: Vec<rquickjs::Object<'js>>,
1455 ) -> rquickjs::Result<bool> {
1456 use fresh_core::file_explorer::FileExplorerDecoration;
1457
1458 let decorations: Vec<FileExplorerDecoration> = decorations
1459 .into_iter()
1460 .map(|obj| {
1461 let path: String = obj.get("path")?;
1462 let symbol: String = obj.get("symbol")?;
1463 let color: Vec<u8> = obj.get("color")?;
1464 let priority: i32 = obj.get("priority").unwrap_or(0);
1465
1466 if color.len() < 3 {
1467 return Err(rquickjs::Error::FromJs {
1468 from: "array",
1469 to: "color",
1470 message: Some(format!(
1471 "color array must have at least 3 elements, got {}",
1472 color.len()
1473 )),
1474 });
1475 }
1476
1477 Ok(FileExplorerDecoration {
1478 path: std::path::PathBuf::from(path),
1479 symbol,
1480 color: [color[0], color[1], color[2]],
1481 priority,
1482 })
1483 })
1484 .collect::<rquickjs::Result<Vec<_>>>()?;
1485
1486 Ok(self
1487 .command_sender
1488 .send(PluginCommand::SetFileExplorerDecorations {
1489 namespace,
1490 decorations,
1491 })
1492 .is_ok())
1493 }
1494
1495 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1497 self.command_sender
1498 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1499 .is_ok()
1500 }
1501
1502 #[allow(clippy::too_many_arguments)]
1506 pub fn add_virtual_text(
1507 &self,
1508 buffer_id: u32,
1509 virtual_text_id: String,
1510 position: u32,
1511 text: String,
1512 r: u8,
1513 g: u8,
1514 b: u8,
1515 before: bool,
1516 use_bg: bool,
1517 ) -> bool {
1518 self.command_sender
1519 .send(PluginCommand::AddVirtualText {
1520 buffer_id: BufferId(buffer_id as usize),
1521 virtual_text_id,
1522 position: position as usize,
1523 text,
1524 color: (r, g, b),
1525 use_bg,
1526 before,
1527 })
1528 .is_ok()
1529 }
1530
1531 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1533 self.command_sender
1534 .send(PluginCommand::RemoveVirtualText {
1535 buffer_id: BufferId(buffer_id as usize),
1536 virtual_text_id,
1537 })
1538 .is_ok()
1539 }
1540
1541 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1543 self.command_sender
1544 .send(PluginCommand::RemoveVirtualTextsByPrefix {
1545 buffer_id: BufferId(buffer_id as usize),
1546 prefix,
1547 })
1548 .is_ok()
1549 }
1550
1551 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1553 self.command_sender
1554 .send(PluginCommand::ClearVirtualTexts {
1555 buffer_id: BufferId(buffer_id as usize),
1556 })
1557 .is_ok()
1558 }
1559
1560 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1562 self.command_sender
1563 .send(PluginCommand::ClearVirtualTextNamespace {
1564 buffer_id: BufferId(buffer_id as usize),
1565 namespace,
1566 })
1567 .is_ok()
1568 }
1569
1570 #[allow(clippy::too_many_arguments)]
1572 pub fn add_virtual_line(
1573 &self,
1574 buffer_id: u32,
1575 position: u32,
1576 text: String,
1577 fg_r: u8,
1578 fg_g: u8,
1579 fg_b: u8,
1580 bg_r: u8,
1581 bg_g: u8,
1582 bg_b: u8,
1583 above: bool,
1584 namespace: String,
1585 priority: i32,
1586 ) -> bool {
1587 self.command_sender
1588 .send(PluginCommand::AddVirtualLine {
1589 buffer_id: BufferId(buffer_id as usize),
1590 position: position as usize,
1591 text,
1592 fg_color: (fg_r, fg_g, fg_b),
1593 bg_color: Some((bg_r, bg_g, bg_b)),
1594 above,
1595 namespace,
1596 priority,
1597 })
1598 .is_ok()
1599 }
1600
1601 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1606 #[qjs(rename = "_promptStart")]
1607 pub fn prompt_start(
1608 &self,
1609 _ctx: rquickjs::Ctx<'_>,
1610 label: String,
1611 initial_value: String,
1612 ) -> u64 {
1613 let id = {
1614 let mut id_ref = self.next_request_id.borrow_mut();
1615 let id = *id_ref;
1616 *id_ref += 1;
1617 self.callback_contexts
1619 .borrow_mut()
1620 .insert(id, self.plugin_name.clone());
1621 id
1622 };
1623
1624 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1625 label,
1626 initial_value,
1627 callback_id: JsCallbackId::new(id),
1628 });
1629
1630 id
1631 }
1632
1633 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1635 self.command_sender
1636 .send(PluginCommand::StartPrompt { label, prompt_type })
1637 .is_ok()
1638 }
1639
1640 pub fn start_prompt_with_initial(
1642 &self,
1643 label: String,
1644 prompt_type: String,
1645 initial_value: String,
1646 ) -> bool {
1647 self.command_sender
1648 .send(PluginCommand::StartPromptWithInitial {
1649 label,
1650 prompt_type,
1651 initial_value,
1652 })
1653 .is_ok()
1654 }
1655
1656 pub fn set_prompt_suggestions(
1660 &self,
1661 suggestions: Vec<fresh_core::command::Suggestion>,
1662 ) -> bool {
1663 self.command_sender
1664 .send(PluginCommand::SetPromptSuggestions { suggestions })
1665 .is_ok()
1666 }
1667
1668 pub fn define_mode(
1672 &self,
1673 name: String,
1674 parent: Option<String>,
1675 bindings_arr: Vec<Vec<String>>,
1676 read_only: rquickjs::function::Opt<bool>,
1677 ) -> bool {
1678 let bindings: Vec<(String, String)> = bindings_arr
1679 .into_iter()
1680 .filter_map(|arr| {
1681 if arr.len() >= 2 {
1682 Some((arr[0].clone(), arr[1].clone()))
1683 } else {
1684 None
1685 }
1686 })
1687 .collect();
1688
1689 {
1692 let mut registered = self.registered_actions.borrow_mut();
1693 for (_, cmd_name) in &bindings {
1694 registered.insert(
1695 cmd_name.clone(),
1696 PluginHandler {
1697 plugin_name: self.plugin_name.clone(),
1698 handler_name: cmd_name.clone(),
1699 },
1700 );
1701 }
1702 }
1703
1704 self.command_sender
1705 .send(PluginCommand::DefineMode {
1706 name,
1707 parent,
1708 bindings,
1709 read_only: read_only.0.unwrap_or(false),
1710 })
1711 .is_ok()
1712 }
1713
1714 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1716 self.command_sender
1717 .send(PluginCommand::SetEditorMode { mode })
1718 .is_ok()
1719 }
1720
1721 pub fn get_editor_mode(&self) -> Option<String> {
1723 self.state_snapshot
1724 .read()
1725 .ok()
1726 .and_then(|s| s.editor_mode.clone())
1727 }
1728
1729 pub fn close_split(&self, split_id: u32) -> bool {
1733 self.command_sender
1734 .send(PluginCommand::CloseSplit {
1735 split_id: SplitId(split_id as usize),
1736 })
1737 .is_ok()
1738 }
1739
1740 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1742 self.command_sender
1743 .send(PluginCommand::SetSplitBuffer {
1744 split_id: SplitId(split_id as usize),
1745 buffer_id: BufferId(buffer_id as usize),
1746 })
1747 .is_ok()
1748 }
1749
1750 pub fn focus_split(&self, split_id: u32) -> bool {
1752 self.command_sender
1753 .send(PluginCommand::FocusSplit {
1754 split_id: SplitId(split_id as usize),
1755 })
1756 .is_ok()
1757 }
1758
1759 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1761 self.command_sender
1762 .send(PluginCommand::SetSplitScroll {
1763 split_id: SplitId(split_id as usize),
1764 top_byte: top_byte as usize,
1765 })
1766 .is_ok()
1767 }
1768
1769 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1771 self.command_sender
1772 .send(PluginCommand::SetSplitRatio {
1773 split_id: SplitId(split_id as usize),
1774 ratio,
1775 })
1776 .is_ok()
1777 }
1778
1779 pub fn distribute_splits_evenly(&self) -> bool {
1781 self.command_sender
1783 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1784 .is_ok()
1785 }
1786
1787 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1789 self.command_sender
1790 .send(PluginCommand::SetBufferCursor {
1791 buffer_id: BufferId(buffer_id as usize),
1792 position: position as usize,
1793 })
1794 .is_ok()
1795 }
1796
1797 #[allow(clippy::too_many_arguments)]
1801 pub fn set_line_indicator(
1802 &self,
1803 buffer_id: u32,
1804 line: u32,
1805 namespace: String,
1806 symbol: String,
1807 r: u8,
1808 g: u8,
1809 b: u8,
1810 priority: i32,
1811 ) -> bool {
1812 self.command_sender
1813 .send(PluginCommand::SetLineIndicator {
1814 buffer_id: BufferId(buffer_id as usize),
1815 line: line as usize,
1816 namespace,
1817 symbol,
1818 color: (r, g, b),
1819 priority,
1820 })
1821 .is_ok()
1822 }
1823
1824 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
1826 self.command_sender
1827 .send(PluginCommand::ClearLineIndicators {
1828 buffer_id: BufferId(buffer_id as usize),
1829 namespace,
1830 })
1831 .is_ok()
1832 }
1833
1834 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
1836 self.command_sender
1837 .send(PluginCommand::SetLineNumbers {
1838 buffer_id: BufferId(buffer_id as usize),
1839 enabled,
1840 })
1841 .is_ok()
1842 }
1843
1844 pub fn create_scroll_sync_group(
1848 &self,
1849 group_id: u32,
1850 left_split: u32,
1851 right_split: u32,
1852 ) -> bool {
1853 self.command_sender
1854 .send(PluginCommand::CreateScrollSyncGroup {
1855 group_id,
1856 left_split: SplitId(left_split as usize),
1857 right_split: SplitId(right_split as usize),
1858 })
1859 .is_ok()
1860 }
1861
1862 pub fn set_scroll_sync_anchors<'js>(
1864 &self,
1865 _ctx: rquickjs::Ctx<'js>,
1866 group_id: u32,
1867 anchors: Vec<Vec<u32>>,
1868 ) -> bool {
1869 let anchors: Vec<(usize, usize)> = anchors
1870 .into_iter()
1871 .filter_map(|pair| {
1872 if pair.len() >= 2 {
1873 Some((pair[0] as usize, pair[1] as usize))
1874 } else {
1875 None
1876 }
1877 })
1878 .collect();
1879 self.command_sender
1880 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
1881 .is_ok()
1882 }
1883
1884 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
1886 self.command_sender
1887 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
1888 .is_ok()
1889 }
1890
1891 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
1897 self.command_sender
1898 .send(PluginCommand::ExecuteActions { actions })
1899 .is_ok()
1900 }
1901
1902 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
1906 self.command_sender
1907 .send(PluginCommand::ShowActionPopup {
1908 popup_id: opts.id,
1909 title: opts.title,
1910 message: opts.message,
1911 actions: opts.actions,
1912 })
1913 .is_ok()
1914 }
1915
1916 pub fn disable_lsp_for_language(&self, language: String) -> bool {
1918 self.command_sender
1919 .send(PluginCommand::DisableLspForLanguage { language })
1920 .is_ok()
1921 }
1922
1923 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
1926 self.command_sender
1927 .send(PluginCommand::SetLspRootUri { language, uri })
1928 .is_ok()
1929 }
1930
1931 #[plugin_api(ts_return = "JsDiagnostic[]")]
1933 pub fn get_all_diagnostics<'js>(
1934 &self,
1935 ctx: rquickjs::Ctx<'js>,
1936 ) -> rquickjs::Result<Value<'js>> {
1937 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
1938
1939 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
1940 let mut result: Vec<JsDiagnostic> = Vec::new();
1942 for (uri, diags) in &s.diagnostics {
1943 for diag in diags {
1944 result.push(JsDiagnostic {
1945 uri: uri.clone(),
1946 message: diag.message.clone(),
1947 severity: diag.severity.map(|s| match s {
1948 lsp_types::DiagnosticSeverity::ERROR => 1,
1949 lsp_types::DiagnosticSeverity::WARNING => 2,
1950 lsp_types::DiagnosticSeverity::INFORMATION => 3,
1951 lsp_types::DiagnosticSeverity::HINT => 4,
1952 _ => 0,
1953 }),
1954 range: JsRange {
1955 start: JsPosition {
1956 line: diag.range.start.line,
1957 character: diag.range.start.character,
1958 },
1959 end: JsPosition {
1960 line: diag.range.end.line,
1961 character: diag.range.end.character,
1962 },
1963 },
1964 source: diag.source.clone(),
1965 });
1966 }
1967 }
1968 result
1969 } else {
1970 Vec::new()
1971 };
1972 rquickjs_serde::to_value(ctx, &diagnostics)
1973 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1974 }
1975
1976 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
1978 self.event_handlers
1979 .borrow()
1980 .get(&event_name)
1981 .cloned()
1982 .unwrap_or_default()
1983 .into_iter()
1984 .map(|h| h.handler_name)
1985 .collect()
1986 }
1987
1988 #[plugin_api(
1992 async_promise,
1993 js_name = "createVirtualBuffer",
1994 ts_return = "VirtualBufferResult"
1995 )]
1996 #[qjs(rename = "_createVirtualBufferStart")]
1997 pub fn create_virtual_buffer_start(
1998 &self,
1999 _ctx: rquickjs::Ctx<'_>,
2000 opts: fresh_core::api::CreateVirtualBufferOptions,
2001 ) -> rquickjs::Result<u64> {
2002 let id = {
2003 let mut id_ref = self.next_request_id.borrow_mut();
2004 let id = *id_ref;
2005 *id_ref += 1;
2006 self.callback_contexts
2008 .borrow_mut()
2009 .insert(id, self.plugin_name.clone());
2010 id
2011 };
2012
2013 let entries: Vec<TextPropertyEntry> = opts
2015 .entries
2016 .unwrap_or_default()
2017 .into_iter()
2018 .map(|e| TextPropertyEntry {
2019 text: e.text,
2020 properties: e.properties.unwrap_or_default(),
2021 })
2022 .collect();
2023
2024 tracing::debug!(
2025 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2026 id
2027 );
2028 let _ = self
2029 .command_sender
2030 .send(PluginCommand::CreateVirtualBufferWithContent {
2031 name: opts.name,
2032 mode: opts.mode.unwrap_or_default(),
2033 read_only: opts.read_only.unwrap_or(false),
2034 entries,
2035 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2036 show_cursors: opts.show_cursors.unwrap_or(true),
2037 editing_disabled: opts.editing_disabled.unwrap_or(false),
2038 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2039 request_id: Some(id),
2040 });
2041 Ok(id)
2042 }
2043
2044 #[plugin_api(
2046 async_promise,
2047 js_name = "createVirtualBufferInSplit",
2048 ts_return = "VirtualBufferResult"
2049 )]
2050 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2051 pub fn create_virtual_buffer_in_split_start(
2052 &self,
2053 _ctx: rquickjs::Ctx<'_>,
2054 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2055 ) -> rquickjs::Result<u64> {
2056 let id = {
2057 let mut id_ref = self.next_request_id.borrow_mut();
2058 let id = *id_ref;
2059 *id_ref += 1;
2060 self.callback_contexts
2062 .borrow_mut()
2063 .insert(id, self.plugin_name.clone());
2064 id
2065 };
2066
2067 let entries: Vec<TextPropertyEntry> = opts
2069 .entries
2070 .unwrap_or_default()
2071 .into_iter()
2072 .map(|e| TextPropertyEntry {
2073 text: e.text,
2074 properties: e.properties.unwrap_or_default(),
2075 })
2076 .collect();
2077
2078 let _ = self
2079 .command_sender
2080 .send(PluginCommand::CreateVirtualBufferInSplit {
2081 name: opts.name,
2082 mode: opts.mode.unwrap_or_default(),
2083 read_only: opts.read_only.unwrap_or(false),
2084 entries,
2085 ratio: opts.ratio.unwrap_or(0.5),
2086 direction: opts.direction,
2087 panel_id: opts.panel_id,
2088 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2089 show_cursors: opts.show_cursors.unwrap_or(true),
2090 editing_disabled: opts.editing_disabled.unwrap_or(false),
2091 line_wrap: opts.line_wrap,
2092 request_id: Some(id),
2093 });
2094 Ok(id)
2095 }
2096
2097 #[plugin_api(
2099 async_promise,
2100 js_name = "createVirtualBufferInExistingSplit",
2101 ts_return = "VirtualBufferResult"
2102 )]
2103 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2104 pub fn create_virtual_buffer_in_existing_split_start(
2105 &self,
2106 _ctx: rquickjs::Ctx<'_>,
2107 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2108 ) -> rquickjs::Result<u64> {
2109 let id = {
2110 let mut id_ref = self.next_request_id.borrow_mut();
2111 let id = *id_ref;
2112 *id_ref += 1;
2113 self.callback_contexts
2115 .borrow_mut()
2116 .insert(id, self.plugin_name.clone());
2117 id
2118 };
2119
2120 let entries: Vec<TextPropertyEntry> = opts
2122 .entries
2123 .unwrap_or_default()
2124 .into_iter()
2125 .map(|e| TextPropertyEntry {
2126 text: e.text,
2127 properties: e.properties.unwrap_or_default(),
2128 })
2129 .collect();
2130
2131 let _ = self
2132 .command_sender
2133 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2134 name: opts.name,
2135 mode: opts.mode.unwrap_or_default(),
2136 read_only: opts.read_only.unwrap_or(false),
2137 entries,
2138 split_id: SplitId(opts.split_id),
2139 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2140 show_cursors: opts.show_cursors.unwrap_or(true),
2141 editing_disabled: opts.editing_disabled.unwrap_or(false),
2142 line_wrap: opts.line_wrap,
2143 request_id: Some(id),
2144 });
2145 Ok(id)
2146 }
2147
2148 pub fn set_virtual_buffer_content<'js>(
2152 &self,
2153 ctx: rquickjs::Ctx<'js>,
2154 buffer_id: u32,
2155 entries_arr: Vec<rquickjs::Object<'js>>,
2156 ) -> rquickjs::Result<bool> {
2157 let entries: Vec<TextPropertyEntry> = entries_arr
2158 .iter()
2159 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2160 .collect();
2161 Ok(self
2162 .command_sender
2163 .send(PluginCommand::SetVirtualBufferContent {
2164 buffer_id: BufferId(buffer_id as usize),
2165 entries,
2166 })
2167 .is_ok())
2168 }
2169
2170 pub fn get_text_properties_at_cursor(
2172 &self,
2173 buffer_id: u32,
2174 ) -> fresh_core::api::TextPropertiesAtCursor {
2175 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2176 }
2177
2178 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2182 #[qjs(rename = "_spawnProcessStart")]
2183 pub fn spawn_process_start(
2184 &self,
2185 _ctx: rquickjs::Ctx<'_>,
2186 command: String,
2187 args: Vec<String>,
2188 cwd: rquickjs::function::Opt<String>,
2189 ) -> u64 {
2190 let id = {
2191 let mut id_ref = self.next_request_id.borrow_mut();
2192 let id = *id_ref;
2193 *id_ref += 1;
2194 self.callback_contexts
2196 .borrow_mut()
2197 .insert(id, self.plugin_name.clone());
2198 id
2199 };
2200 let effective_cwd = cwd.0.or_else(|| {
2202 self.state_snapshot
2203 .read()
2204 .ok()
2205 .map(|s| s.working_dir.to_string_lossy().to_string())
2206 });
2207 tracing::info!(
2208 "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2209 command,
2210 args,
2211 effective_cwd,
2212 id
2213 );
2214 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2215 callback_id: JsCallbackId::new(id),
2216 command,
2217 args,
2218 cwd: effective_cwd,
2219 });
2220 id
2221 }
2222
2223 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2225 #[qjs(rename = "_spawnProcessWaitStart")]
2226 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2227 let id = {
2228 let mut id_ref = self.next_request_id.borrow_mut();
2229 let id = *id_ref;
2230 *id_ref += 1;
2231 self.callback_contexts
2233 .borrow_mut()
2234 .insert(id, self.plugin_name.clone());
2235 id
2236 };
2237 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2238 process_id,
2239 callback_id: JsCallbackId::new(id),
2240 });
2241 id
2242 }
2243
2244 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2246 #[qjs(rename = "_getBufferTextStart")]
2247 pub fn get_buffer_text_start(
2248 &self,
2249 _ctx: rquickjs::Ctx<'_>,
2250 buffer_id: u32,
2251 start: u32,
2252 end: u32,
2253 ) -> u64 {
2254 let id = {
2255 let mut id_ref = self.next_request_id.borrow_mut();
2256 let id = *id_ref;
2257 *id_ref += 1;
2258 self.callback_contexts
2260 .borrow_mut()
2261 .insert(id, self.plugin_name.clone());
2262 id
2263 };
2264 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2265 buffer_id: BufferId(buffer_id as usize),
2266 start: start as usize,
2267 end: end as usize,
2268 request_id: id,
2269 });
2270 id
2271 }
2272
2273 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2275 #[qjs(rename = "_delayStart")]
2276 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2277 let id = {
2278 let mut id_ref = self.next_request_id.borrow_mut();
2279 let id = *id_ref;
2280 *id_ref += 1;
2281 self.callback_contexts
2283 .borrow_mut()
2284 .insert(id, self.plugin_name.clone());
2285 id
2286 };
2287 let _ = self.command_sender.send(PluginCommand::Delay {
2288 callback_id: JsCallbackId::new(id),
2289 duration_ms,
2290 });
2291 id
2292 }
2293
2294 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2296 #[qjs(rename = "_sendLspRequestStart")]
2297 pub fn send_lsp_request_start<'js>(
2298 &self,
2299 ctx: rquickjs::Ctx<'js>,
2300 language: String,
2301 method: String,
2302 params: Option<rquickjs::Object<'js>>,
2303 ) -> rquickjs::Result<u64> {
2304 let id = {
2305 let mut id_ref = self.next_request_id.borrow_mut();
2306 let id = *id_ref;
2307 *id_ref += 1;
2308 self.callback_contexts
2310 .borrow_mut()
2311 .insert(id, self.plugin_name.clone());
2312 id
2313 };
2314 let params_json: Option<serde_json::Value> = params.map(|obj| {
2316 let val = obj.into_value();
2317 js_to_json(&ctx, val)
2318 });
2319 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2320 request_id: id,
2321 language,
2322 method,
2323 params: params_json,
2324 });
2325 Ok(id)
2326 }
2327
2328 #[plugin_api(
2330 async_thenable,
2331 js_name = "spawnBackgroundProcess",
2332 ts_return = "BackgroundProcessResult"
2333 )]
2334 #[qjs(rename = "_spawnBackgroundProcessStart")]
2335 pub fn spawn_background_process_start(
2336 &self,
2337 _ctx: rquickjs::Ctx<'_>,
2338 command: String,
2339 args: Vec<String>,
2340 cwd: rquickjs::function::Opt<String>,
2341 ) -> u64 {
2342 let id = {
2343 let mut id_ref = self.next_request_id.borrow_mut();
2344 let id = *id_ref;
2345 *id_ref += 1;
2346 self.callback_contexts
2348 .borrow_mut()
2349 .insert(id, self.plugin_name.clone());
2350 id
2351 };
2352 let process_id = id;
2354 let _ = self
2355 .command_sender
2356 .send(PluginCommand::SpawnBackgroundProcess {
2357 process_id,
2358 command,
2359 args,
2360 cwd: cwd.0,
2361 callback_id: JsCallbackId::new(id),
2362 });
2363 id
2364 }
2365
2366 pub fn kill_background_process(&self, process_id: u64) -> bool {
2368 self.command_sender
2369 .send(PluginCommand::KillBackgroundProcess { process_id })
2370 .is_ok()
2371 }
2372
2373 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2377 self.command_sender
2378 .send(PluginCommand::RefreshLines {
2379 buffer_id: BufferId(buffer_id as usize),
2380 })
2381 .is_ok()
2382 }
2383
2384 pub fn get_current_locale(&self) -> String {
2386 self.services.current_locale()
2387 }
2388}
2389
2390pub struct QuickJsBackend {
2392 runtime: Runtime,
2393 main_context: Context,
2395 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2397 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2399 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2401 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2403 command_sender: mpsc::Sender<PluginCommand>,
2405 #[allow(dead_code)]
2407 pending_responses: PendingResponses,
2408 next_request_id: Rc<RefCell<u64>>,
2410 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2412 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2414}
2415
2416impl QuickJsBackend {
2417 pub fn new() -> Result<Self> {
2419 let (tx, _rx) = mpsc::channel();
2420 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2421 let services = Arc::new(fresh_core::services::NoopServiceBridge);
2422 Self::with_state(state_snapshot, tx, services)
2423 }
2424
2425 pub fn with_state(
2427 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2428 command_sender: mpsc::Sender<PluginCommand>,
2429 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2430 ) -> Result<Self> {
2431 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2432 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2433 }
2434
2435 pub fn with_state_and_responses(
2437 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2438 command_sender: mpsc::Sender<PluginCommand>,
2439 pending_responses: PendingResponses,
2440 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2441 ) -> Result<Self> {
2442 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2443
2444 let runtime =
2445 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2446
2447 runtime.set_host_promise_rejection_tracker(Some(Box::new(
2449 |_ctx, _promise, reason, is_handled| {
2450 if !is_handled {
2451 let error_msg = if let Some(exc) = reason.as_exception() {
2453 format!(
2454 "{}: {}",
2455 exc.message().unwrap_or_default(),
2456 exc.stack().unwrap_or_default()
2457 )
2458 } else {
2459 format!("{:?}", reason)
2460 };
2461
2462 tracing::error!("Unhandled Promise rejection: {}", error_msg);
2463
2464 if should_panic_on_js_errors() {
2465 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2468 set_fatal_js_error(full_msg);
2469 }
2470 }
2471 },
2472 )));
2473
2474 let main_context = Context::full(&runtime)
2475 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2476
2477 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2478 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2479 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2480 let next_request_id = Rc::new(RefCell::new(1u64));
2481 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2482
2483 let backend = Self {
2484 runtime,
2485 main_context,
2486 plugin_contexts,
2487 event_handlers,
2488 registered_actions,
2489 state_snapshot,
2490 command_sender,
2491 pending_responses,
2492 next_request_id,
2493 callback_contexts,
2494 services,
2495 };
2496
2497 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2499
2500 tracing::debug!("QuickJsBackend::new: runtime created successfully");
2501 Ok(backend)
2502 }
2503
2504 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2506 let state_snapshot = Arc::clone(&self.state_snapshot);
2507 let command_sender = self.command_sender.clone();
2508 let event_handlers = Rc::clone(&self.event_handlers);
2509 let registered_actions = Rc::clone(&self.registered_actions);
2510 let next_request_id = Rc::clone(&self.next_request_id);
2511
2512 context.with(|ctx| {
2513 let globals = ctx.globals();
2514
2515 globals.set("__pluginName__", plugin_name)?;
2517
2518 let js_api = JsEditorApi {
2521 state_snapshot: Arc::clone(&state_snapshot),
2522 command_sender: command_sender.clone(),
2523 registered_actions: Rc::clone(®istered_actions),
2524 event_handlers: Rc::clone(&event_handlers),
2525 next_request_id: Rc::clone(&next_request_id),
2526 callback_contexts: Rc::clone(&self.callback_contexts),
2527 services: self.services.clone(),
2528 plugin_name: plugin_name.to_string(),
2529 };
2530 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2531
2532 globals.set("editor", editor)?;
2534
2535 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2537
2538 let console = Object::new(ctx.clone())?;
2541 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2542 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2543 tracing::info!("console.log: {}", parts.join(" "));
2544 })?)?;
2545 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2546 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2547 tracing::warn!("console.warn: {}", parts.join(" "));
2548 })?)?;
2549 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2550 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2551 tracing::error!("console.error: {}", parts.join(" "));
2552 })?)?;
2553 globals.set("console", console)?;
2554
2555 ctx.eval::<(), _>(r#"
2557 // Pending promise callbacks: callbackId -> { resolve, reject }
2558 globalThis._pendingCallbacks = new Map();
2559
2560 // Resolve a pending callback (called from Rust)
2561 globalThis._resolveCallback = function(callbackId, result) {
2562 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2563 const cb = globalThis._pendingCallbacks.get(callbackId);
2564 if (cb) {
2565 console.log('[JS] _resolveCallback: found callback, calling resolve()');
2566 globalThis._pendingCallbacks.delete(callbackId);
2567 cb.resolve(result);
2568 console.log('[JS] _resolveCallback: resolve() called');
2569 } else {
2570 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2571 }
2572 };
2573
2574 // Reject a pending callback (called from Rust)
2575 globalThis._rejectCallback = function(callbackId, error) {
2576 const cb = globalThis._pendingCallbacks.get(callbackId);
2577 if (cb) {
2578 globalThis._pendingCallbacks.delete(callbackId);
2579 cb.reject(new Error(error));
2580 }
2581 };
2582
2583 // Generic async wrapper decorator
2584 // Wraps a function that returns a callbackId into a promise-returning function
2585 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2586 // NOTE: We pass the method name as a string and call via bracket notation
2587 // to preserve rquickjs's automatic Ctx injection for methods
2588 globalThis._wrapAsync = function(methodName, fnName) {
2589 const startFn = editor[methodName];
2590 if (typeof startFn !== 'function') {
2591 // Return a function that always throws - catches missing implementations
2592 return function(...args) {
2593 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2594 editor.debug(`[ASYNC ERROR] ${error.message}`);
2595 throw error;
2596 };
2597 }
2598 return function(...args) {
2599 // Call via bracket notation to preserve method binding and Ctx injection
2600 const callbackId = editor[methodName](...args);
2601 return new Promise((resolve, reject) => {
2602 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2603 // TODO: Implement setTimeout polyfill using editor.delay() or similar
2604 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2605 });
2606 };
2607 };
2608
2609 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2610 // The returned object has .result promise and is itself thenable
2611 globalThis._wrapAsyncThenable = function(methodName, fnName) {
2612 const startFn = editor[methodName];
2613 if (typeof startFn !== 'function') {
2614 // Return a function that always throws - catches missing implementations
2615 return function(...args) {
2616 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2617 editor.debug(`[ASYNC ERROR] ${error.message}`);
2618 throw error;
2619 };
2620 }
2621 return function(...args) {
2622 // Call via bracket notation to preserve method binding and Ctx injection
2623 const callbackId = editor[methodName](...args);
2624 const resultPromise = new Promise((resolve, reject) => {
2625 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2626 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2627 });
2628 return {
2629 get result() { return resultPromise; },
2630 then(onFulfilled, onRejected) {
2631 return resultPromise.then(onFulfilled, onRejected);
2632 },
2633 catch(onRejected) {
2634 return resultPromise.catch(onRejected);
2635 }
2636 };
2637 };
2638 };
2639
2640 // Apply wrappers to async functions on editor
2641 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
2642 editor.delay = _wrapAsync("_delayStart", "delay");
2643 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
2644 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
2645 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
2646 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
2647 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
2648 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
2649 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
2650 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
2651 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
2652
2653 // Wrapper for deleteTheme - wraps sync function in Promise
2654 editor.deleteTheme = function(name) {
2655 return new Promise(function(resolve, reject) {
2656 const success = editor._deleteThemeSync(name);
2657 if (success) {
2658 resolve();
2659 } else {
2660 reject(new Error("Failed to delete theme: " + name));
2661 }
2662 });
2663 };
2664 "#.as_bytes())?;
2665
2666 Ok::<_, rquickjs::Error>(())
2667 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
2668
2669 Ok(())
2670 }
2671
2672 pub async fn load_module_with_source(
2674 &mut self,
2675 path: &str,
2676 _plugin_source: &str,
2677 ) -> Result<()> {
2678 let path_buf = PathBuf::from(path);
2679 let source = std::fs::read_to_string(&path_buf)
2680 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
2681
2682 let filename = path_buf
2683 .file_name()
2684 .and_then(|s| s.to_str())
2685 .unwrap_or("plugin.ts");
2686
2687 if has_es_imports(&source) {
2689 match bundle_module(&path_buf) {
2691 Ok(bundled) => {
2692 self.execute_js(&bundled, path)?;
2693 }
2694 Err(e) => {
2695 tracing::warn!(
2696 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
2697 path,
2698 e
2699 );
2700 return Ok(()); }
2702 }
2703 } else if has_es_module_syntax(&source) {
2704 let stripped = strip_imports_and_exports(&source);
2706 let js_code = if filename.ends_with(".ts") {
2707 transpile_typescript(&stripped, filename)?
2708 } else {
2709 stripped
2710 };
2711 self.execute_js(&js_code, path)?;
2712 } else {
2713 let js_code = if filename.ends_with(".ts") {
2715 transpile_typescript(&source, filename)?
2716 } else {
2717 source
2718 };
2719 self.execute_js(&js_code, path)?;
2720 }
2721
2722 Ok(())
2723 }
2724
2725 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
2727 let plugin_name = Path::new(source_name)
2729 .file_stem()
2730 .and_then(|s| s.to_str())
2731 .unwrap_or("unknown");
2732
2733 tracing::debug!(
2734 "execute_js: starting for plugin '{}' from '{}'",
2735 plugin_name,
2736 source_name
2737 );
2738
2739 let context = {
2741 let mut contexts = self.plugin_contexts.borrow_mut();
2742 if let Some(ctx) = contexts.get(plugin_name) {
2743 ctx.clone()
2744 } else {
2745 let ctx = Context::full(&self.runtime).map_err(|e| {
2746 anyhow!(
2747 "Failed to create QuickJS context for plugin {}: {}",
2748 plugin_name,
2749 e
2750 )
2751 })?;
2752 self.setup_context_api(&ctx, plugin_name)?;
2753 contexts.insert(plugin_name.to_string(), ctx.clone());
2754 ctx
2755 }
2756 };
2757
2758 let wrapped_code = format!("(function() {{ {} }})();", code);
2762 let wrapped = wrapped_code.as_str();
2763
2764 context.with(|ctx| {
2765 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
2766
2767 let mut eval_options = rquickjs::context::EvalOptions::default();
2769 eval_options.global = true;
2770 eval_options.filename = Some(source_name.to_string());
2771 let result = ctx
2772 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
2773 .map_err(|e| format_js_error(&ctx, e, source_name));
2774
2775 tracing::debug!(
2776 "execute_js: plugin code execution finished for '{}', result: {:?}",
2777 plugin_name,
2778 result.is_ok()
2779 );
2780
2781 result
2782 })
2783 }
2784
2785 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
2787 let _event_data_str = event_data.to_string();
2788 tracing::debug!("emit: event '{}' with data: {:?}", event_name, event_data);
2789
2790 self.services
2792 .set_js_execution_state(format!("hook '{}'", event_name));
2793
2794 let handlers = self.event_handlers.borrow().get(event_name).cloned();
2795
2796 if let Some(handler_pairs) = handlers {
2797 if handler_pairs.is_empty() {
2798 self.services.clear_js_execution_state();
2799 return Ok(true);
2800 }
2801
2802 let plugin_contexts = self.plugin_contexts.borrow();
2803 for handler in handler_pairs {
2804 let context_opt = plugin_contexts.get(&handler.plugin_name);
2805 if let Some(context) = context_opt {
2806 let handler_name = &handler.handler_name;
2807 let json_string = serde_json::to_string(event_data)?;
2813 let js_string_literal = serde_json::to_string(&json_string)?;
2814 let code = format!(
2815 r#"
2816 (function() {{
2817 try {{
2818 const data = JSON.parse({});
2819 if (typeof globalThis["{}"] === 'function') {{
2820 const result = globalThis["{}"](data);
2821 // If handler returns a Promise, catch rejections
2822 if (result && typeof result.then === 'function') {{
2823 result.catch(function(e) {{
2824 console.error('Handler {} async error:', e);
2825 // Re-throw to make it an unhandled rejection for the runtime to catch
2826 throw e;
2827 }});
2828 }}
2829 }}
2830 }} catch (e) {{
2831 console.error('Handler {} sync error:', e);
2832 throw e;
2833 }}
2834 }})();
2835 "#,
2836 js_string_literal, handler_name, handler_name, handler_name, handler_name
2837 );
2838
2839 context.with(|ctx| {
2840 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
2841 log_js_error(&ctx, e, &format!("handler {}", handler_name));
2842 }
2843 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
2845 });
2846 }
2847 }
2848 }
2849
2850 self.services.clear_js_execution_state();
2851 Ok(true)
2852 }
2853
2854 pub fn has_handlers(&self, event_name: &str) -> bool {
2856 self.event_handlers
2857 .borrow()
2858 .get(event_name)
2859 .map(|v| !v.is_empty())
2860 .unwrap_or(false)
2861 }
2862
2863 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
2867 let pair = self.registered_actions.borrow().get(action_name).cloned();
2868 let (plugin_name, function_name) = match pair {
2869 Some(handler) => (handler.plugin_name, handler.handler_name),
2870 None => ("main".to_string(), action_name.to_string()),
2871 };
2872
2873 let plugin_contexts = self.plugin_contexts.borrow();
2874 let context = plugin_contexts
2875 .get(&plugin_name)
2876 .unwrap_or(&self.main_context);
2877
2878 self.services
2880 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
2881
2882 tracing::info!(
2883 "start_action: BEGIN '{}' -> function '{}'",
2884 action_name,
2885 function_name
2886 );
2887
2888 let code = format!(
2890 r#"
2891 (function() {{
2892 console.log('[JS] start_action: calling {fn}');
2893 try {{
2894 if (typeof globalThis.{fn} === 'function') {{
2895 console.log('[JS] start_action: {fn} is a function, invoking...');
2896 globalThis.{fn}();
2897 console.log('[JS] start_action: {fn} invoked (may be async)');
2898 }} else {{
2899 console.error('[JS] Action {action} is not defined as a global function');
2900 }}
2901 }} catch (e) {{
2902 console.error('[JS] Action {action} error:', e);
2903 }}
2904 }})();
2905 "#,
2906 fn = function_name,
2907 action = action_name
2908 );
2909
2910 tracing::info!("start_action: evaluating JS code");
2911 context.with(|ctx| {
2912 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2913 log_js_error(&ctx, e, &format!("action {}", action_name));
2914 }
2915 tracing::info!("start_action: running pending microtasks");
2916 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
2918 tracing::info!("start_action: executed {} pending jobs", count);
2919 });
2920
2921 tracing::info!("start_action: END '{}'", action_name);
2922
2923 self.services.clear_js_execution_state();
2925
2926 Ok(())
2927 }
2928
2929 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
2931 let pair = self.registered_actions.borrow().get(action_name).cloned();
2933 let (plugin_name, function_name) = match pair {
2934 Some(handler) => (handler.plugin_name, handler.handler_name),
2935 None => ("main".to_string(), action_name.to_string()),
2936 };
2937
2938 let plugin_contexts = self.plugin_contexts.borrow();
2939 let context = plugin_contexts
2940 .get(&plugin_name)
2941 .unwrap_or(&self.main_context);
2942
2943 tracing::debug!(
2944 "execute_action: '{}' -> function '{}'",
2945 action_name,
2946 function_name
2947 );
2948
2949 let code = format!(
2952 r#"
2953 (async function() {{
2954 try {{
2955 if (typeof globalThis.{fn} === 'function') {{
2956 const result = globalThis.{fn}();
2957 // If it's a Promise, await it
2958 if (result && typeof result.then === 'function') {{
2959 await result;
2960 }}
2961 }} else {{
2962 console.error('Action {action} is not defined as a global function');
2963 }}
2964 }} catch (e) {{
2965 console.error('Action {action} error:', e);
2966 }}
2967 }})();
2968 "#,
2969 fn = function_name,
2970 action = action_name
2971 );
2972
2973 context.with(|ctx| {
2974 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2976 Ok(value) => {
2977 if value.is_object() {
2979 if let Some(obj) = value.as_object() {
2980 if obj.get::<_, rquickjs::Function>("then").is_ok() {
2982 run_pending_jobs_checked(
2985 &ctx,
2986 &format!("execute_action {} promise", action_name),
2987 );
2988 }
2989 }
2990 }
2991 }
2992 Err(e) => {
2993 log_js_error(&ctx, e, &format!("action {}", action_name));
2994 }
2995 }
2996 });
2997
2998 Ok(())
2999 }
3000
3001 pub fn poll_event_loop_once(&mut self) -> bool {
3003 let mut had_work = false;
3004
3005 self.main_context.with(|ctx| {
3007 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3008 if count > 0 {
3009 had_work = true;
3010 }
3011 });
3012
3013 let contexts = self.plugin_contexts.borrow().clone();
3015 for (name, context) in contexts {
3016 context.with(|ctx| {
3017 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3018 if count > 0 {
3019 had_work = true;
3020 }
3021 });
3022 }
3023 had_work
3024 }
3025
3026 pub fn send_status(&self, message: String) {
3028 let _ = self
3029 .command_sender
3030 .send(PluginCommand::SetStatus { message });
3031 }
3032
3033 pub fn resolve_callback(
3038 &mut self,
3039 callback_id: fresh_core::api::JsCallbackId,
3040 result_json: &str,
3041 ) {
3042 let id = callback_id.as_u64();
3043 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3044
3045 let plugin_name = {
3047 let mut contexts = self.callback_contexts.borrow_mut();
3048 contexts.remove(&id)
3049 };
3050
3051 let Some(name) = plugin_name else {
3052 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3053 return;
3054 };
3055
3056 let plugin_contexts = self.plugin_contexts.borrow();
3057 let Some(context) = plugin_contexts.get(&name) else {
3058 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3059 return;
3060 };
3061
3062 context.with(|ctx| {
3063 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3065 Ok(v) => v,
3066 Err(e) => {
3067 tracing::error!(
3068 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3069 id,
3070 e
3071 );
3072 return;
3073 }
3074 };
3075
3076 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3078 Ok(v) => v,
3079 Err(e) => {
3080 tracing::error!(
3081 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3082 id,
3083 e
3084 );
3085 return;
3086 }
3087 };
3088
3089 let globals = ctx.globals();
3091 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3092 Ok(f) => f,
3093 Err(e) => {
3094 tracing::error!(
3095 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3096 id,
3097 e
3098 );
3099 return;
3100 }
3101 };
3102
3103 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3105 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3106 }
3107
3108 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3110 tracing::info!(
3111 "resolve_callback: executed {} pending jobs for callback_id={}",
3112 job_count,
3113 id
3114 );
3115 });
3116 }
3117
3118 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3120 let id = callback_id.as_u64();
3121
3122 let plugin_name = {
3124 let mut contexts = self.callback_contexts.borrow_mut();
3125 contexts.remove(&id)
3126 };
3127
3128 let Some(name) = plugin_name else {
3129 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3130 return;
3131 };
3132
3133 let plugin_contexts = self.plugin_contexts.borrow();
3134 let Some(context) = plugin_contexts.get(&name) else {
3135 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3136 return;
3137 };
3138
3139 context.with(|ctx| {
3140 let globals = ctx.globals();
3142 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3143 Ok(f) => f,
3144 Err(e) => {
3145 tracing::error!(
3146 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3147 id,
3148 e
3149 );
3150 return;
3151 }
3152 };
3153
3154 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3156 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3157 }
3158
3159 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3161 });
3162 }
3163}
3164
3165#[cfg(test)]
3166mod tests {
3167 use super::*;
3168 use fresh_core::api::{BufferInfo, CursorInfo};
3169 use std::sync::mpsc;
3170
3171 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3173 let (tx, rx) = mpsc::channel();
3174 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3175 let services = Arc::new(TestServiceBridge::new());
3176 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3177 (backend, rx)
3178 }
3179
3180 struct TestServiceBridge {
3181 en_strings: std::sync::Mutex<HashMap<String, String>>,
3182 }
3183
3184 impl TestServiceBridge {
3185 fn new() -> Self {
3186 Self {
3187 en_strings: std::sync::Mutex::new(HashMap::new()),
3188 }
3189 }
3190 }
3191
3192 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3193 fn as_any(&self) -> &dyn std::any::Any {
3194 self
3195 }
3196 fn translate(
3197 &self,
3198 _plugin_name: &str,
3199 key: &str,
3200 _args: &HashMap<String, String>,
3201 ) -> String {
3202 self.en_strings
3203 .lock()
3204 .unwrap()
3205 .get(key)
3206 .cloned()
3207 .unwrap_or_else(|| key.to_string())
3208 }
3209 fn current_locale(&self) -> String {
3210 "en".to_string()
3211 }
3212 fn set_js_execution_state(&self, _state: String) {}
3213 fn clear_js_execution_state(&self) {}
3214 fn get_theme_schema(&self) -> serde_json::Value {
3215 serde_json::json!({})
3216 }
3217 fn get_builtin_themes(&self) -> serde_json::Value {
3218 serde_json::json!([])
3219 }
3220 fn register_command(&self, _command: fresh_core::command::Command) {}
3221 fn unregister_command(&self, _name: &str) {}
3222 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3223 fn plugins_dir(&self) -> std::path::PathBuf {
3224 std::path::PathBuf::from("/tmp/plugins")
3225 }
3226 fn config_dir(&self) -> std::path::PathBuf {
3227 std::path::PathBuf::from("/tmp/config")
3228 }
3229 }
3230
3231 #[test]
3232 fn test_quickjs_backend_creation() {
3233 let backend = QuickJsBackend::new();
3234 assert!(backend.is_ok());
3235 }
3236
3237 #[test]
3238 fn test_execute_simple_js() {
3239 let mut backend = QuickJsBackend::new().unwrap();
3240 let result = backend.execute_js("const x = 1 + 2;", "test.js");
3241 assert!(result.is_ok());
3242 }
3243
3244 #[test]
3245 fn test_event_handler_registration() {
3246 let backend = QuickJsBackend::new().unwrap();
3247
3248 assert!(!backend.has_handlers("test_event"));
3250
3251 backend
3253 .event_handlers
3254 .borrow_mut()
3255 .entry("test_event".to_string())
3256 .or_default()
3257 .push(PluginHandler {
3258 plugin_name: "test".to_string(),
3259 handler_name: "testHandler".to_string(),
3260 });
3261
3262 assert!(backend.has_handlers("test_event"));
3264 }
3265
3266 #[test]
3269 fn test_api_set_status() {
3270 let (mut backend, rx) = create_test_backend();
3271
3272 backend
3273 .execute_js(
3274 r#"
3275 const editor = getEditor();
3276 editor.setStatus("Hello from test");
3277 "#,
3278 "test.js",
3279 )
3280 .unwrap();
3281
3282 let cmd = rx.try_recv().unwrap();
3283 match cmd {
3284 PluginCommand::SetStatus { message } => {
3285 assert_eq!(message, "Hello from test");
3286 }
3287 _ => panic!("Expected SetStatus command, got {:?}", cmd),
3288 }
3289 }
3290
3291 #[test]
3292 fn test_api_register_command() {
3293 let (mut backend, rx) = create_test_backend();
3294
3295 backend
3296 .execute_js(
3297 r#"
3298 const editor = getEditor();
3299 globalThis.myTestHandler = function() { };
3300 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3301 "#,
3302 "test_plugin.js",
3303 )
3304 .unwrap();
3305
3306 let cmd = rx.try_recv().unwrap();
3307 match cmd {
3308 PluginCommand::RegisterCommand { command } => {
3309 assert_eq!(command.name, "Test Command");
3310 assert_eq!(command.description, "A test command");
3311 assert_eq!(command.plugin_name, "test_plugin");
3313 }
3314 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3315 }
3316 }
3317
3318 #[test]
3319 fn test_api_define_mode() {
3320 let (mut backend, rx) = create_test_backend();
3321
3322 backend
3323 .execute_js(
3324 r#"
3325 const editor = getEditor();
3326 editor.defineMode("test-mode", null, [
3327 ["a", "action_a"],
3328 ["b", "action_b"]
3329 ]);
3330 "#,
3331 "test.js",
3332 )
3333 .unwrap();
3334
3335 let cmd = rx.try_recv().unwrap();
3336 match cmd {
3337 PluginCommand::DefineMode {
3338 name,
3339 parent,
3340 bindings,
3341 read_only,
3342 } => {
3343 assert_eq!(name, "test-mode");
3344 assert!(parent.is_none());
3345 assert_eq!(bindings.len(), 2);
3346 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3347 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3348 assert!(!read_only);
3349 }
3350 _ => panic!("Expected DefineMode, got {:?}", cmd),
3351 }
3352 }
3353
3354 #[test]
3355 fn test_api_set_editor_mode() {
3356 let (mut backend, rx) = create_test_backend();
3357
3358 backend
3359 .execute_js(
3360 r#"
3361 const editor = getEditor();
3362 editor.setEditorMode("vi-normal");
3363 "#,
3364 "test.js",
3365 )
3366 .unwrap();
3367
3368 let cmd = rx.try_recv().unwrap();
3369 match cmd {
3370 PluginCommand::SetEditorMode { mode } => {
3371 assert_eq!(mode, Some("vi-normal".to_string()));
3372 }
3373 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3374 }
3375 }
3376
3377 #[test]
3378 fn test_api_clear_editor_mode() {
3379 let (mut backend, rx) = create_test_backend();
3380
3381 backend
3382 .execute_js(
3383 r#"
3384 const editor = getEditor();
3385 editor.setEditorMode(null);
3386 "#,
3387 "test.js",
3388 )
3389 .unwrap();
3390
3391 let cmd = rx.try_recv().unwrap();
3392 match cmd {
3393 PluginCommand::SetEditorMode { mode } => {
3394 assert!(mode.is_none());
3395 }
3396 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3397 }
3398 }
3399
3400 #[test]
3401 fn test_api_insert_at_cursor() {
3402 let (mut backend, rx) = create_test_backend();
3403
3404 backend
3405 .execute_js(
3406 r#"
3407 const editor = getEditor();
3408 editor.insertAtCursor("Hello, World!");
3409 "#,
3410 "test.js",
3411 )
3412 .unwrap();
3413
3414 let cmd = rx.try_recv().unwrap();
3415 match cmd {
3416 PluginCommand::InsertAtCursor { text } => {
3417 assert_eq!(text, "Hello, World!");
3418 }
3419 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3420 }
3421 }
3422
3423 #[test]
3424 fn test_api_set_context() {
3425 let (mut backend, rx) = create_test_backend();
3426
3427 backend
3428 .execute_js(
3429 r#"
3430 const editor = getEditor();
3431 editor.setContext("myContext", true);
3432 "#,
3433 "test.js",
3434 )
3435 .unwrap();
3436
3437 let cmd = rx.try_recv().unwrap();
3438 match cmd {
3439 PluginCommand::SetContext { name, active } => {
3440 assert_eq!(name, "myContext");
3441 assert!(active);
3442 }
3443 _ => panic!("Expected SetContext, got {:?}", cmd),
3444 }
3445 }
3446
3447 #[tokio::test]
3448 async fn test_execute_action_sync_function() {
3449 let (mut backend, rx) = create_test_backend();
3450
3451 backend.registered_actions.borrow_mut().insert(
3453 "my_sync_action".to_string(),
3454 PluginHandler {
3455 plugin_name: "test".to_string(),
3456 handler_name: "my_sync_action".to_string(),
3457 },
3458 );
3459
3460 backend
3462 .execute_js(
3463 r#"
3464 const editor = getEditor();
3465 globalThis.my_sync_action = function() {
3466 editor.setStatus("sync action executed");
3467 };
3468 "#,
3469 "test.js",
3470 )
3471 .unwrap();
3472
3473 while rx.try_recv().is_ok() {}
3475
3476 backend.execute_action("my_sync_action").await.unwrap();
3478
3479 let cmd = rx.try_recv().unwrap();
3481 match cmd {
3482 PluginCommand::SetStatus { message } => {
3483 assert_eq!(message, "sync action executed");
3484 }
3485 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3486 }
3487 }
3488
3489 #[tokio::test]
3490 async fn test_execute_action_async_function() {
3491 let (mut backend, rx) = create_test_backend();
3492
3493 backend.registered_actions.borrow_mut().insert(
3495 "my_async_action".to_string(),
3496 PluginHandler {
3497 plugin_name: "test".to_string(),
3498 handler_name: "my_async_action".to_string(),
3499 },
3500 );
3501
3502 backend
3504 .execute_js(
3505 r#"
3506 const editor = getEditor();
3507 globalThis.my_async_action = async function() {
3508 await Promise.resolve();
3509 editor.setStatus("async action executed");
3510 };
3511 "#,
3512 "test.js",
3513 )
3514 .unwrap();
3515
3516 while rx.try_recv().is_ok() {}
3518
3519 backend.execute_action("my_async_action").await.unwrap();
3521
3522 let cmd = rx.try_recv().unwrap();
3524 match cmd {
3525 PluginCommand::SetStatus { message } => {
3526 assert_eq!(message, "async action executed");
3527 }
3528 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3529 }
3530 }
3531
3532 #[tokio::test]
3533 async fn test_execute_action_with_registered_handler() {
3534 let (mut backend, rx) = create_test_backend();
3535
3536 backend.registered_actions.borrow_mut().insert(
3538 "my_action".to_string(),
3539 PluginHandler {
3540 plugin_name: "test".to_string(),
3541 handler_name: "actual_handler_function".to_string(),
3542 },
3543 );
3544
3545 backend
3546 .execute_js(
3547 r#"
3548 const editor = getEditor();
3549 globalThis.actual_handler_function = function() {
3550 editor.setStatus("handler executed");
3551 };
3552 "#,
3553 "test.js",
3554 )
3555 .unwrap();
3556
3557 while rx.try_recv().is_ok() {}
3559
3560 backend.execute_action("my_action").await.unwrap();
3562
3563 let cmd = rx.try_recv().unwrap();
3564 match cmd {
3565 PluginCommand::SetStatus { message } => {
3566 assert_eq!(message, "handler executed");
3567 }
3568 _ => panic!("Expected SetStatus, got {:?}", cmd),
3569 }
3570 }
3571
3572 #[test]
3573 fn test_api_on_event_registration() {
3574 let (mut backend, _rx) = create_test_backend();
3575
3576 backend
3577 .execute_js(
3578 r#"
3579 const editor = getEditor();
3580 globalThis.myEventHandler = function() { };
3581 editor.on("bufferSave", "myEventHandler");
3582 "#,
3583 "test.js",
3584 )
3585 .unwrap();
3586
3587 assert!(backend.has_handlers("bufferSave"));
3588 }
3589
3590 #[test]
3591 fn test_api_off_event_unregistration() {
3592 let (mut backend, _rx) = create_test_backend();
3593
3594 backend
3595 .execute_js(
3596 r#"
3597 const editor = getEditor();
3598 globalThis.myEventHandler = function() { };
3599 editor.on("bufferSave", "myEventHandler");
3600 editor.off("bufferSave", "myEventHandler");
3601 "#,
3602 "test.js",
3603 )
3604 .unwrap();
3605
3606 assert!(!backend.has_handlers("bufferSave"));
3608 }
3609
3610 #[tokio::test]
3611 async fn test_emit_event() {
3612 let (mut backend, rx) = create_test_backend();
3613
3614 backend
3615 .execute_js(
3616 r#"
3617 const editor = getEditor();
3618 globalThis.onSaveHandler = function(data) {
3619 editor.setStatus("saved: " + JSON.stringify(data));
3620 };
3621 editor.on("bufferSave", "onSaveHandler");
3622 "#,
3623 "test.js",
3624 )
3625 .unwrap();
3626
3627 while rx.try_recv().is_ok() {}
3629
3630 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
3632 backend.emit("bufferSave", &event_data).await.unwrap();
3633
3634 let cmd = rx.try_recv().unwrap();
3635 match cmd {
3636 PluginCommand::SetStatus { message } => {
3637 assert!(message.contains("/test.txt"));
3638 }
3639 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
3640 }
3641 }
3642
3643 #[test]
3644 fn test_api_copy_to_clipboard() {
3645 let (mut backend, rx) = create_test_backend();
3646
3647 backend
3648 .execute_js(
3649 r#"
3650 const editor = getEditor();
3651 editor.copyToClipboard("clipboard text");
3652 "#,
3653 "test.js",
3654 )
3655 .unwrap();
3656
3657 let cmd = rx.try_recv().unwrap();
3658 match cmd {
3659 PluginCommand::SetClipboard { text } => {
3660 assert_eq!(text, "clipboard text");
3661 }
3662 _ => panic!("Expected SetClipboard, got {:?}", cmd),
3663 }
3664 }
3665
3666 #[test]
3667 fn test_api_open_file() {
3668 let (mut backend, rx) = create_test_backend();
3669
3670 backend
3672 .execute_js(
3673 r#"
3674 const editor = getEditor();
3675 editor.openFile("/path/to/file.txt", null, null);
3676 "#,
3677 "test.js",
3678 )
3679 .unwrap();
3680
3681 let cmd = rx.try_recv().unwrap();
3682 match cmd {
3683 PluginCommand::OpenFileAtLocation { path, line, column } => {
3684 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
3685 assert!(line.is_none());
3686 assert!(column.is_none());
3687 }
3688 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
3689 }
3690 }
3691
3692 #[test]
3693 fn test_api_delete_range() {
3694 let (mut backend, rx) = create_test_backend();
3695
3696 backend
3698 .execute_js(
3699 r#"
3700 const editor = getEditor();
3701 editor.deleteRange(0, 10, 20);
3702 "#,
3703 "test.js",
3704 )
3705 .unwrap();
3706
3707 let cmd = rx.try_recv().unwrap();
3708 match cmd {
3709 PluginCommand::DeleteRange { range, .. } => {
3710 assert_eq!(range.start, 10);
3711 assert_eq!(range.end, 20);
3712 }
3713 _ => panic!("Expected DeleteRange, got {:?}", cmd),
3714 }
3715 }
3716
3717 #[test]
3718 fn test_api_insert_text() {
3719 let (mut backend, rx) = create_test_backend();
3720
3721 backend
3723 .execute_js(
3724 r#"
3725 const editor = getEditor();
3726 editor.insertText(0, 5, "inserted");
3727 "#,
3728 "test.js",
3729 )
3730 .unwrap();
3731
3732 let cmd = rx.try_recv().unwrap();
3733 match cmd {
3734 PluginCommand::InsertText { position, text, .. } => {
3735 assert_eq!(position, 5);
3736 assert_eq!(text, "inserted");
3737 }
3738 _ => panic!("Expected InsertText, got {:?}", cmd),
3739 }
3740 }
3741
3742 #[test]
3743 fn test_api_set_buffer_cursor() {
3744 let (mut backend, rx) = create_test_backend();
3745
3746 backend
3748 .execute_js(
3749 r#"
3750 const editor = getEditor();
3751 editor.setBufferCursor(0, 100);
3752 "#,
3753 "test.js",
3754 )
3755 .unwrap();
3756
3757 let cmd = rx.try_recv().unwrap();
3758 match cmd {
3759 PluginCommand::SetBufferCursor { position, .. } => {
3760 assert_eq!(position, 100);
3761 }
3762 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
3763 }
3764 }
3765
3766 #[test]
3767 fn test_api_get_cursor_position_from_state() {
3768 let (tx, _rx) = mpsc::channel();
3769 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3770
3771 {
3773 let mut state = state_snapshot.write().unwrap();
3774 state.primary_cursor = Some(CursorInfo {
3775 position: 42,
3776 selection: None,
3777 });
3778 }
3779
3780 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3781 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3782
3783 backend
3785 .execute_js(
3786 r#"
3787 const editor = getEditor();
3788 const pos = editor.getCursorPosition();
3789 globalThis._testResult = pos;
3790 "#,
3791 "test.js",
3792 )
3793 .unwrap();
3794
3795 backend
3797 .plugin_contexts
3798 .borrow()
3799 .get("test")
3800 .unwrap()
3801 .clone()
3802 .with(|ctx| {
3803 let global = ctx.globals();
3804 let result: u32 = global.get("_testResult").unwrap();
3805 assert_eq!(result, 42);
3806 });
3807 }
3808
3809 #[test]
3810 fn test_api_path_functions() {
3811 let (mut backend, _rx) = create_test_backend();
3812
3813 #[cfg(windows)]
3816 let absolute_path = r#"C:\\foo\\bar"#;
3817 #[cfg(not(windows))]
3818 let absolute_path = "/foo/bar";
3819
3820 let js_code = format!(
3822 r#"
3823 const editor = getEditor();
3824 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
3825 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
3826 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
3827 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
3828 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
3829 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
3830 "#,
3831 absolute_path
3832 );
3833 backend.execute_js(&js_code, "test.js").unwrap();
3834
3835 backend
3836 .plugin_contexts
3837 .borrow()
3838 .get("test")
3839 .unwrap()
3840 .clone()
3841 .with(|ctx| {
3842 let global = ctx.globals();
3843 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
3844 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
3845 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
3846 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
3847 assert!(!global.get::<_, bool>("_isRelative").unwrap());
3848 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
3849 });
3850 }
3851
3852 #[test]
3853 fn test_typescript_transpilation() {
3854 use fresh_parser_js::transpile_typescript;
3855
3856 let (mut backend, rx) = create_test_backend();
3857
3858 let ts_code = r#"
3860 const editor = getEditor();
3861 function greet(name: string): string {
3862 return "Hello, " + name;
3863 }
3864 editor.setStatus(greet("TypeScript"));
3865 "#;
3866
3867 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
3869
3870 backend.execute_js(&js_code, "test.js").unwrap();
3872
3873 let cmd = rx.try_recv().unwrap();
3874 match cmd {
3875 PluginCommand::SetStatus { message } => {
3876 assert_eq!(message, "Hello, TypeScript");
3877 }
3878 _ => panic!("Expected SetStatus, got {:?}", cmd),
3879 }
3880 }
3881
3882 #[test]
3883 fn test_api_get_buffer_text_sends_command() {
3884 let (mut backend, rx) = create_test_backend();
3885
3886 backend
3888 .execute_js(
3889 r#"
3890 const editor = getEditor();
3891 // Store the promise for later
3892 globalThis._textPromise = editor.getBufferText(0, 10, 20);
3893 "#,
3894 "test.js",
3895 )
3896 .unwrap();
3897
3898 let cmd = rx.try_recv().unwrap();
3900 match cmd {
3901 PluginCommand::GetBufferText {
3902 buffer_id,
3903 start,
3904 end,
3905 request_id,
3906 } => {
3907 assert_eq!(buffer_id.0, 0);
3908 assert_eq!(start, 10);
3909 assert_eq!(end, 20);
3910 assert!(request_id > 0); }
3912 _ => panic!("Expected GetBufferText, got {:?}", cmd),
3913 }
3914 }
3915
3916 #[test]
3917 fn test_api_get_buffer_text_resolves_callback() {
3918 let (mut backend, rx) = create_test_backend();
3919
3920 backend
3922 .execute_js(
3923 r#"
3924 const editor = getEditor();
3925 globalThis._resolvedText = null;
3926 editor.getBufferText(0, 0, 100).then(text => {
3927 globalThis._resolvedText = text;
3928 });
3929 "#,
3930 "test.js",
3931 )
3932 .unwrap();
3933
3934 let request_id = match rx.try_recv().unwrap() {
3936 PluginCommand::GetBufferText { request_id, .. } => request_id,
3937 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
3938 };
3939
3940 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
3942
3943 backend
3945 .plugin_contexts
3946 .borrow()
3947 .get("test")
3948 .unwrap()
3949 .clone()
3950 .with(|ctx| {
3951 run_pending_jobs_checked(&ctx, "test async getText");
3952 });
3953
3954 backend
3956 .plugin_contexts
3957 .borrow()
3958 .get("test")
3959 .unwrap()
3960 .clone()
3961 .with(|ctx| {
3962 let global = ctx.globals();
3963 let result: String = global.get("_resolvedText").unwrap();
3964 assert_eq!(result, "hello world");
3965 });
3966 }
3967
3968 #[test]
3969 fn test_plugin_translation() {
3970 let (mut backend, _rx) = create_test_backend();
3971
3972 backend
3974 .execute_js(
3975 r#"
3976 const editor = getEditor();
3977 globalThis._translated = editor.t("test.key");
3978 "#,
3979 "test.js",
3980 )
3981 .unwrap();
3982
3983 backend
3984 .plugin_contexts
3985 .borrow()
3986 .get("test")
3987 .unwrap()
3988 .clone()
3989 .with(|ctx| {
3990 let global = ctx.globals();
3991 let result: String = global.get("_translated").unwrap();
3993 assert_eq!(result, "test.key");
3994 });
3995 }
3996
3997 #[test]
3998 fn test_plugin_translation_with_registered_strings() {
3999 let (mut backend, _rx) = create_test_backend();
4000
4001 let mut en_strings = std::collections::HashMap::new();
4003 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4004 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4005
4006 let mut strings = std::collections::HashMap::new();
4007 strings.insert("en".to_string(), en_strings);
4008
4009 if let Some(bridge) = backend
4011 .services
4012 .as_any()
4013 .downcast_ref::<TestServiceBridge>()
4014 {
4015 let mut en = bridge.en_strings.lock().unwrap();
4016 en.insert("greeting".to_string(), "Hello, World!".to_string());
4017 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4018 }
4019
4020 backend
4022 .execute_js(
4023 r#"
4024 const editor = getEditor();
4025 globalThis._greeting = editor.t("greeting");
4026 globalThis._prompt = editor.t("prompt.find_file");
4027 globalThis._missing = editor.t("nonexistent.key");
4028 "#,
4029 "test.js",
4030 )
4031 .unwrap();
4032
4033 backend
4034 .plugin_contexts
4035 .borrow()
4036 .get("test")
4037 .unwrap()
4038 .clone()
4039 .with(|ctx| {
4040 let global = ctx.globals();
4041 let greeting: String = global.get("_greeting").unwrap();
4042 assert_eq!(greeting, "Hello, World!");
4043
4044 let prompt: String = global.get("_prompt").unwrap();
4045 assert_eq!(prompt, "Find file: ");
4046
4047 let missing: String = global.get("_missing").unwrap();
4049 assert_eq!(missing, "nonexistent.key");
4050 });
4051 }
4052
4053 #[test]
4056 fn test_api_set_line_indicator() {
4057 let (mut backend, rx) = create_test_backend();
4058
4059 backend
4060 .execute_js(
4061 r#"
4062 const editor = getEditor();
4063 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4064 "#,
4065 "test.js",
4066 )
4067 .unwrap();
4068
4069 let cmd = rx.try_recv().unwrap();
4070 match cmd {
4071 PluginCommand::SetLineIndicator {
4072 buffer_id,
4073 line,
4074 namespace,
4075 symbol,
4076 color,
4077 priority,
4078 } => {
4079 assert_eq!(buffer_id.0, 1);
4080 assert_eq!(line, 5);
4081 assert_eq!(namespace, "test-ns");
4082 assert_eq!(symbol, "●");
4083 assert_eq!(color, (255, 0, 0));
4084 assert_eq!(priority, 10);
4085 }
4086 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4087 }
4088 }
4089
4090 #[test]
4091 fn test_api_clear_line_indicators() {
4092 let (mut backend, rx) = create_test_backend();
4093
4094 backend
4095 .execute_js(
4096 r#"
4097 const editor = getEditor();
4098 editor.clearLineIndicators(1, "test-ns");
4099 "#,
4100 "test.js",
4101 )
4102 .unwrap();
4103
4104 let cmd = rx.try_recv().unwrap();
4105 match cmd {
4106 PluginCommand::ClearLineIndicators {
4107 buffer_id,
4108 namespace,
4109 } => {
4110 assert_eq!(buffer_id.0, 1);
4111 assert_eq!(namespace, "test-ns");
4112 }
4113 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4114 }
4115 }
4116
4117 #[test]
4120 fn test_api_create_virtual_buffer_sends_command() {
4121 let (mut backend, rx) = create_test_backend();
4122
4123 backend
4124 .execute_js(
4125 r#"
4126 const editor = getEditor();
4127 editor.createVirtualBuffer({
4128 name: "*Test Buffer*",
4129 mode: "test-mode",
4130 readOnly: true,
4131 entries: [
4132 { text: "Line 1\n", properties: { type: "header" } },
4133 { text: "Line 2\n", properties: { type: "content" } }
4134 ],
4135 showLineNumbers: false,
4136 showCursors: true,
4137 editingDisabled: true
4138 });
4139 "#,
4140 "test.js",
4141 )
4142 .unwrap();
4143
4144 let cmd = rx.try_recv().unwrap();
4145 match cmd {
4146 PluginCommand::CreateVirtualBufferWithContent {
4147 name,
4148 mode,
4149 read_only,
4150 entries,
4151 show_line_numbers,
4152 show_cursors,
4153 editing_disabled,
4154 ..
4155 } => {
4156 assert_eq!(name, "*Test Buffer*");
4157 assert_eq!(mode, "test-mode");
4158 assert!(read_only);
4159 assert_eq!(entries.len(), 2);
4160 assert_eq!(entries[0].text, "Line 1\n");
4161 assert!(!show_line_numbers);
4162 assert!(show_cursors);
4163 assert!(editing_disabled);
4164 }
4165 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4166 }
4167 }
4168
4169 #[test]
4170 fn test_api_set_virtual_buffer_content() {
4171 let (mut backend, rx) = create_test_backend();
4172
4173 backend
4174 .execute_js(
4175 r#"
4176 const editor = getEditor();
4177 editor.setVirtualBufferContent(5, [
4178 { text: "New content\n", properties: { type: "updated" } }
4179 ]);
4180 "#,
4181 "test.js",
4182 )
4183 .unwrap();
4184
4185 let cmd = rx.try_recv().unwrap();
4186 match cmd {
4187 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4188 assert_eq!(buffer_id.0, 5);
4189 assert_eq!(entries.len(), 1);
4190 assert_eq!(entries[0].text, "New content\n");
4191 }
4192 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4193 }
4194 }
4195
4196 #[test]
4199 fn test_api_add_overlay() {
4200 let (mut backend, rx) = create_test_backend();
4201
4202 backend.execute_js(r#"
4203 const editor = getEditor();
4204 editor.addOverlay(1, "highlight", 10, 20, 255, 128, 0, false, true, false, 50, 50, 50, false);
4205 "#, "test.js").unwrap();
4206
4207 let cmd = rx.try_recv().unwrap();
4208 match cmd {
4209 PluginCommand::AddOverlay {
4210 buffer_id,
4211 namespace,
4212 range,
4213 color,
4214 bg_color,
4215 underline,
4216 bold,
4217 italic,
4218 extend_to_line_end,
4219 } => {
4220 assert_eq!(buffer_id.0, 1);
4221 assert!(namespace.is_some());
4222 assert_eq!(namespace.unwrap().as_str(), "highlight");
4223 assert_eq!(range, 10..20);
4224 assert_eq!(color, (255, 128, 0));
4225 assert_eq!(bg_color, Some((50, 50, 50)));
4226 assert!(!underline);
4227 assert!(bold);
4228 assert!(!italic);
4229 assert!(!extend_to_line_end);
4230 }
4231 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4232 }
4233 }
4234
4235 #[test]
4236 fn test_api_clear_namespace() {
4237 let (mut backend, rx) = create_test_backend();
4238
4239 backend
4240 .execute_js(
4241 r#"
4242 const editor = getEditor();
4243 editor.clearNamespace(1, "highlight");
4244 "#,
4245 "test.js",
4246 )
4247 .unwrap();
4248
4249 let cmd = rx.try_recv().unwrap();
4250 match cmd {
4251 PluginCommand::ClearNamespace {
4252 buffer_id,
4253 namespace,
4254 } => {
4255 assert_eq!(buffer_id.0, 1);
4256 assert_eq!(namespace.as_str(), "highlight");
4257 }
4258 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4259 }
4260 }
4261
4262 #[test]
4265 fn test_api_get_theme_schema() {
4266 let (mut backend, _rx) = create_test_backend();
4267
4268 backend
4269 .execute_js(
4270 r#"
4271 const editor = getEditor();
4272 const schema = editor.getThemeSchema();
4273 globalThis._isObject = typeof schema === 'object' && schema !== null;
4274 "#,
4275 "test.js",
4276 )
4277 .unwrap();
4278
4279 backend
4280 .plugin_contexts
4281 .borrow()
4282 .get("test")
4283 .unwrap()
4284 .clone()
4285 .with(|ctx| {
4286 let global = ctx.globals();
4287 let is_object: bool = global.get("_isObject").unwrap();
4288 assert!(is_object);
4290 });
4291 }
4292
4293 #[test]
4294 fn test_api_get_builtin_themes() {
4295 let (mut backend, _rx) = create_test_backend();
4296
4297 backend
4298 .execute_js(
4299 r#"
4300 const editor = getEditor();
4301 const themes = editor.getBuiltinThemes();
4302 globalThis._isObject = typeof themes === 'object' && themes !== null;
4303 "#,
4304 "test.js",
4305 )
4306 .unwrap();
4307
4308 backend
4309 .plugin_contexts
4310 .borrow()
4311 .get("test")
4312 .unwrap()
4313 .clone()
4314 .with(|ctx| {
4315 let global = ctx.globals();
4316 let is_object: bool = global.get("_isObject").unwrap();
4317 assert!(is_object);
4319 });
4320 }
4321
4322 #[test]
4323 fn test_api_apply_theme() {
4324 let (mut backend, rx) = create_test_backend();
4325
4326 backend
4327 .execute_js(
4328 r#"
4329 const editor = getEditor();
4330 editor.applyTheme("dark");
4331 "#,
4332 "test.js",
4333 )
4334 .unwrap();
4335
4336 let cmd = rx.try_recv().unwrap();
4337 match cmd {
4338 PluginCommand::ApplyTheme { theme_name } => {
4339 assert_eq!(theme_name, "dark");
4340 }
4341 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4342 }
4343 }
4344
4345 #[test]
4348 fn test_api_close_buffer() {
4349 let (mut backend, rx) = create_test_backend();
4350
4351 backend
4352 .execute_js(
4353 r#"
4354 const editor = getEditor();
4355 editor.closeBuffer(3);
4356 "#,
4357 "test.js",
4358 )
4359 .unwrap();
4360
4361 let cmd = rx.try_recv().unwrap();
4362 match cmd {
4363 PluginCommand::CloseBuffer { buffer_id } => {
4364 assert_eq!(buffer_id.0, 3);
4365 }
4366 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4367 }
4368 }
4369
4370 #[test]
4371 fn test_api_focus_split() {
4372 let (mut backend, rx) = create_test_backend();
4373
4374 backend
4375 .execute_js(
4376 r#"
4377 const editor = getEditor();
4378 editor.focusSplit(2);
4379 "#,
4380 "test.js",
4381 )
4382 .unwrap();
4383
4384 let cmd = rx.try_recv().unwrap();
4385 match cmd {
4386 PluginCommand::FocusSplit { split_id } => {
4387 assert_eq!(split_id.0, 2);
4388 }
4389 _ => panic!("Expected FocusSplit, got {:?}", cmd),
4390 }
4391 }
4392
4393 #[test]
4394 fn test_api_list_buffers() {
4395 let (tx, _rx) = mpsc::channel();
4396 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4397
4398 {
4400 let mut state = state_snapshot.write().unwrap();
4401 state.buffers.insert(
4402 BufferId(0),
4403 BufferInfo {
4404 id: BufferId(0),
4405 path: Some(PathBuf::from("/test1.txt")),
4406 modified: false,
4407 length: 100,
4408 },
4409 );
4410 state.buffers.insert(
4411 BufferId(1),
4412 BufferInfo {
4413 id: BufferId(1),
4414 path: Some(PathBuf::from("/test2.txt")),
4415 modified: true,
4416 length: 200,
4417 },
4418 );
4419 }
4420
4421 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4422 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4423
4424 backend
4425 .execute_js(
4426 r#"
4427 const editor = getEditor();
4428 const buffers = editor.listBuffers();
4429 globalThis._isArray = Array.isArray(buffers);
4430 globalThis._length = buffers.length;
4431 "#,
4432 "test.js",
4433 )
4434 .unwrap();
4435
4436 backend
4437 .plugin_contexts
4438 .borrow()
4439 .get("test")
4440 .unwrap()
4441 .clone()
4442 .with(|ctx| {
4443 let global = ctx.globals();
4444 let is_array: bool = global.get("_isArray").unwrap();
4445 let length: u32 = global.get("_length").unwrap();
4446 assert!(is_array);
4447 assert_eq!(length, 2);
4448 });
4449 }
4450
4451 #[test]
4454 fn test_api_start_prompt() {
4455 let (mut backend, rx) = create_test_backend();
4456
4457 backend
4458 .execute_js(
4459 r#"
4460 const editor = getEditor();
4461 editor.startPrompt("Enter value:", "test-prompt");
4462 "#,
4463 "test.js",
4464 )
4465 .unwrap();
4466
4467 let cmd = rx.try_recv().unwrap();
4468 match cmd {
4469 PluginCommand::StartPrompt { label, prompt_type } => {
4470 assert_eq!(label, "Enter value:");
4471 assert_eq!(prompt_type, "test-prompt");
4472 }
4473 _ => panic!("Expected StartPrompt, got {:?}", cmd),
4474 }
4475 }
4476
4477 #[test]
4478 fn test_api_start_prompt_with_initial() {
4479 let (mut backend, rx) = create_test_backend();
4480
4481 backend
4482 .execute_js(
4483 r#"
4484 const editor = getEditor();
4485 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4486 "#,
4487 "test.js",
4488 )
4489 .unwrap();
4490
4491 let cmd = rx.try_recv().unwrap();
4492 match cmd {
4493 PluginCommand::StartPromptWithInitial {
4494 label,
4495 prompt_type,
4496 initial_value,
4497 } => {
4498 assert_eq!(label, "Enter value:");
4499 assert_eq!(prompt_type, "test-prompt");
4500 assert_eq!(initial_value, "default");
4501 }
4502 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4503 }
4504 }
4505
4506 #[test]
4507 fn test_api_set_prompt_suggestions() {
4508 let (mut backend, rx) = create_test_backend();
4509
4510 backend
4511 .execute_js(
4512 r#"
4513 const editor = getEditor();
4514 editor.setPromptSuggestions([
4515 { text: "Option 1", value: "opt1" },
4516 { text: "Option 2", value: "opt2" }
4517 ]);
4518 "#,
4519 "test.js",
4520 )
4521 .unwrap();
4522
4523 let cmd = rx.try_recv().unwrap();
4524 match cmd {
4525 PluginCommand::SetPromptSuggestions { suggestions } => {
4526 assert_eq!(suggestions.len(), 2);
4527 assert_eq!(suggestions[0].text, "Option 1");
4528 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4529 }
4530 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4531 }
4532 }
4533
4534 #[test]
4537 fn test_api_get_active_buffer_id() {
4538 let (tx, _rx) = mpsc::channel();
4539 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4540
4541 {
4542 let mut state = state_snapshot.write().unwrap();
4543 state.active_buffer_id = BufferId(42);
4544 }
4545
4546 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4547 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4548
4549 backend
4550 .execute_js(
4551 r#"
4552 const editor = getEditor();
4553 globalThis._activeId = editor.getActiveBufferId();
4554 "#,
4555 "test.js",
4556 )
4557 .unwrap();
4558
4559 backend
4560 .plugin_contexts
4561 .borrow()
4562 .get("test")
4563 .unwrap()
4564 .clone()
4565 .with(|ctx| {
4566 let global = ctx.globals();
4567 let result: u32 = global.get("_activeId").unwrap();
4568 assert_eq!(result, 42);
4569 });
4570 }
4571
4572 #[test]
4573 fn test_api_get_active_split_id() {
4574 let (tx, _rx) = mpsc::channel();
4575 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4576
4577 {
4578 let mut state = state_snapshot.write().unwrap();
4579 state.active_split_id = 7;
4580 }
4581
4582 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4583 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4584
4585 backend
4586 .execute_js(
4587 r#"
4588 const editor = getEditor();
4589 globalThis._splitId = editor.getActiveSplitId();
4590 "#,
4591 "test.js",
4592 )
4593 .unwrap();
4594
4595 backend
4596 .plugin_contexts
4597 .borrow()
4598 .get("test")
4599 .unwrap()
4600 .clone()
4601 .with(|ctx| {
4602 let global = ctx.globals();
4603 let result: u32 = global.get("_splitId").unwrap();
4604 assert_eq!(result, 7);
4605 });
4606 }
4607
4608 #[test]
4611 fn test_api_file_exists() {
4612 let (mut backend, _rx) = create_test_backend();
4613
4614 backend
4615 .execute_js(
4616 r#"
4617 const editor = getEditor();
4618 // Test with a path that definitely exists
4619 globalThis._exists = editor.fileExists("/");
4620 "#,
4621 "test.js",
4622 )
4623 .unwrap();
4624
4625 backend
4626 .plugin_contexts
4627 .borrow()
4628 .get("test")
4629 .unwrap()
4630 .clone()
4631 .with(|ctx| {
4632 let global = ctx.globals();
4633 let result: bool = global.get("_exists").unwrap();
4634 assert!(result);
4635 });
4636 }
4637
4638 #[test]
4639 fn test_api_get_cwd() {
4640 let (mut backend, _rx) = create_test_backend();
4641
4642 backend
4643 .execute_js(
4644 r#"
4645 const editor = getEditor();
4646 globalThis._cwd = editor.getCwd();
4647 "#,
4648 "test.js",
4649 )
4650 .unwrap();
4651
4652 backend
4653 .plugin_contexts
4654 .borrow()
4655 .get("test")
4656 .unwrap()
4657 .clone()
4658 .with(|ctx| {
4659 let global = ctx.globals();
4660 let result: String = global.get("_cwd").unwrap();
4661 assert!(!result.is_empty());
4663 });
4664 }
4665
4666 #[test]
4667 fn test_api_get_env() {
4668 let (mut backend, _rx) = create_test_backend();
4669
4670 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
4672
4673 backend
4674 .execute_js(
4675 r#"
4676 const editor = getEditor();
4677 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
4678 "#,
4679 "test.js",
4680 )
4681 .unwrap();
4682
4683 backend
4684 .plugin_contexts
4685 .borrow()
4686 .get("test")
4687 .unwrap()
4688 .clone()
4689 .with(|ctx| {
4690 let global = ctx.globals();
4691 let result: Option<String> = global.get("_envVal").unwrap();
4692 assert_eq!(result, Some("test_value".to_string()));
4693 });
4694
4695 std::env::remove_var("TEST_PLUGIN_VAR");
4696 }
4697
4698 #[test]
4699 fn test_api_get_config() {
4700 let (mut backend, _rx) = create_test_backend();
4701
4702 backend
4703 .execute_js(
4704 r#"
4705 const editor = getEditor();
4706 const config = editor.getConfig();
4707 globalThis._isObject = typeof config === 'object';
4708 "#,
4709 "test.js",
4710 )
4711 .unwrap();
4712
4713 backend
4714 .plugin_contexts
4715 .borrow()
4716 .get("test")
4717 .unwrap()
4718 .clone()
4719 .with(|ctx| {
4720 let global = ctx.globals();
4721 let is_object: bool = global.get("_isObject").unwrap();
4722 assert!(is_object);
4724 });
4725 }
4726
4727 #[test]
4728 fn test_api_get_themes_dir() {
4729 let (mut backend, _rx) = create_test_backend();
4730
4731 backend
4732 .execute_js(
4733 r#"
4734 const editor = getEditor();
4735 globalThis._themesDir = editor.getThemesDir();
4736 "#,
4737 "test.js",
4738 )
4739 .unwrap();
4740
4741 backend
4742 .plugin_contexts
4743 .borrow()
4744 .get("test")
4745 .unwrap()
4746 .clone()
4747 .with(|ctx| {
4748 let global = ctx.globals();
4749 let result: String = global.get("_themesDir").unwrap();
4750 assert!(!result.is_empty());
4752 });
4753 }
4754
4755 #[test]
4758 fn test_api_read_dir() {
4759 let (mut backend, _rx) = create_test_backend();
4760
4761 backend
4762 .execute_js(
4763 r#"
4764 const editor = getEditor();
4765 const entries = editor.readDir("/tmp");
4766 globalThis._isArray = Array.isArray(entries);
4767 globalThis._length = entries.length;
4768 "#,
4769 "test.js",
4770 )
4771 .unwrap();
4772
4773 backend
4774 .plugin_contexts
4775 .borrow()
4776 .get("test")
4777 .unwrap()
4778 .clone()
4779 .with(|ctx| {
4780 let global = ctx.globals();
4781 let is_array: bool = global.get("_isArray").unwrap();
4782 let length: u32 = global.get("_length").unwrap();
4783 assert!(is_array);
4785 let _ = length;
4787 });
4788 }
4789
4790 #[test]
4793 fn test_api_execute_action() {
4794 let (mut backend, rx) = create_test_backend();
4795
4796 backend
4797 .execute_js(
4798 r#"
4799 const editor = getEditor();
4800 editor.executeAction("move_cursor_up");
4801 "#,
4802 "test.js",
4803 )
4804 .unwrap();
4805
4806 let cmd = rx.try_recv().unwrap();
4807 match cmd {
4808 PluginCommand::ExecuteAction { action_name } => {
4809 assert_eq!(action_name, "move_cursor_up");
4810 }
4811 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
4812 }
4813 }
4814
4815 #[test]
4818 fn test_api_debug() {
4819 let (mut backend, _rx) = create_test_backend();
4820
4821 backend
4823 .execute_js(
4824 r#"
4825 const editor = getEditor();
4826 editor.debug("Test debug message");
4827 editor.debug("Another message with special chars: <>&\"'");
4828 "#,
4829 "test.js",
4830 )
4831 .unwrap();
4832 }
4834
4835 #[test]
4838 fn test_typescript_preamble_generated() {
4839 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
4841 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
4842 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
4843 println!(
4844 "Generated {} bytes of TypeScript preamble",
4845 JSEDITORAPI_TS_PREAMBLE.len()
4846 );
4847 }
4848
4849 #[test]
4850 fn test_typescript_editor_api_generated() {
4851 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
4853 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
4854 println!(
4855 "Generated {} bytes of EditorAPI interface",
4856 JSEDITORAPI_TS_EDITOR_API.len()
4857 );
4858 }
4859
4860 #[test]
4861 fn test_js_methods_list() {
4862 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
4864 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
4865 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
4867 if i < 20 {
4868 println!(" - {}", method);
4869 }
4870 }
4871 if JSEDITORAPI_JS_METHODS.len() > 20 {
4872 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
4873 }
4874 }
4875}