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 pub fn find_buffer_by_path(&self, path: String) -> u32 {
835 let path_buf = std::path::PathBuf::from(&path);
836 if let Ok(s) = self.state_snapshot.read() {
837 for (id, info) in &s.buffers {
838 if let Some(buf_path) = &info.path {
839 if buf_path == &path_buf {
840 return id.0 as u32;
841 }
842 }
843 }
844 }
845 0
846 }
847
848 #[plugin_api(ts_return = "BufferSavedDiff | null")]
850 pub fn get_buffer_saved_diff<'js>(
851 &self,
852 ctx: rquickjs::Ctx<'js>,
853 buffer_id: u32,
854 ) -> rquickjs::Result<Value<'js>> {
855 let diff = if let Ok(s) = self.state_snapshot.read() {
856 s.buffer_saved_diffs
857 .get(&BufferId(buffer_id as usize))
858 .cloned()
859 } else {
860 None
861 };
862 rquickjs_serde::to_value(ctx, &diff)
863 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
864 }
865
866 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
870 self.command_sender
871 .send(PluginCommand::InsertText {
872 buffer_id: BufferId(buffer_id as usize),
873 position: position as usize,
874 text,
875 })
876 .is_ok()
877 }
878
879 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
881 self.command_sender
882 .send(PluginCommand::DeleteRange {
883 buffer_id: BufferId(buffer_id as usize),
884 range: (start as usize)..(end as usize),
885 })
886 .is_ok()
887 }
888
889 pub fn insert_at_cursor(&self, text: String) -> bool {
891 self.command_sender
892 .send(PluginCommand::InsertAtCursor { text })
893 .is_ok()
894 }
895
896 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
900 self.command_sender
901 .send(PluginCommand::OpenFileAtLocation {
902 path: PathBuf::from(path),
903 line: line.map(|l| l as usize),
904 column: column.map(|c| c as usize),
905 })
906 .is_ok()
907 }
908
909 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
911 self.command_sender
912 .send(PluginCommand::OpenFileInSplit {
913 split_id: split_id as usize,
914 path: PathBuf::from(path),
915 line: Some(line as usize),
916 column: Some(column as usize),
917 })
918 .is_ok()
919 }
920
921 pub fn show_buffer(&self, buffer_id: u32) -> bool {
923 self.command_sender
924 .send(PluginCommand::ShowBuffer {
925 buffer_id: BufferId(buffer_id as usize),
926 })
927 .is_ok()
928 }
929
930 pub fn close_buffer(&self, buffer_id: u32) -> bool {
932 self.command_sender
933 .send(PluginCommand::CloseBuffer {
934 buffer_id: BufferId(buffer_id as usize),
935 })
936 .is_ok()
937 }
938
939 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
943 self.event_handlers
944 .borrow_mut()
945 .entry(event_name)
946 .or_default()
947 .push(PluginHandler {
948 plugin_name: self.plugin_name.clone(),
949 handler_name,
950 });
951 }
952
953 pub fn off(&self, event_name: String, handler_name: String) {
955 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
956 list.retain(|h| h.handler_name != handler_name);
957 }
958 }
959
960 pub fn get_env(&self, name: String) -> Option<String> {
964 std::env::var(&name).ok()
965 }
966
967 pub fn get_cwd(&self) -> String {
969 self.state_snapshot
970 .read()
971 .map(|s| s.working_dir.to_string_lossy().to_string())
972 .unwrap_or_else(|_| ".".to_string())
973 }
974
975 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
980 let mut result_parts: Vec<String> = Vec::new();
981 let mut has_leading_slash = false;
982
983 for part in &parts.0 {
984 let normalized = part.replace('\\', "/");
986
987 let is_absolute = normalized.starts_with('/')
989 || (normalized.len() >= 2
990 && normalized
991 .chars()
992 .next()
993 .map(|c| c.is_ascii_alphabetic())
994 .unwrap_or(false)
995 && normalized.chars().nth(1) == Some(':'));
996
997 if is_absolute {
998 result_parts.clear();
1000 has_leading_slash = normalized.starts_with('/');
1001 }
1002
1003 for segment in normalized.split('/') {
1005 if !segment.is_empty() && segment != "." {
1006 if segment == ".." {
1007 result_parts.pop();
1008 } else {
1009 result_parts.push(segment.to_string());
1010 }
1011 }
1012 }
1013 }
1014
1015 let joined = result_parts.join("/");
1017
1018 if has_leading_slash && !joined.is_empty() {
1020 format!("/{}", joined)
1021 } else {
1022 joined
1023 }
1024 }
1025
1026 pub fn path_dirname(&self, path: String) -> String {
1028 Path::new(&path)
1029 .parent()
1030 .map(|p| p.to_string_lossy().to_string())
1031 .unwrap_or_default()
1032 }
1033
1034 pub fn path_basename(&self, path: String) -> String {
1036 Path::new(&path)
1037 .file_name()
1038 .map(|s| s.to_string_lossy().to_string())
1039 .unwrap_or_default()
1040 }
1041
1042 pub fn path_extname(&self, path: String) -> String {
1044 Path::new(&path)
1045 .extension()
1046 .map(|s| format!(".{}", s.to_string_lossy()))
1047 .unwrap_or_default()
1048 }
1049
1050 pub fn path_is_absolute(&self, path: String) -> bool {
1052 Path::new(&path).is_absolute()
1053 }
1054
1055 pub fn file_exists(&self, path: String) -> bool {
1059 Path::new(&path).exists()
1060 }
1061
1062 pub fn read_file(&self, path: String) -> Option<String> {
1064 std::fs::read_to_string(&path).ok()
1065 }
1066
1067 pub fn write_file(&self, path: String, content: String) -> bool {
1069 std::fs::write(&path, content).is_ok()
1070 }
1071
1072 #[plugin_api(ts_return = "DirEntry[]")]
1074 pub fn read_dir<'js>(
1075 &self,
1076 ctx: rquickjs::Ctx<'js>,
1077 path: String,
1078 ) -> rquickjs::Result<Value<'js>> {
1079 use fresh_core::api::DirEntry;
1080
1081 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1082 Ok(entries) => entries
1083 .filter_map(|e| e.ok())
1084 .map(|entry| {
1085 let file_type = entry.file_type().ok();
1086 DirEntry {
1087 name: entry.file_name().to_string_lossy().to_string(),
1088 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1089 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1090 }
1091 })
1092 .collect(),
1093 Err(e) => {
1094 tracing::warn!("readDir failed for '{}': {}", path, e);
1095 Vec::new()
1096 }
1097 };
1098
1099 rquickjs_serde::to_value(ctx, &entries)
1100 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1101 }
1102
1103 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1107 let config: serde_json::Value = self
1108 .state_snapshot
1109 .read()
1110 .map(|s| s.config.clone())
1111 .unwrap_or_else(|_| serde_json::json!({}));
1112
1113 rquickjs_serde::to_value(ctx, &config)
1114 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1115 }
1116
1117 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1119 let config: serde_json::Value = self
1120 .state_snapshot
1121 .read()
1122 .map(|s| s.user_config.clone())
1123 .unwrap_or_else(|_| serde_json::json!({}));
1124
1125 rquickjs_serde::to_value(ctx, &config)
1126 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1127 }
1128
1129 pub fn reload_config(&self) {
1131 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1132 }
1133
1134 pub fn reload_themes(&self) {
1137 let _ = self.command_sender.send(PluginCommand::ReloadThemes);
1138 }
1139
1140 pub fn register_grammar(
1143 &self,
1144 language: String,
1145 grammar_path: String,
1146 extensions: Vec<String>,
1147 ) -> bool {
1148 self.command_sender
1149 .send(PluginCommand::RegisterGrammar {
1150 language,
1151 grammar_path,
1152 extensions,
1153 })
1154 .is_ok()
1155 }
1156
1157 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1159 self.command_sender
1160 .send(PluginCommand::RegisterLanguageConfig { language, config })
1161 .is_ok()
1162 }
1163
1164 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1166 self.command_sender
1167 .send(PluginCommand::RegisterLspServer { language, config })
1168 .is_ok()
1169 }
1170
1171 pub fn reload_grammars(&self) {
1174 let _ = self.command_sender.send(PluginCommand::ReloadGrammars);
1175 }
1176
1177 pub fn get_config_dir(&self) -> String {
1179 self.services.config_dir().to_string_lossy().to_string()
1180 }
1181
1182 pub fn get_themes_dir(&self) -> String {
1184 self.services
1185 .config_dir()
1186 .join("themes")
1187 .to_string_lossy()
1188 .to_string()
1189 }
1190
1191 pub fn apply_theme(&self, theme_name: String) -> bool {
1193 self.command_sender
1194 .send(PluginCommand::ApplyTheme { theme_name })
1195 .is_ok()
1196 }
1197
1198 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1200 let schema = self.services.get_theme_schema();
1201 rquickjs_serde::to_value(ctx, &schema)
1202 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1203 }
1204
1205 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1207 let themes = self.services.get_builtin_themes();
1208 rquickjs_serde::to_value(ctx, &themes)
1209 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1210 }
1211
1212 #[qjs(rename = "_deleteThemeSync")]
1214 pub fn delete_theme_sync(&self, name: String) -> bool {
1215 let themes_dir = self.services.config_dir().join("themes");
1217 let theme_path = themes_dir.join(format!("{}.json", name));
1218
1219 if let Ok(canonical) = theme_path.canonicalize() {
1221 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1222 if canonical.starts_with(&themes_canonical) {
1223 return std::fs::remove_file(&canonical).is_ok();
1224 }
1225 }
1226 }
1227 false
1228 }
1229
1230 pub fn delete_theme(&self, name: String) -> bool {
1232 self.delete_theme_sync(name)
1233 }
1234
1235 pub fn file_stat<'js>(
1239 &self,
1240 ctx: rquickjs::Ctx<'js>,
1241 path: String,
1242 ) -> rquickjs::Result<Value<'js>> {
1243 let metadata = std::fs::metadata(&path).ok();
1244 let stat = metadata.map(|m| {
1245 serde_json::json!({
1246 "isFile": m.is_file(),
1247 "isDir": m.is_dir(),
1248 "size": m.len(),
1249 "readonly": m.permissions().readonly(),
1250 })
1251 });
1252 rquickjs_serde::to_value(ctx, &stat)
1253 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1254 }
1255
1256 pub fn is_process_running(&self, _process_id: u64) -> bool {
1260 false
1263 }
1264
1265 pub fn kill_process(&self, process_id: u64) -> bool {
1267 self.command_sender
1268 .send(PluginCommand::KillBackgroundProcess { process_id })
1269 .is_ok()
1270 }
1271
1272 pub fn plugin_translate<'js>(
1276 &self,
1277 _ctx: rquickjs::Ctx<'js>,
1278 plugin_name: String,
1279 key: String,
1280 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1281 ) -> String {
1282 let args_map: HashMap<String, String> = args
1283 .0
1284 .map(|obj| {
1285 let mut map = HashMap::new();
1286 for (k, v) in obj.props::<String, String>().flatten() {
1287 map.insert(k, v);
1288 }
1289 map
1290 })
1291 .unwrap_or_default();
1292
1293 self.services.translate(&plugin_name, &key, &args_map)
1294 }
1295
1296 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1303 #[qjs(rename = "_createCompositeBufferStart")]
1304 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1305 let id = {
1306 let mut id_ref = self.next_request_id.borrow_mut();
1307 let id = *id_ref;
1308 *id_ref += 1;
1309 self.callback_contexts
1311 .borrow_mut()
1312 .insert(id, self.plugin_name.clone());
1313 id
1314 };
1315
1316 let _ = self
1317 .command_sender
1318 .send(PluginCommand::CreateCompositeBuffer {
1319 name: opts.name,
1320 mode: opts.mode,
1321 layout: opts.layout,
1322 sources: opts.sources,
1323 hunks: opts.hunks,
1324 request_id: Some(id),
1325 });
1326
1327 id
1328 }
1329
1330 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1334 self.command_sender
1335 .send(PluginCommand::UpdateCompositeAlignment {
1336 buffer_id: BufferId(buffer_id as usize),
1337 hunks,
1338 })
1339 .is_ok()
1340 }
1341
1342 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1344 self.command_sender
1345 .send(PluginCommand::CloseCompositeBuffer {
1346 buffer_id: BufferId(buffer_id as usize),
1347 })
1348 .is_ok()
1349 }
1350
1351 #[plugin_api(
1355 async_promise,
1356 js_name = "getHighlights",
1357 ts_return = "TsHighlightSpan[]"
1358 )]
1359 #[qjs(rename = "_getHighlightsStart")]
1360 pub fn get_highlights_start<'js>(
1361 &self,
1362 _ctx: rquickjs::Ctx<'js>,
1363 buffer_id: u32,
1364 start: u32,
1365 end: u32,
1366 ) -> rquickjs::Result<u64> {
1367 let id = {
1368 let mut id_ref = self.next_request_id.borrow_mut();
1369 let id = *id_ref;
1370 *id_ref += 1;
1371 self.callback_contexts
1373 .borrow_mut()
1374 .insert(id, self.plugin_name.clone());
1375 id
1376 };
1377
1378 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1379 buffer_id: BufferId(buffer_id as usize),
1380 range: (start as usize)..(end as usize),
1381 request_id: id,
1382 });
1383
1384 Ok(id)
1385 }
1386
1387 pub fn add_overlay<'js>(
1405 &self,
1406 _ctx: rquickjs::Ctx<'js>,
1407 buffer_id: u32,
1408 namespace: String,
1409 start: u32,
1410 end: u32,
1411 options: rquickjs::Object<'js>,
1412 ) -> rquickjs::Result<bool> {
1413 use fresh_core::api::OverlayColorSpec;
1414
1415 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1417 if let Ok(theme_key) = obj.get::<_, String>(key) {
1419 if !theme_key.is_empty() {
1420 return Some(OverlayColorSpec::ThemeKey(theme_key));
1421 }
1422 }
1423 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1425 if arr.len() >= 3 {
1426 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1427 }
1428 }
1429 None
1430 }
1431
1432 let fg = parse_color_spec("fg", &options);
1433 let bg = parse_color_spec("bg", &options);
1434 let underline: bool = options.get("underline").unwrap_or(false);
1435 let bold: bool = options.get("bold").unwrap_or(false);
1436 let italic: bool = options.get("italic").unwrap_or(false);
1437 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1438
1439 let options = OverlayOptions {
1440 fg,
1441 bg,
1442 underline,
1443 bold,
1444 italic,
1445 extend_to_line_end,
1446 };
1447
1448 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1449 buffer_id: BufferId(buffer_id as usize),
1450 namespace: Some(OverlayNamespace::from_string(namespace)),
1451 range: (start as usize)..(end as usize),
1452 options,
1453 });
1454
1455 Ok(true)
1456 }
1457
1458 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1460 self.command_sender
1461 .send(PluginCommand::ClearNamespace {
1462 buffer_id: BufferId(buffer_id as usize),
1463 namespace: OverlayNamespace::from_string(namespace),
1464 })
1465 .is_ok()
1466 }
1467
1468 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1470 self.command_sender
1471 .send(PluginCommand::ClearAllOverlays {
1472 buffer_id: BufferId(buffer_id as usize),
1473 })
1474 .is_ok()
1475 }
1476
1477 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1479 self.command_sender
1480 .send(PluginCommand::ClearOverlaysInRange {
1481 buffer_id: BufferId(buffer_id as usize),
1482 start: start as usize,
1483 end: end as usize,
1484 })
1485 .is_ok()
1486 }
1487
1488 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1490 use fresh_core::overlay::OverlayHandle;
1491 self.command_sender
1492 .send(PluginCommand::RemoveOverlay {
1493 buffer_id: BufferId(buffer_id as usize),
1494 handle: OverlayHandle(handle),
1495 })
1496 .is_ok()
1497 }
1498
1499 #[allow(clippy::too_many_arguments)]
1509 pub fn submit_view_transform<'js>(
1510 &self,
1511 _ctx: rquickjs::Ctx<'js>,
1512 buffer_id: u32,
1513 split_id: Option<u32>,
1514 start: u32,
1515 end: u32,
1516 tokens: Vec<rquickjs::Object<'js>>,
1517 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1518 ) -> rquickjs::Result<bool> {
1519 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1520
1521 let tokens: Vec<ViewTokenWire> = tokens
1522 .into_iter()
1523 .enumerate()
1524 .map(|(idx, obj)| {
1525 parse_view_token(&obj, idx)
1527 })
1528 .collect::<rquickjs::Result<Vec<_>>>()?;
1529
1530 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
1532 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
1533 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
1534 Some(LayoutHints {
1535 compose_width,
1536 column_guides,
1537 })
1538 } else {
1539 None
1540 };
1541
1542 let payload = ViewTransformPayload {
1543 range: (start as usize)..(end as usize),
1544 tokens,
1545 layout_hints: parsed_layout_hints,
1546 };
1547
1548 Ok(self
1549 .command_sender
1550 .send(PluginCommand::SubmitViewTransform {
1551 buffer_id: BufferId(buffer_id as usize),
1552 split_id: split_id.map(|id| SplitId(id as usize)),
1553 payload,
1554 })
1555 .is_ok())
1556 }
1557
1558 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1560 self.command_sender
1561 .send(PluginCommand::ClearViewTransform {
1562 buffer_id: BufferId(buffer_id as usize),
1563 split_id: split_id.map(|id| SplitId(id as usize)),
1564 })
1565 .is_ok()
1566 }
1567
1568 pub fn set_file_explorer_decorations<'js>(
1572 &self,
1573 _ctx: rquickjs::Ctx<'js>,
1574 namespace: String,
1575 decorations: Vec<rquickjs::Object<'js>>,
1576 ) -> rquickjs::Result<bool> {
1577 use fresh_core::file_explorer::FileExplorerDecoration;
1578
1579 let decorations: Vec<FileExplorerDecoration> = decorations
1580 .into_iter()
1581 .map(|obj| {
1582 let path: String = obj.get("path")?;
1583 let symbol: String = obj.get("symbol")?;
1584 let color: Vec<u8> = obj.get("color")?;
1585 let priority: i32 = obj.get("priority").unwrap_or(0);
1586
1587 if color.len() < 3 {
1588 return Err(rquickjs::Error::FromJs {
1589 from: "array",
1590 to: "color",
1591 message: Some(format!(
1592 "color array must have at least 3 elements, got {}",
1593 color.len()
1594 )),
1595 });
1596 }
1597
1598 Ok(FileExplorerDecoration {
1599 path: std::path::PathBuf::from(path),
1600 symbol,
1601 color: [color[0], color[1], color[2]],
1602 priority,
1603 })
1604 })
1605 .collect::<rquickjs::Result<Vec<_>>>()?;
1606
1607 Ok(self
1608 .command_sender
1609 .send(PluginCommand::SetFileExplorerDecorations {
1610 namespace,
1611 decorations,
1612 })
1613 .is_ok())
1614 }
1615
1616 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1618 self.command_sender
1619 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1620 .is_ok()
1621 }
1622
1623 #[allow(clippy::too_many_arguments)]
1627 pub fn add_virtual_text(
1628 &self,
1629 buffer_id: u32,
1630 virtual_text_id: String,
1631 position: u32,
1632 text: String,
1633 r: u8,
1634 g: u8,
1635 b: u8,
1636 before: bool,
1637 use_bg: bool,
1638 ) -> bool {
1639 self.command_sender
1640 .send(PluginCommand::AddVirtualText {
1641 buffer_id: BufferId(buffer_id as usize),
1642 virtual_text_id,
1643 position: position as usize,
1644 text,
1645 color: (r, g, b),
1646 use_bg,
1647 before,
1648 })
1649 .is_ok()
1650 }
1651
1652 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1654 self.command_sender
1655 .send(PluginCommand::RemoveVirtualText {
1656 buffer_id: BufferId(buffer_id as usize),
1657 virtual_text_id,
1658 })
1659 .is_ok()
1660 }
1661
1662 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1664 self.command_sender
1665 .send(PluginCommand::RemoveVirtualTextsByPrefix {
1666 buffer_id: BufferId(buffer_id as usize),
1667 prefix,
1668 })
1669 .is_ok()
1670 }
1671
1672 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1674 self.command_sender
1675 .send(PluginCommand::ClearVirtualTexts {
1676 buffer_id: BufferId(buffer_id as usize),
1677 })
1678 .is_ok()
1679 }
1680
1681 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1683 self.command_sender
1684 .send(PluginCommand::ClearVirtualTextNamespace {
1685 buffer_id: BufferId(buffer_id as usize),
1686 namespace,
1687 })
1688 .is_ok()
1689 }
1690
1691 #[allow(clippy::too_many_arguments)]
1693 pub fn add_virtual_line(
1694 &self,
1695 buffer_id: u32,
1696 position: u32,
1697 text: String,
1698 fg_r: u8,
1699 fg_g: u8,
1700 fg_b: u8,
1701 bg_r: u8,
1702 bg_g: u8,
1703 bg_b: u8,
1704 above: bool,
1705 namespace: String,
1706 priority: i32,
1707 ) -> bool {
1708 self.command_sender
1709 .send(PluginCommand::AddVirtualLine {
1710 buffer_id: BufferId(buffer_id as usize),
1711 position: position as usize,
1712 text,
1713 fg_color: (fg_r, fg_g, fg_b),
1714 bg_color: Some((bg_r, bg_g, bg_b)),
1715 above,
1716 namespace,
1717 priority,
1718 })
1719 .is_ok()
1720 }
1721
1722 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1727 #[qjs(rename = "_promptStart")]
1728 pub fn prompt_start(
1729 &self,
1730 _ctx: rquickjs::Ctx<'_>,
1731 label: String,
1732 initial_value: String,
1733 ) -> u64 {
1734 let id = {
1735 let mut id_ref = self.next_request_id.borrow_mut();
1736 let id = *id_ref;
1737 *id_ref += 1;
1738 self.callback_contexts
1740 .borrow_mut()
1741 .insert(id, self.plugin_name.clone());
1742 id
1743 };
1744
1745 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1746 label,
1747 initial_value,
1748 callback_id: JsCallbackId::new(id),
1749 });
1750
1751 id
1752 }
1753
1754 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1756 self.command_sender
1757 .send(PluginCommand::StartPrompt { label, prompt_type })
1758 .is_ok()
1759 }
1760
1761 pub fn start_prompt_with_initial(
1763 &self,
1764 label: String,
1765 prompt_type: String,
1766 initial_value: String,
1767 ) -> bool {
1768 self.command_sender
1769 .send(PluginCommand::StartPromptWithInitial {
1770 label,
1771 prompt_type,
1772 initial_value,
1773 })
1774 .is_ok()
1775 }
1776
1777 pub fn set_prompt_suggestions(
1781 &self,
1782 suggestions: Vec<fresh_core::command::Suggestion>,
1783 ) -> bool {
1784 self.command_sender
1785 .send(PluginCommand::SetPromptSuggestions { suggestions })
1786 .is_ok()
1787 }
1788
1789 pub fn define_mode(
1793 &self,
1794 name: String,
1795 parent: Option<String>,
1796 bindings_arr: Vec<Vec<String>>,
1797 read_only: rquickjs::function::Opt<bool>,
1798 ) -> bool {
1799 let bindings: Vec<(String, String)> = bindings_arr
1800 .into_iter()
1801 .filter_map(|arr| {
1802 if arr.len() >= 2 {
1803 Some((arr[0].clone(), arr[1].clone()))
1804 } else {
1805 None
1806 }
1807 })
1808 .collect();
1809
1810 {
1813 let mut registered = self.registered_actions.borrow_mut();
1814 for (_, cmd_name) in &bindings {
1815 registered.insert(
1816 cmd_name.clone(),
1817 PluginHandler {
1818 plugin_name: self.plugin_name.clone(),
1819 handler_name: cmd_name.clone(),
1820 },
1821 );
1822 }
1823 }
1824
1825 self.command_sender
1826 .send(PluginCommand::DefineMode {
1827 name,
1828 parent,
1829 bindings,
1830 read_only: read_only.0.unwrap_or(false),
1831 })
1832 .is_ok()
1833 }
1834
1835 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1837 self.command_sender
1838 .send(PluginCommand::SetEditorMode { mode })
1839 .is_ok()
1840 }
1841
1842 pub fn get_editor_mode(&self) -> Option<String> {
1844 self.state_snapshot
1845 .read()
1846 .ok()
1847 .and_then(|s| s.editor_mode.clone())
1848 }
1849
1850 pub fn close_split(&self, split_id: u32) -> bool {
1854 self.command_sender
1855 .send(PluginCommand::CloseSplit {
1856 split_id: SplitId(split_id as usize),
1857 })
1858 .is_ok()
1859 }
1860
1861 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1863 self.command_sender
1864 .send(PluginCommand::SetSplitBuffer {
1865 split_id: SplitId(split_id as usize),
1866 buffer_id: BufferId(buffer_id as usize),
1867 })
1868 .is_ok()
1869 }
1870
1871 pub fn focus_split(&self, split_id: u32) -> bool {
1873 self.command_sender
1874 .send(PluginCommand::FocusSplit {
1875 split_id: SplitId(split_id as usize),
1876 })
1877 .is_ok()
1878 }
1879
1880 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1882 self.command_sender
1883 .send(PluginCommand::SetSplitScroll {
1884 split_id: SplitId(split_id as usize),
1885 top_byte: top_byte as usize,
1886 })
1887 .is_ok()
1888 }
1889
1890 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1892 self.command_sender
1893 .send(PluginCommand::SetSplitRatio {
1894 split_id: SplitId(split_id as usize),
1895 ratio,
1896 })
1897 .is_ok()
1898 }
1899
1900 pub fn distribute_splits_evenly(&self) -> bool {
1902 self.command_sender
1904 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1905 .is_ok()
1906 }
1907
1908 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1910 self.command_sender
1911 .send(PluginCommand::SetBufferCursor {
1912 buffer_id: BufferId(buffer_id as usize),
1913 position: position as usize,
1914 })
1915 .is_ok()
1916 }
1917
1918 #[allow(clippy::too_many_arguments)]
1922 pub fn set_line_indicator(
1923 &self,
1924 buffer_id: u32,
1925 line: u32,
1926 namespace: String,
1927 symbol: String,
1928 r: u8,
1929 g: u8,
1930 b: u8,
1931 priority: i32,
1932 ) -> bool {
1933 self.command_sender
1934 .send(PluginCommand::SetLineIndicator {
1935 buffer_id: BufferId(buffer_id as usize),
1936 line: line as usize,
1937 namespace,
1938 symbol,
1939 color: (r, g, b),
1940 priority,
1941 })
1942 .is_ok()
1943 }
1944
1945 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
1947 self.command_sender
1948 .send(PluginCommand::ClearLineIndicators {
1949 buffer_id: BufferId(buffer_id as usize),
1950 namespace,
1951 })
1952 .is_ok()
1953 }
1954
1955 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
1957 self.command_sender
1958 .send(PluginCommand::SetLineNumbers {
1959 buffer_id: BufferId(buffer_id as usize),
1960 enabled,
1961 })
1962 .is_ok()
1963 }
1964
1965 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
1967 self.command_sender
1968 .send(PluginCommand::SetLineWrap {
1969 buffer_id: BufferId(buffer_id as usize),
1970 split_id: split_id.map(|s| SplitId(s as usize)),
1971 enabled,
1972 })
1973 .is_ok()
1974 }
1975
1976 pub fn create_scroll_sync_group(
1980 &self,
1981 group_id: u32,
1982 left_split: u32,
1983 right_split: u32,
1984 ) -> bool {
1985 self.command_sender
1986 .send(PluginCommand::CreateScrollSyncGroup {
1987 group_id,
1988 left_split: SplitId(left_split as usize),
1989 right_split: SplitId(right_split as usize),
1990 })
1991 .is_ok()
1992 }
1993
1994 pub fn set_scroll_sync_anchors<'js>(
1996 &self,
1997 _ctx: rquickjs::Ctx<'js>,
1998 group_id: u32,
1999 anchors: Vec<Vec<u32>>,
2000 ) -> bool {
2001 let anchors: Vec<(usize, usize)> = anchors
2002 .into_iter()
2003 .filter_map(|pair| {
2004 if pair.len() >= 2 {
2005 Some((pair[0] as usize, pair[1] as usize))
2006 } else {
2007 None
2008 }
2009 })
2010 .collect();
2011 self.command_sender
2012 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2013 .is_ok()
2014 }
2015
2016 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2018 self.command_sender
2019 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2020 .is_ok()
2021 }
2022
2023 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2029 self.command_sender
2030 .send(PluginCommand::ExecuteActions { actions })
2031 .is_ok()
2032 }
2033
2034 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2038 self.command_sender
2039 .send(PluginCommand::ShowActionPopup {
2040 popup_id: opts.id,
2041 title: opts.title,
2042 message: opts.message,
2043 actions: opts.actions,
2044 })
2045 .is_ok()
2046 }
2047
2048 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2050 self.command_sender
2051 .send(PluginCommand::DisableLspForLanguage { language })
2052 .is_ok()
2053 }
2054
2055 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2058 self.command_sender
2059 .send(PluginCommand::SetLspRootUri { language, uri })
2060 .is_ok()
2061 }
2062
2063 #[plugin_api(ts_return = "JsDiagnostic[]")]
2065 pub fn get_all_diagnostics<'js>(
2066 &self,
2067 ctx: rquickjs::Ctx<'js>,
2068 ) -> rquickjs::Result<Value<'js>> {
2069 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2070
2071 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2072 let mut result: Vec<JsDiagnostic> = Vec::new();
2074 for (uri, diags) in &s.diagnostics {
2075 for diag in diags {
2076 result.push(JsDiagnostic {
2077 uri: uri.clone(),
2078 message: diag.message.clone(),
2079 severity: diag.severity.map(|s| match s {
2080 lsp_types::DiagnosticSeverity::ERROR => 1,
2081 lsp_types::DiagnosticSeverity::WARNING => 2,
2082 lsp_types::DiagnosticSeverity::INFORMATION => 3,
2083 lsp_types::DiagnosticSeverity::HINT => 4,
2084 _ => 0,
2085 }),
2086 range: JsRange {
2087 start: JsPosition {
2088 line: diag.range.start.line,
2089 character: diag.range.start.character,
2090 },
2091 end: JsPosition {
2092 line: diag.range.end.line,
2093 character: diag.range.end.character,
2094 },
2095 },
2096 source: diag.source.clone(),
2097 });
2098 }
2099 }
2100 result
2101 } else {
2102 Vec::new()
2103 };
2104 rquickjs_serde::to_value(ctx, &diagnostics)
2105 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2106 }
2107
2108 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2110 self.event_handlers
2111 .borrow()
2112 .get(&event_name)
2113 .cloned()
2114 .unwrap_or_default()
2115 .into_iter()
2116 .map(|h| h.handler_name)
2117 .collect()
2118 }
2119
2120 #[plugin_api(
2124 async_promise,
2125 js_name = "createVirtualBuffer",
2126 ts_return = "VirtualBufferResult"
2127 )]
2128 #[qjs(rename = "_createVirtualBufferStart")]
2129 pub fn create_virtual_buffer_start(
2130 &self,
2131 _ctx: rquickjs::Ctx<'_>,
2132 opts: fresh_core::api::CreateVirtualBufferOptions,
2133 ) -> rquickjs::Result<u64> {
2134 let id = {
2135 let mut id_ref = self.next_request_id.borrow_mut();
2136 let id = *id_ref;
2137 *id_ref += 1;
2138 self.callback_contexts
2140 .borrow_mut()
2141 .insert(id, self.plugin_name.clone());
2142 id
2143 };
2144
2145 let entries: Vec<TextPropertyEntry> = opts
2147 .entries
2148 .unwrap_or_default()
2149 .into_iter()
2150 .map(|e| TextPropertyEntry {
2151 text: e.text,
2152 properties: e.properties.unwrap_or_default(),
2153 })
2154 .collect();
2155
2156 tracing::debug!(
2157 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2158 id
2159 );
2160 let _ = self
2161 .command_sender
2162 .send(PluginCommand::CreateVirtualBufferWithContent {
2163 name: opts.name,
2164 mode: opts.mode.unwrap_or_default(),
2165 read_only: opts.read_only.unwrap_or(false),
2166 entries,
2167 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2168 show_cursors: opts.show_cursors.unwrap_or(true),
2169 editing_disabled: opts.editing_disabled.unwrap_or(false),
2170 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2171 request_id: Some(id),
2172 });
2173 Ok(id)
2174 }
2175
2176 #[plugin_api(
2178 async_promise,
2179 js_name = "createVirtualBufferInSplit",
2180 ts_return = "VirtualBufferResult"
2181 )]
2182 #[qjs(rename = "_createVirtualBufferInSplitStart")]
2183 pub fn create_virtual_buffer_in_split_start(
2184 &self,
2185 _ctx: rquickjs::Ctx<'_>,
2186 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2187 ) -> rquickjs::Result<u64> {
2188 let id = {
2189 let mut id_ref = self.next_request_id.borrow_mut();
2190 let id = *id_ref;
2191 *id_ref += 1;
2192 self.callback_contexts
2194 .borrow_mut()
2195 .insert(id, self.plugin_name.clone());
2196 id
2197 };
2198
2199 let entries: Vec<TextPropertyEntry> = opts
2201 .entries
2202 .unwrap_or_default()
2203 .into_iter()
2204 .map(|e| TextPropertyEntry {
2205 text: e.text,
2206 properties: e.properties.unwrap_or_default(),
2207 })
2208 .collect();
2209
2210 let _ = self
2211 .command_sender
2212 .send(PluginCommand::CreateVirtualBufferInSplit {
2213 name: opts.name,
2214 mode: opts.mode.unwrap_or_default(),
2215 read_only: opts.read_only.unwrap_or(false),
2216 entries,
2217 ratio: opts.ratio.unwrap_or(0.5),
2218 direction: opts.direction,
2219 panel_id: opts.panel_id,
2220 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2221 show_cursors: opts.show_cursors.unwrap_or(true),
2222 editing_disabled: opts.editing_disabled.unwrap_or(false),
2223 line_wrap: opts.line_wrap,
2224 request_id: Some(id),
2225 });
2226 Ok(id)
2227 }
2228
2229 #[plugin_api(
2231 async_promise,
2232 js_name = "createVirtualBufferInExistingSplit",
2233 ts_return = "VirtualBufferResult"
2234 )]
2235 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2236 pub fn create_virtual_buffer_in_existing_split_start(
2237 &self,
2238 _ctx: rquickjs::Ctx<'_>,
2239 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2240 ) -> rquickjs::Result<u64> {
2241 let id = {
2242 let mut id_ref = self.next_request_id.borrow_mut();
2243 let id = *id_ref;
2244 *id_ref += 1;
2245 self.callback_contexts
2247 .borrow_mut()
2248 .insert(id, self.plugin_name.clone());
2249 id
2250 };
2251
2252 let entries: Vec<TextPropertyEntry> = opts
2254 .entries
2255 .unwrap_or_default()
2256 .into_iter()
2257 .map(|e| TextPropertyEntry {
2258 text: e.text,
2259 properties: e.properties.unwrap_or_default(),
2260 })
2261 .collect();
2262
2263 let _ = self
2264 .command_sender
2265 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2266 name: opts.name,
2267 mode: opts.mode.unwrap_or_default(),
2268 read_only: opts.read_only.unwrap_or(false),
2269 entries,
2270 split_id: SplitId(opts.split_id),
2271 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2272 show_cursors: opts.show_cursors.unwrap_or(true),
2273 editing_disabled: opts.editing_disabled.unwrap_or(false),
2274 line_wrap: opts.line_wrap,
2275 request_id: Some(id),
2276 });
2277 Ok(id)
2278 }
2279
2280 pub fn set_virtual_buffer_content<'js>(
2284 &self,
2285 ctx: rquickjs::Ctx<'js>,
2286 buffer_id: u32,
2287 entries_arr: Vec<rquickjs::Object<'js>>,
2288 ) -> rquickjs::Result<bool> {
2289 let entries: Vec<TextPropertyEntry> = entries_arr
2290 .iter()
2291 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2292 .collect();
2293 Ok(self
2294 .command_sender
2295 .send(PluginCommand::SetVirtualBufferContent {
2296 buffer_id: BufferId(buffer_id as usize),
2297 entries,
2298 })
2299 .is_ok())
2300 }
2301
2302 pub fn get_text_properties_at_cursor(
2304 &self,
2305 buffer_id: u32,
2306 ) -> fresh_core::api::TextPropertiesAtCursor {
2307 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2308 }
2309
2310 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2314 #[qjs(rename = "_spawnProcessStart")]
2315 pub fn spawn_process_start(
2316 &self,
2317 _ctx: rquickjs::Ctx<'_>,
2318 command: String,
2319 args: Vec<String>,
2320 cwd: rquickjs::function::Opt<String>,
2321 ) -> u64 {
2322 let id = {
2323 let mut id_ref = self.next_request_id.borrow_mut();
2324 let id = *id_ref;
2325 *id_ref += 1;
2326 self.callback_contexts
2328 .borrow_mut()
2329 .insert(id, self.plugin_name.clone());
2330 id
2331 };
2332 let effective_cwd = cwd.0.or_else(|| {
2334 self.state_snapshot
2335 .read()
2336 .ok()
2337 .map(|s| s.working_dir.to_string_lossy().to_string())
2338 });
2339 tracing::info!(
2340 "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2341 command,
2342 args,
2343 effective_cwd,
2344 id
2345 );
2346 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2347 callback_id: JsCallbackId::new(id),
2348 command,
2349 args,
2350 cwd: effective_cwd,
2351 });
2352 id
2353 }
2354
2355 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2357 #[qjs(rename = "_spawnProcessWaitStart")]
2358 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2359 let id = {
2360 let mut id_ref = self.next_request_id.borrow_mut();
2361 let id = *id_ref;
2362 *id_ref += 1;
2363 self.callback_contexts
2365 .borrow_mut()
2366 .insert(id, self.plugin_name.clone());
2367 id
2368 };
2369 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2370 process_id,
2371 callback_id: JsCallbackId::new(id),
2372 });
2373 id
2374 }
2375
2376 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2378 #[qjs(rename = "_getBufferTextStart")]
2379 pub fn get_buffer_text_start(
2380 &self,
2381 _ctx: rquickjs::Ctx<'_>,
2382 buffer_id: u32,
2383 start: u32,
2384 end: u32,
2385 ) -> u64 {
2386 let id = {
2387 let mut id_ref = self.next_request_id.borrow_mut();
2388 let id = *id_ref;
2389 *id_ref += 1;
2390 self.callback_contexts
2392 .borrow_mut()
2393 .insert(id, self.plugin_name.clone());
2394 id
2395 };
2396 let _ = self.command_sender.send(PluginCommand::GetBufferText {
2397 buffer_id: BufferId(buffer_id as usize),
2398 start: start as usize,
2399 end: end as usize,
2400 request_id: id,
2401 });
2402 id
2403 }
2404
2405 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2407 #[qjs(rename = "_delayStart")]
2408 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2409 let id = {
2410 let mut id_ref = self.next_request_id.borrow_mut();
2411 let id = *id_ref;
2412 *id_ref += 1;
2413 self.callback_contexts
2415 .borrow_mut()
2416 .insert(id, self.plugin_name.clone());
2417 id
2418 };
2419 let _ = self.command_sender.send(PluginCommand::Delay {
2420 callback_id: JsCallbackId::new(id),
2421 duration_ms,
2422 });
2423 id
2424 }
2425
2426 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2428 #[qjs(rename = "_sendLspRequestStart")]
2429 pub fn send_lsp_request_start<'js>(
2430 &self,
2431 ctx: rquickjs::Ctx<'js>,
2432 language: String,
2433 method: String,
2434 params: Option<rquickjs::Object<'js>>,
2435 ) -> rquickjs::Result<u64> {
2436 let id = {
2437 let mut id_ref = self.next_request_id.borrow_mut();
2438 let id = *id_ref;
2439 *id_ref += 1;
2440 self.callback_contexts
2442 .borrow_mut()
2443 .insert(id, self.plugin_name.clone());
2444 id
2445 };
2446 let params_json: Option<serde_json::Value> = params.map(|obj| {
2448 let val = obj.into_value();
2449 js_to_json(&ctx, val)
2450 });
2451 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2452 request_id: id,
2453 language,
2454 method,
2455 params: params_json,
2456 });
2457 Ok(id)
2458 }
2459
2460 #[plugin_api(
2462 async_thenable,
2463 js_name = "spawnBackgroundProcess",
2464 ts_return = "BackgroundProcessResult"
2465 )]
2466 #[qjs(rename = "_spawnBackgroundProcessStart")]
2467 pub fn spawn_background_process_start(
2468 &self,
2469 _ctx: rquickjs::Ctx<'_>,
2470 command: String,
2471 args: Vec<String>,
2472 cwd: rquickjs::function::Opt<String>,
2473 ) -> u64 {
2474 let id = {
2475 let mut id_ref = self.next_request_id.borrow_mut();
2476 let id = *id_ref;
2477 *id_ref += 1;
2478 self.callback_contexts
2480 .borrow_mut()
2481 .insert(id, self.plugin_name.clone());
2482 id
2483 };
2484 let process_id = id;
2486 let _ = self
2487 .command_sender
2488 .send(PluginCommand::SpawnBackgroundProcess {
2489 process_id,
2490 command,
2491 args,
2492 cwd: cwd.0,
2493 callback_id: JsCallbackId::new(id),
2494 });
2495 id
2496 }
2497
2498 pub fn kill_background_process(&self, process_id: u64) -> bool {
2500 self.command_sender
2501 .send(PluginCommand::KillBackgroundProcess { process_id })
2502 .is_ok()
2503 }
2504
2505 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2509 self.command_sender
2510 .send(PluginCommand::RefreshLines {
2511 buffer_id: BufferId(buffer_id as usize),
2512 })
2513 .is_ok()
2514 }
2515
2516 pub fn get_current_locale(&self) -> String {
2518 self.services.current_locale()
2519 }
2520
2521 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2525 #[qjs(rename = "_loadPluginStart")]
2526 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2527 let id = {
2528 let mut id_ref = self.next_request_id.borrow_mut();
2529 let id = *id_ref;
2530 *id_ref += 1;
2531 self.callback_contexts
2532 .borrow_mut()
2533 .insert(id, self.plugin_name.clone());
2534 id
2535 };
2536 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2537 path: std::path::PathBuf::from(path),
2538 callback_id: JsCallbackId::new(id),
2539 });
2540 id
2541 }
2542
2543 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2545 #[qjs(rename = "_unloadPluginStart")]
2546 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2547 let id = {
2548 let mut id_ref = self.next_request_id.borrow_mut();
2549 let id = *id_ref;
2550 *id_ref += 1;
2551 self.callback_contexts
2552 .borrow_mut()
2553 .insert(id, self.plugin_name.clone());
2554 id
2555 };
2556 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2557 name,
2558 callback_id: JsCallbackId::new(id),
2559 });
2560 id
2561 }
2562
2563 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
2565 #[qjs(rename = "_reloadPluginStart")]
2566 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2567 let id = {
2568 let mut id_ref = self.next_request_id.borrow_mut();
2569 let id = *id_ref;
2570 *id_ref += 1;
2571 self.callback_contexts
2572 .borrow_mut()
2573 .insert(id, self.plugin_name.clone());
2574 id
2575 };
2576 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
2577 name,
2578 callback_id: JsCallbackId::new(id),
2579 });
2580 id
2581 }
2582
2583 #[plugin_api(
2586 async_promise,
2587 js_name = "listPlugins",
2588 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
2589 )]
2590 #[qjs(rename = "_listPluginsStart")]
2591 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2592 let id = {
2593 let mut id_ref = self.next_request_id.borrow_mut();
2594 let id = *id_ref;
2595 *id_ref += 1;
2596 self.callback_contexts
2597 .borrow_mut()
2598 .insert(id, self.plugin_name.clone());
2599 id
2600 };
2601 let _ = self.command_sender.send(PluginCommand::ListPlugins {
2602 callback_id: JsCallbackId::new(id),
2603 });
2604 id
2605 }
2606}
2607
2608fn parse_view_token(
2615 obj: &rquickjs::Object<'_>,
2616 idx: usize,
2617) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
2618 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2619
2620 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
2622 from: "object",
2623 to: "ViewTokenWire",
2624 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
2625 })?;
2626
2627 let source_offset: Option<usize> = obj
2629 .get("sourceOffset")
2630 .ok()
2631 .or_else(|| obj.get("source_offset").ok());
2632
2633 let kind = if kind_value.is_string() {
2635 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2638 from: "value",
2639 to: "string",
2640 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
2641 })?;
2642
2643 match kind_str.to_lowercase().as_str() {
2644 "text" => {
2645 let text: String = obj.get("text").unwrap_or_default();
2646 ViewTokenWireKind::Text(text)
2647 }
2648 "newline" => ViewTokenWireKind::Newline,
2649 "space" => ViewTokenWireKind::Space,
2650 "break" => ViewTokenWireKind::Break,
2651 _ => {
2652 tracing::warn!(
2654 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
2655 idx, kind_str
2656 );
2657 return Err(rquickjs::Error::FromJs {
2658 from: "string",
2659 to: "ViewTokenWireKind",
2660 message: Some(format!(
2661 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
2662 idx, kind_str
2663 )),
2664 });
2665 }
2666 }
2667 } else if kind_value.is_object() {
2668 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2670 from: "value",
2671 to: "object",
2672 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
2673 })?;
2674
2675 if let Ok(text) = kind_obj.get::<_, String>("Text") {
2676 ViewTokenWireKind::Text(text)
2677 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
2678 ViewTokenWireKind::BinaryByte(byte)
2679 } else {
2680 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
2682 tracing::warn!(
2683 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
2684 idx,
2685 keys
2686 );
2687 return Err(rquickjs::Error::FromJs {
2688 from: "object",
2689 to: "ViewTokenWireKind",
2690 message: Some(format!(
2691 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
2692 idx, keys
2693 )),
2694 });
2695 }
2696 } else {
2697 tracing::warn!(
2698 "token[{}]: 'kind' field must be a string or object, got: {:?}",
2699 idx,
2700 kind_value.type_of()
2701 );
2702 return Err(rquickjs::Error::FromJs {
2703 from: "value",
2704 to: "ViewTokenWireKind",
2705 message: Some(format!(
2706 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
2707 idx
2708 )),
2709 });
2710 };
2711
2712 let style = parse_view_token_style(obj, idx)?;
2714
2715 Ok(ViewTokenWire {
2716 source_offset,
2717 kind,
2718 style,
2719 })
2720}
2721
2722fn parse_view_token_style(
2724 obj: &rquickjs::Object<'_>,
2725 idx: usize,
2726) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
2727 use fresh_core::api::ViewTokenStyle;
2728
2729 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
2730 let Some(s) = style_obj else {
2731 return Ok(None);
2732 };
2733
2734 let fg: Option<Vec<u8>> = s.get("fg").ok();
2735 let bg: Option<Vec<u8>> = s.get("bg").ok();
2736
2737 let fg_color = if let Some(ref c) = fg {
2739 if c.len() < 3 {
2740 tracing::warn!(
2741 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
2742 idx,
2743 c.len()
2744 );
2745 None
2746 } else {
2747 Some((c[0], c[1], c[2]))
2748 }
2749 } else {
2750 None
2751 };
2752
2753 let bg_color = if let Some(ref c) = bg {
2754 if c.len() < 3 {
2755 tracing::warn!(
2756 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
2757 idx,
2758 c.len()
2759 );
2760 None
2761 } else {
2762 Some((c[0], c[1], c[2]))
2763 }
2764 } else {
2765 None
2766 };
2767
2768 Ok(Some(ViewTokenStyle {
2769 fg: fg_color,
2770 bg: bg_color,
2771 bold: s.get("bold").unwrap_or(false),
2772 italic: s.get("italic").unwrap_or(false),
2773 }))
2774}
2775
2776pub struct QuickJsBackend {
2778 runtime: Runtime,
2779 main_context: Context,
2781 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2783 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2785 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2787 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2789 command_sender: mpsc::Sender<PluginCommand>,
2791 #[allow(dead_code)]
2793 pending_responses: PendingResponses,
2794 next_request_id: Rc<RefCell<u64>>,
2796 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2798 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2800}
2801
2802impl QuickJsBackend {
2803 pub fn new() -> Result<Self> {
2805 let (tx, _rx) = mpsc::channel();
2806 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2807 let services = Arc::new(fresh_core::services::NoopServiceBridge);
2808 Self::with_state(state_snapshot, tx, services)
2809 }
2810
2811 pub fn with_state(
2813 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2814 command_sender: mpsc::Sender<PluginCommand>,
2815 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2816 ) -> Result<Self> {
2817 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2818 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2819 }
2820
2821 pub fn with_state_and_responses(
2823 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2824 command_sender: mpsc::Sender<PluginCommand>,
2825 pending_responses: PendingResponses,
2826 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2827 ) -> Result<Self> {
2828 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2829
2830 let runtime =
2831 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2832
2833 runtime.set_host_promise_rejection_tracker(Some(Box::new(
2835 |_ctx, _promise, reason, is_handled| {
2836 if !is_handled {
2837 let error_msg = if let Some(exc) = reason.as_exception() {
2839 format!(
2840 "{}: {}",
2841 exc.message().unwrap_or_default(),
2842 exc.stack().unwrap_or_default()
2843 )
2844 } else {
2845 format!("{:?}", reason)
2846 };
2847
2848 tracing::error!("Unhandled Promise rejection: {}", error_msg);
2849
2850 if should_panic_on_js_errors() {
2851 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2854 set_fatal_js_error(full_msg);
2855 }
2856 }
2857 },
2858 )));
2859
2860 let main_context = Context::full(&runtime)
2861 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2862
2863 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2864 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2865 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2866 let next_request_id = Rc::new(RefCell::new(1u64));
2867 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2868
2869 let backend = Self {
2870 runtime,
2871 main_context,
2872 plugin_contexts,
2873 event_handlers,
2874 registered_actions,
2875 state_snapshot,
2876 command_sender,
2877 pending_responses,
2878 next_request_id,
2879 callback_contexts,
2880 services,
2881 };
2882
2883 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2885
2886 tracing::debug!("QuickJsBackend::new: runtime created successfully");
2887 Ok(backend)
2888 }
2889
2890 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2892 let state_snapshot = Arc::clone(&self.state_snapshot);
2893 let command_sender = self.command_sender.clone();
2894 let event_handlers = Rc::clone(&self.event_handlers);
2895 let registered_actions = Rc::clone(&self.registered_actions);
2896 let next_request_id = Rc::clone(&self.next_request_id);
2897
2898 context.with(|ctx| {
2899 let globals = ctx.globals();
2900
2901 globals.set("__pluginName__", plugin_name)?;
2903
2904 let js_api = JsEditorApi {
2907 state_snapshot: Arc::clone(&state_snapshot),
2908 command_sender: command_sender.clone(),
2909 registered_actions: Rc::clone(®istered_actions),
2910 event_handlers: Rc::clone(&event_handlers),
2911 next_request_id: Rc::clone(&next_request_id),
2912 callback_contexts: Rc::clone(&self.callback_contexts),
2913 services: self.services.clone(),
2914 plugin_name: plugin_name.to_string(),
2915 };
2916 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2917
2918 globals.set("editor", editor)?;
2920
2921 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2923
2924 let console = Object::new(ctx.clone())?;
2927 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2928 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2929 tracing::info!("console.log: {}", parts.join(" "));
2930 })?)?;
2931 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2932 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2933 tracing::warn!("console.warn: {}", parts.join(" "));
2934 })?)?;
2935 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2936 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2937 tracing::error!("console.error: {}", parts.join(" "));
2938 })?)?;
2939 globals.set("console", console)?;
2940
2941 ctx.eval::<(), _>(r#"
2943 // Pending promise callbacks: callbackId -> { resolve, reject }
2944 globalThis._pendingCallbacks = new Map();
2945
2946 // Resolve a pending callback (called from Rust)
2947 globalThis._resolveCallback = function(callbackId, result) {
2948 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2949 const cb = globalThis._pendingCallbacks.get(callbackId);
2950 if (cb) {
2951 console.log('[JS] _resolveCallback: found callback, calling resolve()');
2952 globalThis._pendingCallbacks.delete(callbackId);
2953 cb.resolve(result);
2954 console.log('[JS] _resolveCallback: resolve() called');
2955 } else {
2956 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2957 }
2958 };
2959
2960 // Reject a pending callback (called from Rust)
2961 globalThis._rejectCallback = function(callbackId, error) {
2962 const cb = globalThis._pendingCallbacks.get(callbackId);
2963 if (cb) {
2964 globalThis._pendingCallbacks.delete(callbackId);
2965 cb.reject(new Error(error));
2966 }
2967 };
2968
2969 // Generic async wrapper decorator
2970 // Wraps a function that returns a callbackId into a promise-returning function
2971 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2972 // NOTE: We pass the method name as a string and call via bracket notation
2973 // to preserve rquickjs's automatic Ctx injection for methods
2974 globalThis._wrapAsync = function(methodName, fnName) {
2975 const startFn = editor[methodName];
2976 if (typeof startFn !== 'function') {
2977 // Return a function that always throws - catches missing implementations
2978 return function(...args) {
2979 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2980 editor.debug(`[ASYNC ERROR] ${error.message}`);
2981 throw error;
2982 };
2983 }
2984 return function(...args) {
2985 // Call via bracket notation to preserve method binding and Ctx injection
2986 const callbackId = editor[methodName](...args);
2987 return new Promise((resolve, reject) => {
2988 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2989 // TODO: Implement setTimeout polyfill using editor.delay() or similar
2990 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2991 });
2992 };
2993 };
2994
2995 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2996 // The returned object has .result promise and is itself thenable
2997 globalThis._wrapAsyncThenable = function(methodName, fnName) {
2998 const startFn = editor[methodName];
2999 if (typeof startFn !== 'function') {
3000 // Return a function that always throws - catches missing implementations
3001 return function(...args) {
3002 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3003 editor.debug(`[ASYNC ERROR] ${error.message}`);
3004 throw error;
3005 };
3006 }
3007 return function(...args) {
3008 // Call via bracket notation to preserve method binding and Ctx injection
3009 const callbackId = editor[methodName](...args);
3010 const resultPromise = new Promise((resolve, reject) => {
3011 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3012 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3013 });
3014 return {
3015 get result() { return resultPromise; },
3016 then(onFulfilled, onRejected) {
3017 return resultPromise.then(onFulfilled, onRejected);
3018 },
3019 catch(onRejected) {
3020 return resultPromise.catch(onRejected);
3021 }
3022 };
3023 };
3024 };
3025
3026 // Apply wrappers to async functions on editor
3027 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3028 editor.delay = _wrapAsync("_delayStart", "delay");
3029 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3030 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3031 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3032 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3033 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3034 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3035 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3036 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3037 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3038 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3039 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3040 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3041 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3042
3043 // Wrapper for deleteTheme - wraps sync function in Promise
3044 editor.deleteTheme = function(name) {
3045 return new Promise(function(resolve, reject) {
3046 const success = editor._deleteThemeSync(name);
3047 if (success) {
3048 resolve();
3049 } else {
3050 reject(new Error("Failed to delete theme: " + name));
3051 }
3052 });
3053 };
3054 "#.as_bytes())?;
3055
3056 Ok::<_, rquickjs::Error>(())
3057 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3058
3059 Ok(())
3060 }
3061
3062 pub async fn load_module_with_source(
3064 &mut self,
3065 path: &str,
3066 _plugin_source: &str,
3067 ) -> Result<()> {
3068 let path_buf = PathBuf::from(path);
3069 let source = std::fs::read_to_string(&path_buf)
3070 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3071
3072 let filename = path_buf
3073 .file_name()
3074 .and_then(|s| s.to_str())
3075 .unwrap_or("plugin.ts");
3076
3077 if has_es_imports(&source) {
3079 match bundle_module(&path_buf) {
3081 Ok(bundled) => {
3082 self.execute_js(&bundled, path)?;
3083 }
3084 Err(e) => {
3085 tracing::warn!(
3086 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3087 path,
3088 e
3089 );
3090 return Ok(()); }
3092 }
3093 } else if has_es_module_syntax(&source) {
3094 let stripped = strip_imports_and_exports(&source);
3096 let js_code = if filename.ends_with(".ts") {
3097 transpile_typescript(&stripped, filename)?
3098 } else {
3099 stripped
3100 };
3101 self.execute_js(&js_code, path)?;
3102 } else {
3103 let js_code = if filename.ends_with(".ts") {
3105 transpile_typescript(&source, filename)?
3106 } else {
3107 source
3108 };
3109 self.execute_js(&js_code, path)?;
3110 }
3111
3112 Ok(())
3113 }
3114
3115 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3117 let plugin_name = Path::new(source_name)
3119 .file_stem()
3120 .and_then(|s| s.to_str())
3121 .unwrap_or("unknown");
3122
3123 tracing::debug!(
3124 "execute_js: starting for plugin '{}' from '{}'",
3125 plugin_name,
3126 source_name
3127 );
3128
3129 let context = {
3131 let mut contexts = self.plugin_contexts.borrow_mut();
3132 if let Some(ctx) = contexts.get(plugin_name) {
3133 ctx.clone()
3134 } else {
3135 let ctx = Context::full(&self.runtime).map_err(|e| {
3136 anyhow!(
3137 "Failed to create QuickJS context for plugin {}: {}",
3138 plugin_name,
3139 e
3140 )
3141 })?;
3142 self.setup_context_api(&ctx, plugin_name)?;
3143 contexts.insert(plugin_name.to_string(), ctx.clone());
3144 ctx
3145 }
3146 };
3147
3148 let wrapped_code = format!("(function() {{ {} }})();", code);
3152 let wrapped = wrapped_code.as_str();
3153
3154 context.with(|ctx| {
3155 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3156
3157 let mut eval_options = rquickjs::context::EvalOptions::default();
3159 eval_options.global = true;
3160 eval_options.filename = Some(source_name.to_string());
3161 let result = ctx
3162 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3163 .map_err(|e| format_js_error(&ctx, e, source_name));
3164
3165 tracing::debug!(
3166 "execute_js: plugin code execution finished for '{}', result: {:?}",
3167 plugin_name,
3168 result.is_ok()
3169 );
3170
3171 result
3172 })
3173 }
3174
3175 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3177 let _event_data_str = event_data.to_string();
3178 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3179
3180 self.services
3182 .set_js_execution_state(format!("hook '{}'", event_name));
3183
3184 let handlers = self.event_handlers.borrow().get(event_name).cloned();
3185
3186 if let Some(handler_pairs) = handlers {
3187 if handler_pairs.is_empty() {
3188 self.services.clear_js_execution_state();
3189 return Ok(true);
3190 }
3191
3192 let plugin_contexts = self.plugin_contexts.borrow();
3193 for handler in handler_pairs {
3194 let context_opt = plugin_contexts.get(&handler.plugin_name);
3195 if let Some(context) = context_opt {
3196 let handler_name = &handler.handler_name;
3197 let json_string = serde_json::to_string(event_data)?;
3203 let js_string_literal = serde_json::to_string(&json_string)?;
3204 let code = format!(
3205 r#"
3206 (function() {{
3207 try {{
3208 const data = JSON.parse({});
3209 if (typeof globalThis["{}"] === 'function') {{
3210 const result = globalThis["{}"](data);
3211 // If handler returns a Promise, catch rejections
3212 if (result && typeof result.then === 'function') {{
3213 result.catch(function(e) {{
3214 console.error('Handler {} async error:', e);
3215 // Re-throw to make it an unhandled rejection for the runtime to catch
3216 throw e;
3217 }});
3218 }}
3219 }}
3220 }} catch (e) {{
3221 console.error('Handler {} sync error:', e);
3222 throw e;
3223 }}
3224 }})();
3225 "#,
3226 js_string_literal, handler_name, handler_name, handler_name, handler_name
3227 );
3228
3229 context.with(|ctx| {
3230 if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3231 log_js_error(&ctx, e, &format!("handler {}", handler_name));
3232 }
3233 run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3235 });
3236 }
3237 }
3238 }
3239
3240 self.services.clear_js_execution_state();
3241 Ok(true)
3242 }
3243
3244 pub fn has_handlers(&self, event_name: &str) -> bool {
3246 self.event_handlers
3247 .borrow()
3248 .get(event_name)
3249 .map(|v| !v.is_empty())
3250 .unwrap_or(false)
3251 }
3252
3253 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3257 let pair = self.registered_actions.borrow().get(action_name).cloned();
3258 let (plugin_name, function_name) = match pair {
3259 Some(handler) => (handler.plugin_name, handler.handler_name),
3260 None => ("main".to_string(), action_name.to_string()),
3261 };
3262
3263 let plugin_contexts = self.plugin_contexts.borrow();
3264 let context = plugin_contexts
3265 .get(&plugin_name)
3266 .unwrap_or(&self.main_context);
3267
3268 self.services
3270 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3271
3272 tracing::info!(
3273 "start_action: BEGIN '{}' -> function '{}'",
3274 action_name,
3275 function_name
3276 );
3277
3278 let code = format!(
3280 r#"
3281 (function() {{
3282 console.log('[JS] start_action: calling {fn}');
3283 try {{
3284 if (typeof globalThis.{fn} === 'function') {{
3285 console.log('[JS] start_action: {fn} is a function, invoking...');
3286 globalThis.{fn}();
3287 console.log('[JS] start_action: {fn} invoked (may be async)');
3288 }} else {{
3289 console.error('[JS] Action {action} is not defined as a global function');
3290 }}
3291 }} catch (e) {{
3292 console.error('[JS] Action {action} error:', e);
3293 }}
3294 }})();
3295 "#,
3296 fn = function_name,
3297 action = action_name
3298 );
3299
3300 tracing::info!("start_action: evaluating JS code");
3301 context.with(|ctx| {
3302 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3303 log_js_error(&ctx, e, &format!("action {}", action_name));
3304 }
3305 tracing::info!("start_action: running pending microtasks");
3306 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3308 tracing::info!("start_action: executed {} pending jobs", count);
3309 });
3310
3311 tracing::info!("start_action: END '{}'", action_name);
3312
3313 self.services.clear_js_execution_state();
3315
3316 Ok(())
3317 }
3318
3319 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3321 let pair = self.registered_actions.borrow().get(action_name).cloned();
3323 let (plugin_name, function_name) = match pair {
3324 Some(handler) => (handler.plugin_name, handler.handler_name),
3325 None => ("main".to_string(), action_name.to_string()),
3326 };
3327
3328 let plugin_contexts = self.plugin_contexts.borrow();
3329 let context = plugin_contexts
3330 .get(&plugin_name)
3331 .unwrap_or(&self.main_context);
3332
3333 tracing::debug!(
3334 "execute_action: '{}' -> function '{}'",
3335 action_name,
3336 function_name
3337 );
3338
3339 let code = format!(
3342 r#"
3343 (async function() {{
3344 try {{
3345 if (typeof globalThis.{fn} === 'function') {{
3346 const result = globalThis.{fn}();
3347 // If it's a Promise, await it
3348 if (result && typeof result.then === 'function') {{
3349 await result;
3350 }}
3351 }} else {{
3352 console.error('Action {action} is not defined as a global function');
3353 }}
3354 }} catch (e) {{
3355 console.error('Action {action} error:', e);
3356 }}
3357 }})();
3358 "#,
3359 fn = function_name,
3360 action = action_name
3361 );
3362
3363 context.with(|ctx| {
3364 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3366 Ok(value) => {
3367 if value.is_object() {
3369 if let Some(obj) = value.as_object() {
3370 if obj.get::<_, rquickjs::Function>("then").is_ok() {
3372 run_pending_jobs_checked(
3375 &ctx,
3376 &format!("execute_action {} promise", action_name),
3377 );
3378 }
3379 }
3380 }
3381 }
3382 Err(e) => {
3383 log_js_error(&ctx, e, &format!("action {}", action_name));
3384 }
3385 }
3386 });
3387
3388 Ok(())
3389 }
3390
3391 pub fn poll_event_loop_once(&mut self) -> bool {
3393 let mut had_work = false;
3394
3395 self.main_context.with(|ctx| {
3397 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3398 if count > 0 {
3399 had_work = true;
3400 }
3401 });
3402
3403 let contexts = self.plugin_contexts.borrow().clone();
3405 for (name, context) in contexts {
3406 context.with(|ctx| {
3407 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3408 if count > 0 {
3409 had_work = true;
3410 }
3411 });
3412 }
3413 had_work
3414 }
3415
3416 pub fn send_status(&self, message: String) {
3418 let _ = self
3419 .command_sender
3420 .send(PluginCommand::SetStatus { message });
3421 }
3422
3423 pub fn resolve_callback(
3428 &mut self,
3429 callback_id: fresh_core::api::JsCallbackId,
3430 result_json: &str,
3431 ) {
3432 let id = callback_id.as_u64();
3433 tracing::debug!("resolve_callback: starting for callback_id={}", id);
3434
3435 let plugin_name = {
3437 let mut contexts = self.callback_contexts.borrow_mut();
3438 contexts.remove(&id)
3439 };
3440
3441 let Some(name) = plugin_name else {
3442 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3443 return;
3444 };
3445
3446 let plugin_contexts = self.plugin_contexts.borrow();
3447 let Some(context) = plugin_contexts.get(&name) else {
3448 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3449 return;
3450 };
3451
3452 context.with(|ctx| {
3453 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3455 Ok(v) => v,
3456 Err(e) => {
3457 tracing::error!(
3458 "resolve_callback: failed to parse JSON for callback_id={}: {}",
3459 id,
3460 e
3461 );
3462 return;
3463 }
3464 };
3465
3466 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3468 Ok(v) => v,
3469 Err(e) => {
3470 tracing::error!(
3471 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3472 id,
3473 e
3474 );
3475 return;
3476 }
3477 };
3478
3479 let globals = ctx.globals();
3481 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3482 Ok(f) => f,
3483 Err(e) => {
3484 tracing::error!(
3485 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3486 id,
3487 e
3488 );
3489 return;
3490 }
3491 };
3492
3493 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3495 log_js_error(&ctx, e, &format!("resolving callback {}", id));
3496 }
3497
3498 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3500 tracing::info!(
3501 "resolve_callback: executed {} pending jobs for callback_id={}",
3502 job_count,
3503 id
3504 );
3505 });
3506 }
3507
3508 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3510 let id = callback_id.as_u64();
3511
3512 let plugin_name = {
3514 let mut contexts = self.callback_contexts.borrow_mut();
3515 contexts.remove(&id)
3516 };
3517
3518 let Some(name) = plugin_name else {
3519 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3520 return;
3521 };
3522
3523 let plugin_contexts = self.plugin_contexts.borrow();
3524 let Some(context) = plugin_contexts.get(&name) else {
3525 tracing::warn!("reject_callback: Context lost for plugin {}", name);
3526 return;
3527 };
3528
3529 context.with(|ctx| {
3530 let globals = ctx.globals();
3532 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3533 Ok(f) => f,
3534 Err(e) => {
3535 tracing::error!(
3536 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3537 id,
3538 e
3539 );
3540 return;
3541 }
3542 };
3543
3544 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3546 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3547 }
3548
3549 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3551 });
3552 }
3553}
3554
3555#[cfg(test)]
3556mod tests {
3557 use super::*;
3558 use fresh_core::api::{BufferInfo, CursorInfo};
3559 use std::sync::mpsc;
3560
3561 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3563 let (tx, rx) = mpsc::channel();
3564 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3565 let services = Arc::new(TestServiceBridge::new());
3566 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3567 (backend, rx)
3568 }
3569
3570 struct TestServiceBridge {
3571 en_strings: std::sync::Mutex<HashMap<String, String>>,
3572 }
3573
3574 impl TestServiceBridge {
3575 fn new() -> Self {
3576 Self {
3577 en_strings: std::sync::Mutex::new(HashMap::new()),
3578 }
3579 }
3580 }
3581
3582 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3583 fn as_any(&self) -> &dyn std::any::Any {
3584 self
3585 }
3586 fn translate(
3587 &self,
3588 _plugin_name: &str,
3589 key: &str,
3590 _args: &HashMap<String, String>,
3591 ) -> String {
3592 self.en_strings
3593 .lock()
3594 .unwrap()
3595 .get(key)
3596 .cloned()
3597 .unwrap_or_else(|| key.to_string())
3598 }
3599 fn current_locale(&self) -> String {
3600 "en".to_string()
3601 }
3602 fn set_js_execution_state(&self, _state: String) {}
3603 fn clear_js_execution_state(&self) {}
3604 fn get_theme_schema(&self) -> serde_json::Value {
3605 serde_json::json!({})
3606 }
3607 fn get_builtin_themes(&self) -> serde_json::Value {
3608 serde_json::json!([])
3609 }
3610 fn register_command(&self, _command: fresh_core::command::Command) {}
3611 fn unregister_command(&self, _name: &str) {}
3612 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3613 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
3614 fn plugins_dir(&self) -> std::path::PathBuf {
3615 std::path::PathBuf::from("/tmp/plugins")
3616 }
3617 fn config_dir(&self) -> std::path::PathBuf {
3618 std::path::PathBuf::from("/tmp/config")
3619 }
3620 }
3621
3622 #[test]
3623 fn test_quickjs_backend_creation() {
3624 let backend = QuickJsBackend::new();
3625 assert!(backend.is_ok());
3626 }
3627
3628 #[test]
3629 fn test_execute_simple_js() {
3630 let mut backend = QuickJsBackend::new().unwrap();
3631 let result = backend.execute_js("const x = 1 + 2;", "test.js");
3632 assert!(result.is_ok());
3633 }
3634
3635 #[test]
3636 fn test_event_handler_registration() {
3637 let backend = QuickJsBackend::new().unwrap();
3638
3639 assert!(!backend.has_handlers("test_event"));
3641
3642 backend
3644 .event_handlers
3645 .borrow_mut()
3646 .entry("test_event".to_string())
3647 .or_default()
3648 .push(PluginHandler {
3649 plugin_name: "test".to_string(),
3650 handler_name: "testHandler".to_string(),
3651 });
3652
3653 assert!(backend.has_handlers("test_event"));
3655 }
3656
3657 #[test]
3660 fn test_api_set_status() {
3661 let (mut backend, rx) = create_test_backend();
3662
3663 backend
3664 .execute_js(
3665 r#"
3666 const editor = getEditor();
3667 editor.setStatus("Hello from test");
3668 "#,
3669 "test.js",
3670 )
3671 .unwrap();
3672
3673 let cmd = rx.try_recv().unwrap();
3674 match cmd {
3675 PluginCommand::SetStatus { message } => {
3676 assert_eq!(message, "Hello from test");
3677 }
3678 _ => panic!("Expected SetStatus command, got {:?}", cmd),
3679 }
3680 }
3681
3682 #[test]
3683 fn test_api_register_command() {
3684 let (mut backend, rx) = create_test_backend();
3685
3686 backend
3687 .execute_js(
3688 r#"
3689 const editor = getEditor();
3690 globalThis.myTestHandler = function() { };
3691 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3692 "#,
3693 "test_plugin.js",
3694 )
3695 .unwrap();
3696
3697 let cmd = rx.try_recv().unwrap();
3698 match cmd {
3699 PluginCommand::RegisterCommand { command } => {
3700 assert_eq!(command.name, "Test Command");
3701 assert_eq!(command.description, "A test command");
3702 assert_eq!(command.plugin_name, "test_plugin");
3704 }
3705 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3706 }
3707 }
3708
3709 #[test]
3710 fn test_api_define_mode() {
3711 let (mut backend, rx) = create_test_backend();
3712
3713 backend
3714 .execute_js(
3715 r#"
3716 const editor = getEditor();
3717 editor.defineMode("test-mode", null, [
3718 ["a", "action_a"],
3719 ["b", "action_b"]
3720 ]);
3721 "#,
3722 "test.js",
3723 )
3724 .unwrap();
3725
3726 let cmd = rx.try_recv().unwrap();
3727 match cmd {
3728 PluginCommand::DefineMode {
3729 name,
3730 parent,
3731 bindings,
3732 read_only,
3733 } => {
3734 assert_eq!(name, "test-mode");
3735 assert!(parent.is_none());
3736 assert_eq!(bindings.len(), 2);
3737 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3738 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3739 assert!(!read_only);
3740 }
3741 _ => panic!("Expected DefineMode, got {:?}", cmd),
3742 }
3743 }
3744
3745 #[test]
3746 fn test_api_set_editor_mode() {
3747 let (mut backend, rx) = create_test_backend();
3748
3749 backend
3750 .execute_js(
3751 r#"
3752 const editor = getEditor();
3753 editor.setEditorMode("vi-normal");
3754 "#,
3755 "test.js",
3756 )
3757 .unwrap();
3758
3759 let cmd = rx.try_recv().unwrap();
3760 match cmd {
3761 PluginCommand::SetEditorMode { mode } => {
3762 assert_eq!(mode, Some("vi-normal".to_string()));
3763 }
3764 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3765 }
3766 }
3767
3768 #[test]
3769 fn test_api_clear_editor_mode() {
3770 let (mut backend, rx) = create_test_backend();
3771
3772 backend
3773 .execute_js(
3774 r#"
3775 const editor = getEditor();
3776 editor.setEditorMode(null);
3777 "#,
3778 "test.js",
3779 )
3780 .unwrap();
3781
3782 let cmd = rx.try_recv().unwrap();
3783 match cmd {
3784 PluginCommand::SetEditorMode { mode } => {
3785 assert!(mode.is_none());
3786 }
3787 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3788 }
3789 }
3790
3791 #[test]
3792 fn test_api_insert_at_cursor() {
3793 let (mut backend, rx) = create_test_backend();
3794
3795 backend
3796 .execute_js(
3797 r#"
3798 const editor = getEditor();
3799 editor.insertAtCursor("Hello, World!");
3800 "#,
3801 "test.js",
3802 )
3803 .unwrap();
3804
3805 let cmd = rx.try_recv().unwrap();
3806 match cmd {
3807 PluginCommand::InsertAtCursor { text } => {
3808 assert_eq!(text, "Hello, World!");
3809 }
3810 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3811 }
3812 }
3813
3814 #[test]
3815 fn test_api_set_context() {
3816 let (mut backend, rx) = create_test_backend();
3817
3818 backend
3819 .execute_js(
3820 r#"
3821 const editor = getEditor();
3822 editor.setContext("myContext", true);
3823 "#,
3824 "test.js",
3825 )
3826 .unwrap();
3827
3828 let cmd = rx.try_recv().unwrap();
3829 match cmd {
3830 PluginCommand::SetContext { name, active } => {
3831 assert_eq!(name, "myContext");
3832 assert!(active);
3833 }
3834 _ => panic!("Expected SetContext, got {:?}", cmd),
3835 }
3836 }
3837
3838 #[tokio::test]
3839 async fn test_execute_action_sync_function() {
3840 let (mut backend, rx) = create_test_backend();
3841
3842 backend.registered_actions.borrow_mut().insert(
3844 "my_sync_action".to_string(),
3845 PluginHandler {
3846 plugin_name: "test".to_string(),
3847 handler_name: "my_sync_action".to_string(),
3848 },
3849 );
3850
3851 backend
3853 .execute_js(
3854 r#"
3855 const editor = getEditor();
3856 globalThis.my_sync_action = function() {
3857 editor.setStatus("sync action executed");
3858 };
3859 "#,
3860 "test.js",
3861 )
3862 .unwrap();
3863
3864 while rx.try_recv().is_ok() {}
3866
3867 backend.execute_action("my_sync_action").await.unwrap();
3869
3870 let cmd = rx.try_recv().unwrap();
3872 match cmd {
3873 PluginCommand::SetStatus { message } => {
3874 assert_eq!(message, "sync action executed");
3875 }
3876 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3877 }
3878 }
3879
3880 #[tokio::test]
3881 async fn test_execute_action_async_function() {
3882 let (mut backend, rx) = create_test_backend();
3883
3884 backend.registered_actions.borrow_mut().insert(
3886 "my_async_action".to_string(),
3887 PluginHandler {
3888 plugin_name: "test".to_string(),
3889 handler_name: "my_async_action".to_string(),
3890 },
3891 );
3892
3893 backend
3895 .execute_js(
3896 r#"
3897 const editor = getEditor();
3898 globalThis.my_async_action = async function() {
3899 await Promise.resolve();
3900 editor.setStatus("async action executed");
3901 };
3902 "#,
3903 "test.js",
3904 )
3905 .unwrap();
3906
3907 while rx.try_recv().is_ok() {}
3909
3910 backend.execute_action("my_async_action").await.unwrap();
3912
3913 let cmd = rx.try_recv().unwrap();
3915 match cmd {
3916 PluginCommand::SetStatus { message } => {
3917 assert_eq!(message, "async action executed");
3918 }
3919 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3920 }
3921 }
3922
3923 #[tokio::test]
3924 async fn test_execute_action_with_registered_handler() {
3925 let (mut backend, rx) = create_test_backend();
3926
3927 backend.registered_actions.borrow_mut().insert(
3929 "my_action".to_string(),
3930 PluginHandler {
3931 plugin_name: "test".to_string(),
3932 handler_name: "actual_handler_function".to_string(),
3933 },
3934 );
3935
3936 backend
3937 .execute_js(
3938 r#"
3939 const editor = getEditor();
3940 globalThis.actual_handler_function = function() {
3941 editor.setStatus("handler executed");
3942 };
3943 "#,
3944 "test.js",
3945 )
3946 .unwrap();
3947
3948 while rx.try_recv().is_ok() {}
3950
3951 backend.execute_action("my_action").await.unwrap();
3953
3954 let cmd = rx.try_recv().unwrap();
3955 match cmd {
3956 PluginCommand::SetStatus { message } => {
3957 assert_eq!(message, "handler executed");
3958 }
3959 _ => panic!("Expected SetStatus, got {:?}", cmd),
3960 }
3961 }
3962
3963 #[test]
3964 fn test_api_on_event_registration() {
3965 let (mut backend, _rx) = create_test_backend();
3966
3967 backend
3968 .execute_js(
3969 r#"
3970 const editor = getEditor();
3971 globalThis.myEventHandler = function() { };
3972 editor.on("bufferSave", "myEventHandler");
3973 "#,
3974 "test.js",
3975 )
3976 .unwrap();
3977
3978 assert!(backend.has_handlers("bufferSave"));
3979 }
3980
3981 #[test]
3982 fn test_api_off_event_unregistration() {
3983 let (mut backend, _rx) = create_test_backend();
3984
3985 backend
3986 .execute_js(
3987 r#"
3988 const editor = getEditor();
3989 globalThis.myEventHandler = function() { };
3990 editor.on("bufferSave", "myEventHandler");
3991 editor.off("bufferSave", "myEventHandler");
3992 "#,
3993 "test.js",
3994 )
3995 .unwrap();
3996
3997 assert!(!backend.has_handlers("bufferSave"));
3999 }
4000
4001 #[tokio::test]
4002 async fn test_emit_event() {
4003 let (mut backend, rx) = create_test_backend();
4004
4005 backend
4006 .execute_js(
4007 r#"
4008 const editor = getEditor();
4009 globalThis.onSaveHandler = function(data) {
4010 editor.setStatus("saved: " + JSON.stringify(data));
4011 };
4012 editor.on("bufferSave", "onSaveHandler");
4013 "#,
4014 "test.js",
4015 )
4016 .unwrap();
4017
4018 while rx.try_recv().is_ok() {}
4020
4021 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
4023 backend.emit("bufferSave", &event_data).await.unwrap();
4024
4025 let cmd = rx.try_recv().unwrap();
4026 match cmd {
4027 PluginCommand::SetStatus { message } => {
4028 assert!(message.contains("/test.txt"));
4029 }
4030 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
4031 }
4032 }
4033
4034 #[test]
4035 fn test_api_copy_to_clipboard() {
4036 let (mut backend, rx) = create_test_backend();
4037
4038 backend
4039 .execute_js(
4040 r#"
4041 const editor = getEditor();
4042 editor.copyToClipboard("clipboard text");
4043 "#,
4044 "test.js",
4045 )
4046 .unwrap();
4047
4048 let cmd = rx.try_recv().unwrap();
4049 match cmd {
4050 PluginCommand::SetClipboard { text } => {
4051 assert_eq!(text, "clipboard text");
4052 }
4053 _ => panic!("Expected SetClipboard, got {:?}", cmd),
4054 }
4055 }
4056
4057 #[test]
4058 fn test_api_open_file() {
4059 let (mut backend, rx) = create_test_backend();
4060
4061 backend
4063 .execute_js(
4064 r#"
4065 const editor = getEditor();
4066 editor.openFile("/path/to/file.txt", null, null);
4067 "#,
4068 "test.js",
4069 )
4070 .unwrap();
4071
4072 let cmd = rx.try_recv().unwrap();
4073 match cmd {
4074 PluginCommand::OpenFileAtLocation { path, line, column } => {
4075 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
4076 assert!(line.is_none());
4077 assert!(column.is_none());
4078 }
4079 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
4080 }
4081 }
4082
4083 #[test]
4084 fn test_api_delete_range() {
4085 let (mut backend, rx) = create_test_backend();
4086
4087 backend
4089 .execute_js(
4090 r#"
4091 const editor = getEditor();
4092 editor.deleteRange(0, 10, 20);
4093 "#,
4094 "test.js",
4095 )
4096 .unwrap();
4097
4098 let cmd = rx.try_recv().unwrap();
4099 match cmd {
4100 PluginCommand::DeleteRange { range, .. } => {
4101 assert_eq!(range.start, 10);
4102 assert_eq!(range.end, 20);
4103 }
4104 _ => panic!("Expected DeleteRange, got {:?}", cmd),
4105 }
4106 }
4107
4108 #[test]
4109 fn test_api_insert_text() {
4110 let (mut backend, rx) = create_test_backend();
4111
4112 backend
4114 .execute_js(
4115 r#"
4116 const editor = getEditor();
4117 editor.insertText(0, 5, "inserted");
4118 "#,
4119 "test.js",
4120 )
4121 .unwrap();
4122
4123 let cmd = rx.try_recv().unwrap();
4124 match cmd {
4125 PluginCommand::InsertText { position, text, .. } => {
4126 assert_eq!(position, 5);
4127 assert_eq!(text, "inserted");
4128 }
4129 _ => panic!("Expected InsertText, got {:?}", cmd),
4130 }
4131 }
4132
4133 #[test]
4134 fn test_api_set_buffer_cursor() {
4135 let (mut backend, rx) = create_test_backend();
4136
4137 backend
4139 .execute_js(
4140 r#"
4141 const editor = getEditor();
4142 editor.setBufferCursor(0, 100);
4143 "#,
4144 "test.js",
4145 )
4146 .unwrap();
4147
4148 let cmd = rx.try_recv().unwrap();
4149 match cmd {
4150 PluginCommand::SetBufferCursor { position, .. } => {
4151 assert_eq!(position, 100);
4152 }
4153 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
4154 }
4155 }
4156
4157 #[test]
4158 fn test_api_get_cursor_position_from_state() {
4159 let (tx, _rx) = mpsc::channel();
4160 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4161
4162 {
4164 let mut state = state_snapshot.write().unwrap();
4165 state.primary_cursor = Some(CursorInfo {
4166 position: 42,
4167 selection: None,
4168 });
4169 }
4170
4171 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4172 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4173
4174 backend
4176 .execute_js(
4177 r#"
4178 const editor = getEditor();
4179 const pos = editor.getCursorPosition();
4180 globalThis._testResult = pos;
4181 "#,
4182 "test.js",
4183 )
4184 .unwrap();
4185
4186 backend
4188 .plugin_contexts
4189 .borrow()
4190 .get("test")
4191 .unwrap()
4192 .clone()
4193 .with(|ctx| {
4194 let global = ctx.globals();
4195 let result: u32 = global.get("_testResult").unwrap();
4196 assert_eq!(result, 42);
4197 });
4198 }
4199
4200 #[test]
4201 fn test_api_path_functions() {
4202 let (mut backend, _rx) = create_test_backend();
4203
4204 #[cfg(windows)]
4207 let absolute_path = r#"C:\\foo\\bar"#;
4208 #[cfg(not(windows))]
4209 let absolute_path = "/foo/bar";
4210
4211 let js_code = format!(
4213 r#"
4214 const editor = getEditor();
4215 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4216 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4217 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4218 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4219 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4220 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4221 "#,
4222 absolute_path
4223 );
4224 backend.execute_js(&js_code, "test.js").unwrap();
4225
4226 backend
4227 .plugin_contexts
4228 .borrow()
4229 .get("test")
4230 .unwrap()
4231 .clone()
4232 .with(|ctx| {
4233 let global = ctx.globals();
4234 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4235 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4236 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4237 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4238 assert!(!global.get::<_, bool>("_isRelative").unwrap());
4239 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4240 });
4241 }
4242
4243 #[test]
4244 fn test_typescript_transpilation() {
4245 use fresh_parser_js::transpile_typescript;
4246
4247 let (mut backend, rx) = create_test_backend();
4248
4249 let ts_code = r#"
4251 const editor = getEditor();
4252 function greet(name: string): string {
4253 return "Hello, " + name;
4254 }
4255 editor.setStatus(greet("TypeScript"));
4256 "#;
4257
4258 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4260
4261 backend.execute_js(&js_code, "test.js").unwrap();
4263
4264 let cmd = rx.try_recv().unwrap();
4265 match cmd {
4266 PluginCommand::SetStatus { message } => {
4267 assert_eq!(message, "Hello, TypeScript");
4268 }
4269 _ => panic!("Expected SetStatus, got {:?}", cmd),
4270 }
4271 }
4272
4273 #[test]
4274 fn test_api_get_buffer_text_sends_command() {
4275 let (mut backend, rx) = create_test_backend();
4276
4277 backend
4279 .execute_js(
4280 r#"
4281 const editor = getEditor();
4282 // Store the promise for later
4283 globalThis._textPromise = editor.getBufferText(0, 10, 20);
4284 "#,
4285 "test.js",
4286 )
4287 .unwrap();
4288
4289 let cmd = rx.try_recv().unwrap();
4291 match cmd {
4292 PluginCommand::GetBufferText {
4293 buffer_id,
4294 start,
4295 end,
4296 request_id,
4297 } => {
4298 assert_eq!(buffer_id.0, 0);
4299 assert_eq!(start, 10);
4300 assert_eq!(end, 20);
4301 assert!(request_id > 0); }
4303 _ => panic!("Expected GetBufferText, got {:?}", cmd),
4304 }
4305 }
4306
4307 #[test]
4308 fn test_api_get_buffer_text_resolves_callback() {
4309 let (mut backend, rx) = create_test_backend();
4310
4311 backend
4313 .execute_js(
4314 r#"
4315 const editor = getEditor();
4316 globalThis._resolvedText = null;
4317 editor.getBufferText(0, 0, 100).then(text => {
4318 globalThis._resolvedText = text;
4319 });
4320 "#,
4321 "test.js",
4322 )
4323 .unwrap();
4324
4325 let request_id = match rx.try_recv().unwrap() {
4327 PluginCommand::GetBufferText { request_id, .. } => request_id,
4328 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4329 };
4330
4331 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4333
4334 backend
4336 .plugin_contexts
4337 .borrow()
4338 .get("test")
4339 .unwrap()
4340 .clone()
4341 .with(|ctx| {
4342 run_pending_jobs_checked(&ctx, "test async getText");
4343 });
4344
4345 backend
4347 .plugin_contexts
4348 .borrow()
4349 .get("test")
4350 .unwrap()
4351 .clone()
4352 .with(|ctx| {
4353 let global = ctx.globals();
4354 let result: String = global.get("_resolvedText").unwrap();
4355 assert_eq!(result, "hello world");
4356 });
4357 }
4358
4359 #[test]
4360 fn test_plugin_translation() {
4361 let (mut backend, _rx) = create_test_backend();
4362
4363 backend
4365 .execute_js(
4366 r#"
4367 const editor = getEditor();
4368 globalThis._translated = editor.t("test.key");
4369 "#,
4370 "test.js",
4371 )
4372 .unwrap();
4373
4374 backend
4375 .plugin_contexts
4376 .borrow()
4377 .get("test")
4378 .unwrap()
4379 .clone()
4380 .with(|ctx| {
4381 let global = ctx.globals();
4382 let result: String = global.get("_translated").unwrap();
4384 assert_eq!(result, "test.key");
4385 });
4386 }
4387
4388 #[test]
4389 fn test_plugin_translation_with_registered_strings() {
4390 let (mut backend, _rx) = create_test_backend();
4391
4392 let mut en_strings = std::collections::HashMap::new();
4394 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4395 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4396
4397 let mut strings = std::collections::HashMap::new();
4398 strings.insert("en".to_string(), en_strings);
4399
4400 if let Some(bridge) = backend
4402 .services
4403 .as_any()
4404 .downcast_ref::<TestServiceBridge>()
4405 {
4406 let mut en = bridge.en_strings.lock().unwrap();
4407 en.insert("greeting".to_string(), "Hello, World!".to_string());
4408 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4409 }
4410
4411 backend
4413 .execute_js(
4414 r#"
4415 const editor = getEditor();
4416 globalThis._greeting = editor.t("greeting");
4417 globalThis._prompt = editor.t("prompt.find_file");
4418 globalThis._missing = editor.t("nonexistent.key");
4419 "#,
4420 "test.js",
4421 )
4422 .unwrap();
4423
4424 backend
4425 .plugin_contexts
4426 .borrow()
4427 .get("test")
4428 .unwrap()
4429 .clone()
4430 .with(|ctx| {
4431 let global = ctx.globals();
4432 let greeting: String = global.get("_greeting").unwrap();
4433 assert_eq!(greeting, "Hello, World!");
4434
4435 let prompt: String = global.get("_prompt").unwrap();
4436 assert_eq!(prompt, "Find file: ");
4437
4438 let missing: String = global.get("_missing").unwrap();
4440 assert_eq!(missing, "nonexistent.key");
4441 });
4442 }
4443
4444 #[test]
4447 fn test_api_set_line_indicator() {
4448 let (mut backend, rx) = create_test_backend();
4449
4450 backend
4451 .execute_js(
4452 r#"
4453 const editor = getEditor();
4454 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4455 "#,
4456 "test.js",
4457 )
4458 .unwrap();
4459
4460 let cmd = rx.try_recv().unwrap();
4461 match cmd {
4462 PluginCommand::SetLineIndicator {
4463 buffer_id,
4464 line,
4465 namespace,
4466 symbol,
4467 color,
4468 priority,
4469 } => {
4470 assert_eq!(buffer_id.0, 1);
4471 assert_eq!(line, 5);
4472 assert_eq!(namespace, "test-ns");
4473 assert_eq!(symbol, "●");
4474 assert_eq!(color, (255, 0, 0));
4475 assert_eq!(priority, 10);
4476 }
4477 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4478 }
4479 }
4480
4481 #[test]
4482 fn test_api_clear_line_indicators() {
4483 let (mut backend, rx) = create_test_backend();
4484
4485 backend
4486 .execute_js(
4487 r#"
4488 const editor = getEditor();
4489 editor.clearLineIndicators(1, "test-ns");
4490 "#,
4491 "test.js",
4492 )
4493 .unwrap();
4494
4495 let cmd = rx.try_recv().unwrap();
4496 match cmd {
4497 PluginCommand::ClearLineIndicators {
4498 buffer_id,
4499 namespace,
4500 } => {
4501 assert_eq!(buffer_id.0, 1);
4502 assert_eq!(namespace, "test-ns");
4503 }
4504 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4505 }
4506 }
4507
4508 #[test]
4511 fn test_api_create_virtual_buffer_sends_command() {
4512 let (mut backend, rx) = create_test_backend();
4513
4514 backend
4515 .execute_js(
4516 r#"
4517 const editor = getEditor();
4518 editor.createVirtualBuffer({
4519 name: "*Test Buffer*",
4520 mode: "test-mode",
4521 readOnly: true,
4522 entries: [
4523 { text: "Line 1\n", properties: { type: "header" } },
4524 { text: "Line 2\n", properties: { type: "content" } }
4525 ],
4526 showLineNumbers: false,
4527 showCursors: true,
4528 editingDisabled: true
4529 });
4530 "#,
4531 "test.js",
4532 )
4533 .unwrap();
4534
4535 let cmd = rx.try_recv().unwrap();
4536 match cmd {
4537 PluginCommand::CreateVirtualBufferWithContent {
4538 name,
4539 mode,
4540 read_only,
4541 entries,
4542 show_line_numbers,
4543 show_cursors,
4544 editing_disabled,
4545 ..
4546 } => {
4547 assert_eq!(name, "*Test Buffer*");
4548 assert_eq!(mode, "test-mode");
4549 assert!(read_only);
4550 assert_eq!(entries.len(), 2);
4551 assert_eq!(entries[0].text, "Line 1\n");
4552 assert!(!show_line_numbers);
4553 assert!(show_cursors);
4554 assert!(editing_disabled);
4555 }
4556 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4557 }
4558 }
4559
4560 #[test]
4561 fn test_api_set_virtual_buffer_content() {
4562 let (mut backend, rx) = create_test_backend();
4563
4564 backend
4565 .execute_js(
4566 r#"
4567 const editor = getEditor();
4568 editor.setVirtualBufferContent(5, [
4569 { text: "New content\n", properties: { type: "updated" } }
4570 ]);
4571 "#,
4572 "test.js",
4573 )
4574 .unwrap();
4575
4576 let cmd = rx.try_recv().unwrap();
4577 match cmd {
4578 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4579 assert_eq!(buffer_id.0, 5);
4580 assert_eq!(entries.len(), 1);
4581 assert_eq!(entries[0].text, "New content\n");
4582 }
4583 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4584 }
4585 }
4586
4587 #[test]
4590 fn test_api_add_overlay() {
4591 let (mut backend, rx) = create_test_backend();
4592
4593 backend
4594 .execute_js(
4595 r#"
4596 const editor = getEditor();
4597 editor.addOverlay(1, "highlight", 10, 20, {
4598 fg: [255, 128, 0],
4599 bg: [50, 50, 50],
4600 bold: true,
4601 });
4602 "#,
4603 "test.js",
4604 )
4605 .unwrap();
4606
4607 let cmd = rx.try_recv().unwrap();
4608 match cmd {
4609 PluginCommand::AddOverlay {
4610 buffer_id,
4611 namespace,
4612 range,
4613 options,
4614 } => {
4615 use fresh_core::api::OverlayColorSpec;
4616 assert_eq!(buffer_id.0, 1);
4617 assert!(namespace.is_some());
4618 assert_eq!(namespace.unwrap().as_str(), "highlight");
4619 assert_eq!(range, 10..20);
4620 assert!(matches!(
4621 options.fg,
4622 Some(OverlayColorSpec::Rgb(255, 128, 0))
4623 ));
4624 assert!(matches!(
4625 options.bg,
4626 Some(OverlayColorSpec::Rgb(50, 50, 50))
4627 ));
4628 assert!(!options.underline);
4629 assert!(options.bold);
4630 assert!(!options.italic);
4631 assert!(!options.extend_to_line_end);
4632 }
4633 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4634 }
4635 }
4636
4637 #[test]
4638 fn test_api_add_overlay_with_theme_keys() {
4639 let (mut backend, rx) = create_test_backend();
4640
4641 backend
4642 .execute_js(
4643 r#"
4644 const editor = getEditor();
4645 // Test with theme keys for colors
4646 editor.addOverlay(1, "themed", 0, 10, {
4647 fg: "ui.status_bar_fg",
4648 bg: "editor.selection_bg",
4649 });
4650 "#,
4651 "test.js",
4652 )
4653 .unwrap();
4654
4655 let cmd = rx.try_recv().unwrap();
4656 match cmd {
4657 PluginCommand::AddOverlay {
4658 buffer_id,
4659 namespace,
4660 range,
4661 options,
4662 } => {
4663 use fresh_core::api::OverlayColorSpec;
4664 assert_eq!(buffer_id.0, 1);
4665 assert!(namespace.is_some());
4666 assert_eq!(namespace.unwrap().as_str(), "themed");
4667 assert_eq!(range, 0..10);
4668 assert!(matches!(
4669 &options.fg,
4670 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
4671 ));
4672 assert!(matches!(
4673 &options.bg,
4674 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
4675 ));
4676 assert!(!options.underline);
4677 assert!(!options.bold);
4678 assert!(!options.italic);
4679 assert!(!options.extend_to_line_end);
4680 }
4681 _ => panic!("Expected AddOverlay, got {:?}", cmd),
4682 }
4683 }
4684
4685 #[test]
4686 fn test_api_clear_namespace() {
4687 let (mut backend, rx) = create_test_backend();
4688
4689 backend
4690 .execute_js(
4691 r#"
4692 const editor = getEditor();
4693 editor.clearNamespace(1, "highlight");
4694 "#,
4695 "test.js",
4696 )
4697 .unwrap();
4698
4699 let cmd = rx.try_recv().unwrap();
4700 match cmd {
4701 PluginCommand::ClearNamespace {
4702 buffer_id,
4703 namespace,
4704 } => {
4705 assert_eq!(buffer_id.0, 1);
4706 assert_eq!(namespace.as_str(), "highlight");
4707 }
4708 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4709 }
4710 }
4711
4712 #[test]
4715 fn test_api_get_theme_schema() {
4716 let (mut backend, _rx) = create_test_backend();
4717
4718 backend
4719 .execute_js(
4720 r#"
4721 const editor = getEditor();
4722 const schema = editor.getThemeSchema();
4723 globalThis._isObject = typeof schema === 'object' && schema !== null;
4724 "#,
4725 "test.js",
4726 )
4727 .unwrap();
4728
4729 backend
4730 .plugin_contexts
4731 .borrow()
4732 .get("test")
4733 .unwrap()
4734 .clone()
4735 .with(|ctx| {
4736 let global = ctx.globals();
4737 let is_object: bool = global.get("_isObject").unwrap();
4738 assert!(is_object);
4740 });
4741 }
4742
4743 #[test]
4744 fn test_api_get_builtin_themes() {
4745 let (mut backend, _rx) = create_test_backend();
4746
4747 backend
4748 .execute_js(
4749 r#"
4750 const editor = getEditor();
4751 const themes = editor.getBuiltinThemes();
4752 globalThis._isObject = typeof themes === 'object' && themes !== null;
4753 "#,
4754 "test.js",
4755 )
4756 .unwrap();
4757
4758 backend
4759 .plugin_contexts
4760 .borrow()
4761 .get("test")
4762 .unwrap()
4763 .clone()
4764 .with(|ctx| {
4765 let global = ctx.globals();
4766 let is_object: bool = global.get("_isObject").unwrap();
4767 assert!(is_object);
4769 });
4770 }
4771
4772 #[test]
4773 fn test_api_apply_theme() {
4774 let (mut backend, rx) = create_test_backend();
4775
4776 backend
4777 .execute_js(
4778 r#"
4779 const editor = getEditor();
4780 editor.applyTheme("dark");
4781 "#,
4782 "test.js",
4783 )
4784 .unwrap();
4785
4786 let cmd = rx.try_recv().unwrap();
4787 match cmd {
4788 PluginCommand::ApplyTheme { theme_name } => {
4789 assert_eq!(theme_name, "dark");
4790 }
4791 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4792 }
4793 }
4794
4795 #[test]
4798 fn test_api_close_buffer() {
4799 let (mut backend, rx) = create_test_backend();
4800
4801 backend
4802 .execute_js(
4803 r#"
4804 const editor = getEditor();
4805 editor.closeBuffer(3);
4806 "#,
4807 "test.js",
4808 )
4809 .unwrap();
4810
4811 let cmd = rx.try_recv().unwrap();
4812 match cmd {
4813 PluginCommand::CloseBuffer { buffer_id } => {
4814 assert_eq!(buffer_id.0, 3);
4815 }
4816 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4817 }
4818 }
4819
4820 #[test]
4821 fn test_api_focus_split() {
4822 let (mut backend, rx) = create_test_backend();
4823
4824 backend
4825 .execute_js(
4826 r#"
4827 const editor = getEditor();
4828 editor.focusSplit(2);
4829 "#,
4830 "test.js",
4831 )
4832 .unwrap();
4833
4834 let cmd = rx.try_recv().unwrap();
4835 match cmd {
4836 PluginCommand::FocusSplit { split_id } => {
4837 assert_eq!(split_id.0, 2);
4838 }
4839 _ => panic!("Expected FocusSplit, got {:?}", cmd),
4840 }
4841 }
4842
4843 #[test]
4844 fn test_api_list_buffers() {
4845 let (tx, _rx) = mpsc::channel();
4846 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4847
4848 {
4850 let mut state = state_snapshot.write().unwrap();
4851 state.buffers.insert(
4852 BufferId(0),
4853 BufferInfo {
4854 id: BufferId(0),
4855 path: Some(PathBuf::from("/test1.txt")),
4856 modified: false,
4857 length: 100,
4858 },
4859 );
4860 state.buffers.insert(
4861 BufferId(1),
4862 BufferInfo {
4863 id: BufferId(1),
4864 path: Some(PathBuf::from("/test2.txt")),
4865 modified: true,
4866 length: 200,
4867 },
4868 );
4869 }
4870
4871 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4872 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4873
4874 backend
4875 .execute_js(
4876 r#"
4877 const editor = getEditor();
4878 const buffers = editor.listBuffers();
4879 globalThis._isArray = Array.isArray(buffers);
4880 globalThis._length = buffers.length;
4881 "#,
4882 "test.js",
4883 )
4884 .unwrap();
4885
4886 backend
4887 .plugin_contexts
4888 .borrow()
4889 .get("test")
4890 .unwrap()
4891 .clone()
4892 .with(|ctx| {
4893 let global = ctx.globals();
4894 let is_array: bool = global.get("_isArray").unwrap();
4895 let length: u32 = global.get("_length").unwrap();
4896 assert!(is_array);
4897 assert_eq!(length, 2);
4898 });
4899 }
4900
4901 #[test]
4904 fn test_api_start_prompt() {
4905 let (mut backend, rx) = create_test_backend();
4906
4907 backend
4908 .execute_js(
4909 r#"
4910 const editor = getEditor();
4911 editor.startPrompt("Enter value:", "test-prompt");
4912 "#,
4913 "test.js",
4914 )
4915 .unwrap();
4916
4917 let cmd = rx.try_recv().unwrap();
4918 match cmd {
4919 PluginCommand::StartPrompt { label, prompt_type } => {
4920 assert_eq!(label, "Enter value:");
4921 assert_eq!(prompt_type, "test-prompt");
4922 }
4923 _ => panic!("Expected StartPrompt, got {:?}", cmd),
4924 }
4925 }
4926
4927 #[test]
4928 fn test_api_start_prompt_with_initial() {
4929 let (mut backend, rx) = create_test_backend();
4930
4931 backend
4932 .execute_js(
4933 r#"
4934 const editor = getEditor();
4935 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4936 "#,
4937 "test.js",
4938 )
4939 .unwrap();
4940
4941 let cmd = rx.try_recv().unwrap();
4942 match cmd {
4943 PluginCommand::StartPromptWithInitial {
4944 label,
4945 prompt_type,
4946 initial_value,
4947 } => {
4948 assert_eq!(label, "Enter value:");
4949 assert_eq!(prompt_type, "test-prompt");
4950 assert_eq!(initial_value, "default");
4951 }
4952 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4953 }
4954 }
4955
4956 #[test]
4957 fn test_api_set_prompt_suggestions() {
4958 let (mut backend, rx) = create_test_backend();
4959
4960 backend
4961 .execute_js(
4962 r#"
4963 const editor = getEditor();
4964 editor.setPromptSuggestions([
4965 { text: "Option 1", value: "opt1" },
4966 { text: "Option 2", value: "opt2" }
4967 ]);
4968 "#,
4969 "test.js",
4970 )
4971 .unwrap();
4972
4973 let cmd = rx.try_recv().unwrap();
4974 match cmd {
4975 PluginCommand::SetPromptSuggestions { suggestions } => {
4976 assert_eq!(suggestions.len(), 2);
4977 assert_eq!(suggestions[0].text, "Option 1");
4978 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4979 }
4980 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4981 }
4982 }
4983
4984 #[test]
4987 fn test_api_get_active_buffer_id() {
4988 let (tx, _rx) = mpsc::channel();
4989 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4990
4991 {
4992 let mut state = state_snapshot.write().unwrap();
4993 state.active_buffer_id = BufferId(42);
4994 }
4995
4996 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4997 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4998
4999 backend
5000 .execute_js(
5001 r#"
5002 const editor = getEditor();
5003 globalThis._activeId = editor.getActiveBufferId();
5004 "#,
5005 "test.js",
5006 )
5007 .unwrap();
5008
5009 backend
5010 .plugin_contexts
5011 .borrow()
5012 .get("test")
5013 .unwrap()
5014 .clone()
5015 .with(|ctx| {
5016 let global = ctx.globals();
5017 let result: u32 = global.get("_activeId").unwrap();
5018 assert_eq!(result, 42);
5019 });
5020 }
5021
5022 #[test]
5023 fn test_api_get_active_split_id() {
5024 let (tx, _rx) = mpsc::channel();
5025 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5026
5027 {
5028 let mut state = state_snapshot.write().unwrap();
5029 state.active_split_id = 7;
5030 }
5031
5032 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5033 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5034
5035 backend
5036 .execute_js(
5037 r#"
5038 const editor = getEditor();
5039 globalThis._splitId = editor.getActiveSplitId();
5040 "#,
5041 "test.js",
5042 )
5043 .unwrap();
5044
5045 backend
5046 .plugin_contexts
5047 .borrow()
5048 .get("test")
5049 .unwrap()
5050 .clone()
5051 .with(|ctx| {
5052 let global = ctx.globals();
5053 let result: u32 = global.get("_splitId").unwrap();
5054 assert_eq!(result, 7);
5055 });
5056 }
5057
5058 #[test]
5061 fn test_api_file_exists() {
5062 let (mut backend, _rx) = create_test_backend();
5063
5064 backend
5065 .execute_js(
5066 r#"
5067 const editor = getEditor();
5068 // Test with a path that definitely exists
5069 globalThis._exists = editor.fileExists("/");
5070 "#,
5071 "test.js",
5072 )
5073 .unwrap();
5074
5075 backend
5076 .plugin_contexts
5077 .borrow()
5078 .get("test")
5079 .unwrap()
5080 .clone()
5081 .with(|ctx| {
5082 let global = ctx.globals();
5083 let result: bool = global.get("_exists").unwrap();
5084 assert!(result);
5085 });
5086 }
5087
5088 #[test]
5089 fn test_api_get_cwd() {
5090 let (mut backend, _rx) = create_test_backend();
5091
5092 backend
5093 .execute_js(
5094 r#"
5095 const editor = getEditor();
5096 globalThis._cwd = editor.getCwd();
5097 "#,
5098 "test.js",
5099 )
5100 .unwrap();
5101
5102 backend
5103 .plugin_contexts
5104 .borrow()
5105 .get("test")
5106 .unwrap()
5107 .clone()
5108 .with(|ctx| {
5109 let global = ctx.globals();
5110 let result: String = global.get("_cwd").unwrap();
5111 assert!(!result.is_empty());
5113 });
5114 }
5115
5116 #[test]
5117 fn test_api_get_env() {
5118 let (mut backend, _rx) = create_test_backend();
5119
5120 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
5122
5123 backend
5124 .execute_js(
5125 r#"
5126 const editor = getEditor();
5127 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
5128 "#,
5129 "test.js",
5130 )
5131 .unwrap();
5132
5133 backend
5134 .plugin_contexts
5135 .borrow()
5136 .get("test")
5137 .unwrap()
5138 .clone()
5139 .with(|ctx| {
5140 let global = ctx.globals();
5141 let result: Option<String> = global.get("_envVal").unwrap();
5142 assert_eq!(result, Some("test_value".to_string()));
5143 });
5144
5145 std::env::remove_var("TEST_PLUGIN_VAR");
5146 }
5147
5148 #[test]
5149 fn test_api_get_config() {
5150 let (mut backend, _rx) = create_test_backend();
5151
5152 backend
5153 .execute_js(
5154 r#"
5155 const editor = getEditor();
5156 const config = editor.getConfig();
5157 globalThis._isObject = typeof config === 'object';
5158 "#,
5159 "test.js",
5160 )
5161 .unwrap();
5162
5163 backend
5164 .plugin_contexts
5165 .borrow()
5166 .get("test")
5167 .unwrap()
5168 .clone()
5169 .with(|ctx| {
5170 let global = ctx.globals();
5171 let is_object: bool = global.get("_isObject").unwrap();
5172 assert!(is_object);
5174 });
5175 }
5176
5177 #[test]
5178 fn test_api_get_themes_dir() {
5179 let (mut backend, _rx) = create_test_backend();
5180
5181 backend
5182 .execute_js(
5183 r#"
5184 const editor = getEditor();
5185 globalThis._themesDir = editor.getThemesDir();
5186 "#,
5187 "test.js",
5188 )
5189 .unwrap();
5190
5191 backend
5192 .plugin_contexts
5193 .borrow()
5194 .get("test")
5195 .unwrap()
5196 .clone()
5197 .with(|ctx| {
5198 let global = ctx.globals();
5199 let result: String = global.get("_themesDir").unwrap();
5200 assert!(!result.is_empty());
5202 });
5203 }
5204
5205 #[test]
5208 fn test_api_read_dir() {
5209 let (mut backend, _rx) = create_test_backend();
5210
5211 backend
5212 .execute_js(
5213 r#"
5214 const editor = getEditor();
5215 const entries = editor.readDir("/tmp");
5216 globalThis._isArray = Array.isArray(entries);
5217 globalThis._length = entries.length;
5218 "#,
5219 "test.js",
5220 )
5221 .unwrap();
5222
5223 backend
5224 .plugin_contexts
5225 .borrow()
5226 .get("test")
5227 .unwrap()
5228 .clone()
5229 .with(|ctx| {
5230 let global = ctx.globals();
5231 let is_array: bool = global.get("_isArray").unwrap();
5232 let length: u32 = global.get("_length").unwrap();
5233 assert!(is_array);
5235 let _ = length;
5237 });
5238 }
5239
5240 #[test]
5243 fn test_api_execute_action() {
5244 let (mut backend, rx) = create_test_backend();
5245
5246 backend
5247 .execute_js(
5248 r#"
5249 const editor = getEditor();
5250 editor.executeAction("move_cursor_up");
5251 "#,
5252 "test.js",
5253 )
5254 .unwrap();
5255
5256 let cmd = rx.try_recv().unwrap();
5257 match cmd {
5258 PluginCommand::ExecuteAction { action_name } => {
5259 assert_eq!(action_name, "move_cursor_up");
5260 }
5261 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5262 }
5263 }
5264
5265 #[test]
5268 fn test_api_debug() {
5269 let (mut backend, _rx) = create_test_backend();
5270
5271 backend
5273 .execute_js(
5274 r#"
5275 const editor = getEditor();
5276 editor.debug("Test debug message");
5277 editor.debug("Another message with special chars: <>&\"'");
5278 "#,
5279 "test.js",
5280 )
5281 .unwrap();
5282 }
5284
5285 #[test]
5288 fn test_typescript_preamble_generated() {
5289 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5291 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5292 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5293 println!(
5294 "Generated {} bytes of TypeScript preamble",
5295 JSEDITORAPI_TS_PREAMBLE.len()
5296 );
5297 }
5298
5299 #[test]
5300 fn test_typescript_editor_api_generated() {
5301 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5303 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5304 println!(
5305 "Generated {} bytes of EditorAPI interface",
5306 JSEDITORAPI_TS_EDITOR_API.len()
5307 );
5308 }
5309
5310 #[test]
5311 fn test_js_methods_list() {
5312 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5314 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5315 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5317 if i < 20 {
5318 println!(" - {}", method);
5319 }
5320 }
5321 if JSEDITORAPI_JS_METHODS.len() > 20 {
5322 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5323 }
5324 }
5325
5326 #[test]
5329 fn test_api_load_plugin_sends_command() {
5330 let (mut backend, rx) = create_test_backend();
5331
5332 backend
5334 .execute_js(
5335 r#"
5336 const editor = getEditor();
5337 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5338 "#,
5339 "test.js",
5340 )
5341 .unwrap();
5342
5343 let cmd = rx.try_recv().unwrap();
5345 match cmd {
5346 PluginCommand::LoadPlugin { path, callback_id } => {
5347 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5348 assert!(callback_id.0 > 0); }
5350 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5351 }
5352 }
5353
5354 #[test]
5355 fn test_api_unload_plugin_sends_command() {
5356 let (mut backend, rx) = create_test_backend();
5357
5358 backend
5360 .execute_js(
5361 r#"
5362 const editor = getEditor();
5363 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5364 "#,
5365 "test.js",
5366 )
5367 .unwrap();
5368
5369 let cmd = rx.try_recv().unwrap();
5371 match cmd {
5372 PluginCommand::UnloadPlugin { name, callback_id } => {
5373 assert_eq!(name, "my-plugin");
5374 assert!(callback_id.0 > 0); }
5376 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5377 }
5378 }
5379
5380 #[test]
5381 fn test_api_reload_plugin_sends_command() {
5382 let (mut backend, rx) = create_test_backend();
5383
5384 backend
5386 .execute_js(
5387 r#"
5388 const editor = getEditor();
5389 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5390 "#,
5391 "test.js",
5392 )
5393 .unwrap();
5394
5395 let cmd = rx.try_recv().unwrap();
5397 match cmd {
5398 PluginCommand::ReloadPlugin { name, callback_id } => {
5399 assert_eq!(name, "my-plugin");
5400 assert!(callback_id.0 > 0); }
5402 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5403 }
5404 }
5405
5406 #[test]
5407 fn test_api_load_plugin_resolves_callback() {
5408 let (mut backend, rx) = create_test_backend();
5409
5410 backend
5412 .execute_js(
5413 r#"
5414 const editor = getEditor();
5415 globalThis._loadResult = null;
5416 editor.loadPlugin("/path/to/plugin.ts").then(result => {
5417 globalThis._loadResult = result;
5418 });
5419 "#,
5420 "test.js",
5421 )
5422 .unwrap();
5423
5424 let callback_id = match rx.try_recv().unwrap() {
5426 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5427 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5428 };
5429
5430 backend.resolve_callback(callback_id, "true");
5432
5433 backend
5435 .plugin_contexts
5436 .borrow()
5437 .get("test")
5438 .unwrap()
5439 .clone()
5440 .with(|ctx| {
5441 run_pending_jobs_checked(&ctx, "test async loadPlugin");
5442 });
5443
5444 backend
5446 .plugin_contexts
5447 .borrow()
5448 .get("test")
5449 .unwrap()
5450 .clone()
5451 .with(|ctx| {
5452 let global = ctx.globals();
5453 let result: bool = global.get("_loadResult").unwrap();
5454 assert!(result);
5455 });
5456 }
5457
5458 #[test]
5459 fn test_api_unload_plugin_rejects_on_error() {
5460 let (mut backend, rx) = create_test_backend();
5461
5462 backend
5464 .execute_js(
5465 r#"
5466 const editor = getEditor();
5467 globalThis._unloadError = null;
5468 editor.unloadPlugin("nonexistent-plugin").catch(err => {
5469 globalThis._unloadError = err.message || String(err);
5470 });
5471 "#,
5472 "test.js",
5473 )
5474 .unwrap();
5475
5476 let callback_id = match rx.try_recv().unwrap() {
5478 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5479 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5480 };
5481
5482 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5484
5485 backend
5487 .plugin_contexts
5488 .borrow()
5489 .get("test")
5490 .unwrap()
5491 .clone()
5492 .with(|ctx| {
5493 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5494 });
5495
5496 backend
5498 .plugin_contexts
5499 .borrow()
5500 .get("test")
5501 .unwrap()
5502 .clone()
5503 .with(|ctx| {
5504 let global = ctx.globals();
5505 let error: String = global.get("_unloadError").unwrap();
5506 assert!(error.contains("nonexistent-plugin"));
5507 });
5508 }
5509}