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 #[plugin_api(ts_return = "JsDiagnostic[]")]
1925 pub fn get_all_diagnostics<'js>(
1926 &self,
1927 ctx: rquickjs::Ctx<'js>,
1928 ) -> rquickjs::Result<Value<'js>> {
1929 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
1930
1931 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
1932 let mut result: Vec<JsDiagnostic> = Vec::new();
1934 for (uri, diags) in &s.diagnostics {
1935 for diag in diags {
1936 result.push(JsDiagnostic {
1937 uri: uri.clone(),
1938 message: diag.message.clone(),
1939 severity: diag.severity.map(|s| match s {
1940 lsp_types::DiagnosticSeverity::ERROR => 1,
1941 lsp_types::DiagnosticSeverity::WARNING => 2,
1942 lsp_types::DiagnosticSeverity::INFORMATION => 3,
1943 lsp_types::DiagnosticSeverity::HINT => 4,
1944 _ => 0,
1945 }),
1946 range: JsRange {
1947 start: JsPosition {
1948 line: diag.range.start.line,
1949 character: diag.range.start.character,
1950 },
1951 end: JsPosition {
1952 line: diag.range.end.line,
1953 character: diag.range.end.character,
1954 },
1955 },
1956 source: diag.source.clone(),
1957 });
1958 }
1959 }
1960 result
1961 } else {
1962 Vec::new()
1963 };
1964 rquickjs_serde::to_value(ctx, &diagnostics)
1965 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1966 }
1967
1968 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
1970 self.event_handlers
1971 .borrow()
1972 .get(&event_name)
1973 .cloned()
1974 .unwrap_or_default()
1975 .into_iter()
1976 .map(|h| h.handler_name)
1977 .collect()
1978 }
1979
1980 #[plugin_api(
1984 async_promise,
1985 js_name = "createVirtualBuffer",
1986 ts_return = "VirtualBufferResult"
1987 )]
1988 #[qjs(rename = "_createVirtualBufferStart")]
1989 pub fn create_virtual_buffer_start(
1990 &self,
1991 _ctx: rquickjs::Ctx<'_>,
1992 opts: fresh_core::api::CreateVirtualBufferOptions,
1993 ) -> rquickjs::Result<u64> {
1994 let id = {
1995 let mut id_ref = self.next_request_id.borrow_mut();
1996 let id = *id_ref;
1997 *id_ref += 1;
1998 self.callback_contexts
2000 .borrow_mut()
2001 .insert(id, self.plugin_name.clone());
2002 id
2003 };
2004
2005 let entries: Vec<TextPropertyEntry> = opts
2007 .entries
2008 .unwrap_or_default()
2009 .into_iter()
2010 .map(|e| TextPropertyEntry {
2011 text: e.text,
2012 properties: e.properties.unwrap_or_default(),
2013 })
2014 .collect();
2015
2016 tracing::debug!(
2017 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2018 id
2019 );
2020 let _ = self
2021 .command_sender
2022 .send(PluginCommand::CreateVirtualBufferWithContent {
2023 name: opts.name,
2024 mode: opts.mode.unwrap_or_default(),
2025 read_only: opts.read_only.unwrap_or(false),
2026 entries,
2027 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2028 show_cursors: opts.show_cursors.unwrap_or(true),
2029 editing_disabled: opts.editing_disabled.unwrap_or(false),
2030 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2031 request_id: Some(id),
2032 });
2033 Ok(id)
2034 }
2035
2036 #[plugin_api(
2038 async_promise,
2039 js_name = "createVirtualBufferInSplit",
2040 ts_return = "VirtualBufferResult"
2041 )]
2042 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2043 pub fn create_virtual_buffer_in_split_start(
2044 &self,
2045 _ctx: rquickjs::Ctx<'_>,
2046 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2047 ) -> rquickjs::Result<u64> {
2048 let id = {
2049 let mut id_ref = self.next_request_id.borrow_mut();
2050 let id = *id_ref;
2051 *id_ref += 1;
2052 self.callback_contexts
2054 .borrow_mut()
2055 .insert(id, self.plugin_name.clone());
2056 id
2057 };
2058
2059 let entries: Vec<TextPropertyEntry> = opts
2061 .entries
2062 .unwrap_or_default()
2063 .into_iter()
2064 .map(|e| TextPropertyEntry {
2065 text: e.text,
2066 properties: e.properties.unwrap_or_default(),
2067 })
2068 .collect();
2069
2070 let _ = self
2071 .command_sender
2072 .send(PluginCommand::CreateVirtualBufferInSplit {
2073 name: opts.name,
2074 mode: opts.mode.unwrap_or_default(),
2075 read_only: opts.read_only.unwrap_or(false),
2076 entries,
2077 ratio: opts.ratio.unwrap_or(0.5),
2078 direction: opts.direction,
2079 panel_id: opts.panel_id,
2080 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2081 show_cursors: opts.show_cursors.unwrap_or(true),
2082 editing_disabled: opts.editing_disabled.unwrap_or(false),
2083 line_wrap: opts.line_wrap,
2084 request_id: Some(id),
2085 });
2086 Ok(id)
2087 }
2088
2089 #[plugin_api(
2091 async_promise,
2092 js_name = "createVirtualBufferInExistingSplit",
2093 ts_return = "VirtualBufferResult"
2094 )]
2095 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2096 pub fn create_virtual_buffer_in_existing_split_start(
2097 &self,
2098 _ctx: rquickjs::Ctx<'_>,
2099 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2100 ) -> rquickjs::Result<u64> {
2101 let id = {
2102 let mut id_ref = self.next_request_id.borrow_mut();
2103 let id = *id_ref;
2104 *id_ref += 1;
2105 self.callback_contexts
2107 .borrow_mut()
2108 .insert(id, self.plugin_name.clone());
2109 id
2110 };
2111
2112 let entries: Vec<TextPropertyEntry> = opts
2114 .entries
2115 .unwrap_or_default()
2116 .into_iter()
2117 .map(|e| TextPropertyEntry {
2118 text: e.text,
2119 properties: e.properties.unwrap_or_default(),
2120 })
2121 .collect();
2122
2123 let _ = self
2124 .command_sender
2125 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2126 name: opts.name,
2127 mode: opts.mode.unwrap_or_default(),
2128 read_only: opts.read_only.unwrap_or(false),
2129 entries,
2130 split_id: SplitId(opts.split_id),
2131 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2132 show_cursors: opts.show_cursors.unwrap_or(true),
2133 editing_disabled: opts.editing_disabled.unwrap_or(false),
2134 line_wrap: opts.line_wrap,
2135 request_id: Some(id),
2136 });
2137 Ok(id)
2138 }
2139
2140 pub fn set_virtual_buffer_content<'js>(
2144 &self,
2145 ctx: rquickjs::Ctx<'js>,
2146 buffer_id: u32,
2147 entries_arr: Vec<rquickjs::Object<'js>>,
2148 ) -> rquickjs::Result<bool> {
2149 let entries: Vec<TextPropertyEntry> = entries_arr
2150 .iter()
2151 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2152 .collect();
2153 Ok(self
2154 .command_sender
2155 .send(PluginCommand::SetVirtualBufferContent {
2156 buffer_id: BufferId(buffer_id as usize),
2157 entries,
2158 })
2159 .is_ok())
2160 }
2161
2162 pub fn get_text_properties_at_cursor(
2164 &self,
2165 buffer_id: u32,
2166 ) -> fresh_core::api::TextPropertiesAtCursor {
2167 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2168 }
2169
2170 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2174 #[qjs(rename = "_spawnProcessStart")]
2175 pub fn spawn_process_start(
2176 &self,
2177 _ctx: rquickjs::Ctx<'_>,
2178 command: String,
2179 args: Vec<String>,
2180 cwd: rquickjs::function::Opt<String>,
2181 ) -> u64 {
2182 let id = {
2183 let mut id_ref = self.next_request_id.borrow_mut();
2184 let id = *id_ref;
2185 *id_ref += 1;
2186 self.callback_contexts
2188 .borrow_mut()
2189 .insert(id, self.plugin_name.clone());
2190 id
2191 };
2192 let effective_cwd = cwd.0.or_else(|| {
2194 self.state_snapshot
2195 .read()
2196 .ok()
2197 .map(|s| s.working_dir.to_string_lossy().to_string())
2198 });
2199 tracing::info!(
2200 "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2201 command,
2202 args,
2203 effective_cwd,
2204 id
2205 );
2206 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2207 callback_id: JsCallbackId::new(id),
2208 command,
2209 args,
2210 cwd: effective_cwd,
2211 });
2212 id
2213 }
2214
2215 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2217 #[qjs(rename = "_spawnProcessWaitStart")]
2218 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2219 let id = {
2220 let mut id_ref = self.next_request_id.borrow_mut();
2221 let id = *id_ref;
2222 *id_ref += 1;
2223 self.callback_contexts
2225 .borrow_mut()
2226 .insert(id, self.plugin_name.clone());
2227 id
2228 };
2229 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2230 process_id,
2231 callback_id: JsCallbackId::new(id),
2232 });
2233 id
2234 }
2235
2236 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2238 #[qjs(rename = "_getBufferTextStart")]
2239 pub fn get_buffer_text_start(
2240 &self,
2241 _ctx: rquickjs::Ctx<'_>,
2242 buffer_id: u32,
2243 start: u32,
2244 end: u32,
2245 ) -> u64 {
2246 let id = {
2247 let mut id_ref = self.next_request_id.borrow_mut();
2248 let id = *id_ref;
2249 *id_ref += 1;
2250 self.callback_contexts
2252 .borrow_mut()
2253 .insert(id, self.plugin_name.clone());
2254 id
2255 };
2256 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2257 buffer_id: BufferId(buffer_id as usize),
2258 start: start as usize,
2259 end: end as usize,
2260 request_id: id,
2261 });
2262 id
2263 }
2264
2265 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2267 #[qjs(rename = "_delayStart")]
2268 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2269 let id = {
2270 let mut id_ref = self.next_request_id.borrow_mut();
2271 let id = *id_ref;
2272 *id_ref += 1;
2273 self.callback_contexts
2275 .borrow_mut()
2276 .insert(id, self.plugin_name.clone());
2277 id
2278 };
2279 let _ = self.command_sender.send(PluginCommand::Delay {
2280 callback_id: JsCallbackId::new(id),
2281 duration_ms,
2282 });
2283 id
2284 }
2285
2286 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2288 #[qjs(rename = "_sendLspRequestStart")]
2289 pub fn send_lsp_request_start<'js>(
2290 &self,
2291 ctx: rquickjs::Ctx<'js>,
2292 language: String,
2293 method: String,
2294 params: Option<rquickjs::Object<'js>>,
2295 ) -> rquickjs::Result<u64> {
2296 let id = {
2297 let mut id_ref = self.next_request_id.borrow_mut();
2298 let id = *id_ref;
2299 *id_ref += 1;
2300 self.callback_contexts
2302 .borrow_mut()
2303 .insert(id, self.plugin_name.clone());
2304 id
2305 };
2306 let params_json: Option<serde_json::Value> = params.map(|obj| {
2308 let val = obj.into_value();
2309 js_to_json(&ctx, val)
2310 });
2311 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2312 request_id: id,
2313 language,
2314 method,
2315 params: params_json,
2316 });
2317 Ok(id)
2318 }
2319
2320 #[plugin_api(
2322 async_thenable,
2323 js_name = "spawnBackgroundProcess",
2324 ts_return = "BackgroundProcessResult"
2325 )]
2326 #[qjs(rename = "_spawnBackgroundProcessStart")]
2327 pub fn spawn_background_process_start(
2328 &self,
2329 _ctx: rquickjs::Ctx<'_>,
2330 command: String,
2331 args: Vec<String>,
2332 cwd: rquickjs::function::Opt<String>,
2333 ) -> u64 {
2334 let id = {
2335 let mut id_ref = self.next_request_id.borrow_mut();
2336 let id = *id_ref;
2337 *id_ref += 1;
2338 self.callback_contexts
2340 .borrow_mut()
2341 .insert(id, self.plugin_name.clone());
2342 id
2343 };
2344 let process_id = id;
2346 let _ = self
2347 .command_sender
2348 .send(PluginCommand::SpawnBackgroundProcess {
2349 process_id,
2350 command,
2351 args,
2352 cwd: cwd.0,
2353 callback_id: JsCallbackId::new(id),
2354 });
2355 id
2356 }
2357
2358 pub fn kill_background_process(&self, process_id: u64) -> bool {
2360 self.command_sender
2361 .send(PluginCommand::KillBackgroundProcess { process_id })
2362 .is_ok()
2363 }
2364
2365 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2369 self.command_sender
2370 .send(PluginCommand::RefreshLines {
2371 buffer_id: BufferId(buffer_id as usize),
2372 })
2373 .is_ok()
2374 }
2375
2376 pub fn get_current_locale(&self) -> String {
2378 self.services.current_locale()
2379 }
2380}
2381
2382pub struct QuickJsBackend {
2384 runtime: Runtime,
2385 main_context: Context,
2387 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2389 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2391 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2393 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2395 command_sender: mpsc::Sender<PluginCommand>,
2397 #[allow(dead_code)]
2399 pending_responses: PendingResponses,
2400 next_request_id: Rc<RefCell<u64>>,
2402 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2404 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2406}
2407
2408impl QuickJsBackend {
2409 pub fn new() -> Result<Self> {
2411 let (tx, _rx) = mpsc::channel();
2412 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2413 let services = Arc::new(fresh_core::services::NoopServiceBridge);
2414 Self::with_state(state_snapshot, tx, services)
2415 }
2416
2417 pub fn with_state(
2419 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2420 command_sender: mpsc::Sender<PluginCommand>,
2421 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2422 ) -> Result<Self> {
2423 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2424 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2425 }
2426
2427 pub fn with_state_and_responses(
2429 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2430 command_sender: mpsc::Sender<PluginCommand>,
2431 pending_responses: PendingResponses,
2432 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2433 ) -> Result<Self> {
2434 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2435
2436 let runtime =
2437 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2438
2439 runtime.set_host_promise_rejection_tracker(Some(Box::new(
2441 |_ctx, _promise, reason, is_handled| {
2442 if !is_handled {
2443 let error_msg = if let Some(exc) = reason.as_exception() {
2445 format!(
2446 "{}: {}",
2447 exc.message().unwrap_or_default(),
2448 exc.stack().unwrap_or_default()
2449 )
2450 } else {
2451 format!("{:?}", reason)
2452 };
2453
2454 tracing::error!("Unhandled Promise rejection: {}", error_msg);
2455
2456 if should_panic_on_js_errors() {
2457 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2460 set_fatal_js_error(full_msg);
2461 }
2462 }
2463 },
2464 )));
2465
2466 let main_context = Context::full(&runtime)
2467 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2468
2469 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2470 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2471 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2472 let next_request_id = Rc::new(RefCell::new(1u64));
2473 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2474
2475 let backend = Self {
2476 runtime,
2477 main_context,
2478 plugin_contexts,
2479 event_handlers,
2480 registered_actions,
2481 state_snapshot,
2482 command_sender,
2483 pending_responses,
2484 next_request_id,
2485 callback_contexts,
2486 services,
2487 };
2488
2489 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2491
2492 tracing::debug!("QuickJsBackend::new: runtime created successfully");
2493 Ok(backend)
2494 }
2495
2496 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2498 let state_snapshot = Arc::clone(&self.state_snapshot);
2499 let command_sender = self.command_sender.clone();
2500 let event_handlers = Rc::clone(&self.event_handlers);
2501 let registered_actions = Rc::clone(&self.registered_actions);
2502 let next_request_id = Rc::clone(&self.next_request_id);
2503
2504 context.with(|ctx| {
2505 let globals = ctx.globals();
2506
2507 globals.set("__pluginName__", plugin_name)?;
2509
2510 let js_api = JsEditorApi {
2513 state_snapshot: Arc::clone(&state_snapshot),
2514 command_sender: command_sender.clone(),
2515 registered_actions: Rc::clone(®istered_actions),
2516 event_handlers: Rc::clone(&event_handlers),
2517 next_request_id: Rc::clone(&next_request_id),
2518 callback_contexts: Rc::clone(&self.callback_contexts),
2519 services: self.services.clone(),
2520 plugin_name: plugin_name.to_string(),
2521 };
2522 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2523
2524 globals.set("editor", editor)?;
2526
2527 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2529
2530 let console = Object::new(ctx.clone())?;
2533 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2534 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2535 tracing::info!("console.log: {}", parts.join(" "));
2536 })?)?;
2537 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2538 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2539 tracing::warn!("console.warn: {}", parts.join(" "));
2540 })?)?;
2541 console.set("error", 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::error!("console.error: {}", parts.join(" "));
2544 })?)?;
2545 globals.set("console", console)?;
2546
2547 ctx.eval::<(), _>(r#"
2549 // Pending promise callbacks: callbackId -> { resolve, reject }
2550 globalThis._pendingCallbacks = new Map();
2551
2552 // Resolve a pending callback (called from Rust)
2553 globalThis._resolveCallback = function(callbackId, result) {
2554 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2555 const cb = globalThis._pendingCallbacks.get(callbackId);
2556 if (cb) {
2557 console.log('[JS] _resolveCallback: found callback, calling resolve()');
2558 globalThis._pendingCallbacks.delete(callbackId);
2559 cb.resolve(result);
2560 console.log('[JS] _resolveCallback: resolve() called');
2561 } else {
2562 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2563 }
2564 };
2565
2566 // Reject a pending callback (called from Rust)
2567 globalThis._rejectCallback = function(callbackId, error) {
2568 const cb = globalThis._pendingCallbacks.get(callbackId);
2569 if (cb) {
2570 globalThis._pendingCallbacks.delete(callbackId);
2571 cb.reject(new Error(error));
2572 }
2573 };
2574
2575 // Generic async wrapper decorator
2576 // Wraps a function that returns a callbackId into a promise-returning function
2577 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2578 // NOTE: We pass the method name as a string and call via bracket notation
2579 // to preserve rquickjs's automatic Ctx injection for methods
2580 globalThis._wrapAsync = function(methodName, fnName) {
2581 const startFn = editor[methodName];
2582 if (typeof startFn !== 'function') {
2583 // Return a function that always throws - catches missing implementations
2584 return function(...args) {
2585 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2586 editor.debug(`[ASYNC ERROR] ${error.message}`);
2587 throw error;
2588 };
2589 }
2590 return function(...args) {
2591 // Call via bracket notation to preserve method binding and Ctx injection
2592 const callbackId = editor[methodName](...args);
2593 return new Promise((resolve, reject) => {
2594 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2595 // TODO: Implement setTimeout polyfill using editor.delay() or similar
2596 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2597 });
2598 };
2599 };
2600
2601 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2602 // The returned object has .result promise and is itself thenable
2603 globalThis._wrapAsyncThenable = function(methodName, fnName) {
2604 const startFn = editor[methodName];
2605 if (typeof startFn !== 'function') {
2606 // Return a function that always throws - catches missing implementations
2607 return function(...args) {
2608 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2609 editor.debug(`[ASYNC ERROR] ${error.message}`);
2610 throw error;
2611 };
2612 }
2613 return function(...args) {
2614 // Call via bracket notation to preserve method binding and Ctx injection
2615 const callbackId = editor[methodName](...args);
2616 const resultPromise = new Promise((resolve, reject) => {
2617 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2618 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2619 });
2620 return {
2621 get result() { return resultPromise; },
2622 then(onFulfilled, onRejected) {
2623 return resultPromise.then(onFulfilled, onRejected);
2624 },
2625 catch(onRejected) {
2626 return resultPromise.catch(onRejected);
2627 }
2628 };
2629 };
2630 };
2631
2632 // Apply wrappers to async functions on editor
2633 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
2634 editor.delay = _wrapAsync("_delayStart", "delay");
2635 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
2636 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
2637 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
2638 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
2639 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
2640 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
2641 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
2642 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
2643 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
2644
2645 // Wrapper for deleteTheme - wraps sync function in Promise
2646 editor.deleteTheme = function(name) {
2647 return new Promise(function(resolve, reject) {
2648 const success = editor._deleteThemeSync(name);
2649 if (success) {
2650 resolve();
2651 } else {
2652 reject(new Error("Failed to delete theme: " + name));
2653 }
2654 });
2655 };
2656 "#.as_bytes())?;
2657
2658 Ok::<_, rquickjs::Error>(())
2659 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
2660
2661 Ok(())
2662 }
2663
2664 pub async fn load_module_with_source(
2666 &mut self,
2667 path: &str,
2668 _plugin_source: &str,
2669 ) -> Result<()> {
2670 let path_buf = PathBuf::from(path);
2671 let source = std::fs::read_to_string(&path_buf)
2672 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
2673
2674 let filename = path_buf
2675 .file_name()
2676 .and_then(|s| s.to_str())
2677 .unwrap_or("plugin.ts");
2678
2679 if has_es_imports(&source) {
2681 match bundle_module(&path_buf) {
2683 Ok(bundled) => {
2684 self.execute_js(&bundled, path)?;
2685 }
2686 Err(e) => {
2687 tracing::warn!(
2688 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
2689 path,
2690 e
2691 );
2692 return Ok(()); }
2694 }
2695 } else if has_es_module_syntax(&source) {
2696 let stripped = strip_imports_and_exports(&source);
2698 let js_code = if filename.ends_with(".ts") {
2699 transpile_typescript(&stripped, filename)?
2700 } else {
2701 stripped
2702 };
2703 self.execute_js(&js_code, path)?;
2704 } else {
2705 let js_code = if filename.ends_with(".ts") {
2707 transpile_typescript(&source, filename)?
2708 } else {
2709 source
2710 };
2711 self.execute_js(&js_code, path)?;
2712 }
2713
2714 Ok(())
2715 }
2716
2717 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
2719 let plugin_name = Path::new(source_name)
2721 .file_stem()
2722 .and_then(|s| s.to_str())
2723 .unwrap_or("unknown");
2724
2725 tracing::debug!(
2726 "execute_js: starting for plugin '{}' from '{}'",
2727 plugin_name,
2728 source_name
2729 );
2730
2731 let context = {
2733 let mut contexts = self.plugin_contexts.borrow_mut();
2734 if let Some(ctx) = contexts.get(plugin_name) {
2735 ctx.clone()
2736 } else {
2737 let ctx = Context::full(&self.runtime).map_err(|e| {
2738 anyhow!(
2739 "Failed to create QuickJS context for plugin {}: {}",
2740 plugin_name,
2741 e
2742 )
2743 })?;
2744 self.setup_context_api(&ctx, plugin_name)?;
2745 contexts.insert(plugin_name.to_string(), ctx.clone());
2746 ctx
2747 }
2748 };
2749
2750 let wrapped_code = format!("(function() {{ {} }})();", code);
2754 let wrapped = wrapped_code.as_str();
2755
2756 context.with(|ctx| {
2757 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
2758
2759 let mut eval_options = rquickjs::context::EvalOptions::default();
2761 eval_options.global = true;
2762 eval_options.filename = Some(source_name.to_string());
2763 let result = ctx
2764 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
2765 .map_err(|e| format_js_error(&ctx, e, source_name));
2766
2767 tracing::debug!(
2768 "execute_js: plugin code execution finished for '{}', result: {:?}",
2769 plugin_name,
2770 result.is_ok()
2771 );
2772
2773 result
2774 })
2775 }
2776
2777 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
2779 let _event_data_str = event_data.to_string();
2780 tracing::debug!("emit: event '{}' with data: {:?}", event_name, event_data);
2781
2782 self.services
2784 .set_js_execution_state(format!("hook '{}'", event_name));
2785
2786 let handlers = self.event_handlers.borrow().get(event_name).cloned();
2787
2788 if let Some(handler_pairs) = handlers {
2789 if handler_pairs.is_empty() {
2790 self.services.clear_js_execution_state();
2791 return Ok(true);
2792 }
2793
2794 let plugin_contexts = self.plugin_contexts.borrow();
2795 for handler in handler_pairs {
2796 let context_opt = plugin_contexts.get(&handler.plugin_name);
2797 if let Some(context) = context_opt {
2798 let handler_name = &handler.handler_name;
2799 let json_string = serde_json::to_string(event_data)?;
2805 let js_string_literal = serde_json::to_string(&json_string)?;
2806 let code = format!(
2807 r#"
2808 (function() {{
2809 try {{
2810 const data = JSON.parse({});
2811 if (typeof globalThis["{}"] === 'function') {{
2812 const result = globalThis["{}"](data);
2813 // If handler returns a Promise, catch rejections
2814 if (result && typeof result.then === 'function') {{
2815 result.catch(function(e) {{
2816 console.error('Handler {} async error:', e);
2817 // Re-throw to make it an unhandled rejection for the runtime to catch
2818 throw e;
2819 }});
2820 }}
2821 }}
2822 }} catch (e) {{
2823 console.error('Handler {} sync error:', e);
2824 throw e;
2825 }}
2826 }})();
2827 "#,
2828 js_string_literal, handler_name, handler_name, handler_name, handler_name
2829 );
2830
2831 context.with(|ctx| {
2832 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
2833 log_js_error(&ctx, e, &format!("handler {}", handler_name));
2834 }
2835 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
2837 });
2838 }
2839 }
2840 }
2841
2842 self.services.clear_js_execution_state();
2843 Ok(true)
2844 }
2845
2846 pub fn has_handlers(&self, event_name: &str) -> bool {
2848 self.event_handlers
2849 .borrow()
2850 .get(event_name)
2851 .map(|v| !v.is_empty())
2852 .unwrap_or(false)
2853 }
2854
2855 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
2859 let pair = self.registered_actions.borrow().get(action_name).cloned();
2860 let (plugin_name, function_name) = match pair {
2861 Some(handler) => (handler.plugin_name, handler.handler_name),
2862 None => ("main".to_string(), action_name.to_string()),
2863 };
2864
2865 let plugin_contexts = self.plugin_contexts.borrow();
2866 let context = plugin_contexts
2867 .get(&plugin_name)
2868 .unwrap_or(&self.main_context);
2869
2870 self.services
2872 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
2873
2874 tracing::info!(
2875 "start_action: BEGIN '{}' -> function '{}'",
2876 action_name,
2877 function_name
2878 );
2879
2880 let code = format!(
2882 r#"
2883 (function() {{
2884 console.log('[JS] start_action: calling {fn}');
2885 try {{
2886 if (typeof globalThis.{fn} === 'function') {{
2887 console.log('[JS] start_action: {fn} is a function, invoking...');
2888 globalThis.{fn}();
2889 console.log('[JS] start_action: {fn} invoked (may be async)');
2890 }} else {{
2891 console.error('[JS] Action {action} is not defined as a global function');
2892 }}
2893 }} catch (e) {{
2894 console.error('[JS] Action {action} error:', e);
2895 }}
2896 }})();
2897 "#,
2898 fn = function_name,
2899 action = action_name
2900 );
2901
2902 tracing::info!("start_action: evaluating JS code");
2903 context.with(|ctx| {
2904 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2905 log_js_error(&ctx, e, &format!("action {}", action_name));
2906 }
2907 tracing::info!("start_action: running pending microtasks");
2908 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
2910 tracing::info!("start_action: executed {} pending jobs", count);
2911 });
2912
2913 tracing::info!("start_action: END '{}'", action_name);
2914
2915 self.services.clear_js_execution_state();
2917
2918 Ok(())
2919 }
2920
2921 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
2923 let pair = self.registered_actions.borrow().get(action_name).cloned();
2925 let (plugin_name, function_name) = match pair {
2926 Some(handler) => (handler.plugin_name, handler.handler_name),
2927 None => ("main".to_string(), action_name.to_string()),
2928 };
2929
2930 let plugin_contexts = self.plugin_contexts.borrow();
2931 let context = plugin_contexts
2932 .get(&plugin_name)
2933 .unwrap_or(&self.main_context);
2934
2935 tracing::debug!(
2936 "execute_action: '{}' -> function '{}'",
2937 action_name,
2938 function_name
2939 );
2940
2941 let code = format!(
2944 r#"
2945 (async function() {{
2946 try {{
2947 if (typeof globalThis.{fn} === 'function') {{
2948 const result = globalThis.{fn}();
2949 // If it's a Promise, await it
2950 if (result && typeof result.then === 'function') {{
2951 await result;
2952 }}
2953 }} else {{
2954 console.error('Action {action} is not defined as a global function');
2955 }}
2956 }} catch (e) {{
2957 console.error('Action {action} error:', e);
2958 }}
2959 }})();
2960 "#,
2961 fn = function_name,
2962 action = action_name
2963 );
2964
2965 context.with(|ctx| {
2966 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2968 Ok(value) => {
2969 if value.is_object() {
2971 if let Some(obj) = value.as_object() {
2972 if obj.get::<_, rquickjs::Function>("then").is_ok() {
2974 run_pending_jobs_checked(
2977 &ctx,
2978 &format!("execute_action {} promise", action_name),
2979 );
2980 }
2981 }
2982 }
2983 }
2984 Err(e) => {
2985 log_js_error(&ctx, e, &format!("action {}", action_name));
2986 }
2987 }
2988 });
2989
2990 Ok(())
2991 }
2992
2993 pub fn poll_event_loop_once(&mut self) -> bool {
2995 let mut had_work = false;
2996
2997 self.main_context.with(|ctx| {
2999 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3000 if count > 0 {
3001 had_work = true;
3002 }
3003 });
3004
3005 let contexts = self.plugin_contexts.borrow().clone();
3007 for (name, context) in contexts {
3008 context.with(|ctx| {
3009 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3010 if count > 0 {
3011 had_work = true;
3012 }
3013 });
3014 }
3015 had_work
3016 }
3017
3018 pub fn send_status(&self, message: String) {
3020 let _ = self
3021 .command_sender
3022 .send(PluginCommand::SetStatus { message });
3023 }
3024
3025 pub fn resolve_callback(
3030 &mut self,
3031 callback_id: fresh_core::api::JsCallbackId,
3032 result_json: &str,
3033 ) {
3034 let id = callback_id.as_u64();
3035 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3036
3037 let plugin_name = {
3039 let mut contexts = self.callback_contexts.borrow_mut();
3040 contexts.remove(&id)
3041 };
3042
3043 let Some(name) = plugin_name else {
3044 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3045 return;
3046 };
3047
3048 let plugin_contexts = self.plugin_contexts.borrow();
3049 let Some(context) = plugin_contexts.get(&name) else {
3050 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3051 return;
3052 };
3053
3054 context.with(|ctx| {
3055 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3057 Ok(v) => v,
3058 Err(e) => {
3059 tracing::error!(
3060 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3061 id,
3062 e
3063 );
3064 return;
3065 }
3066 };
3067
3068 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3070 Ok(v) => v,
3071 Err(e) => {
3072 tracing::error!(
3073 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3074 id,
3075 e
3076 );
3077 return;
3078 }
3079 };
3080
3081 let globals = ctx.globals();
3083 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3084 Ok(f) => f,
3085 Err(e) => {
3086 tracing::error!(
3087 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3088 id,
3089 e
3090 );
3091 return;
3092 }
3093 };
3094
3095 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3097 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3098 }
3099
3100 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3102 tracing::info!(
3103 "resolve_callback: executed {} pending jobs for callback_id={}",
3104 job_count,
3105 id
3106 );
3107 });
3108 }
3109
3110 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3112 let id = callback_id.as_u64();
3113
3114 let plugin_name = {
3116 let mut contexts = self.callback_contexts.borrow_mut();
3117 contexts.remove(&id)
3118 };
3119
3120 let Some(name) = plugin_name else {
3121 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3122 return;
3123 };
3124
3125 let plugin_contexts = self.plugin_contexts.borrow();
3126 let Some(context) = plugin_contexts.get(&name) else {
3127 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3128 return;
3129 };
3130
3131 context.with(|ctx| {
3132 let globals = ctx.globals();
3134 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3135 Ok(f) => f,
3136 Err(e) => {
3137 tracing::error!(
3138 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3139 id,
3140 e
3141 );
3142 return;
3143 }
3144 };
3145
3146 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3148 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3149 }
3150
3151 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3153 });
3154 }
3155}
3156
3157#[cfg(test)]
3158mod tests {
3159 use super::*;
3160 use fresh_core::api::{BufferInfo, CursorInfo};
3161 use std::sync::mpsc;
3162
3163 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3165 let (tx, rx) = mpsc::channel();
3166 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3167 let services = Arc::new(TestServiceBridge::new());
3168 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3169 (backend, rx)
3170 }
3171
3172 struct TestServiceBridge {
3173 en_strings: std::sync::Mutex<HashMap<String, String>>,
3174 }
3175
3176 impl TestServiceBridge {
3177 fn new() -> Self {
3178 Self {
3179 en_strings: std::sync::Mutex::new(HashMap::new()),
3180 }
3181 }
3182 }
3183
3184 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3185 fn as_any(&self) -> &dyn std::any::Any {
3186 self
3187 }
3188 fn translate(
3189 &self,
3190 _plugin_name: &str,
3191 key: &str,
3192 _args: &HashMap<String, String>,
3193 ) -> String {
3194 self.en_strings
3195 .lock()
3196 .unwrap()
3197 .get(key)
3198 .cloned()
3199 .unwrap_or_else(|| key.to_string())
3200 }
3201 fn current_locale(&self) -> String {
3202 "en".to_string()
3203 }
3204 fn set_js_execution_state(&self, _state: String) {}
3205 fn clear_js_execution_state(&self) {}
3206 fn get_theme_schema(&self) -> serde_json::Value {
3207 serde_json::json!({})
3208 }
3209 fn get_builtin_themes(&self) -> serde_json::Value {
3210 serde_json::json!([])
3211 }
3212 fn register_command(&self, _command: fresh_core::command::Command) {}
3213 fn unregister_command(&self, _name: &str) {}
3214 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3215 fn plugins_dir(&self) -> std::path::PathBuf {
3216 std::path::PathBuf::from("/tmp/plugins")
3217 }
3218 fn config_dir(&self) -> std::path::PathBuf {
3219 std::path::PathBuf::from("/tmp/config")
3220 }
3221 }
3222
3223 #[test]
3224 fn test_quickjs_backend_creation() {
3225 let backend = QuickJsBackend::new();
3226 assert!(backend.is_ok());
3227 }
3228
3229 #[test]
3230 fn test_execute_simple_js() {
3231 let mut backend = QuickJsBackend::new().unwrap();
3232 let result = backend.execute_js("const x = 1 + 2;", "test.js");
3233 assert!(result.is_ok());
3234 }
3235
3236 #[test]
3237 fn test_event_handler_registration() {
3238 let backend = QuickJsBackend::new().unwrap();
3239
3240 assert!(!backend.has_handlers("test_event"));
3242
3243 backend
3245 .event_handlers
3246 .borrow_mut()
3247 .entry("test_event".to_string())
3248 .or_default()
3249 .push(PluginHandler {
3250 plugin_name: "test".to_string(),
3251 handler_name: "testHandler".to_string(),
3252 });
3253
3254 assert!(backend.has_handlers("test_event"));
3256 }
3257
3258 #[test]
3261 fn test_api_set_status() {
3262 let (mut backend, rx) = create_test_backend();
3263
3264 backend
3265 .execute_js(
3266 r#"
3267 const editor = getEditor();
3268 editor.setStatus("Hello from test");
3269 "#,
3270 "test.js",
3271 )
3272 .unwrap();
3273
3274 let cmd = rx.try_recv().unwrap();
3275 match cmd {
3276 PluginCommand::SetStatus { message } => {
3277 assert_eq!(message, "Hello from test");
3278 }
3279 _ => panic!("Expected SetStatus command, got {:?}", cmd),
3280 }
3281 }
3282
3283 #[test]
3284 fn test_api_register_command() {
3285 let (mut backend, rx) = create_test_backend();
3286
3287 backend
3288 .execute_js(
3289 r#"
3290 const editor = getEditor();
3291 globalThis.myTestHandler = function() { };
3292 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3293 "#,
3294 "test_plugin.js",
3295 )
3296 .unwrap();
3297
3298 let cmd = rx.try_recv().unwrap();
3299 match cmd {
3300 PluginCommand::RegisterCommand { command } => {
3301 assert_eq!(command.name, "Test Command");
3302 assert_eq!(command.description, "A test command");
3303 assert_eq!(command.plugin_name, "test_plugin");
3305 }
3306 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3307 }
3308 }
3309
3310 #[test]
3311 fn test_api_define_mode() {
3312 let (mut backend, rx) = create_test_backend();
3313
3314 backend
3315 .execute_js(
3316 r#"
3317 const editor = getEditor();
3318 editor.defineMode("test-mode", null, [
3319 ["a", "action_a"],
3320 ["b", "action_b"]
3321 ]);
3322 "#,
3323 "test.js",
3324 )
3325 .unwrap();
3326
3327 let cmd = rx.try_recv().unwrap();
3328 match cmd {
3329 PluginCommand::DefineMode {
3330 name,
3331 parent,
3332 bindings,
3333 read_only,
3334 } => {
3335 assert_eq!(name, "test-mode");
3336 assert!(parent.is_none());
3337 assert_eq!(bindings.len(), 2);
3338 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3339 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3340 assert!(!read_only);
3341 }
3342 _ => panic!("Expected DefineMode, got {:?}", cmd),
3343 }
3344 }
3345
3346 #[test]
3347 fn test_api_set_editor_mode() {
3348 let (mut backend, rx) = create_test_backend();
3349
3350 backend
3351 .execute_js(
3352 r#"
3353 const editor = getEditor();
3354 editor.setEditorMode("vi-normal");
3355 "#,
3356 "test.js",
3357 )
3358 .unwrap();
3359
3360 let cmd = rx.try_recv().unwrap();
3361 match cmd {
3362 PluginCommand::SetEditorMode { mode } => {
3363 assert_eq!(mode, Some("vi-normal".to_string()));
3364 }
3365 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3366 }
3367 }
3368
3369 #[test]
3370 fn test_api_clear_editor_mode() {
3371 let (mut backend, rx) = create_test_backend();
3372
3373 backend
3374 .execute_js(
3375 r#"
3376 const editor = getEditor();
3377 editor.setEditorMode(null);
3378 "#,
3379 "test.js",
3380 )
3381 .unwrap();
3382
3383 let cmd = rx.try_recv().unwrap();
3384 match cmd {
3385 PluginCommand::SetEditorMode { mode } => {
3386 assert!(mode.is_none());
3387 }
3388 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3389 }
3390 }
3391
3392 #[test]
3393 fn test_api_insert_at_cursor() {
3394 let (mut backend, rx) = create_test_backend();
3395
3396 backend
3397 .execute_js(
3398 r#"
3399 const editor = getEditor();
3400 editor.insertAtCursor("Hello, World!");
3401 "#,
3402 "test.js",
3403 )
3404 .unwrap();
3405
3406 let cmd = rx.try_recv().unwrap();
3407 match cmd {
3408 PluginCommand::InsertAtCursor { text } => {
3409 assert_eq!(text, "Hello, World!");
3410 }
3411 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3412 }
3413 }
3414
3415 #[test]
3416 fn test_api_set_context() {
3417 let (mut backend, rx) = create_test_backend();
3418
3419 backend
3420 .execute_js(
3421 r#"
3422 const editor = getEditor();
3423 editor.setContext("myContext", true);
3424 "#,
3425 "test.js",
3426 )
3427 .unwrap();
3428
3429 let cmd = rx.try_recv().unwrap();
3430 match cmd {
3431 PluginCommand::SetContext { name, active } => {
3432 assert_eq!(name, "myContext");
3433 assert!(active);
3434 }
3435 _ => panic!("Expected SetContext, got {:?}", cmd),
3436 }
3437 }
3438
3439 #[tokio::test]
3440 async fn test_execute_action_sync_function() {
3441 let (mut backend, rx) = create_test_backend();
3442
3443 backend.registered_actions.borrow_mut().insert(
3445 "my_sync_action".to_string(),
3446 PluginHandler {
3447 plugin_name: "test".to_string(),
3448 handler_name: "my_sync_action".to_string(),
3449 },
3450 );
3451
3452 backend
3454 .execute_js(
3455 r#"
3456 const editor = getEditor();
3457 globalThis.my_sync_action = function() {
3458 editor.setStatus("sync action executed");
3459 };
3460 "#,
3461 "test.js",
3462 )
3463 .unwrap();
3464
3465 while rx.try_recv().is_ok() {}
3467
3468 backend.execute_action("my_sync_action").await.unwrap();
3470
3471 let cmd = rx.try_recv().unwrap();
3473 match cmd {
3474 PluginCommand::SetStatus { message } => {
3475 assert_eq!(message, "sync action executed");
3476 }
3477 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3478 }
3479 }
3480
3481 #[tokio::test]
3482 async fn test_execute_action_async_function() {
3483 let (mut backend, rx) = create_test_backend();
3484
3485 backend.registered_actions.borrow_mut().insert(
3487 "my_async_action".to_string(),
3488 PluginHandler {
3489 plugin_name: "test".to_string(),
3490 handler_name: "my_async_action".to_string(),
3491 },
3492 );
3493
3494 backend
3496 .execute_js(
3497 r#"
3498 const editor = getEditor();
3499 globalThis.my_async_action = async function() {
3500 await Promise.resolve();
3501 editor.setStatus("async action executed");
3502 };
3503 "#,
3504 "test.js",
3505 )
3506 .unwrap();
3507
3508 while rx.try_recv().is_ok() {}
3510
3511 backend.execute_action("my_async_action").await.unwrap();
3513
3514 let cmd = rx.try_recv().unwrap();
3516 match cmd {
3517 PluginCommand::SetStatus { message } => {
3518 assert_eq!(message, "async action executed");
3519 }
3520 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3521 }
3522 }
3523
3524 #[tokio::test]
3525 async fn test_execute_action_with_registered_handler() {
3526 let (mut backend, rx) = create_test_backend();
3527
3528 backend.registered_actions.borrow_mut().insert(
3530 "my_action".to_string(),
3531 PluginHandler {
3532 plugin_name: "test".to_string(),
3533 handler_name: "actual_handler_function".to_string(),
3534 },
3535 );
3536
3537 backend
3538 .execute_js(
3539 r#"
3540 const editor = getEditor();
3541 globalThis.actual_handler_function = function() {
3542 editor.setStatus("handler executed");
3543 };
3544 "#,
3545 "test.js",
3546 )
3547 .unwrap();
3548
3549 while rx.try_recv().is_ok() {}
3551
3552 backend.execute_action("my_action").await.unwrap();
3554
3555 let cmd = rx.try_recv().unwrap();
3556 match cmd {
3557 PluginCommand::SetStatus { message } => {
3558 assert_eq!(message, "handler executed");
3559 }
3560 _ => panic!("Expected SetStatus, got {:?}", cmd),
3561 }
3562 }
3563
3564 #[test]
3565 fn test_api_on_event_registration() {
3566 let (mut backend, _rx) = create_test_backend();
3567
3568 backend
3569 .execute_js(
3570 r#"
3571 const editor = getEditor();
3572 globalThis.myEventHandler = function() { };
3573 editor.on("bufferSave", "myEventHandler");
3574 "#,
3575 "test.js",
3576 )
3577 .unwrap();
3578
3579 assert!(backend.has_handlers("bufferSave"));
3580 }
3581
3582 #[test]
3583 fn test_api_off_event_unregistration() {
3584 let (mut backend, _rx) = create_test_backend();
3585
3586 backend
3587 .execute_js(
3588 r#"
3589 const editor = getEditor();
3590 globalThis.myEventHandler = function() { };
3591 editor.on("bufferSave", "myEventHandler");
3592 editor.off("bufferSave", "myEventHandler");
3593 "#,
3594 "test.js",
3595 )
3596 .unwrap();
3597
3598 assert!(!backend.has_handlers("bufferSave"));
3600 }
3601
3602 #[tokio::test]
3603 async fn test_emit_event() {
3604 let (mut backend, rx) = create_test_backend();
3605
3606 backend
3607 .execute_js(
3608 r#"
3609 const editor = getEditor();
3610 globalThis.onSaveHandler = function(data) {
3611 editor.setStatus("saved: " + JSON.stringify(data));
3612 };
3613 editor.on("bufferSave", "onSaveHandler");
3614 "#,
3615 "test.js",
3616 )
3617 .unwrap();
3618
3619 while rx.try_recv().is_ok() {}
3621
3622 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
3624 backend.emit("bufferSave", &event_data).await.unwrap();
3625
3626 let cmd = rx.try_recv().unwrap();
3627 match cmd {
3628 PluginCommand::SetStatus { message } => {
3629 assert!(message.contains("/test.txt"));
3630 }
3631 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
3632 }
3633 }
3634
3635 #[test]
3636 fn test_api_copy_to_clipboard() {
3637 let (mut backend, rx) = create_test_backend();
3638
3639 backend
3640 .execute_js(
3641 r#"
3642 const editor = getEditor();
3643 editor.copyToClipboard("clipboard text");
3644 "#,
3645 "test.js",
3646 )
3647 .unwrap();
3648
3649 let cmd = rx.try_recv().unwrap();
3650 match cmd {
3651 PluginCommand::SetClipboard { text } => {
3652 assert_eq!(text, "clipboard text");
3653 }
3654 _ => panic!("Expected SetClipboard, got {:?}", cmd),
3655 }
3656 }
3657
3658 #[test]
3659 fn test_api_open_file() {
3660 let (mut backend, rx) = create_test_backend();
3661
3662 backend
3664 .execute_js(
3665 r#"
3666 const editor = getEditor();
3667 editor.openFile("/path/to/file.txt", null, null);
3668 "#,
3669 "test.js",
3670 )
3671 .unwrap();
3672
3673 let cmd = rx.try_recv().unwrap();
3674 match cmd {
3675 PluginCommand::OpenFileAtLocation { path, line, column } => {
3676 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
3677 assert!(line.is_none());
3678 assert!(column.is_none());
3679 }
3680 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
3681 }
3682 }
3683
3684 #[test]
3685 fn test_api_delete_range() {
3686 let (mut backend, rx) = create_test_backend();
3687
3688 backend
3690 .execute_js(
3691 r#"
3692 const editor = getEditor();
3693 editor.deleteRange(0, 10, 20);
3694 "#,
3695 "test.js",
3696 )
3697 .unwrap();
3698
3699 let cmd = rx.try_recv().unwrap();
3700 match cmd {
3701 PluginCommand::DeleteRange { range, .. } => {
3702 assert_eq!(range.start, 10);
3703 assert_eq!(range.end, 20);
3704 }
3705 _ => panic!("Expected DeleteRange, got {:?}", cmd),
3706 }
3707 }
3708
3709 #[test]
3710 fn test_api_insert_text() {
3711 let (mut backend, rx) = create_test_backend();
3712
3713 backend
3715 .execute_js(
3716 r#"
3717 const editor = getEditor();
3718 editor.insertText(0, 5, "inserted");
3719 "#,
3720 "test.js",
3721 )
3722 .unwrap();
3723
3724 let cmd = rx.try_recv().unwrap();
3725 match cmd {
3726 PluginCommand::InsertText { position, text, .. } => {
3727 assert_eq!(position, 5);
3728 assert_eq!(text, "inserted");
3729 }
3730 _ => panic!("Expected InsertText, got {:?}", cmd),
3731 }
3732 }
3733
3734 #[test]
3735 fn test_api_set_buffer_cursor() {
3736 let (mut backend, rx) = create_test_backend();
3737
3738 backend
3740 .execute_js(
3741 r#"
3742 const editor = getEditor();
3743 editor.setBufferCursor(0, 100);
3744 "#,
3745 "test.js",
3746 )
3747 .unwrap();
3748
3749 let cmd = rx.try_recv().unwrap();
3750 match cmd {
3751 PluginCommand::SetBufferCursor { position, .. } => {
3752 assert_eq!(position, 100);
3753 }
3754 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
3755 }
3756 }
3757
3758 #[test]
3759 fn test_api_get_cursor_position_from_state() {
3760 let (tx, _rx) = mpsc::channel();
3761 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3762
3763 {
3765 let mut state = state_snapshot.write().unwrap();
3766 state.primary_cursor = Some(CursorInfo {
3767 position: 42,
3768 selection: None,
3769 });
3770 }
3771
3772 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3773 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3774
3775 backend
3777 .execute_js(
3778 r#"
3779 const editor = getEditor();
3780 const pos = editor.getCursorPosition();
3781 globalThis._testResult = pos;
3782 "#,
3783 "test.js",
3784 )
3785 .unwrap();
3786
3787 backend
3789 .plugin_contexts
3790 .borrow()
3791 .get("test")
3792 .unwrap()
3793 .clone()
3794 .with(|ctx| {
3795 let global = ctx.globals();
3796 let result: u32 = global.get("_testResult").unwrap();
3797 assert_eq!(result, 42);
3798 });
3799 }
3800
3801 #[test]
3802 fn test_api_path_functions() {
3803 let (mut backend, _rx) = create_test_backend();
3804
3805 #[cfg(windows)]
3808 let absolute_path = r#"C:\\foo\\bar"#;
3809 #[cfg(not(windows))]
3810 let absolute_path = "/foo/bar";
3811
3812 let js_code = format!(
3814 r#"
3815 const editor = getEditor();
3816 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
3817 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
3818 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
3819 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
3820 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
3821 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
3822 "#,
3823 absolute_path
3824 );
3825 backend.execute_js(&js_code, "test.js").unwrap();
3826
3827 backend
3828 .plugin_contexts
3829 .borrow()
3830 .get("test")
3831 .unwrap()
3832 .clone()
3833 .with(|ctx| {
3834 let global = ctx.globals();
3835 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
3836 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
3837 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
3838 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
3839 assert!(!global.get::<_, bool>("_isRelative").unwrap());
3840 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
3841 });
3842 }
3843
3844 #[test]
3845 fn test_typescript_transpilation() {
3846 use fresh_parser_js::transpile_typescript;
3847
3848 let (mut backend, rx) = create_test_backend();
3849
3850 let ts_code = r#"
3852 const editor = getEditor();
3853 function greet(name: string): string {
3854 return "Hello, " + name;
3855 }
3856 editor.setStatus(greet("TypeScript"));
3857 "#;
3858
3859 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
3861
3862 backend.execute_js(&js_code, "test.js").unwrap();
3864
3865 let cmd = rx.try_recv().unwrap();
3866 match cmd {
3867 PluginCommand::SetStatus { message } => {
3868 assert_eq!(message, "Hello, TypeScript");
3869 }
3870 _ => panic!("Expected SetStatus, got {:?}", cmd),
3871 }
3872 }
3873
3874 #[test]
3875 fn test_api_get_buffer_text_sends_command() {
3876 let (mut backend, rx) = create_test_backend();
3877
3878 backend
3880 .execute_js(
3881 r#"
3882 const editor = getEditor();
3883 // Store the promise for later
3884 globalThis._textPromise = editor.getBufferText(0, 10, 20);
3885 "#,
3886 "test.js",
3887 )
3888 .unwrap();
3889
3890 let cmd = rx.try_recv().unwrap();
3892 match cmd {
3893 PluginCommand::GetBufferText {
3894 buffer_id,
3895 start,
3896 end,
3897 request_id,
3898 } => {
3899 assert_eq!(buffer_id.0, 0);
3900 assert_eq!(start, 10);
3901 assert_eq!(end, 20);
3902 assert!(request_id > 0); }
3904 _ => panic!("Expected GetBufferText, got {:?}", cmd),
3905 }
3906 }
3907
3908 #[test]
3909 fn test_api_get_buffer_text_resolves_callback() {
3910 let (mut backend, rx) = create_test_backend();
3911
3912 backend
3914 .execute_js(
3915 r#"
3916 const editor = getEditor();
3917 globalThis._resolvedText = null;
3918 editor.getBufferText(0, 0, 100).then(text => {
3919 globalThis._resolvedText = text;
3920 });
3921 "#,
3922 "test.js",
3923 )
3924 .unwrap();
3925
3926 let request_id = match rx.try_recv().unwrap() {
3928 PluginCommand::GetBufferText { request_id, .. } => request_id,
3929 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
3930 };
3931
3932 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
3934
3935 backend
3937 .plugin_contexts
3938 .borrow()
3939 .get("test")
3940 .unwrap()
3941 .clone()
3942 .with(|ctx| {
3943 run_pending_jobs_checked(&ctx, "test async getText");
3944 });
3945
3946 backend
3948 .plugin_contexts
3949 .borrow()
3950 .get("test")
3951 .unwrap()
3952 .clone()
3953 .with(|ctx| {
3954 let global = ctx.globals();
3955 let result: String = global.get("_resolvedText").unwrap();
3956 assert_eq!(result, "hello world");
3957 });
3958 }
3959
3960 #[test]
3961 fn test_plugin_translation() {
3962 let (mut backend, _rx) = create_test_backend();
3963
3964 backend
3966 .execute_js(
3967 r#"
3968 const editor = getEditor();
3969 globalThis._translated = editor.t("test.key");
3970 "#,
3971 "test.js",
3972 )
3973 .unwrap();
3974
3975 backend
3976 .plugin_contexts
3977 .borrow()
3978 .get("test")
3979 .unwrap()
3980 .clone()
3981 .with(|ctx| {
3982 let global = ctx.globals();
3983 let result: String = global.get("_translated").unwrap();
3985 assert_eq!(result, "test.key");
3986 });
3987 }
3988
3989 #[test]
3990 fn test_plugin_translation_with_registered_strings() {
3991 let (mut backend, _rx) = create_test_backend();
3992
3993 let mut en_strings = std::collections::HashMap::new();
3995 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
3996 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
3997
3998 let mut strings = std::collections::HashMap::new();
3999 strings.insert("en".to_string(), en_strings);
4000
4001 if let Some(bridge) = backend
4003 .services
4004 .as_any()
4005 .downcast_ref::<TestServiceBridge>()
4006 {
4007 let mut en = bridge.en_strings.lock().unwrap();
4008 en.insert("greeting".to_string(), "Hello, World!".to_string());
4009 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4010 }
4011
4012 backend
4014 .execute_js(
4015 r#"
4016 const editor = getEditor();
4017 globalThis._greeting = editor.t("greeting");
4018 globalThis._prompt = editor.t("prompt.find_file");
4019 globalThis._missing = editor.t("nonexistent.key");
4020 "#,
4021 "test.js",
4022 )
4023 .unwrap();
4024
4025 backend
4026 .plugin_contexts
4027 .borrow()
4028 .get("test")
4029 .unwrap()
4030 .clone()
4031 .with(|ctx| {
4032 let global = ctx.globals();
4033 let greeting: String = global.get("_greeting").unwrap();
4034 assert_eq!(greeting, "Hello, World!");
4035
4036 let prompt: String = global.get("_prompt").unwrap();
4037 assert_eq!(prompt, "Find file: ");
4038
4039 let missing: String = global.get("_missing").unwrap();
4041 assert_eq!(missing, "nonexistent.key");
4042 });
4043 }
4044
4045 #[test]
4048 fn test_api_set_line_indicator() {
4049 let (mut backend, rx) = create_test_backend();
4050
4051 backend
4052 .execute_js(
4053 r#"
4054 const editor = getEditor();
4055 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4056 "#,
4057 "test.js",
4058 )
4059 .unwrap();
4060
4061 let cmd = rx.try_recv().unwrap();
4062 match cmd {
4063 PluginCommand::SetLineIndicator {
4064 buffer_id,
4065 line,
4066 namespace,
4067 symbol,
4068 color,
4069 priority,
4070 } => {
4071 assert_eq!(buffer_id.0, 1);
4072 assert_eq!(line, 5);
4073 assert_eq!(namespace, "test-ns");
4074 assert_eq!(symbol, "●");
4075 assert_eq!(color, (255, 0, 0));
4076 assert_eq!(priority, 10);
4077 }
4078 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4079 }
4080 }
4081
4082 #[test]
4083 fn test_api_clear_line_indicators() {
4084 let (mut backend, rx) = create_test_backend();
4085
4086 backend
4087 .execute_js(
4088 r#"
4089 const editor = getEditor();
4090 editor.clearLineIndicators(1, "test-ns");
4091 "#,
4092 "test.js",
4093 )
4094 .unwrap();
4095
4096 let cmd = rx.try_recv().unwrap();
4097 match cmd {
4098 PluginCommand::ClearLineIndicators {
4099 buffer_id,
4100 namespace,
4101 } => {
4102 assert_eq!(buffer_id.0, 1);
4103 assert_eq!(namespace, "test-ns");
4104 }
4105 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4106 }
4107 }
4108
4109 #[test]
4112 fn test_api_create_virtual_buffer_sends_command() {
4113 let (mut backend, rx) = create_test_backend();
4114
4115 backend
4116 .execute_js(
4117 r#"
4118 const editor = getEditor();
4119 editor.createVirtualBuffer({
4120 name: "*Test Buffer*",
4121 mode: "test-mode",
4122 readOnly: true,
4123 entries: [
4124 { text: "Line 1\n", properties: { type: "header" } },
4125 { text: "Line 2\n", properties: { type: "content" } }
4126 ],
4127 showLineNumbers: false,
4128 showCursors: true,
4129 editingDisabled: true
4130 });
4131 "#,
4132 "test.js",
4133 )
4134 .unwrap();
4135
4136 let cmd = rx.try_recv().unwrap();
4137 match cmd {
4138 PluginCommand::CreateVirtualBufferWithContent {
4139 name,
4140 mode,
4141 read_only,
4142 entries,
4143 show_line_numbers,
4144 show_cursors,
4145 editing_disabled,
4146 ..
4147 } => {
4148 assert_eq!(name, "*Test Buffer*");
4149 assert_eq!(mode, "test-mode");
4150 assert!(read_only);
4151 assert_eq!(entries.len(), 2);
4152 assert_eq!(entries[0].text, "Line 1\n");
4153 assert!(!show_line_numbers);
4154 assert!(show_cursors);
4155 assert!(editing_disabled);
4156 }
4157 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4158 }
4159 }
4160
4161 #[test]
4162 fn test_api_set_virtual_buffer_content() {
4163 let (mut backend, rx) = create_test_backend();
4164
4165 backend
4166 .execute_js(
4167 r#"
4168 const editor = getEditor();
4169 editor.setVirtualBufferContent(5, [
4170 { text: "New content\n", properties: { type: "updated" } }
4171 ]);
4172 "#,
4173 "test.js",
4174 )
4175 .unwrap();
4176
4177 let cmd = rx.try_recv().unwrap();
4178 match cmd {
4179 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4180 assert_eq!(buffer_id.0, 5);
4181 assert_eq!(entries.len(), 1);
4182 assert_eq!(entries[0].text, "New content\n");
4183 }
4184 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4185 }
4186 }
4187
4188 #[test]
4191 fn test_api_add_overlay() {
4192 let (mut backend, rx) = create_test_backend();
4193
4194 backend.execute_js(r#"
4195 const editor = getEditor();
4196 editor.addOverlay(1, "highlight", 10, 20, 255, 128, 0, false, true, false, 50, 50, 50, false);
4197 "#, "test.js").unwrap();
4198
4199 let cmd = rx.try_recv().unwrap();
4200 match cmd {
4201 PluginCommand::AddOverlay {
4202 buffer_id,
4203 namespace,
4204 range,
4205 color,
4206 bg_color,
4207 underline,
4208 bold,
4209 italic,
4210 extend_to_line_end,
4211 } => {
4212 assert_eq!(buffer_id.0, 1);
4213 assert!(namespace.is_some());
4214 assert_eq!(namespace.unwrap().as_str(), "highlight");
4215 assert_eq!(range, 10..20);
4216 assert_eq!(color, (255, 128, 0));
4217 assert_eq!(bg_color, Some((50, 50, 50)));
4218 assert!(!underline);
4219 assert!(bold);
4220 assert!(!italic);
4221 assert!(!extend_to_line_end);
4222 }
4223 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4224 }
4225 }
4226
4227 #[test]
4228 fn test_api_clear_namespace() {
4229 let (mut backend, rx) = create_test_backend();
4230
4231 backend
4232 .execute_js(
4233 r#"
4234 const editor = getEditor();
4235 editor.clearNamespace(1, "highlight");
4236 "#,
4237 "test.js",
4238 )
4239 .unwrap();
4240
4241 let cmd = rx.try_recv().unwrap();
4242 match cmd {
4243 PluginCommand::ClearNamespace {
4244 buffer_id,
4245 namespace,
4246 } => {
4247 assert_eq!(buffer_id.0, 1);
4248 assert_eq!(namespace.as_str(), "highlight");
4249 }
4250 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4251 }
4252 }
4253
4254 #[test]
4257 fn test_api_get_theme_schema() {
4258 let (mut backend, _rx) = create_test_backend();
4259
4260 backend
4261 .execute_js(
4262 r#"
4263 const editor = getEditor();
4264 const schema = editor.getThemeSchema();
4265 globalThis._isObject = typeof schema === 'object' && schema !== null;
4266 "#,
4267 "test.js",
4268 )
4269 .unwrap();
4270
4271 backend
4272 .plugin_contexts
4273 .borrow()
4274 .get("test")
4275 .unwrap()
4276 .clone()
4277 .with(|ctx| {
4278 let global = ctx.globals();
4279 let is_object: bool = global.get("_isObject").unwrap();
4280 assert!(is_object);
4282 });
4283 }
4284
4285 #[test]
4286 fn test_api_get_builtin_themes() {
4287 let (mut backend, _rx) = create_test_backend();
4288
4289 backend
4290 .execute_js(
4291 r#"
4292 const editor = getEditor();
4293 const themes = editor.getBuiltinThemes();
4294 globalThis._isObject = typeof themes === 'object' && themes !== null;
4295 "#,
4296 "test.js",
4297 )
4298 .unwrap();
4299
4300 backend
4301 .plugin_contexts
4302 .borrow()
4303 .get("test")
4304 .unwrap()
4305 .clone()
4306 .with(|ctx| {
4307 let global = ctx.globals();
4308 let is_object: bool = global.get("_isObject").unwrap();
4309 assert!(is_object);
4311 });
4312 }
4313
4314 #[test]
4315 fn test_api_apply_theme() {
4316 let (mut backend, rx) = create_test_backend();
4317
4318 backend
4319 .execute_js(
4320 r#"
4321 const editor = getEditor();
4322 editor.applyTheme("dark");
4323 "#,
4324 "test.js",
4325 )
4326 .unwrap();
4327
4328 let cmd = rx.try_recv().unwrap();
4329 match cmd {
4330 PluginCommand::ApplyTheme { theme_name } => {
4331 assert_eq!(theme_name, "dark");
4332 }
4333 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4334 }
4335 }
4336
4337 #[test]
4340 fn test_api_close_buffer() {
4341 let (mut backend, rx) = create_test_backend();
4342
4343 backend
4344 .execute_js(
4345 r#"
4346 const editor = getEditor();
4347 editor.closeBuffer(3);
4348 "#,
4349 "test.js",
4350 )
4351 .unwrap();
4352
4353 let cmd = rx.try_recv().unwrap();
4354 match cmd {
4355 PluginCommand::CloseBuffer { buffer_id } => {
4356 assert_eq!(buffer_id.0, 3);
4357 }
4358 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4359 }
4360 }
4361
4362 #[test]
4363 fn test_api_focus_split() {
4364 let (mut backend, rx) = create_test_backend();
4365
4366 backend
4367 .execute_js(
4368 r#"
4369 const editor = getEditor();
4370 editor.focusSplit(2);
4371 "#,
4372 "test.js",
4373 )
4374 .unwrap();
4375
4376 let cmd = rx.try_recv().unwrap();
4377 match cmd {
4378 PluginCommand::FocusSplit { split_id } => {
4379 assert_eq!(split_id.0, 2);
4380 }
4381 _ => panic!("Expected FocusSplit, got {:?}", cmd),
4382 }
4383 }
4384
4385 #[test]
4386 fn test_api_list_buffers() {
4387 let (tx, _rx) = mpsc::channel();
4388 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4389
4390 {
4392 let mut state = state_snapshot.write().unwrap();
4393 state.buffers.insert(
4394 BufferId(0),
4395 BufferInfo {
4396 id: BufferId(0),
4397 path: Some(PathBuf::from("/test1.txt")),
4398 modified: false,
4399 length: 100,
4400 },
4401 );
4402 state.buffers.insert(
4403 BufferId(1),
4404 BufferInfo {
4405 id: BufferId(1),
4406 path: Some(PathBuf::from("/test2.txt")),
4407 modified: true,
4408 length: 200,
4409 },
4410 );
4411 }
4412
4413 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4414 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4415
4416 backend
4417 .execute_js(
4418 r#"
4419 const editor = getEditor();
4420 const buffers = editor.listBuffers();
4421 globalThis._isArray = Array.isArray(buffers);
4422 globalThis._length = buffers.length;
4423 "#,
4424 "test.js",
4425 )
4426 .unwrap();
4427
4428 backend
4429 .plugin_contexts
4430 .borrow()
4431 .get("test")
4432 .unwrap()
4433 .clone()
4434 .with(|ctx| {
4435 let global = ctx.globals();
4436 let is_array: bool = global.get("_isArray").unwrap();
4437 let length: u32 = global.get("_length").unwrap();
4438 assert!(is_array);
4439 assert_eq!(length, 2);
4440 });
4441 }
4442
4443 #[test]
4446 fn test_api_start_prompt() {
4447 let (mut backend, rx) = create_test_backend();
4448
4449 backend
4450 .execute_js(
4451 r#"
4452 const editor = getEditor();
4453 editor.startPrompt("Enter value:", "test-prompt");
4454 "#,
4455 "test.js",
4456 )
4457 .unwrap();
4458
4459 let cmd = rx.try_recv().unwrap();
4460 match cmd {
4461 PluginCommand::StartPrompt { label, prompt_type } => {
4462 assert_eq!(label, "Enter value:");
4463 assert_eq!(prompt_type, "test-prompt");
4464 }
4465 _ => panic!("Expected StartPrompt, got {:?}", cmd),
4466 }
4467 }
4468
4469 #[test]
4470 fn test_api_start_prompt_with_initial() {
4471 let (mut backend, rx) = create_test_backend();
4472
4473 backend
4474 .execute_js(
4475 r#"
4476 const editor = getEditor();
4477 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4478 "#,
4479 "test.js",
4480 )
4481 .unwrap();
4482
4483 let cmd = rx.try_recv().unwrap();
4484 match cmd {
4485 PluginCommand::StartPromptWithInitial {
4486 label,
4487 prompt_type,
4488 initial_value,
4489 } => {
4490 assert_eq!(label, "Enter value:");
4491 assert_eq!(prompt_type, "test-prompt");
4492 assert_eq!(initial_value, "default");
4493 }
4494 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4495 }
4496 }
4497
4498 #[test]
4499 fn test_api_set_prompt_suggestions() {
4500 let (mut backend, rx) = create_test_backend();
4501
4502 backend
4503 .execute_js(
4504 r#"
4505 const editor = getEditor();
4506 editor.setPromptSuggestions([
4507 { text: "Option 1", value: "opt1" },
4508 { text: "Option 2", value: "opt2" }
4509 ]);
4510 "#,
4511 "test.js",
4512 )
4513 .unwrap();
4514
4515 let cmd = rx.try_recv().unwrap();
4516 match cmd {
4517 PluginCommand::SetPromptSuggestions { suggestions } => {
4518 assert_eq!(suggestions.len(), 2);
4519 assert_eq!(suggestions[0].text, "Option 1");
4520 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4521 }
4522 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4523 }
4524 }
4525
4526 #[test]
4529 fn test_api_get_active_buffer_id() {
4530 let (tx, _rx) = mpsc::channel();
4531 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4532
4533 {
4534 let mut state = state_snapshot.write().unwrap();
4535 state.active_buffer_id = BufferId(42);
4536 }
4537
4538 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4539 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4540
4541 backend
4542 .execute_js(
4543 r#"
4544 const editor = getEditor();
4545 globalThis._activeId = editor.getActiveBufferId();
4546 "#,
4547 "test.js",
4548 )
4549 .unwrap();
4550
4551 backend
4552 .plugin_contexts
4553 .borrow()
4554 .get("test")
4555 .unwrap()
4556 .clone()
4557 .with(|ctx| {
4558 let global = ctx.globals();
4559 let result: u32 = global.get("_activeId").unwrap();
4560 assert_eq!(result, 42);
4561 });
4562 }
4563
4564 #[test]
4565 fn test_api_get_active_split_id() {
4566 let (tx, _rx) = mpsc::channel();
4567 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4568
4569 {
4570 let mut state = state_snapshot.write().unwrap();
4571 state.active_split_id = 7;
4572 }
4573
4574 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4575 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4576
4577 backend
4578 .execute_js(
4579 r#"
4580 const editor = getEditor();
4581 globalThis._splitId = editor.getActiveSplitId();
4582 "#,
4583 "test.js",
4584 )
4585 .unwrap();
4586
4587 backend
4588 .plugin_contexts
4589 .borrow()
4590 .get("test")
4591 .unwrap()
4592 .clone()
4593 .with(|ctx| {
4594 let global = ctx.globals();
4595 let result: u32 = global.get("_splitId").unwrap();
4596 assert_eq!(result, 7);
4597 });
4598 }
4599
4600 #[test]
4603 fn test_api_file_exists() {
4604 let (mut backend, _rx) = create_test_backend();
4605
4606 backend
4607 .execute_js(
4608 r#"
4609 const editor = getEditor();
4610 // Test with a path that definitely exists
4611 globalThis._exists = editor.fileExists("/");
4612 "#,
4613 "test.js",
4614 )
4615 .unwrap();
4616
4617 backend
4618 .plugin_contexts
4619 .borrow()
4620 .get("test")
4621 .unwrap()
4622 .clone()
4623 .with(|ctx| {
4624 let global = ctx.globals();
4625 let result: bool = global.get("_exists").unwrap();
4626 assert!(result);
4627 });
4628 }
4629
4630 #[test]
4631 fn test_api_get_cwd() {
4632 let (mut backend, _rx) = create_test_backend();
4633
4634 backend
4635 .execute_js(
4636 r#"
4637 const editor = getEditor();
4638 globalThis._cwd = editor.getCwd();
4639 "#,
4640 "test.js",
4641 )
4642 .unwrap();
4643
4644 backend
4645 .plugin_contexts
4646 .borrow()
4647 .get("test")
4648 .unwrap()
4649 .clone()
4650 .with(|ctx| {
4651 let global = ctx.globals();
4652 let result: String = global.get("_cwd").unwrap();
4653 assert!(!result.is_empty());
4655 });
4656 }
4657
4658 #[test]
4659 fn test_api_get_env() {
4660 let (mut backend, _rx) = create_test_backend();
4661
4662 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
4664
4665 backend
4666 .execute_js(
4667 r#"
4668 const editor = getEditor();
4669 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
4670 "#,
4671 "test.js",
4672 )
4673 .unwrap();
4674
4675 backend
4676 .plugin_contexts
4677 .borrow()
4678 .get("test")
4679 .unwrap()
4680 .clone()
4681 .with(|ctx| {
4682 let global = ctx.globals();
4683 let result: Option<String> = global.get("_envVal").unwrap();
4684 assert_eq!(result, Some("test_value".to_string()));
4685 });
4686
4687 std::env::remove_var("TEST_PLUGIN_VAR");
4688 }
4689
4690 #[test]
4691 fn test_api_get_config() {
4692 let (mut backend, _rx) = create_test_backend();
4693
4694 backend
4695 .execute_js(
4696 r#"
4697 const editor = getEditor();
4698 const config = editor.getConfig();
4699 globalThis._isObject = typeof config === 'object';
4700 "#,
4701 "test.js",
4702 )
4703 .unwrap();
4704
4705 backend
4706 .plugin_contexts
4707 .borrow()
4708 .get("test")
4709 .unwrap()
4710 .clone()
4711 .with(|ctx| {
4712 let global = ctx.globals();
4713 let is_object: bool = global.get("_isObject").unwrap();
4714 assert!(is_object);
4716 });
4717 }
4718
4719 #[test]
4720 fn test_api_get_themes_dir() {
4721 let (mut backend, _rx) = create_test_backend();
4722
4723 backend
4724 .execute_js(
4725 r#"
4726 const editor = getEditor();
4727 globalThis._themesDir = editor.getThemesDir();
4728 "#,
4729 "test.js",
4730 )
4731 .unwrap();
4732
4733 backend
4734 .plugin_contexts
4735 .borrow()
4736 .get("test")
4737 .unwrap()
4738 .clone()
4739 .with(|ctx| {
4740 let global = ctx.globals();
4741 let result: String = global.get("_themesDir").unwrap();
4742 assert!(!result.is_empty());
4744 });
4745 }
4746
4747 #[test]
4750 fn test_api_read_dir() {
4751 let (mut backend, _rx) = create_test_backend();
4752
4753 backend
4754 .execute_js(
4755 r#"
4756 const editor = getEditor();
4757 const entries = editor.readDir("/tmp");
4758 globalThis._isArray = Array.isArray(entries);
4759 globalThis._length = entries.length;
4760 "#,
4761 "test.js",
4762 )
4763 .unwrap();
4764
4765 backend
4766 .plugin_contexts
4767 .borrow()
4768 .get("test")
4769 .unwrap()
4770 .clone()
4771 .with(|ctx| {
4772 let global = ctx.globals();
4773 let is_array: bool = global.get("_isArray").unwrap();
4774 let length: u32 = global.get("_length").unwrap();
4775 assert!(is_array);
4777 let _ = length;
4779 });
4780 }
4781
4782 #[test]
4785 fn test_api_execute_action() {
4786 let (mut backend, rx) = create_test_backend();
4787
4788 backend
4789 .execute_js(
4790 r#"
4791 const editor = getEditor();
4792 editor.executeAction("move_cursor_up");
4793 "#,
4794 "test.js",
4795 )
4796 .unwrap();
4797
4798 let cmd = rx.try_recv().unwrap();
4799 match cmd {
4800 PluginCommand::ExecuteAction { action_name } => {
4801 assert_eq!(action_name, "move_cursor_up");
4802 }
4803 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
4804 }
4805 }
4806
4807 #[test]
4810 fn test_api_debug() {
4811 let (mut backend, _rx) = create_test_backend();
4812
4813 backend
4815 .execute_js(
4816 r#"
4817 const editor = getEditor();
4818 editor.debug("Test debug message");
4819 editor.debug("Another message with special chars: <>&\"'");
4820 "#,
4821 "test.js",
4822 )
4823 .unwrap();
4824 }
4826
4827 #[test]
4830 fn test_typescript_preamble_generated() {
4831 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
4833 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
4834 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
4835 println!(
4836 "Generated {} bytes of TypeScript preamble",
4837 JSEDITORAPI_TS_PREAMBLE.len()
4838 );
4839 }
4840
4841 #[test]
4842 fn test_typescript_editor_api_generated() {
4843 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
4845 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
4846 println!(
4847 "Generated {} bytes of EditorAPI interface",
4848 JSEDITORAPI_TS_EDITOR_API.len()
4849 );
4850 }
4851
4852 #[test]
4853 fn test_js_methods_list() {
4854 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
4856 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
4857 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
4859 if i < 20 {
4860 println!(" - {}", method);
4861 }
4862 }
4863 if JSEDITORAPI_JS_METHODS.len() > 20 {
4864 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
4865 }
4866 }
4867}