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