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