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 copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
113 std::fs::create_dir_all(dst)?;
114 for entry in std::fs::read_dir(src)? {
115 let entry = entry?;
116 let file_type = entry.file_type()?;
117 let src_path = entry.path();
118 let dst_path = dst.join(entry.file_name());
119 if file_type.is_dir() {
120 copy_dir_recursive(&src_path, &dst_path)?;
121 } else {
122 std::fs::copy(&src_path, &dst_path)?;
123 }
124 }
125 Ok(())
126}
127
128fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
130 use rquickjs::Type;
131 match val.type_of() {
132 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
133 Type::Bool => val
134 .as_bool()
135 .map(serde_json::Value::Bool)
136 .unwrap_or(serde_json::Value::Null),
137 Type::Int => val
138 .as_int()
139 .map(|n| serde_json::Value::Number(n.into()))
140 .unwrap_or(serde_json::Value::Null),
141 Type::Float => val
142 .as_float()
143 .map(|f| {
144 if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
147 serde_json::Value::Number((f as i64).into())
148 } else {
149 serde_json::Number::from_f64(f)
150 .map(serde_json::Value::Number)
151 .unwrap_or(serde_json::Value::Null)
152 }
153 })
154 .unwrap_or(serde_json::Value::Null),
155 Type::String => val
156 .as_string()
157 .and_then(|s| s.to_string().ok())
158 .map(serde_json::Value::String)
159 .unwrap_or(serde_json::Value::Null),
160 Type::Array => {
161 if let Some(arr) = val.as_array() {
162 let items: Vec<serde_json::Value> = arr
163 .iter()
164 .filter_map(|item| item.ok())
165 .map(|item| js_to_json(ctx, item))
166 .collect();
167 serde_json::Value::Array(items)
168 } else {
169 serde_json::Value::Null
170 }
171 }
172 Type::Object | Type::Constructor | Type::Function => {
173 if let Some(obj) = val.as_object() {
174 let mut map = serde_json::Map::new();
175 for key in obj.keys::<String>().flatten() {
176 if let Ok(v) = obj.get::<_, Value>(&key) {
177 map.insert(key, js_to_json(ctx, v));
178 }
179 }
180 serde_json::Value::Object(map)
181 } else {
182 serde_json::Value::Null
183 }
184 }
185 _ => serde_json::Value::Null,
186 }
187}
188
189fn json_to_js_value<'js>(
191 ctx: &rquickjs::Ctx<'js>,
192 val: &serde_json::Value,
193) -> rquickjs::Result<Value<'js>> {
194 match val {
195 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
196 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
197 serde_json::Value::Number(n) => {
198 if let Some(i) = n.as_i64() {
199 Ok(Value::new_int(ctx.clone(), i as i32))
200 } else if let Some(f) = n.as_f64() {
201 Ok(Value::new_float(ctx.clone(), f))
202 } else {
203 Ok(Value::new_null(ctx.clone()))
204 }
205 }
206 serde_json::Value::String(s) => {
207 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
208 Ok(js_str.into_value())
209 }
210 serde_json::Value::Array(arr) => {
211 let js_arr = rquickjs::Array::new(ctx.clone())?;
212 for (i, item) in arr.iter().enumerate() {
213 let js_val = json_to_js_value(ctx, item)?;
214 js_arr.set(i, js_val)?;
215 }
216 Ok(js_arr.into_value())
217 }
218 serde_json::Value::Object(map) => {
219 let obj = rquickjs::Object::new(ctx.clone())?;
220 for (key, val) in map {
221 let js_val = json_to_js_value(ctx, val)?;
222 obj.set(key.as_str(), js_val)?;
223 }
224 Ok(obj.into_value())
225 }
226 }
227}
228
229fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
232 let js_data = match json_to_js_value(ctx, event_data) {
233 Ok(v) => v,
234 Err(e) => {
235 log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
236 return;
237 }
238 };
239
240 let globals = ctx.globals();
241 let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
242 return;
243 };
244
245 match func.call::<_, rquickjs::Value>((js_data,)) {
246 Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
247 Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
248 }
249
250 run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
251}
252
253fn attach_promise_catch<'js>(
255 ctx: &rquickjs::Ctx<'js>,
256 globals: &rquickjs::Object<'js>,
257 handler_name: &str,
258 result: rquickjs::Value<'js>,
259) {
260 let Some(obj) = result.as_object() else {
261 return;
262 };
263 if obj.get::<_, rquickjs::Function>("then").is_err() {
264 return;
265 }
266 let _ = globals.set("__pendingPromise", result);
267 let catch_code = format!(
268 r#"globalThis.__pendingPromise.catch(function(e) {{
269 console.error('Handler {} async error:', e);
270 throw e;
271 }}); delete globalThis.__pendingPromise;"#,
272 handler_name
273 );
274 let _ = ctx.eval::<(), _>(catch_code.as_bytes());
275}
276
277fn get_text_properties_at_cursor_typed(
279 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
280 buffer_id: u32,
281) -> fresh_core::api::TextPropertiesAtCursor {
282 use fresh_core::api::TextPropertiesAtCursor;
283
284 let snap = match snapshot.read() {
285 Ok(s) => s,
286 Err(_) => return TextPropertiesAtCursor(Vec::new()),
287 };
288 let buffer_id_typed = BufferId(buffer_id as usize);
289 let cursor_pos = match snap
290 .buffer_cursor_positions
291 .get(&buffer_id_typed)
292 .copied()
293 .or_else(|| {
294 if snap.active_buffer_id == buffer_id_typed {
295 snap.primary_cursor.as_ref().map(|c| c.position)
296 } else {
297 None
298 }
299 }) {
300 Some(pos) => pos,
301 None => return TextPropertiesAtCursor(Vec::new()),
302 };
303
304 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
305 Some(p) => p,
306 None => return TextPropertiesAtCursor(Vec::new()),
307 };
308
309 let result: Vec<_> = properties
311 .iter()
312 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
313 .map(|prop| prop.properties.clone())
314 .collect();
315
316 TextPropertiesAtCursor(result)
317}
318
319fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
321 use rquickjs::Type;
322 match val.type_of() {
323 Type::Null => "null".to_string(),
324 Type::Undefined => "undefined".to_string(),
325 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
326 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
327 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
328 Type::String => val
329 .as_string()
330 .and_then(|s| s.to_string().ok())
331 .unwrap_or_default(),
332 Type::Object | Type::Exception => {
333 if let Some(obj) = val.as_object() {
335 let name: Option<String> = obj.get("name").ok();
337 let message: Option<String> = obj.get("message").ok();
338 let stack: Option<String> = obj.get("stack").ok();
339
340 if message.is_some() || name.is_some() {
341 let name = name.unwrap_or_else(|| "Error".to_string());
343 let message = message.unwrap_or_default();
344 if let Some(stack) = stack {
345 return format!("{}: {}\n{}", name, message, stack);
346 } else {
347 return format!("{}: {}", name, message);
348 }
349 }
350
351 let json = js_to_json(ctx, val.clone());
353 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
354 } else {
355 "[object]".to_string()
356 }
357 }
358 Type::Array => {
359 let json = js_to_json(ctx, val.clone());
360 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
361 }
362 Type::Function | Type::Constructor => "[function]".to_string(),
363 Type::Symbol => "[symbol]".to_string(),
364 Type::BigInt => val
365 .as_big_int()
366 .and_then(|b| b.clone().to_i64().ok())
367 .map(|n| n.to_string())
368 .unwrap_or_else(|| "[bigint]".to_string()),
369 _ => format!("[{}]", val.type_name()),
370 }
371}
372
373fn format_js_error(
375 ctx: &rquickjs::Ctx<'_>,
376 err: rquickjs::Error,
377 source_name: &str,
378) -> anyhow::Error {
379 if err.is_exception() {
381 let exc = ctx.catch();
383 if !exc.is_undefined() && !exc.is_null() {
384 if let Some(exc_obj) = exc.as_object() {
386 let message: String = exc_obj
387 .get::<_, String>("message")
388 .unwrap_or_else(|_| "Unknown error".to_string());
389 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
390 let name: String = exc_obj
391 .get::<_, String>("name")
392 .unwrap_or_else(|_| "Error".to_string());
393
394 if !stack.is_empty() {
395 return anyhow::anyhow!(
396 "JS error in {}: {}: {}\nStack trace:\n{}",
397 source_name,
398 name,
399 message,
400 stack
401 );
402 } else {
403 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
404 }
405 } else {
406 let exc_str: String = exc
408 .as_string()
409 .and_then(|s: &rquickjs::String| s.to_string().ok())
410 .unwrap_or_else(|| format!("{:?}", exc));
411 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
412 }
413 }
414 }
415
416 anyhow::anyhow!("JS error in {}: {}", source_name, err)
418}
419
420fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
423 let error = format_js_error(ctx, err, context);
424 tracing::error!("{}", error);
425
426 if should_panic_on_js_errors() {
428 panic!("JavaScript error in {}: {}", context, error);
429 }
430}
431
432static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
434 std::sync::atomic::AtomicBool::new(false);
435
436pub fn set_panic_on_js_errors(enabled: bool) {
438 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
439}
440
441fn should_panic_on_js_errors() -> bool {
443 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
444}
445
446static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
450
451static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
453
454fn set_fatal_js_error(msg: String) {
456 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
457 if guard.is_none() {
458 *guard = Some(msg);
460 }
461 }
462 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
463}
464
465pub fn has_fatal_js_error() -> bool {
467 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
468}
469
470pub fn take_fatal_js_error() -> Option<String> {
472 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
473 return None;
474 }
475 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
476 guard.take()
477 } else {
478 Some("Fatal JS error (message unavailable)".to_string())
479 }
480}
481
482fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
485 let mut count = 0;
486 loop {
487 let exc: rquickjs::Value = ctx.catch();
489 if exc.is_exception() {
491 let error_msg = if let Some(err) = exc.as_exception() {
492 format!(
493 "{}: {}",
494 err.message().unwrap_or_default(),
495 err.stack().unwrap_or_default()
496 )
497 } else {
498 format!("{:?}", exc)
499 };
500 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
501 if should_panic_on_js_errors() {
502 panic!("Unhandled JS exception during {}: {}", context, error_msg);
503 }
504 }
505
506 if !ctx.execute_pending_job() {
507 break;
508 }
509 count += 1;
510 }
511
512 let exc: rquickjs::Value = ctx.catch();
514 if exc.is_exception() {
515 let error_msg = if let Some(err) = exc.as_exception() {
516 format!(
517 "{}: {}",
518 err.message().unwrap_or_default(),
519 err.stack().unwrap_or_default()
520 )
521 } else {
522 format!("{:?}", exc)
523 };
524 tracing::error!(
525 "Unhandled JS exception after running jobs in {}: {}",
526 context,
527 error_msg
528 );
529 if should_panic_on_js_errors() {
530 panic!(
531 "Unhandled JS exception after running jobs in {}: {}",
532 context, error_msg
533 );
534 }
535 }
536
537 count
538}
539
540fn parse_text_property_entry(
542 ctx: &rquickjs::Ctx<'_>,
543 obj: &Object<'_>,
544) -> Option<TextPropertyEntry> {
545 let text: String = obj.get("text").ok()?;
546 let properties: HashMap<String, serde_json::Value> = obj
547 .get::<_, Object>("properties")
548 .ok()
549 .map(|props_obj| {
550 let mut map = HashMap::new();
551 for key in props_obj.keys::<String>().flatten() {
552 if let Ok(v) = props_obj.get::<_, Value>(&key) {
553 map.insert(key, js_to_json(ctx, v));
554 }
555 }
556 map
557 })
558 .unwrap_or_default();
559
560 let style: Option<fresh_core::api::OverlayOptions> =
562 obj.get::<_, Object>("style").ok().and_then(|style_obj| {
563 let json_val = js_to_json(ctx, Value::from_object(style_obj));
564 serde_json::from_value(json_val).ok()
565 });
566
567 let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
569 .get::<_, rquickjs::Array>("inlineOverlays")
570 .ok()
571 .map(|arr| {
572 arr.iter::<Object>()
573 .flatten()
574 .filter_map(|item| {
575 let json_val = js_to_json(ctx, Value::from_object(item));
576 serde_json::from_value(json_val).ok()
577 })
578 .collect()
579 })
580 .unwrap_or_default();
581
582 Some(TextPropertyEntry {
583 text,
584 properties,
585 style,
586 inline_overlays,
587 })
588}
589
590pub type PendingResponses =
592 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
593
594#[derive(Debug, Clone)]
596pub struct TsPluginInfo {
597 pub name: String,
598 pub path: PathBuf,
599 pub enabled: bool,
600}
601
602#[derive(Debug, Clone, Default)]
608pub struct PluginTrackedState {
609 pub overlay_namespaces: Vec<(BufferId, String)>,
611 pub virtual_line_namespaces: Vec<(BufferId, String)>,
613 pub line_indicator_namespaces: Vec<(BufferId, String)>,
615 pub virtual_text_ids: Vec<(BufferId, String)>,
617 pub file_explorer_namespaces: Vec<String>,
619 pub contexts_set: Vec<String>,
621 pub background_process_ids: Vec<u64>,
624 pub scroll_sync_group_ids: Vec<u32>,
626 pub virtual_buffer_ids: Vec<BufferId>,
628 pub composite_buffer_ids: Vec<BufferId>,
630 pub terminal_ids: Vec<fresh_core::TerminalId>,
632}
633
634pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
639
640#[derive(Debug, Clone)]
641pub struct PluginHandler {
642 pub plugin_name: String,
643 pub handler_name: String,
644}
645
646#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
649#[rquickjs::class]
650pub struct JsEditorApi {
651 #[qjs(skip_trace)]
652 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
653 #[qjs(skip_trace)]
654 command_sender: mpsc::Sender<PluginCommand>,
655 #[qjs(skip_trace)]
656 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
657 #[qjs(skip_trace)]
658 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
659 #[qjs(skip_trace)]
660 next_request_id: Rc<RefCell<u64>>,
661 #[qjs(skip_trace)]
662 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
663 #[qjs(skip_trace)]
664 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
665 #[qjs(skip_trace)]
666 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
667 #[qjs(skip_trace)]
668 async_resource_owners: AsyncResourceOwners,
669 pub plugin_name: String,
670}
671
672#[plugin_api_impl]
673#[rquickjs::methods(rename_all = "camelCase")]
674impl JsEditorApi {
675 pub fn api_version(&self) -> u32 {
680 2
681 }
682
683 pub fn get_active_buffer_id(&self) -> u32 {
685 self.state_snapshot
686 .read()
687 .map(|s| s.active_buffer_id.0 as u32)
688 .unwrap_or(0)
689 }
690
691 pub fn get_active_split_id(&self) -> u32 {
693 self.state_snapshot
694 .read()
695 .map(|s| s.active_split_id as u32)
696 .unwrap_or(0)
697 }
698
699 #[plugin_api(ts_return = "BufferInfo[]")]
701 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
702 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
703 s.buffers.values().cloned().collect()
704 } else {
705 Vec::new()
706 };
707 rquickjs_serde::to_value(ctx, &buffers)
708 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
709 }
710
711 pub fn debug(&self, msg: String) {
714 tracing::trace!("Plugin.debug: {}", msg);
715 }
716
717 pub fn info(&self, msg: String) {
718 tracing::info!("Plugin: {}", msg);
719 }
720
721 pub fn warn(&self, msg: String) {
722 tracing::warn!("Plugin: {}", msg);
723 }
724
725 pub fn error(&self, msg: String) {
726 tracing::error!("Plugin: {}", msg);
727 }
728
729 pub fn set_status(&self, msg: String) {
732 let _ = self
733 .command_sender
734 .send(PluginCommand::SetStatus { message: msg });
735 }
736
737 pub fn copy_to_clipboard(&self, text: String) {
740 let _ = self
741 .command_sender
742 .send(PluginCommand::SetClipboard { text });
743 }
744
745 pub fn set_clipboard(&self, text: String) {
746 let _ = self
747 .command_sender
748 .send(PluginCommand::SetClipboard { text });
749 }
750
751 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
756 if let Some(mode_name) = mode {
757 let key = format!("{}\0{}", action, mode_name);
758 if let Ok(snapshot) = self.state_snapshot.read() {
759 return snapshot.keybinding_labels.get(&key).cloned();
760 }
761 }
762 None
763 }
764
765 pub fn register_command<'js>(
776 &self,
777 _ctx: rquickjs::Ctx<'js>,
778 name: String,
779 description: String,
780 handler_name: String,
781 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
782 rquickjs::Value<'js>,
783 >,
784 ) -> rquickjs::Result<bool> {
785 let plugin_name = self.plugin_name.clone();
787 let context_str: Option<String> = context.0.and_then(|v| {
789 if v.is_null() || v.is_undefined() {
790 None
791 } else {
792 v.as_string().and_then(|s| s.to_string().ok())
793 }
794 });
795
796 tracing::debug!(
797 "registerCommand: plugin='{}', name='{}', handler='{}'",
798 plugin_name,
799 name,
800 handler_name
801 );
802
803 self.registered_actions.borrow_mut().insert(
805 handler_name.clone(),
806 PluginHandler {
807 plugin_name: self.plugin_name.clone(),
808 handler_name: handler_name.clone(),
809 },
810 );
811
812 let command = Command {
814 name: name.clone(),
815 description,
816 action_name: handler_name,
817 plugin_name,
818 custom_contexts: context_str.into_iter().collect(),
819 };
820
821 Ok(self
822 .command_sender
823 .send(PluginCommand::RegisterCommand { command })
824 .is_ok())
825 }
826
827 pub fn unregister_command(&self, name: String) -> bool {
829 self.command_sender
830 .send(PluginCommand::UnregisterCommand { name })
831 .is_ok()
832 }
833
834 pub fn set_context(&self, name: String, active: bool) -> bool {
836 if active {
838 self.plugin_tracked_state
839 .borrow_mut()
840 .entry(self.plugin_name.clone())
841 .or_default()
842 .contexts_set
843 .push(name.clone());
844 }
845 self.command_sender
846 .send(PluginCommand::SetContext { name, active })
847 .is_ok()
848 }
849
850 pub fn execute_action(&self, action_name: String) -> bool {
852 self.command_sender
853 .send(PluginCommand::ExecuteAction { action_name })
854 .is_ok()
855 }
856
857 pub fn t<'js>(
862 &self,
863 _ctx: rquickjs::Ctx<'js>,
864 key: String,
865 args: rquickjs::function::Rest<Value<'js>>,
866 ) -> String {
867 let plugin_name = self.plugin_name.clone();
869 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
871 if let Some(obj) = first_arg.as_object() {
872 let mut map = HashMap::new();
873 for k in obj.keys::<String>().flatten() {
874 if let Ok(v) = obj.get::<_, String>(&k) {
875 map.insert(k, v);
876 }
877 }
878 map
879 } else {
880 HashMap::new()
881 }
882 } else {
883 HashMap::new()
884 };
885 let res = self.services.translate(&plugin_name, &key, &args_map);
886
887 tracing::info!(
888 "Translating: key={}, plugin={}, args={:?} => res='{}'",
889 key,
890 plugin_name,
891 args_map,
892 res
893 );
894 res
895 }
896
897 pub fn get_cursor_position(&self) -> u32 {
901 self.state_snapshot
902 .read()
903 .ok()
904 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
905 .unwrap_or(0)
906 }
907
908 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
910 if let Ok(s) = self.state_snapshot.read() {
911 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
912 if let Some(p) = &b.path {
913 return p.to_string_lossy().to_string();
914 }
915 }
916 }
917 String::new()
918 }
919
920 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
922 if let Ok(s) = self.state_snapshot.read() {
923 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
924 return b.length as u32;
925 }
926 }
927 0
928 }
929
930 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
932 if let Ok(s) = self.state_snapshot.read() {
933 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
934 return b.modified;
935 }
936 }
937 false
938 }
939
940 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
943 self.command_sender
944 .send(PluginCommand::SaveBufferToPath {
945 buffer_id: BufferId(buffer_id as usize),
946 path: std::path::PathBuf::from(path),
947 })
948 .is_ok()
949 }
950
951 #[plugin_api(ts_return = "BufferInfo | null")]
953 pub fn get_buffer_info<'js>(
954 &self,
955 ctx: rquickjs::Ctx<'js>,
956 buffer_id: u32,
957 ) -> rquickjs::Result<Value<'js>> {
958 let info = if let Ok(s) = self.state_snapshot.read() {
959 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
960 } else {
961 None
962 };
963 rquickjs_serde::to_value(ctx, &info)
964 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
965 }
966
967 #[plugin_api(ts_return = "CursorInfo | null")]
969 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
970 let cursor = if let Ok(s) = self.state_snapshot.read() {
971 s.primary_cursor.clone()
972 } else {
973 None
974 };
975 rquickjs_serde::to_value(ctx, &cursor)
976 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
977 }
978
979 #[plugin_api(ts_return = "CursorInfo[]")]
981 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
982 let cursors = if let Ok(s) = self.state_snapshot.read() {
983 s.all_cursors.clone()
984 } else {
985 Vec::new()
986 };
987 rquickjs_serde::to_value(ctx, &cursors)
988 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
989 }
990
991 #[plugin_api(ts_return = "number[]")]
993 pub fn get_all_cursor_positions<'js>(
994 &self,
995 ctx: rquickjs::Ctx<'js>,
996 ) -> rquickjs::Result<Value<'js>> {
997 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
998 s.all_cursors.iter().map(|c| c.position as u32).collect()
999 } else {
1000 Vec::new()
1001 };
1002 rquickjs_serde::to_value(ctx, &positions)
1003 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1004 }
1005
1006 #[plugin_api(ts_return = "ViewportInfo | null")]
1008 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1009 let viewport = if let Ok(s) = self.state_snapshot.read() {
1010 s.viewport.clone()
1011 } else {
1012 None
1013 };
1014 rquickjs_serde::to_value(ctx, &viewport)
1015 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1016 }
1017
1018 pub fn get_cursor_line(&self) -> u32 {
1020 0
1024 }
1025
1026 #[plugin_api(
1029 async_promise,
1030 js_name = "getLineStartPosition",
1031 ts_return = "number | null"
1032 )]
1033 #[qjs(rename = "_getLineStartPositionStart")]
1034 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1035 let id = {
1036 let mut id_ref = self.next_request_id.borrow_mut();
1037 let id = *id_ref;
1038 *id_ref += 1;
1039 self.callback_contexts
1041 .borrow_mut()
1042 .insert(id, self.plugin_name.clone());
1043 id
1044 };
1045 let _ = self
1047 .command_sender
1048 .send(PluginCommand::GetLineStartPosition {
1049 buffer_id: BufferId(0),
1050 line,
1051 request_id: id,
1052 });
1053 id
1054 }
1055
1056 #[plugin_api(
1060 async_promise,
1061 js_name = "getLineEndPosition",
1062 ts_return = "number | null"
1063 )]
1064 #[qjs(rename = "_getLineEndPositionStart")]
1065 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1066 let id = {
1067 let mut id_ref = self.next_request_id.borrow_mut();
1068 let id = *id_ref;
1069 *id_ref += 1;
1070 self.callback_contexts
1071 .borrow_mut()
1072 .insert(id, self.plugin_name.clone());
1073 id
1074 };
1075 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1077 buffer_id: BufferId(0),
1078 line,
1079 request_id: id,
1080 });
1081 id
1082 }
1083
1084 #[plugin_api(
1087 async_promise,
1088 js_name = "getBufferLineCount",
1089 ts_return = "number | null"
1090 )]
1091 #[qjs(rename = "_getBufferLineCountStart")]
1092 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1093 let id = {
1094 let mut id_ref = self.next_request_id.borrow_mut();
1095 let id = *id_ref;
1096 *id_ref += 1;
1097 self.callback_contexts
1098 .borrow_mut()
1099 .insert(id, self.plugin_name.clone());
1100 id
1101 };
1102 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1104 buffer_id: BufferId(0),
1105 request_id: id,
1106 });
1107 id
1108 }
1109
1110 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1113 self.command_sender
1114 .send(PluginCommand::ScrollToLineCenter {
1115 split_id: SplitId(split_id as usize),
1116 buffer_id: BufferId(buffer_id as usize),
1117 line: line as usize,
1118 })
1119 .is_ok()
1120 }
1121
1122 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1124 let path_buf = std::path::PathBuf::from(&path);
1125 if let Ok(s) = self.state_snapshot.read() {
1126 for (id, info) in &s.buffers {
1127 if let Some(buf_path) = &info.path {
1128 if buf_path == &path_buf {
1129 return id.0 as u32;
1130 }
1131 }
1132 }
1133 }
1134 0
1135 }
1136
1137 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1139 pub fn get_buffer_saved_diff<'js>(
1140 &self,
1141 ctx: rquickjs::Ctx<'js>,
1142 buffer_id: u32,
1143 ) -> rquickjs::Result<Value<'js>> {
1144 let diff = if let Ok(s) = self.state_snapshot.read() {
1145 s.buffer_saved_diffs
1146 .get(&BufferId(buffer_id as usize))
1147 .cloned()
1148 } else {
1149 None
1150 };
1151 rquickjs_serde::to_value(ctx, &diff)
1152 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1153 }
1154
1155 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1159 self.command_sender
1160 .send(PluginCommand::InsertText {
1161 buffer_id: BufferId(buffer_id as usize),
1162 position: position as usize,
1163 text,
1164 })
1165 .is_ok()
1166 }
1167
1168 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1170 self.command_sender
1171 .send(PluginCommand::DeleteRange {
1172 buffer_id: BufferId(buffer_id as usize),
1173 range: (start as usize)..(end as usize),
1174 })
1175 .is_ok()
1176 }
1177
1178 pub fn insert_at_cursor(&self, text: String) -> bool {
1180 self.command_sender
1181 .send(PluginCommand::InsertAtCursor { text })
1182 .is_ok()
1183 }
1184
1185 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1189 self.command_sender
1190 .send(PluginCommand::OpenFileAtLocation {
1191 path: PathBuf::from(path),
1192 line: line.map(|l| l as usize),
1193 column: column.map(|c| c as usize),
1194 })
1195 .is_ok()
1196 }
1197
1198 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1200 self.command_sender
1201 .send(PluginCommand::OpenFileInSplit {
1202 split_id: split_id as usize,
1203 path: PathBuf::from(path),
1204 line: Some(line as usize),
1205 column: Some(column as usize),
1206 })
1207 .is_ok()
1208 }
1209
1210 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1212 self.command_sender
1213 .send(PluginCommand::ShowBuffer {
1214 buffer_id: BufferId(buffer_id as usize),
1215 })
1216 .is_ok()
1217 }
1218
1219 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1221 self.command_sender
1222 .send(PluginCommand::CloseBuffer {
1223 buffer_id: BufferId(buffer_id as usize),
1224 })
1225 .is_ok()
1226 }
1227
1228 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1232 if event_name == "lines_changed" {
1236 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1237 }
1238 self.event_handlers
1239 .borrow_mut()
1240 .entry(event_name)
1241 .or_default()
1242 .push(PluginHandler {
1243 plugin_name: self.plugin_name.clone(),
1244 handler_name,
1245 });
1246 }
1247
1248 pub fn off(&self, event_name: String, handler_name: String) {
1250 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1251 list.retain(|h| h.handler_name != handler_name);
1252 }
1253 }
1254
1255 pub fn get_env(&self, name: String) -> Option<String> {
1259 std::env::var(&name).ok()
1260 }
1261
1262 pub fn get_cwd(&self) -> String {
1264 self.state_snapshot
1265 .read()
1266 .map(|s| s.working_dir.to_string_lossy().to_string())
1267 .unwrap_or_else(|_| ".".to_string())
1268 }
1269
1270 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1275 let mut result_parts: Vec<String> = Vec::new();
1276 let mut has_leading_slash = false;
1277
1278 for part in &parts.0 {
1279 let normalized = part.replace('\\', "/");
1281
1282 let is_absolute = normalized.starts_with('/')
1284 || (normalized.len() >= 2
1285 && normalized
1286 .chars()
1287 .next()
1288 .map(|c| c.is_ascii_alphabetic())
1289 .unwrap_or(false)
1290 && normalized.chars().nth(1) == Some(':'));
1291
1292 if is_absolute {
1293 result_parts.clear();
1295 has_leading_slash = normalized.starts_with('/');
1296 }
1297
1298 for segment in normalized.split('/') {
1300 if !segment.is_empty() && segment != "." {
1301 if segment == ".." {
1302 result_parts.pop();
1303 } else {
1304 result_parts.push(segment.to_string());
1305 }
1306 }
1307 }
1308 }
1309
1310 let joined = result_parts.join("/");
1312
1313 if has_leading_slash && !joined.is_empty() {
1315 format!("/{}", joined)
1316 } else {
1317 joined
1318 }
1319 }
1320
1321 pub fn path_dirname(&self, path: String) -> String {
1323 Path::new(&path)
1324 .parent()
1325 .map(|p| p.to_string_lossy().to_string())
1326 .unwrap_or_default()
1327 }
1328
1329 pub fn path_basename(&self, path: String) -> String {
1331 Path::new(&path)
1332 .file_name()
1333 .map(|s| s.to_string_lossy().to_string())
1334 .unwrap_or_default()
1335 }
1336
1337 pub fn path_extname(&self, path: String) -> String {
1339 Path::new(&path)
1340 .extension()
1341 .map(|s| format!(".{}", s.to_string_lossy()))
1342 .unwrap_or_default()
1343 }
1344
1345 pub fn path_is_absolute(&self, path: String) -> bool {
1347 Path::new(&path).is_absolute()
1348 }
1349
1350 pub fn file_uri_to_path(&self, uri: String) -> String {
1354 url::Url::parse(&uri)
1355 .ok()
1356 .and_then(|u| u.to_file_path().ok())
1357 .map(|p| p.to_string_lossy().to_string())
1358 .unwrap_or_default()
1359 }
1360
1361 pub fn path_to_file_uri(&self, path: String) -> String {
1365 url::Url::from_file_path(&path)
1366 .map(|u| u.to_string())
1367 .unwrap_or_default()
1368 }
1369
1370 pub fn utf8_byte_length(&self, text: String) -> u32 {
1378 text.len() as u32
1379 }
1380
1381 pub fn file_exists(&self, path: String) -> bool {
1385 Path::new(&path).exists()
1386 }
1387
1388 pub fn read_file(&self, path: String) -> Option<String> {
1390 std::fs::read_to_string(&path).ok()
1391 }
1392
1393 pub fn write_file(&self, path: String, content: String) -> bool {
1395 let p = Path::new(&path);
1396 if let Some(parent) = p.parent() {
1397 if !parent.exists() {
1398 if std::fs::create_dir_all(parent).is_err() {
1399 return false;
1400 }
1401 }
1402 }
1403 std::fs::write(p, content).is_ok()
1404 }
1405
1406 #[plugin_api(ts_return = "DirEntry[]")]
1408 pub fn read_dir<'js>(
1409 &self,
1410 ctx: rquickjs::Ctx<'js>,
1411 path: String,
1412 ) -> rquickjs::Result<Value<'js>> {
1413 use fresh_core::api::DirEntry;
1414
1415 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1416 Ok(entries) => entries
1417 .filter_map(|e| e.ok())
1418 .map(|entry| {
1419 let file_type = entry.file_type().ok();
1420 DirEntry {
1421 name: entry.file_name().to_string_lossy().to_string(),
1422 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1423 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1424 }
1425 })
1426 .collect(),
1427 Err(e) => {
1428 tracing::warn!("readDir failed for '{}': {}", path, e);
1429 Vec::new()
1430 }
1431 };
1432
1433 rquickjs_serde::to_value(ctx, &entries)
1434 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1435 }
1436
1437 pub fn create_dir(&self, path: String) -> bool {
1440 let p = Path::new(&path);
1441 if p.is_dir() {
1442 return true;
1443 }
1444 std::fs::create_dir_all(p).is_ok()
1445 }
1446
1447 pub fn remove_path(&self, path: String) -> bool {
1451 let target = match Path::new(&path).canonicalize() {
1452 Ok(p) => p,
1453 Err(_) => return false, };
1455
1456 let temp_dir = std::env::temp_dir()
1462 .canonicalize()
1463 .unwrap_or_else(|_| std::env::temp_dir());
1464 let config_dir = self
1465 .services
1466 .config_dir()
1467 .canonicalize()
1468 .unwrap_or_else(|_| self.services.config_dir());
1469
1470 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1472 if !allowed {
1473 tracing::warn!(
1474 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1475 target,
1476 temp_dir,
1477 config_dir
1478 );
1479 return false;
1480 }
1481
1482 if target == temp_dir || target == config_dir {
1484 tracing::warn!(
1485 "removePath refused: cannot remove root directory {:?}",
1486 target
1487 );
1488 return false;
1489 }
1490
1491 match trash::delete(&target) {
1492 Ok(()) => true,
1493 Err(e) => {
1494 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1495 false
1496 }
1497 }
1498 }
1499
1500 pub fn rename_path(&self, from: String, to: String) -> bool {
1503 if std::fs::rename(&from, &to).is_ok() {
1505 return true;
1506 }
1507 let from_path = Path::new(&from);
1509 let copied = if from_path.is_dir() {
1510 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1511 } else {
1512 std::fs::copy(&from, &to).is_ok()
1513 };
1514 if copied {
1515 return trash::delete(from_path).is_ok();
1516 }
1517 false
1518 }
1519
1520 pub fn copy_path(&self, from: String, to: String) -> bool {
1523 let from_path = Path::new(&from);
1524 let to_path = Path::new(&to);
1525 if from_path.is_dir() {
1526 copy_dir_recursive(from_path, to_path).is_ok()
1527 } else {
1528 if let Some(parent) = to_path.parent() {
1530 if !parent.exists() {
1531 if std::fs::create_dir_all(parent).is_err() {
1532 return false;
1533 }
1534 }
1535 }
1536 std::fs::copy(from_path, to_path).is_ok()
1537 }
1538 }
1539
1540 pub fn get_temp_dir(&self) -> String {
1542 std::env::temp_dir().to_string_lossy().to_string()
1543 }
1544
1545 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1549 let config: serde_json::Value = self
1550 .state_snapshot
1551 .read()
1552 .map(|s| s.config.clone())
1553 .unwrap_or_else(|_| serde_json::json!({}));
1554
1555 rquickjs_serde::to_value(ctx, &config)
1556 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1557 }
1558
1559 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1561 let config: serde_json::Value = self
1562 .state_snapshot
1563 .read()
1564 .map(|s| s.user_config.clone())
1565 .unwrap_or_else(|_| serde_json::json!({}));
1566
1567 rquickjs_serde::to_value(ctx, &config)
1568 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1569 }
1570
1571 pub fn reload_config(&self) {
1573 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1574 }
1575
1576 pub fn reload_themes(&self) {
1579 let _ = self
1580 .command_sender
1581 .send(PluginCommand::ReloadThemes { apply_theme: None });
1582 }
1583
1584 pub fn reload_and_apply_theme(&self, theme_name: String) {
1586 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1587 apply_theme: Some(theme_name),
1588 });
1589 }
1590
1591 pub fn register_grammar(
1594 &self,
1595 language: String,
1596 grammar_path: String,
1597 extensions: Vec<String>,
1598 ) -> bool {
1599 self.command_sender
1600 .send(PluginCommand::RegisterGrammar {
1601 language,
1602 grammar_path,
1603 extensions,
1604 })
1605 .is_ok()
1606 }
1607
1608 pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1610 self.command_sender
1611 .send(PluginCommand::RegisterLanguageConfig { language, config })
1612 .is_ok()
1613 }
1614
1615 pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1617 self.command_sender
1618 .send(PluginCommand::RegisterLspServer { language, config })
1619 .is_ok()
1620 }
1621
1622 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1626 #[qjs(rename = "_reloadGrammarsStart")]
1627 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1628 let id = {
1629 let mut id_ref = self.next_request_id.borrow_mut();
1630 let id = *id_ref;
1631 *id_ref += 1;
1632 self.callback_contexts
1633 .borrow_mut()
1634 .insert(id, self.plugin_name.clone());
1635 id
1636 };
1637 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1638 callback_id: fresh_core::api::JsCallbackId::new(id),
1639 });
1640 id
1641 }
1642
1643 pub fn get_config_dir(&self) -> String {
1645 self.services.config_dir().to_string_lossy().to_string()
1646 }
1647
1648 pub fn get_themes_dir(&self) -> String {
1650 self.services
1651 .config_dir()
1652 .join("themes")
1653 .to_string_lossy()
1654 .to_string()
1655 }
1656
1657 pub fn apply_theme(&self, theme_name: String) -> bool {
1659 self.command_sender
1660 .send(PluginCommand::ApplyTheme { theme_name })
1661 .is_ok()
1662 }
1663
1664 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1666 let schema = self.services.get_theme_schema();
1667 rquickjs_serde::to_value(ctx, &schema)
1668 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1669 }
1670
1671 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1673 let themes = self.services.get_builtin_themes();
1674 rquickjs_serde::to_value(ctx, &themes)
1675 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1676 }
1677
1678 #[qjs(rename = "_deleteThemeSync")]
1680 pub fn delete_theme_sync(&self, name: String) -> bool {
1681 let themes_dir = self.services.config_dir().join("themes");
1683 let theme_path = themes_dir.join(format!("{}.json", name));
1684
1685 if let Ok(canonical) = theme_path.canonicalize() {
1687 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1688 if canonical.starts_with(&themes_canonical) {
1689 return std::fs::remove_file(&canonical).is_ok();
1690 }
1691 }
1692 }
1693 false
1694 }
1695
1696 pub fn delete_theme(&self, name: String) -> bool {
1698 self.delete_theme_sync(name)
1699 }
1700
1701 pub fn get_theme_data<'js>(
1703 &self,
1704 ctx: rquickjs::Ctx<'js>,
1705 name: String,
1706 ) -> rquickjs::Result<Value<'js>> {
1707 match self.services.get_theme_data(&name) {
1708 Some(data) => rquickjs_serde::to_value(ctx, &data)
1709 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1710 None => Ok(Value::new_null(ctx)),
1711 }
1712 }
1713
1714 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1716 self.services
1717 .save_theme_file(&name, &content)
1718 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1719 }
1720
1721 pub fn theme_file_exists(&self, name: String) -> bool {
1723 self.services.theme_file_exists(&name)
1724 }
1725
1726 pub fn file_stat<'js>(
1730 &self,
1731 ctx: rquickjs::Ctx<'js>,
1732 path: String,
1733 ) -> rquickjs::Result<Value<'js>> {
1734 let metadata = std::fs::metadata(&path).ok();
1735 let stat = metadata.map(|m| {
1736 serde_json::json!({
1737 "isFile": m.is_file(),
1738 "isDir": m.is_dir(),
1739 "size": m.len(),
1740 "readonly": m.permissions().readonly(),
1741 })
1742 });
1743 rquickjs_serde::to_value(ctx, &stat)
1744 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1745 }
1746
1747 pub fn is_process_running(&self, _process_id: u64) -> bool {
1751 false
1754 }
1755
1756 pub fn kill_process(&self, process_id: u64) -> bool {
1758 self.command_sender
1759 .send(PluginCommand::KillBackgroundProcess { process_id })
1760 .is_ok()
1761 }
1762
1763 pub fn plugin_translate<'js>(
1767 &self,
1768 _ctx: rquickjs::Ctx<'js>,
1769 plugin_name: String,
1770 key: String,
1771 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1772 ) -> String {
1773 let args_map: HashMap<String, String> = args
1774 .0
1775 .map(|obj| {
1776 let mut map = HashMap::new();
1777 for (k, v) in obj.props::<String, String>().flatten() {
1778 map.insert(k, v);
1779 }
1780 map
1781 })
1782 .unwrap_or_default();
1783
1784 self.services.translate(&plugin_name, &key, &args_map)
1785 }
1786
1787 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1794 #[qjs(rename = "_createCompositeBufferStart")]
1795 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1796 let id = {
1797 let mut id_ref = self.next_request_id.borrow_mut();
1798 let id = *id_ref;
1799 *id_ref += 1;
1800 self.callback_contexts
1802 .borrow_mut()
1803 .insert(id, self.plugin_name.clone());
1804 id
1805 };
1806
1807 if let Ok(mut owners) = self.async_resource_owners.lock() {
1809 owners.insert(id, self.plugin_name.clone());
1810 }
1811 let _ = self
1812 .command_sender
1813 .send(PluginCommand::CreateCompositeBuffer {
1814 name: opts.name,
1815 mode: opts.mode,
1816 layout: opts.layout,
1817 sources: opts.sources,
1818 hunks: opts.hunks,
1819 request_id: Some(id),
1820 });
1821
1822 id
1823 }
1824
1825 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1829 self.command_sender
1830 .send(PluginCommand::UpdateCompositeAlignment {
1831 buffer_id: BufferId(buffer_id as usize),
1832 hunks,
1833 })
1834 .is_ok()
1835 }
1836
1837 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1839 self.command_sender
1840 .send(PluginCommand::CloseCompositeBuffer {
1841 buffer_id: BufferId(buffer_id as usize),
1842 })
1843 .is_ok()
1844 }
1845
1846 #[plugin_api(
1850 async_promise,
1851 js_name = "getHighlights",
1852 ts_return = "TsHighlightSpan[]"
1853 )]
1854 #[qjs(rename = "_getHighlightsStart")]
1855 pub fn get_highlights_start<'js>(
1856 &self,
1857 _ctx: rquickjs::Ctx<'js>,
1858 buffer_id: u32,
1859 start: u32,
1860 end: u32,
1861 ) -> rquickjs::Result<u64> {
1862 let id = {
1863 let mut id_ref = self.next_request_id.borrow_mut();
1864 let id = *id_ref;
1865 *id_ref += 1;
1866 self.callback_contexts
1868 .borrow_mut()
1869 .insert(id, self.plugin_name.clone());
1870 id
1871 };
1872
1873 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1874 buffer_id: BufferId(buffer_id as usize),
1875 range: (start as usize)..(end as usize),
1876 request_id: id,
1877 });
1878
1879 Ok(id)
1880 }
1881
1882 pub fn add_overlay<'js>(
1904 &self,
1905 _ctx: rquickjs::Ctx<'js>,
1906 buffer_id: u32,
1907 namespace: String,
1908 start: u32,
1909 end: u32,
1910 options: rquickjs::Object<'js>,
1911 ) -> rquickjs::Result<bool> {
1912 use fresh_core::api::OverlayColorSpec;
1913
1914 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1916 if let Ok(theme_key) = obj.get::<_, String>(key) {
1918 if !theme_key.is_empty() {
1919 return Some(OverlayColorSpec::ThemeKey(theme_key));
1920 }
1921 }
1922 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1924 if arr.len() >= 3 {
1925 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1926 }
1927 }
1928 None
1929 }
1930
1931 let fg = parse_color_spec("fg", &options);
1932 let bg = parse_color_spec("bg", &options);
1933 let underline: bool = options.get("underline").unwrap_or(false);
1934 let bold: bool = options.get("bold").unwrap_or(false);
1935 let italic: bool = options.get("italic").unwrap_or(false);
1936 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
1937 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1938 let url: Option<String> = options.get("url").ok();
1939
1940 let options = OverlayOptions {
1941 fg,
1942 bg,
1943 underline,
1944 bold,
1945 italic,
1946 strikethrough,
1947 extend_to_line_end,
1948 url,
1949 };
1950
1951 self.plugin_tracked_state
1953 .borrow_mut()
1954 .entry(self.plugin_name.clone())
1955 .or_default()
1956 .overlay_namespaces
1957 .push((BufferId(buffer_id as usize), namespace.clone()));
1958
1959 let _ = self.command_sender.send(PluginCommand::AddOverlay {
1960 buffer_id: BufferId(buffer_id as usize),
1961 namespace: Some(OverlayNamespace::from_string(namespace)),
1962 range: (start as usize)..(end as usize),
1963 options,
1964 });
1965
1966 Ok(true)
1967 }
1968
1969 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1971 self.command_sender
1972 .send(PluginCommand::ClearNamespace {
1973 buffer_id: BufferId(buffer_id as usize),
1974 namespace: OverlayNamespace::from_string(namespace),
1975 })
1976 .is_ok()
1977 }
1978
1979 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1981 self.command_sender
1982 .send(PluginCommand::ClearAllOverlays {
1983 buffer_id: BufferId(buffer_id as usize),
1984 })
1985 .is_ok()
1986 }
1987
1988 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1990 self.command_sender
1991 .send(PluginCommand::ClearOverlaysInRange {
1992 buffer_id: BufferId(buffer_id as usize),
1993 start: start as usize,
1994 end: end as usize,
1995 })
1996 .is_ok()
1997 }
1998
1999 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2001 use fresh_core::overlay::OverlayHandle;
2002 self.command_sender
2003 .send(PluginCommand::RemoveOverlay {
2004 buffer_id: BufferId(buffer_id as usize),
2005 handle: OverlayHandle(handle),
2006 })
2007 .is_ok()
2008 }
2009
2010 pub fn add_conceal(
2014 &self,
2015 buffer_id: u32,
2016 namespace: String,
2017 start: u32,
2018 end: u32,
2019 replacement: Option<String>,
2020 ) -> bool {
2021 self.plugin_tracked_state
2023 .borrow_mut()
2024 .entry(self.plugin_name.clone())
2025 .or_default()
2026 .overlay_namespaces
2027 .push((BufferId(buffer_id as usize), namespace.clone()));
2028
2029 self.command_sender
2030 .send(PluginCommand::AddConceal {
2031 buffer_id: BufferId(buffer_id as usize),
2032 namespace: OverlayNamespace::from_string(namespace),
2033 start: start as usize,
2034 end: end as usize,
2035 replacement,
2036 })
2037 .is_ok()
2038 }
2039
2040 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2042 self.command_sender
2043 .send(PluginCommand::ClearConcealNamespace {
2044 buffer_id: BufferId(buffer_id as usize),
2045 namespace: OverlayNamespace::from_string(namespace),
2046 })
2047 .is_ok()
2048 }
2049
2050 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2052 self.command_sender
2053 .send(PluginCommand::ClearConcealsInRange {
2054 buffer_id: BufferId(buffer_id as usize),
2055 start: start as usize,
2056 end: end as usize,
2057 })
2058 .is_ok()
2059 }
2060
2061 pub fn add_soft_break(
2065 &self,
2066 buffer_id: u32,
2067 namespace: String,
2068 position: u32,
2069 indent: u32,
2070 ) -> bool {
2071 self.plugin_tracked_state
2073 .borrow_mut()
2074 .entry(self.plugin_name.clone())
2075 .or_default()
2076 .overlay_namespaces
2077 .push((BufferId(buffer_id as usize), namespace.clone()));
2078
2079 self.command_sender
2080 .send(PluginCommand::AddSoftBreak {
2081 buffer_id: BufferId(buffer_id as usize),
2082 namespace: OverlayNamespace::from_string(namespace),
2083 position: position as usize,
2084 indent: indent as u16,
2085 })
2086 .is_ok()
2087 }
2088
2089 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2091 self.command_sender
2092 .send(PluginCommand::ClearSoftBreakNamespace {
2093 buffer_id: BufferId(buffer_id as usize),
2094 namespace: OverlayNamespace::from_string(namespace),
2095 })
2096 .is_ok()
2097 }
2098
2099 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2101 self.command_sender
2102 .send(PluginCommand::ClearSoftBreaksInRange {
2103 buffer_id: BufferId(buffer_id as usize),
2104 start: start as usize,
2105 end: end as usize,
2106 })
2107 .is_ok()
2108 }
2109
2110 #[allow(clippy::too_many_arguments)]
2120 pub fn submit_view_transform<'js>(
2121 &self,
2122 _ctx: rquickjs::Ctx<'js>,
2123 buffer_id: u32,
2124 split_id: Option<u32>,
2125 start: u32,
2126 end: u32,
2127 tokens: Vec<rquickjs::Object<'js>>,
2128 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2129 ) -> rquickjs::Result<bool> {
2130 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2131
2132 let tokens: Vec<ViewTokenWire> = tokens
2133 .into_iter()
2134 .enumerate()
2135 .map(|(idx, obj)| {
2136 parse_view_token(&obj, idx)
2138 })
2139 .collect::<rquickjs::Result<Vec<_>>>()?;
2140
2141 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2143 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2144 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2145 Some(LayoutHints {
2146 compose_width,
2147 column_guides,
2148 })
2149 } else {
2150 None
2151 };
2152
2153 let payload = ViewTransformPayload {
2154 range: (start as usize)..(end as usize),
2155 tokens,
2156 layout_hints: parsed_layout_hints,
2157 };
2158
2159 Ok(self
2160 .command_sender
2161 .send(PluginCommand::SubmitViewTransform {
2162 buffer_id: BufferId(buffer_id as usize),
2163 split_id: split_id.map(|id| SplitId(id as usize)),
2164 payload,
2165 })
2166 .is_ok())
2167 }
2168
2169 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2171 self.command_sender
2172 .send(PluginCommand::ClearViewTransform {
2173 buffer_id: BufferId(buffer_id as usize),
2174 split_id: split_id.map(|id| SplitId(id as usize)),
2175 })
2176 .is_ok()
2177 }
2178
2179 pub fn set_layout_hints<'js>(
2182 &self,
2183 buffer_id: u32,
2184 split_id: Option<u32>,
2185 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2186 ) -> rquickjs::Result<bool> {
2187 use fresh_core::api::LayoutHints;
2188
2189 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2190 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2191 let parsed_hints = LayoutHints {
2192 compose_width,
2193 column_guides,
2194 };
2195
2196 Ok(self
2197 .command_sender
2198 .send(PluginCommand::SetLayoutHints {
2199 buffer_id: BufferId(buffer_id as usize),
2200 split_id: split_id.map(|id| SplitId(id as usize)),
2201 range: 0..0,
2202 hints: parsed_hints,
2203 })
2204 .is_ok())
2205 }
2206
2207 pub fn set_file_explorer_decorations<'js>(
2211 &self,
2212 _ctx: rquickjs::Ctx<'js>,
2213 namespace: String,
2214 decorations: Vec<rquickjs::Object<'js>>,
2215 ) -> rquickjs::Result<bool> {
2216 use fresh_core::file_explorer::FileExplorerDecoration;
2217
2218 let decorations: Vec<FileExplorerDecoration> = decorations
2219 .into_iter()
2220 .map(|obj| {
2221 let path: String = obj.get("path")?;
2222 let symbol: String = obj.get("symbol")?;
2223 let color: Vec<u8> = obj.get("color")?;
2224 let priority: i32 = obj.get("priority").unwrap_or(0);
2225
2226 if color.len() < 3 {
2227 return Err(rquickjs::Error::FromJs {
2228 from: "array",
2229 to: "color",
2230 message: Some(format!(
2231 "color array must have at least 3 elements, got {}",
2232 color.len()
2233 )),
2234 });
2235 }
2236
2237 Ok(FileExplorerDecoration {
2238 path: std::path::PathBuf::from(path),
2239 symbol,
2240 color: [color[0], color[1], color[2]],
2241 priority,
2242 })
2243 })
2244 .collect::<rquickjs::Result<Vec<_>>>()?;
2245
2246 self.plugin_tracked_state
2248 .borrow_mut()
2249 .entry(self.plugin_name.clone())
2250 .or_default()
2251 .file_explorer_namespaces
2252 .push(namespace.clone());
2253
2254 Ok(self
2255 .command_sender
2256 .send(PluginCommand::SetFileExplorerDecorations {
2257 namespace,
2258 decorations,
2259 })
2260 .is_ok())
2261 }
2262
2263 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2265 self.command_sender
2266 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2267 .is_ok()
2268 }
2269
2270 #[allow(clippy::too_many_arguments)]
2274 pub fn add_virtual_text(
2275 &self,
2276 buffer_id: u32,
2277 virtual_text_id: String,
2278 position: u32,
2279 text: String,
2280 r: u8,
2281 g: u8,
2282 b: u8,
2283 before: bool,
2284 use_bg: bool,
2285 ) -> bool {
2286 self.plugin_tracked_state
2288 .borrow_mut()
2289 .entry(self.plugin_name.clone())
2290 .or_default()
2291 .virtual_text_ids
2292 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2293
2294 self.command_sender
2295 .send(PluginCommand::AddVirtualText {
2296 buffer_id: BufferId(buffer_id as usize),
2297 virtual_text_id,
2298 position: position as usize,
2299 text,
2300 color: (r, g, b),
2301 use_bg,
2302 before,
2303 })
2304 .is_ok()
2305 }
2306
2307 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2309 self.command_sender
2310 .send(PluginCommand::RemoveVirtualText {
2311 buffer_id: BufferId(buffer_id as usize),
2312 virtual_text_id,
2313 })
2314 .is_ok()
2315 }
2316
2317 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2319 self.command_sender
2320 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2321 buffer_id: BufferId(buffer_id as usize),
2322 prefix,
2323 })
2324 .is_ok()
2325 }
2326
2327 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2329 self.command_sender
2330 .send(PluginCommand::ClearVirtualTexts {
2331 buffer_id: BufferId(buffer_id as usize),
2332 })
2333 .is_ok()
2334 }
2335
2336 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2338 self.command_sender
2339 .send(PluginCommand::ClearVirtualTextNamespace {
2340 buffer_id: BufferId(buffer_id as usize),
2341 namespace,
2342 })
2343 .is_ok()
2344 }
2345
2346 #[allow(clippy::too_many_arguments)]
2348 pub fn add_virtual_line(
2349 &self,
2350 buffer_id: u32,
2351 position: u32,
2352 text: String,
2353 fg_r: u8,
2354 fg_g: u8,
2355 fg_b: u8,
2356 bg_r: u8,
2357 bg_g: u8,
2358 bg_b: u8,
2359 above: bool,
2360 namespace: String,
2361 priority: i32,
2362 ) -> bool {
2363 self.plugin_tracked_state
2365 .borrow_mut()
2366 .entry(self.plugin_name.clone())
2367 .or_default()
2368 .virtual_line_namespaces
2369 .push((BufferId(buffer_id as usize), namespace.clone()));
2370
2371 self.command_sender
2372 .send(PluginCommand::AddVirtualLine {
2373 buffer_id: BufferId(buffer_id as usize),
2374 position: position as usize,
2375 text,
2376 fg_color: (fg_r, fg_g, fg_b),
2377 bg_color: Some((bg_r, bg_g, bg_b)),
2378 above,
2379 namespace,
2380 priority,
2381 })
2382 .is_ok()
2383 }
2384
2385 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2390 #[qjs(rename = "_promptStart")]
2391 pub fn prompt_start(
2392 &self,
2393 _ctx: rquickjs::Ctx<'_>,
2394 label: String,
2395 initial_value: String,
2396 ) -> u64 {
2397 let id = {
2398 let mut id_ref = self.next_request_id.borrow_mut();
2399 let id = *id_ref;
2400 *id_ref += 1;
2401 self.callback_contexts
2403 .borrow_mut()
2404 .insert(id, self.plugin_name.clone());
2405 id
2406 };
2407
2408 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2409 label,
2410 initial_value,
2411 callback_id: JsCallbackId::new(id),
2412 });
2413
2414 id
2415 }
2416
2417 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2419 self.command_sender
2420 .send(PluginCommand::StartPrompt { label, prompt_type })
2421 .is_ok()
2422 }
2423
2424 pub fn start_prompt_with_initial(
2426 &self,
2427 label: String,
2428 prompt_type: String,
2429 initial_value: String,
2430 ) -> bool {
2431 self.command_sender
2432 .send(PluginCommand::StartPromptWithInitial {
2433 label,
2434 prompt_type,
2435 initial_value,
2436 })
2437 .is_ok()
2438 }
2439
2440 pub fn set_prompt_suggestions(
2444 &self,
2445 suggestions: Vec<fresh_core::command::Suggestion>,
2446 ) -> bool {
2447 self.command_sender
2448 .send(PluginCommand::SetPromptSuggestions { suggestions })
2449 .is_ok()
2450 }
2451
2452 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2453 self.command_sender
2454 .send(PluginCommand::SetPromptInputSync { sync })
2455 .is_ok()
2456 }
2457
2458 pub fn define_mode(
2462 &self,
2463 name: String,
2464 bindings_arr: Vec<Vec<String>>,
2465 read_only: rquickjs::function::Opt<bool>,
2466 allow_text_input: rquickjs::function::Opt<bool>,
2467 ) -> bool {
2468 let bindings: Vec<(String, String)> = bindings_arr
2469 .into_iter()
2470 .filter_map(|arr| {
2471 if arr.len() >= 2 {
2472 Some((arr[0].clone(), arr[1].clone()))
2473 } else {
2474 None
2475 }
2476 })
2477 .collect();
2478
2479 {
2482 let mut registered = self.registered_actions.borrow_mut();
2483 for (_, cmd_name) in &bindings {
2484 registered.insert(
2485 cmd_name.clone(),
2486 PluginHandler {
2487 plugin_name: self.plugin_name.clone(),
2488 handler_name: cmd_name.clone(),
2489 },
2490 );
2491 }
2492 }
2493
2494 let allow_text = allow_text_input.0.unwrap_or(false);
2497 if allow_text {
2498 let mut registered = self.registered_actions.borrow_mut();
2499 registered.insert(
2500 "mode_text_input".to_string(),
2501 PluginHandler {
2502 plugin_name: self.plugin_name.clone(),
2503 handler_name: "mode_text_input".to_string(),
2504 },
2505 );
2506 }
2507
2508 self.command_sender
2509 .send(PluginCommand::DefineMode {
2510 name,
2511 bindings,
2512 read_only: read_only.0.unwrap_or(false),
2513 allow_text_input: allow_text,
2514 plugin_name: Some(self.plugin_name.clone()),
2515 })
2516 .is_ok()
2517 }
2518
2519 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2521 self.command_sender
2522 .send(PluginCommand::SetEditorMode { mode })
2523 .is_ok()
2524 }
2525
2526 pub fn get_editor_mode(&self) -> Option<String> {
2528 self.state_snapshot
2529 .read()
2530 .ok()
2531 .and_then(|s| s.editor_mode.clone())
2532 }
2533
2534 pub fn close_split(&self, split_id: u32) -> bool {
2538 self.command_sender
2539 .send(PluginCommand::CloseSplit {
2540 split_id: SplitId(split_id as usize),
2541 })
2542 .is_ok()
2543 }
2544
2545 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2547 self.command_sender
2548 .send(PluginCommand::SetSplitBuffer {
2549 split_id: SplitId(split_id as usize),
2550 buffer_id: BufferId(buffer_id as usize),
2551 })
2552 .is_ok()
2553 }
2554
2555 pub fn focus_split(&self, split_id: u32) -> bool {
2557 self.command_sender
2558 .send(PluginCommand::FocusSplit {
2559 split_id: SplitId(split_id as usize),
2560 })
2561 .is_ok()
2562 }
2563
2564 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2566 self.command_sender
2567 .send(PluginCommand::SetSplitScroll {
2568 split_id: SplitId(split_id as usize),
2569 top_byte: top_byte as usize,
2570 })
2571 .is_ok()
2572 }
2573
2574 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2576 self.command_sender
2577 .send(PluginCommand::SetSplitRatio {
2578 split_id: SplitId(split_id as usize),
2579 ratio,
2580 })
2581 .is_ok()
2582 }
2583
2584 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2586 self.command_sender
2587 .send(PluginCommand::SetSplitLabel {
2588 split_id: SplitId(split_id as usize),
2589 label,
2590 })
2591 .is_ok()
2592 }
2593
2594 pub fn clear_split_label(&self, split_id: u32) -> bool {
2596 self.command_sender
2597 .send(PluginCommand::ClearSplitLabel {
2598 split_id: SplitId(split_id as usize),
2599 })
2600 .is_ok()
2601 }
2602
2603 #[plugin_api(
2605 async_promise,
2606 js_name = "getSplitByLabel",
2607 ts_return = "number | null"
2608 )]
2609 #[qjs(rename = "_getSplitByLabelStart")]
2610 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2611 let id = {
2612 let mut id_ref = self.next_request_id.borrow_mut();
2613 let id = *id_ref;
2614 *id_ref += 1;
2615 self.callback_contexts
2616 .borrow_mut()
2617 .insert(id, self.plugin_name.clone());
2618 id
2619 };
2620 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2621 label,
2622 request_id: id,
2623 });
2624 id
2625 }
2626
2627 pub fn distribute_splits_evenly(&self) -> bool {
2629 self.command_sender
2631 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2632 .is_ok()
2633 }
2634
2635 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2637 self.command_sender
2638 .send(PluginCommand::SetBufferCursor {
2639 buffer_id: BufferId(buffer_id as usize),
2640 position: position as usize,
2641 })
2642 .is_ok()
2643 }
2644
2645 #[allow(clippy::too_many_arguments)]
2649 pub fn set_line_indicator(
2650 &self,
2651 buffer_id: u32,
2652 line: u32,
2653 namespace: String,
2654 symbol: String,
2655 r: u8,
2656 g: u8,
2657 b: u8,
2658 priority: i32,
2659 ) -> bool {
2660 self.plugin_tracked_state
2662 .borrow_mut()
2663 .entry(self.plugin_name.clone())
2664 .or_default()
2665 .line_indicator_namespaces
2666 .push((BufferId(buffer_id as usize), namespace.clone()));
2667
2668 self.command_sender
2669 .send(PluginCommand::SetLineIndicator {
2670 buffer_id: BufferId(buffer_id as usize),
2671 line: line as usize,
2672 namespace,
2673 symbol,
2674 color: (r, g, b),
2675 priority,
2676 })
2677 .is_ok()
2678 }
2679
2680 #[allow(clippy::too_many_arguments)]
2682 pub fn set_line_indicators(
2683 &self,
2684 buffer_id: u32,
2685 lines: Vec<u32>,
2686 namespace: String,
2687 symbol: String,
2688 r: u8,
2689 g: u8,
2690 b: u8,
2691 priority: i32,
2692 ) -> bool {
2693 self.plugin_tracked_state
2695 .borrow_mut()
2696 .entry(self.plugin_name.clone())
2697 .or_default()
2698 .line_indicator_namespaces
2699 .push((BufferId(buffer_id as usize), namespace.clone()));
2700
2701 self.command_sender
2702 .send(PluginCommand::SetLineIndicators {
2703 buffer_id: BufferId(buffer_id as usize),
2704 lines: lines.into_iter().map(|l| l as usize).collect(),
2705 namespace,
2706 symbol,
2707 color: (r, g, b),
2708 priority,
2709 })
2710 .is_ok()
2711 }
2712
2713 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2715 self.command_sender
2716 .send(PluginCommand::ClearLineIndicators {
2717 buffer_id: BufferId(buffer_id as usize),
2718 namespace,
2719 })
2720 .is_ok()
2721 }
2722
2723 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2725 self.command_sender
2726 .send(PluginCommand::SetLineNumbers {
2727 buffer_id: BufferId(buffer_id as usize),
2728 enabled,
2729 })
2730 .is_ok()
2731 }
2732
2733 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2735 self.command_sender
2736 .send(PluginCommand::SetViewMode {
2737 buffer_id: BufferId(buffer_id as usize),
2738 mode,
2739 })
2740 .is_ok()
2741 }
2742
2743 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2745 self.command_sender
2746 .send(PluginCommand::SetLineWrap {
2747 buffer_id: BufferId(buffer_id as usize),
2748 split_id: split_id.map(|s| SplitId(s as usize)),
2749 enabled,
2750 })
2751 .is_ok()
2752 }
2753
2754 pub fn set_view_state<'js>(
2758 &self,
2759 ctx: rquickjs::Ctx<'js>,
2760 buffer_id: u32,
2761 key: String,
2762 value: Value<'js>,
2763 ) -> bool {
2764 let bid = BufferId(buffer_id as usize);
2765
2766 let json_value = if value.is_undefined() || value.is_null() {
2768 None
2769 } else {
2770 Some(js_to_json(&ctx, value))
2771 };
2772
2773 if let Ok(mut snapshot) = self.state_snapshot.write() {
2775 if let Some(ref json_val) = json_value {
2776 snapshot
2777 .plugin_view_states
2778 .entry(bid)
2779 .or_default()
2780 .insert(key.clone(), json_val.clone());
2781 } else {
2782 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2784 map.remove(&key);
2785 if map.is_empty() {
2786 snapshot.plugin_view_states.remove(&bid);
2787 }
2788 }
2789 }
2790 }
2791
2792 self.command_sender
2794 .send(PluginCommand::SetViewState {
2795 buffer_id: bid,
2796 key,
2797 value: json_value,
2798 })
2799 .is_ok()
2800 }
2801
2802 pub fn get_view_state<'js>(
2804 &self,
2805 ctx: rquickjs::Ctx<'js>,
2806 buffer_id: u32,
2807 key: String,
2808 ) -> rquickjs::Result<Value<'js>> {
2809 let bid = BufferId(buffer_id as usize);
2810 if let Ok(snapshot) = self.state_snapshot.read() {
2811 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2812 if let Some(json_val) = map.get(&key) {
2813 return json_to_js_value(&ctx, json_val);
2814 }
2815 }
2816 }
2817 Ok(Value::new_undefined(ctx.clone()))
2818 }
2819
2820 pub fn set_global_state<'js>(
2826 &self,
2827 ctx: rquickjs::Ctx<'js>,
2828 key: String,
2829 value: Value<'js>,
2830 ) -> bool {
2831 let json_value = if value.is_undefined() || value.is_null() {
2833 None
2834 } else {
2835 Some(js_to_json(&ctx, value))
2836 };
2837
2838 if let Ok(mut snapshot) = self.state_snapshot.write() {
2840 if let Some(ref json_val) = json_value {
2841 snapshot
2842 .plugin_global_states
2843 .entry(self.plugin_name.clone())
2844 .or_default()
2845 .insert(key.clone(), json_val.clone());
2846 } else {
2847 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
2849 map.remove(&key);
2850 if map.is_empty() {
2851 snapshot.plugin_global_states.remove(&self.plugin_name);
2852 }
2853 }
2854 }
2855 }
2856
2857 self.command_sender
2859 .send(PluginCommand::SetGlobalState {
2860 plugin_name: self.plugin_name.clone(),
2861 key,
2862 value: json_value,
2863 })
2864 .is_ok()
2865 }
2866
2867 pub fn get_global_state<'js>(
2871 &self,
2872 ctx: rquickjs::Ctx<'js>,
2873 key: String,
2874 ) -> rquickjs::Result<Value<'js>> {
2875 if let Ok(snapshot) = self.state_snapshot.read() {
2876 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
2877 if let Some(json_val) = map.get(&key) {
2878 return json_to_js_value(&ctx, json_val);
2879 }
2880 }
2881 }
2882 Ok(Value::new_undefined(ctx.clone()))
2883 }
2884
2885 pub fn create_scroll_sync_group(
2889 &self,
2890 group_id: u32,
2891 left_split: u32,
2892 right_split: u32,
2893 ) -> bool {
2894 self.plugin_tracked_state
2896 .borrow_mut()
2897 .entry(self.plugin_name.clone())
2898 .or_default()
2899 .scroll_sync_group_ids
2900 .push(group_id);
2901 self.command_sender
2902 .send(PluginCommand::CreateScrollSyncGroup {
2903 group_id,
2904 left_split: SplitId(left_split as usize),
2905 right_split: SplitId(right_split as usize),
2906 })
2907 .is_ok()
2908 }
2909
2910 pub fn set_scroll_sync_anchors<'js>(
2912 &self,
2913 _ctx: rquickjs::Ctx<'js>,
2914 group_id: u32,
2915 anchors: Vec<Vec<u32>>,
2916 ) -> bool {
2917 let anchors: Vec<(usize, usize)> = anchors
2918 .into_iter()
2919 .filter_map(|pair| {
2920 if pair.len() >= 2 {
2921 Some((pair[0] as usize, pair[1] as usize))
2922 } else {
2923 None
2924 }
2925 })
2926 .collect();
2927 self.command_sender
2928 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2929 .is_ok()
2930 }
2931
2932 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2934 self.command_sender
2935 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2936 .is_ok()
2937 }
2938
2939 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2945 self.command_sender
2946 .send(PluginCommand::ExecuteActions { actions })
2947 .is_ok()
2948 }
2949
2950 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2954 self.command_sender
2955 .send(PluginCommand::ShowActionPopup {
2956 popup_id: opts.id,
2957 title: opts.title,
2958 message: opts.message,
2959 actions: opts.actions,
2960 })
2961 .is_ok()
2962 }
2963
2964 pub fn disable_lsp_for_language(&self, language: String) -> bool {
2966 self.command_sender
2967 .send(PluginCommand::DisableLspForLanguage { language })
2968 .is_ok()
2969 }
2970
2971 pub fn restart_lsp_for_language(&self, language: String) -> bool {
2973 self.command_sender
2974 .send(PluginCommand::RestartLspForLanguage { language })
2975 .is_ok()
2976 }
2977
2978 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2981 self.command_sender
2982 .send(PluginCommand::SetLspRootUri { language, uri })
2983 .is_ok()
2984 }
2985
2986 #[plugin_api(ts_return = "JsDiagnostic[]")]
2988 pub fn get_all_diagnostics<'js>(
2989 &self,
2990 ctx: rquickjs::Ctx<'js>,
2991 ) -> rquickjs::Result<Value<'js>> {
2992 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2993
2994 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2995 let mut result: Vec<JsDiagnostic> = Vec::new();
2997 for (uri, diags) in &s.diagnostics {
2998 for diag in diags {
2999 result.push(JsDiagnostic {
3000 uri: uri.clone(),
3001 message: diag.message.clone(),
3002 severity: diag.severity.map(|s| match s {
3003 lsp_types::DiagnosticSeverity::ERROR => 1,
3004 lsp_types::DiagnosticSeverity::WARNING => 2,
3005 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3006 lsp_types::DiagnosticSeverity::HINT => 4,
3007 _ => 0,
3008 }),
3009 range: JsRange {
3010 start: JsPosition {
3011 line: diag.range.start.line,
3012 character: diag.range.start.character,
3013 },
3014 end: JsPosition {
3015 line: diag.range.end.line,
3016 character: diag.range.end.character,
3017 },
3018 },
3019 source: diag.source.clone(),
3020 });
3021 }
3022 }
3023 result
3024 } else {
3025 Vec::new()
3026 };
3027 rquickjs_serde::to_value(ctx, &diagnostics)
3028 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3029 }
3030
3031 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3033 self.event_handlers
3034 .borrow()
3035 .get(&event_name)
3036 .cloned()
3037 .unwrap_or_default()
3038 .into_iter()
3039 .map(|h| h.handler_name)
3040 .collect()
3041 }
3042
3043 #[plugin_api(
3047 async_promise,
3048 js_name = "createVirtualBuffer",
3049 ts_return = "VirtualBufferResult"
3050 )]
3051 #[qjs(rename = "_createVirtualBufferStart")]
3052 pub fn create_virtual_buffer_start(
3053 &self,
3054 _ctx: rquickjs::Ctx<'_>,
3055 opts: fresh_core::api::CreateVirtualBufferOptions,
3056 ) -> rquickjs::Result<u64> {
3057 let id = {
3058 let mut id_ref = self.next_request_id.borrow_mut();
3059 let id = *id_ref;
3060 *id_ref += 1;
3061 self.callback_contexts
3063 .borrow_mut()
3064 .insert(id, self.plugin_name.clone());
3065 id
3066 };
3067
3068 let entries: Vec<TextPropertyEntry> = opts
3070 .entries
3071 .unwrap_or_default()
3072 .into_iter()
3073 .map(|e| TextPropertyEntry {
3074 text: e.text,
3075 properties: e.properties.unwrap_or_default(),
3076 style: e.style,
3077 inline_overlays: e.inline_overlays.unwrap_or_default(),
3078 })
3079 .collect();
3080
3081 tracing::debug!(
3082 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3083 id
3084 );
3085 if let Ok(mut owners) = self.async_resource_owners.lock() {
3087 owners.insert(id, self.plugin_name.clone());
3088 }
3089 let _ = self
3090 .command_sender
3091 .send(PluginCommand::CreateVirtualBufferWithContent {
3092 name: opts.name,
3093 mode: opts.mode.unwrap_or_default(),
3094 read_only: opts.read_only.unwrap_or(false),
3095 entries,
3096 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3097 show_cursors: opts.show_cursors.unwrap_or(true),
3098 editing_disabled: opts.editing_disabled.unwrap_or(false),
3099 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3100 request_id: Some(id),
3101 });
3102 Ok(id)
3103 }
3104
3105 #[plugin_api(
3107 async_promise,
3108 js_name = "createVirtualBufferInSplit",
3109 ts_return = "VirtualBufferResult"
3110 )]
3111 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3112 pub fn create_virtual_buffer_in_split_start(
3113 &self,
3114 _ctx: rquickjs::Ctx<'_>,
3115 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3116 ) -> rquickjs::Result<u64> {
3117 let id = {
3118 let mut id_ref = self.next_request_id.borrow_mut();
3119 let id = *id_ref;
3120 *id_ref += 1;
3121 self.callback_contexts
3123 .borrow_mut()
3124 .insert(id, self.plugin_name.clone());
3125 id
3126 };
3127
3128 let entries: Vec<TextPropertyEntry> = opts
3130 .entries
3131 .unwrap_or_default()
3132 .into_iter()
3133 .map(|e| TextPropertyEntry {
3134 text: e.text,
3135 properties: e.properties.unwrap_or_default(),
3136 style: e.style,
3137 inline_overlays: e.inline_overlays.unwrap_or_default(),
3138 })
3139 .collect();
3140
3141 if let Ok(mut owners) = self.async_resource_owners.lock() {
3143 owners.insert(id, self.plugin_name.clone());
3144 }
3145 let _ = self
3146 .command_sender
3147 .send(PluginCommand::CreateVirtualBufferInSplit {
3148 name: opts.name,
3149 mode: opts.mode.unwrap_or_default(),
3150 read_only: opts.read_only.unwrap_or(false),
3151 entries,
3152 ratio: opts.ratio.unwrap_or(0.5),
3153 direction: opts.direction,
3154 panel_id: opts.panel_id,
3155 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3156 show_cursors: opts.show_cursors.unwrap_or(true),
3157 editing_disabled: opts.editing_disabled.unwrap_or(false),
3158 line_wrap: opts.line_wrap,
3159 before: opts.before.unwrap_or(false),
3160 request_id: Some(id),
3161 });
3162 Ok(id)
3163 }
3164
3165 #[plugin_api(
3167 async_promise,
3168 js_name = "createVirtualBufferInExistingSplit",
3169 ts_return = "VirtualBufferResult"
3170 )]
3171 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3172 pub fn create_virtual_buffer_in_existing_split_start(
3173 &self,
3174 _ctx: rquickjs::Ctx<'_>,
3175 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3176 ) -> rquickjs::Result<u64> {
3177 let id = {
3178 let mut id_ref = self.next_request_id.borrow_mut();
3179 let id = *id_ref;
3180 *id_ref += 1;
3181 self.callback_contexts
3183 .borrow_mut()
3184 .insert(id, self.plugin_name.clone());
3185 id
3186 };
3187
3188 let entries: Vec<TextPropertyEntry> = opts
3190 .entries
3191 .unwrap_or_default()
3192 .into_iter()
3193 .map(|e| TextPropertyEntry {
3194 text: e.text,
3195 properties: e.properties.unwrap_or_default(),
3196 style: e.style,
3197 inline_overlays: e.inline_overlays.unwrap_or_default(),
3198 })
3199 .collect();
3200
3201 if let Ok(mut owners) = self.async_resource_owners.lock() {
3203 owners.insert(id, self.plugin_name.clone());
3204 }
3205 let _ = self
3206 .command_sender
3207 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3208 name: opts.name,
3209 mode: opts.mode.unwrap_or_default(),
3210 read_only: opts.read_only.unwrap_or(false),
3211 entries,
3212 split_id: SplitId(opts.split_id),
3213 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3214 show_cursors: opts.show_cursors.unwrap_or(true),
3215 editing_disabled: opts.editing_disabled.unwrap_or(false),
3216 line_wrap: opts.line_wrap,
3217 request_id: Some(id),
3218 });
3219 Ok(id)
3220 }
3221
3222 pub fn set_virtual_buffer_content<'js>(
3226 &self,
3227 ctx: rquickjs::Ctx<'js>,
3228 buffer_id: u32,
3229 entries_arr: Vec<rquickjs::Object<'js>>,
3230 ) -> rquickjs::Result<bool> {
3231 let entries: Vec<TextPropertyEntry> = entries_arr
3232 .iter()
3233 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3234 .collect();
3235 Ok(self
3236 .command_sender
3237 .send(PluginCommand::SetVirtualBufferContent {
3238 buffer_id: BufferId(buffer_id as usize),
3239 entries,
3240 })
3241 .is_ok())
3242 }
3243
3244 pub fn get_text_properties_at_cursor(
3246 &self,
3247 buffer_id: u32,
3248 ) -> fresh_core::api::TextPropertiesAtCursor {
3249 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3250 }
3251
3252 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3256 #[qjs(rename = "_spawnProcessStart")]
3257 pub fn spawn_process_start(
3258 &self,
3259 _ctx: rquickjs::Ctx<'_>,
3260 command: String,
3261 args: Vec<String>,
3262 cwd: rquickjs::function::Opt<String>,
3263 ) -> u64 {
3264 let id = {
3265 let mut id_ref = self.next_request_id.borrow_mut();
3266 let id = *id_ref;
3267 *id_ref += 1;
3268 self.callback_contexts
3270 .borrow_mut()
3271 .insert(id, self.plugin_name.clone());
3272 id
3273 };
3274 let effective_cwd = cwd.0.or_else(|| {
3276 self.state_snapshot
3277 .read()
3278 .ok()
3279 .map(|s| s.working_dir.to_string_lossy().to_string())
3280 });
3281 tracing::info!(
3282 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3283 self.plugin_name,
3284 command,
3285 args,
3286 effective_cwd,
3287 id
3288 );
3289 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3290 callback_id: JsCallbackId::new(id),
3291 command,
3292 args,
3293 cwd: effective_cwd,
3294 });
3295 id
3296 }
3297
3298 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3300 #[qjs(rename = "_spawnProcessWaitStart")]
3301 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3302 let id = {
3303 let mut id_ref = self.next_request_id.borrow_mut();
3304 let id = *id_ref;
3305 *id_ref += 1;
3306 self.callback_contexts
3308 .borrow_mut()
3309 .insert(id, self.plugin_name.clone());
3310 id
3311 };
3312 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3313 process_id,
3314 callback_id: JsCallbackId::new(id),
3315 });
3316 id
3317 }
3318
3319 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3321 #[qjs(rename = "_getBufferTextStart")]
3322 pub fn get_buffer_text_start(
3323 &self,
3324 _ctx: rquickjs::Ctx<'_>,
3325 buffer_id: u32,
3326 start: u32,
3327 end: u32,
3328 ) -> u64 {
3329 let id = {
3330 let mut id_ref = self.next_request_id.borrow_mut();
3331 let id = *id_ref;
3332 *id_ref += 1;
3333 self.callback_contexts
3335 .borrow_mut()
3336 .insert(id, self.plugin_name.clone());
3337 id
3338 };
3339 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3340 buffer_id: BufferId(buffer_id as usize),
3341 start: start as usize,
3342 end: end as usize,
3343 request_id: id,
3344 });
3345 id
3346 }
3347
3348 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3350 #[qjs(rename = "_delayStart")]
3351 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3352 let id = {
3353 let mut id_ref = self.next_request_id.borrow_mut();
3354 let id = *id_ref;
3355 *id_ref += 1;
3356 self.callback_contexts
3358 .borrow_mut()
3359 .insert(id, self.plugin_name.clone());
3360 id
3361 };
3362 let _ = self.command_sender.send(PluginCommand::Delay {
3363 callback_id: JsCallbackId::new(id),
3364 duration_ms,
3365 });
3366 id
3367 }
3368
3369 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3373 #[qjs(rename = "_grepProjectStart")]
3374 pub fn grep_project_start(
3375 &self,
3376 _ctx: rquickjs::Ctx<'_>,
3377 pattern: String,
3378 fixed_string: Option<bool>,
3379 case_sensitive: Option<bool>,
3380 max_results: Option<u32>,
3381 whole_words: Option<bool>,
3382 ) -> u64 {
3383 let id = {
3384 let mut id_ref = self.next_request_id.borrow_mut();
3385 let id = *id_ref;
3386 *id_ref += 1;
3387 self.callback_contexts
3388 .borrow_mut()
3389 .insert(id, self.plugin_name.clone());
3390 id
3391 };
3392 let _ = self.command_sender.send(PluginCommand::GrepProject {
3393 pattern,
3394 fixed_string: fixed_string.unwrap_or(true),
3395 case_sensitive: case_sensitive.unwrap_or(true),
3396 max_results: max_results.unwrap_or(200) as usize,
3397 whole_words: whole_words.unwrap_or(false),
3398 callback_id: JsCallbackId::new(id),
3399 });
3400 id
3401 }
3402
3403 #[plugin_api(
3407 js_name = "grepProjectStreaming",
3408 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3409 )]
3410 #[qjs(rename = "_grepProjectStreamingStart")]
3411 pub fn grep_project_streaming_start(
3412 &self,
3413 _ctx: rquickjs::Ctx<'_>,
3414 pattern: String,
3415 fixed_string: bool,
3416 case_sensitive: bool,
3417 max_results: u32,
3418 whole_words: bool,
3419 ) -> u64 {
3420 let id = {
3421 let mut id_ref = self.next_request_id.borrow_mut();
3422 let id = *id_ref;
3423 *id_ref += 1;
3424 self.callback_contexts
3425 .borrow_mut()
3426 .insert(id, self.plugin_name.clone());
3427 id
3428 };
3429 let _ = self
3430 .command_sender
3431 .send(PluginCommand::GrepProjectStreaming {
3432 pattern,
3433 fixed_string,
3434 case_sensitive,
3435 max_results: max_results as usize,
3436 whole_words,
3437 search_id: id,
3438 callback_id: JsCallbackId::new(id),
3439 });
3440 id
3441 }
3442
3443 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3447 #[qjs(rename = "_replaceInFileStart")]
3448 pub fn replace_in_file_start(
3449 &self,
3450 _ctx: rquickjs::Ctx<'_>,
3451 file_path: String,
3452 matches: Vec<Vec<u32>>,
3453 replacement: String,
3454 ) -> u64 {
3455 let id = {
3456 let mut id_ref = self.next_request_id.borrow_mut();
3457 let id = *id_ref;
3458 *id_ref += 1;
3459 self.callback_contexts
3460 .borrow_mut()
3461 .insert(id, self.plugin_name.clone());
3462 id
3463 };
3464 let match_pairs: Vec<(usize, usize)> = matches
3466 .iter()
3467 .map(|m| (m[0] as usize, m[1] as usize))
3468 .collect();
3469 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3470 file_path: PathBuf::from(file_path),
3471 matches: match_pairs,
3472 replacement,
3473 callback_id: JsCallbackId::new(id),
3474 });
3475 id
3476 }
3477
3478 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3480 #[qjs(rename = "_sendLspRequestStart")]
3481 pub fn send_lsp_request_start<'js>(
3482 &self,
3483 ctx: rquickjs::Ctx<'js>,
3484 language: String,
3485 method: String,
3486 params: Option<rquickjs::Object<'js>>,
3487 ) -> rquickjs::Result<u64> {
3488 let id = {
3489 let mut id_ref = self.next_request_id.borrow_mut();
3490 let id = *id_ref;
3491 *id_ref += 1;
3492 self.callback_contexts
3494 .borrow_mut()
3495 .insert(id, self.plugin_name.clone());
3496 id
3497 };
3498 let params_json: Option<serde_json::Value> = params.map(|obj| {
3500 let val = obj.into_value();
3501 js_to_json(&ctx, val)
3502 });
3503 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3504 request_id: id,
3505 language,
3506 method,
3507 params: params_json,
3508 });
3509 Ok(id)
3510 }
3511
3512 #[plugin_api(
3514 async_thenable,
3515 js_name = "spawnBackgroundProcess",
3516 ts_return = "BackgroundProcessResult"
3517 )]
3518 #[qjs(rename = "_spawnBackgroundProcessStart")]
3519 pub fn spawn_background_process_start(
3520 &self,
3521 _ctx: rquickjs::Ctx<'_>,
3522 command: String,
3523 args: Vec<String>,
3524 cwd: rquickjs::function::Opt<String>,
3525 ) -> u64 {
3526 let id = {
3527 let mut id_ref = self.next_request_id.borrow_mut();
3528 let id = *id_ref;
3529 *id_ref += 1;
3530 self.callback_contexts
3532 .borrow_mut()
3533 .insert(id, self.plugin_name.clone());
3534 id
3535 };
3536 let process_id = id;
3538 self.plugin_tracked_state
3540 .borrow_mut()
3541 .entry(self.plugin_name.clone())
3542 .or_default()
3543 .background_process_ids
3544 .push(process_id);
3545 let _ = self
3546 .command_sender
3547 .send(PluginCommand::SpawnBackgroundProcess {
3548 process_id,
3549 command,
3550 args,
3551 cwd: cwd.0,
3552 callback_id: JsCallbackId::new(id),
3553 });
3554 id
3555 }
3556
3557 pub fn kill_background_process(&self, process_id: u64) -> bool {
3559 self.command_sender
3560 .send(PluginCommand::KillBackgroundProcess { process_id })
3561 .is_ok()
3562 }
3563
3564 #[plugin_api(
3568 async_promise,
3569 js_name = "createTerminal",
3570 ts_return = "TerminalResult"
3571 )]
3572 #[qjs(rename = "_createTerminalStart")]
3573 pub fn create_terminal_start(
3574 &self,
3575 _ctx: rquickjs::Ctx<'_>,
3576 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3577 ) -> rquickjs::Result<u64> {
3578 let id = {
3579 let mut id_ref = self.next_request_id.borrow_mut();
3580 let id = *id_ref;
3581 *id_ref += 1;
3582 self.callback_contexts
3583 .borrow_mut()
3584 .insert(id, self.plugin_name.clone());
3585 id
3586 };
3587
3588 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3589 cwd: None,
3590 direction: None,
3591 ratio: None,
3592 focus: None,
3593 });
3594
3595 if let Ok(mut owners) = self.async_resource_owners.lock() {
3597 owners.insert(id, self.plugin_name.clone());
3598 }
3599 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3600 cwd: opts.cwd,
3601 direction: opts.direction,
3602 ratio: opts.ratio,
3603 focus: opts.focus,
3604 request_id: id,
3605 });
3606 Ok(id)
3607 }
3608
3609 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3611 self.command_sender
3612 .send(PluginCommand::SendTerminalInput {
3613 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3614 data,
3615 })
3616 .is_ok()
3617 }
3618
3619 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3621 self.command_sender
3622 .send(PluginCommand::CloseTerminal {
3623 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3624 })
3625 .is_ok()
3626 }
3627
3628 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
3632 self.command_sender
3633 .send(PluginCommand::RefreshLines {
3634 buffer_id: BufferId(buffer_id as usize),
3635 })
3636 .is_ok()
3637 }
3638
3639 pub fn get_current_locale(&self) -> String {
3641 self.services.current_locale()
3642 }
3643
3644 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
3648 #[qjs(rename = "_loadPluginStart")]
3649 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
3650 let id = {
3651 let mut id_ref = self.next_request_id.borrow_mut();
3652 let id = *id_ref;
3653 *id_ref += 1;
3654 self.callback_contexts
3655 .borrow_mut()
3656 .insert(id, self.plugin_name.clone());
3657 id
3658 };
3659 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
3660 path: std::path::PathBuf::from(path),
3661 callback_id: JsCallbackId::new(id),
3662 });
3663 id
3664 }
3665
3666 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
3668 #[qjs(rename = "_unloadPluginStart")]
3669 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3670 let id = {
3671 let mut id_ref = self.next_request_id.borrow_mut();
3672 let id = *id_ref;
3673 *id_ref += 1;
3674 self.callback_contexts
3675 .borrow_mut()
3676 .insert(id, self.plugin_name.clone());
3677 id
3678 };
3679 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
3680 name,
3681 callback_id: JsCallbackId::new(id),
3682 });
3683 id
3684 }
3685
3686 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3688 #[qjs(rename = "_reloadPluginStart")]
3689 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3690 let id = {
3691 let mut id_ref = self.next_request_id.borrow_mut();
3692 let id = *id_ref;
3693 *id_ref += 1;
3694 self.callback_contexts
3695 .borrow_mut()
3696 .insert(id, self.plugin_name.clone());
3697 id
3698 };
3699 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3700 name,
3701 callback_id: JsCallbackId::new(id),
3702 });
3703 id
3704 }
3705
3706 #[plugin_api(
3709 async_promise,
3710 js_name = "listPlugins",
3711 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3712 )]
3713 #[qjs(rename = "_listPluginsStart")]
3714 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3715 let id = {
3716 let mut id_ref = self.next_request_id.borrow_mut();
3717 let id = *id_ref;
3718 *id_ref += 1;
3719 self.callback_contexts
3720 .borrow_mut()
3721 .insert(id, self.plugin_name.clone());
3722 id
3723 };
3724 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3725 callback_id: JsCallbackId::new(id),
3726 });
3727 id
3728 }
3729}
3730
3731fn parse_view_token(
3738 obj: &rquickjs::Object<'_>,
3739 idx: usize,
3740) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3741 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3742
3743 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3745 from: "object",
3746 to: "ViewTokenWire",
3747 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3748 })?;
3749
3750 let source_offset: Option<usize> = obj
3752 .get("sourceOffset")
3753 .ok()
3754 .or_else(|| obj.get("source_offset").ok());
3755
3756 let kind = if kind_value.is_string() {
3758 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3761 from: "value",
3762 to: "string",
3763 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3764 })?;
3765
3766 match kind_str.to_lowercase().as_str() {
3767 "text" => {
3768 let text: String = obj.get("text").unwrap_or_default();
3769 ViewTokenWireKind::Text(text)
3770 }
3771 "newline" => ViewTokenWireKind::Newline,
3772 "space" => ViewTokenWireKind::Space,
3773 "break" => ViewTokenWireKind::Break,
3774 _ => {
3775 tracing::warn!(
3777 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3778 idx, kind_str
3779 );
3780 return Err(rquickjs::Error::FromJs {
3781 from: "string",
3782 to: "ViewTokenWireKind",
3783 message: Some(format!(
3784 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3785 idx, kind_str
3786 )),
3787 });
3788 }
3789 }
3790 } else if kind_value.is_object() {
3791 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3793 from: "value",
3794 to: "object",
3795 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3796 })?;
3797
3798 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3799 ViewTokenWireKind::Text(text)
3800 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3801 ViewTokenWireKind::BinaryByte(byte)
3802 } else {
3803 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3805 tracing::warn!(
3806 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3807 idx,
3808 keys
3809 );
3810 return Err(rquickjs::Error::FromJs {
3811 from: "object",
3812 to: "ViewTokenWireKind",
3813 message: Some(format!(
3814 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3815 idx, keys
3816 )),
3817 });
3818 }
3819 } else {
3820 tracing::warn!(
3821 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3822 idx,
3823 kind_value.type_of()
3824 );
3825 return Err(rquickjs::Error::FromJs {
3826 from: "value",
3827 to: "ViewTokenWireKind",
3828 message: Some(format!(
3829 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3830 idx
3831 )),
3832 });
3833 };
3834
3835 let style = parse_view_token_style(obj, idx)?;
3837
3838 Ok(ViewTokenWire {
3839 source_offset,
3840 kind,
3841 style,
3842 })
3843}
3844
3845fn parse_view_token_style(
3847 obj: &rquickjs::Object<'_>,
3848 idx: usize,
3849) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
3850 use fresh_core::api::ViewTokenStyle;
3851
3852 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
3853 let Some(s) = style_obj else {
3854 return Ok(None);
3855 };
3856
3857 let fg: Option<Vec<u8>> = s.get("fg").ok();
3858 let bg: Option<Vec<u8>> = s.get("bg").ok();
3859
3860 let fg_color = if let Some(ref c) = fg {
3862 if c.len() < 3 {
3863 tracing::warn!(
3864 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
3865 idx,
3866 c.len()
3867 );
3868 None
3869 } else {
3870 Some((c[0], c[1], c[2]))
3871 }
3872 } else {
3873 None
3874 };
3875
3876 let bg_color = if let Some(ref c) = bg {
3877 if c.len() < 3 {
3878 tracing::warn!(
3879 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
3880 idx,
3881 c.len()
3882 );
3883 None
3884 } else {
3885 Some((c[0], c[1], c[2]))
3886 }
3887 } else {
3888 None
3889 };
3890
3891 Ok(Some(ViewTokenStyle {
3892 fg: fg_color,
3893 bg: bg_color,
3894 bold: s.get("bold").unwrap_or(false),
3895 italic: s.get("italic").unwrap_or(false),
3896 }))
3897}
3898
3899pub struct QuickJsBackend {
3901 runtime: Runtime,
3902 main_context: Context,
3904 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
3906 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
3908 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
3910 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3912 command_sender: mpsc::Sender<PluginCommand>,
3914 #[allow(dead_code)]
3916 pending_responses: PendingResponses,
3917 next_request_id: Rc<RefCell<u64>>,
3919 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
3921 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3923 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
3925 async_resource_owners: AsyncResourceOwners,
3928}
3929
3930impl QuickJsBackend {
3931 pub fn new() -> Result<Self> {
3933 let (tx, _rx) = mpsc::channel();
3934 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3935 let services = Arc::new(fresh_core::services::NoopServiceBridge);
3936 Self::with_state(state_snapshot, tx, services)
3937 }
3938
3939 pub fn with_state(
3941 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3942 command_sender: mpsc::Sender<PluginCommand>,
3943 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3944 ) -> Result<Self> {
3945 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
3946 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
3947 }
3948
3949 pub fn with_state_and_responses(
3951 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3952 command_sender: mpsc::Sender<PluginCommand>,
3953 pending_responses: PendingResponses,
3954 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3955 ) -> Result<Self> {
3956 let async_resource_owners: AsyncResourceOwners =
3957 Arc::new(std::sync::Mutex::new(HashMap::new()));
3958 Self::with_state_responses_and_resources(
3959 state_snapshot,
3960 command_sender,
3961 pending_responses,
3962 services,
3963 async_resource_owners,
3964 )
3965 }
3966
3967 pub fn with_state_responses_and_resources(
3970 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3971 command_sender: mpsc::Sender<PluginCommand>,
3972 pending_responses: PendingResponses,
3973 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
3974 async_resource_owners: AsyncResourceOwners,
3975 ) -> Result<Self> {
3976 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
3977
3978 let runtime =
3979 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
3980
3981 runtime.set_host_promise_rejection_tracker(Some(Box::new(
3983 |_ctx, _promise, reason, is_handled| {
3984 if !is_handled {
3985 let error_msg = if let Some(exc) = reason.as_exception() {
3987 format!(
3988 "{}: {}",
3989 exc.message().unwrap_or_default(),
3990 exc.stack().unwrap_or_default()
3991 )
3992 } else {
3993 format!("{:?}", reason)
3994 };
3995
3996 tracing::error!("Unhandled Promise rejection: {}", error_msg);
3997
3998 if should_panic_on_js_errors() {
3999 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4002 set_fatal_js_error(full_msg);
4003 }
4004 }
4005 },
4006 )));
4007
4008 let main_context = Context::full(&runtime)
4009 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4010
4011 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4012 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4013 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4014 let next_request_id = Rc::new(RefCell::new(1u64));
4015 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4016 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4017
4018 let backend = Self {
4019 runtime,
4020 main_context,
4021 plugin_contexts,
4022 event_handlers,
4023 registered_actions,
4024 state_snapshot,
4025 command_sender,
4026 pending_responses,
4027 next_request_id,
4028 callback_contexts,
4029 services,
4030 plugin_tracked_state,
4031 async_resource_owners,
4032 };
4033
4034 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4036
4037 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4038 Ok(backend)
4039 }
4040
4041 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4043 let state_snapshot = Arc::clone(&self.state_snapshot);
4044 let command_sender = self.command_sender.clone();
4045 let event_handlers = Rc::clone(&self.event_handlers);
4046 let registered_actions = Rc::clone(&self.registered_actions);
4047 let next_request_id = Rc::clone(&self.next_request_id);
4048
4049 context.with(|ctx| {
4050 let globals = ctx.globals();
4051
4052 globals.set("__pluginName__", plugin_name)?;
4054
4055 let js_api = JsEditorApi {
4058 state_snapshot: Arc::clone(&state_snapshot),
4059 command_sender: command_sender.clone(),
4060 registered_actions: Rc::clone(®istered_actions),
4061 event_handlers: Rc::clone(&event_handlers),
4062 next_request_id: Rc::clone(&next_request_id),
4063 callback_contexts: Rc::clone(&self.callback_contexts),
4064 services: self.services.clone(),
4065 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4066 async_resource_owners: Arc::clone(&self.async_resource_owners),
4067 plugin_name: plugin_name.to_string(),
4068 };
4069 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4070
4071 globals.set("editor", editor)?;
4073
4074 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4076
4077 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4079
4080 let console = Object::new(ctx.clone())?;
4083 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4084 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4085 tracing::info!("console.log: {}", parts.join(" "));
4086 })?)?;
4087 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4088 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4089 tracing::warn!("console.warn: {}", parts.join(" "));
4090 })?)?;
4091 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4092 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4093 tracing::error!("console.error: {}", parts.join(" "));
4094 })?)?;
4095 globals.set("console", console)?;
4096
4097 ctx.eval::<(), _>(r#"
4099 // Pending promise callbacks: callbackId -> { resolve, reject }
4100 globalThis._pendingCallbacks = new Map();
4101
4102 // Resolve a pending callback (called from Rust)
4103 globalThis._resolveCallback = function(callbackId, result) {
4104 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4105 const cb = globalThis._pendingCallbacks.get(callbackId);
4106 if (cb) {
4107 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4108 globalThis._pendingCallbacks.delete(callbackId);
4109 cb.resolve(result);
4110 console.log('[JS] _resolveCallback: resolve() called');
4111 } else {
4112 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4113 }
4114 };
4115
4116 // Reject a pending callback (called from Rust)
4117 globalThis._rejectCallback = function(callbackId, error) {
4118 const cb = globalThis._pendingCallbacks.get(callbackId);
4119 if (cb) {
4120 globalThis._pendingCallbacks.delete(callbackId);
4121 cb.reject(new Error(error));
4122 }
4123 };
4124
4125 // Streaming callbacks: called multiple times with partial results
4126 globalThis._streamingCallbacks = new Map();
4127
4128 // Called from Rust with partial data. When done=true, cleans up.
4129 globalThis._callStreamingCallback = function(callbackId, result, done) {
4130 const cb = globalThis._streamingCallbacks.get(callbackId);
4131 if (cb) {
4132 cb(result, done);
4133 if (done) {
4134 globalThis._streamingCallbacks.delete(callbackId);
4135 }
4136 }
4137 };
4138
4139 // Generic async wrapper decorator
4140 // Wraps a function that returns a callbackId into a promise-returning function
4141 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4142 // NOTE: We pass the method name as a string and call via bracket notation
4143 // to preserve rquickjs's automatic Ctx injection for methods
4144 globalThis._wrapAsync = function(methodName, fnName) {
4145 const startFn = editor[methodName];
4146 if (typeof startFn !== 'function') {
4147 // Return a function that always throws - catches missing implementations
4148 return function(...args) {
4149 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4150 editor.debug(`[ASYNC ERROR] ${error.message}`);
4151 throw error;
4152 };
4153 }
4154 return function(...args) {
4155 // Call via bracket notation to preserve method binding and Ctx injection
4156 const callbackId = editor[methodName](...args);
4157 return new Promise((resolve, reject) => {
4158 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4159 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4160 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4161 });
4162 };
4163 };
4164
4165 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4166 // The returned object has .result promise and is itself thenable
4167 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4168 const startFn = editor[methodName];
4169 if (typeof startFn !== 'function') {
4170 // Return a function that always throws - catches missing implementations
4171 return function(...args) {
4172 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4173 editor.debug(`[ASYNC ERROR] ${error.message}`);
4174 throw error;
4175 };
4176 }
4177 return function(...args) {
4178 // Call via bracket notation to preserve method binding and Ctx injection
4179 const callbackId = editor[methodName](...args);
4180 const resultPromise = new Promise((resolve, reject) => {
4181 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4182 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4183 });
4184 return {
4185 get result() { return resultPromise; },
4186 then(onFulfilled, onRejected) {
4187 return resultPromise.then(onFulfilled, onRejected);
4188 },
4189 catch(onRejected) {
4190 return resultPromise.catch(onRejected);
4191 }
4192 };
4193 };
4194 };
4195
4196 // Apply wrappers to async functions on editor
4197 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4198 editor.delay = _wrapAsync("_delayStart", "delay");
4199 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4200 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4201 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4202 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4203 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4204 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4205 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4206 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4207 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4208 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4209 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4210 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4211 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4212 editor.prompt = _wrapAsync("_promptStart", "prompt");
4213 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4214 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4215 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4216 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4217 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4218 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4219
4220 // Streaming grep: takes a progress callback, returns a thenable with searchId
4221 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4222 opts = opts || {};
4223 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4224 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4225 const maxResults = opts.maxResults || 10000;
4226 const wholeWords = opts.wholeWords || false;
4227
4228 const searchId = editor._grepProjectStreamingStart(
4229 pattern, fixedString, caseSensitive, maxResults, wholeWords
4230 );
4231
4232 // Register streaming callback
4233 if (progressCallback) {
4234 globalThis._streamingCallbacks.set(searchId, progressCallback);
4235 }
4236
4237 // Create completion promise (resolved via _resolveCallback when search finishes)
4238 const resultPromise = new Promise(function(resolve, reject) {
4239 globalThis._pendingCallbacks.set(searchId, {
4240 resolve: function(result) {
4241 globalThis._streamingCallbacks.delete(searchId);
4242 resolve(result);
4243 },
4244 reject: function(err) {
4245 globalThis._streamingCallbacks.delete(searchId);
4246 reject(err);
4247 }
4248 });
4249 });
4250
4251 return {
4252 searchId: searchId,
4253 get result() { return resultPromise; },
4254 then: function(f, r) { return resultPromise.then(f, r); },
4255 catch: function(r) { return resultPromise.catch(r); }
4256 };
4257 };
4258
4259 // Wrapper for deleteTheme - wraps sync function in Promise
4260 editor.deleteTheme = function(name) {
4261 return new Promise(function(resolve, reject) {
4262 const success = editor._deleteThemeSync(name);
4263 if (success) {
4264 resolve();
4265 } else {
4266 reject(new Error("Failed to delete theme: " + name));
4267 }
4268 });
4269 };
4270 "#.as_bytes())?;
4271
4272 Ok::<_, rquickjs::Error>(())
4273 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4274
4275 Ok(())
4276 }
4277
4278 pub async fn load_module_with_source(
4280 &mut self,
4281 path: &str,
4282 _plugin_source: &str,
4283 ) -> Result<()> {
4284 let path_buf = PathBuf::from(path);
4285 let source = std::fs::read_to_string(&path_buf)
4286 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4287
4288 let filename = path_buf
4289 .file_name()
4290 .and_then(|s| s.to_str())
4291 .unwrap_or("plugin.ts");
4292
4293 if has_es_imports(&source) {
4295 match bundle_module(&path_buf) {
4297 Ok(bundled) => {
4298 self.execute_js(&bundled, path)?;
4299 }
4300 Err(e) => {
4301 tracing::warn!(
4302 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4303 path,
4304 e
4305 );
4306 return Ok(()); }
4308 }
4309 } else if has_es_module_syntax(&source) {
4310 let stripped = strip_imports_and_exports(&source);
4312 let js_code = if filename.ends_with(".ts") {
4313 transpile_typescript(&stripped, filename)?
4314 } else {
4315 stripped
4316 };
4317 self.execute_js(&js_code, path)?;
4318 } else {
4319 let js_code = if filename.ends_with(".ts") {
4321 transpile_typescript(&source, filename)?
4322 } else {
4323 source
4324 };
4325 self.execute_js(&js_code, path)?;
4326 }
4327
4328 Ok(())
4329 }
4330
4331 fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4333 let plugin_name = Path::new(source_name)
4335 .file_stem()
4336 .and_then(|s| s.to_str())
4337 .unwrap_or("unknown");
4338
4339 tracing::debug!(
4340 "execute_js: starting for plugin '{}' from '{}'",
4341 plugin_name,
4342 source_name
4343 );
4344
4345 let context = {
4347 let mut contexts = self.plugin_contexts.borrow_mut();
4348 if let Some(ctx) = contexts.get(plugin_name) {
4349 ctx.clone()
4350 } else {
4351 let ctx = Context::full(&self.runtime).map_err(|e| {
4352 anyhow!(
4353 "Failed to create QuickJS context for plugin {}: {}",
4354 plugin_name,
4355 e
4356 )
4357 })?;
4358 self.setup_context_api(&ctx, plugin_name)?;
4359 contexts.insert(plugin_name.to_string(), ctx.clone());
4360 ctx
4361 }
4362 };
4363
4364 let wrapped_code = format!("(function() {{ {} }})();", code);
4368 let wrapped = wrapped_code.as_str();
4369
4370 context.with(|ctx| {
4371 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4372
4373 let mut eval_options = rquickjs::context::EvalOptions::default();
4375 eval_options.global = true;
4376 eval_options.filename = Some(source_name.to_string());
4377 let result = ctx
4378 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4379 .map_err(|e| format_js_error(&ctx, e, source_name));
4380
4381 tracing::debug!(
4382 "execute_js: plugin code execution finished for '{}', result: {:?}",
4383 plugin_name,
4384 result.is_ok()
4385 );
4386
4387 result
4388 })
4389 }
4390
4391 pub fn execute_source(
4397 &mut self,
4398 source: &str,
4399 plugin_name: &str,
4400 is_typescript: bool,
4401 ) -> Result<()> {
4402 use fresh_parser_js::{
4403 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4404 };
4405
4406 if has_es_imports(source) {
4407 tracing::warn!(
4408 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4409 plugin_name
4410 );
4411 }
4412
4413 let js_code = if has_es_module_syntax(source) {
4414 let stripped = strip_imports_and_exports(source);
4415 if is_typescript {
4416 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4417 } else {
4418 stripped
4419 }
4420 } else if is_typescript {
4421 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4422 } else {
4423 source.to_string()
4424 };
4425
4426 let source_name = format!(
4428 "{}.{}",
4429 plugin_name,
4430 if is_typescript { "ts" } else { "js" }
4431 );
4432 self.execute_js(&js_code, &source_name)
4433 }
4434
4435 pub fn cleanup_plugin(&self, plugin_name: &str) {
4441 self.plugin_contexts.borrow_mut().remove(plugin_name);
4443
4444 for handlers in self.event_handlers.borrow_mut().values_mut() {
4446 handlers.retain(|h| h.plugin_name != plugin_name);
4447 }
4448
4449 self.registered_actions
4451 .borrow_mut()
4452 .retain(|_, h| h.plugin_name != plugin_name);
4453
4454 self.callback_contexts
4456 .borrow_mut()
4457 .retain(|_, pname| pname != plugin_name);
4458
4459 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4461 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4463 std::collections::HashSet::new();
4464 for (buf_id, ns) in &tracked.overlay_namespaces {
4465 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4466 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4468 buffer_id: *buf_id,
4469 namespace: OverlayNamespace::from_string(ns.clone()),
4470 });
4471 let _ = self
4473 .command_sender
4474 .send(PluginCommand::ClearConcealNamespace {
4475 buffer_id: *buf_id,
4476 namespace: OverlayNamespace::from_string(ns.clone()),
4477 });
4478 let _ = self
4479 .command_sender
4480 .send(PluginCommand::ClearSoftBreakNamespace {
4481 buffer_id: *buf_id,
4482 namespace: OverlayNamespace::from_string(ns.clone()),
4483 });
4484 }
4485 }
4486
4487 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4493 std::collections::HashSet::new();
4494 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4495 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4496 let _ = self
4497 .command_sender
4498 .send(PluginCommand::ClearLineIndicators {
4499 buffer_id: *buf_id,
4500 namespace: ns.clone(),
4501 });
4502 }
4503 }
4504
4505 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4507 std::collections::HashSet::new();
4508 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4509 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4510 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4511 buffer_id: *buf_id,
4512 virtual_text_id: vt_id.clone(),
4513 });
4514 }
4515 }
4516
4517 let mut seen_fe_ns: std::collections::HashSet<String> =
4519 std::collections::HashSet::new();
4520 for ns in &tracked.file_explorer_namespaces {
4521 if seen_fe_ns.insert(ns.clone()) {
4522 let _ = self
4523 .command_sender
4524 .send(PluginCommand::ClearFileExplorerDecorations {
4525 namespace: ns.clone(),
4526 });
4527 }
4528 }
4529
4530 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4532 for ctx_name in &tracked.contexts_set {
4533 if seen_ctx.insert(ctx_name.clone()) {
4534 let _ = self.command_sender.send(PluginCommand::SetContext {
4535 name: ctx_name.clone(),
4536 active: false,
4537 });
4538 }
4539 }
4540
4541 for process_id in &tracked.background_process_ids {
4545 let _ = self
4546 .command_sender
4547 .send(PluginCommand::KillBackgroundProcess {
4548 process_id: *process_id,
4549 });
4550 }
4551
4552 for group_id in &tracked.scroll_sync_group_ids {
4554 let _ = self
4555 .command_sender
4556 .send(PluginCommand::RemoveScrollSyncGroup {
4557 group_id: *group_id,
4558 });
4559 }
4560
4561 for buffer_id in &tracked.virtual_buffer_ids {
4563 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4564 buffer_id: *buffer_id,
4565 });
4566 }
4567
4568 for buffer_id in &tracked.composite_buffer_ids {
4570 let _ = self
4571 .command_sender
4572 .send(PluginCommand::CloseCompositeBuffer {
4573 buffer_id: *buffer_id,
4574 });
4575 }
4576
4577 for terminal_id in &tracked.terminal_ids {
4579 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4580 terminal_id: *terminal_id,
4581 });
4582 }
4583 }
4584
4585 if let Ok(mut owners) = self.async_resource_owners.lock() {
4587 owners.retain(|_, name| name != plugin_name);
4588 }
4589
4590 tracing::debug!(
4591 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
4592 plugin_name
4593 );
4594 }
4595
4596 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
4598 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
4599
4600 self.services
4601 .set_js_execution_state(format!("hook '{}'", event_name));
4602
4603 let handlers = self.event_handlers.borrow().get(event_name).cloned();
4604 if let Some(handler_pairs) = handlers {
4605 let plugin_contexts = self.plugin_contexts.borrow();
4606 for handler in &handler_pairs {
4607 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
4608 continue;
4609 };
4610 context.with(|ctx| {
4611 call_handler(&ctx, &handler.handler_name, event_data);
4612 });
4613 }
4614 }
4615
4616 self.services.clear_js_execution_state();
4617 Ok(true)
4618 }
4619
4620 pub fn has_handlers(&self, event_name: &str) -> bool {
4622 self.event_handlers
4623 .borrow()
4624 .get(event_name)
4625 .map(|v| !v.is_empty())
4626 .unwrap_or(false)
4627 }
4628
4629 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
4633 let (lookup_name, text_input_char) =
4636 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
4637 ("mode_text_input", Some(ch.to_string()))
4638 } else {
4639 (action_name, None)
4640 };
4641
4642 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
4643 let (plugin_name, function_name) = match pair {
4644 Some(handler) => (handler.plugin_name, handler.handler_name),
4645 None => ("main".to_string(), lookup_name.to_string()),
4646 };
4647
4648 let plugin_contexts = self.plugin_contexts.borrow();
4649 let context = plugin_contexts
4650 .get(&plugin_name)
4651 .unwrap_or(&self.main_context);
4652
4653 self.services
4655 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
4656
4657 tracing::info!(
4658 "start_action: BEGIN '{}' -> function '{}'",
4659 action_name,
4660 function_name
4661 );
4662
4663 let call_args = if let Some(ref ch) = text_input_char {
4666 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
4667 format!("({{text:\"{}\"}})", escaped)
4668 } else {
4669 "()".to_string()
4670 };
4671
4672 let code = format!(
4673 r#"
4674 (function() {{
4675 console.log('[JS] start_action: calling {fn}');
4676 try {{
4677 if (typeof globalThis.{fn} === 'function') {{
4678 console.log('[JS] start_action: {fn} is a function, invoking...');
4679 globalThis.{fn}{args};
4680 console.log('[JS] start_action: {fn} invoked (may be async)');
4681 }} else {{
4682 console.error('[JS] Action {action} is not defined as a global function');
4683 }}
4684 }} catch (e) {{
4685 console.error('[JS] Action {action} error:', e);
4686 }}
4687 }})();
4688 "#,
4689 fn = function_name,
4690 action = action_name,
4691 args = call_args
4692 );
4693
4694 tracing::info!("start_action: evaluating JS code");
4695 context.with(|ctx| {
4696 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4697 log_js_error(&ctx, e, &format!("action {}", action_name));
4698 }
4699 tracing::info!("start_action: running pending microtasks");
4700 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
4702 tracing::info!("start_action: executed {} pending jobs", count);
4703 });
4704
4705 tracing::info!("start_action: END '{}'", action_name);
4706
4707 self.services.clear_js_execution_state();
4709
4710 Ok(())
4711 }
4712
4713 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
4715 let pair = self.registered_actions.borrow().get(action_name).cloned();
4717 let (plugin_name, function_name) = match pair {
4718 Some(handler) => (handler.plugin_name, handler.handler_name),
4719 None => ("main".to_string(), action_name.to_string()),
4720 };
4721
4722 let plugin_contexts = self.plugin_contexts.borrow();
4723 let context = plugin_contexts
4724 .get(&plugin_name)
4725 .unwrap_or(&self.main_context);
4726
4727 tracing::debug!(
4728 "execute_action: '{}' -> function '{}'",
4729 action_name,
4730 function_name
4731 );
4732
4733 let code = format!(
4736 r#"
4737 (async function() {{
4738 try {{
4739 if (typeof globalThis.{fn} === 'function') {{
4740 const result = globalThis.{fn}();
4741 // If it's a Promise, await it
4742 if (result && typeof result.then === 'function') {{
4743 await result;
4744 }}
4745 }} else {{
4746 console.error('Action {action} is not defined as a global function');
4747 }}
4748 }} catch (e) {{
4749 console.error('Action {action} error:', e);
4750 }}
4751 }})();
4752 "#,
4753 fn = function_name,
4754 action = action_name
4755 );
4756
4757 context.with(|ctx| {
4758 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4760 Ok(value) => {
4761 if value.is_object() {
4763 if let Some(obj) = value.as_object() {
4764 if obj.get::<_, rquickjs::Function>("then").is_ok() {
4766 run_pending_jobs_checked(
4769 &ctx,
4770 &format!("execute_action {} promise", action_name),
4771 );
4772 }
4773 }
4774 }
4775 }
4776 Err(e) => {
4777 log_js_error(&ctx, e, &format!("action {}", action_name));
4778 }
4779 }
4780 });
4781
4782 Ok(())
4783 }
4784
4785 pub fn poll_event_loop_once(&mut self) -> bool {
4787 let mut had_work = false;
4788
4789 self.main_context.with(|ctx| {
4791 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
4792 if count > 0 {
4793 had_work = true;
4794 }
4795 });
4796
4797 let contexts = self.plugin_contexts.borrow().clone();
4799 for (name, context) in contexts {
4800 context.with(|ctx| {
4801 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
4802 if count > 0 {
4803 had_work = true;
4804 }
4805 });
4806 }
4807 had_work
4808 }
4809
4810 pub fn send_status(&self, message: String) {
4812 let _ = self
4813 .command_sender
4814 .send(PluginCommand::SetStatus { message });
4815 }
4816
4817 pub fn send_hook_completed(&self, hook_name: String) {
4821 let _ = self
4822 .command_sender
4823 .send(PluginCommand::HookCompleted { hook_name });
4824 }
4825
4826 pub fn resolve_callback(
4831 &mut self,
4832 callback_id: fresh_core::api::JsCallbackId,
4833 result_json: &str,
4834 ) {
4835 let id = callback_id.as_u64();
4836 tracing::debug!("resolve_callback: starting for callback_id={}", id);
4837
4838 let plugin_name = {
4840 let mut contexts = self.callback_contexts.borrow_mut();
4841 contexts.remove(&id)
4842 };
4843
4844 let Some(name) = plugin_name else {
4845 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
4846 return;
4847 };
4848
4849 let plugin_contexts = self.plugin_contexts.borrow();
4850 let Some(context) = plugin_contexts.get(&name) else {
4851 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
4852 return;
4853 };
4854
4855 context.with(|ctx| {
4856 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
4858 Ok(v) => v,
4859 Err(e) => {
4860 tracing::error!(
4861 "resolve_callback: failed to parse JSON for callback_id={}: {}",
4862 id,
4863 e
4864 );
4865 return;
4866 }
4867 };
4868
4869 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
4871 Ok(v) => v,
4872 Err(e) => {
4873 tracing::error!(
4874 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
4875 id,
4876 e
4877 );
4878 return;
4879 }
4880 };
4881
4882 let globals = ctx.globals();
4884 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
4885 Ok(f) => f,
4886 Err(e) => {
4887 tracing::error!(
4888 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
4889 id,
4890 e
4891 );
4892 return;
4893 }
4894 };
4895
4896 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
4898 log_js_error(&ctx, e, &format!("resolving callback {}", id));
4899 }
4900
4901 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
4903 tracing::info!(
4904 "resolve_callback: executed {} pending jobs for callback_id={}",
4905 job_count,
4906 id
4907 );
4908 });
4909 }
4910
4911 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
4913 let id = callback_id.as_u64();
4914
4915 let plugin_name = {
4917 let mut contexts = self.callback_contexts.borrow_mut();
4918 contexts.remove(&id)
4919 };
4920
4921 let Some(name) = plugin_name else {
4922 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
4923 return;
4924 };
4925
4926 let plugin_contexts = self.plugin_contexts.borrow();
4927 let Some(context) = plugin_contexts.get(&name) else {
4928 tracing::warn!("reject_callback: Context lost for plugin {}", name);
4929 return;
4930 };
4931
4932 context.with(|ctx| {
4933 let globals = ctx.globals();
4935 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
4936 Ok(f) => f,
4937 Err(e) => {
4938 tracing::error!(
4939 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
4940 id,
4941 e
4942 );
4943 return;
4944 }
4945 };
4946
4947 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
4949 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
4950 }
4951
4952 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
4954 });
4955 }
4956
4957 pub fn call_streaming_callback(
4961 &mut self,
4962 callback_id: fresh_core::api::JsCallbackId,
4963 result_json: &str,
4964 done: bool,
4965 ) {
4966 let id = callback_id.as_u64();
4967
4968 let plugin_name = {
4970 let contexts = self.callback_contexts.borrow();
4971 contexts.get(&id).cloned()
4972 };
4973
4974 let Some(name) = plugin_name else {
4975 tracing::warn!(
4976 "call_streaming_callback: No plugin found for callback_id={}",
4977 id
4978 );
4979 return;
4980 };
4981
4982 if done {
4984 self.callback_contexts.borrow_mut().remove(&id);
4985 }
4986
4987 let plugin_contexts = self.plugin_contexts.borrow();
4988 let Some(context) = plugin_contexts.get(&name) else {
4989 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
4990 return;
4991 };
4992
4993 context.with(|ctx| {
4994 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
4995 Ok(v) => v,
4996 Err(e) => {
4997 tracing::error!(
4998 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
4999 id,
5000 e
5001 );
5002 return;
5003 }
5004 };
5005
5006 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5007 Ok(v) => v,
5008 Err(e) => {
5009 tracing::error!(
5010 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5011 id,
5012 e
5013 );
5014 return;
5015 }
5016 };
5017
5018 let globals = ctx.globals();
5019 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5020 Ok(f) => f,
5021 Err(e) => {
5022 tracing::error!(
5023 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5024 id,
5025 e
5026 );
5027 return;
5028 }
5029 };
5030
5031 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5032 log_js_error(
5033 &ctx,
5034 e,
5035 &format!("calling streaming callback {}", id),
5036 );
5037 }
5038
5039 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5040 });
5041 }
5042}
5043
5044#[cfg(test)]
5045mod tests {
5046 use super::*;
5047 use fresh_core::api::{BufferInfo, CursorInfo};
5048 use std::sync::mpsc;
5049
5050 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5052 let (tx, rx) = mpsc::channel();
5053 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5054 let services = Arc::new(TestServiceBridge::new());
5055 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5056 (backend, rx)
5057 }
5058
5059 struct TestServiceBridge {
5060 en_strings: std::sync::Mutex<HashMap<String, String>>,
5061 }
5062
5063 impl TestServiceBridge {
5064 fn new() -> Self {
5065 Self {
5066 en_strings: std::sync::Mutex::new(HashMap::new()),
5067 }
5068 }
5069 }
5070
5071 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5072 fn as_any(&self) -> &dyn std::any::Any {
5073 self
5074 }
5075 fn translate(
5076 &self,
5077 _plugin_name: &str,
5078 key: &str,
5079 _args: &HashMap<String, String>,
5080 ) -> String {
5081 self.en_strings
5082 .lock()
5083 .unwrap()
5084 .get(key)
5085 .cloned()
5086 .unwrap_or_else(|| key.to_string())
5087 }
5088 fn current_locale(&self) -> String {
5089 "en".to_string()
5090 }
5091 fn set_js_execution_state(&self, _state: String) {}
5092 fn clear_js_execution_state(&self) {}
5093 fn get_theme_schema(&self) -> serde_json::Value {
5094 serde_json::json!({})
5095 }
5096 fn get_builtin_themes(&self) -> serde_json::Value {
5097 serde_json::json!([])
5098 }
5099 fn register_command(&self, _command: fresh_core::command::Command) {}
5100 fn unregister_command(&self, _name: &str) {}
5101 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5102 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5103 fn plugins_dir(&self) -> std::path::PathBuf {
5104 std::path::PathBuf::from("/tmp/plugins")
5105 }
5106 fn config_dir(&self) -> std::path::PathBuf {
5107 std::path::PathBuf::from("/tmp/config")
5108 }
5109 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5110 None
5111 }
5112 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5113 Err("not implemented in test".to_string())
5114 }
5115 fn theme_file_exists(&self, _name: &str) -> bool {
5116 false
5117 }
5118 }
5119
5120 #[test]
5121 fn test_quickjs_backend_creation() {
5122 let backend = QuickJsBackend::new();
5123 assert!(backend.is_ok());
5124 }
5125
5126 #[test]
5127 fn test_execute_simple_js() {
5128 let mut backend = QuickJsBackend::new().unwrap();
5129 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5130 assert!(result.is_ok());
5131 }
5132
5133 #[test]
5134 fn test_event_handler_registration() {
5135 let backend = QuickJsBackend::new().unwrap();
5136
5137 assert!(!backend.has_handlers("test_event"));
5139
5140 backend
5142 .event_handlers
5143 .borrow_mut()
5144 .entry("test_event".to_string())
5145 .or_default()
5146 .push(PluginHandler {
5147 plugin_name: "test".to_string(),
5148 handler_name: "testHandler".to_string(),
5149 });
5150
5151 assert!(backend.has_handlers("test_event"));
5153 }
5154
5155 #[test]
5158 fn test_api_set_status() {
5159 let (mut backend, rx) = create_test_backend();
5160
5161 backend
5162 .execute_js(
5163 r#"
5164 const editor = getEditor();
5165 editor.setStatus("Hello from test");
5166 "#,
5167 "test.js",
5168 )
5169 .unwrap();
5170
5171 let cmd = rx.try_recv().unwrap();
5172 match cmd {
5173 PluginCommand::SetStatus { message } => {
5174 assert_eq!(message, "Hello from test");
5175 }
5176 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5177 }
5178 }
5179
5180 #[test]
5181 fn test_api_register_command() {
5182 let (mut backend, rx) = create_test_backend();
5183
5184 backend
5185 .execute_js(
5186 r#"
5187 const editor = getEditor();
5188 globalThis.myTestHandler = function() { };
5189 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5190 "#,
5191 "test_plugin.js",
5192 )
5193 .unwrap();
5194
5195 let cmd = rx.try_recv().unwrap();
5196 match cmd {
5197 PluginCommand::RegisterCommand { command } => {
5198 assert_eq!(command.name, "Test Command");
5199 assert_eq!(command.description, "A test command");
5200 assert_eq!(command.plugin_name, "test_plugin");
5202 }
5203 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5204 }
5205 }
5206
5207 #[test]
5208 fn test_api_define_mode() {
5209 let (mut backend, rx) = create_test_backend();
5210
5211 backend
5212 .execute_js(
5213 r#"
5214 const editor = getEditor();
5215 editor.defineMode("test-mode", [
5216 ["a", "action_a"],
5217 ["b", "action_b"]
5218 ]);
5219 "#,
5220 "test.js",
5221 )
5222 .unwrap();
5223
5224 let cmd = rx.try_recv().unwrap();
5225 match cmd {
5226 PluginCommand::DefineMode {
5227 name,
5228 bindings,
5229 read_only,
5230 allow_text_input,
5231 plugin_name,
5232 } => {
5233 assert_eq!(name, "test-mode");
5234 assert_eq!(bindings.len(), 2);
5235 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5236 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5237 assert!(!read_only);
5238 assert!(!allow_text_input);
5239 assert!(plugin_name.is_some());
5240 }
5241 _ => panic!("Expected DefineMode, got {:?}", cmd),
5242 }
5243 }
5244
5245 #[test]
5246 fn test_api_set_editor_mode() {
5247 let (mut backend, rx) = create_test_backend();
5248
5249 backend
5250 .execute_js(
5251 r#"
5252 const editor = getEditor();
5253 editor.setEditorMode("vi-normal");
5254 "#,
5255 "test.js",
5256 )
5257 .unwrap();
5258
5259 let cmd = rx.try_recv().unwrap();
5260 match cmd {
5261 PluginCommand::SetEditorMode { mode } => {
5262 assert_eq!(mode, Some("vi-normal".to_string()));
5263 }
5264 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5265 }
5266 }
5267
5268 #[test]
5269 fn test_api_clear_editor_mode() {
5270 let (mut backend, rx) = create_test_backend();
5271
5272 backend
5273 .execute_js(
5274 r#"
5275 const editor = getEditor();
5276 editor.setEditorMode(null);
5277 "#,
5278 "test.js",
5279 )
5280 .unwrap();
5281
5282 let cmd = rx.try_recv().unwrap();
5283 match cmd {
5284 PluginCommand::SetEditorMode { mode } => {
5285 assert!(mode.is_none());
5286 }
5287 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5288 }
5289 }
5290
5291 #[test]
5292 fn test_api_insert_at_cursor() {
5293 let (mut backend, rx) = create_test_backend();
5294
5295 backend
5296 .execute_js(
5297 r#"
5298 const editor = getEditor();
5299 editor.insertAtCursor("Hello, World!");
5300 "#,
5301 "test.js",
5302 )
5303 .unwrap();
5304
5305 let cmd = rx.try_recv().unwrap();
5306 match cmd {
5307 PluginCommand::InsertAtCursor { text } => {
5308 assert_eq!(text, "Hello, World!");
5309 }
5310 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5311 }
5312 }
5313
5314 #[test]
5315 fn test_api_set_context() {
5316 let (mut backend, rx) = create_test_backend();
5317
5318 backend
5319 .execute_js(
5320 r#"
5321 const editor = getEditor();
5322 editor.setContext("myContext", true);
5323 "#,
5324 "test.js",
5325 )
5326 .unwrap();
5327
5328 let cmd = rx.try_recv().unwrap();
5329 match cmd {
5330 PluginCommand::SetContext { name, active } => {
5331 assert_eq!(name, "myContext");
5332 assert!(active);
5333 }
5334 _ => panic!("Expected SetContext, got {:?}", cmd),
5335 }
5336 }
5337
5338 #[tokio::test]
5339 async fn test_execute_action_sync_function() {
5340 let (mut backend, rx) = create_test_backend();
5341
5342 backend.registered_actions.borrow_mut().insert(
5344 "my_sync_action".to_string(),
5345 PluginHandler {
5346 plugin_name: "test".to_string(),
5347 handler_name: "my_sync_action".to_string(),
5348 },
5349 );
5350
5351 backend
5353 .execute_js(
5354 r#"
5355 const editor = getEditor();
5356 globalThis.my_sync_action = function() {
5357 editor.setStatus("sync action executed");
5358 };
5359 "#,
5360 "test.js",
5361 )
5362 .unwrap();
5363
5364 while rx.try_recv().is_ok() {}
5366
5367 backend.execute_action("my_sync_action").await.unwrap();
5369
5370 let cmd = rx.try_recv().unwrap();
5372 match cmd {
5373 PluginCommand::SetStatus { message } => {
5374 assert_eq!(message, "sync action executed");
5375 }
5376 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5377 }
5378 }
5379
5380 #[tokio::test]
5381 async fn test_execute_action_async_function() {
5382 let (mut backend, rx) = create_test_backend();
5383
5384 backend.registered_actions.borrow_mut().insert(
5386 "my_async_action".to_string(),
5387 PluginHandler {
5388 plugin_name: "test".to_string(),
5389 handler_name: "my_async_action".to_string(),
5390 },
5391 );
5392
5393 backend
5395 .execute_js(
5396 r#"
5397 const editor = getEditor();
5398 globalThis.my_async_action = async function() {
5399 await Promise.resolve();
5400 editor.setStatus("async action executed");
5401 };
5402 "#,
5403 "test.js",
5404 )
5405 .unwrap();
5406
5407 while rx.try_recv().is_ok() {}
5409
5410 backend.execute_action("my_async_action").await.unwrap();
5412
5413 let cmd = rx.try_recv().unwrap();
5415 match cmd {
5416 PluginCommand::SetStatus { message } => {
5417 assert_eq!(message, "async action executed");
5418 }
5419 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5420 }
5421 }
5422
5423 #[tokio::test]
5424 async fn test_execute_action_with_registered_handler() {
5425 let (mut backend, rx) = create_test_backend();
5426
5427 backend.registered_actions.borrow_mut().insert(
5429 "my_action".to_string(),
5430 PluginHandler {
5431 plugin_name: "test".to_string(),
5432 handler_name: "actual_handler_function".to_string(),
5433 },
5434 );
5435
5436 backend
5437 .execute_js(
5438 r#"
5439 const editor = getEditor();
5440 globalThis.actual_handler_function = function() {
5441 editor.setStatus("handler executed");
5442 };
5443 "#,
5444 "test.js",
5445 )
5446 .unwrap();
5447
5448 while rx.try_recv().is_ok() {}
5450
5451 backend.execute_action("my_action").await.unwrap();
5453
5454 let cmd = rx.try_recv().unwrap();
5455 match cmd {
5456 PluginCommand::SetStatus { message } => {
5457 assert_eq!(message, "handler executed");
5458 }
5459 _ => panic!("Expected SetStatus, got {:?}", cmd),
5460 }
5461 }
5462
5463 #[test]
5464 fn test_api_on_event_registration() {
5465 let (mut backend, _rx) = create_test_backend();
5466
5467 backend
5468 .execute_js(
5469 r#"
5470 const editor = getEditor();
5471 globalThis.myEventHandler = function() { };
5472 editor.on("bufferSave", "myEventHandler");
5473 "#,
5474 "test.js",
5475 )
5476 .unwrap();
5477
5478 assert!(backend.has_handlers("bufferSave"));
5479 }
5480
5481 #[test]
5482 fn test_api_off_event_unregistration() {
5483 let (mut backend, _rx) = create_test_backend();
5484
5485 backend
5486 .execute_js(
5487 r#"
5488 const editor = getEditor();
5489 globalThis.myEventHandler = function() { };
5490 editor.on("bufferSave", "myEventHandler");
5491 editor.off("bufferSave", "myEventHandler");
5492 "#,
5493 "test.js",
5494 )
5495 .unwrap();
5496
5497 assert!(!backend.has_handlers("bufferSave"));
5499 }
5500
5501 #[tokio::test]
5502 async fn test_emit_event() {
5503 let (mut backend, rx) = create_test_backend();
5504
5505 backend
5506 .execute_js(
5507 r#"
5508 const editor = getEditor();
5509 globalThis.onSaveHandler = function(data) {
5510 editor.setStatus("saved: " + JSON.stringify(data));
5511 };
5512 editor.on("bufferSave", "onSaveHandler");
5513 "#,
5514 "test.js",
5515 )
5516 .unwrap();
5517
5518 while rx.try_recv().is_ok() {}
5520
5521 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5523 backend.emit("bufferSave", &event_data).await.unwrap();
5524
5525 let cmd = rx.try_recv().unwrap();
5526 match cmd {
5527 PluginCommand::SetStatus { message } => {
5528 assert!(message.contains("/test.txt"));
5529 }
5530 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5531 }
5532 }
5533
5534 #[test]
5535 fn test_api_copy_to_clipboard() {
5536 let (mut backend, rx) = create_test_backend();
5537
5538 backend
5539 .execute_js(
5540 r#"
5541 const editor = getEditor();
5542 editor.copyToClipboard("clipboard text");
5543 "#,
5544 "test.js",
5545 )
5546 .unwrap();
5547
5548 let cmd = rx.try_recv().unwrap();
5549 match cmd {
5550 PluginCommand::SetClipboard { text } => {
5551 assert_eq!(text, "clipboard text");
5552 }
5553 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5554 }
5555 }
5556
5557 #[test]
5558 fn test_api_open_file() {
5559 let (mut backend, rx) = create_test_backend();
5560
5561 backend
5563 .execute_js(
5564 r#"
5565 const editor = getEditor();
5566 editor.openFile("/path/to/file.txt", null, null);
5567 "#,
5568 "test.js",
5569 )
5570 .unwrap();
5571
5572 let cmd = rx.try_recv().unwrap();
5573 match cmd {
5574 PluginCommand::OpenFileAtLocation { path, line, column } => {
5575 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5576 assert!(line.is_none());
5577 assert!(column.is_none());
5578 }
5579 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5580 }
5581 }
5582
5583 #[test]
5584 fn test_api_delete_range() {
5585 let (mut backend, rx) = create_test_backend();
5586
5587 backend
5589 .execute_js(
5590 r#"
5591 const editor = getEditor();
5592 editor.deleteRange(0, 10, 20);
5593 "#,
5594 "test.js",
5595 )
5596 .unwrap();
5597
5598 let cmd = rx.try_recv().unwrap();
5599 match cmd {
5600 PluginCommand::DeleteRange { range, .. } => {
5601 assert_eq!(range.start, 10);
5602 assert_eq!(range.end, 20);
5603 }
5604 _ => panic!("Expected DeleteRange, got {:?}", cmd),
5605 }
5606 }
5607
5608 #[test]
5609 fn test_api_insert_text() {
5610 let (mut backend, rx) = create_test_backend();
5611
5612 backend
5614 .execute_js(
5615 r#"
5616 const editor = getEditor();
5617 editor.insertText(0, 5, "inserted");
5618 "#,
5619 "test.js",
5620 )
5621 .unwrap();
5622
5623 let cmd = rx.try_recv().unwrap();
5624 match cmd {
5625 PluginCommand::InsertText { position, text, .. } => {
5626 assert_eq!(position, 5);
5627 assert_eq!(text, "inserted");
5628 }
5629 _ => panic!("Expected InsertText, got {:?}", cmd),
5630 }
5631 }
5632
5633 #[test]
5634 fn test_api_set_buffer_cursor() {
5635 let (mut backend, rx) = create_test_backend();
5636
5637 backend
5639 .execute_js(
5640 r#"
5641 const editor = getEditor();
5642 editor.setBufferCursor(0, 100);
5643 "#,
5644 "test.js",
5645 )
5646 .unwrap();
5647
5648 let cmd = rx.try_recv().unwrap();
5649 match cmd {
5650 PluginCommand::SetBufferCursor { position, .. } => {
5651 assert_eq!(position, 100);
5652 }
5653 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
5654 }
5655 }
5656
5657 #[test]
5658 fn test_api_get_cursor_position_from_state() {
5659 let (tx, _rx) = mpsc::channel();
5660 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5661
5662 {
5664 let mut state = state_snapshot.write().unwrap();
5665 state.primary_cursor = Some(CursorInfo {
5666 position: 42,
5667 selection: None,
5668 });
5669 }
5670
5671 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5672 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5673
5674 backend
5676 .execute_js(
5677 r#"
5678 const editor = getEditor();
5679 const pos = editor.getCursorPosition();
5680 globalThis._testResult = pos;
5681 "#,
5682 "test.js",
5683 )
5684 .unwrap();
5685
5686 backend
5688 .plugin_contexts
5689 .borrow()
5690 .get("test")
5691 .unwrap()
5692 .clone()
5693 .with(|ctx| {
5694 let global = ctx.globals();
5695 let result: u32 = global.get("_testResult").unwrap();
5696 assert_eq!(result, 42);
5697 });
5698 }
5699
5700 #[test]
5701 fn test_api_path_functions() {
5702 let (mut backend, _rx) = create_test_backend();
5703
5704 #[cfg(windows)]
5707 let absolute_path = r#"C:\\foo\\bar"#;
5708 #[cfg(not(windows))]
5709 let absolute_path = "/foo/bar";
5710
5711 let js_code = format!(
5713 r#"
5714 const editor = getEditor();
5715 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
5716 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
5717 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
5718 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
5719 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
5720 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
5721 "#,
5722 absolute_path
5723 );
5724 backend.execute_js(&js_code, "test.js").unwrap();
5725
5726 backend
5727 .plugin_contexts
5728 .borrow()
5729 .get("test")
5730 .unwrap()
5731 .clone()
5732 .with(|ctx| {
5733 let global = ctx.globals();
5734 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
5735 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
5736 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
5737 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
5738 assert!(!global.get::<_, bool>("_isRelative").unwrap());
5739 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
5740 });
5741 }
5742
5743 #[test]
5744 fn test_file_uri_to_path_and_back() {
5745 let (mut backend, _rx) = create_test_backend();
5746
5747 #[cfg(not(windows))]
5749 let js_code = r#"
5750 const editor = getEditor();
5751 // Basic file URI to path
5752 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
5753 // Percent-encoded characters
5754 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
5755 // Invalid URI returns empty string
5756 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5757 // Path to file URI
5758 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
5759 // Round-trip
5760 globalThis._roundtrip = editor.fileUriToPath(
5761 editor.pathToFileUri("/home/user/file.txt")
5762 );
5763 "#;
5764
5765 #[cfg(windows)]
5766 let js_code = r#"
5767 const editor = getEditor();
5768 // Windows URI with encoded colon (the bug from issue #1071)
5769 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
5770 // Windows URI with normal colon
5771 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
5772 // Invalid URI returns empty string
5773 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5774 // Path to file URI
5775 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
5776 // Round-trip
5777 globalThis._roundtrip = editor.fileUriToPath(
5778 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
5779 );
5780 "#;
5781
5782 backend.execute_js(js_code, "test.js").unwrap();
5783
5784 backend
5785 .plugin_contexts
5786 .borrow()
5787 .get("test")
5788 .unwrap()
5789 .clone()
5790 .with(|ctx| {
5791 let global = ctx.globals();
5792
5793 #[cfg(not(windows))]
5794 {
5795 assert_eq!(
5796 global.get::<_, String>("_path1").unwrap(),
5797 "/home/user/file.txt"
5798 );
5799 assert_eq!(
5800 global.get::<_, String>("_path2").unwrap(),
5801 "/home/user/my file.txt"
5802 );
5803 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5804 assert_eq!(
5805 global.get::<_, String>("_uri1").unwrap(),
5806 "file:///home/user/file.txt"
5807 );
5808 assert_eq!(
5809 global.get::<_, String>("_roundtrip").unwrap(),
5810 "/home/user/file.txt"
5811 );
5812 }
5813
5814 #[cfg(windows)]
5815 {
5816 assert_eq!(
5818 global.get::<_, String>("_path1").unwrap(),
5819 "C:\\Users\\admin\\Repos\\file.cs"
5820 );
5821 assert_eq!(
5822 global.get::<_, String>("_path2").unwrap(),
5823 "C:\\Users\\admin\\Repos\\file.cs"
5824 );
5825 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5826 assert_eq!(
5827 global.get::<_, String>("_uri1").unwrap(),
5828 "file:///C:/Users/admin/Repos/file.cs"
5829 );
5830 assert_eq!(
5831 global.get::<_, String>("_roundtrip").unwrap(),
5832 "C:\\Users\\admin\\Repos\\file.cs"
5833 );
5834 }
5835 });
5836 }
5837
5838 #[test]
5839 fn test_typescript_transpilation() {
5840 use fresh_parser_js::transpile_typescript;
5841
5842 let (mut backend, rx) = create_test_backend();
5843
5844 let ts_code = r#"
5846 const editor = getEditor();
5847 function greet(name: string): string {
5848 return "Hello, " + name;
5849 }
5850 editor.setStatus(greet("TypeScript"));
5851 "#;
5852
5853 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
5855
5856 backend.execute_js(&js_code, "test.js").unwrap();
5858
5859 let cmd = rx.try_recv().unwrap();
5860 match cmd {
5861 PluginCommand::SetStatus { message } => {
5862 assert_eq!(message, "Hello, TypeScript");
5863 }
5864 _ => panic!("Expected SetStatus, got {:?}", cmd),
5865 }
5866 }
5867
5868 #[test]
5869 fn test_api_get_buffer_text_sends_command() {
5870 let (mut backend, rx) = create_test_backend();
5871
5872 backend
5874 .execute_js(
5875 r#"
5876 const editor = getEditor();
5877 // Store the promise for later
5878 globalThis._textPromise = editor.getBufferText(0, 10, 20);
5879 "#,
5880 "test.js",
5881 )
5882 .unwrap();
5883
5884 let cmd = rx.try_recv().unwrap();
5886 match cmd {
5887 PluginCommand::GetBufferText {
5888 buffer_id,
5889 start,
5890 end,
5891 request_id,
5892 } => {
5893 assert_eq!(buffer_id.0, 0);
5894 assert_eq!(start, 10);
5895 assert_eq!(end, 20);
5896 assert!(request_id > 0); }
5898 _ => panic!("Expected GetBufferText, got {:?}", cmd),
5899 }
5900 }
5901
5902 #[test]
5903 fn test_api_get_buffer_text_resolves_callback() {
5904 let (mut backend, rx) = create_test_backend();
5905
5906 backend
5908 .execute_js(
5909 r#"
5910 const editor = getEditor();
5911 globalThis._resolvedText = null;
5912 editor.getBufferText(0, 0, 100).then(text => {
5913 globalThis._resolvedText = text;
5914 });
5915 "#,
5916 "test.js",
5917 )
5918 .unwrap();
5919
5920 let request_id = match rx.try_recv().unwrap() {
5922 PluginCommand::GetBufferText { request_id, .. } => request_id,
5923 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
5924 };
5925
5926 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
5928
5929 backend
5931 .plugin_contexts
5932 .borrow()
5933 .get("test")
5934 .unwrap()
5935 .clone()
5936 .with(|ctx| {
5937 run_pending_jobs_checked(&ctx, "test async getText");
5938 });
5939
5940 backend
5942 .plugin_contexts
5943 .borrow()
5944 .get("test")
5945 .unwrap()
5946 .clone()
5947 .with(|ctx| {
5948 let global = ctx.globals();
5949 let result: String = global.get("_resolvedText").unwrap();
5950 assert_eq!(result, "hello world");
5951 });
5952 }
5953
5954 #[test]
5955 fn test_plugin_translation() {
5956 let (mut backend, _rx) = create_test_backend();
5957
5958 backend
5960 .execute_js(
5961 r#"
5962 const editor = getEditor();
5963 globalThis._translated = editor.t("test.key");
5964 "#,
5965 "test.js",
5966 )
5967 .unwrap();
5968
5969 backend
5970 .plugin_contexts
5971 .borrow()
5972 .get("test")
5973 .unwrap()
5974 .clone()
5975 .with(|ctx| {
5976 let global = ctx.globals();
5977 let result: String = global.get("_translated").unwrap();
5979 assert_eq!(result, "test.key");
5980 });
5981 }
5982
5983 #[test]
5984 fn test_plugin_translation_with_registered_strings() {
5985 let (mut backend, _rx) = create_test_backend();
5986
5987 let mut en_strings = std::collections::HashMap::new();
5989 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
5990 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
5991
5992 let mut strings = std::collections::HashMap::new();
5993 strings.insert("en".to_string(), en_strings);
5994
5995 if let Some(bridge) = backend
5997 .services
5998 .as_any()
5999 .downcast_ref::<TestServiceBridge>()
6000 {
6001 let mut en = bridge.en_strings.lock().unwrap();
6002 en.insert("greeting".to_string(), "Hello, World!".to_string());
6003 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6004 }
6005
6006 backend
6008 .execute_js(
6009 r#"
6010 const editor = getEditor();
6011 globalThis._greeting = editor.t("greeting");
6012 globalThis._prompt = editor.t("prompt.find_file");
6013 globalThis._missing = editor.t("nonexistent.key");
6014 "#,
6015 "test.js",
6016 )
6017 .unwrap();
6018
6019 backend
6020 .plugin_contexts
6021 .borrow()
6022 .get("test")
6023 .unwrap()
6024 .clone()
6025 .with(|ctx| {
6026 let global = ctx.globals();
6027 let greeting: String = global.get("_greeting").unwrap();
6028 assert_eq!(greeting, "Hello, World!");
6029
6030 let prompt: String = global.get("_prompt").unwrap();
6031 assert_eq!(prompt, "Find file: ");
6032
6033 let missing: String = global.get("_missing").unwrap();
6035 assert_eq!(missing, "nonexistent.key");
6036 });
6037 }
6038
6039 #[test]
6042 fn test_api_set_line_indicator() {
6043 let (mut backend, rx) = create_test_backend();
6044
6045 backend
6046 .execute_js(
6047 r#"
6048 const editor = getEditor();
6049 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6050 "#,
6051 "test.js",
6052 )
6053 .unwrap();
6054
6055 let cmd = rx.try_recv().unwrap();
6056 match cmd {
6057 PluginCommand::SetLineIndicator {
6058 buffer_id,
6059 line,
6060 namespace,
6061 symbol,
6062 color,
6063 priority,
6064 } => {
6065 assert_eq!(buffer_id.0, 1);
6066 assert_eq!(line, 5);
6067 assert_eq!(namespace, "test-ns");
6068 assert_eq!(symbol, "●");
6069 assert_eq!(color, (255, 0, 0));
6070 assert_eq!(priority, 10);
6071 }
6072 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6073 }
6074 }
6075
6076 #[test]
6077 fn test_api_clear_line_indicators() {
6078 let (mut backend, rx) = create_test_backend();
6079
6080 backend
6081 .execute_js(
6082 r#"
6083 const editor = getEditor();
6084 editor.clearLineIndicators(1, "test-ns");
6085 "#,
6086 "test.js",
6087 )
6088 .unwrap();
6089
6090 let cmd = rx.try_recv().unwrap();
6091 match cmd {
6092 PluginCommand::ClearLineIndicators {
6093 buffer_id,
6094 namespace,
6095 } => {
6096 assert_eq!(buffer_id.0, 1);
6097 assert_eq!(namespace, "test-ns");
6098 }
6099 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6100 }
6101 }
6102
6103 #[test]
6106 fn test_api_create_virtual_buffer_sends_command() {
6107 let (mut backend, rx) = create_test_backend();
6108
6109 backend
6110 .execute_js(
6111 r#"
6112 const editor = getEditor();
6113 editor.createVirtualBuffer({
6114 name: "*Test Buffer*",
6115 mode: "test-mode",
6116 readOnly: true,
6117 entries: [
6118 { text: "Line 1\n", properties: { type: "header" } },
6119 { text: "Line 2\n", properties: { type: "content" } }
6120 ],
6121 showLineNumbers: false,
6122 showCursors: true,
6123 editingDisabled: true
6124 });
6125 "#,
6126 "test.js",
6127 )
6128 .unwrap();
6129
6130 let cmd = rx.try_recv().unwrap();
6131 match cmd {
6132 PluginCommand::CreateVirtualBufferWithContent {
6133 name,
6134 mode,
6135 read_only,
6136 entries,
6137 show_line_numbers,
6138 show_cursors,
6139 editing_disabled,
6140 ..
6141 } => {
6142 assert_eq!(name, "*Test Buffer*");
6143 assert_eq!(mode, "test-mode");
6144 assert!(read_only);
6145 assert_eq!(entries.len(), 2);
6146 assert_eq!(entries[0].text, "Line 1\n");
6147 assert!(!show_line_numbers);
6148 assert!(show_cursors);
6149 assert!(editing_disabled);
6150 }
6151 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6152 }
6153 }
6154
6155 #[test]
6156 fn test_api_set_virtual_buffer_content() {
6157 let (mut backend, rx) = create_test_backend();
6158
6159 backend
6160 .execute_js(
6161 r#"
6162 const editor = getEditor();
6163 editor.setVirtualBufferContent(5, [
6164 { text: "New content\n", properties: { type: "updated" } }
6165 ]);
6166 "#,
6167 "test.js",
6168 )
6169 .unwrap();
6170
6171 let cmd = rx.try_recv().unwrap();
6172 match cmd {
6173 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6174 assert_eq!(buffer_id.0, 5);
6175 assert_eq!(entries.len(), 1);
6176 assert_eq!(entries[0].text, "New content\n");
6177 }
6178 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6179 }
6180 }
6181
6182 #[test]
6185 fn test_api_add_overlay() {
6186 let (mut backend, rx) = create_test_backend();
6187
6188 backend
6189 .execute_js(
6190 r#"
6191 const editor = getEditor();
6192 editor.addOverlay(1, "highlight", 10, 20, {
6193 fg: [255, 128, 0],
6194 bg: [50, 50, 50],
6195 bold: true,
6196 });
6197 "#,
6198 "test.js",
6199 )
6200 .unwrap();
6201
6202 let cmd = rx.try_recv().unwrap();
6203 match cmd {
6204 PluginCommand::AddOverlay {
6205 buffer_id,
6206 namespace,
6207 range,
6208 options,
6209 } => {
6210 use fresh_core::api::OverlayColorSpec;
6211 assert_eq!(buffer_id.0, 1);
6212 assert!(namespace.is_some());
6213 assert_eq!(namespace.unwrap().as_str(), "highlight");
6214 assert_eq!(range, 10..20);
6215 assert!(matches!(
6216 options.fg,
6217 Some(OverlayColorSpec::Rgb(255, 128, 0))
6218 ));
6219 assert!(matches!(
6220 options.bg,
6221 Some(OverlayColorSpec::Rgb(50, 50, 50))
6222 ));
6223 assert!(!options.underline);
6224 assert!(options.bold);
6225 assert!(!options.italic);
6226 assert!(!options.extend_to_line_end);
6227 }
6228 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6229 }
6230 }
6231
6232 #[test]
6233 fn test_api_add_overlay_with_theme_keys() {
6234 let (mut backend, rx) = create_test_backend();
6235
6236 backend
6237 .execute_js(
6238 r#"
6239 const editor = getEditor();
6240 // Test with theme keys for colors
6241 editor.addOverlay(1, "themed", 0, 10, {
6242 fg: "ui.status_bar_fg",
6243 bg: "editor.selection_bg",
6244 });
6245 "#,
6246 "test.js",
6247 )
6248 .unwrap();
6249
6250 let cmd = rx.try_recv().unwrap();
6251 match cmd {
6252 PluginCommand::AddOverlay {
6253 buffer_id,
6254 namespace,
6255 range,
6256 options,
6257 } => {
6258 use fresh_core::api::OverlayColorSpec;
6259 assert_eq!(buffer_id.0, 1);
6260 assert!(namespace.is_some());
6261 assert_eq!(namespace.unwrap().as_str(), "themed");
6262 assert_eq!(range, 0..10);
6263 assert!(matches!(
6264 &options.fg,
6265 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6266 ));
6267 assert!(matches!(
6268 &options.bg,
6269 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6270 ));
6271 assert!(!options.underline);
6272 assert!(!options.bold);
6273 assert!(!options.italic);
6274 assert!(!options.extend_to_line_end);
6275 }
6276 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6277 }
6278 }
6279
6280 #[test]
6281 fn test_api_clear_namespace() {
6282 let (mut backend, rx) = create_test_backend();
6283
6284 backend
6285 .execute_js(
6286 r#"
6287 const editor = getEditor();
6288 editor.clearNamespace(1, "highlight");
6289 "#,
6290 "test.js",
6291 )
6292 .unwrap();
6293
6294 let cmd = rx.try_recv().unwrap();
6295 match cmd {
6296 PluginCommand::ClearNamespace {
6297 buffer_id,
6298 namespace,
6299 } => {
6300 assert_eq!(buffer_id.0, 1);
6301 assert_eq!(namespace.as_str(), "highlight");
6302 }
6303 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6304 }
6305 }
6306
6307 #[test]
6310 fn test_api_get_theme_schema() {
6311 let (mut backend, _rx) = create_test_backend();
6312
6313 backend
6314 .execute_js(
6315 r#"
6316 const editor = getEditor();
6317 const schema = editor.getThemeSchema();
6318 globalThis._isObject = typeof schema === 'object' && schema !== null;
6319 "#,
6320 "test.js",
6321 )
6322 .unwrap();
6323
6324 backend
6325 .plugin_contexts
6326 .borrow()
6327 .get("test")
6328 .unwrap()
6329 .clone()
6330 .with(|ctx| {
6331 let global = ctx.globals();
6332 let is_object: bool = global.get("_isObject").unwrap();
6333 assert!(is_object);
6335 });
6336 }
6337
6338 #[test]
6339 fn test_api_get_builtin_themes() {
6340 let (mut backend, _rx) = create_test_backend();
6341
6342 backend
6343 .execute_js(
6344 r#"
6345 const editor = getEditor();
6346 const themes = editor.getBuiltinThemes();
6347 globalThis._isObject = typeof themes === 'object' && themes !== null;
6348 "#,
6349 "test.js",
6350 )
6351 .unwrap();
6352
6353 backend
6354 .plugin_contexts
6355 .borrow()
6356 .get("test")
6357 .unwrap()
6358 .clone()
6359 .with(|ctx| {
6360 let global = ctx.globals();
6361 let is_object: bool = global.get("_isObject").unwrap();
6362 assert!(is_object);
6364 });
6365 }
6366
6367 #[test]
6368 fn test_api_apply_theme() {
6369 let (mut backend, rx) = create_test_backend();
6370
6371 backend
6372 .execute_js(
6373 r#"
6374 const editor = getEditor();
6375 editor.applyTheme("dark");
6376 "#,
6377 "test.js",
6378 )
6379 .unwrap();
6380
6381 let cmd = rx.try_recv().unwrap();
6382 match cmd {
6383 PluginCommand::ApplyTheme { theme_name } => {
6384 assert_eq!(theme_name, "dark");
6385 }
6386 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6387 }
6388 }
6389
6390 #[test]
6391 fn test_api_get_theme_data_missing() {
6392 let (mut backend, _rx) = create_test_backend();
6393
6394 backend
6395 .execute_js(
6396 r#"
6397 const editor = getEditor();
6398 const data = editor.getThemeData("nonexistent");
6399 globalThis._isNull = data === null;
6400 "#,
6401 "test.js",
6402 )
6403 .unwrap();
6404
6405 backend
6406 .plugin_contexts
6407 .borrow()
6408 .get("test")
6409 .unwrap()
6410 .clone()
6411 .with(|ctx| {
6412 let global = ctx.globals();
6413 let is_null: bool = global.get("_isNull").unwrap();
6414 assert!(is_null);
6416 });
6417 }
6418
6419 #[test]
6420 fn test_api_get_theme_data_present() {
6421 let (tx, _rx) = mpsc::channel();
6423 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6424 let services = Arc::new(ThemeCacheTestBridge {
6425 inner: TestServiceBridge::new(),
6426 });
6427 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6428
6429 backend
6430 .execute_js(
6431 r#"
6432 const editor = getEditor();
6433 const data = editor.getThemeData("test-theme");
6434 globalThis._hasData = data !== null && typeof data === 'object';
6435 globalThis._name = data ? data.name : null;
6436 "#,
6437 "test.js",
6438 )
6439 .unwrap();
6440
6441 backend
6442 .plugin_contexts
6443 .borrow()
6444 .get("test")
6445 .unwrap()
6446 .clone()
6447 .with(|ctx| {
6448 let global = ctx.globals();
6449 let has_data: bool = global.get("_hasData").unwrap();
6450 assert!(has_data, "getThemeData should return theme object");
6451 let name: String = global.get("_name").unwrap();
6452 assert_eq!(name, "test-theme");
6453 });
6454 }
6455
6456 #[test]
6457 fn test_api_theme_file_exists() {
6458 let (mut backend, _rx) = create_test_backend();
6459
6460 backend
6461 .execute_js(
6462 r#"
6463 const editor = getEditor();
6464 globalThis._exists = editor.themeFileExists("anything");
6465 "#,
6466 "test.js",
6467 )
6468 .unwrap();
6469
6470 backend
6471 .plugin_contexts
6472 .borrow()
6473 .get("test")
6474 .unwrap()
6475 .clone()
6476 .with(|ctx| {
6477 let global = ctx.globals();
6478 let exists: bool = global.get("_exists").unwrap();
6479 assert!(!exists);
6481 });
6482 }
6483
6484 #[test]
6485 fn test_api_save_theme_file_error() {
6486 let (mut backend, _rx) = create_test_backend();
6487
6488 backend
6489 .execute_js(
6490 r#"
6491 const editor = getEditor();
6492 let threw = false;
6493 try {
6494 editor.saveThemeFile("test", "{}");
6495 } catch (e) {
6496 threw = true;
6497 }
6498 globalThis._threw = threw;
6499 "#,
6500 "test.js",
6501 )
6502 .unwrap();
6503
6504 backend
6505 .plugin_contexts
6506 .borrow()
6507 .get("test")
6508 .unwrap()
6509 .clone()
6510 .with(|ctx| {
6511 let global = ctx.globals();
6512 let threw: bool = global.get("_threw").unwrap();
6513 assert!(threw);
6515 });
6516 }
6517
6518 struct ThemeCacheTestBridge {
6520 inner: TestServiceBridge,
6521 }
6522
6523 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6524 fn as_any(&self) -> &dyn std::any::Any {
6525 self
6526 }
6527 fn translate(
6528 &self,
6529 plugin_name: &str,
6530 key: &str,
6531 args: &HashMap<String, String>,
6532 ) -> String {
6533 self.inner.translate(plugin_name, key, args)
6534 }
6535 fn current_locale(&self) -> String {
6536 self.inner.current_locale()
6537 }
6538 fn set_js_execution_state(&self, state: String) {
6539 self.inner.set_js_execution_state(state);
6540 }
6541 fn clear_js_execution_state(&self) {
6542 self.inner.clear_js_execution_state();
6543 }
6544 fn get_theme_schema(&self) -> serde_json::Value {
6545 self.inner.get_theme_schema()
6546 }
6547 fn get_builtin_themes(&self) -> serde_json::Value {
6548 self.inner.get_builtin_themes()
6549 }
6550 fn register_command(&self, command: fresh_core::command::Command) {
6551 self.inner.register_command(command);
6552 }
6553 fn unregister_command(&self, name: &str) {
6554 self.inner.unregister_command(name);
6555 }
6556 fn unregister_commands_by_prefix(&self, prefix: &str) {
6557 self.inner.unregister_commands_by_prefix(prefix);
6558 }
6559 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6560 self.inner.unregister_commands_by_plugin(plugin_name);
6561 }
6562 fn plugins_dir(&self) -> std::path::PathBuf {
6563 self.inner.plugins_dir()
6564 }
6565 fn config_dir(&self) -> std::path::PathBuf {
6566 self.inner.config_dir()
6567 }
6568 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6569 if name == "test-theme" {
6570 Some(serde_json::json!({
6571 "name": "test-theme",
6572 "editor": {},
6573 "ui": {},
6574 "syntax": {}
6575 }))
6576 } else {
6577 None
6578 }
6579 }
6580 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6581 Err("test bridge does not support save".to_string())
6582 }
6583 fn theme_file_exists(&self, name: &str) -> bool {
6584 name == "test-theme"
6585 }
6586 }
6587
6588 #[test]
6591 fn test_api_close_buffer() {
6592 let (mut backend, rx) = create_test_backend();
6593
6594 backend
6595 .execute_js(
6596 r#"
6597 const editor = getEditor();
6598 editor.closeBuffer(3);
6599 "#,
6600 "test.js",
6601 )
6602 .unwrap();
6603
6604 let cmd = rx.try_recv().unwrap();
6605 match cmd {
6606 PluginCommand::CloseBuffer { buffer_id } => {
6607 assert_eq!(buffer_id.0, 3);
6608 }
6609 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
6610 }
6611 }
6612
6613 #[test]
6614 fn test_api_focus_split() {
6615 let (mut backend, rx) = create_test_backend();
6616
6617 backend
6618 .execute_js(
6619 r#"
6620 const editor = getEditor();
6621 editor.focusSplit(2);
6622 "#,
6623 "test.js",
6624 )
6625 .unwrap();
6626
6627 let cmd = rx.try_recv().unwrap();
6628 match cmd {
6629 PluginCommand::FocusSplit { split_id } => {
6630 assert_eq!(split_id.0, 2);
6631 }
6632 _ => panic!("Expected FocusSplit, got {:?}", cmd),
6633 }
6634 }
6635
6636 #[test]
6637 fn test_api_list_buffers() {
6638 let (tx, _rx) = mpsc::channel();
6639 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6640
6641 {
6643 let mut state = state_snapshot.write().unwrap();
6644 state.buffers.insert(
6645 BufferId(0),
6646 BufferInfo {
6647 id: BufferId(0),
6648 path: Some(PathBuf::from("/test1.txt")),
6649 modified: false,
6650 length: 100,
6651 is_virtual: false,
6652 view_mode: "source".to_string(),
6653 is_composing_in_any_split: false,
6654 compose_width: None,
6655 language: "text".to_string(),
6656 },
6657 );
6658 state.buffers.insert(
6659 BufferId(1),
6660 BufferInfo {
6661 id: BufferId(1),
6662 path: Some(PathBuf::from("/test2.txt")),
6663 modified: true,
6664 length: 200,
6665 is_virtual: false,
6666 view_mode: "source".to_string(),
6667 is_composing_in_any_split: false,
6668 compose_width: None,
6669 language: "text".to_string(),
6670 },
6671 );
6672 }
6673
6674 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6675 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6676
6677 backend
6678 .execute_js(
6679 r#"
6680 const editor = getEditor();
6681 const buffers = editor.listBuffers();
6682 globalThis._isArray = Array.isArray(buffers);
6683 globalThis._length = buffers.length;
6684 "#,
6685 "test.js",
6686 )
6687 .unwrap();
6688
6689 backend
6690 .plugin_contexts
6691 .borrow()
6692 .get("test")
6693 .unwrap()
6694 .clone()
6695 .with(|ctx| {
6696 let global = ctx.globals();
6697 let is_array: bool = global.get("_isArray").unwrap();
6698 let length: u32 = global.get("_length").unwrap();
6699 assert!(is_array);
6700 assert_eq!(length, 2);
6701 });
6702 }
6703
6704 #[test]
6707 fn test_api_start_prompt() {
6708 let (mut backend, rx) = create_test_backend();
6709
6710 backend
6711 .execute_js(
6712 r#"
6713 const editor = getEditor();
6714 editor.startPrompt("Enter value:", "test-prompt");
6715 "#,
6716 "test.js",
6717 )
6718 .unwrap();
6719
6720 let cmd = rx.try_recv().unwrap();
6721 match cmd {
6722 PluginCommand::StartPrompt { label, prompt_type } => {
6723 assert_eq!(label, "Enter value:");
6724 assert_eq!(prompt_type, "test-prompt");
6725 }
6726 _ => panic!("Expected StartPrompt, got {:?}", cmd),
6727 }
6728 }
6729
6730 #[test]
6731 fn test_api_start_prompt_with_initial() {
6732 let (mut backend, rx) = create_test_backend();
6733
6734 backend
6735 .execute_js(
6736 r#"
6737 const editor = getEditor();
6738 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
6739 "#,
6740 "test.js",
6741 )
6742 .unwrap();
6743
6744 let cmd = rx.try_recv().unwrap();
6745 match cmd {
6746 PluginCommand::StartPromptWithInitial {
6747 label,
6748 prompt_type,
6749 initial_value,
6750 } => {
6751 assert_eq!(label, "Enter value:");
6752 assert_eq!(prompt_type, "test-prompt");
6753 assert_eq!(initial_value, "default");
6754 }
6755 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
6756 }
6757 }
6758
6759 #[test]
6760 fn test_api_set_prompt_suggestions() {
6761 let (mut backend, rx) = create_test_backend();
6762
6763 backend
6764 .execute_js(
6765 r#"
6766 const editor = getEditor();
6767 editor.setPromptSuggestions([
6768 { text: "Option 1", value: "opt1" },
6769 { text: "Option 2", value: "opt2" }
6770 ]);
6771 "#,
6772 "test.js",
6773 )
6774 .unwrap();
6775
6776 let cmd = rx.try_recv().unwrap();
6777 match cmd {
6778 PluginCommand::SetPromptSuggestions { suggestions } => {
6779 assert_eq!(suggestions.len(), 2);
6780 assert_eq!(suggestions[0].text, "Option 1");
6781 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
6782 }
6783 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
6784 }
6785 }
6786
6787 #[test]
6790 fn test_api_get_active_buffer_id() {
6791 let (tx, _rx) = mpsc::channel();
6792 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6793
6794 {
6795 let mut state = state_snapshot.write().unwrap();
6796 state.active_buffer_id = BufferId(42);
6797 }
6798
6799 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6800 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6801
6802 backend
6803 .execute_js(
6804 r#"
6805 const editor = getEditor();
6806 globalThis._activeId = editor.getActiveBufferId();
6807 "#,
6808 "test.js",
6809 )
6810 .unwrap();
6811
6812 backend
6813 .plugin_contexts
6814 .borrow()
6815 .get("test")
6816 .unwrap()
6817 .clone()
6818 .with(|ctx| {
6819 let global = ctx.globals();
6820 let result: u32 = global.get("_activeId").unwrap();
6821 assert_eq!(result, 42);
6822 });
6823 }
6824
6825 #[test]
6826 fn test_api_get_active_split_id() {
6827 let (tx, _rx) = mpsc::channel();
6828 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6829
6830 {
6831 let mut state = state_snapshot.write().unwrap();
6832 state.active_split_id = 7;
6833 }
6834
6835 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6836 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6837
6838 backend
6839 .execute_js(
6840 r#"
6841 const editor = getEditor();
6842 globalThis._splitId = editor.getActiveSplitId();
6843 "#,
6844 "test.js",
6845 )
6846 .unwrap();
6847
6848 backend
6849 .plugin_contexts
6850 .borrow()
6851 .get("test")
6852 .unwrap()
6853 .clone()
6854 .with(|ctx| {
6855 let global = ctx.globals();
6856 let result: u32 = global.get("_splitId").unwrap();
6857 assert_eq!(result, 7);
6858 });
6859 }
6860
6861 #[test]
6864 fn test_api_file_exists() {
6865 let (mut backend, _rx) = create_test_backend();
6866
6867 backend
6868 .execute_js(
6869 r#"
6870 const editor = getEditor();
6871 // Test with a path that definitely exists
6872 globalThis._exists = editor.fileExists("/");
6873 "#,
6874 "test.js",
6875 )
6876 .unwrap();
6877
6878 backend
6879 .plugin_contexts
6880 .borrow()
6881 .get("test")
6882 .unwrap()
6883 .clone()
6884 .with(|ctx| {
6885 let global = ctx.globals();
6886 let result: bool = global.get("_exists").unwrap();
6887 assert!(result);
6888 });
6889 }
6890
6891 #[test]
6892 fn test_api_get_cwd() {
6893 let (mut backend, _rx) = create_test_backend();
6894
6895 backend
6896 .execute_js(
6897 r#"
6898 const editor = getEditor();
6899 globalThis._cwd = editor.getCwd();
6900 "#,
6901 "test.js",
6902 )
6903 .unwrap();
6904
6905 backend
6906 .plugin_contexts
6907 .borrow()
6908 .get("test")
6909 .unwrap()
6910 .clone()
6911 .with(|ctx| {
6912 let global = ctx.globals();
6913 let result: String = global.get("_cwd").unwrap();
6914 assert!(!result.is_empty());
6916 });
6917 }
6918
6919 #[test]
6920 fn test_api_get_env() {
6921 let (mut backend, _rx) = create_test_backend();
6922
6923 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
6925
6926 backend
6927 .execute_js(
6928 r#"
6929 const editor = getEditor();
6930 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
6931 "#,
6932 "test.js",
6933 )
6934 .unwrap();
6935
6936 backend
6937 .plugin_contexts
6938 .borrow()
6939 .get("test")
6940 .unwrap()
6941 .clone()
6942 .with(|ctx| {
6943 let global = ctx.globals();
6944 let result: Option<String> = global.get("_envVal").unwrap();
6945 assert_eq!(result, Some("test_value".to_string()));
6946 });
6947
6948 std::env::remove_var("TEST_PLUGIN_VAR");
6949 }
6950
6951 #[test]
6952 fn test_api_get_config() {
6953 let (mut backend, _rx) = create_test_backend();
6954
6955 backend
6956 .execute_js(
6957 r#"
6958 const editor = getEditor();
6959 const config = editor.getConfig();
6960 globalThis._isObject = typeof config === 'object';
6961 "#,
6962 "test.js",
6963 )
6964 .unwrap();
6965
6966 backend
6967 .plugin_contexts
6968 .borrow()
6969 .get("test")
6970 .unwrap()
6971 .clone()
6972 .with(|ctx| {
6973 let global = ctx.globals();
6974 let is_object: bool = global.get("_isObject").unwrap();
6975 assert!(is_object);
6977 });
6978 }
6979
6980 #[test]
6981 fn test_api_get_themes_dir() {
6982 let (mut backend, _rx) = create_test_backend();
6983
6984 backend
6985 .execute_js(
6986 r#"
6987 const editor = getEditor();
6988 globalThis._themesDir = editor.getThemesDir();
6989 "#,
6990 "test.js",
6991 )
6992 .unwrap();
6993
6994 backend
6995 .plugin_contexts
6996 .borrow()
6997 .get("test")
6998 .unwrap()
6999 .clone()
7000 .with(|ctx| {
7001 let global = ctx.globals();
7002 let result: String = global.get("_themesDir").unwrap();
7003 assert!(!result.is_empty());
7005 });
7006 }
7007
7008 #[test]
7011 fn test_api_read_dir() {
7012 let (mut backend, _rx) = create_test_backend();
7013
7014 backend
7015 .execute_js(
7016 r#"
7017 const editor = getEditor();
7018 const entries = editor.readDir("/tmp");
7019 globalThis._isArray = Array.isArray(entries);
7020 globalThis._length = entries.length;
7021 "#,
7022 "test.js",
7023 )
7024 .unwrap();
7025
7026 backend
7027 .plugin_contexts
7028 .borrow()
7029 .get("test")
7030 .unwrap()
7031 .clone()
7032 .with(|ctx| {
7033 let global = ctx.globals();
7034 let is_array: bool = global.get("_isArray").unwrap();
7035 let length: u32 = global.get("_length").unwrap();
7036 assert!(is_array);
7038 let _ = length;
7040 });
7041 }
7042
7043 #[test]
7046 fn test_api_execute_action() {
7047 let (mut backend, rx) = create_test_backend();
7048
7049 backend
7050 .execute_js(
7051 r#"
7052 const editor = getEditor();
7053 editor.executeAction("move_cursor_up");
7054 "#,
7055 "test.js",
7056 )
7057 .unwrap();
7058
7059 let cmd = rx.try_recv().unwrap();
7060 match cmd {
7061 PluginCommand::ExecuteAction { action_name } => {
7062 assert_eq!(action_name, "move_cursor_up");
7063 }
7064 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7065 }
7066 }
7067
7068 #[test]
7071 fn test_api_debug() {
7072 let (mut backend, _rx) = create_test_backend();
7073
7074 backend
7076 .execute_js(
7077 r#"
7078 const editor = getEditor();
7079 editor.debug("Test debug message");
7080 editor.debug("Another message with special chars: <>&\"'");
7081 "#,
7082 "test.js",
7083 )
7084 .unwrap();
7085 }
7087
7088 #[test]
7091 fn test_typescript_preamble_generated() {
7092 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7094 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7095 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7096 println!(
7097 "Generated {} bytes of TypeScript preamble",
7098 JSEDITORAPI_TS_PREAMBLE.len()
7099 );
7100 }
7101
7102 #[test]
7103 fn test_typescript_editor_api_generated() {
7104 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7106 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7107 println!(
7108 "Generated {} bytes of EditorAPI interface",
7109 JSEDITORAPI_TS_EDITOR_API.len()
7110 );
7111 }
7112
7113 #[test]
7114 fn test_js_methods_list() {
7115 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7117 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7118 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7120 if i < 20 {
7121 println!(" - {}", method);
7122 }
7123 }
7124 if JSEDITORAPI_JS_METHODS.len() > 20 {
7125 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7126 }
7127 }
7128
7129 #[test]
7132 fn test_api_load_plugin_sends_command() {
7133 let (mut backend, rx) = create_test_backend();
7134
7135 backend
7137 .execute_js(
7138 r#"
7139 const editor = getEditor();
7140 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7141 "#,
7142 "test.js",
7143 )
7144 .unwrap();
7145
7146 let cmd = rx.try_recv().unwrap();
7148 match cmd {
7149 PluginCommand::LoadPlugin { path, callback_id } => {
7150 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7151 assert!(callback_id.0 > 0); }
7153 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7154 }
7155 }
7156
7157 #[test]
7158 fn test_api_unload_plugin_sends_command() {
7159 let (mut backend, rx) = create_test_backend();
7160
7161 backend
7163 .execute_js(
7164 r#"
7165 const editor = getEditor();
7166 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7167 "#,
7168 "test.js",
7169 )
7170 .unwrap();
7171
7172 let cmd = rx.try_recv().unwrap();
7174 match cmd {
7175 PluginCommand::UnloadPlugin { name, callback_id } => {
7176 assert_eq!(name, "my-plugin");
7177 assert!(callback_id.0 > 0); }
7179 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7180 }
7181 }
7182
7183 #[test]
7184 fn test_api_reload_plugin_sends_command() {
7185 let (mut backend, rx) = create_test_backend();
7186
7187 backend
7189 .execute_js(
7190 r#"
7191 const editor = getEditor();
7192 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7193 "#,
7194 "test.js",
7195 )
7196 .unwrap();
7197
7198 let cmd = rx.try_recv().unwrap();
7200 match cmd {
7201 PluginCommand::ReloadPlugin { name, callback_id } => {
7202 assert_eq!(name, "my-plugin");
7203 assert!(callback_id.0 > 0); }
7205 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7206 }
7207 }
7208
7209 #[test]
7210 fn test_api_load_plugin_resolves_callback() {
7211 let (mut backend, rx) = create_test_backend();
7212
7213 backend
7215 .execute_js(
7216 r#"
7217 const editor = getEditor();
7218 globalThis._loadResult = null;
7219 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7220 globalThis._loadResult = result;
7221 });
7222 "#,
7223 "test.js",
7224 )
7225 .unwrap();
7226
7227 let callback_id = match rx.try_recv().unwrap() {
7229 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7230 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7231 };
7232
7233 backend.resolve_callback(callback_id, "true");
7235
7236 backend
7238 .plugin_contexts
7239 .borrow()
7240 .get("test")
7241 .unwrap()
7242 .clone()
7243 .with(|ctx| {
7244 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7245 });
7246
7247 backend
7249 .plugin_contexts
7250 .borrow()
7251 .get("test")
7252 .unwrap()
7253 .clone()
7254 .with(|ctx| {
7255 let global = ctx.globals();
7256 let result: bool = global.get("_loadResult").unwrap();
7257 assert!(result);
7258 });
7259 }
7260
7261 #[test]
7262 fn test_api_version() {
7263 let (mut backend, _rx) = create_test_backend();
7264
7265 backend
7266 .execute_js(
7267 r#"
7268 const editor = getEditor();
7269 globalThis._apiVersion = editor.apiVersion();
7270 "#,
7271 "test.js",
7272 )
7273 .unwrap();
7274
7275 backend
7276 .plugin_contexts
7277 .borrow()
7278 .get("test")
7279 .unwrap()
7280 .clone()
7281 .with(|ctx| {
7282 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7283 assert_eq!(version, 2);
7284 });
7285 }
7286
7287 #[test]
7288 fn test_api_unload_plugin_rejects_on_error() {
7289 let (mut backend, rx) = create_test_backend();
7290
7291 backend
7293 .execute_js(
7294 r#"
7295 const editor = getEditor();
7296 globalThis._unloadError = null;
7297 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7298 globalThis._unloadError = err.message || String(err);
7299 });
7300 "#,
7301 "test.js",
7302 )
7303 .unwrap();
7304
7305 let callback_id = match rx.try_recv().unwrap() {
7307 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7308 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7309 };
7310
7311 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7313
7314 backend
7316 .plugin_contexts
7317 .borrow()
7318 .get("test")
7319 .unwrap()
7320 .clone()
7321 .with(|ctx| {
7322 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7323 });
7324
7325 backend
7327 .plugin_contexts
7328 .borrow()
7329 .get("test")
7330 .unwrap()
7331 .clone()
7332 .with(|ctx| {
7333 let global = ctx.globals();
7334 let error: String = global.get("_unloadError").unwrap();
7335 assert!(error.contains("nonexistent-plugin"));
7336 });
7337 }
7338
7339 #[test]
7340 fn test_api_set_global_state() {
7341 let (mut backend, rx) = create_test_backend();
7342
7343 backend
7344 .execute_js(
7345 r#"
7346 const editor = getEditor();
7347 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7348 "#,
7349 "test_plugin.js",
7350 )
7351 .unwrap();
7352
7353 let cmd = rx.try_recv().unwrap();
7354 match cmd {
7355 PluginCommand::SetGlobalState {
7356 plugin_name,
7357 key,
7358 value,
7359 } => {
7360 assert_eq!(plugin_name, "test_plugin");
7361 assert_eq!(key, "myKey");
7362 let v = value.unwrap();
7363 assert_eq!(v["enabled"], serde_json::json!(true));
7364 assert_eq!(v["count"], serde_json::json!(42));
7365 }
7366 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7367 }
7368 }
7369
7370 #[test]
7371 fn test_api_set_global_state_delete() {
7372 let (mut backend, rx) = create_test_backend();
7373
7374 backend
7375 .execute_js(
7376 r#"
7377 const editor = getEditor();
7378 editor.setGlobalState("myKey", null);
7379 "#,
7380 "test_plugin.js",
7381 )
7382 .unwrap();
7383
7384 let cmd = rx.try_recv().unwrap();
7385 match cmd {
7386 PluginCommand::SetGlobalState {
7387 plugin_name,
7388 key,
7389 value,
7390 } => {
7391 assert_eq!(plugin_name, "test_plugin");
7392 assert_eq!(key, "myKey");
7393 assert!(value.is_none(), "null should delete the key");
7394 }
7395 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7396 }
7397 }
7398
7399 #[test]
7400 fn test_api_get_global_state_roundtrip() {
7401 let (mut backend, _rx) = create_test_backend();
7402
7403 backend
7405 .execute_js(
7406 r#"
7407 const editor = getEditor();
7408 editor.setGlobalState("flag", true);
7409 globalThis._result = editor.getGlobalState("flag");
7410 "#,
7411 "test_plugin.js",
7412 )
7413 .unwrap();
7414
7415 backend
7416 .plugin_contexts
7417 .borrow()
7418 .get("test_plugin")
7419 .unwrap()
7420 .clone()
7421 .with(|ctx| {
7422 let global = ctx.globals();
7423 let result: bool = global.get("_result").unwrap();
7424 assert!(
7425 result,
7426 "getGlobalState should return the value set by setGlobalState"
7427 );
7428 });
7429 }
7430
7431 #[test]
7432 fn test_api_get_global_state_missing_key() {
7433 let (mut backend, _rx) = create_test_backend();
7434
7435 backend
7436 .execute_js(
7437 r#"
7438 const editor = getEditor();
7439 globalThis._result = editor.getGlobalState("nonexistent");
7440 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7441 "#,
7442 "test_plugin.js",
7443 )
7444 .unwrap();
7445
7446 backend
7447 .plugin_contexts
7448 .borrow()
7449 .get("test_plugin")
7450 .unwrap()
7451 .clone()
7452 .with(|ctx| {
7453 let global = ctx.globals();
7454 let is_undefined: bool = global.get("_isUndefined").unwrap();
7455 assert!(
7456 is_undefined,
7457 "getGlobalState for missing key should return undefined"
7458 );
7459 });
7460 }
7461
7462 #[test]
7463 fn test_api_global_state_isolation_between_plugins() {
7464 let (tx, _rx) = mpsc::channel();
7466 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7467 let services = Arc::new(TestServiceBridge::new());
7468
7469 let mut backend_a =
7471 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7472 .unwrap();
7473 backend_a
7474 .execute_js(
7475 r#"
7476 const editor = getEditor();
7477 editor.setGlobalState("flag", "from_plugin_a");
7478 "#,
7479 "plugin_a.js",
7480 )
7481 .unwrap();
7482
7483 let mut backend_b =
7485 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7486 .unwrap();
7487 backend_b
7488 .execute_js(
7489 r#"
7490 const editor = getEditor();
7491 editor.setGlobalState("flag", "from_plugin_b");
7492 "#,
7493 "plugin_b.js",
7494 )
7495 .unwrap();
7496
7497 backend_a
7499 .execute_js(
7500 r#"
7501 const editor = getEditor();
7502 globalThis._aValue = editor.getGlobalState("flag");
7503 "#,
7504 "plugin_a.js",
7505 )
7506 .unwrap();
7507
7508 backend_a
7509 .plugin_contexts
7510 .borrow()
7511 .get("plugin_a")
7512 .unwrap()
7513 .clone()
7514 .with(|ctx| {
7515 let global = ctx.globals();
7516 let a_value: String = global.get("_aValue").unwrap();
7517 assert_eq!(
7518 a_value, "from_plugin_a",
7519 "Plugin A should see its own value, not plugin B's"
7520 );
7521 });
7522
7523 backend_b
7525 .execute_js(
7526 r#"
7527 const editor = getEditor();
7528 globalThis._bValue = editor.getGlobalState("flag");
7529 "#,
7530 "plugin_b.js",
7531 )
7532 .unwrap();
7533
7534 backend_b
7535 .plugin_contexts
7536 .borrow()
7537 .get("plugin_b")
7538 .unwrap()
7539 .clone()
7540 .with(|ctx| {
7541 let global = ctx.globals();
7542 let b_value: String = global.get("_bValue").unwrap();
7543 assert_eq!(
7544 b_value, "from_plugin_b",
7545 "Plugin B should see its own value, not plugin A's"
7546 );
7547 });
7548 }
7549}