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