1use anyhow::{anyhow, Result};
90use fresh_core::api::{
91 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
92 JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions, PluginCommand,
93 PluginResponse,
94};
95use fresh_core::command::Command;
96use fresh_core::overlay::OverlayNamespace;
97use fresh_core::text_property::TextPropertyEntry;
98use fresh_core::{BufferId, SplitId};
99use fresh_parser_js::{
100 bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
101 transpile_typescript,
102};
103use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
104use rquickjs::{Context, Function, Object, Runtime, Value};
105use std::cell::RefCell;
106use std::collections::HashMap;
107use std::path::{Path, PathBuf};
108use std::rc::Rc;
109use std::sync::{mpsc, Arc, RwLock};
110
111fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
113 use rquickjs::Type;
114 match val.type_of() {
115 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
116 Type::Bool => val
117 .as_bool()
118 .map(serde_json::Value::Bool)
119 .unwrap_or(serde_json::Value::Null),
120 Type::Int => val
121 .as_int()
122 .map(|n| serde_json::Value::Number(n.into()))
123 .unwrap_or(serde_json::Value::Null),
124 Type::Float => val
125 .as_float()
126 .and_then(serde_json::Number::from_f64)
127 .map(serde_json::Value::Number)
128 .unwrap_or(serde_json::Value::Null),
129 Type::String => val
130 .as_string()
131 .and_then(|s| s.to_string().ok())
132 .map(serde_json::Value::String)
133 .unwrap_or(serde_json::Value::Null),
134 Type::Array => {
135 if let Some(arr) = val.as_array() {
136 let items: Vec<serde_json::Value> = arr
137 .iter()
138 .filter_map(|item| item.ok())
139 .map(|item| js_to_json(ctx, item))
140 .collect();
141 serde_json::Value::Array(items)
142 } else {
143 serde_json::Value::Null
144 }
145 }
146 Type::Object | Type::Constructor | Type::Function => {
147 if let Some(obj) = val.as_object() {
148 let mut map = serde_json::Map::new();
149 for key in obj.keys::<String>().flatten() {
150 if let Ok(v) = obj.get::<_, Value>(&key) {
151 map.insert(key, js_to_json(ctx, v));
152 }
153 }
154 serde_json::Value::Object(map)
155 } else {
156 serde_json::Value::Null
157 }
158 }
159 _ => serde_json::Value::Null,
160 }
161}
162
163fn get_text_properties_at_cursor_typed(
165 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
166 buffer_id: u32,
167) -> fresh_core::api::TextPropertiesAtCursor {
168 use fresh_core::api::TextPropertiesAtCursor;
169
170 let snap = match snapshot.read() {
171 Ok(s) => s,
172 Err(_) => return TextPropertiesAtCursor(Vec::new()),
173 };
174 let buffer_id_typed = BufferId(buffer_id as usize);
175 let cursor_pos = match snap
176 .buffer_cursor_positions
177 .get(&buffer_id_typed)
178 .copied()
179 .or_else(|| {
180 if snap.active_buffer_id == buffer_id_typed {
181 snap.primary_cursor.as_ref().map(|c| c.position)
182 } else {
183 None
184 }
185 }) {
186 Some(pos) => pos,
187 None => return TextPropertiesAtCursor(Vec::new()),
188 };
189
190 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
191 Some(p) => p,
192 None => return TextPropertiesAtCursor(Vec::new()),
193 };
194
195 let result: Vec<_> = properties
197 .iter()
198 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
199 .map(|prop| prop.properties.clone())
200 .collect();
201
202 TextPropertiesAtCursor(result)
203}
204
205fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
207 use rquickjs::Type;
208 match val.type_of() {
209 Type::Null => "null".to_string(),
210 Type::Undefined => "undefined".to_string(),
211 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
212 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
213 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
214 Type::String => val
215 .as_string()
216 .and_then(|s| s.to_string().ok())
217 .unwrap_or_default(),
218 Type::Object | Type::Exception => {
219 if let Some(obj) = val.as_object() {
221 let name: Option<String> = obj.get("name").ok();
223 let message: Option<String> = obj.get("message").ok();
224 let stack: Option<String> = obj.get("stack").ok();
225
226 if message.is_some() || name.is_some() {
227 let name = name.unwrap_or_else(|| "Error".to_string());
229 let message = message.unwrap_or_default();
230 if let Some(stack) = stack {
231 return format!("{}: {}\n{}", name, message, stack);
232 } else {
233 return format!("{}: {}", name, message);
234 }
235 }
236
237 let json = js_to_json(ctx, val.clone());
239 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
240 } else {
241 "[object]".to_string()
242 }
243 }
244 Type::Array => {
245 let json = js_to_json(ctx, val.clone());
246 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
247 }
248 Type::Function | Type::Constructor => "[function]".to_string(),
249 Type::Symbol => "[symbol]".to_string(),
250 Type::BigInt => val
251 .as_big_int()
252 .and_then(|b| b.clone().to_i64().ok())
253 .map(|n| n.to_string())
254 .unwrap_or_else(|| "[bigint]".to_string()),
255 _ => format!("[{}]", val.type_name()),
256 }
257}
258
259fn format_js_error(
261 ctx: &rquickjs::Ctx<'_>,
262 err: rquickjs::Error,
263 source_name: &str,
264) -> anyhow::Error {
265 if err.is_exception() {
267 let exc = ctx.catch();
269 if !exc.is_undefined() && !exc.is_null() {
270 if let Some(exc_obj) = exc.as_object() {
272 let message: String = exc_obj
273 .get::<_, String>("message")
274 .unwrap_or_else(|_| "Unknown error".to_string());
275 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
276 let name: String = exc_obj
277 .get::<_, String>("name")
278 .unwrap_or_else(|_| "Error".to_string());
279
280 if !stack.is_empty() {
281 return anyhow::anyhow!(
282 "JS error in {}: {}: {}\nStack trace:\n{}",
283 source_name,
284 name,
285 message,
286 stack
287 );
288 } else {
289 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
290 }
291 } else {
292 let exc_str: String = exc
294 .as_string()
295 .and_then(|s: &rquickjs::String| s.to_string().ok())
296 .unwrap_or_else(|| format!("{:?}", exc));
297 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
298 }
299 }
300 }
301
302 anyhow::anyhow!("JS error in {}: {}", source_name, err)
304}
305
306fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
309 let error = format_js_error(ctx, err, context);
310 tracing::error!("{}", error);
311
312 if should_panic_on_js_errors() {
314 panic!("JavaScript error in {}: {}", context, error);
315 }
316}
317
318static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
320 std::sync::atomic::AtomicBool::new(false);
321
322pub fn set_panic_on_js_errors(enabled: bool) {
324 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
325}
326
327fn should_panic_on_js_errors() -> bool {
329 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
330}
331
332static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
336
337static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
339
340fn set_fatal_js_error(msg: String) {
342 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
343 if guard.is_none() {
344 *guard = Some(msg);
346 }
347 }
348 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
349}
350
351pub fn has_fatal_js_error() -> bool {
353 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
354}
355
356pub fn take_fatal_js_error() -> Option<String> {
358 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
359 return None;
360 }
361 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
362 guard.take()
363 } else {
364 Some("Fatal JS error (message unavailable)".to_string())
365 }
366}
367
368fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
371 let mut count = 0;
372 loop {
373 let exc: rquickjs::Value = ctx.catch();
375 if exc.is_exception() {
377 let error_msg = if let Some(err) = exc.as_exception() {
378 format!(
379 "{}: {}",
380 err.message().unwrap_or_default(),
381 err.stack().unwrap_or_default()
382 )
383 } else {
384 format!("{:?}", exc)
385 };
386 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
387 if should_panic_on_js_errors() {
388 panic!("Unhandled JS exception during {}: {}", context, error_msg);
389 }
390 }
391
392 if !ctx.execute_pending_job() {
393 break;
394 }
395 count += 1;
396 }
397
398 let exc: rquickjs::Value = ctx.catch();
400 if exc.is_exception() {
401 let error_msg = if let Some(err) = exc.as_exception() {
402 format!(
403 "{}: {}",
404 err.message().unwrap_or_default(),
405 err.stack().unwrap_or_default()
406 )
407 } else {
408 format!("{:?}", exc)
409 };
410 tracing::error!(
411 "Unhandled JS exception after running jobs in {}: {}",
412 context,
413 error_msg
414 );
415 if should_panic_on_js_errors() {
416 panic!(
417 "Unhandled JS exception after running jobs in {}: {}",
418 context, error_msg
419 );
420 }
421 }
422
423 count
424}
425
426fn parse_text_property_entry(
428 ctx: &rquickjs::Ctx<'_>,
429 obj: &Object<'_>,
430) -> Option<TextPropertyEntry> {
431 let text: String = obj.get("text").ok()?;
432 let properties: HashMap<String, serde_json::Value> = obj
433 .get::<_, Object>("properties")
434 .ok()
435 .map(|props_obj| {
436 let mut map = HashMap::new();
437 for key in props_obj.keys::<String>().flatten() {
438 if let Ok(v) = props_obj.get::<_, Value>(&key) {
439 map.insert(key, js_to_json(ctx, v));
440 }
441 }
442 map
443 })
444 .unwrap_or_default();
445 Some(TextPropertyEntry { text, properties })
446}
447
448pub type PendingResponses =
450 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
451
452#[derive(Debug, Clone)]
454pub struct TsPluginInfo {
455 pub name: String,
456 pub path: PathBuf,
457 pub enabled: bool,
458}
459
460#[derive(Debug, Clone)]
462pub struct PluginHandler {
463 pub plugin_name: String,
464 pub handler_name: String,
465}
466
467#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
470#[rquickjs::class]
471pub struct JsEditorApi {
472 #[qjs(skip_trace)]
473 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
474 #[qjs(skip_trace)]
475 command_sender: mpsc::Sender<PluginCommand>,
476 #[qjs(skip_trace)]
477 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
478 #[qjs(skip_trace)]
479 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
480 #[qjs(skip_trace)]
481 next_request_id: Rc<RefCell<u64>>,
482 #[qjs(skip_trace)]
483 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
484 #[qjs(skip_trace)]
485 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
486 pub plugin_name: String,
487}
488
489#[plugin_api_impl]
490#[rquickjs::methods(rename_all = "camelCase")]
491impl JsEditorApi {
492 pub fn get_active_buffer_id(&self) -> u32 {
496 self.state_snapshot
497 .read()
498 .map(|s| s.active_buffer_id.0 as u32)
499 .unwrap_or(0)
500 }
501
502 pub fn get_active_split_id(&self) -> u32 {
504 self.state_snapshot
505 .read()
506 .map(|s| s.active_split_id as u32)
507 .unwrap_or(0)
508 }
509
510 #[plugin_api(ts_return = "BufferInfo[]")]
512 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
513 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
514 s.buffers.values().cloned().collect()
515 } else {
516 Vec::new()
517 };
518 rquickjs_serde::to_value(ctx, &buffers)
519 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
520 }
521
522 pub fn debug(&self, msg: String) {
525 tracing::info!("Plugin.debug: {}", msg);
526 }
527
528 pub fn info(&self, msg: String) {
529 tracing::info!("Plugin: {}", msg);
530 }
531
532 pub fn warn(&self, msg: String) {
533 tracing::warn!("Plugin: {}", msg);
534 }
535
536 pub fn error(&self, msg: String) {
537 tracing::error!("Plugin: {}", msg);
538 }
539
540 pub fn set_status(&self, msg: String) {
543 let _ = self
544 .command_sender
545 .send(PluginCommand::SetStatus { message: msg });
546 }
547
548 pub fn copy_to_clipboard(&self, text: String) {
551 let _ = self
552 .command_sender
553 .send(PluginCommand::SetClipboard { text });
554 }
555
556 pub fn set_clipboard(&self, text: String) {
557 let _ = self
558 .command_sender
559 .send(PluginCommand::SetClipboard { text });
560 }
561
562 pub fn register_command<'js>(
567 &self,
568 _ctx: rquickjs::Ctx<'js>,
569 name: String,
570 description: String,
571 handler_name: String,
572 context: rquickjs::function::Opt<rquickjs::Value<'js>>,
573 ) -> rquickjs::Result<bool> {
574 let plugin_name = self.plugin_name.clone();
576 let context_str: Option<String> = context.0.and_then(|v| {
578 if v.is_null() || v.is_undefined() {
579 None
580 } else {
581 v.as_string().and_then(|s| s.to_string().ok())
582 }
583 });
584
585 tracing::debug!(
586 "registerCommand: plugin='{}', name='{}', handler='{}'",
587 plugin_name,
588 name,
589 handler_name
590 );
591
592 self.registered_actions.borrow_mut().insert(
594 handler_name.clone(),
595 PluginHandler {
596 plugin_name: self.plugin_name.clone(),
597 handler_name: handler_name.clone(),
598 },
599 );
600
601 let command = Command {
603 name: name.clone(),
604 description,
605 action_name: handler_name,
606 plugin_name,
607 custom_contexts: context_str.into_iter().collect(),
608 };
609
610 Ok(self
611 .command_sender
612 .send(PluginCommand::RegisterCommand { command })
613 .is_ok())
614 }
615
616 pub fn unregister_command(&self, name: String) -> bool {
618 self.command_sender
619 .send(PluginCommand::UnregisterCommand { name })
620 .is_ok()
621 }
622
623 pub fn set_context(&self, name: String, active: bool) -> bool {
625 self.command_sender
626 .send(PluginCommand::SetContext { name, active })
627 .is_ok()
628 }
629
630 pub fn execute_action(&self, action_name: String) -> bool {
632 self.command_sender
633 .send(PluginCommand::ExecuteAction { action_name })
634 .is_ok()
635 }
636
637 pub fn t<'js>(
642 &self,
643 _ctx: rquickjs::Ctx<'js>,
644 key: String,
645 args: rquickjs::function::Rest<Value<'js>>,
646 ) -> String {
647 let plugin_name = self.plugin_name.clone();
649 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
651 if let Some(obj) = first_arg.as_object() {
652 let mut map = HashMap::new();
653 for k in obj.keys::<String>().flatten() {
654 if let Ok(v) = obj.get::<_, String>(&k) {
655 map.insert(k, v);
656 }
657 }
658 map
659 } else {
660 HashMap::new()
661 }
662 } else {
663 HashMap::new()
664 };
665 let res = self.services.translate(&plugin_name, &key, &args_map);
666
667 tracing::info!(
668 "Translating: key={}, plugin={}, args={:?} => res='{}'",
669 key,
670 plugin_name,
671 args_map,
672 res
673 );
674 res
675 }
676
677 pub fn get_cursor_position(&self) -> u32 {
681 self.state_snapshot
682 .read()
683 .ok()
684 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
685 .unwrap_or(0)
686 }
687
688 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
690 if let Ok(s) = self.state_snapshot.read() {
691 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
692 if let Some(p) = &b.path {
693 return p.to_string_lossy().to_string();
694 }
695 }
696 }
697 String::new()
698 }
699
700 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
702 if let Ok(s) = self.state_snapshot.read() {
703 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
704 return b.length as u32;
705 }
706 }
707 0
708 }
709
710 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
712 if let Ok(s) = self.state_snapshot.read() {
713 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
714 return b.modified;
715 }
716 }
717 false
718 }
719
720 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
723 self.command_sender
724 .send(PluginCommand::SaveBufferToPath {
725 buffer_id: BufferId(buffer_id as usize),
726 path: std::path::PathBuf::from(path),
727 })
728 .is_ok()
729 }
730
731 #[plugin_api(ts_return = "BufferInfo | null")]
733 pub fn get_buffer_info<'js>(
734 &self,
735 ctx: rquickjs::Ctx<'js>,
736 buffer_id: u32,
737 ) -> rquickjs::Result<Value<'js>> {
738 let info = if let Ok(s) = self.state_snapshot.read() {
739 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
740 } else {
741 None
742 };
743 rquickjs_serde::to_value(ctx, &info)
744 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
745 }
746
747 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
749 let cursor = if let Ok(s) = self.state_snapshot.read() {
750 s.primary_cursor.clone()
751 } else {
752 None
753 };
754 rquickjs_serde::to_value(ctx, &cursor)
755 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
756 }
757
758 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
760 let cursors = if let Ok(s) = self.state_snapshot.read() {
761 s.all_cursors.clone()
762 } else {
763 Vec::new()
764 };
765 rquickjs_serde::to_value(ctx, &cursors)
766 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
767 }
768
769 pub fn get_all_cursor_positions<'js>(
771 &self,
772 ctx: rquickjs::Ctx<'js>,
773 ) -> rquickjs::Result<Value<'js>> {
774 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
775 s.all_cursors.iter().map(|c| c.position as u32).collect()
776 } else {
777 Vec::new()
778 };
779 rquickjs_serde::to_value(ctx, &positions)
780 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
781 }
782
783 #[plugin_api(ts_return = "ViewportInfo | null")]
785 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
786 let viewport = if let Ok(s) = self.state_snapshot.read() {
787 s.viewport.clone()
788 } else {
789 None
790 };
791 rquickjs_serde::to_value(ctx, &viewport)
792 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
793 }
794
795 pub fn get_cursor_line(&self) -> u32 {
797 0
801 }
802
803 #[plugin_api(
806 async_promise,
807 js_name = "getLineStartPosition",
808 ts_return = "number | null"
809 )]
810 #[qjs(rename = "_getLineStartPositionStart")]
811 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
812 let id = {
813 let mut id_ref = self.next_request_id.borrow_mut();
814 let id = *id_ref;
815 *id_ref += 1;
816 self.callback_contexts
818 .borrow_mut()
819 .insert(id, self.plugin_name.clone());
820 id
821 };
822 let _ = self
824 .command_sender
825 .send(PluginCommand::GetLineStartPosition {
826 buffer_id: BufferId(0),
827 line,
828 request_id: id,
829 });
830 id
831 }
832
833 #[plugin_api(
837 async_promise,
838 js_name = "getLineEndPosition",
839 ts_return = "number | null"
840 )]
841 #[qjs(rename = "_getLineEndPositionStart")]
842 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
843 let id = {
844 let mut id_ref = self.next_request_id.borrow_mut();
845 let id = *id_ref;
846 *id_ref += 1;
847 self.callback_contexts
848 .borrow_mut()
849 .insert(id, self.plugin_name.clone());
850 id
851 };
852 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
854 buffer_id: BufferId(0),
855 line,
856 request_id: id,
857 });
858 id
859 }
860
861 #[plugin_api(
864 async_promise,
865 js_name = "getBufferLineCount",
866 ts_return = "number | null"
867 )]
868 #[qjs(rename = "_getBufferLineCountStart")]
869 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
870 let id = {
871 let mut id_ref = self.next_request_id.borrow_mut();
872 let id = *id_ref;
873 *id_ref += 1;
874 self.callback_contexts
875 .borrow_mut()
876 .insert(id, self.plugin_name.clone());
877 id
878 };
879 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
881 buffer_id: BufferId(0),
882 request_id: id,
883 });
884 id
885 }
886
887 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
890 self.command_sender
891 .send(PluginCommand::ScrollToLineCenter {
892 split_id: SplitId(split_id as usize),
893 buffer_id: BufferId(buffer_id as usize),
894 line: line as usize,
895 })
896 .is_ok()
897 }
898
899 pub fn find_buffer_by_path(&self, path: String) -> u32 {
901 let path_buf = std::path::PathBuf::from(&path);
902 if let Ok(s) = self.state_snapshot.read() {
903 for (id, info) in &s.buffers {
904 if let Some(buf_path) = &info.path {
905 if buf_path == &path_buf {
906 return id.0 as u32;
907 }
908 }
909 }
910 }
911 0
912 }
913
914 #[plugin_api(ts_return = "BufferSavedDiff | null")]
916 pub fn get_buffer_saved_diff<'js>(
917 &self,
918 ctx: rquickjs::Ctx<'js>,
919 buffer_id: u32,
920 ) -> rquickjs::Result<Value<'js>> {
921 let diff = if let Ok(s) = self.state_snapshot.read() {
922 s.buffer_saved_diffs
923 .get(&BufferId(buffer_id as usize))
924 .cloned()
925 } else {
926 None
927 };
928 rquickjs_serde::to_value(ctx, &diff)
929 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
930 }
931
932 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
936 self.command_sender
937 .send(PluginCommand::InsertText {
938 buffer_id: BufferId(buffer_id as usize),
939 position: position as usize,
940 text,
941 })
942 .is_ok()
943 }
944
945 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
947 self.command_sender
948 .send(PluginCommand::DeleteRange {
949 buffer_id: BufferId(buffer_id as usize),
950 range: (start as usize)..(end as usize),
951 })
952 .is_ok()
953 }
954
955 pub fn insert_at_cursor(&self, text: String) -> bool {
957 self.command_sender
958 .send(PluginCommand::InsertAtCursor { text })
959 .is_ok()
960 }
961
962 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
966 self.command_sender
967 .send(PluginCommand::OpenFileAtLocation {
968 path: PathBuf::from(path),
969 line: line.map(|l| l as usize),
970 column: column.map(|c| c as usize),
971 })
972 .is_ok()
973 }
974
975 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
977 self.command_sender
978 .send(PluginCommand::OpenFileInSplit {
979 split_id: split_id as usize,
980 path: PathBuf::from(path),
981 line: Some(line as usize),
982 column: Some(column as usize),
983 })
984 .is_ok()
985 }
986
987 pub fn show_buffer(&self, buffer_id: u32) -> bool {
989 self.command_sender
990 .send(PluginCommand::ShowBuffer {
991 buffer_id: BufferId(buffer_id as usize),
992 })
993 .is_ok()
994 }
995
996 pub fn close_buffer(&self, buffer_id: u32) -> bool {
998 self.command_sender
999 .send(PluginCommand::CloseBuffer {
1000 buffer_id: BufferId(buffer_id as usize),
1001 })
1002 .is_ok()
1003 }
1004
1005 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1009 self.event_handlers
1010 .borrow_mut()
1011 .entry(event_name)
1012 .or_default()
1013 .push(PluginHandler {
1014 plugin_name: self.plugin_name.clone(),
1015 handler_name,
1016 });
1017 }
1018
1019 pub fn off(&self, event_name: String, handler_name: String) {
1021 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1022 list.retain(|h| h.handler_name != handler_name);
1023 }
1024 }
1025
1026 pub fn get_env(&self, name: String) -> Option<String> {
1030 std::env::var(&name).ok()
1031 }
1032
1033 pub fn get_cwd(&self) -> String {
1035 self.state_snapshot
1036 .read()
1037 .map(|s| s.working_dir.to_string_lossy().to_string())
1038 .unwrap_or_else(|_| ".".to_string())
1039 }
1040
1041 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1046 let mut result_parts: Vec<String> = Vec::new();
1047 let mut has_leading_slash = false;
1048
1049 for part in &parts.0 {
1050 let normalized = part.replace('\\', "/");
1052
1053 let is_absolute = normalized.starts_with('/')
1055 || (normalized.len() >= 2
1056 && normalized
1057 .chars()
1058 .next()
1059 .map(|c| c.is_ascii_alphabetic())
1060 .unwrap_or(false)
1061 && normalized.chars().nth(1) == Some(':'));
1062
1063 if is_absolute {
1064 result_parts.clear();
1066 has_leading_slash = normalized.starts_with('/');
1067 }
1068
1069 for segment in normalized.split('/') {
1071 if !segment.is_empty() && segment != "." {
1072 if segment == ".." {
1073 result_parts.pop();
1074 } else {
1075 result_parts.push(segment.to_string());
1076 }
1077 }
1078 }
1079 }
1080
1081 let joined = result_parts.join("/");
1083
1084 if has_leading_slash && !joined.is_empty() {
1086 format!("/{}", joined)
1087 } else {
1088 joined
1089 }
1090 }
1091
1092 pub fn path_dirname(&self, path: String) -> String {
1094 Path::new(&path)
1095 .parent()
1096 .map(|p| p.to_string_lossy().to_string())
1097 .unwrap_or_default()
1098 }
1099
1100 pub fn path_basename(&self, path: String) -> String {
1102 Path::new(&path)
1103 .file_name()
1104 .map(|s| s.to_string_lossy().to_string())
1105 .unwrap_or_default()
1106 }
1107
1108 pub fn path_extname(&self, path: String) -> String {
1110 Path::new(&path)
1111 .extension()
1112 .map(|s| format!(".{}", s.to_string_lossy()))
1113 .unwrap_or_default()
1114 }
1115
1116 pub fn path_is_absolute(&self, path: String) -> bool {
1118 Path::new(&path).is_absolute()
1119 }
1120
1121 pub fn file_exists(&self, path: String) -> bool {
1125 Path::new(&path).exists()
1126 }
1127
1128 pub fn read_file(&self, path: String) -> Option<String> {
1130 std::fs::read_to_string(&path).ok()
1131 }
1132
1133 pub fn write_file(&self, path: String, content: String) -> bool {
1135 std::fs::write(&path, content).is_ok()
1136 }
1137
1138 #[plugin_api(ts_return = "DirEntry[]")]
1140 pub fn read_dir<'js>(
1141 &self,
1142 ctx: rquickjs::Ctx<'js>,
1143 path: String,
1144 ) -> rquickjs::Result<Value<'js>> {
1145 use fresh_core::api::DirEntry;
1146
1147 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1148 Ok(entries) => entries
1149 .filter_map(|e| e.ok())
1150 .map(|entry| {
1151 let file_type = entry.file_type().ok();
1152 DirEntry {
1153 name: entry.file_name().to_string_lossy().to_string(),
1154 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1155 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1156 }
1157 })
1158 .collect(),
1159 Err(e) => {
1160 tracing::warn!("readDir failed for '{}': {}", path, e);
1161 Vec::new()
1162 }
1163 };
1164
1165 rquickjs_serde::to_value(ctx, &entries)
1166 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1167 }
1168
1169 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1173 let config: serde_json::Value = self
1174 .state_snapshot
1175 .read()
1176 .map(|s| s.config.clone())
1177 .unwrap_or_else(|_| serde_json::json!({}));
1178
1179 rquickjs_serde::to_value(ctx, &config)
1180 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1181 }
1182
1183 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1185 let config: serde_json::Value = self
1186 .state_snapshot
1187 .read()
1188 .map(|s| s.user_config.clone())
1189 .unwrap_or_else(|_| serde_json::json!({}));
1190
1191 rquickjs_serde::to_value(ctx, &config)
1192 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1193 }
1194
1195 pub fn reload_config(&self) {
1197 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1198 }
1199
1200 pub fn reload_themes(&self) {
1203 let _ = self.command_sender.send(PluginCommand::ReloadThemes);
1204 }
1205
1206 pub fn register_grammar(
1209 &self,
1210 language: String,
1211 grammar_path: String,
1212 extensions: Vec<String>,
1213 ) -> bool {
1214 self.command_sender
1215 .send(PluginCommand::RegisterGrammar {
1216 language,
1217 grammar_path,
1218 extensions,
1219 })
1220 .is_ok()
1221 }
1222
1223 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1225 self.command_sender
1226 .send(PluginCommand::RegisterLanguageConfig { language, config })
1227 .is_ok()
1228 }
1229
1230 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1232 self.command_sender
1233 .send(PluginCommand::RegisterLspServer { language, config })
1234 .is_ok()
1235 }
1236
1237 pub fn reload_grammars(&self) {
1240 let _ = self.command_sender.send(PluginCommand::ReloadGrammars);
1241 }
1242
1243 pub fn get_config_dir(&self) -> String {
1245 self.services.config_dir().to_string_lossy().to_string()
1246 }
1247
1248 pub fn get_themes_dir(&self) -> String {
1250 self.services
1251 .config_dir()
1252 .join("themes")
1253 .to_string_lossy()
1254 .to_string()
1255 }
1256
1257 pub fn apply_theme(&self, theme_name: String) -> bool {
1259 self.command_sender
1260 .send(PluginCommand::ApplyTheme { theme_name })
1261 .is_ok()
1262 }
1263
1264 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1266 let schema = self.services.get_theme_schema();
1267 rquickjs_serde::to_value(ctx, &schema)
1268 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1269 }
1270
1271 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1273 let themes = self.services.get_builtin_themes();
1274 rquickjs_serde::to_value(ctx, &themes)
1275 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1276 }
1277
1278 #[qjs(rename = "_deleteThemeSync")]
1280 pub fn delete_theme_sync(&self, name: String) -> bool {
1281 let themes_dir = self.services.config_dir().join("themes");
1283 let theme_path = themes_dir.join(format!("{}.json", name));
1284
1285 if let Ok(canonical) = theme_path.canonicalize() {
1287 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1288 if canonical.starts_with(&themes_canonical) {
1289 return std::fs::remove_file(&canonical).is_ok();
1290 }
1291 }
1292 }
1293 false
1294 }
1295
1296 pub fn delete_theme(&self, name: String) -> bool {
1298 self.delete_theme_sync(name)
1299 }
1300
1301 pub fn file_stat<'js>(
1305 &self,
1306 ctx: rquickjs::Ctx<'js>,
1307 path: String,
1308 ) -> rquickjs::Result<Value<'js>> {
1309 let metadata = std::fs::metadata(&path).ok();
1310 let stat = metadata.map(|m| {
1311 serde_json::json!({
1312 "isFile": m.is_file(),
1313 "isDir": m.is_dir(),
1314 "size": m.len(),
1315 "readonly": m.permissions().readonly(),
1316 })
1317 });
1318 rquickjs_serde::to_value(ctx, &stat)
1319 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1320 }
1321
1322 pub fn is_process_running(&self, _process_id: u64) -> bool {
1326 false
1329 }
1330
1331 pub fn kill_process(&self, process_id: u64) -> bool {
1333 self.command_sender
1334 .send(PluginCommand::KillBackgroundProcess { process_id })
1335 .is_ok()
1336 }
1337
1338 pub fn plugin_translate<'js>(
1342 &self,
1343 _ctx: rquickjs::Ctx<'js>,
1344 plugin_name: String,
1345 key: String,
1346 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1347 ) -> String {
1348 let args_map: HashMap<String, String> = args
1349 .0
1350 .map(|obj| {
1351 let mut map = HashMap::new();
1352 for (k, v) in obj.props::<String, String>().flatten() {
1353 map.insert(k, v);
1354 }
1355 map
1356 })
1357 .unwrap_or_default();
1358
1359 self.services.translate(&plugin_name, &key, &args_map)
1360 }
1361
1362 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1369 #[qjs(rename = "_createCompositeBufferStart")]
1370 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1371 let id = {
1372 let mut id_ref = self.next_request_id.borrow_mut();
1373 let id = *id_ref;
1374 *id_ref += 1;
1375 self.callback_contexts
1377 .borrow_mut()
1378 .insert(id, self.plugin_name.clone());
1379 id
1380 };
1381
1382 let _ = self
1383 .command_sender
1384 .send(PluginCommand::CreateCompositeBuffer {
1385 name: opts.name,
1386 mode: opts.mode,
1387 layout: opts.layout,
1388 sources: opts.sources,
1389 hunks: opts.hunks,
1390 request_id: Some(id),
1391 });
1392
1393 id
1394 }
1395
1396 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1400 self.command_sender
1401 .send(PluginCommand::UpdateCompositeAlignment {
1402 buffer_id: BufferId(buffer_id as usize),
1403 hunks,
1404 })
1405 .is_ok()
1406 }
1407
1408 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1410 self.command_sender
1411 .send(PluginCommand::CloseCompositeBuffer {
1412 buffer_id: BufferId(buffer_id as usize),
1413 })
1414 .is_ok()
1415 }
1416
1417 #[plugin_api(
1421 async_promise,
1422 js_name = "getHighlights",
1423 ts_return = "TsHighlightSpan[]"
1424 )]
1425 #[qjs(rename = "_getHighlightsStart")]
1426 pub fn get_highlights_start<'js>(
1427 &self,
1428 _ctx: rquickjs::Ctx<'js>,
1429 buffer_id: u32,
1430 start: u32,
1431 end: u32,
1432 ) -> rquickjs::Result<u64> {
1433 let id = {
1434 let mut id_ref = self.next_request_id.borrow_mut();
1435 let id = *id_ref;
1436 *id_ref += 1;
1437 self.callback_contexts
1439 .borrow_mut()
1440 .insert(id, self.plugin_name.clone());
1441 id
1442 };
1443
1444 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1445 buffer_id: BufferId(buffer_id as usize),
1446 range: (start as usize)..(end as usize),
1447 request_id: id,
1448 });
1449
1450 Ok(id)
1451 }
1452
1453 pub fn add_overlay<'js>(
1471 &self,
1472 _ctx: rquickjs::Ctx<'js>,
1473 buffer_id: u32,
1474 namespace: String,
1475 start: u32,
1476 end: u32,
1477 options: rquickjs::Object<'js>,
1478 ) -> rquickjs::Result<bool> {
1479 use fresh_core::api::OverlayColorSpec;
1480
1481 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1483 if let Ok(theme_key) = obj.get::<_, String>(key) {
1485 if !theme_key.is_empty() {
1486 return Some(OverlayColorSpec::ThemeKey(theme_key));
1487 }
1488 }
1489 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1491 if arr.len() >= 3 {
1492 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1493 }
1494 }
1495 None
1496 }
1497
1498 let fg = parse_color_spec("fg", &options);
1499 let bg = parse_color_spec("bg", &options);
1500 let underline: bool = options.get("underline").unwrap_or(false);
1501 let bold: bool = options.get("bold").unwrap_or(false);
1502 let italic: bool = options.get("italic").unwrap_or(false);
1503 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1504
1505 let options = OverlayOptions {
1506 fg,
1507 bg,
1508 underline,
1509 bold,
1510 italic,
1511 extend_to_line_end,
1512 };
1513
1514 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1515 buffer_id: BufferId(buffer_id as usize),
1516 namespace: Some(OverlayNamespace::from_string(namespace)),
1517 range: (start as usize)..(end as usize),
1518 options,
1519 });
1520
1521 Ok(true)
1522 }
1523
1524 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1526 self.command_sender
1527 .send(PluginCommand::ClearNamespace {
1528 buffer_id: BufferId(buffer_id as usize),
1529 namespace: OverlayNamespace::from_string(namespace),
1530 })
1531 .is_ok()
1532 }
1533
1534 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1536 self.command_sender
1537 .send(PluginCommand::ClearAllOverlays {
1538 buffer_id: BufferId(buffer_id as usize),
1539 })
1540 .is_ok()
1541 }
1542
1543 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1545 self.command_sender
1546 .send(PluginCommand::ClearOverlaysInRange {
1547 buffer_id: BufferId(buffer_id as usize),
1548 start: start as usize,
1549 end: end as usize,
1550 })
1551 .is_ok()
1552 }
1553
1554 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1556 use fresh_core::overlay::OverlayHandle;
1557 self.command_sender
1558 .send(PluginCommand::RemoveOverlay {
1559 buffer_id: BufferId(buffer_id as usize),
1560 handle: OverlayHandle(handle),
1561 })
1562 .is_ok()
1563 }
1564
1565 #[allow(clippy::too_many_arguments)]
1575 pub fn submit_view_transform<'js>(
1576 &self,
1577 _ctx: rquickjs::Ctx<'js>,
1578 buffer_id: u32,
1579 split_id: Option<u32>,
1580 start: u32,
1581 end: u32,
1582 tokens: Vec<rquickjs::Object<'js>>,
1583 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1584 ) -> rquickjs::Result<bool> {
1585 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1586
1587 let tokens: Vec<ViewTokenWire> = tokens
1588 .into_iter()
1589 .enumerate()
1590 .map(|(idx, obj)| {
1591 parse_view_token(&obj, idx)
1593 })
1594 .collect::<rquickjs::Result<Vec<_>>>()?;
1595
1596 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
1598 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
1599 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
1600 Some(LayoutHints {
1601 compose_width,
1602 column_guides,
1603 })
1604 } else {
1605 None
1606 };
1607
1608 let payload = ViewTransformPayload {
1609 range: (start as usize)..(end as usize),
1610 tokens,
1611 layout_hints: parsed_layout_hints,
1612 };
1613
1614 Ok(self
1615 .command_sender
1616 .send(PluginCommand::SubmitViewTransform {
1617 buffer_id: BufferId(buffer_id as usize),
1618 split_id: split_id.map(|id| SplitId(id as usize)),
1619 payload,
1620 })
1621 .is_ok())
1622 }
1623
1624 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1626 self.command_sender
1627 .send(PluginCommand::ClearViewTransform {
1628 buffer_id: BufferId(buffer_id as usize),
1629 split_id: split_id.map(|id| SplitId(id as usize)),
1630 })
1631 .is_ok()
1632 }
1633
1634 pub fn set_file_explorer_decorations<'js>(
1638 &self,
1639 _ctx: rquickjs::Ctx<'js>,
1640 namespace: String,
1641 decorations: Vec<rquickjs::Object<'js>>,
1642 ) -> rquickjs::Result<bool> {
1643 use fresh_core::file_explorer::FileExplorerDecoration;
1644
1645 let decorations: Vec<FileExplorerDecoration> = decorations
1646 .into_iter()
1647 .map(|obj| {
1648 let path: String = obj.get("path")?;
1649 let symbol: String = obj.get("symbol")?;
1650 let color: Vec<u8> = obj.get("color")?;
1651 let priority: i32 = obj.get("priority").unwrap_or(0);
1652
1653 if color.len() < 3 {
1654 return Err(rquickjs::Error::FromJs {
1655 from: "array",
1656 to: "color",
1657 message: Some(format!(
1658 "color array must have at least 3 elements, got {}",
1659 color.len()
1660 )),
1661 });
1662 }
1663
1664 Ok(FileExplorerDecoration {
1665 path: std::path::PathBuf::from(path),
1666 symbol,
1667 color: [color[0], color[1], color[2]],
1668 priority,
1669 })
1670 })
1671 .collect::<rquickjs::Result<Vec<_>>>()?;
1672
1673 Ok(self
1674 .command_sender
1675 .send(PluginCommand::SetFileExplorerDecorations {
1676 namespace,
1677 decorations,
1678 })
1679 .is_ok())
1680 }
1681
1682 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1684 self.command_sender
1685 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1686 .is_ok()
1687 }
1688
1689 #[allow(clippy::too_many_arguments)]
1693 pub fn add_virtual_text(
1694 &self,
1695 buffer_id: u32,
1696 virtual_text_id: String,
1697 position: u32,
1698 text: String,
1699 r: u8,
1700 g: u8,
1701 b: u8,
1702 before: bool,
1703 use_bg: bool,
1704 ) -> bool {
1705 self.command_sender
1706 .send(PluginCommand::AddVirtualText {
1707 buffer_id: BufferId(buffer_id as usize),
1708 virtual_text_id,
1709 position: position as usize,
1710 text,
1711 color: (r, g, b),
1712 use_bg,
1713 before,
1714 })
1715 .is_ok()
1716 }
1717
1718 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1720 self.command_sender
1721 .send(PluginCommand::RemoveVirtualText {
1722 buffer_id: BufferId(buffer_id as usize),
1723 virtual_text_id,
1724 })
1725 .is_ok()
1726 }
1727
1728 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1730 self.command_sender
1731 .send(PluginCommand::RemoveVirtualTextsByPrefix {
1732 buffer_id: BufferId(buffer_id as usize),
1733 prefix,
1734 })
1735 .is_ok()
1736 }
1737
1738 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1740 self.command_sender
1741 .send(PluginCommand::ClearVirtualTexts {
1742 buffer_id: BufferId(buffer_id as usize),
1743 })
1744 .is_ok()
1745 }
1746
1747 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1749 self.command_sender
1750 .send(PluginCommand::ClearVirtualTextNamespace {
1751 buffer_id: BufferId(buffer_id as usize),
1752 namespace,
1753 })
1754 .is_ok()
1755 }
1756
1757 #[allow(clippy::too_many_arguments)]
1759 pub fn add_virtual_line(
1760 &self,
1761 buffer_id: u32,
1762 position: u32,
1763 text: String,
1764 fg_r: u8,
1765 fg_g: u8,
1766 fg_b: u8,
1767 bg_r: u8,
1768 bg_g: u8,
1769 bg_b: u8,
1770 above: bool,
1771 namespace: String,
1772 priority: i32,
1773 ) -> bool {
1774 self.command_sender
1775 .send(PluginCommand::AddVirtualLine {
1776 buffer_id: BufferId(buffer_id as usize),
1777 position: position as usize,
1778 text,
1779 fg_color: (fg_r, fg_g, fg_b),
1780 bg_color: Some((bg_r, bg_g, bg_b)),
1781 above,
1782 namespace,
1783 priority,
1784 })
1785 .is_ok()
1786 }
1787
1788 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1793 #[qjs(rename = "_promptStart")]
1794 pub fn prompt_start(
1795 &self,
1796 _ctx: rquickjs::Ctx<'_>,
1797 label: String,
1798 initial_value: String,
1799 ) -> u64 {
1800 let id = {
1801 let mut id_ref = self.next_request_id.borrow_mut();
1802 let id = *id_ref;
1803 *id_ref += 1;
1804 self.callback_contexts
1806 .borrow_mut()
1807 .insert(id, self.plugin_name.clone());
1808 id
1809 };
1810
1811 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1812 label,
1813 initial_value,
1814 callback_id: JsCallbackId::new(id),
1815 });
1816
1817 id
1818 }
1819
1820 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1822 self.command_sender
1823 .send(PluginCommand::StartPrompt { label, prompt_type })
1824 .is_ok()
1825 }
1826
1827 pub fn start_prompt_with_initial(
1829 &self,
1830 label: String,
1831 prompt_type: String,
1832 initial_value: String,
1833 ) -> bool {
1834 self.command_sender
1835 .send(PluginCommand::StartPromptWithInitial {
1836 label,
1837 prompt_type,
1838 initial_value,
1839 })
1840 .is_ok()
1841 }
1842
1843 pub fn set_prompt_suggestions(
1847 &self,
1848 suggestions: Vec<fresh_core::command::Suggestion>,
1849 ) -> bool {
1850 self.command_sender
1851 .send(PluginCommand::SetPromptSuggestions { suggestions })
1852 .is_ok()
1853 }
1854
1855 pub fn define_mode(
1859 &self,
1860 name: String,
1861 parent: Option<String>,
1862 bindings_arr: Vec<Vec<String>>,
1863 read_only: rquickjs::function::Opt<bool>,
1864 ) -> bool {
1865 let bindings: Vec<(String, String)> = bindings_arr
1866 .into_iter()
1867 .filter_map(|arr| {
1868 if arr.len() >= 2 {
1869 Some((arr[0].clone(), arr[1].clone()))
1870 } else {
1871 None
1872 }
1873 })
1874 .collect();
1875
1876 {
1879 let mut registered = self.registered_actions.borrow_mut();
1880 for (_, cmd_name) in &bindings {
1881 registered.insert(
1882 cmd_name.clone(),
1883 PluginHandler {
1884 plugin_name: self.plugin_name.clone(),
1885 handler_name: cmd_name.clone(),
1886 },
1887 );
1888 }
1889 }
1890
1891 self.command_sender
1892 .send(PluginCommand::DefineMode {
1893 name,
1894 parent,
1895 bindings,
1896 read_only: read_only.0.unwrap_or(false),
1897 })
1898 .is_ok()
1899 }
1900
1901 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1903 self.command_sender
1904 .send(PluginCommand::SetEditorMode { mode })
1905 .is_ok()
1906 }
1907
1908 pub fn get_editor_mode(&self) -> Option<String> {
1910 self.state_snapshot
1911 .read()
1912 .ok()
1913 .and_then(|s| s.editor_mode.clone())
1914 }
1915
1916 pub fn close_split(&self, split_id: u32) -> bool {
1920 self.command_sender
1921 .send(PluginCommand::CloseSplit {
1922 split_id: SplitId(split_id as usize),
1923 })
1924 .is_ok()
1925 }
1926
1927 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1929 self.command_sender
1930 .send(PluginCommand::SetSplitBuffer {
1931 split_id: SplitId(split_id as usize),
1932 buffer_id: BufferId(buffer_id as usize),
1933 })
1934 .is_ok()
1935 }
1936
1937 pub fn focus_split(&self, split_id: u32) -> bool {
1939 self.command_sender
1940 .send(PluginCommand::FocusSplit {
1941 split_id: SplitId(split_id as usize),
1942 })
1943 .is_ok()
1944 }
1945
1946 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1948 self.command_sender
1949 .send(PluginCommand::SetSplitScroll {
1950 split_id: SplitId(split_id as usize),
1951 top_byte: top_byte as usize,
1952 })
1953 .is_ok()
1954 }
1955
1956 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1958 self.command_sender
1959 .send(PluginCommand::SetSplitRatio {
1960 split_id: SplitId(split_id as usize),
1961 ratio,
1962 })
1963 .is_ok()
1964 }
1965
1966 pub fn distribute_splits_evenly(&self) -> bool {
1968 self.command_sender
1970 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1971 .is_ok()
1972 }
1973
1974 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1976 self.command_sender
1977 .send(PluginCommand::SetBufferCursor {
1978 buffer_id: BufferId(buffer_id as usize),
1979 position: position as usize,
1980 })
1981 .is_ok()
1982 }
1983
1984 #[allow(clippy::too_many_arguments)]
1988 pub fn set_line_indicator(
1989 &self,
1990 buffer_id: u32,
1991 line: u32,
1992 namespace: String,
1993 symbol: String,
1994 r: u8,
1995 g: u8,
1996 b: u8,
1997 priority: i32,
1998 ) -> bool {
1999 self.command_sender
2000 .send(PluginCommand::SetLineIndicator {
2001 buffer_id: BufferId(buffer_id as usize),
2002 line: line as usize,
2003 namespace,
2004 symbol,
2005 color: (r, g, b),
2006 priority,
2007 })
2008 .is_ok()
2009 }
2010
2011 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2013 self.command_sender
2014 .send(PluginCommand::ClearLineIndicators {
2015 buffer_id: BufferId(buffer_id as usize),
2016 namespace,
2017 })
2018 .is_ok()
2019 }
2020
2021 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2023 self.command_sender
2024 .send(PluginCommand::SetLineNumbers {
2025 buffer_id: BufferId(buffer_id as usize),
2026 enabled,
2027 })
2028 .is_ok()
2029 }
2030
2031 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2033 self.command_sender
2034 .send(PluginCommand::SetLineWrap {
2035 buffer_id: BufferId(buffer_id as usize),
2036 split_id: split_id.map(|s| SplitId(s as usize)),
2037 enabled,
2038 })
2039 .is_ok()
2040 }
2041
2042 pub fn create_scroll_sync_group(
2046 &self,
2047 group_id: u32,
2048 left_split: u32,
2049 right_split: u32,
2050 ) -> bool {
2051 self.command_sender
2052 .send(PluginCommand::CreateScrollSyncGroup {
2053 group_id,
2054 left_split: SplitId(left_split as usize),
2055 right_split: SplitId(right_split as usize),
2056 })
2057 .is_ok()
2058 }
2059
2060 pub fn set_scroll_sync_anchors<'js>(
2062 &self,
2063 _ctx: rquickjs::Ctx<'js>,
2064 group_id: u32,
2065 anchors: Vec<Vec<u32>>,
2066 ) -> bool {
2067 let anchors: Vec<(usize, usize)> = anchors
2068 .into_iter()
2069 .filter_map(|pair| {
2070 if pair.len() >= 2 {
2071 Some((pair[0] as usize, pair[1] as usize))
2072 } else {
2073 None
2074 }
2075 })
2076 .collect();
2077 self.command_sender
2078 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2079 .is_ok()
2080 }
2081
2082 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2084 self.command_sender
2085 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2086 .is_ok()
2087 }
2088
2089 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2095 self.command_sender
2096 .send(PluginCommand::ExecuteActions { actions })
2097 .is_ok()
2098 }
2099
2100 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2104 self.command_sender
2105 .send(PluginCommand::ShowActionPopup {
2106 popup_id: opts.id,
2107 title: opts.title,
2108 message: opts.message,
2109 actions: opts.actions,
2110 })
2111 .is_ok()
2112 }
2113
2114 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2116 self.command_sender
2117 .send(PluginCommand::DisableLspForLanguage { language })
2118 .is_ok()
2119 }
2120
2121 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2124 self.command_sender
2125 .send(PluginCommand::SetLspRootUri { language, uri })
2126 .is_ok()
2127 }
2128
2129 #[plugin_api(ts_return = "JsDiagnostic[]")]
2131 pub fn get_all_diagnostics<'js>(
2132 &self,
2133 ctx: rquickjs::Ctx<'js>,
2134 ) -> rquickjs::Result<Value<'js>> {
2135 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2136
2137 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2138 let mut result: Vec<JsDiagnostic> = Vec::new();
2140 for (uri, diags) in &s.diagnostics {
2141 for diag in diags {
2142 result.push(JsDiagnostic {
2143 uri: uri.clone(),
2144 message: diag.message.clone(),
2145 severity: diag.severity.map(|s| match s {
2146 lsp_types::DiagnosticSeverity::ERROR => 1,
2147 lsp_types::DiagnosticSeverity::WARNING => 2,
2148 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2149 lsp_types::DiagnosticSeverity::HINT => 4,
2150 _ => 0,
2151 }),
2152 range: JsRange {
2153 start: JsPosition {
2154 line: diag.range.start.line,
2155 character: diag.range.start.character,
2156 },
2157 end: JsPosition {
2158 line: diag.range.end.line,
2159 character: diag.range.end.character,
2160 },
2161 },
2162 source: diag.source.clone(),
2163 });
2164 }
2165 }
2166 result
2167 } else {
2168 Vec::new()
2169 };
2170 rquickjs_serde::to_value(ctx, &diagnostics)
2171 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2172 }
2173
2174 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2176 self.event_handlers
2177 .borrow()
2178 .get(&event_name)
2179 .cloned()
2180 .unwrap_or_default()
2181 .into_iter()
2182 .map(|h| h.handler_name)
2183 .collect()
2184 }
2185
2186 #[plugin_api(
2190 async_promise,
2191 js_name = "createVirtualBuffer",
2192 ts_return = "VirtualBufferResult"
2193 )]
2194 #[qjs(rename = "_createVirtualBufferStart")]
2195 pub fn create_virtual_buffer_start(
2196 &self,
2197 _ctx: rquickjs::Ctx<'_>,
2198 opts: fresh_core::api::CreateVirtualBufferOptions,
2199 ) -> rquickjs::Result<u64> {
2200 let id = {
2201 let mut id_ref = self.next_request_id.borrow_mut();
2202 let id = *id_ref;
2203 *id_ref += 1;
2204 self.callback_contexts
2206 .borrow_mut()
2207 .insert(id, self.plugin_name.clone());
2208 id
2209 };
2210
2211 let entries: Vec<TextPropertyEntry> = opts
2213 .entries
2214 .unwrap_or_default()
2215 .into_iter()
2216 .map(|e| TextPropertyEntry {
2217 text: e.text,
2218 properties: e.properties.unwrap_or_default(),
2219 })
2220 .collect();
2221
2222 tracing::debug!(
2223 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2224 id
2225 );
2226 let _ = self
2227 .command_sender
2228 .send(PluginCommand::CreateVirtualBufferWithContent {
2229 name: opts.name,
2230 mode: opts.mode.unwrap_or_default(),
2231 read_only: opts.read_only.unwrap_or(false),
2232 entries,
2233 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2234 show_cursors: opts.show_cursors.unwrap_or(true),
2235 editing_disabled: opts.editing_disabled.unwrap_or(false),
2236 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2237 request_id: Some(id),
2238 });
2239 Ok(id)
2240 }
2241
2242 #[plugin_api(
2244 async_promise,
2245 js_name = "createVirtualBufferInSplit",
2246 ts_return = "VirtualBufferResult"
2247 )]
2248 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2249 pub fn create_virtual_buffer_in_split_start(
2250 &self,
2251 _ctx: rquickjs::Ctx<'_>,
2252 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2253 ) -> rquickjs::Result<u64> {
2254 let id = {
2255 let mut id_ref = self.next_request_id.borrow_mut();
2256 let id = *id_ref;
2257 *id_ref += 1;
2258 self.callback_contexts
2260 .borrow_mut()
2261 .insert(id, self.plugin_name.clone());
2262 id
2263 };
2264
2265 let entries: Vec<TextPropertyEntry> = opts
2267 .entries
2268 .unwrap_or_default()
2269 .into_iter()
2270 .map(|e| TextPropertyEntry {
2271 text: e.text,
2272 properties: e.properties.unwrap_or_default(),
2273 })
2274 .collect();
2275
2276 let _ = self
2277 .command_sender
2278 .send(PluginCommand::CreateVirtualBufferInSplit {
2279 name: opts.name,
2280 mode: opts.mode.unwrap_or_default(),
2281 read_only: opts.read_only.unwrap_or(false),
2282 entries,
2283 ratio: opts.ratio.unwrap_or(0.5),
2284 direction: opts.direction,
2285 panel_id: opts.panel_id,
2286 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2287 show_cursors: opts.show_cursors.unwrap_or(true),
2288 editing_disabled: opts.editing_disabled.unwrap_or(false),
2289 line_wrap: opts.line_wrap,
2290 request_id: Some(id),
2291 });
2292 Ok(id)
2293 }
2294
2295 #[plugin_api(
2297 async_promise,
2298 js_name = "createVirtualBufferInExistingSplit",
2299 ts_return = "VirtualBufferResult"
2300 )]
2301 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2302 pub fn create_virtual_buffer_in_existing_split_start(
2303 &self,
2304 _ctx: rquickjs::Ctx<'_>,
2305 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2306 ) -> rquickjs::Result<u64> {
2307 let id = {
2308 let mut id_ref = self.next_request_id.borrow_mut();
2309 let id = *id_ref;
2310 *id_ref += 1;
2311 self.callback_contexts
2313 .borrow_mut()
2314 .insert(id, self.plugin_name.clone());
2315 id
2316 };
2317
2318 let entries: Vec<TextPropertyEntry> = opts
2320 .entries
2321 .unwrap_or_default()
2322 .into_iter()
2323 .map(|e| TextPropertyEntry {
2324 text: e.text,
2325 properties: e.properties.unwrap_or_default(),
2326 })
2327 .collect();
2328
2329 let _ = self
2330 .command_sender
2331 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2332 name: opts.name,
2333 mode: opts.mode.unwrap_or_default(),
2334 read_only: opts.read_only.unwrap_or(false),
2335 entries,
2336 split_id: SplitId(opts.split_id),
2337 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2338 show_cursors: opts.show_cursors.unwrap_or(true),
2339 editing_disabled: opts.editing_disabled.unwrap_or(false),
2340 line_wrap: opts.line_wrap,
2341 request_id: Some(id),
2342 });
2343 Ok(id)
2344 }
2345
2346 pub fn set_virtual_buffer_content<'js>(
2350 &self,
2351 ctx: rquickjs::Ctx<'js>,
2352 buffer_id: u32,
2353 entries_arr: Vec<rquickjs::Object<'js>>,
2354 ) -> rquickjs::Result<bool> {
2355 let entries: Vec<TextPropertyEntry> = entries_arr
2356 .iter()
2357 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2358 .collect();
2359 Ok(self
2360 .command_sender
2361 .send(PluginCommand::SetVirtualBufferContent {
2362 buffer_id: BufferId(buffer_id as usize),
2363 entries,
2364 })
2365 .is_ok())
2366 }
2367
2368 pub fn get_text_properties_at_cursor(
2370 &self,
2371 buffer_id: u32,
2372 ) -> fresh_core::api::TextPropertiesAtCursor {
2373 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2374 }
2375
2376 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2380 #[qjs(rename = "_spawnProcessStart")]
2381 pub fn spawn_process_start(
2382 &self,
2383 _ctx: rquickjs::Ctx<'_>,
2384 command: String,
2385 args: Vec<String>,
2386 cwd: rquickjs::function::Opt<String>,
2387 ) -> u64 {
2388 let id = {
2389 let mut id_ref = self.next_request_id.borrow_mut();
2390 let id = *id_ref;
2391 *id_ref += 1;
2392 self.callback_contexts
2394 .borrow_mut()
2395 .insert(id, self.plugin_name.clone());
2396 id
2397 };
2398 let effective_cwd = cwd.0.or_else(|| {
2400 self.state_snapshot
2401 .read()
2402 .ok()
2403 .map(|s| s.working_dir.to_string_lossy().to_string())
2404 });
2405 tracing::info!(
2406 "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2407 command,
2408 args,
2409 effective_cwd,
2410 id
2411 );
2412 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2413 callback_id: JsCallbackId::new(id),
2414 command,
2415 args,
2416 cwd: effective_cwd,
2417 });
2418 id
2419 }
2420
2421 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2423 #[qjs(rename = "_spawnProcessWaitStart")]
2424 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2425 let id = {
2426 let mut id_ref = self.next_request_id.borrow_mut();
2427 let id = *id_ref;
2428 *id_ref += 1;
2429 self.callback_contexts
2431 .borrow_mut()
2432 .insert(id, self.plugin_name.clone());
2433 id
2434 };
2435 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2436 process_id,
2437 callback_id: JsCallbackId::new(id),
2438 });
2439 id
2440 }
2441
2442 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2444 #[qjs(rename = "_getBufferTextStart")]
2445 pub fn get_buffer_text_start(
2446 &self,
2447 _ctx: rquickjs::Ctx<'_>,
2448 buffer_id: u32,
2449 start: u32,
2450 end: u32,
2451 ) -> u64 {
2452 let id = {
2453 let mut id_ref = self.next_request_id.borrow_mut();
2454 let id = *id_ref;
2455 *id_ref += 1;
2456 self.callback_contexts
2458 .borrow_mut()
2459 .insert(id, self.plugin_name.clone());
2460 id
2461 };
2462 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2463 buffer_id: BufferId(buffer_id as usize),
2464 start: start as usize,
2465 end: end as usize,
2466 request_id: id,
2467 });
2468 id
2469 }
2470
2471 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2473 #[qjs(rename = "_delayStart")]
2474 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2475 let id = {
2476 let mut id_ref = self.next_request_id.borrow_mut();
2477 let id = *id_ref;
2478 *id_ref += 1;
2479 self.callback_contexts
2481 .borrow_mut()
2482 .insert(id, self.plugin_name.clone());
2483 id
2484 };
2485 let _ = self.command_sender.send(PluginCommand::Delay {
2486 callback_id: JsCallbackId::new(id),
2487 duration_ms,
2488 });
2489 id
2490 }
2491
2492 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2494 #[qjs(rename = "_sendLspRequestStart")]
2495 pub fn send_lsp_request_start<'js>(
2496 &self,
2497 ctx: rquickjs::Ctx<'js>,
2498 language: String,
2499 method: String,
2500 params: Option<rquickjs::Object<'js>>,
2501 ) -> rquickjs::Result<u64> {
2502 let id = {
2503 let mut id_ref = self.next_request_id.borrow_mut();
2504 let id = *id_ref;
2505 *id_ref += 1;
2506 self.callback_contexts
2508 .borrow_mut()
2509 .insert(id, self.plugin_name.clone());
2510 id
2511 };
2512 let params_json: Option<serde_json::Value> = params.map(|obj| {
2514 let val = obj.into_value();
2515 js_to_json(&ctx, val)
2516 });
2517 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2518 request_id: id,
2519 language,
2520 method,
2521 params: params_json,
2522 });
2523 Ok(id)
2524 }
2525
2526 #[plugin_api(
2528 async_thenable,
2529 js_name = "spawnBackgroundProcess",
2530 ts_return = "BackgroundProcessResult"
2531 )]
2532 #[qjs(rename = "_spawnBackgroundProcessStart")]
2533 pub fn spawn_background_process_start(
2534 &self,
2535 _ctx: rquickjs::Ctx<'_>,
2536 command: String,
2537 args: Vec<String>,
2538 cwd: rquickjs::function::Opt<String>,
2539 ) -> u64 {
2540 let id = {
2541 let mut id_ref = self.next_request_id.borrow_mut();
2542 let id = *id_ref;
2543 *id_ref += 1;
2544 self.callback_contexts
2546 .borrow_mut()
2547 .insert(id, self.plugin_name.clone());
2548 id
2549 };
2550 let process_id = id;
2552 let _ = self
2553 .command_sender
2554 .send(PluginCommand::SpawnBackgroundProcess {
2555 process_id,
2556 command,
2557 args,
2558 cwd: cwd.0,
2559 callback_id: JsCallbackId::new(id),
2560 });
2561 id
2562 }
2563
2564 pub fn kill_background_process(&self, process_id: u64) -> bool {
2566 self.command_sender
2567 .send(PluginCommand::KillBackgroundProcess { process_id })
2568 .is_ok()
2569 }
2570
2571 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2575 self.command_sender
2576 .send(PluginCommand::RefreshLines {
2577 buffer_id: BufferId(buffer_id as usize),
2578 })
2579 .is_ok()
2580 }
2581
2582 pub fn get_current_locale(&self) -> String {
2584 self.services.current_locale()
2585 }
2586
2587 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2591 #[qjs(rename = "_loadPluginStart")]
2592 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2593 let id = {
2594 let mut id_ref = self.next_request_id.borrow_mut();
2595 let id = *id_ref;
2596 *id_ref += 1;
2597 self.callback_contexts
2598 .borrow_mut()
2599 .insert(id, self.plugin_name.clone());
2600 id
2601 };
2602 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2603 path: std::path::PathBuf::from(path),
2604 callback_id: JsCallbackId::new(id),
2605 });
2606 id
2607 }
2608
2609 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2611 #[qjs(rename = "_unloadPluginStart")]
2612 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2613 let id = {
2614 let mut id_ref = self.next_request_id.borrow_mut();
2615 let id = *id_ref;
2616 *id_ref += 1;
2617 self.callback_contexts
2618 .borrow_mut()
2619 .insert(id, self.plugin_name.clone());
2620 id
2621 };
2622 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2623 name,
2624 callback_id: JsCallbackId::new(id),
2625 });
2626 id
2627 }
2628
2629 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
2631 #[qjs(rename = "_reloadPluginStart")]
2632 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2633 let id = {
2634 let mut id_ref = self.next_request_id.borrow_mut();
2635 let id = *id_ref;
2636 *id_ref += 1;
2637 self.callback_contexts
2638 .borrow_mut()
2639 .insert(id, self.plugin_name.clone());
2640 id
2641 };
2642 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
2643 name,
2644 callback_id: JsCallbackId::new(id),
2645 });
2646 id
2647 }
2648
2649 #[plugin_api(
2652 async_promise,
2653 js_name = "listPlugins",
2654 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
2655 )]
2656 #[qjs(rename = "_listPluginsStart")]
2657 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2658 let id = {
2659 let mut id_ref = self.next_request_id.borrow_mut();
2660 let id = *id_ref;
2661 *id_ref += 1;
2662 self.callback_contexts
2663 .borrow_mut()
2664 .insert(id, self.plugin_name.clone());
2665 id
2666 };
2667 let _ = self.command_sender.send(PluginCommand::ListPlugins {
2668 callback_id: JsCallbackId::new(id),
2669 });
2670 id
2671 }
2672}
2673
2674fn parse_view_token(
2681 obj: &rquickjs::Object<'_>,
2682 idx: usize,
2683) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
2684 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2685
2686 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
2688 from: "object",
2689 to: "ViewTokenWire",
2690 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
2691 })?;
2692
2693 let source_offset: Option<usize> = obj
2695 .get("sourceOffset")
2696 .ok()
2697 .or_else(|| obj.get("source_offset").ok());
2698
2699 let kind = if kind_value.is_string() {
2701 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2704 from: "value",
2705 to: "string",
2706 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
2707 })?;
2708
2709 match kind_str.to_lowercase().as_str() {
2710 "text" => {
2711 let text: String = obj.get("text").unwrap_or_default();
2712 ViewTokenWireKind::Text(text)
2713 }
2714 "newline" => ViewTokenWireKind::Newline,
2715 "space" => ViewTokenWireKind::Space,
2716 "break" => ViewTokenWireKind::Break,
2717 _ => {
2718 tracing::warn!(
2720 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
2721 idx, kind_str
2722 );
2723 return Err(rquickjs::Error::FromJs {
2724 from: "string",
2725 to: "ViewTokenWireKind",
2726 message: Some(format!(
2727 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
2728 idx, kind_str
2729 )),
2730 });
2731 }
2732 }
2733 } else if kind_value.is_object() {
2734 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2736 from: "value",
2737 to: "object",
2738 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
2739 })?;
2740
2741 if let Ok(text) = kind_obj.get::<_, String>("Text") {
2742 ViewTokenWireKind::Text(text)
2743 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
2744 ViewTokenWireKind::BinaryByte(byte)
2745 } else {
2746 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
2748 tracing::warn!(
2749 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
2750 idx,
2751 keys
2752 );
2753 return Err(rquickjs::Error::FromJs {
2754 from: "object",
2755 to: "ViewTokenWireKind",
2756 message: Some(format!(
2757 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
2758 idx, keys
2759 )),
2760 });
2761 }
2762 } else {
2763 tracing::warn!(
2764 "token[{}]: 'kind' field must be a string or object, got: {:?}",
2765 idx,
2766 kind_value.type_of()
2767 );
2768 return Err(rquickjs::Error::FromJs {
2769 from: "value",
2770 to: "ViewTokenWireKind",
2771 message: Some(format!(
2772 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
2773 idx
2774 )),
2775 });
2776 };
2777
2778 let style = parse_view_token_style(obj, idx)?;
2780
2781 Ok(ViewTokenWire {
2782 source_offset,
2783 kind,
2784 style,
2785 })
2786}
2787
2788fn parse_view_token_style(
2790 obj: &rquickjs::Object<'_>,
2791 idx: usize,
2792) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
2793 use fresh_core::api::ViewTokenStyle;
2794
2795 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
2796 let Some(s) = style_obj else {
2797 return Ok(None);
2798 };
2799
2800 let fg: Option<Vec<u8>> = s.get("fg").ok();
2801 let bg: Option<Vec<u8>> = s.get("bg").ok();
2802
2803 let fg_color = if let Some(ref c) = fg {
2805 if c.len() < 3 {
2806 tracing::warn!(
2807 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
2808 idx,
2809 c.len()
2810 );
2811 None
2812 } else {
2813 Some((c[0], c[1], c[2]))
2814 }
2815 } else {
2816 None
2817 };
2818
2819 let bg_color = if let Some(ref c) = bg {
2820 if c.len() < 3 {
2821 tracing::warn!(
2822 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
2823 idx,
2824 c.len()
2825 );
2826 None
2827 } else {
2828 Some((c[0], c[1], c[2]))
2829 }
2830 } else {
2831 None
2832 };
2833
2834 Ok(Some(ViewTokenStyle {
2835 fg: fg_color,
2836 bg: bg_color,
2837 bold: s.get("bold").unwrap_or(false),
2838 italic: s.get("italic").unwrap_or(false),
2839 }))
2840}
2841
2842pub struct QuickJsBackend {
2844 runtime: Runtime,
2845 main_context: Context,
2847 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2849 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2851 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2853 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2855 command_sender: mpsc::Sender<PluginCommand>,
2857 #[allow(dead_code)]
2859 pending_responses: PendingResponses,
2860 next_request_id: Rc<RefCell<u64>>,
2862 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2864 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2866}
2867
2868impl QuickJsBackend {
2869 pub fn new() -> Result<Self> {
2871 let (tx, _rx) = mpsc::channel();
2872 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2873 let services = Arc::new(fresh_core::services::NoopServiceBridge);
2874 Self::with_state(state_snapshot, tx, services)
2875 }
2876
2877 pub fn with_state(
2879 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2880 command_sender: mpsc::Sender<PluginCommand>,
2881 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2882 ) -> Result<Self> {
2883 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2884 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2885 }
2886
2887 pub fn with_state_and_responses(
2889 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2890 command_sender: mpsc::Sender<PluginCommand>,
2891 pending_responses: PendingResponses,
2892 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2893 ) -> Result<Self> {
2894 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2895
2896 let runtime =
2897 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2898
2899 runtime.set_host_promise_rejection_tracker(Some(Box::new(
2901 |_ctx, _promise, reason, is_handled| {
2902 if !is_handled {
2903 let error_msg = if let Some(exc) = reason.as_exception() {
2905 format!(
2906 "{}: {}",
2907 exc.message().unwrap_or_default(),
2908 exc.stack().unwrap_or_default()
2909 )
2910 } else {
2911 format!("{:?}", reason)
2912 };
2913
2914 tracing::error!("Unhandled Promise rejection: {}", error_msg);
2915
2916 if should_panic_on_js_errors() {
2917 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2920 set_fatal_js_error(full_msg);
2921 }
2922 }
2923 },
2924 )));
2925
2926 let main_context = Context::full(&runtime)
2927 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2928
2929 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2930 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2931 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2932 let next_request_id = Rc::new(RefCell::new(1u64));
2933 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2934
2935 let backend = Self {
2936 runtime,
2937 main_context,
2938 plugin_contexts,
2939 event_handlers,
2940 registered_actions,
2941 state_snapshot,
2942 command_sender,
2943 pending_responses,
2944 next_request_id,
2945 callback_contexts,
2946 services,
2947 };
2948
2949 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2951
2952 tracing::debug!("QuickJsBackend::new: runtime created successfully");
2953 Ok(backend)
2954 }
2955
2956 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2958 let state_snapshot = Arc::clone(&self.state_snapshot);
2959 let command_sender = self.command_sender.clone();
2960 let event_handlers = Rc::clone(&self.event_handlers);
2961 let registered_actions = Rc::clone(&self.registered_actions);
2962 let next_request_id = Rc::clone(&self.next_request_id);
2963
2964 context.with(|ctx| {
2965 let globals = ctx.globals();
2966
2967 globals.set("__pluginName__", plugin_name)?;
2969
2970 let js_api = JsEditorApi {
2973 state_snapshot: Arc::clone(&state_snapshot),
2974 command_sender: command_sender.clone(),
2975 registered_actions: Rc::clone(®istered_actions),
2976 event_handlers: Rc::clone(&event_handlers),
2977 next_request_id: Rc::clone(&next_request_id),
2978 callback_contexts: Rc::clone(&self.callback_contexts),
2979 services: self.services.clone(),
2980 plugin_name: plugin_name.to_string(),
2981 };
2982 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2983
2984 globals.set("editor", editor)?;
2986
2987 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2989
2990 let console = Object::new(ctx.clone())?;
2993 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2994 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2995 tracing::info!("console.log: {}", parts.join(" "));
2996 })?)?;
2997 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2998 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2999 tracing::warn!("console.warn: {}", parts.join(" "));
3000 })?)?;
3001 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3002 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3003 tracing::error!("console.error: {}", parts.join(" "));
3004 })?)?;
3005 globals.set("console", console)?;
3006
3007 ctx.eval::<(), _>(r#"
3009 // Pending promise callbacks: callbackId -> { resolve, reject }
3010 globalThis._pendingCallbacks = new Map();
3011
3012 // Resolve a pending callback (called from Rust)
3013 globalThis._resolveCallback = function(callbackId, result) {
3014 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
3015 const cb = globalThis._pendingCallbacks.get(callbackId);
3016 if (cb) {
3017 console.log('[JS] _resolveCallback: found callback, calling resolve()');
3018 globalThis._pendingCallbacks.delete(callbackId);
3019 cb.resolve(result);
3020 console.log('[JS] _resolveCallback: resolve() called');
3021 } else {
3022 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
3023 }
3024 };
3025
3026 // Reject a pending callback (called from Rust)
3027 globalThis._rejectCallback = function(callbackId, error) {
3028 const cb = globalThis._pendingCallbacks.get(callbackId);
3029 if (cb) {
3030 globalThis._pendingCallbacks.delete(callbackId);
3031 cb.reject(new Error(error));
3032 }
3033 };
3034
3035 // Generic async wrapper decorator
3036 // Wraps a function that returns a callbackId into a promise-returning function
3037 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
3038 // NOTE: We pass the method name as a string and call via bracket notation
3039 // to preserve rquickjs's automatic Ctx injection for methods
3040 globalThis._wrapAsync = function(methodName, fnName) {
3041 const startFn = editor[methodName];
3042 if (typeof startFn !== 'function') {
3043 // Return a function that always throws - catches missing implementations
3044 return function(...args) {
3045 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3046 editor.debug(`[ASYNC ERROR] ${error.message}`);
3047 throw error;
3048 };
3049 }
3050 return function(...args) {
3051 // Call via bracket notation to preserve method binding and Ctx injection
3052 const callbackId = editor[methodName](...args);
3053 return new Promise((resolve, reject) => {
3054 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3055 // TODO: Implement setTimeout polyfill using editor.delay() or similar
3056 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3057 });
3058 };
3059 };
3060
3061 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
3062 // The returned object has .result promise and is itself thenable
3063 globalThis._wrapAsyncThenable = function(methodName, fnName) {
3064 const startFn = editor[methodName];
3065 if (typeof startFn !== 'function') {
3066 // Return a function that always throws - catches missing implementations
3067 return function(...args) {
3068 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3069 editor.debug(`[ASYNC ERROR] ${error.message}`);
3070 throw error;
3071 };
3072 }
3073 return function(...args) {
3074 // Call via bracket notation to preserve method binding and Ctx injection
3075 const callbackId = editor[methodName](...args);
3076 const resultPromise = new Promise((resolve, reject) => {
3077 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3078 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3079 });
3080 return {
3081 get result() { return resultPromise; },
3082 then(onFulfilled, onRejected) {
3083 return resultPromise.then(onFulfilled, onRejected);
3084 },
3085 catch(onRejected) {
3086 return resultPromise.catch(onRejected);
3087 }
3088 };
3089 };
3090 };
3091
3092 // Apply wrappers to async functions on editor
3093 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3094 editor.delay = _wrapAsync("_delayStart", "delay");
3095 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3096 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3097 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3098 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3099 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3100 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3101 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3102 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3103 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3104 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3105 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3106 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3107 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3108 editor.prompt = _wrapAsync("_promptStart", "prompt");
3109 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
3110 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
3111
3112 // Wrapper for deleteTheme - wraps sync function in Promise
3113 editor.deleteTheme = function(name) {
3114 return new Promise(function(resolve, reject) {
3115 const success = editor._deleteThemeSync(name);
3116 if (success) {
3117 resolve();
3118 } else {
3119 reject(new Error("Failed to delete theme: " + name));
3120 }
3121 });
3122 };
3123 "#.as_bytes())?;
3124
3125 Ok::<_, rquickjs::Error>(())
3126 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3127
3128 Ok(())
3129 }
3130
3131 pub async fn load_module_with_source(
3133 &mut self,
3134 path: &str,
3135 _plugin_source: &str,
3136 ) -> Result<()> {
3137 let path_buf = PathBuf::from(path);
3138 let source = std::fs::read_to_string(&path_buf)
3139 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3140
3141 let filename = path_buf
3142 .file_name()
3143 .and_then(|s| s.to_str())
3144 .unwrap_or("plugin.ts");
3145
3146 if has_es_imports(&source) {
3148 match bundle_module(&path_buf) {
3150 Ok(bundled) => {
3151 self.execute_js(&bundled, path)?;
3152 }
3153 Err(e) => {
3154 tracing::warn!(
3155 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3156 path,
3157 e
3158 );
3159 return Ok(()); }
3161 }
3162 } else if has_es_module_syntax(&source) {
3163 let stripped = strip_imports_and_exports(&source);
3165 let js_code = if filename.ends_with(".ts") {
3166 transpile_typescript(&stripped, filename)?
3167 } else {
3168 stripped
3169 };
3170 self.execute_js(&js_code, path)?;
3171 } else {
3172 let js_code = if filename.ends_with(".ts") {
3174 transpile_typescript(&source, filename)?
3175 } else {
3176 source
3177 };
3178 self.execute_js(&js_code, path)?;
3179 }
3180
3181 Ok(())
3182 }
3183
3184 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3186 let plugin_name = Path::new(source_name)
3188 .file_stem()
3189 .and_then(|s| s.to_str())
3190 .unwrap_or("unknown");
3191
3192 tracing::debug!(
3193 "execute_js: starting for plugin '{}' from '{}'",
3194 plugin_name,
3195 source_name
3196 );
3197
3198 let context = {
3200 let mut contexts = self.plugin_contexts.borrow_mut();
3201 if let Some(ctx) = contexts.get(plugin_name) {
3202 ctx.clone()
3203 } else {
3204 let ctx = Context::full(&self.runtime).map_err(|e| {
3205 anyhow!(
3206 "Failed to create QuickJS context for plugin {}: {}",
3207 plugin_name,
3208 e
3209 )
3210 })?;
3211 self.setup_context_api(&ctx, plugin_name)?;
3212 contexts.insert(plugin_name.to_string(), ctx.clone());
3213 ctx
3214 }
3215 };
3216
3217 let wrapped_code = format!("(function() {{ {} }})();", code);
3221 let wrapped = wrapped_code.as_str();
3222
3223 context.with(|ctx| {
3224 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3225
3226 let mut eval_options = rquickjs::context::EvalOptions::default();
3228 eval_options.global = true;
3229 eval_options.filename = Some(source_name.to_string());
3230 let result = ctx
3231 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3232 .map_err(|e| format_js_error(&ctx, e, source_name));
3233
3234 tracing::debug!(
3235 "execute_js: plugin code execution finished for '{}', result: {:?}",
3236 plugin_name,
3237 result.is_ok()
3238 );
3239
3240 result
3241 })
3242 }
3243
3244 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3246 let _event_data_str = event_data.to_string();
3247 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3248
3249 self.services
3251 .set_js_execution_state(format!("hook '{}'", event_name));
3252
3253 let handlers = self.event_handlers.borrow().get(event_name).cloned();
3254
3255 if let Some(handler_pairs) = handlers {
3256 if handler_pairs.is_empty() {
3257 self.services.clear_js_execution_state();
3258 return Ok(true);
3259 }
3260
3261 let plugin_contexts = self.plugin_contexts.borrow();
3262 for handler in handler_pairs {
3263 let context_opt = plugin_contexts.get(&handler.plugin_name);
3264 if let Some(context) = context_opt {
3265 let handler_name = &handler.handler_name;
3266 let json_string = serde_json::to_string(event_data)?;
3272 let js_string_literal = serde_json::to_string(&json_string)?;
3273 let code = format!(
3274 r#"
3275 (function() {{
3276 try {{
3277 const data = JSON.parse({});
3278 if (typeof globalThis["{}"] === 'function') {{
3279 const result = globalThis["{}"](data);
3280 // If handler returns a Promise, catch rejections
3281 if (result && typeof result.then === 'function') {{
3282 result.catch(function(e) {{
3283 console.error('Handler {} async error:', e);
3284 // Re-throw to make it an unhandled rejection for the runtime to catch
3285 throw e;
3286 }});
3287 }}
3288 }}
3289 }} catch (e) {{
3290 console.error('Handler {} sync error:', e);
3291 throw e;
3292 }}
3293 }})();
3294 "#,
3295 js_string_literal, handler_name, handler_name, handler_name, handler_name
3296 );
3297
3298 context.with(|ctx| {
3299 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3300 log_js_error(&ctx, e, &format!("handler {}", handler_name));
3301 }
3302 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3304 });
3305 }
3306 }
3307 }
3308
3309 self.services.clear_js_execution_state();
3310 Ok(true)
3311 }
3312
3313 pub fn has_handlers(&self, event_name: &str) -> bool {
3315 self.event_handlers
3316 .borrow()
3317 .get(event_name)
3318 .map(|v| !v.is_empty())
3319 .unwrap_or(false)
3320 }
3321
3322 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3326 let pair = self.registered_actions.borrow().get(action_name).cloned();
3327 let (plugin_name, function_name) = match pair {
3328 Some(handler) => (handler.plugin_name, handler.handler_name),
3329 None => ("main".to_string(), action_name.to_string()),
3330 };
3331
3332 let plugin_contexts = self.plugin_contexts.borrow();
3333 let context = plugin_contexts
3334 .get(&plugin_name)
3335 .unwrap_or(&self.main_context);
3336
3337 self.services
3339 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3340
3341 tracing::info!(
3342 "start_action: BEGIN '{}' -> function '{}'",
3343 action_name,
3344 function_name
3345 );
3346
3347 let code = format!(
3349 r#"
3350 (function() {{
3351 console.log('[JS] start_action: calling {fn}');
3352 try {{
3353 if (typeof globalThis.{fn} === 'function') {{
3354 console.log('[JS] start_action: {fn} is a function, invoking...');
3355 globalThis.{fn}();
3356 console.log('[JS] start_action: {fn} invoked (may be async)');
3357 }} else {{
3358 console.error('[JS] Action {action} is not defined as a global function');
3359 }}
3360 }} catch (e) {{
3361 console.error('[JS] Action {action} error:', e);
3362 }}
3363 }})();
3364 "#,
3365 fn = function_name,
3366 action = action_name
3367 );
3368
3369 tracing::info!("start_action: evaluating JS code");
3370 context.with(|ctx| {
3371 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3372 log_js_error(&ctx, e, &format!("action {}", action_name));
3373 }
3374 tracing::info!("start_action: running pending microtasks");
3375 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3377 tracing::info!("start_action: executed {} pending jobs", count);
3378 });
3379
3380 tracing::info!("start_action: END '{}'", action_name);
3381
3382 self.services.clear_js_execution_state();
3384
3385 Ok(())
3386 }
3387
3388 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3390 let pair = self.registered_actions.borrow().get(action_name).cloned();
3392 let (plugin_name, function_name) = match pair {
3393 Some(handler) => (handler.plugin_name, handler.handler_name),
3394 None => ("main".to_string(), action_name.to_string()),
3395 };
3396
3397 let plugin_contexts = self.plugin_contexts.borrow();
3398 let context = plugin_contexts
3399 .get(&plugin_name)
3400 .unwrap_or(&self.main_context);
3401
3402 tracing::debug!(
3403 "execute_action: '{}' -> function '{}'",
3404 action_name,
3405 function_name
3406 );
3407
3408 let code = format!(
3411 r#"
3412 (async function() {{
3413 try {{
3414 if (typeof globalThis.{fn} === 'function') {{
3415 const result = globalThis.{fn}();
3416 // If it's a Promise, await it
3417 if (result && typeof result.then === 'function') {{
3418 await result;
3419 }}
3420 }} else {{
3421 console.error('Action {action} is not defined as a global function');
3422 }}
3423 }} catch (e) {{
3424 console.error('Action {action} error:', e);
3425 }}
3426 }})();
3427 "#,
3428 fn = function_name,
3429 action = action_name
3430 );
3431
3432 context.with(|ctx| {
3433 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3435 Ok(value) => {
3436 if value.is_object() {
3438 if let Some(obj) = value.as_object() {
3439 if obj.get::<_, rquickjs::Function>("then").is_ok() {
3441 run_pending_jobs_checked(
3444 &ctx,
3445 &format!("execute_action {} promise", action_name),
3446 );
3447 }
3448 }
3449 }
3450 }
3451 Err(e) => {
3452 log_js_error(&ctx, e, &format!("action {}", action_name));
3453 }
3454 }
3455 });
3456
3457 Ok(())
3458 }
3459
3460 pub fn poll_event_loop_once(&mut self) -> bool {
3462 let mut had_work = false;
3463
3464 self.main_context.with(|ctx| {
3466 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3467 if count > 0 {
3468 had_work = true;
3469 }
3470 });
3471
3472 let contexts = self.plugin_contexts.borrow().clone();
3474 for (name, context) in contexts {
3475 context.with(|ctx| {
3476 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3477 if count > 0 {
3478 had_work = true;
3479 }
3480 });
3481 }
3482 had_work
3483 }
3484
3485 pub fn send_status(&self, message: String) {
3487 let _ = self
3488 .command_sender
3489 .send(PluginCommand::SetStatus { message });
3490 }
3491
3492 pub fn resolve_callback(
3497 &mut self,
3498 callback_id: fresh_core::api::JsCallbackId,
3499 result_json: &str,
3500 ) {
3501 let id = callback_id.as_u64();
3502 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3503
3504 let plugin_name = {
3506 let mut contexts = self.callback_contexts.borrow_mut();
3507 contexts.remove(&id)
3508 };
3509
3510 let Some(name) = plugin_name else {
3511 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3512 return;
3513 };
3514
3515 let plugin_contexts = self.plugin_contexts.borrow();
3516 let Some(context) = plugin_contexts.get(&name) else {
3517 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3518 return;
3519 };
3520
3521 context.with(|ctx| {
3522 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3524 Ok(v) => v,
3525 Err(e) => {
3526 tracing::error!(
3527 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3528 id,
3529 e
3530 );
3531 return;
3532 }
3533 };
3534
3535 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3537 Ok(v) => v,
3538 Err(e) => {
3539 tracing::error!(
3540 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3541 id,
3542 e
3543 );
3544 return;
3545 }
3546 };
3547
3548 let globals = ctx.globals();
3550 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3551 Ok(f) => f,
3552 Err(e) => {
3553 tracing::error!(
3554 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3555 id,
3556 e
3557 );
3558 return;
3559 }
3560 };
3561
3562 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3564 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3565 }
3566
3567 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3569 tracing::info!(
3570 "resolve_callback: executed {} pending jobs for callback_id={}",
3571 job_count,
3572 id
3573 );
3574 });
3575 }
3576
3577 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3579 let id = callback_id.as_u64();
3580
3581 let plugin_name = {
3583 let mut contexts = self.callback_contexts.borrow_mut();
3584 contexts.remove(&id)
3585 };
3586
3587 let Some(name) = plugin_name else {
3588 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3589 return;
3590 };
3591
3592 let plugin_contexts = self.plugin_contexts.borrow();
3593 let Some(context) = plugin_contexts.get(&name) else {
3594 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3595 return;
3596 };
3597
3598 context.with(|ctx| {
3599 let globals = ctx.globals();
3601 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3602 Ok(f) => f,
3603 Err(e) => {
3604 tracing::error!(
3605 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3606 id,
3607 e
3608 );
3609 return;
3610 }
3611 };
3612
3613 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3615 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3616 }
3617
3618 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3620 });
3621 }
3622}
3623
3624#[cfg(test)]
3625mod tests {
3626 use super::*;
3627 use fresh_core::api::{BufferInfo, CursorInfo};
3628 use std::sync::mpsc;
3629
3630 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3632 let (tx, rx) = mpsc::channel();
3633 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3634 let services = Arc::new(TestServiceBridge::new());
3635 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3636 (backend, rx)
3637 }
3638
3639 struct TestServiceBridge {
3640 en_strings: std::sync::Mutex<HashMap<String, String>>,
3641 }
3642
3643 impl TestServiceBridge {
3644 fn new() -> Self {
3645 Self {
3646 en_strings: std::sync::Mutex::new(HashMap::new()),
3647 }
3648 }
3649 }
3650
3651 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3652 fn as_any(&self) -> &dyn std::any::Any {
3653 self
3654 }
3655 fn translate(
3656 &self,
3657 _plugin_name: &str,
3658 key: &str,
3659 _args: &HashMap<String, String>,
3660 ) -> String {
3661 self.en_strings
3662 .lock()
3663 .unwrap()
3664 .get(key)
3665 .cloned()
3666 .unwrap_or_else(|| key.to_string())
3667 }
3668 fn current_locale(&self) -> String {
3669 "en".to_string()
3670 }
3671 fn set_js_execution_state(&self, _state: String) {}
3672 fn clear_js_execution_state(&self) {}
3673 fn get_theme_schema(&self) -> serde_json::Value {
3674 serde_json::json!({})
3675 }
3676 fn get_builtin_themes(&self) -> serde_json::Value {
3677 serde_json::json!([])
3678 }
3679 fn register_command(&self, _command: fresh_core::command::Command) {}
3680 fn unregister_command(&self, _name: &str) {}
3681 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3682 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
3683 fn plugins_dir(&self) -> std::path::PathBuf {
3684 std::path::PathBuf::from("/tmp/plugins")
3685 }
3686 fn config_dir(&self) -> std::path::PathBuf {
3687 std::path::PathBuf::from("/tmp/config")
3688 }
3689 }
3690
3691 #[test]
3692 fn test_quickjs_backend_creation() {
3693 let backend = QuickJsBackend::new();
3694 assert!(backend.is_ok());
3695 }
3696
3697 #[test]
3698 fn test_execute_simple_js() {
3699 let mut backend = QuickJsBackend::new().unwrap();
3700 let result = backend.execute_js("const x = 1 + 2;", "test.js");
3701 assert!(result.is_ok());
3702 }
3703
3704 #[test]
3705 fn test_event_handler_registration() {
3706 let backend = QuickJsBackend::new().unwrap();
3707
3708 assert!(!backend.has_handlers("test_event"));
3710
3711 backend
3713 .event_handlers
3714 .borrow_mut()
3715 .entry("test_event".to_string())
3716 .or_default()
3717 .push(PluginHandler {
3718 plugin_name: "test".to_string(),
3719 handler_name: "testHandler".to_string(),
3720 });
3721
3722 assert!(backend.has_handlers("test_event"));
3724 }
3725
3726 #[test]
3729 fn test_api_set_status() {
3730 let (mut backend, rx) = create_test_backend();
3731
3732 backend
3733 .execute_js(
3734 r#"
3735 const editor = getEditor();
3736 editor.setStatus("Hello from test");
3737 "#,
3738 "test.js",
3739 )
3740 .unwrap();
3741
3742 let cmd = rx.try_recv().unwrap();
3743 match cmd {
3744 PluginCommand::SetStatus { message } => {
3745 assert_eq!(message, "Hello from test");
3746 }
3747 _ => panic!("Expected SetStatus command, got {:?}", cmd),
3748 }
3749 }
3750
3751 #[test]
3752 fn test_api_register_command() {
3753 let (mut backend, rx) = create_test_backend();
3754
3755 backend
3756 .execute_js(
3757 r#"
3758 const editor = getEditor();
3759 globalThis.myTestHandler = function() { };
3760 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3761 "#,
3762 "test_plugin.js",
3763 )
3764 .unwrap();
3765
3766 let cmd = rx.try_recv().unwrap();
3767 match cmd {
3768 PluginCommand::RegisterCommand { command } => {
3769 assert_eq!(command.name, "Test Command");
3770 assert_eq!(command.description, "A test command");
3771 assert_eq!(command.plugin_name, "test_plugin");
3773 }
3774 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3775 }
3776 }
3777
3778 #[test]
3779 fn test_api_define_mode() {
3780 let (mut backend, rx) = create_test_backend();
3781
3782 backend
3783 .execute_js(
3784 r#"
3785 const editor = getEditor();
3786 editor.defineMode("test-mode", null, [
3787 ["a", "action_a"],
3788 ["b", "action_b"]
3789 ]);
3790 "#,
3791 "test.js",
3792 )
3793 .unwrap();
3794
3795 let cmd = rx.try_recv().unwrap();
3796 match cmd {
3797 PluginCommand::DefineMode {
3798 name,
3799 parent,
3800 bindings,
3801 read_only,
3802 } => {
3803 assert_eq!(name, "test-mode");
3804 assert!(parent.is_none());
3805 assert_eq!(bindings.len(), 2);
3806 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3807 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3808 assert!(!read_only);
3809 }
3810 _ => panic!("Expected DefineMode, got {:?}", cmd),
3811 }
3812 }
3813
3814 #[test]
3815 fn test_api_set_editor_mode() {
3816 let (mut backend, rx) = create_test_backend();
3817
3818 backend
3819 .execute_js(
3820 r#"
3821 const editor = getEditor();
3822 editor.setEditorMode("vi-normal");
3823 "#,
3824 "test.js",
3825 )
3826 .unwrap();
3827
3828 let cmd = rx.try_recv().unwrap();
3829 match cmd {
3830 PluginCommand::SetEditorMode { mode } => {
3831 assert_eq!(mode, Some("vi-normal".to_string()));
3832 }
3833 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3834 }
3835 }
3836
3837 #[test]
3838 fn test_api_clear_editor_mode() {
3839 let (mut backend, rx) = create_test_backend();
3840
3841 backend
3842 .execute_js(
3843 r#"
3844 const editor = getEditor();
3845 editor.setEditorMode(null);
3846 "#,
3847 "test.js",
3848 )
3849 .unwrap();
3850
3851 let cmd = rx.try_recv().unwrap();
3852 match cmd {
3853 PluginCommand::SetEditorMode { mode } => {
3854 assert!(mode.is_none());
3855 }
3856 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3857 }
3858 }
3859
3860 #[test]
3861 fn test_api_insert_at_cursor() {
3862 let (mut backend, rx) = create_test_backend();
3863
3864 backend
3865 .execute_js(
3866 r#"
3867 const editor = getEditor();
3868 editor.insertAtCursor("Hello, World!");
3869 "#,
3870 "test.js",
3871 )
3872 .unwrap();
3873
3874 let cmd = rx.try_recv().unwrap();
3875 match cmd {
3876 PluginCommand::InsertAtCursor { text } => {
3877 assert_eq!(text, "Hello, World!");
3878 }
3879 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3880 }
3881 }
3882
3883 #[test]
3884 fn test_api_set_context() {
3885 let (mut backend, rx) = create_test_backend();
3886
3887 backend
3888 .execute_js(
3889 r#"
3890 const editor = getEditor();
3891 editor.setContext("myContext", true);
3892 "#,
3893 "test.js",
3894 )
3895 .unwrap();
3896
3897 let cmd = rx.try_recv().unwrap();
3898 match cmd {
3899 PluginCommand::SetContext { name, active } => {
3900 assert_eq!(name, "myContext");
3901 assert!(active);
3902 }
3903 _ => panic!("Expected SetContext, got {:?}", cmd),
3904 }
3905 }
3906
3907 #[tokio::test]
3908 async fn test_execute_action_sync_function() {
3909 let (mut backend, rx) = create_test_backend();
3910
3911 backend.registered_actions.borrow_mut().insert(
3913 "my_sync_action".to_string(),
3914 PluginHandler {
3915 plugin_name: "test".to_string(),
3916 handler_name: "my_sync_action".to_string(),
3917 },
3918 );
3919
3920 backend
3922 .execute_js(
3923 r#"
3924 const editor = getEditor();
3925 globalThis.my_sync_action = function() {
3926 editor.setStatus("sync action executed");
3927 };
3928 "#,
3929 "test.js",
3930 )
3931 .unwrap();
3932
3933 while rx.try_recv().is_ok() {}
3935
3936 backend.execute_action("my_sync_action").await.unwrap();
3938
3939 let cmd = rx.try_recv().unwrap();
3941 match cmd {
3942 PluginCommand::SetStatus { message } => {
3943 assert_eq!(message, "sync action executed");
3944 }
3945 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3946 }
3947 }
3948
3949 #[tokio::test]
3950 async fn test_execute_action_async_function() {
3951 let (mut backend, rx) = create_test_backend();
3952
3953 backend.registered_actions.borrow_mut().insert(
3955 "my_async_action".to_string(),
3956 PluginHandler {
3957 plugin_name: "test".to_string(),
3958 handler_name: "my_async_action".to_string(),
3959 },
3960 );
3961
3962 backend
3964 .execute_js(
3965 r#"
3966 const editor = getEditor();
3967 globalThis.my_async_action = async function() {
3968 await Promise.resolve();
3969 editor.setStatus("async action executed");
3970 };
3971 "#,
3972 "test.js",
3973 )
3974 .unwrap();
3975
3976 while rx.try_recv().is_ok() {}
3978
3979 backend.execute_action("my_async_action").await.unwrap();
3981
3982 let cmd = rx.try_recv().unwrap();
3984 match cmd {
3985 PluginCommand::SetStatus { message } => {
3986 assert_eq!(message, "async action executed");
3987 }
3988 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3989 }
3990 }
3991
3992 #[tokio::test]
3993 async fn test_execute_action_with_registered_handler() {
3994 let (mut backend, rx) = create_test_backend();
3995
3996 backend.registered_actions.borrow_mut().insert(
3998 "my_action".to_string(),
3999 PluginHandler {
4000 plugin_name: "test".to_string(),
4001 handler_name: "actual_handler_function".to_string(),
4002 },
4003 );
4004
4005 backend
4006 .execute_js(
4007 r#"
4008 const editor = getEditor();
4009 globalThis.actual_handler_function = function() {
4010 editor.setStatus("handler executed");
4011 };
4012 "#,
4013 "test.js",
4014 )
4015 .unwrap();
4016
4017 while rx.try_recv().is_ok() {}
4019
4020 backend.execute_action("my_action").await.unwrap();
4022
4023 let cmd = rx.try_recv().unwrap();
4024 match cmd {
4025 PluginCommand::SetStatus { message } => {
4026 assert_eq!(message, "handler executed");
4027 }
4028 _ => panic!("Expected SetStatus, got {:?}", cmd),
4029 }
4030 }
4031
4032 #[test]
4033 fn test_api_on_event_registration() {
4034 let (mut backend, _rx) = create_test_backend();
4035
4036 backend
4037 .execute_js(
4038 r#"
4039 const editor = getEditor();
4040 globalThis.myEventHandler = function() { };
4041 editor.on("bufferSave", "myEventHandler");
4042 "#,
4043 "test.js",
4044 )
4045 .unwrap();
4046
4047 assert!(backend.has_handlers("bufferSave"));
4048 }
4049
4050 #[test]
4051 fn test_api_off_event_unregistration() {
4052 let (mut backend, _rx) = create_test_backend();
4053
4054 backend
4055 .execute_js(
4056 r#"
4057 const editor = getEditor();
4058 globalThis.myEventHandler = function() { };
4059 editor.on("bufferSave", "myEventHandler");
4060 editor.off("bufferSave", "myEventHandler");
4061 "#,
4062 "test.js",
4063 )
4064 .unwrap();
4065
4066 assert!(!backend.has_handlers("bufferSave"));
4068 }
4069
4070 #[tokio::test]
4071 async fn test_emit_event() {
4072 let (mut backend, rx) = create_test_backend();
4073
4074 backend
4075 .execute_js(
4076 r#"
4077 const editor = getEditor();
4078 globalThis.onSaveHandler = function(data) {
4079 editor.setStatus("saved: " + JSON.stringify(data));
4080 };
4081 editor.on("bufferSave", "onSaveHandler");
4082 "#,
4083 "test.js",
4084 )
4085 .unwrap();
4086
4087 while rx.try_recv().is_ok() {}
4089
4090 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
4092 backend.emit("bufferSave", &event_data).await.unwrap();
4093
4094 let cmd = rx.try_recv().unwrap();
4095 match cmd {
4096 PluginCommand::SetStatus { message } => {
4097 assert!(message.contains("/test.txt"));
4098 }
4099 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
4100 }
4101 }
4102
4103 #[test]
4104 fn test_api_copy_to_clipboard() {
4105 let (mut backend, rx) = create_test_backend();
4106
4107 backend
4108 .execute_js(
4109 r#"
4110 const editor = getEditor();
4111 editor.copyToClipboard("clipboard text");
4112 "#,
4113 "test.js",
4114 )
4115 .unwrap();
4116
4117 let cmd = rx.try_recv().unwrap();
4118 match cmd {
4119 PluginCommand::SetClipboard { text } => {
4120 assert_eq!(text, "clipboard text");
4121 }
4122 _ => panic!("Expected SetClipboard, got {:?}", cmd),
4123 }
4124 }
4125
4126 #[test]
4127 fn test_api_open_file() {
4128 let (mut backend, rx) = create_test_backend();
4129
4130 backend
4132 .execute_js(
4133 r#"
4134 const editor = getEditor();
4135 editor.openFile("/path/to/file.txt", null, null);
4136 "#,
4137 "test.js",
4138 )
4139 .unwrap();
4140
4141 let cmd = rx.try_recv().unwrap();
4142 match cmd {
4143 PluginCommand::OpenFileAtLocation { path, line, column } => {
4144 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
4145 assert!(line.is_none());
4146 assert!(column.is_none());
4147 }
4148 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
4149 }
4150 }
4151
4152 #[test]
4153 fn test_api_delete_range() {
4154 let (mut backend, rx) = create_test_backend();
4155
4156 backend
4158 .execute_js(
4159 r#"
4160 const editor = getEditor();
4161 editor.deleteRange(0, 10, 20);
4162 "#,
4163 "test.js",
4164 )
4165 .unwrap();
4166
4167 let cmd = rx.try_recv().unwrap();
4168 match cmd {
4169 PluginCommand::DeleteRange { range, .. } => {
4170 assert_eq!(range.start, 10);
4171 assert_eq!(range.end, 20);
4172 }
4173 _ => panic!("Expected DeleteRange, got {:?}", cmd),
4174 }
4175 }
4176
4177 #[test]
4178 fn test_api_insert_text() {
4179 let (mut backend, rx) = create_test_backend();
4180
4181 backend
4183 .execute_js(
4184 r#"
4185 const editor = getEditor();
4186 editor.insertText(0, 5, "inserted");
4187 "#,
4188 "test.js",
4189 )
4190 .unwrap();
4191
4192 let cmd = rx.try_recv().unwrap();
4193 match cmd {
4194 PluginCommand::InsertText { position, text, .. } => {
4195 assert_eq!(position, 5);
4196 assert_eq!(text, "inserted");
4197 }
4198 _ => panic!("Expected InsertText, got {:?}", cmd),
4199 }
4200 }
4201
4202 #[test]
4203 fn test_api_set_buffer_cursor() {
4204 let (mut backend, rx) = create_test_backend();
4205
4206 backend
4208 .execute_js(
4209 r#"
4210 const editor = getEditor();
4211 editor.setBufferCursor(0, 100);
4212 "#,
4213 "test.js",
4214 )
4215 .unwrap();
4216
4217 let cmd = rx.try_recv().unwrap();
4218 match cmd {
4219 PluginCommand::SetBufferCursor { position, .. } => {
4220 assert_eq!(position, 100);
4221 }
4222 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
4223 }
4224 }
4225
4226 #[test]
4227 fn test_api_get_cursor_position_from_state() {
4228 let (tx, _rx) = mpsc::channel();
4229 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4230
4231 {
4233 let mut state = state_snapshot.write().unwrap();
4234 state.primary_cursor = Some(CursorInfo {
4235 position: 42,
4236 selection: None,
4237 });
4238 }
4239
4240 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4241 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4242
4243 backend
4245 .execute_js(
4246 r#"
4247 const editor = getEditor();
4248 const pos = editor.getCursorPosition();
4249 globalThis._testResult = pos;
4250 "#,
4251 "test.js",
4252 )
4253 .unwrap();
4254
4255 backend
4257 .plugin_contexts
4258 .borrow()
4259 .get("test")
4260 .unwrap()
4261 .clone()
4262 .with(|ctx| {
4263 let global = ctx.globals();
4264 let result: u32 = global.get("_testResult").unwrap();
4265 assert_eq!(result, 42);
4266 });
4267 }
4268
4269 #[test]
4270 fn test_api_path_functions() {
4271 let (mut backend, _rx) = create_test_backend();
4272
4273 #[cfg(windows)]
4276 let absolute_path = r#"C:\\foo\\bar"#;
4277 #[cfg(not(windows))]
4278 let absolute_path = "/foo/bar";
4279
4280 let js_code = format!(
4282 r#"
4283 const editor = getEditor();
4284 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4285 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4286 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4287 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4288 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4289 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4290 "#,
4291 absolute_path
4292 );
4293 backend.execute_js(&js_code, "test.js").unwrap();
4294
4295 backend
4296 .plugin_contexts
4297 .borrow()
4298 .get("test")
4299 .unwrap()
4300 .clone()
4301 .with(|ctx| {
4302 let global = ctx.globals();
4303 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4304 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4305 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4306 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4307 assert!(!global.get::<_, bool>("_isRelative").unwrap());
4308 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4309 });
4310 }
4311
4312 #[test]
4313 fn test_typescript_transpilation() {
4314 use fresh_parser_js::transpile_typescript;
4315
4316 let (mut backend, rx) = create_test_backend();
4317
4318 let ts_code = r#"
4320 const editor = getEditor();
4321 function greet(name: string): string {
4322 return "Hello, " + name;
4323 }
4324 editor.setStatus(greet("TypeScript"));
4325 "#;
4326
4327 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4329
4330 backend.execute_js(&js_code, "test.js").unwrap();
4332
4333 let cmd = rx.try_recv().unwrap();
4334 match cmd {
4335 PluginCommand::SetStatus { message } => {
4336 assert_eq!(message, "Hello, TypeScript");
4337 }
4338 _ => panic!("Expected SetStatus, got {:?}", cmd),
4339 }
4340 }
4341
4342 #[test]
4343 fn test_api_get_buffer_text_sends_command() {
4344 let (mut backend, rx) = create_test_backend();
4345
4346 backend
4348 .execute_js(
4349 r#"
4350 const editor = getEditor();
4351 // Store the promise for later
4352 globalThis._textPromise = editor.getBufferText(0, 10, 20);
4353 "#,
4354 "test.js",
4355 )
4356 .unwrap();
4357
4358 let cmd = rx.try_recv().unwrap();
4360 match cmd {
4361 PluginCommand::GetBufferText {
4362 buffer_id,
4363 start,
4364 end,
4365 request_id,
4366 } => {
4367 assert_eq!(buffer_id.0, 0);
4368 assert_eq!(start, 10);
4369 assert_eq!(end, 20);
4370 assert!(request_id > 0); }
4372 _ => panic!("Expected GetBufferText, got {:?}", cmd),
4373 }
4374 }
4375
4376 #[test]
4377 fn test_api_get_buffer_text_resolves_callback() {
4378 let (mut backend, rx) = create_test_backend();
4379
4380 backend
4382 .execute_js(
4383 r#"
4384 const editor = getEditor();
4385 globalThis._resolvedText = null;
4386 editor.getBufferText(0, 0, 100).then(text => {
4387 globalThis._resolvedText = text;
4388 });
4389 "#,
4390 "test.js",
4391 )
4392 .unwrap();
4393
4394 let request_id = match rx.try_recv().unwrap() {
4396 PluginCommand::GetBufferText { request_id, .. } => request_id,
4397 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4398 };
4399
4400 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4402
4403 backend
4405 .plugin_contexts
4406 .borrow()
4407 .get("test")
4408 .unwrap()
4409 .clone()
4410 .with(|ctx| {
4411 run_pending_jobs_checked(&ctx, "test async getText");
4412 });
4413
4414 backend
4416 .plugin_contexts
4417 .borrow()
4418 .get("test")
4419 .unwrap()
4420 .clone()
4421 .with(|ctx| {
4422 let global = ctx.globals();
4423 let result: String = global.get("_resolvedText").unwrap();
4424 assert_eq!(result, "hello world");
4425 });
4426 }
4427
4428 #[test]
4429 fn test_plugin_translation() {
4430 let (mut backend, _rx) = create_test_backend();
4431
4432 backend
4434 .execute_js(
4435 r#"
4436 const editor = getEditor();
4437 globalThis._translated = editor.t("test.key");
4438 "#,
4439 "test.js",
4440 )
4441 .unwrap();
4442
4443 backend
4444 .plugin_contexts
4445 .borrow()
4446 .get("test")
4447 .unwrap()
4448 .clone()
4449 .with(|ctx| {
4450 let global = ctx.globals();
4451 let result: String = global.get("_translated").unwrap();
4453 assert_eq!(result, "test.key");
4454 });
4455 }
4456
4457 #[test]
4458 fn test_plugin_translation_with_registered_strings() {
4459 let (mut backend, _rx) = create_test_backend();
4460
4461 let mut en_strings = std::collections::HashMap::new();
4463 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4464 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4465
4466 let mut strings = std::collections::HashMap::new();
4467 strings.insert("en".to_string(), en_strings);
4468
4469 if let Some(bridge) = backend
4471 .services
4472 .as_any()
4473 .downcast_ref::<TestServiceBridge>()
4474 {
4475 let mut en = bridge.en_strings.lock().unwrap();
4476 en.insert("greeting".to_string(), "Hello, World!".to_string());
4477 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4478 }
4479
4480 backend
4482 .execute_js(
4483 r#"
4484 const editor = getEditor();
4485 globalThis._greeting = editor.t("greeting");
4486 globalThis._prompt = editor.t("prompt.find_file");
4487 globalThis._missing = editor.t("nonexistent.key");
4488 "#,
4489 "test.js",
4490 )
4491 .unwrap();
4492
4493 backend
4494 .plugin_contexts
4495 .borrow()
4496 .get("test")
4497 .unwrap()
4498 .clone()
4499 .with(|ctx| {
4500 let global = ctx.globals();
4501 let greeting: String = global.get("_greeting").unwrap();
4502 assert_eq!(greeting, "Hello, World!");
4503
4504 let prompt: String = global.get("_prompt").unwrap();
4505 assert_eq!(prompt, "Find file: ");
4506
4507 let missing: String = global.get("_missing").unwrap();
4509 assert_eq!(missing, "nonexistent.key");
4510 });
4511 }
4512
4513 #[test]
4516 fn test_api_set_line_indicator() {
4517 let (mut backend, rx) = create_test_backend();
4518
4519 backend
4520 .execute_js(
4521 r#"
4522 const editor = getEditor();
4523 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4524 "#,
4525 "test.js",
4526 )
4527 .unwrap();
4528
4529 let cmd = rx.try_recv().unwrap();
4530 match cmd {
4531 PluginCommand::SetLineIndicator {
4532 buffer_id,
4533 line,
4534 namespace,
4535 symbol,
4536 color,
4537 priority,
4538 } => {
4539 assert_eq!(buffer_id.0, 1);
4540 assert_eq!(line, 5);
4541 assert_eq!(namespace, "test-ns");
4542 assert_eq!(symbol, "●");
4543 assert_eq!(color, (255, 0, 0));
4544 assert_eq!(priority, 10);
4545 }
4546 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4547 }
4548 }
4549
4550 #[test]
4551 fn test_api_clear_line_indicators() {
4552 let (mut backend, rx) = create_test_backend();
4553
4554 backend
4555 .execute_js(
4556 r#"
4557 const editor = getEditor();
4558 editor.clearLineIndicators(1, "test-ns");
4559 "#,
4560 "test.js",
4561 )
4562 .unwrap();
4563
4564 let cmd = rx.try_recv().unwrap();
4565 match cmd {
4566 PluginCommand::ClearLineIndicators {
4567 buffer_id,
4568 namespace,
4569 } => {
4570 assert_eq!(buffer_id.0, 1);
4571 assert_eq!(namespace, "test-ns");
4572 }
4573 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4574 }
4575 }
4576
4577 #[test]
4580 fn test_api_create_virtual_buffer_sends_command() {
4581 let (mut backend, rx) = create_test_backend();
4582
4583 backend
4584 .execute_js(
4585 r#"
4586 const editor = getEditor();
4587 editor.createVirtualBuffer({
4588 name: "*Test Buffer*",
4589 mode: "test-mode",
4590 readOnly: true,
4591 entries: [
4592 { text: "Line 1\n", properties: { type: "header" } },
4593 { text: "Line 2\n", properties: { type: "content" } }
4594 ],
4595 showLineNumbers: false,
4596 showCursors: true,
4597 editingDisabled: true
4598 });
4599 "#,
4600 "test.js",
4601 )
4602 .unwrap();
4603
4604 let cmd = rx.try_recv().unwrap();
4605 match cmd {
4606 PluginCommand::CreateVirtualBufferWithContent {
4607 name,
4608 mode,
4609 read_only,
4610 entries,
4611 show_line_numbers,
4612 show_cursors,
4613 editing_disabled,
4614 ..
4615 } => {
4616 assert_eq!(name, "*Test Buffer*");
4617 assert_eq!(mode, "test-mode");
4618 assert!(read_only);
4619 assert_eq!(entries.len(), 2);
4620 assert_eq!(entries[0].text, "Line 1\n");
4621 assert!(!show_line_numbers);
4622 assert!(show_cursors);
4623 assert!(editing_disabled);
4624 }
4625 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4626 }
4627 }
4628
4629 #[test]
4630 fn test_api_set_virtual_buffer_content() {
4631 let (mut backend, rx) = create_test_backend();
4632
4633 backend
4634 .execute_js(
4635 r#"
4636 const editor = getEditor();
4637 editor.setVirtualBufferContent(5, [
4638 { text: "New content\n", properties: { type: "updated" } }
4639 ]);
4640 "#,
4641 "test.js",
4642 )
4643 .unwrap();
4644
4645 let cmd = rx.try_recv().unwrap();
4646 match cmd {
4647 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4648 assert_eq!(buffer_id.0, 5);
4649 assert_eq!(entries.len(), 1);
4650 assert_eq!(entries[0].text, "New content\n");
4651 }
4652 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4653 }
4654 }
4655
4656 #[test]
4659 fn test_api_add_overlay() {
4660 let (mut backend, rx) = create_test_backend();
4661
4662 backend
4663 .execute_js(
4664 r#"
4665 const editor = getEditor();
4666 editor.addOverlay(1, "highlight", 10, 20, {
4667 fg: [255, 128, 0],
4668 bg: [50, 50, 50],
4669 bold: true,
4670 });
4671 "#,
4672 "test.js",
4673 )
4674 .unwrap();
4675
4676 let cmd = rx.try_recv().unwrap();
4677 match cmd {
4678 PluginCommand::AddOverlay {
4679 buffer_id,
4680 namespace,
4681 range,
4682 options,
4683 } => {
4684 use fresh_core::api::OverlayColorSpec;
4685 assert_eq!(buffer_id.0, 1);
4686 assert!(namespace.is_some());
4687 assert_eq!(namespace.unwrap().as_str(), "highlight");
4688 assert_eq!(range, 10..20);
4689 assert!(matches!(
4690 options.fg,
4691 Some(OverlayColorSpec::Rgb(255, 128, 0))
4692 ));
4693 assert!(matches!(
4694 options.bg,
4695 Some(OverlayColorSpec::Rgb(50, 50, 50))
4696 ));
4697 assert!(!options.underline);
4698 assert!(options.bold);
4699 assert!(!options.italic);
4700 assert!(!options.extend_to_line_end);
4701 }
4702 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4703 }
4704 }
4705
4706 #[test]
4707 fn test_api_add_overlay_with_theme_keys() {
4708 let (mut backend, rx) = create_test_backend();
4709
4710 backend
4711 .execute_js(
4712 r#"
4713 const editor = getEditor();
4714 // Test with theme keys for colors
4715 editor.addOverlay(1, "themed", 0, 10, {
4716 fg: "ui.status_bar_fg",
4717 bg: "editor.selection_bg",
4718 });
4719 "#,
4720 "test.js",
4721 )
4722 .unwrap();
4723
4724 let cmd = rx.try_recv().unwrap();
4725 match cmd {
4726 PluginCommand::AddOverlay {
4727 buffer_id,
4728 namespace,
4729 range,
4730 options,
4731 } => {
4732 use fresh_core::api::OverlayColorSpec;
4733 assert_eq!(buffer_id.0, 1);
4734 assert!(namespace.is_some());
4735 assert_eq!(namespace.unwrap().as_str(), "themed");
4736 assert_eq!(range, 0..10);
4737 assert!(matches!(
4738 &options.fg,
4739 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
4740 ));
4741 assert!(matches!(
4742 &options.bg,
4743 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
4744 ));
4745 assert!(!options.underline);
4746 assert!(!options.bold);
4747 assert!(!options.italic);
4748 assert!(!options.extend_to_line_end);
4749 }
4750 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4751 }
4752 }
4753
4754 #[test]
4755 fn test_api_clear_namespace() {
4756 let (mut backend, rx) = create_test_backend();
4757
4758 backend
4759 .execute_js(
4760 r#"
4761 const editor = getEditor();
4762 editor.clearNamespace(1, "highlight");
4763 "#,
4764 "test.js",
4765 )
4766 .unwrap();
4767
4768 let cmd = rx.try_recv().unwrap();
4769 match cmd {
4770 PluginCommand::ClearNamespace {
4771 buffer_id,
4772 namespace,
4773 } => {
4774 assert_eq!(buffer_id.0, 1);
4775 assert_eq!(namespace.as_str(), "highlight");
4776 }
4777 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4778 }
4779 }
4780
4781 #[test]
4784 fn test_api_get_theme_schema() {
4785 let (mut backend, _rx) = create_test_backend();
4786
4787 backend
4788 .execute_js(
4789 r#"
4790 const editor = getEditor();
4791 const schema = editor.getThemeSchema();
4792 globalThis._isObject = typeof schema === 'object' && schema !== null;
4793 "#,
4794 "test.js",
4795 )
4796 .unwrap();
4797
4798 backend
4799 .plugin_contexts
4800 .borrow()
4801 .get("test")
4802 .unwrap()
4803 .clone()
4804 .with(|ctx| {
4805 let global = ctx.globals();
4806 let is_object: bool = global.get("_isObject").unwrap();
4807 assert!(is_object);
4809 });
4810 }
4811
4812 #[test]
4813 fn test_api_get_builtin_themes() {
4814 let (mut backend, _rx) = create_test_backend();
4815
4816 backend
4817 .execute_js(
4818 r#"
4819 const editor = getEditor();
4820 const themes = editor.getBuiltinThemes();
4821 globalThis._isObject = typeof themes === 'object' && themes !== null;
4822 "#,
4823 "test.js",
4824 )
4825 .unwrap();
4826
4827 backend
4828 .plugin_contexts
4829 .borrow()
4830 .get("test")
4831 .unwrap()
4832 .clone()
4833 .with(|ctx| {
4834 let global = ctx.globals();
4835 let is_object: bool = global.get("_isObject").unwrap();
4836 assert!(is_object);
4838 });
4839 }
4840
4841 #[test]
4842 fn test_api_apply_theme() {
4843 let (mut backend, rx) = create_test_backend();
4844
4845 backend
4846 .execute_js(
4847 r#"
4848 const editor = getEditor();
4849 editor.applyTheme("dark");
4850 "#,
4851 "test.js",
4852 )
4853 .unwrap();
4854
4855 let cmd = rx.try_recv().unwrap();
4856 match cmd {
4857 PluginCommand::ApplyTheme { theme_name } => {
4858 assert_eq!(theme_name, "dark");
4859 }
4860 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4861 }
4862 }
4863
4864 #[test]
4867 fn test_api_close_buffer() {
4868 let (mut backend, rx) = create_test_backend();
4869
4870 backend
4871 .execute_js(
4872 r#"
4873 const editor = getEditor();
4874 editor.closeBuffer(3);
4875 "#,
4876 "test.js",
4877 )
4878 .unwrap();
4879
4880 let cmd = rx.try_recv().unwrap();
4881 match cmd {
4882 PluginCommand::CloseBuffer { buffer_id } => {
4883 assert_eq!(buffer_id.0, 3);
4884 }
4885 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4886 }
4887 }
4888
4889 #[test]
4890 fn test_api_focus_split() {
4891 let (mut backend, rx) = create_test_backend();
4892
4893 backend
4894 .execute_js(
4895 r#"
4896 const editor = getEditor();
4897 editor.focusSplit(2);
4898 "#,
4899 "test.js",
4900 )
4901 .unwrap();
4902
4903 let cmd = rx.try_recv().unwrap();
4904 match cmd {
4905 PluginCommand::FocusSplit { split_id } => {
4906 assert_eq!(split_id.0, 2);
4907 }
4908 _ => panic!("Expected FocusSplit, got {:?}", cmd),
4909 }
4910 }
4911
4912 #[test]
4913 fn test_api_list_buffers() {
4914 let (tx, _rx) = mpsc::channel();
4915 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4916
4917 {
4919 let mut state = state_snapshot.write().unwrap();
4920 state.buffers.insert(
4921 BufferId(0),
4922 BufferInfo {
4923 id: BufferId(0),
4924 path: Some(PathBuf::from("/test1.txt")),
4925 modified: false,
4926 length: 100,
4927 },
4928 );
4929 state.buffers.insert(
4930 BufferId(1),
4931 BufferInfo {
4932 id: BufferId(1),
4933 path: Some(PathBuf::from("/test2.txt")),
4934 modified: true,
4935 length: 200,
4936 },
4937 );
4938 }
4939
4940 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4941 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4942
4943 backend
4944 .execute_js(
4945 r#"
4946 const editor = getEditor();
4947 const buffers = editor.listBuffers();
4948 globalThis._isArray = Array.isArray(buffers);
4949 globalThis._length = buffers.length;
4950 "#,
4951 "test.js",
4952 )
4953 .unwrap();
4954
4955 backend
4956 .plugin_contexts
4957 .borrow()
4958 .get("test")
4959 .unwrap()
4960 .clone()
4961 .with(|ctx| {
4962 let global = ctx.globals();
4963 let is_array: bool = global.get("_isArray").unwrap();
4964 let length: u32 = global.get("_length").unwrap();
4965 assert!(is_array);
4966 assert_eq!(length, 2);
4967 });
4968 }
4969
4970 #[test]
4973 fn test_api_start_prompt() {
4974 let (mut backend, rx) = create_test_backend();
4975
4976 backend
4977 .execute_js(
4978 r#"
4979 const editor = getEditor();
4980 editor.startPrompt("Enter value:", "test-prompt");
4981 "#,
4982 "test.js",
4983 )
4984 .unwrap();
4985
4986 let cmd = rx.try_recv().unwrap();
4987 match cmd {
4988 PluginCommand::StartPrompt { label, prompt_type } => {
4989 assert_eq!(label, "Enter value:");
4990 assert_eq!(prompt_type, "test-prompt");
4991 }
4992 _ => panic!("Expected StartPrompt, got {:?}", cmd),
4993 }
4994 }
4995
4996 #[test]
4997 fn test_api_start_prompt_with_initial() {
4998 let (mut backend, rx) = create_test_backend();
4999
5000 backend
5001 .execute_js(
5002 r#"
5003 const editor = getEditor();
5004 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
5005 "#,
5006 "test.js",
5007 )
5008 .unwrap();
5009
5010 let cmd = rx.try_recv().unwrap();
5011 match cmd {
5012 PluginCommand::StartPromptWithInitial {
5013 label,
5014 prompt_type,
5015 initial_value,
5016 } => {
5017 assert_eq!(label, "Enter value:");
5018 assert_eq!(prompt_type, "test-prompt");
5019 assert_eq!(initial_value, "default");
5020 }
5021 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
5022 }
5023 }
5024
5025 #[test]
5026 fn test_api_set_prompt_suggestions() {
5027 let (mut backend, rx) = create_test_backend();
5028
5029 backend
5030 .execute_js(
5031 r#"
5032 const editor = getEditor();
5033 editor.setPromptSuggestions([
5034 { text: "Option 1", value: "opt1" },
5035 { text: "Option 2", value: "opt2" }
5036 ]);
5037 "#,
5038 "test.js",
5039 )
5040 .unwrap();
5041
5042 let cmd = rx.try_recv().unwrap();
5043 match cmd {
5044 PluginCommand::SetPromptSuggestions { suggestions } => {
5045 assert_eq!(suggestions.len(), 2);
5046 assert_eq!(suggestions[0].text, "Option 1");
5047 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
5048 }
5049 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
5050 }
5051 }
5052
5053 #[test]
5056 fn test_api_get_active_buffer_id() {
5057 let (tx, _rx) = mpsc::channel();
5058 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5059
5060 {
5061 let mut state = state_snapshot.write().unwrap();
5062 state.active_buffer_id = BufferId(42);
5063 }
5064
5065 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5066 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5067
5068 backend
5069 .execute_js(
5070 r#"
5071 const editor = getEditor();
5072 globalThis._activeId = editor.getActiveBufferId();
5073 "#,
5074 "test.js",
5075 )
5076 .unwrap();
5077
5078 backend
5079 .plugin_contexts
5080 .borrow()
5081 .get("test")
5082 .unwrap()
5083 .clone()
5084 .with(|ctx| {
5085 let global = ctx.globals();
5086 let result: u32 = global.get("_activeId").unwrap();
5087 assert_eq!(result, 42);
5088 });
5089 }
5090
5091 #[test]
5092 fn test_api_get_active_split_id() {
5093 let (tx, _rx) = mpsc::channel();
5094 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5095
5096 {
5097 let mut state = state_snapshot.write().unwrap();
5098 state.active_split_id = 7;
5099 }
5100
5101 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5102 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5103
5104 backend
5105 .execute_js(
5106 r#"
5107 const editor = getEditor();
5108 globalThis._splitId = editor.getActiveSplitId();
5109 "#,
5110 "test.js",
5111 )
5112 .unwrap();
5113
5114 backend
5115 .plugin_contexts
5116 .borrow()
5117 .get("test")
5118 .unwrap()
5119 .clone()
5120 .with(|ctx| {
5121 let global = ctx.globals();
5122 let result: u32 = global.get("_splitId").unwrap();
5123 assert_eq!(result, 7);
5124 });
5125 }
5126
5127 #[test]
5130 fn test_api_file_exists() {
5131 let (mut backend, _rx) = create_test_backend();
5132
5133 backend
5134 .execute_js(
5135 r#"
5136 const editor = getEditor();
5137 // Test with a path that definitely exists
5138 globalThis._exists = editor.fileExists("/");
5139 "#,
5140 "test.js",
5141 )
5142 .unwrap();
5143
5144 backend
5145 .plugin_contexts
5146 .borrow()
5147 .get("test")
5148 .unwrap()
5149 .clone()
5150 .with(|ctx| {
5151 let global = ctx.globals();
5152 let result: bool = global.get("_exists").unwrap();
5153 assert!(result);
5154 });
5155 }
5156
5157 #[test]
5158 fn test_api_get_cwd() {
5159 let (mut backend, _rx) = create_test_backend();
5160
5161 backend
5162 .execute_js(
5163 r#"
5164 const editor = getEditor();
5165 globalThis._cwd = editor.getCwd();
5166 "#,
5167 "test.js",
5168 )
5169 .unwrap();
5170
5171 backend
5172 .plugin_contexts
5173 .borrow()
5174 .get("test")
5175 .unwrap()
5176 .clone()
5177 .with(|ctx| {
5178 let global = ctx.globals();
5179 let result: String = global.get("_cwd").unwrap();
5180 assert!(!result.is_empty());
5182 });
5183 }
5184
5185 #[test]
5186 fn test_api_get_env() {
5187 let (mut backend, _rx) = create_test_backend();
5188
5189 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
5191
5192 backend
5193 .execute_js(
5194 r#"
5195 const editor = getEditor();
5196 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
5197 "#,
5198 "test.js",
5199 )
5200 .unwrap();
5201
5202 backend
5203 .plugin_contexts
5204 .borrow()
5205 .get("test")
5206 .unwrap()
5207 .clone()
5208 .with(|ctx| {
5209 let global = ctx.globals();
5210 let result: Option<String> = global.get("_envVal").unwrap();
5211 assert_eq!(result, Some("test_value".to_string()));
5212 });
5213
5214 std::env::remove_var("TEST_PLUGIN_VAR");
5215 }
5216
5217 #[test]
5218 fn test_api_get_config() {
5219 let (mut backend, _rx) = create_test_backend();
5220
5221 backend
5222 .execute_js(
5223 r#"
5224 const editor = getEditor();
5225 const config = editor.getConfig();
5226 globalThis._isObject = typeof config === 'object';
5227 "#,
5228 "test.js",
5229 )
5230 .unwrap();
5231
5232 backend
5233 .plugin_contexts
5234 .borrow()
5235 .get("test")
5236 .unwrap()
5237 .clone()
5238 .with(|ctx| {
5239 let global = ctx.globals();
5240 let is_object: bool = global.get("_isObject").unwrap();
5241 assert!(is_object);
5243 });
5244 }
5245
5246 #[test]
5247 fn test_api_get_themes_dir() {
5248 let (mut backend, _rx) = create_test_backend();
5249
5250 backend
5251 .execute_js(
5252 r#"
5253 const editor = getEditor();
5254 globalThis._themesDir = editor.getThemesDir();
5255 "#,
5256 "test.js",
5257 )
5258 .unwrap();
5259
5260 backend
5261 .plugin_contexts
5262 .borrow()
5263 .get("test")
5264 .unwrap()
5265 .clone()
5266 .with(|ctx| {
5267 let global = ctx.globals();
5268 let result: String = global.get("_themesDir").unwrap();
5269 assert!(!result.is_empty());
5271 });
5272 }
5273
5274 #[test]
5277 fn test_api_read_dir() {
5278 let (mut backend, _rx) = create_test_backend();
5279
5280 backend
5281 .execute_js(
5282 r#"
5283 const editor = getEditor();
5284 const entries = editor.readDir("/tmp");
5285 globalThis._isArray = Array.isArray(entries);
5286 globalThis._length = entries.length;
5287 "#,
5288 "test.js",
5289 )
5290 .unwrap();
5291
5292 backend
5293 .plugin_contexts
5294 .borrow()
5295 .get("test")
5296 .unwrap()
5297 .clone()
5298 .with(|ctx| {
5299 let global = ctx.globals();
5300 let is_array: bool = global.get("_isArray").unwrap();
5301 let length: u32 = global.get("_length").unwrap();
5302 assert!(is_array);
5304 let _ = length;
5306 });
5307 }
5308
5309 #[test]
5312 fn test_api_execute_action() {
5313 let (mut backend, rx) = create_test_backend();
5314
5315 backend
5316 .execute_js(
5317 r#"
5318 const editor = getEditor();
5319 editor.executeAction("move_cursor_up");
5320 "#,
5321 "test.js",
5322 )
5323 .unwrap();
5324
5325 let cmd = rx.try_recv().unwrap();
5326 match cmd {
5327 PluginCommand::ExecuteAction { action_name } => {
5328 assert_eq!(action_name, "move_cursor_up");
5329 }
5330 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5331 }
5332 }
5333
5334 #[test]
5337 fn test_api_debug() {
5338 let (mut backend, _rx) = create_test_backend();
5339
5340 backend
5342 .execute_js(
5343 r#"
5344 const editor = getEditor();
5345 editor.debug("Test debug message");
5346 editor.debug("Another message with special chars: <>&\"'");
5347 "#,
5348 "test.js",
5349 )
5350 .unwrap();
5351 }
5353
5354 #[test]
5357 fn test_typescript_preamble_generated() {
5358 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5360 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5361 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5362 println!(
5363 "Generated {} bytes of TypeScript preamble",
5364 JSEDITORAPI_TS_PREAMBLE.len()
5365 );
5366 }
5367
5368 #[test]
5369 fn test_typescript_editor_api_generated() {
5370 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5372 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5373 println!(
5374 "Generated {} bytes of EditorAPI interface",
5375 JSEDITORAPI_TS_EDITOR_API.len()
5376 );
5377 }
5378
5379 #[test]
5380 fn test_js_methods_list() {
5381 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5383 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5384 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5386 if i < 20 {
5387 println!(" - {}", method);
5388 }
5389 }
5390 if JSEDITORAPI_JS_METHODS.len() > 20 {
5391 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5392 }
5393 }
5394
5395 #[test]
5398 fn test_api_load_plugin_sends_command() {
5399 let (mut backend, rx) = create_test_backend();
5400
5401 backend
5403 .execute_js(
5404 r#"
5405 const editor = getEditor();
5406 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5407 "#,
5408 "test.js",
5409 )
5410 .unwrap();
5411
5412 let cmd = rx.try_recv().unwrap();
5414 match cmd {
5415 PluginCommand::LoadPlugin { path, callback_id } => {
5416 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5417 assert!(callback_id.0 > 0); }
5419 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5420 }
5421 }
5422
5423 #[test]
5424 fn test_api_unload_plugin_sends_command() {
5425 let (mut backend, rx) = create_test_backend();
5426
5427 backend
5429 .execute_js(
5430 r#"
5431 const editor = getEditor();
5432 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5433 "#,
5434 "test.js",
5435 )
5436 .unwrap();
5437
5438 let cmd = rx.try_recv().unwrap();
5440 match cmd {
5441 PluginCommand::UnloadPlugin { name, callback_id } => {
5442 assert_eq!(name, "my-plugin");
5443 assert!(callback_id.0 > 0); }
5445 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5446 }
5447 }
5448
5449 #[test]
5450 fn test_api_reload_plugin_sends_command() {
5451 let (mut backend, rx) = create_test_backend();
5452
5453 backend
5455 .execute_js(
5456 r#"
5457 const editor = getEditor();
5458 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5459 "#,
5460 "test.js",
5461 )
5462 .unwrap();
5463
5464 let cmd = rx.try_recv().unwrap();
5466 match cmd {
5467 PluginCommand::ReloadPlugin { name, callback_id } => {
5468 assert_eq!(name, "my-plugin");
5469 assert!(callback_id.0 > 0); }
5471 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5472 }
5473 }
5474
5475 #[test]
5476 fn test_api_load_plugin_resolves_callback() {
5477 let (mut backend, rx) = create_test_backend();
5478
5479 backend
5481 .execute_js(
5482 r#"
5483 const editor = getEditor();
5484 globalThis._loadResult = null;
5485 editor.loadPlugin("/path/to/plugin.ts").then(result => {
5486 globalThis._loadResult = result;
5487 });
5488 "#,
5489 "test.js",
5490 )
5491 .unwrap();
5492
5493 let callback_id = match rx.try_recv().unwrap() {
5495 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5496 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5497 };
5498
5499 backend.resolve_callback(callback_id, "true");
5501
5502 backend
5504 .plugin_contexts
5505 .borrow()
5506 .get("test")
5507 .unwrap()
5508 .clone()
5509 .with(|ctx| {
5510 run_pending_jobs_checked(&ctx, "test async loadPlugin");
5511 });
5512
5513 backend
5515 .plugin_contexts
5516 .borrow()
5517 .get("test")
5518 .unwrap()
5519 .clone()
5520 .with(|ctx| {
5521 let global = ctx.globals();
5522 let result: bool = global.get("_loadResult").unwrap();
5523 assert!(result);
5524 });
5525 }
5526
5527 #[test]
5528 fn test_api_unload_plugin_rejects_on_error() {
5529 let (mut backend, rx) = create_test_backend();
5530
5531 backend
5533 .execute_js(
5534 r#"
5535 const editor = getEditor();
5536 globalThis._unloadError = null;
5537 editor.unloadPlugin("nonexistent-plugin").catch(err => {
5538 globalThis._unloadError = err.message || String(err);
5539 });
5540 "#,
5541 "test.js",
5542 )
5543 .unwrap();
5544
5545 let callback_id = match rx.try_recv().unwrap() {
5547 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5548 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5549 };
5550
5551 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5553
5554 backend
5556 .plugin_contexts
5557 .borrow()
5558 .get("test")
5559 .unwrap()
5560 .clone()
5561 .with(|ctx| {
5562 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5563 });
5564
5565 backend
5567 .plugin_contexts
5568 .borrow()
5569 .get("test")
5570 .unwrap()
5571 .clone()
5572 .with(|ctx| {
5573 let global = ctx.globals();
5574 let error: String = global.get("_unloadError").unwrap();
5575 assert!(error.contains("nonexistent-plugin"));
5576 });
5577 }
5578}