1use anyhow::{anyhow, Result};
90use fresh_core::api::{
91 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
92 GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
93 PluginCommand, 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 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
726 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
727 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
728 s.available_grammars.clone()
729 } else {
730 Vec::new()
731 };
732 rquickjs_serde::to_value(ctx, &grammars)
733 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
734 }
735
736 pub fn debug(&self, msg: String) {
739 tracing::trace!("Plugin.debug: {}", msg);
740 }
741
742 pub fn info(&self, msg: String) {
743 tracing::info!("Plugin: {}", msg);
744 }
745
746 pub fn warn(&self, msg: String) {
747 tracing::warn!("Plugin: {}", msg);
748 }
749
750 pub fn error(&self, msg: String) {
751 tracing::error!("Plugin: {}", msg);
752 }
753
754 pub fn set_status(&self, msg: String) {
757 let _ = self
758 .command_sender
759 .send(PluginCommand::SetStatus { message: msg });
760 }
761
762 pub fn copy_to_clipboard(&self, text: String) {
765 let _ = self
766 .command_sender
767 .send(PluginCommand::SetClipboard { text });
768 }
769
770 pub fn set_clipboard(&self, text: String) {
771 let _ = self
772 .command_sender
773 .send(PluginCommand::SetClipboard { text });
774 }
775
776 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
781 if let Some(mode_name) = mode {
782 let key = format!("{}\0{}", action, mode_name);
783 if let Ok(snapshot) = self.state_snapshot.read() {
784 return snapshot.keybinding_labels.get(&key).cloned();
785 }
786 }
787 None
788 }
789
790 pub fn register_command<'js>(
801 &self,
802 ctx: rquickjs::Ctx<'js>,
803 name: String,
804 description: String,
805 handler_name: String,
806 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
807 rquickjs::Value<'js>,
808 >,
809 ) -> rquickjs::Result<bool> {
810 let plugin_name = self.plugin_name.clone();
812 let context_str: Option<String> = context.0.and_then(|v| {
814 if v.is_null() || v.is_undefined() {
815 None
816 } else {
817 v.as_string().and_then(|s| s.to_string().ok())
818 }
819 });
820
821 tracing::debug!(
822 "registerCommand: plugin='{}', name='{}', handler='{}'",
823 plugin_name,
824 name,
825 handler_name
826 );
827
828 let tracking_key = if name.starts_with('%') {
832 format!("{}:{}", plugin_name, name)
833 } else {
834 name.clone()
835 };
836 {
837 let names = self.registered_command_names.borrow();
838 if let Some(existing_plugin) = names.get(&tracking_key) {
839 if existing_plugin != &plugin_name {
840 let msg = format!(
841 "Command '{}' already registered by plugin '{}'",
842 name, existing_plugin
843 );
844 tracing::warn!("registerCommand collision: {}", msg);
845 return Err(
846 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
847 );
848 }
849 }
851 }
852
853 self.registered_command_names
855 .borrow_mut()
856 .insert(tracking_key, plugin_name.clone());
857
858 self.registered_actions.borrow_mut().insert(
860 handler_name.clone(),
861 PluginHandler {
862 plugin_name: self.plugin_name.clone(),
863 handler_name: handler_name.clone(),
864 },
865 );
866
867 let command = Command {
869 name: name.clone(),
870 description,
871 action_name: handler_name,
872 plugin_name,
873 custom_contexts: context_str.into_iter().collect(),
874 };
875
876 Ok(self
877 .command_sender
878 .send(PluginCommand::RegisterCommand { command })
879 .is_ok())
880 }
881
882 pub fn unregister_command(&self, name: String) -> bool {
884 let tracking_key = if name.starts_with('%') {
887 format!("{}:{}", self.plugin_name, name)
888 } else {
889 name.clone()
890 };
891 self.registered_command_names
892 .borrow_mut()
893 .remove(&tracking_key);
894 self.command_sender
895 .send(PluginCommand::UnregisterCommand { name })
896 .is_ok()
897 }
898
899 pub fn set_context(&self, name: String, active: bool) -> bool {
901 if active {
903 self.plugin_tracked_state
904 .borrow_mut()
905 .entry(self.plugin_name.clone())
906 .or_default()
907 .contexts_set
908 .push(name.clone());
909 }
910 self.command_sender
911 .send(PluginCommand::SetContext { name, active })
912 .is_ok()
913 }
914
915 pub fn execute_action(&self, action_name: String) -> bool {
917 self.command_sender
918 .send(PluginCommand::ExecuteAction { action_name })
919 .is_ok()
920 }
921
922 pub fn t<'js>(
927 &self,
928 _ctx: rquickjs::Ctx<'js>,
929 key: String,
930 args: rquickjs::function::Rest<Value<'js>>,
931 ) -> String {
932 let plugin_name = self.plugin_name.clone();
934 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
936 if let Some(obj) = first_arg.as_object() {
937 let mut map = HashMap::new();
938 for k in obj.keys::<String>().flatten() {
939 if let Ok(v) = obj.get::<_, String>(&k) {
940 map.insert(k, v);
941 }
942 }
943 map
944 } else {
945 HashMap::new()
946 }
947 } else {
948 HashMap::new()
949 };
950 let res = self.services.translate(&plugin_name, &key, &args_map);
951
952 tracing::info!(
953 "Translating: key={}, plugin={}, args={:?} => res='{}'",
954 key,
955 plugin_name,
956 args_map,
957 res
958 );
959 res
960 }
961
962 pub fn get_cursor_position(&self) -> u32 {
966 self.state_snapshot
967 .read()
968 .ok()
969 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
970 .unwrap_or(0)
971 }
972
973 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
975 if let Ok(s) = self.state_snapshot.read() {
976 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
977 if let Some(p) = &b.path {
978 return p.to_string_lossy().to_string();
979 }
980 }
981 }
982 String::new()
983 }
984
985 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
987 if let Ok(s) = self.state_snapshot.read() {
988 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
989 return b.length as u32;
990 }
991 }
992 0
993 }
994
995 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
997 if let Ok(s) = self.state_snapshot.read() {
998 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
999 return b.modified;
1000 }
1001 }
1002 false
1003 }
1004
1005 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1008 self.command_sender
1009 .send(PluginCommand::SaveBufferToPath {
1010 buffer_id: BufferId(buffer_id as usize),
1011 path: std::path::PathBuf::from(path),
1012 })
1013 .is_ok()
1014 }
1015
1016 #[plugin_api(ts_return = "BufferInfo | null")]
1018 pub fn get_buffer_info<'js>(
1019 &self,
1020 ctx: rquickjs::Ctx<'js>,
1021 buffer_id: u32,
1022 ) -> rquickjs::Result<Value<'js>> {
1023 let info = if let Ok(s) = self.state_snapshot.read() {
1024 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1025 } else {
1026 None
1027 };
1028 rquickjs_serde::to_value(ctx, &info)
1029 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1030 }
1031
1032 #[plugin_api(ts_return = "CursorInfo | null")]
1034 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1035 let cursor = if let Ok(s) = self.state_snapshot.read() {
1036 s.primary_cursor.clone()
1037 } else {
1038 None
1039 };
1040 rquickjs_serde::to_value(ctx, &cursor)
1041 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1042 }
1043
1044 #[plugin_api(ts_return = "CursorInfo[]")]
1046 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1047 let cursors = if let Ok(s) = self.state_snapshot.read() {
1048 s.all_cursors.clone()
1049 } else {
1050 Vec::new()
1051 };
1052 rquickjs_serde::to_value(ctx, &cursors)
1053 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1054 }
1055
1056 #[plugin_api(ts_return = "number[]")]
1058 pub fn get_all_cursor_positions<'js>(
1059 &self,
1060 ctx: rquickjs::Ctx<'js>,
1061 ) -> rquickjs::Result<Value<'js>> {
1062 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1063 s.all_cursors.iter().map(|c| c.position as u32).collect()
1064 } else {
1065 Vec::new()
1066 };
1067 rquickjs_serde::to_value(ctx, &positions)
1068 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1069 }
1070
1071 #[plugin_api(ts_return = "ViewportInfo | null")]
1073 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1074 let viewport = if let Ok(s) = self.state_snapshot.read() {
1075 s.viewport.clone()
1076 } else {
1077 None
1078 };
1079 rquickjs_serde::to_value(ctx, &viewport)
1080 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1081 }
1082
1083 pub fn get_cursor_line(&self) -> u32 {
1085 0
1089 }
1090
1091 #[plugin_api(
1094 async_promise,
1095 js_name = "getLineStartPosition",
1096 ts_return = "number | null"
1097 )]
1098 #[qjs(rename = "_getLineStartPositionStart")]
1099 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1100 let id = {
1101 let mut id_ref = self.next_request_id.borrow_mut();
1102 let id = *id_ref;
1103 *id_ref += 1;
1104 self.callback_contexts
1106 .borrow_mut()
1107 .insert(id, self.plugin_name.clone());
1108 id
1109 };
1110 let _ = self
1112 .command_sender
1113 .send(PluginCommand::GetLineStartPosition {
1114 buffer_id: BufferId(0),
1115 line,
1116 request_id: id,
1117 });
1118 id
1119 }
1120
1121 #[plugin_api(
1125 async_promise,
1126 js_name = "getLineEndPosition",
1127 ts_return = "number | null"
1128 )]
1129 #[qjs(rename = "_getLineEndPositionStart")]
1130 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1131 let id = {
1132 let mut id_ref = self.next_request_id.borrow_mut();
1133 let id = *id_ref;
1134 *id_ref += 1;
1135 self.callback_contexts
1136 .borrow_mut()
1137 .insert(id, self.plugin_name.clone());
1138 id
1139 };
1140 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1142 buffer_id: BufferId(0),
1143 line,
1144 request_id: id,
1145 });
1146 id
1147 }
1148
1149 #[plugin_api(
1152 async_promise,
1153 js_name = "getBufferLineCount",
1154 ts_return = "number | null"
1155 )]
1156 #[qjs(rename = "_getBufferLineCountStart")]
1157 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1158 let id = {
1159 let mut id_ref = self.next_request_id.borrow_mut();
1160 let id = *id_ref;
1161 *id_ref += 1;
1162 self.callback_contexts
1163 .borrow_mut()
1164 .insert(id, self.plugin_name.clone());
1165 id
1166 };
1167 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1169 buffer_id: BufferId(0),
1170 request_id: id,
1171 });
1172 id
1173 }
1174
1175 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1178 self.command_sender
1179 .send(PluginCommand::ScrollToLineCenter {
1180 split_id: SplitId(split_id as usize),
1181 buffer_id: BufferId(buffer_id as usize),
1182 line: line as usize,
1183 })
1184 .is_ok()
1185 }
1186
1187 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1189 let path_buf = std::path::PathBuf::from(&path);
1190 if let Ok(s) = self.state_snapshot.read() {
1191 for (id, info) in &s.buffers {
1192 if let Some(buf_path) = &info.path {
1193 if buf_path == &path_buf {
1194 return id.0 as u32;
1195 }
1196 }
1197 }
1198 }
1199 0
1200 }
1201
1202 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1204 pub fn get_buffer_saved_diff<'js>(
1205 &self,
1206 ctx: rquickjs::Ctx<'js>,
1207 buffer_id: u32,
1208 ) -> rquickjs::Result<Value<'js>> {
1209 let diff = if let Ok(s) = self.state_snapshot.read() {
1210 s.buffer_saved_diffs
1211 .get(&BufferId(buffer_id as usize))
1212 .cloned()
1213 } else {
1214 None
1215 };
1216 rquickjs_serde::to_value(ctx, &diff)
1217 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1218 }
1219
1220 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1224 self.command_sender
1225 .send(PluginCommand::InsertText {
1226 buffer_id: BufferId(buffer_id as usize),
1227 position: position as usize,
1228 text,
1229 })
1230 .is_ok()
1231 }
1232
1233 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1235 self.command_sender
1236 .send(PluginCommand::DeleteRange {
1237 buffer_id: BufferId(buffer_id as usize),
1238 range: (start as usize)..(end as usize),
1239 })
1240 .is_ok()
1241 }
1242
1243 pub fn insert_at_cursor(&self, text: String) -> bool {
1245 self.command_sender
1246 .send(PluginCommand::InsertAtCursor { text })
1247 .is_ok()
1248 }
1249
1250 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1254 self.command_sender
1255 .send(PluginCommand::OpenFileAtLocation {
1256 path: PathBuf::from(path),
1257 line: line.map(|l| l as usize),
1258 column: column.map(|c| c as usize),
1259 })
1260 .is_ok()
1261 }
1262
1263 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1265 self.command_sender
1266 .send(PluginCommand::OpenFileInSplit {
1267 split_id: split_id as usize,
1268 path: PathBuf::from(path),
1269 line: Some(line as usize),
1270 column: Some(column as usize),
1271 })
1272 .is_ok()
1273 }
1274
1275 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1277 self.command_sender
1278 .send(PluginCommand::ShowBuffer {
1279 buffer_id: BufferId(buffer_id as usize),
1280 })
1281 .is_ok()
1282 }
1283
1284 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1286 self.command_sender
1287 .send(PluginCommand::CloseBuffer {
1288 buffer_id: BufferId(buffer_id as usize),
1289 })
1290 .is_ok()
1291 }
1292
1293 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1297 if event_name == "lines_changed" {
1301 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1302 }
1303 self.event_handlers
1304 .borrow_mut()
1305 .entry(event_name)
1306 .or_default()
1307 .push(PluginHandler {
1308 plugin_name: self.plugin_name.clone(),
1309 handler_name,
1310 });
1311 }
1312
1313 pub fn off(&self, event_name: String, handler_name: String) {
1315 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1316 list.retain(|h| h.handler_name != handler_name);
1317 }
1318 }
1319
1320 pub fn get_env(&self, name: String) -> Option<String> {
1324 std::env::var(&name).ok()
1325 }
1326
1327 pub fn get_cwd(&self) -> String {
1329 self.state_snapshot
1330 .read()
1331 .map(|s| s.working_dir.to_string_lossy().to_string())
1332 .unwrap_or_else(|_| ".".to_string())
1333 }
1334
1335 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1340 let mut result_parts: Vec<String> = Vec::new();
1341 let mut has_leading_slash = false;
1342
1343 for part in &parts.0 {
1344 let normalized = part.replace('\\', "/");
1346
1347 let is_absolute = normalized.starts_with('/')
1349 || (normalized.len() >= 2
1350 && normalized
1351 .chars()
1352 .next()
1353 .map(|c| c.is_ascii_alphabetic())
1354 .unwrap_or(false)
1355 && normalized.chars().nth(1) == Some(':'));
1356
1357 if is_absolute {
1358 result_parts.clear();
1360 has_leading_slash = normalized.starts_with('/');
1361 }
1362
1363 for segment in normalized.split('/') {
1365 if !segment.is_empty() && segment != "." {
1366 if segment == ".." {
1367 result_parts.pop();
1368 } else {
1369 result_parts.push(segment.to_string());
1370 }
1371 }
1372 }
1373 }
1374
1375 let joined = result_parts.join("/");
1377
1378 if has_leading_slash && !joined.is_empty() {
1380 format!("/{}", joined)
1381 } else {
1382 joined
1383 }
1384 }
1385
1386 pub fn path_dirname(&self, path: String) -> String {
1388 Path::new(&path)
1389 .parent()
1390 .map(|p| p.to_string_lossy().to_string())
1391 .unwrap_or_default()
1392 }
1393
1394 pub fn path_basename(&self, path: String) -> String {
1396 Path::new(&path)
1397 .file_name()
1398 .map(|s| s.to_string_lossy().to_string())
1399 .unwrap_or_default()
1400 }
1401
1402 pub fn path_extname(&self, path: String) -> String {
1404 Path::new(&path)
1405 .extension()
1406 .map(|s| format!(".{}", s.to_string_lossy()))
1407 .unwrap_or_default()
1408 }
1409
1410 pub fn path_is_absolute(&self, path: String) -> bool {
1412 Path::new(&path).is_absolute()
1413 }
1414
1415 pub fn file_uri_to_path(&self, uri: String) -> String {
1419 fresh_core::file_uri::file_uri_to_path(&uri)
1420 .map(|p| p.to_string_lossy().to_string())
1421 .unwrap_or_default()
1422 }
1423
1424 pub fn path_to_file_uri(&self, path: String) -> String {
1428 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1429 }
1430
1431 pub fn utf8_byte_length(&self, text: String) -> u32 {
1439 text.len() as u32
1440 }
1441
1442 pub fn file_exists(&self, path: String) -> bool {
1446 Path::new(&path).exists()
1447 }
1448
1449 pub fn read_file(&self, path: String) -> Option<String> {
1451 std::fs::read_to_string(&path).ok()
1452 }
1453
1454 pub fn write_file(&self, path: String, content: String) -> bool {
1456 let p = Path::new(&path);
1457 if let Some(parent) = p.parent() {
1458 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1459 return false;
1460 }
1461 }
1462 std::fs::write(p, content).is_ok()
1463 }
1464
1465 #[plugin_api(ts_return = "DirEntry[]")]
1467 pub fn read_dir<'js>(
1468 &self,
1469 ctx: rquickjs::Ctx<'js>,
1470 path: String,
1471 ) -> rquickjs::Result<Value<'js>> {
1472 use fresh_core::api::DirEntry;
1473
1474 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1475 Ok(entries) => entries
1476 .filter_map(|e| e.ok())
1477 .map(|entry| {
1478 let file_type = entry.file_type().ok();
1479 DirEntry {
1480 name: entry.file_name().to_string_lossy().to_string(),
1481 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1482 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1483 }
1484 })
1485 .collect(),
1486 Err(e) => {
1487 tracing::warn!("readDir failed for '{}': {}", path, e);
1488 Vec::new()
1489 }
1490 };
1491
1492 rquickjs_serde::to_value(ctx, &entries)
1493 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1494 }
1495
1496 pub fn create_dir(&self, path: String) -> bool {
1499 let p = Path::new(&path);
1500 if p.is_dir() {
1501 return true;
1502 }
1503 std::fs::create_dir_all(p).is_ok()
1504 }
1505
1506 pub fn remove_path(&self, path: String) -> bool {
1510 let target = match Path::new(&path).canonicalize() {
1511 Ok(p) => p,
1512 Err(_) => return false, };
1514
1515 let temp_dir = std::env::temp_dir()
1521 .canonicalize()
1522 .unwrap_or_else(|_| std::env::temp_dir());
1523 let config_dir = self
1524 .services
1525 .config_dir()
1526 .canonicalize()
1527 .unwrap_or_else(|_| self.services.config_dir());
1528
1529 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1531 if !allowed {
1532 tracing::warn!(
1533 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1534 target,
1535 temp_dir,
1536 config_dir
1537 );
1538 return false;
1539 }
1540
1541 if target == temp_dir || target == config_dir {
1543 tracing::warn!(
1544 "removePath refused: cannot remove root directory {:?}",
1545 target
1546 );
1547 return false;
1548 }
1549
1550 match trash::delete(&target) {
1551 Ok(()) => true,
1552 Err(e) => {
1553 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1554 false
1555 }
1556 }
1557 }
1558
1559 pub fn rename_path(&self, from: String, to: String) -> bool {
1562 if std::fs::rename(&from, &to).is_ok() {
1564 return true;
1565 }
1566 let from_path = Path::new(&from);
1568 let copied = if from_path.is_dir() {
1569 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1570 } else {
1571 std::fs::copy(&from, &to).is_ok()
1572 };
1573 if copied {
1574 return trash::delete(from_path).is_ok();
1575 }
1576 false
1577 }
1578
1579 pub fn copy_path(&self, from: String, to: String) -> bool {
1582 let from_path = Path::new(&from);
1583 let to_path = Path::new(&to);
1584 if from_path.is_dir() {
1585 copy_dir_recursive(from_path, to_path).is_ok()
1586 } else {
1587 if let Some(parent) = to_path.parent() {
1589 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1590 return false;
1591 }
1592 }
1593 std::fs::copy(from_path, to_path).is_ok()
1594 }
1595 }
1596
1597 pub fn get_temp_dir(&self) -> String {
1599 std::env::temp_dir().to_string_lossy().to_string()
1600 }
1601
1602 pub fn get_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.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 get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1618 let config: serde_json::Value = self
1619 .state_snapshot
1620 .read()
1621 .map(|s| s.user_config.clone())
1622 .unwrap_or_else(|_| serde_json::json!({}));
1623
1624 rquickjs_serde::to_value(ctx, &config)
1625 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1626 }
1627
1628 pub fn reload_config(&self) {
1630 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1631 }
1632
1633 pub fn reload_themes(&self) {
1636 let _ = self
1637 .command_sender
1638 .send(PluginCommand::ReloadThemes { apply_theme: None });
1639 }
1640
1641 pub fn reload_and_apply_theme(&self, theme_name: String) {
1643 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1644 apply_theme: Some(theme_name),
1645 });
1646 }
1647
1648 pub fn register_grammar<'js>(
1651 &self,
1652 ctx: rquickjs::Ctx<'js>,
1653 language: String,
1654 grammar_path: String,
1655 extensions: Vec<String>,
1656 ) -> rquickjs::Result<bool> {
1657 {
1659 let langs = self.registered_grammar_languages.borrow();
1660 if let Some(existing_plugin) = langs.get(&language) {
1661 if existing_plugin != &self.plugin_name {
1662 let msg = format!(
1663 "Grammar for language '{}' already registered by plugin '{}'",
1664 language, existing_plugin
1665 );
1666 tracing::warn!("registerGrammar collision: {}", msg);
1667 return Err(
1668 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1669 );
1670 }
1671 }
1672 }
1673 self.registered_grammar_languages
1674 .borrow_mut()
1675 .insert(language.clone(), self.plugin_name.clone());
1676
1677 Ok(self
1678 .command_sender
1679 .send(PluginCommand::RegisterGrammar {
1680 language,
1681 grammar_path,
1682 extensions,
1683 })
1684 .is_ok())
1685 }
1686
1687 pub fn register_language_config<'js>(
1689 &self,
1690 ctx: rquickjs::Ctx<'js>,
1691 language: String,
1692 config: LanguagePackConfig,
1693 ) -> rquickjs::Result<bool> {
1694 {
1696 let langs = self.registered_language_configs.borrow();
1697 if let Some(existing_plugin) = langs.get(&language) {
1698 if existing_plugin != &self.plugin_name {
1699 let msg = format!(
1700 "Language config for '{}' already registered by plugin '{}'",
1701 language, existing_plugin
1702 );
1703 tracing::warn!("registerLanguageConfig collision: {}", msg);
1704 return Err(
1705 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1706 );
1707 }
1708 }
1709 }
1710 self.registered_language_configs
1711 .borrow_mut()
1712 .insert(language.clone(), self.plugin_name.clone());
1713
1714 Ok(self
1715 .command_sender
1716 .send(PluginCommand::RegisterLanguageConfig { language, config })
1717 .is_ok())
1718 }
1719
1720 pub fn register_lsp_server<'js>(
1722 &self,
1723 ctx: rquickjs::Ctx<'js>,
1724 language: String,
1725 config: LspServerPackConfig,
1726 ) -> rquickjs::Result<bool> {
1727 {
1729 let langs = self.registered_lsp_servers.borrow();
1730 if let Some(existing_plugin) = langs.get(&language) {
1731 if existing_plugin != &self.plugin_name {
1732 let msg = format!(
1733 "LSP server for language '{}' already registered by plugin '{}'",
1734 language, existing_plugin
1735 );
1736 tracing::warn!("registerLspServer collision: {}", msg);
1737 return Err(
1738 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1739 );
1740 }
1741 }
1742 }
1743 self.registered_lsp_servers
1744 .borrow_mut()
1745 .insert(language.clone(), self.plugin_name.clone());
1746
1747 Ok(self
1748 .command_sender
1749 .send(PluginCommand::RegisterLspServer { language, config })
1750 .is_ok())
1751 }
1752
1753 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1757 #[qjs(rename = "_reloadGrammarsStart")]
1758 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1759 let id = {
1760 let mut id_ref = self.next_request_id.borrow_mut();
1761 let id = *id_ref;
1762 *id_ref += 1;
1763 self.callback_contexts
1764 .borrow_mut()
1765 .insert(id, self.plugin_name.clone());
1766 id
1767 };
1768 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1769 callback_id: fresh_core::api::JsCallbackId::new(id),
1770 });
1771 id
1772 }
1773
1774 pub fn get_plugin_dir(&self) -> String {
1777 self.services
1778 .plugins_dir()
1779 .join("packages")
1780 .join(&self.plugin_name)
1781 .to_string_lossy()
1782 .to_string()
1783 }
1784
1785 pub fn get_config_dir(&self) -> String {
1787 self.services.config_dir().to_string_lossy().to_string()
1788 }
1789
1790 pub fn get_themes_dir(&self) -> String {
1792 self.services
1793 .config_dir()
1794 .join("themes")
1795 .to_string_lossy()
1796 .to_string()
1797 }
1798
1799 pub fn apply_theme(&self, theme_name: String) -> bool {
1801 self.command_sender
1802 .send(PluginCommand::ApplyTheme { theme_name })
1803 .is_ok()
1804 }
1805
1806 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1808 let schema = self.services.get_theme_schema();
1809 rquickjs_serde::to_value(ctx, &schema)
1810 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1811 }
1812
1813 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1815 let themes = self.services.get_builtin_themes();
1816 rquickjs_serde::to_value(ctx, &themes)
1817 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1818 }
1819
1820 #[qjs(rename = "_deleteThemeSync")]
1822 pub fn delete_theme_sync(&self, name: String) -> bool {
1823 let themes_dir = self.services.config_dir().join("themes");
1825 let theme_path = themes_dir.join(format!("{}.json", name));
1826
1827 if let Ok(canonical) = theme_path.canonicalize() {
1829 if let Ok(themes_canonical) = themes_dir.canonicalize() {
1830 if canonical.starts_with(&themes_canonical) {
1831 return std::fs::remove_file(&canonical).is_ok();
1832 }
1833 }
1834 }
1835 false
1836 }
1837
1838 pub fn delete_theme(&self, name: String) -> bool {
1840 self.delete_theme_sync(name)
1841 }
1842
1843 pub fn get_theme_data<'js>(
1845 &self,
1846 ctx: rquickjs::Ctx<'js>,
1847 name: String,
1848 ) -> rquickjs::Result<Value<'js>> {
1849 match self.services.get_theme_data(&name) {
1850 Some(data) => rquickjs_serde::to_value(ctx, &data)
1851 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1852 None => Ok(Value::new_null(ctx)),
1853 }
1854 }
1855
1856 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1858 self.services
1859 .save_theme_file(&name, &content)
1860 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1861 }
1862
1863 pub fn theme_file_exists(&self, name: String) -> bool {
1865 self.services.theme_file_exists(&name)
1866 }
1867
1868 pub fn file_stat<'js>(
1872 &self,
1873 ctx: rquickjs::Ctx<'js>,
1874 path: String,
1875 ) -> rquickjs::Result<Value<'js>> {
1876 let metadata = std::fs::metadata(&path).ok();
1877 let stat = metadata.map(|m| {
1878 serde_json::json!({
1879 "isFile": m.is_file(),
1880 "isDir": m.is_dir(),
1881 "size": m.len(),
1882 "readonly": m.permissions().readonly(),
1883 })
1884 });
1885 rquickjs_serde::to_value(ctx, &stat)
1886 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1887 }
1888
1889 pub fn is_process_running(&self, _process_id: u64) -> bool {
1893 false
1896 }
1897
1898 pub fn kill_process(&self, process_id: u64) -> bool {
1900 self.command_sender
1901 .send(PluginCommand::KillBackgroundProcess { process_id })
1902 .is_ok()
1903 }
1904
1905 pub fn plugin_translate<'js>(
1909 &self,
1910 _ctx: rquickjs::Ctx<'js>,
1911 plugin_name: String,
1912 key: String,
1913 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1914 ) -> String {
1915 let args_map: HashMap<String, String> = args
1916 .0
1917 .map(|obj| {
1918 let mut map = HashMap::new();
1919 for (k, v) in obj.props::<String, String>().flatten() {
1920 map.insert(k, v);
1921 }
1922 map
1923 })
1924 .unwrap_or_default();
1925
1926 self.services.translate(&plugin_name, &key, &args_map)
1927 }
1928
1929 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1936 #[qjs(rename = "_createCompositeBufferStart")]
1937 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1938 let id = {
1939 let mut id_ref = self.next_request_id.borrow_mut();
1940 let id = *id_ref;
1941 *id_ref += 1;
1942 self.callback_contexts
1944 .borrow_mut()
1945 .insert(id, self.plugin_name.clone());
1946 id
1947 };
1948
1949 if let Ok(mut owners) = self.async_resource_owners.lock() {
1951 owners.insert(id, self.plugin_name.clone());
1952 }
1953 let _ = self
1954 .command_sender
1955 .send(PluginCommand::CreateCompositeBuffer {
1956 name: opts.name,
1957 mode: opts.mode,
1958 layout: opts.layout,
1959 sources: opts.sources,
1960 hunks: opts.hunks,
1961 request_id: Some(id),
1962 });
1963
1964 id
1965 }
1966
1967 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1971 self.command_sender
1972 .send(PluginCommand::UpdateCompositeAlignment {
1973 buffer_id: BufferId(buffer_id as usize),
1974 hunks,
1975 })
1976 .is_ok()
1977 }
1978
1979 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1981 self.command_sender
1982 .send(PluginCommand::CloseCompositeBuffer {
1983 buffer_id: BufferId(buffer_id as usize),
1984 })
1985 .is_ok()
1986 }
1987
1988 #[plugin_api(
1992 async_promise,
1993 js_name = "getHighlights",
1994 ts_return = "TsHighlightSpan[]"
1995 )]
1996 #[qjs(rename = "_getHighlightsStart")]
1997 pub fn get_highlights_start<'js>(
1998 &self,
1999 _ctx: rquickjs::Ctx<'js>,
2000 buffer_id: u32,
2001 start: u32,
2002 end: u32,
2003 ) -> rquickjs::Result<u64> {
2004 let id = {
2005 let mut id_ref = self.next_request_id.borrow_mut();
2006 let id = *id_ref;
2007 *id_ref += 1;
2008 self.callback_contexts
2010 .borrow_mut()
2011 .insert(id, self.plugin_name.clone());
2012 id
2013 };
2014
2015 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2016 buffer_id: BufferId(buffer_id as usize),
2017 range: (start as usize)..(end as usize),
2018 request_id: id,
2019 });
2020
2021 Ok(id)
2022 }
2023
2024 pub fn add_overlay<'js>(
2046 &self,
2047 _ctx: rquickjs::Ctx<'js>,
2048 buffer_id: u32,
2049 namespace: String,
2050 start: u32,
2051 end: u32,
2052 options: rquickjs::Object<'js>,
2053 ) -> rquickjs::Result<bool> {
2054 use fresh_core::api::OverlayColorSpec;
2055
2056 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2058 if let Ok(theme_key) = obj.get::<_, String>(key) {
2060 if !theme_key.is_empty() {
2061 return Some(OverlayColorSpec::ThemeKey(theme_key));
2062 }
2063 }
2064 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2066 if arr.len() >= 3 {
2067 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2068 }
2069 }
2070 None
2071 }
2072
2073 let fg = parse_color_spec("fg", &options);
2074 let bg = parse_color_spec("bg", &options);
2075 let underline: bool = options.get("underline").unwrap_or(false);
2076 let bold: bool = options.get("bold").unwrap_or(false);
2077 let italic: bool = options.get("italic").unwrap_or(false);
2078 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2079 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2080 let url: Option<String> = options.get("url").ok();
2081
2082 let options = OverlayOptions {
2083 fg,
2084 bg,
2085 underline,
2086 bold,
2087 italic,
2088 strikethrough,
2089 extend_to_line_end,
2090 url,
2091 };
2092
2093 self.plugin_tracked_state
2095 .borrow_mut()
2096 .entry(self.plugin_name.clone())
2097 .or_default()
2098 .overlay_namespaces
2099 .push((BufferId(buffer_id as usize), namespace.clone()));
2100
2101 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2102 buffer_id: BufferId(buffer_id as usize),
2103 namespace: Some(OverlayNamespace::from_string(namespace)),
2104 range: (start as usize)..(end as usize),
2105 options,
2106 });
2107
2108 Ok(true)
2109 }
2110
2111 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2113 self.command_sender
2114 .send(PluginCommand::ClearNamespace {
2115 buffer_id: BufferId(buffer_id as usize),
2116 namespace: OverlayNamespace::from_string(namespace),
2117 })
2118 .is_ok()
2119 }
2120
2121 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2123 self.command_sender
2124 .send(PluginCommand::ClearAllOverlays {
2125 buffer_id: BufferId(buffer_id as usize),
2126 })
2127 .is_ok()
2128 }
2129
2130 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2132 self.command_sender
2133 .send(PluginCommand::ClearOverlaysInRange {
2134 buffer_id: BufferId(buffer_id as usize),
2135 start: start as usize,
2136 end: end as usize,
2137 })
2138 .is_ok()
2139 }
2140
2141 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2143 use fresh_core::overlay::OverlayHandle;
2144 self.command_sender
2145 .send(PluginCommand::RemoveOverlay {
2146 buffer_id: BufferId(buffer_id as usize),
2147 handle: OverlayHandle(handle),
2148 })
2149 .is_ok()
2150 }
2151
2152 pub fn add_conceal(
2156 &self,
2157 buffer_id: u32,
2158 namespace: String,
2159 start: u32,
2160 end: u32,
2161 replacement: Option<String>,
2162 ) -> bool {
2163 self.plugin_tracked_state
2165 .borrow_mut()
2166 .entry(self.plugin_name.clone())
2167 .or_default()
2168 .overlay_namespaces
2169 .push((BufferId(buffer_id as usize), namespace.clone()));
2170
2171 self.command_sender
2172 .send(PluginCommand::AddConceal {
2173 buffer_id: BufferId(buffer_id as usize),
2174 namespace: OverlayNamespace::from_string(namespace),
2175 start: start as usize,
2176 end: end as usize,
2177 replacement,
2178 })
2179 .is_ok()
2180 }
2181
2182 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2184 self.command_sender
2185 .send(PluginCommand::ClearConcealNamespace {
2186 buffer_id: BufferId(buffer_id as usize),
2187 namespace: OverlayNamespace::from_string(namespace),
2188 })
2189 .is_ok()
2190 }
2191
2192 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2194 self.command_sender
2195 .send(PluginCommand::ClearConcealsInRange {
2196 buffer_id: BufferId(buffer_id as usize),
2197 start: start as usize,
2198 end: end as usize,
2199 })
2200 .is_ok()
2201 }
2202
2203 pub fn add_soft_break(
2207 &self,
2208 buffer_id: u32,
2209 namespace: String,
2210 position: u32,
2211 indent: u32,
2212 ) -> bool {
2213 self.plugin_tracked_state
2215 .borrow_mut()
2216 .entry(self.plugin_name.clone())
2217 .or_default()
2218 .overlay_namespaces
2219 .push((BufferId(buffer_id as usize), namespace.clone()));
2220
2221 self.command_sender
2222 .send(PluginCommand::AddSoftBreak {
2223 buffer_id: BufferId(buffer_id as usize),
2224 namespace: OverlayNamespace::from_string(namespace),
2225 position: position as usize,
2226 indent: indent as u16,
2227 })
2228 .is_ok()
2229 }
2230
2231 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2233 self.command_sender
2234 .send(PluginCommand::ClearSoftBreakNamespace {
2235 buffer_id: BufferId(buffer_id as usize),
2236 namespace: OverlayNamespace::from_string(namespace),
2237 })
2238 .is_ok()
2239 }
2240
2241 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2243 self.command_sender
2244 .send(PluginCommand::ClearSoftBreaksInRange {
2245 buffer_id: BufferId(buffer_id as usize),
2246 start: start as usize,
2247 end: end as usize,
2248 })
2249 .is_ok()
2250 }
2251
2252 #[allow(clippy::too_many_arguments)]
2262 pub fn submit_view_transform<'js>(
2263 &self,
2264 _ctx: rquickjs::Ctx<'js>,
2265 buffer_id: u32,
2266 split_id: Option<u32>,
2267 start: u32,
2268 end: u32,
2269 tokens: Vec<rquickjs::Object<'js>>,
2270 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2271 ) -> rquickjs::Result<bool> {
2272 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2273
2274 let tokens: Vec<ViewTokenWire> = tokens
2275 .into_iter()
2276 .enumerate()
2277 .map(|(idx, obj)| {
2278 parse_view_token(&obj, idx)
2280 })
2281 .collect::<rquickjs::Result<Vec<_>>>()?;
2282
2283 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2285 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2286 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2287 Some(LayoutHints {
2288 compose_width,
2289 column_guides,
2290 })
2291 } else {
2292 None
2293 };
2294
2295 let payload = ViewTransformPayload {
2296 range: (start as usize)..(end as usize),
2297 tokens,
2298 layout_hints: parsed_layout_hints,
2299 };
2300
2301 Ok(self
2302 .command_sender
2303 .send(PluginCommand::SubmitViewTransform {
2304 buffer_id: BufferId(buffer_id as usize),
2305 split_id: split_id.map(|id| SplitId(id as usize)),
2306 payload,
2307 })
2308 .is_ok())
2309 }
2310
2311 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2313 self.command_sender
2314 .send(PluginCommand::ClearViewTransform {
2315 buffer_id: BufferId(buffer_id as usize),
2316 split_id: split_id.map(|id| SplitId(id as usize)),
2317 })
2318 .is_ok()
2319 }
2320
2321 pub fn set_layout_hints<'js>(
2324 &self,
2325 buffer_id: u32,
2326 split_id: Option<u32>,
2327 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2328 ) -> rquickjs::Result<bool> {
2329 use fresh_core::api::LayoutHints;
2330
2331 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2332 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2333 let parsed_hints = LayoutHints {
2334 compose_width,
2335 column_guides,
2336 };
2337
2338 Ok(self
2339 .command_sender
2340 .send(PluginCommand::SetLayoutHints {
2341 buffer_id: BufferId(buffer_id as usize),
2342 split_id: split_id.map(|id| SplitId(id as usize)),
2343 range: 0..0,
2344 hints: parsed_hints,
2345 })
2346 .is_ok())
2347 }
2348
2349 pub fn set_file_explorer_decorations<'js>(
2353 &self,
2354 _ctx: rquickjs::Ctx<'js>,
2355 namespace: String,
2356 decorations: Vec<rquickjs::Object<'js>>,
2357 ) -> rquickjs::Result<bool> {
2358 use fresh_core::file_explorer::FileExplorerDecoration;
2359
2360 let decorations: Vec<FileExplorerDecoration> = decorations
2361 .into_iter()
2362 .map(|obj| {
2363 let path: String = obj.get("path")?;
2364 let symbol: String = obj.get("symbol")?;
2365 let color: Vec<u8> = obj.get("color")?;
2366 let priority: i32 = obj.get("priority").unwrap_or(0);
2367
2368 if color.len() < 3 {
2369 return Err(rquickjs::Error::FromJs {
2370 from: "array",
2371 to: "color",
2372 message: Some(format!(
2373 "color array must have at least 3 elements, got {}",
2374 color.len()
2375 )),
2376 });
2377 }
2378
2379 Ok(FileExplorerDecoration {
2380 path: std::path::PathBuf::from(path),
2381 symbol,
2382 color: [color[0], color[1], color[2]],
2383 priority,
2384 })
2385 })
2386 .collect::<rquickjs::Result<Vec<_>>>()?;
2387
2388 self.plugin_tracked_state
2390 .borrow_mut()
2391 .entry(self.plugin_name.clone())
2392 .or_default()
2393 .file_explorer_namespaces
2394 .push(namespace.clone());
2395
2396 Ok(self
2397 .command_sender
2398 .send(PluginCommand::SetFileExplorerDecorations {
2399 namespace,
2400 decorations,
2401 })
2402 .is_ok())
2403 }
2404
2405 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2407 self.command_sender
2408 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2409 .is_ok()
2410 }
2411
2412 #[allow(clippy::too_many_arguments)]
2416 pub fn add_virtual_text(
2417 &self,
2418 buffer_id: u32,
2419 virtual_text_id: String,
2420 position: u32,
2421 text: String,
2422 r: u8,
2423 g: u8,
2424 b: u8,
2425 before: bool,
2426 use_bg: bool,
2427 ) -> bool {
2428 self.plugin_tracked_state
2430 .borrow_mut()
2431 .entry(self.plugin_name.clone())
2432 .or_default()
2433 .virtual_text_ids
2434 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2435
2436 self.command_sender
2437 .send(PluginCommand::AddVirtualText {
2438 buffer_id: BufferId(buffer_id as usize),
2439 virtual_text_id,
2440 position: position as usize,
2441 text,
2442 color: (r, g, b),
2443 use_bg,
2444 before,
2445 })
2446 .is_ok()
2447 }
2448
2449 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2451 self.command_sender
2452 .send(PluginCommand::RemoveVirtualText {
2453 buffer_id: BufferId(buffer_id as usize),
2454 virtual_text_id,
2455 })
2456 .is_ok()
2457 }
2458
2459 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2461 self.command_sender
2462 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2463 buffer_id: BufferId(buffer_id as usize),
2464 prefix,
2465 })
2466 .is_ok()
2467 }
2468
2469 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2471 self.command_sender
2472 .send(PluginCommand::ClearVirtualTexts {
2473 buffer_id: BufferId(buffer_id as usize),
2474 })
2475 .is_ok()
2476 }
2477
2478 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2480 self.command_sender
2481 .send(PluginCommand::ClearVirtualTextNamespace {
2482 buffer_id: BufferId(buffer_id as usize),
2483 namespace,
2484 })
2485 .is_ok()
2486 }
2487
2488 #[allow(clippy::too_many_arguments)]
2490 pub fn add_virtual_line(
2491 &self,
2492 buffer_id: u32,
2493 position: u32,
2494 text: String,
2495 fg_r: u8,
2496 fg_g: u8,
2497 fg_b: u8,
2498 bg_r: u8,
2499 bg_g: u8,
2500 bg_b: u8,
2501 above: bool,
2502 namespace: String,
2503 priority: i32,
2504 ) -> bool {
2505 self.plugin_tracked_state
2507 .borrow_mut()
2508 .entry(self.plugin_name.clone())
2509 .or_default()
2510 .virtual_line_namespaces
2511 .push((BufferId(buffer_id as usize), namespace.clone()));
2512
2513 self.command_sender
2514 .send(PluginCommand::AddVirtualLine {
2515 buffer_id: BufferId(buffer_id as usize),
2516 position: position as usize,
2517 text,
2518 fg_color: (fg_r, fg_g, fg_b),
2519 bg_color: Some((bg_r, bg_g, bg_b)),
2520 above,
2521 namespace,
2522 priority,
2523 })
2524 .is_ok()
2525 }
2526
2527 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2532 #[qjs(rename = "_promptStart")]
2533 pub fn prompt_start(
2534 &self,
2535 _ctx: rquickjs::Ctx<'_>,
2536 label: String,
2537 initial_value: String,
2538 ) -> u64 {
2539 let id = {
2540 let mut id_ref = self.next_request_id.borrow_mut();
2541 let id = *id_ref;
2542 *id_ref += 1;
2543 self.callback_contexts
2545 .borrow_mut()
2546 .insert(id, self.plugin_name.clone());
2547 id
2548 };
2549
2550 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2551 label,
2552 initial_value,
2553 callback_id: JsCallbackId::new(id),
2554 });
2555
2556 id
2557 }
2558
2559 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2561 self.command_sender
2562 .send(PluginCommand::StartPrompt { label, prompt_type })
2563 .is_ok()
2564 }
2565
2566 pub fn start_prompt_with_initial(
2568 &self,
2569 label: String,
2570 prompt_type: String,
2571 initial_value: String,
2572 ) -> bool {
2573 self.command_sender
2574 .send(PluginCommand::StartPromptWithInitial {
2575 label,
2576 prompt_type,
2577 initial_value,
2578 })
2579 .is_ok()
2580 }
2581
2582 pub fn set_prompt_suggestions(
2586 &self,
2587 suggestions: Vec<fresh_core::command::Suggestion>,
2588 ) -> bool {
2589 self.command_sender
2590 .send(PluginCommand::SetPromptSuggestions { suggestions })
2591 .is_ok()
2592 }
2593
2594 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2595 self.command_sender
2596 .send(PluginCommand::SetPromptInputSync { sync })
2597 .is_ok()
2598 }
2599
2600 pub fn define_mode(
2604 &self,
2605 name: String,
2606 bindings_arr: Vec<Vec<String>>,
2607 read_only: rquickjs::function::Opt<bool>,
2608 allow_text_input: rquickjs::function::Opt<bool>,
2609 ) -> bool {
2610 let bindings: Vec<(String, String)> = bindings_arr
2611 .into_iter()
2612 .filter_map(|arr| {
2613 if arr.len() >= 2 {
2614 Some((arr[0].clone(), arr[1].clone()))
2615 } else {
2616 None
2617 }
2618 })
2619 .collect();
2620
2621 {
2624 let mut registered = self.registered_actions.borrow_mut();
2625 for (_, cmd_name) in &bindings {
2626 registered.insert(
2627 cmd_name.clone(),
2628 PluginHandler {
2629 plugin_name: self.plugin_name.clone(),
2630 handler_name: cmd_name.clone(),
2631 },
2632 );
2633 }
2634 }
2635
2636 let allow_text = allow_text_input.0.unwrap_or(false);
2639 if allow_text {
2640 let mut registered = self.registered_actions.borrow_mut();
2641 registered.insert(
2642 "mode_text_input".to_string(),
2643 PluginHandler {
2644 plugin_name: self.plugin_name.clone(),
2645 handler_name: "mode_text_input".to_string(),
2646 },
2647 );
2648 }
2649
2650 self.command_sender
2651 .send(PluginCommand::DefineMode {
2652 name,
2653 bindings,
2654 read_only: read_only.0.unwrap_or(false),
2655 allow_text_input: allow_text,
2656 plugin_name: Some(self.plugin_name.clone()),
2657 })
2658 .is_ok()
2659 }
2660
2661 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2663 self.command_sender
2664 .send(PluginCommand::SetEditorMode { mode })
2665 .is_ok()
2666 }
2667
2668 pub fn get_editor_mode(&self) -> Option<String> {
2670 self.state_snapshot
2671 .read()
2672 .ok()
2673 .and_then(|s| s.editor_mode.clone())
2674 }
2675
2676 pub fn close_split(&self, split_id: u32) -> bool {
2680 self.command_sender
2681 .send(PluginCommand::CloseSplit {
2682 split_id: SplitId(split_id as usize),
2683 })
2684 .is_ok()
2685 }
2686
2687 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2689 self.command_sender
2690 .send(PluginCommand::SetSplitBuffer {
2691 split_id: SplitId(split_id as usize),
2692 buffer_id: BufferId(buffer_id as usize),
2693 })
2694 .is_ok()
2695 }
2696
2697 pub fn focus_split(&self, split_id: u32) -> bool {
2699 self.command_sender
2700 .send(PluginCommand::FocusSplit {
2701 split_id: SplitId(split_id as usize),
2702 })
2703 .is_ok()
2704 }
2705
2706 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2708 self.command_sender
2709 .send(PluginCommand::SetSplitScroll {
2710 split_id: SplitId(split_id as usize),
2711 top_byte: top_byte as usize,
2712 })
2713 .is_ok()
2714 }
2715
2716 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2718 self.command_sender
2719 .send(PluginCommand::SetSplitRatio {
2720 split_id: SplitId(split_id as usize),
2721 ratio,
2722 })
2723 .is_ok()
2724 }
2725
2726 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2728 self.command_sender
2729 .send(PluginCommand::SetSplitLabel {
2730 split_id: SplitId(split_id as usize),
2731 label,
2732 })
2733 .is_ok()
2734 }
2735
2736 pub fn clear_split_label(&self, split_id: u32) -> bool {
2738 self.command_sender
2739 .send(PluginCommand::ClearSplitLabel {
2740 split_id: SplitId(split_id as usize),
2741 })
2742 .is_ok()
2743 }
2744
2745 #[plugin_api(
2747 async_promise,
2748 js_name = "getSplitByLabel",
2749 ts_return = "number | null"
2750 )]
2751 #[qjs(rename = "_getSplitByLabelStart")]
2752 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2753 let id = {
2754 let mut id_ref = self.next_request_id.borrow_mut();
2755 let id = *id_ref;
2756 *id_ref += 1;
2757 self.callback_contexts
2758 .borrow_mut()
2759 .insert(id, self.plugin_name.clone());
2760 id
2761 };
2762 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2763 label,
2764 request_id: id,
2765 });
2766 id
2767 }
2768
2769 pub fn distribute_splits_evenly(&self) -> bool {
2771 self.command_sender
2773 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2774 .is_ok()
2775 }
2776
2777 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2779 self.command_sender
2780 .send(PluginCommand::SetBufferCursor {
2781 buffer_id: BufferId(buffer_id as usize),
2782 position: position as usize,
2783 })
2784 .is_ok()
2785 }
2786
2787 #[allow(clippy::too_many_arguments)]
2791 pub fn set_line_indicator(
2792 &self,
2793 buffer_id: u32,
2794 line: u32,
2795 namespace: String,
2796 symbol: String,
2797 r: u8,
2798 g: u8,
2799 b: u8,
2800 priority: i32,
2801 ) -> bool {
2802 self.plugin_tracked_state
2804 .borrow_mut()
2805 .entry(self.plugin_name.clone())
2806 .or_default()
2807 .line_indicator_namespaces
2808 .push((BufferId(buffer_id as usize), namespace.clone()));
2809
2810 self.command_sender
2811 .send(PluginCommand::SetLineIndicator {
2812 buffer_id: BufferId(buffer_id as usize),
2813 line: line as usize,
2814 namespace,
2815 symbol,
2816 color: (r, g, b),
2817 priority,
2818 })
2819 .is_ok()
2820 }
2821
2822 #[allow(clippy::too_many_arguments)]
2824 pub fn set_line_indicators(
2825 &self,
2826 buffer_id: u32,
2827 lines: Vec<u32>,
2828 namespace: String,
2829 symbol: String,
2830 r: u8,
2831 g: u8,
2832 b: u8,
2833 priority: i32,
2834 ) -> bool {
2835 self.plugin_tracked_state
2837 .borrow_mut()
2838 .entry(self.plugin_name.clone())
2839 .or_default()
2840 .line_indicator_namespaces
2841 .push((BufferId(buffer_id as usize), namespace.clone()));
2842
2843 self.command_sender
2844 .send(PluginCommand::SetLineIndicators {
2845 buffer_id: BufferId(buffer_id as usize),
2846 lines: lines.into_iter().map(|l| l as usize).collect(),
2847 namespace,
2848 symbol,
2849 color: (r, g, b),
2850 priority,
2851 })
2852 .is_ok()
2853 }
2854
2855 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2857 self.command_sender
2858 .send(PluginCommand::ClearLineIndicators {
2859 buffer_id: BufferId(buffer_id as usize),
2860 namespace,
2861 })
2862 .is_ok()
2863 }
2864
2865 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2867 self.command_sender
2868 .send(PluginCommand::SetLineNumbers {
2869 buffer_id: BufferId(buffer_id as usize),
2870 enabled,
2871 })
2872 .is_ok()
2873 }
2874
2875 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2877 self.command_sender
2878 .send(PluginCommand::SetViewMode {
2879 buffer_id: BufferId(buffer_id as usize),
2880 mode,
2881 })
2882 .is_ok()
2883 }
2884
2885 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2887 self.command_sender
2888 .send(PluginCommand::SetLineWrap {
2889 buffer_id: BufferId(buffer_id as usize),
2890 split_id: split_id.map(|s| SplitId(s as usize)),
2891 enabled,
2892 })
2893 .is_ok()
2894 }
2895
2896 pub fn set_view_state<'js>(
2900 &self,
2901 ctx: rquickjs::Ctx<'js>,
2902 buffer_id: u32,
2903 key: String,
2904 value: Value<'js>,
2905 ) -> bool {
2906 let bid = BufferId(buffer_id as usize);
2907
2908 let json_value = if value.is_undefined() || value.is_null() {
2910 None
2911 } else {
2912 Some(js_to_json(&ctx, value))
2913 };
2914
2915 if let Ok(mut snapshot) = self.state_snapshot.write() {
2917 if let Some(ref json_val) = json_value {
2918 snapshot
2919 .plugin_view_states
2920 .entry(bid)
2921 .or_default()
2922 .insert(key.clone(), json_val.clone());
2923 } else {
2924 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2926 map.remove(&key);
2927 if map.is_empty() {
2928 snapshot.plugin_view_states.remove(&bid);
2929 }
2930 }
2931 }
2932 }
2933
2934 self.command_sender
2936 .send(PluginCommand::SetViewState {
2937 buffer_id: bid,
2938 key,
2939 value: json_value,
2940 })
2941 .is_ok()
2942 }
2943
2944 pub fn get_view_state<'js>(
2946 &self,
2947 ctx: rquickjs::Ctx<'js>,
2948 buffer_id: u32,
2949 key: String,
2950 ) -> rquickjs::Result<Value<'js>> {
2951 let bid = BufferId(buffer_id as usize);
2952 if let Ok(snapshot) = self.state_snapshot.read() {
2953 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2954 if let Some(json_val) = map.get(&key) {
2955 return json_to_js_value(&ctx, json_val);
2956 }
2957 }
2958 }
2959 Ok(Value::new_undefined(ctx.clone()))
2960 }
2961
2962 pub fn set_global_state<'js>(
2968 &self,
2969 ctx: rquickjs::Ctx<'js>,
2970 key: String,
2971 value: Value<'js>,
2972 ) -> bool {
2973 let json_value = if value.is_undefined() || value.is_null() {
2975 None
2976 } else {
2977 Some(js_to_json(&ctx, value))
2978 };
2979
2980 if let Ok(mut snapshot) = self.state_snapshot.write() {
2982 if let Some(ref json_val) = json_value {
2983 snapshot
2984 .plugin_global_states
2985 .entry(self.plugin_name.clone())
2986 .or_default()
2987 .insert(key.clone(), json_val.clone());
2988 } else {
2989 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
2991 map.remove(&key);
2992 if map.is_empty() {
2993 snapshot.plugin_global_states.remove(&self.plugin_name);
2994 }
2995 }
2996 }
2997 }
2998
2999 self.command_sender
3001 .send(PluginCommand::SetGlobalState {
3002 plugin_name: self.plugin_name.clone(),
3003 key,
3004 value: json_value,
3005 })
3006 .is_ok()
3007 }
3008
3009 pub fn get_global_state<'js>(
3013 &self,
3014 ctx: rquickjs::Ctx<'js>,
3015 key: String,
3016 ) -> rquickjs::Result<Value<'js>> {
3017 if let Ok(snapshot) = self.state_snapshot.read() {
3018 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3019 if let Some(json_val) = map.get(&key) {
3020 return json_to_js_value(&ctx, json_val);
3021 }
3022 }
3023 }
3024 Ok(Value::new_undefined(ctx.clone()))
3025 }
3026
3027 pub fn create_scroll_sync_group(
3031 &self,
3032 group_id: u32,
3033 left_split: u32,
3034 right_split: u32,
3035 ) -> bool {
3036 self.plugin_tracked_state
3038 .borrow_mut()
3039 .entry(self.plugin_name.clone())
3040 .or_default()
3041 .scroll_sync_group_ids
3042 .push(group_id);
3043 self.command_sender
3044 .send(PluginCommand::CreateScrollSyncGroup {
3045 group_id,
3046 left_split: SplitId(left_split as usize),
3047 right_split: SplitId(right_split as usize),
3048 })
3049 .is_ok()
3050 }
3051
3052 pub fn set_scroll_sync_anchors<'js>(
3054 &self,
3055 _ctx: rquickjs::Ctx<'js>,
3056 group_id: u32,
3057 anchors: Vec<Vec<u32>>,
3058 ) -> bool {
3059 let anchors: Vec<(usize, usize)> = anchors
3060 .into_iter()
3061 .filter_map(|pair| {
3062 if pair.len() >= 2 {
3063 Some((pair[0] as usize, pair[1] as usize))
3064 } else {
3065 None
3066 }
3067 })
3068 .collect();
3069 self.command_sender
3070 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3071 .is_ok()
3072 }
3073
3074 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3076 self.command_sender
3077 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3078 .is_ok()
3079 }
3080
3081 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3087 self.command_sender
3088 .send(PluginCommand::ExecuteActions { actions })
3089 .is_ok()
3090 }
3091
3092 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3096 self.command_sender
3097 .send(PluginCommand::ShowActionPopup {
3098 popup_id: opts.id,
3099 title: opts.title,
3100 message: opts.message,
3101 actions: opts.actions,
3102 })
3103 .is_ok()
3104 }
3105
3106 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3108 self.command_sender
3109 .send(PluginCommand::DisableLspForLanguage { language })
3110 .is_ok()
3111 }
3112
3113 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3115 self.command_sender
3116 .send(PluginCommand::RestartLspForLanguage { language })
3117 .is_ok()
3118 }
3119
3120 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3123 self.command_sender
3124 .send(PluginCommand::SetLspRootUri { language, uri })
3125 .is_ok()
3126 }
3127
3128 #[plugin_api(ts_return = "JsDiagnostic[]")]
3130 pub fn get_all_diagnostics<'js>(
3131 &self,
3132 ctx: rquickjs::Ctx<'js>,
3133 ) -> rquickjs::Result<Value<'js>> {
3134 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3135
3136 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3137 let mut result: Vec<JsDiagnostic> = Vec::new();
3139 for (uri, diags) in &s.diagnostics {
3140 for diag in diags {
3141 result.push(JsDiagnostic {
3142 uri: uri.clone(),
3143 message: diag.message.clone(),
3144 severity: diag.severity.map(|s| match s {
3145 lsp_types::DiagnosticSeverity::ERROR => 1,
3146 lsp_types::DiagnosticSeverity::WARNING => 2,
3147 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3148 lsp_types::DiagnosticSeverity::HINT => 4,
3149 _ => 0,
3150 }),
3151 range: JsRange {
3152 start: JsPosition {
3153 line: diag.range.start.line,
3154 character: diag.range.start.character,
3155 },
3156 end: JsPosition {
3157 line: diag.range.end.line,
3158 character: diag.range.end.character,
3159 },
3160 },
3161 source: diag.source.clone(),
3162 });
3163 }
3164 }
3165 result
3166 } else {
3167 Vec::new()
3168 };
3169 rquickjs_serde::to_value(ctx, &diagnostics)
3170 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3171 }
3172
3173 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3175 self.event_handlers
3176 .borrow()
3177 .get(&event_name)
3178 .cloned()
3179 .unwrap_or_default()
3180 .into_iter()
3181 .map(|h| h.handler_name)
3182 .collect()
3183 }
3184
3185 #[plugin_api(
3189 async_promise,
3190 js_name = "createVirtualBuffer",
3191 ts_return = "VirtualBufferResult"
3192 )]
3193 #[qjs(rename = "_createVirtualBufferStart")]
3194 pub fn create_virtual_buffer_start(
3195 &self,
3196 _ctx: rquickjs::Ctx<'_>,
3197 opts: fresh_core::api::CreateVirtualBufferOptions,
3198 ) -> rquickjs::Result<u64> {
3199 let id = {
3200 let mut id_ref = self.next_request_id.borrow_mut();
3201 let id = *id_ref;
3202 *id_ref += 1;
3203 self.callback_contexts
3205 .borrow_mut()
3206 .insert(id, self.plugin_name.clone());
3207 id
3208 };
3209
3210 let entries: Vec<TextPropertyEntry> = opts
3212 .entries
3213 .unwrap_or_default()
3214 .into_iter()
3215 .map(|e| TextPropertyEntry {
3216 text: e.text,
3217 properties: e.properties.unwrap_or_default(),
3218 style: e.style,
3219 inline_overlays: e.inline_overlays.unwrap_or_default(),
3220 })
3221 .collect();
3222
3223 tracing::debug!(
3224 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3225 id
3226 );
3227 if let Ok(mut owners) = self.async_resource_owners.lock() {
3229 owners.insert(id, self.plugin_name.clone());
3230 }
3231 let _ = self
3232 .command_sender
3233 .send(PluginCommand::CreateVirtualBufferWithContent {
3234 name: opts.name,
3235 mode: opts.mode.unwrap_or_default(),
3236 read_only: opts.read_only.unwrap_or(false),
3237 entries,
3238 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3239 show_cursors: opts.show_cursors.unwrap_or(true),
3240 editing_disabled: opts.editing_disabled.unwrap_or(false),
3241 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3242 request_id: Some(id),
3243 });
3244 Ok(id)
3245 }
3246
3247 #[plugin_api(
3249 async_promise,
3250 js_name = "createVirtualBufferInSplit",
3251 ts_return = "VirtualBufferResult"
3252 )]
3253 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3254 pub fn create_virtual_buffer_in_split_start(
3255 &self,
3256 _ctx: rquickjs::Ctx<'_>,
3257 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3258 ) -> rquickjs::Result<u64> {
3259 let id = {
3260 let mut id_ref = self.next_request_id.borrow_mut();
3261 let id = *id_ref;
3262 *id_ref += 1;
3263 self.callback_contexts
3265 .borrow_mut()
3266 .insert(id, self.plugin_name.clone());
3267 id
3268 };
3269
3270 let entries: Vec<TextPropertyEntry> = opts
3272 .entries
3273 .unwrap_or_default()
3274 .into_iter()
3275 .map(|e| TextPropertyEntry {
3276 text: e.text,
3277 properties: e.properties.unwrap_or_default(),
3278 style: e.style,
3279 inline_overlays: e.inline_overlays.unwrap_or_default(),
3280 })
3281 .collect();
3282
3283 if let Ok(mut owners) = self.async_resource_owners.lock() {
3285 owners.insert(id, self.plugin_name.clone());
3286 }
3287 let _ = self
3288 .command_sender
3289 .send(PluginCommand::CreateVirtualBufferInSplit {
3290 name: opts.name,
3291 mode: opts.mode.unwrap_or_default(),
3292 read_only: opts.read_only.unwrap_or(false),
3293 entries,
3294 ratio: opts.ratio.unwrap_or(0.5),
3295 direction: opts.direction,
3296 panel_id: opts.panel_id,
3297 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3298 show_cursors: opts.show_cursors.unwrap_or(true),
3299 editing_disabled: opts.editing_disabled.unwrap_or(false),
3300 line_wrap: opts.line_wrap,
3301 before: opts.before.unwrap_or(false),
3302 request_id: Some(id),
3303 });
3304 Ok(id)
3305 }
3306
3307 #[plugin_api(
3309 async_promise,
3310 js_name = "createVirtualBufferInExistingSplit",
3311 ts_return = "VirtualBufferResult"
3312 )]
3313 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3314 pub fn create_virtual_buffer_in_existing_split_start(
3315 &self,
3316 _ctx: rquickjs::Ctx<'_>,
3317 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3318 ) -> rquickjs::Result<u64> {
3319 let id = {
3320 let mut id_ref = self.next_request_id.borrow_mut();
3321 let id = *id_ref;
3322 *id_ref += 1;
3323 self.callback_contexts
3325 .borrow_mut()
3326 .insert(id, self.plugin_name.clone());
3327 id
3328 };
3329
3330 let entries: Vec<TextPropertyEntry> = opts
3332 .entries
3333 .unwrap_or_default()
3334 .into_iter()
3335 .map(|e| TextPropertyEntry {
3336 text: e.text,
3337 properties: e.properties.unwrap_or_default(),
3338 style: e.style,
3339 inline_overlays: e.inline_overlays.unwrap_or_default(),
3340 })
3341 .collect();
3342
3343 if let Ok(mut owners) = self.async_resource_owners.lock() {
3345 owners.insert(id, self.plugin_name.clone());
3346 }
3347 let _ = self
3348 .command_sender
3349 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3350 name: opts.name,
3351 mode: opts.mode.unwrap_or_default(),
3352 read_only: opts.read_only.unwrap_or(false),
3353 entries,
3354 split_id: SplitId(opts.split_id),
3355 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3356 show_cursors: opts.show_cursors.unwrap_or(true),
3357 editing_disabled: opts.editing_disabled.unwrap_or(false),
3358 line_wrap: opts.line_wrap,
3359 request_id: Some(id),
3360 });
3361 Ok(id)
3362 }
3363
3364 pub fn set_virtual_buffer_content<'js>(
3368 &self,
3369 ctx: rquickjs::Ctx<'js>,
3370 buffer_id: u32,
3371 entries_arr: Vec<rquickjs::Object<'js>>,
3372 ) -> rquickjs::Result<bool> {
3373 let entries: Vec<TextPropertyEntry> = entries_arr
3374 .iter()
3375 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3376 .collect();
3377 Ok(self
3378 .command_sender
3379 .send(PluginCommand::SetVirtualBufferContent {
3380 buffer_id: BufferId(buffer_id as usize),
3381 entries,
3382 })
3383 .is_ok())
3384 }
3385
3386 pub fn get_text_properties_at_cursor(
3388 &self,
3389 buffer_id: u32,
3390 ) -> fresh_core::api::TextPropertiesAtCursor {
3391 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3392 }
3393
3394 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3398 #[qjs(rename = "_spawnProcessStart")]
3399 pub fn spawn_process_start(
3400 &self,
3401 _ctx: rquickjs::Ctx<'_>,
3402 command: String,
3403 args: Vec<String>,
3404 cwd: rquickjs::function::Opt<String>,
3405 ) -> u64 {
3406 let id = {
3407 let mut id_ref = self.next_request_id.borrow_mut();
3408 let id = *id_ref;
3409 *id_ref += 1;
3410 self.callback_contexts
3412 .borrow_mut()
3413 .insert(id, self.plugin_name.clone());
3414 id
3415 };
3416 let effective_cwd = cwd.0.or_else(|| {
3418 self.state_snapshot
3419 .read()
3420 .ok()
3421 .map(|s| s.working_dir.to_string_lossy().to_string())
3422 });
3423 tracing::info!(
3424 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3425 self.plugin_name,
3426 command,
3427 args,
3428 effective_cwd,
3429 id
3430 );
3431 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3432 callback_id: JsCallbackId::new(id),
3433 command,
3434 args,
3435 cwd: effective_cwd,
3436 });
3437 id
3438 }
3439
3440 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3442 #[qjs(rename = "_spawnProcessWaitStart")]
3443 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3444 let id = {
3445 let mut id_ref = self.next_request_id.borrow_mut();
3446 let id = *id_ref;
3447 *id_ref += 1;
3448 self.callback_contexts
3450 .borrow_mut()
3451 .insert(id, self.plugin_name.clone());
3452 id
3453 };
3454 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3455 process_id,
3456 callback_id: JsCallbackId::new(id),
3457 });
3458 id
3459 }
3460
3461 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3463 #[qjs(rename = "_getBufferTextStart")]
3464 pub fn get_buffer_text_start(
3465 &self,
3466 _ctx: rquickjs::Ctx<'_>,
3467 buffer_id: u32,
3468 start: u32,
3469 end: u32,
3470 ) -> u64 {
3471 let id = {
3472 let mut id_ref = self.next_request_id.borrow_mut();
3473 let id = *id_ref;
3474 *id_ref += 1;
3475 self.callback_contexts
3477 .borrow_mut()
3478 .insert(id, self.plugin_name.clone());
3479 id
3480 };
3481 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3482 buffer_id: BufferId(buffer_id as usize),
3483 start: start as usize,
3484 end: end as usize,
3485 request_id: id,
3486 });
3487 id
3488 }
3489
3490 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3492 #[qjs(rename = "_delayStart")]
3493 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3494 let id = {
3495 let mut id_ref = self.next_request_id.borrow_mut();
3496 let id = *id_ref;
3497 *id_ref += 1;
3498 self.callback_contexts
3500 .borrow_mut()
3501 .insert(id, self.plugin_name.clone());
3502 id
3503 };
3504 let _ = self.command_sender.send(PluginCommand::Delay {
3505 callback_id: JsCallbackId::new(id),
3506 duration_ms,
3507 });
3508 id
3509 }
3510
3511 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3515 #[qjs(rename = "_grepProjectStart")]
3516 pub fn grep_project_start(
3517 &self,
3518 _ctx: rquickjs::Ctx<'_>,
3519 pattern: String,
3520 fixed_string: Option<bool>,
3521 case_sensitive: Option<bool>,
3522 max_results: Option<u32>,
3523 whole_words: Option<bool>,
3524 ) -> u64 {
3525 let id = {
3526 let mut id_ref = self.next_request_id.borrow_mut();
3527 let id = *id_ref;
3528 *id_ref += 1;
3529 self.callback_contexts
3530 .borrow_mut()
3531 .insert(id, self.plugin_name.clone());
3532 id
3533 };
3534 let _ = self.command_sender.send(PluginCommand::GrepProject {
3535 pattern,
3536 fixed_string: fixed_string.unwrap_or(true),
3537 case_sensitive: case_sensitive.unwrap_or(true),
3538 max_results: max_results.unwrap_or(200) as usize,
3539 whole_words: whole_words.unwrap_or(false),
3540 callback_id: JsCallbackId::new(id),
3541 });
3542 id
3543 }
3544
3545 #[plugin_api(
3549 js_name = "grepProjectStreaming",
3550 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3551 )]
3552 #[qjs(rename = "_grepProjectStreamingStart")]
3553 pub fn grep_project_streaming_start(
3554 &self,
3555 _ctx: rquickjs::Ctx<'_>,
3556 pattern: String,
3557 fixed_string: bool,
3558 case_sensitive: bool,
3559 max_results: u32,
3560 whole_words: bool,
3561 ) -> u64 {
3562 let id = {
3563 let mut id_ref = self.next_request_id.borrow_mut();
3564 let id = *id_ref;
3565 *id_ref += 1;
3566 self.callback_contexts
3567 .borrow_mut()
3568 .insert(id, self.plugin_name.clone());
3569 id
3570 };
3571 let _ = self
3572 .command_sender
3573 .send(PluginCommand::GrepProjectStreaming {
3574 pattern,
3575 fixed_string,
3576 case_sensitive,
3577 max_results: max_results as usize,
3578 whole_words,
3579 search_id: id,
3580 callback_id: JsCallbackId::new(id),
3581 });
3582 id
3583 }
3584
3585 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3589 #[qjs(rename = "_replaceInFileStart")]
3590 pub fn replace_in_file_start(
3591 &self,
3592 _ctx: rquickjs::Ctx<'_>,
3593 file_path: String,
3594 matches: Vec<Vec<u32>>,
3595 replacement: String,
3596 ) -> u64 {
3597 let id = {
3598 let mut id_ref = self.next_request_id.borrow_mut();
3599 let id = *id_ref;
3600 *id_ref += 1;
3601 self.callback_contexts
3602 .borrow_mut()
3603 .insert(id, self.plugin_name.clone());
3604 id
3605 };
3606 let match_pairs: Vec<(usize, usize)> = matches
3608 .iter()
3609 .map(|m| (m[0] as usize, m[1] as usize))
3610 .collect();
3611 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3612 file_path: PathBuf::from(file_path),
3613 matches: match_pairs,
3614 replacement,
3615 callback_id: JsCallbackId::new(id),
3616 });
3617 id
3618 }
3619
3620 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3622 #[qjs(rename = "_sendLspRequestStart")]
3623 pub fn send_lsp_request_start<'js>(
3624 &self,
3625 ctx: rquickjs::Ctx<'js>,
3626 language: String,
3627 method: String,
3628 params: Option<rquickjs::Object<'js>>,
3629 ) -> rquickjs::Result<u64> {
3630 let id = {
3631 let mut id_ref = self.next_request_id.borrow_mut();
3632 let id = *id_ref;
3633 *id_ref += 1;
3634 self.callback_contexts
3636 .borrow_mut()
3637 .insert(id, self.plugin_name.clone());
3638 id
3639 };
3640 let params_json: Option<serde_json::Value> = params.map(|obj| {
3642 let val = obj.into_value();
3643 js_to_json(&ctx, val)
3644 });
3645 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3646 request_id: id,
3647 language,
3648 method,
3649 params: params_json,
3650 });
3651 Ok(id)
3652 }
3653
3654 #[plugin_api(
3656 async_thenable,
3657 js_name = "spawnBackgroundProcess",
3658 ts_return = "BackgroundProcessResult"
3659 )]
3660 #[qjs(rename = "_spawnBackgroundProcessStart")]
3661 pub fn spawn_background_process_start(
3662 &self,
3663 _ctx: rquickjs::Ctx<'_>,
3664 command: String,
3665 args: Vec<String>,
3666 cwd: rquickjs::function::Opt<String>,
3667 ) -> u64 {
3668 let id = {
3669 let mut id_ref = self.next_request_id.borrow_mut();
3670 let id = *id_ref;
3671 *id_ref += 1;
3672 self.callback_contexts
3674 .borrow_mut()
3675 .insert(id, self.plugin_name.clone());
3676 id
3677 };
3678 let process_id = id;
3680 self.plugin_tracked_state
3682 .borrow_mut()
3683 .entry(self.plugin_name.clone())
3684 .or_default()
3685 .background_process_ids
3686 .push(process_id);
3687 let _ = self
3688 .command_sender
3689 .send(PluginCommand::SpawnBackgroundProcess {
3690 process_id,
3691 command,
3692 args,
3693 cwd: cwd.0,
3694 callback_id: JsCallbackId::new(id),
3695 });
3696 id
3697 }
3698
3699 pub fn kill_background_process(&self, process_id: u64) -> bool {
3701 self.command_sender
3702 .send(PluginCommand::KillBackgroundProcess { process_id })
3703 .is_ok()
3704 }
3705
3706 #[plugin_api(
3710 async_promise,
3711 js_name = "createTerminal",
3712 ts_return = "TerminalResult"
3713 )]
3714 #[qjs(rename = "_createTerminalStart")]
3715 pub fn create_terminal_start(
3716 &self,
3717 _ctx: rquickjs::Ctx<'_>,
3718 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3719 ) -> rquickjs::Result<u64> {
3720 let id = {
3721 let mut id_ref = self.next_request_id.borrow_mut();
3722 let id = *id_ref;
3723 *id_ref += 1;
3724 self.callback_contexts
3725 .borrow_mut()
3726 .insert(id, self.plugin_name.clone());
3727 id
3728 };
3729
3730 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3731 cwd: None,
3732 direction: None,
3733 ratio: None,
3734 focus: None,
3735 });
3736
3737 if let Ok(mut owners) = self.async_resource_owners.lock() {
3739 owners.insert(id, self.plugin_name.clone());
3740 }
3741 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3742 cwd: opts.cwd,
3743 direction: opts.direction,
3744 ratio: opts.ratio,
3745 focus: opts.focus,
3746 request_id: id,
3747 });
3748 Ok(id)
3749 }
3750
3751 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3753 self.command_sender
3754 .send(PluginCommand::SendTerminalInput {
3755 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3756 data,
3757 })
3758 .is_ok()
3759 }
3760
3761 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3763 self.command_sender
3764 .send(PluginCommand::CloseTerminal {
3765 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3766 })
3767 .is_ok()
3768 }
3769
3770 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
3774 self.command_sender
3775 .send(PluginCommand::RefreshLines {
3776 buffer_id: BufferId(buffer_id as usize),
3777 })
3778 .is_ok()
3779 }
3780
3781 pub fn get_current_locale(&self) -> String {
3783 self.services.current_locale()
3784 }
3785
3786 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
3790 #[qjs(rename = "_loadPluginStart")]
3791 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
3792 let id = {
3793 let mut id_ref = self.next_request_id.borrow_mut();
3794 let id = *id_ref;
3795 *id_ref += 1;
3796 self.callback_contexts
3797 .borrow_mut()
3798 .insert(id, self.plugin_name.clone());
3799 id
3800 };
3801 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
3802 path: std::path::PathBuf::from(path),
3803 callback_id: JsCallbackId::new(id),
3804 });
3805 id
3806 }
3807
3808 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
3810 #[qjs(rename = "_unloadPluginStart")]
3811 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3812 let id = {
3813 let mut id_ref = self.next_request_id.borrow_mut();
3814 let id = *id_ref;
3815 *id_ref += 1;
3816 self.callback_contexts
3817 .borrow_mut()
3818 .insert(id, self.plugin_name.clone());
3819 id
3820 };
3821 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
3822 name,
3823 callback_id: JsCallbackId::new(id),
3824 });
3825 id
3826 }
3827
3828 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3830 #[qjs(rename = "_reloadPluginStart")]
3831 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3832 let id = {
3833 let mut id_ref = self.next_request_id.borrow_mut();
3834 let id = *id_ref;
3835 *id_ref += 1;
3836 self.callback_contexts
3837 .borrow_mut()
3838 .insert(id, self.plugin_name.clone());
3839 id
3840 };
3841 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3842 name,
3843 callback_id: JsCallbackId::new(id),
3844 });
3845 id
3846 }
3847
3848 #[plugin_api(
3851 async_promise,
3852 js_name = "listPlugins",
3853 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3854 )]
3855 #[qjs(rename = "_listPluginsStart")]
3856 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3857 let id = {
3858 let mut id_ref = self.next_request_id.borrow_mut();
3859 let id = *id_ref;
3860 *id_ref += 1;
3861 self.callback_contexts
3862 .borrow_mut()
3863 .insert(id, self.plugin_name.clone());
3864 id
3865 };
3866 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3867 callback_id: JsCallbackId::new(id),
3868 });
3869 id
3870 }
3871}
3872
3873fn parse_view_token(
3880 obj: &rquickjs::Object<'_>,
3881 idx: usize,
3882) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3883 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3884
3885 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3887 from: "object",
3888 to: "ViewTokenWire",
3889 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3890 })?;
3891
3892 let source_offset: Option<usize> = obj
3894 .get("sourceOffset")
3895 .ok()
3896 .or_else(|| obj.get("source_offset").ok());
3897
3898 let kind = if kind_value.is_string() {
3900 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3903 from: "value",
3904 to: "string",
3905 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3906 })?;
3907
3908 match kind_str.to_lowercase().as_str() {
3909 "text" => {
3910 let text: String = obj.get("text").unwrap_or_default();
3911 ViewTokenWireKind::Text(text)
3912 }
3913 "newline" => ViewTokenWireKind::Newline,
3914 "space" => ViewTokenWireKind::Space,
3915 "break" => ViewTokenWireKind::Break,
3916 _ => {
3917 tracing::warn!(
3919 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3920 idx, kind_str
3921 );
3922 return Err(rquickjs::Error::FromJs {
3923 from: "string",
3924 to: "ViewTokenWireKind",
3925 message: Some(format!(
3926 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3927 idx, kind_str
3928 )),
3929 });
3930 }
3931 }
3932 } else if kind_value.is_object() {
3933 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3935 from: "value",
3936 to: "object",
3937 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3938 })?;
3939
3940 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3941 ViewTokenWireKind::Text(text)
3942 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3943 ViewTokenWireKind::BinaryByte(byte)
3944 } else {
3945 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3947 tracing::warn!(
3948 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3949 idx,
3950 keys
3951 );
3952 return Err(rquickjs::Error::FromJs {
3953 from: "object",
3954 to: "ViewTokenWireKind",
3955 message: Some(format!(
3956 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3957 idx, keys
3958 )),
3959 });
3960 }
3961 } else {
3962 tracing::warn!(
3963 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3964 idx,
3965 kind_value.type_of()
3966 );
3967 return Err(rquickjs::Error::FromJs {
3968 from: "value",
3969 to: "ViewTokenWireKind",
3970 message: Some(format!(
3971 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3972 idx
3973 )),
3974 });
3975 };
3976
3977 let style = parse_view_token_style(obj, idx)?;
3979
3980 Ok(ViewTokenWire {
3981 source_offset,
3982 kind,
3983 style,
3984 })
3985}
3986
3987fn parse_view_token_style(
3989 obj: &rquickjs::Object<'_>,
3990 idx: usize,
3991) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
3992 use fresh_core::api::ViewTokenStyle;
3993
3994 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
3995 let Some(s) = style_obj else {
3996 return Ok(None);
3997 };
3998
3999 let fg: Option<Vec<u8>> = s.get("fg").ok();
4000 let bg: Option<Vec<u8>> = s.get("bg").ok();
4001
4002 let fg_color = if let Some(ref c) = fg {
4004 if c.len() < 3 {
4005 tracing::warn!(
4006 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4007 idx,
4008 c.len()
4009 );
4010 None
4011 } else {
4012 Some((c[0], c[1], c[2]))
4013 }
4014 } else {
4015 None
4016 };
4017
4018 let bg_color = if let Some(ref c) = bg {
4019 if c.len() < 3 {
4020 tracing::warn!(
4021 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4022 idx,
4023 c.len()
4024 );
4025 None
4026 } else {
4027 Some((c[0], c[1], c[2]))
4028 }
4029 } else {
4030 None
4031 };
4032
4033 Ok(Some(ViewTokenStyle {
4034 fg: fg_color,
4035 bg: bg_color,
4036 bold: s.get("bold").unwrap_or(false),
4037 italic: s.get("italic").unwrap_or(false),
4038 }))
4039}
4040
4041pub struct QuickJsBackend {
4043 runtime: Runtime,
4044 main_context: Context,
4046 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4048 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4050 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4052 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4054 command_sender: mpsc::Sender<PluginCommand>,
4056 #[allow(dead_code)]
4058 pending_responses: PendingResponses,
4059 next_request_id: Rc<RefCell<u64>>,
4061 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4063 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4065 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4067 async_resource_owners: AsyncResourceOwners,
4070 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4072 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4074 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4076 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4078}
4079
4080impl QuickJsBackend {
4081 pub fn new() -> Result<Self> {
4083 let (tx, _rx) = mpsc::channel();
4084 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4085 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4086 Self::with_state(state_snapshot, tx, services)
4087 }
4088
4089 pub fn with_state(
4091 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4092 command_sender: mpsc::Sender<PluginCommand>,
4093 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4094 ) -> Result<Self> {
4095 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4096 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4097 }
4098
4099 pub fn with_state_and_responses(
4101 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4102 command_sender: mpsc::Sender<PluginCommand>,
4103 pending_responses: PendingResponses,
4104 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4105 ) -> Result<Self> {
4106 let async_resource_owners: AsyncResourceOwners =
4107 Arc::new(std::sync::Mutex::new(HashMap::new()));
4108 Self::with_state_responses_and_resources(
4109 state_snapshot,
4110 command_sender,
4111 pending_responses,
4112 services,
4113 async_resource_owners,
4114 )
4115 }
4116
4117 pub fn with_state_responses_and_resources(
4120 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4121 command_sender: mpsc::Sender<PluginCommand>,
4122 pending_responses: PendingResponses,
4123 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4124 async_resource_owners: AsyncResourceOwners,
4125 ) -> Result<Self> {
4126 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4127
4128 let runtime =
4129 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4130
4131 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4133 |_ctx, _promise, reason, is_handled| {
4134 if !is_handled {
4135 let error_msg = if let Some(exc) = reason.as_exception() {
4137 format!(
4138 "{}: {}",
4139 exc.message().unwrap_or_default(),
4140 exc.stack().unwrap_or_default()
4141 )
4142 } else {
4143 format!("{:?}", reason)
4144 };
4145
4146 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4147
4148 if should_panic_on_js_errors() {
4149 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4152 set_fatal_js_error(full_msg);
4153 }
4154 }
4155 },
4156 )));
4157
4158 let main_context = Context::full(&runtime)
4159 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4160
4161 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4162 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4163 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4164 let next_request_id = Rc::new(RefCell::new(1u64));
4165 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4166 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4167 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4168 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4169 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4170 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4171
4172 let backend = Self {
4173 runtime,
4174 main_context,
4175 plugin_contexts,
4176 event_handlers,
4177 registered_actions,
4178 state_snapshot,
4179 command_sender,
4180 pending_responses,
4181 next_request_id,
4182 callback_contexts,
4183 services,
4184 plugin_tracked_state,
4185 async_resource_owners,
4186 registered_command_names,
4187 registered_grammar_languages,
4188 registered_language_configs,
4189 registered_lsp_servers,
4190 };
4191
4192 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4194
4195 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4196 Ok(backend)
4197 }
4198
4199 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4201 let state_snapshot = Arc::clone(&self.state_snapshot);
4202 let command_sender = self.command_sender.clone();
4203 let event_handlers = Rc::clone(&self.event_handlers);
4204 let registered_actions = Rc::clone(&self.registered_actions);
4205 let next_request_id = Rc::clone(&self.next_request_id);
4206 let registered_command_names = Rc::clone(&self.registered_command_names);
4207 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4208 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4209 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4210
4211 context.with(|ctx| {
4212 let globals = ctx.globals();
4213
4214 globals.set("__pluginName__", plugin_name)?;
4216
4217 let js_api = JsEditorApi {
4220 state_snapshot: Arc::clone(&state_snapshot),
4221 command_sender: command_sender.clone(),
4222 registered_actions: Rc::clone(®istered_actions),
4223 event_handlers: Rc::clone(&event_handlers),
4224 next_request_id: Rc::clone(&next_request_id),
4225 callback_contexts: Rc::clone(&self.callback_contexts),
4226 services: self.services.clone(),
4227 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4228 async_resource_owners: Arc::clone(&self.async_resource_owners),
4229 registered_command_names: Rc::clone(®istered_command_names),
4230 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4231 registered_language_configs: Rc::clone(®istered_language_configs),
4232 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4233 plugin_name: plugin_name.to_string(),
4234 };
4235 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4236
4237 globals.set("editor", editor)?;
4239
4240 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4242
4243 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4245
4246 let console = Object::new(ctx.clone())?;
4249 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4250 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4251 tracing::info!("console.log: {}", parts.join(" "));
4252 })?)?;
4253 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4254 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4255 tracing::warn!("console.warn: {}", parts.join(" "));
4256 })?)?;
4257 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4258 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4259 tracing::error!("console.error: {}", parts.join(" "));
4260 })?)?;
4261 globals.set("console", console)?;
4262
4263 ctx.eval::<(), _>(r#"
4265 // Pending promise callbacks: callbackId -> { resolve, reject }
4266 globalThis._pendingCallbacks = new Map();
4267
4268 // Resolve a pending callback (called from Rust)
4269 globalThis._resolveCallback = function(callbackId, result) {
4270 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4271 const cb = globalThis._pendingCallbacks.get(callbackId);
4272 if (cb) {
4273 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4274 globalThis._pendingCallbacks.delete(callbackId);
4275 cb.resolve(result);
4276 console.log('[JS] _resolveCallback: resolve() called');
4277 } else {
4278 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4279 }
4280 };
4281
4282 // Reject a pending callback (called from Rust)
4283 globalThis._rejectCallback = function(callbackId, error) {
4284 const cb = globalThis._pendingCallbacks.get(callbackId);
4285 if (cb) {
4286 globalThis._pendingCallbacks.delete(callbackId);
4287 cb.reject(new Error(error));
4288 }
4289 };
4290
4291 // Streaming callbacks: called multiple times with partial results
4292 globalThis._streamingCallbacks = new Map();
4293
4294 // Called from Rust with partial data. When done=true, cleans up.
4295 globalThis._callStreamingCallback = function(callbackId, result, done) {
4296 const cb = globalThis._streamingCallbacks.get(callbackId);
4297 if (cb) {
4298 cb(result, done);
4299 if (done) {
4300 globalThis._streamingCallbacks.delete(callbackId);
4301 }
4302 }
4303 };
4304
4305 // Generic async wrapper decorator
4306 // Wraps a function that returns a callbackId into a promise-returning function
4307 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4308 // NOTE: We pass the method name as a string and call via bracket notation
4309 // to preserve rquickjs's automatic Ctx injection for methods
4310 globalThis._wrapAsync = function(methodName, fnName) {
4311 const startFn = editor[methodName];
4312 if (typeof startFn !== 'function') {
4313 // Return a function that always throws - catches missing implementations
4314 return function(...args) {
4315 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4316 editor.debug(`[ASYNC ERROR] ${error.message}`);
4317 throw error;
4318 };
4319 }
4320 return function(...args) {
4321 // Call via bracket notation to preserve method binding and Ctx injection
4322 const callbackId = editor[methodName](...args);
4323 return new Promise((resolve, reject) => {
4324 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4325 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4326 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4327 });
4328 };
4329 };
4330
4331 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4332 // The returned object has .result promise and is itself thenable
4333 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4334 const startFn = editor[methodName];
4335 if (typeof startFn !== 'function') {
4336 // Return a function that always throws - catches missing implementations
4337 return function(...args) {
4338 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4339 editor.debug(`[ASYNC ERROR] ${error.message}`);
4340 throw error;
4341 };
4342 }
4343 return function(...args) {
4344 // Call via bracket notation to preserve method binding and Ctx injection
4345 const callbackId = editor[methodName](...args);
4346 const resultPromise = new Promise((resolve, reject) => {
4347 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4348 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4349 });
4350 return {
4351 get result() { return resultPromise; },
4352 then(onFulfilled, onRejected) {
4353 return resultPromise.then(onFulfilled, onRejected);
4354 },
4355 catch(onRejected) {
4356 return resultPromise.catch(onRejected);
4357 }
4358 };
4359 };
4360 };
4361
4362 // Apply wrappers to async functions on editor
4363 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4364 editor.delay = _wrapAsync("_delayStart", "delay");
4365 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4366 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4367 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4368 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4369 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4370 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4371 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4372 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4373 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4374 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4375 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4376 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4377 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4378 editor.prompt = _wrapAsync("_promptStart", "prompt");
4379 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4380 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4381 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4382 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4383 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4384 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4385
4386 // Streaming grep: takes a progress callback, returns a thenable with searchId
4387 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4388 opts = opts || {};
4389 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4390 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4391 const maxResults = opts.maxResults || 10000;
4392 const wholeWords = opts.wholeWords || false;
4393
4394 const searchId = editor._grepProjectStreamingStart(
4395 pattern, fixedString, caseSensitive, maxResults, wholeWords
4396 );
4397
4398 // Register streaming callback
4399 if (progressCallback) {
4400 globalThis._streamingCallbacks.set(searchId, progressCallback);
4401 }
4402
4403 // Create completion promise (resolved via _resolveCallback when search finishes)
4404 const resultPromise = new Promise(function(resolve, reject) {
4405 globalThis._pendingCallbacks.set(searchId, {
4406 resolve: function(result) {
4407 globalThis._streamingCallbacks.delete(searchId);
4408 resolve(result);
4409 },
4410 reject: function(err) {
4411 globalThis._streamingCallbacks.delete(searchId);
4412 reject(err);
4413 }
4414 });
4415 });
4416
4417 return {
4418 searchId: searchId,
4419 get result() { return resultPromise; },
4420 then: function(f, r) { return resultPromise.then(f, r); },
4421 catch: function(r) { return resultPromise.catch(r); }
4422 };
4423 };
4424
4425 // Wrapper for deleteTheme - wraps sync function in Promise
4426 editor.deleteTheme = function(name) {
4427 return new Promise(function(resolve, reject) {
4428 const success = editor._deleteThemeSync(name);
4429 if (success) {
4430 resolve();
4431 } else {
4432 reject(new Error("Failed to delete theme: " + name));
4433 }
4434 });
4435 };
4436 "#.as_bytes())?;
4437
4438 Ok::<_, rquickjs::Error>(())
4439 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4440
4441 Ok(())
4442 }
4443
4444 pub async fn load_module_with_source(
4446 &mut self,
4447 path: &str,
4448 _plugin_source: &str,
4449 ) -> Result<()> {
4450 let path_buf = PathBuf::from(path);
4451 let source = std::fs::read_to_string(&path_buf)
4452 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4453
4454 let filename = path_buf
4455 .file_name()
4456 .and_then(|s| s.to_str())
4457 .unwrap_or("plugin.ts");
4458
4459 if has_es_imports(&source) {
4461 match bundle_module(&path_buf) {
4463 Ok(bundled) => {
4464 self.execute_js(&bundled, path)?;
4465 }
4466 Err(e) => {
4467 tracing::warn!(
4468 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4469 path,
4470 e
4471 );
4472 return Ok(()); }
4474 }
4475 } else if has_es_module_syntax(&source) {
4476 let stripped = strip_imports_and_exports(&source);
4478 let js_code = if filename.ends_with(".ts") {
4479 transpile_typescript(&stripped, filename)?
4480 } else {
4481 stripped
4482 };
4483 self.execute_js(&js_code, path)?;
4484 } else {
4485 let js_code = if filename.ends_with(".ts") {
4487 transpile_typescript(&source, filename)?
4488 } else {
4489 source
4490 };
4491 self.execute_js(&js_code, path)?;
4492 }
4493
4494 Ok(())
4495 }
4496
4497 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4499 let plugin_name = Path::new(source_name)
4501 .file_stem()
4502 .and_then(|s| s.to_str())
4503 .unwrap_or("unknown");
4504
4505 tracing::debug!(
4506 "execute_js: starting for plugin '{}' from '{}'",
4507 plugin_name,
4508 source_name
4509 );
4510
4511 let context = {
4513 let mut contexts = self.plugin_contexts.borrow_mut();
4514 if let Some(ctx) = contexts.get(plugin_name) {
4515 ctx.clone()
4516 } else {
4517 let ctx = Context::full(&self.runtime).map_err(|e| {
4518 anyhow!(
4519 "Failed to create QuickJS context for plugin {}: {}",
4520 plugin_name,
4521 e
4522 )
4523 })?;
4524 self.setup_context_api(&ctx, plugin_name)?;
4525 contexts.insert(plugin_name.to_string(), ctx.clone());
4526 ctx
4527 }
4528 };
4529
4530 let wrapped_code = format!("(function() {{ {} }})();", code);
4534 let wrapped = wrapped_code.as_str();
4535
4536 context.with(|ctx| {
4537 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4538
4539 let mut eval_options = rquickjs::context::EvalOptions::default();
4541 eval_options.global = true;
4542 eval_options.filename = Some(source_name.to_string());
4543 let result = ctx
4544 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4545 .map_err(|e| format_js_error(&ctx, e, source_name));
4546
4547 tracing::debug!(
4548 "execute_js: plugin code execution finished for '{}', result: {:?}",
4549 plugin_name,
4550 result.is_ok()
4551 );
4552
4553 result
4554 })
4555 }
4556
4557 pub fn execute_source(
4563 &mut self,
4564 source: &str,
4565 plugin_name: &str,
4566 is_typescript: bool,
4567 ) -> Result<()> {
4568 use fresh_parser_js::{
4569 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4570 };
4571
4572 if has_es_imports(source) {
4573 tracing::warn!(
4574 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4575 plugin_name
4576 );
4577 }
4578
4579 let js_code = if has_es_module_syntax(source) {
4580 let stripped = strip_imports_and_exports(source);
4581 if is_typescript {
4582 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4583 } else {
4584 stripped
4585 }
4586 } else if is_typescript {
4587 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4588 } else {
4589 source.to_string()
4590 };
4591
4592 let source_name = format!(
4594 "{}.{}",
4595 plugin_name,
4596 if is_typescript { "ts" } else { "js" }
4597 );
4598 self.execute_js(&js_code, &source_name)
4599 }
4600
4601 pub fn cleanup_plugin(&self, plugin_name: &str) {
4607 self.plugin_contexts.borrow_mut().remove(plugin_name);
4609
4610 for handlers in self.event_handlers.borrow_mut().values_mut() {
4612 handlers.retain(|h| h.plugin_name != plugin_name);
4613 }
4614
4615 self.registered_actions
4617 .borrow_mut()
4618 .retain(|_, h| h.plugin_name != plugin_name);
4619
4620 self.callback_contexts
4622 .borrow_mut()
4623 .retain(|_, pname| pname != plugin_name);
4624
4625 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4627 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4629 std::collections::HashSet::new();
4630 for (buf_id, ns) in &tracked.overlay_namespaces {
4631 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4632 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4634 buffer_id: *buf_id,
4635 namespace: OverlayNamespace::from_string(ns.clone()),
4636 });
4637 let _ = self
4639 .command_sender
4640 .send(PluginCommand::ClearConcealNamespace {
4641 buffer_id: *buf_id,
4642 namespace: OverlayNamespace::from_string(ns.clone()),
4643 });
4644 let _ = self
4645 .command_sender
4646 .send(PluginCommand::ClearSoftBreakNamespace {
4647 buffer_id: *buf_id,
4648 namespace: OverlayNamespace::from_string(ns.clone()),
4649 });
4650 }
4651 }
4652
4653 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4659 std::collections::HashSet::new();
4660 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4661 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4662 let _ = self
4663 .command_sender
4664 .send(PluginCommand::ClearLineIndicators {
4665 buffer_id: *buf_id,
4666 namespace: ns.clone(),
4667 });
4668 }
4669 }
4670
4671 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4673 std::collections::HashSet::new();
4674 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4675 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4676 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4677 buffer_id: *buf_id,
4678 virtual_text_id: vt_id.clone(),
4679 });
4680 }
4681 }
4682
4683 let mut seen_fe_ns: std::collections::HashSet<String> =
4685 std::collections::HashSet::new();
4686 for ns in &tracked.file_explorer_namespaces {
4687 if seen_fe_ns.insert(ns.clone()) {
4688 let _ = self
4689 .command_sender
4690 .send(PluginCommand::ClearFileExplorerDecorations {
4691 namespace: ns.clone(),
4692 });
4693 }
4694 }
4695
4696 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4698 for ctx_name in &tracked.contexts_set {
4699 if seen_ctx.insert(ctx_name.clone()) {
4700 let _ = self.command_sender.send(PluginCommand::SetContext {
4701 name: ctx_name.clone(),
4702 active: false,
4703 });
4704 }
4705 }
4706
4707 for process_id in &tracked.background_process_ids {
4711 let _ = self
4712 .command_sender
4713 .send(PluginCommand::KillBackgroundProcess {
4714 process_id: *process_id,
4715 });
4716 }
4717
4718 for group_id in &tracked.scroll_sync_group_ids {
4720 let _ = self
4721 .command_sender
4722 .send(PluginCommand::RemoveScrollSyncGroup {
4723 group_id: *group_id,
4724 });
4725 }
4726
4727 for buffer_id in &tracked.virtual_buffer_ids {
4729 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4730 buffer_id: *buffer_id,
4731 });
4732 }
4733
4734 for buffer_id in &tracked.composite_buffer_ids {
4736 let _ = self
4737 .command_sender
4738 .send(PluginCommand::CloseCompositeBuffer {
4739 buffer_id: *buffer_id,
4740 });
4741 }
4742
4743 for terminal_id in &tracked.terminal_ids {
4745 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4746 terminal_id: *terminal_id,
4747 });
4748 }
4749 }
4750
4751 if let Ok(mut owners) = self.async_resource_owners.lock() {
4753 owners.retain(|_, name| name != plugin_name);
4754 }
4755
4756 self.registered_command_names
4758 .borrow_mut()
4759 .retain(|_, pname| pname != plugin_name);
4760 self.registered_grammar_languages
4761 .borrow_mut()
4762 .retain(|_, pname| pname != plugin_name);
4763 self.registered_language_configs
4764 .borrow_mut()
4765 .retain(|_, pname| pname != plugin_name);
4766 self.registered_lsp_servers
4767 .borrow_mut()
4768 .retain(|_, pname| pname != plugin_name);
4769
4770 tracing::debug!(
4771 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
4772 plugin_name
4773 );
4774 }
4775
4776 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
4778 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
4779
4780 self.services
4781 .set_js_execution_state(format!("hook '{}'", event_name));
4782
4783 let handlers = self.event_handlers.borrow().get(event_name).cloned();
4784 if let Some(handler_pairs) = handlers {
4785 let plugin_contexts = self.plugin_contexts.borrow();
4786 for handler in &handler_pairs {
4787 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
4788 continue;
4789 };
4790 context.with(|ctx| {
4791 call_handler(&ctx, &handler.handler_name, event_data);
4792 });
4793 }
4794 }
4795
4796 self.services.clear_js_execution_state();
4797 Ok(true)
4798 }
4799
4800 pub fn has_handlers(&self, event_name: &str) -> bool {
4802 self.event_handlers
4803 .borrow()
4804 .get(event_name)
4805 .map(|v| !v.is_empty())
4806 .unwrap_or(false)
4807 }
4808
4809 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
4813 let (lookup_name, text_input_char) =
4816 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
4817 ("mode_text_input", Some(ch.to_string()))
4818 } else {
4819 (action_name, None)
4820 };
4821
4822 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
4823 let (plugin_name, function_name) = match pair {
4824 Some(handler) => (handler.plugin_name, handler.handler_name),
4825 None => ("main".to_string(), lookup_name.to_string()),
4826 };
4827
4828 let plugin_contexts = self.plugin_contexts.borrow();
4829 let context = plugin_contexts
4830 .get(&plugin_name)
4831 .unwrap_or(&self.main_context);
4832
4833 self.services
4835 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
4836
4837 tracing::info!(
4838 "start_action: BEGIN '{}' -> function '{}'",
4839 action_name,
4840 function_name
4841 );
4842
4843 let call_args = if let Some(ref ch) = text_input_char {
4846 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
4847 format!("({{text:\"{}\"}})", escaped)
4848 } else {
4849 "()".to_string()
4850 };
4851
4852 let code = format!(
4853 r#"
4854 (function() {{
4855 console.log('[JS] start_action: calling {fn}');
4856 try {{
4857 if (typeof globalThis.{fn} === 'function') {{
4858 console.log('[JS] start_action: {fn} is a function, invoking...');
4859 globalThis.{fn}{args};
4860 console.log('[JS] start_action: {fn} invoked (may be async)');
4861 }} else {{
4862 console.error('[JS] Action {action} is not defined as a global function');
4863 }}
4864 }} catch (e) {{
4865 console.error('[JS] Action {action} error:', e);
4866 }}
4867 }})();
4868 "#,
4869 fn = function_name,
4870 action = action_name,
4871 args = call_args
4872 );
4873
4874 tracing::info!("start_action: evaluating JS code");
4875 context.with(|ctx| {
4876 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4877 log_js_error(&ctx, e, &format!("action {}", action_name));
4878 }
4879 tracing::info!("start_action: running pending microtasks");
4880 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
4882 tracing::info!("start_action: executed {} pending jobs", count);
4883 });
4884
4885 tracing::info!("start_action: END '{}'", action_name);
4886
4887 self.services.clear_js_execution_state();
4889
4890 Ok(())
4891 }
4892
4893 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
4895 let pair = self.registered_actions.borrow().get(action_name).cloned();
4897 let (plugin_name, function_name) = match pair {
4898 Some(handler) => (handler.plugin_name, handler.handler_name),
4899 None => ("main".to_string(), action_name.to_string()),
4900 };
4901
4902 let plugin_contexts = self.plugin_contexts.borrow();
4903 let context = plugin_contexts
4904 .get(&plugin_name)
4905 .unwrap_or(&self.main_context);
4906
4907 tracing::debug!(
4908 "execute_action: '{}' -> function '{}'",
4909 action_name,
4910 function_name
4911 );
4912
4913 let code = format!(
4916 r#"
4917 (async function() {{
4918 try {{
4919 if (typeof globalThis.{fn} === 'function') {{
4920 const result = globalThis.{fn}();
4921 // If it's a Promise, await it
4922 if (result && typeof result.then === 'function') {{
4923 await result;
4924 }}
4925 }} else {{
4926 console.error('Action {action} is not defined as a global function');
4927 }}
4928 }} catch (e) {{
4929 console.error('Action {action} error:', e);
4930 }}
4931 }})();
4932 "#,
4933 fn = function_name,
4934 action = action_name
4935 );
4936
4937 context.with(|ctx| {
4938 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4940 Ok(value) => {
4941 if value.is_object() {
4943 if let Some(obj) = value.as_object() {
4944 if obj.get::<_, rquickjs::Function>("then").is_ok() {
4946 run_pending_jobs_checked(
4949 &ctx,
4950 &format!("execute_action {} promise", action_name),
4951 );
4952 }
4953 }
4954 }
4955 }
4956 Err(e) => {
4957 log_js_error(&ctx, e, &format!("action {}", action_name));
4958 }
4959 }
4960 });
4961
4962 Ok(())
4963 }
4964
4965 pub fn poll_event_loop_once(&mut self) -> bool {
4967 let mut had_work = false;
4968
4969 self.main_context.with(|ctx| {
4971 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
4972 if count > 0 {
4973 had_work = true;
4974 }
4975 });
4976
4977 let contexts = self.plugin_contexts.borrow().clone();
4979 for (name, context) in contexts {
4980 context.with(|ctx| {
4981 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
4982 if count > 0 {
4983 had_work = true;
4984 }
4985 });
4986 }
4987 had_work
4988 }
4989
4990 pub fn send_status(&self, message: String) {
4992 let _ = self
4993 .command_sender
4994 .send(PluginCommand::SetStatus { message });
4995 }
4996
4997 pub fn send_hook_completed(&self, hook_name: String) {
5001 let _ = self
5002 .command_sender
5003 .send(PluginCommand::HookCompleted { hook_name });
5004 }
5005
5006 pub fn resolve_callback(
5011 &mut self,
5012 callback_id: fresh_core::api::JsCallbackId,
5013 result_json: &str,
5014 ) {
5015 let id = callback_id.as_u64();
5016 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5017
5018 let plugin_name = {
5020 let mut contexts = self.callback_contexts.borrow_mut();
5021 contexts.remove(&id)
5022 };
5023
5024 let Some(name) = plugin_name else {
5025 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5026 return;
5027 };
5028
5029 let plugin_contexts = self.plugin_contexts.borrow();
5030 let Some(context) = plugin_contexts.get(&name) else {
5031 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5032 return;
5033 };
5034
5035 context.with(|ctx| {
5036 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5038 Ok(v) => v,
5039 Err(e) => {
5040 tracing::error!(
5041 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5042 id,
5043 e
5044 );
5045 return;
5046 }
5047 };
5048
5049 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5051 Ok(v) => v,
5052 Err(e) => {
5053 tracing::error!(
5054 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5055 id,
5056 e
5057 );
5058 return;
5059 }
5060 };
5061
5062 let globals = ctx.globals();
5064 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5065 Ok(f) => f,
5066 Err(e) => {
5067 tracing::error!(
5068 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5069 id,
5070 e
5071 );
5072 return;
5073 }
5074 };
5075
5076 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5078 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5079 }
5080
5081 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5083 tracing::info!(
5084 "resolve_callback: executed {} pending jobs for callback_id={}",
5085 job_count,
5086 id
5087 );
5088 });
5089 }
5090
5091 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5093 let id = callback_id.as_u64();
5094
5095 let plugin_name = {
5097 let mut contexts = self.callback_contexts.borrow_mut();
5098 contexts.remove(&id)
5099 };
5100
5101 let Some(name) = plugin_name else {
5102 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5103 return;
5104 };
5105
5106 let plugin_contexts = self.plugin_contexts.borrow();
5107 let Some(context) = plugin_contexts.get(&name) else {
5108 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5109 return;
5110 };
5111
5112 context.with(|ctx| {
5113 let globals = ctx.globals();
5115 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5116 Ok(f) => f,
5117 Err(e) => {
5118 tracing::error!(
5119 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5120 id,
5121 e
5122 );
5123 return;
5124 }
5125 };
5126
5127 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5129 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5130 }
5131
5132 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5134 });
5135 }
5136
5137 pub fn call_streaming_callback(
5141 &mut self,
5142 callback_id: fresh_core::api::JsCallbackId,
5143 result_json: &str,
5144 done: bool,
5145 ) {
5146 let id = callback_id.as_u64();
5147
5148 let plugin_name = {
5150 let contexts = self.callback_contexts.borrow();
5151 contexts.get(&id).cloned()
5152 };
5153
5154 let Some(name) = plugin_name else {
5155 tracing::warn!(
5156 "call_streaming_callback: No plugin found for callback_id={}",
5157 id
5158 );
5159 return;
5160 };
5161
5162 if done {
5164 self.callback_contexts.borrow_mut().remove(&id);
5165 }
5166
5167 let plugin_contexts = self.plugin_contexts.borrow();
5168 let Some(context) = plugin_contexts.get(&name) else {
5169 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5170 return;
5171 };
5172
5173 context.with(|ctx| {
5174 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5175 Ok(v) => v,
5176 Err(e) => {
5177 tracing::error!(
5178 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5179 id,
5180 e
5181 );
5182 return;
5183 }
5184 };
5185
5186 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5187 Ok(v) => v,
5188 Err(e) => {
5189 tracing::error!(
5190 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5191 id,
5192 e
5193 );
5194 return;
5195 }
5196 };
5197
5198 let globals = ctx.globals();
5199 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5200 Ok(f) => f,
5201 Err(e) => {
5202 tracing::error!(
5203 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5204 id,
5205 e
5206 );
5207 return;
5208 }
5209 };
5210
5211 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5212 log_js_error(
5213 &ctx,
5214 e,
5215 &format!("calling streaming callback {}", id),
5216 );
5217 }
5218
5219 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5220 });
5221 }
5222}
5223
5224#[cfg(test)]
5225mod tests {
5226 use super::*;
5227 use fresh_core::api::{BufferInfo, CursorInfo};
5228 use std::sync::mpsc;
5229
5230 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5232 let (tx, rx) = mpsc::channel();
5233 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5234 let services = Arc::new(TestServiceBridge::new());
5235 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5236 (backend, rx)
5237 }
5238
5239 struct TestServiceBridge {
5240 en_strings: std::sync::Mutex<HashMap<String, String>>,
5241 }
5242
5243 impl TestServiceBridge {
5244 fn new() -> Self {
5245 Self {
5246 en_strings: std::sync::Mutex::new(HashMap::new()),
5247 }
5248 }
5249 }
5250
5251 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5252 fn as_any(&self) -> &dyn std::any::Any {
5253 self
5254 }
5255 fn translate(
5256 &self,
5257 _plugin_name: &str,
5258 key: &str,
5259 _args: &HashMap<String, String>,
5260 ) -> String {
5261 self.en_strings
5262 .lock()
5263 .unwrap()
5264 .get(key)
5265 .cloned()
5266 .unwrap_or_else(|| key.to_string())
5267 }
5268 fn current_locale(&self) -> String {
5269 "en".to_string()
5270 }
5271 fn set_js_execution_state(&self, _state: String) {}
5272 fn clear_js_execution_state(&self) {}
5273 fn get_theme_schema(&self) -> serde_json::Value {
5274 serde_json::json!({})
5275 }
5276 fn get_builtin_themes(&self) -> serde_json::Value {
5277 serde_json::json!([])
5278 }
5279 fn register_command(&self, _command: fresh_core::command::Command) {}
5280 fn unregister_command(&self, _name: &str) {}
5281 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5282 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5283 fn plugins_dir(&self) -> std::path::PathBuf {
5284 std::path::PathBuf::from("/tmp/plugins")
5285 }
5286 fn config_dir(&self) -> std::path::PathBuf {
5287 std::path::PathBuf::from("/tmp/config")
5288 }
5289 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5290 None
5291 }
5292 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5293 Err("not implemented in test".to_string())
5294 }
5295 fn theme_file_exists(&self, _name: &str) -> bool {
5296 false
5297 }
5298 }
5299
5300 #[test]
5301 fn test_quickjs_backend_creation() {
5302 let backend = QuickJsBackend::new();
5303 assert!(backend.is_ok());
5304 }
5305
5306 #[test]
5307 fn test_execute_simple_js() {
5308 let mut backend = QuickJsBackend::new().unwrap();
5309 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5310 assert!(result.is_ok());
5311 }
5312
5313 #[test]
5314 fn test_event_handler_registration() {
5315 let backend = QuickJsBackend::new().unwrap();
5316
5317 assert!(!backend.has_handlers("test_event"));
5319
5320 backend
5322 .event_handlers
5323 .borrow_mut()
5324 .entry("test_event".to_string())
5325 .or_default()
5326 .push(PluginHandler {
5327 plugin_name: "test".to_string(),
5328 handler_name: "testHandler".to_string(),
5329 });
5330
5331 assert!(backend.has_handlers("test_event"));
5333 }
5334
5335 #[test]
5338 fn test_api_set_status() {
5339 let (mut backend, rx) = create_test_backend();
5340
5341 backend
5342 .execute_js(
5343 r#"
5344 const editor = getEditor();
5345 editor.setStatus("Hello from test");
5346 "#,
5347 "test.js",
5348 )
5349 .unwrap();
5350
5351 let cmd = rx.try_recv().unwrap();
5352 match cmd {
5353 PluginCommand::SetStatus { message } => {
5354 assert_eq!(message, "Hello from test");
5355 }
5356 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5357 }
5358 }
5359
5360 #[test]
5361 fn test_api_register_command() {
5362 let (mut backend, rx) = create_test_backend();
5363
5364 backend
5365 .execute_js(
5366 r#"
5367 const editor = getEditor();
5368 globalThis.myTestHandler = function() { };
5369 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5370 "#,
5371 "test_plugin.js",
5372 )
5373 .unwrap();
5374
5375 let cmd = rx.try_recv().unwrap();
5376 match cmd {
5377 PluginCommand::RegisterCommand { command } => {
5378 assert_eq!(command.name, "Test Command");
5379 assert_eq!(command.description, "A test command");
5380 assert_eq!(command.plugin_name, "test_plugin");
5382 }
5383 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5384 }
5385 }
5386
5387 #[test]
5388 fn test_api_define_mode() {
5389 let (mut backend, rx) = create_test_backend();
5390
5391 backend
5392 .execute_js(
5393 r#"
5394 const editor = getEditor();
5395 editor.defineMode("test-mode", [
5396 ["a", "action_a"],
5397 ["b", "action_b"]
5398 ]);
5399 "#,
5400 "test.js",
5401 )
5402 .unwrap();
5403
5404 let cmd = rx.try_recv().unwrap();
5405 match cmd {
5406 PluginCommand::DefineMode {
5407 name,
5408 bindings,
5409 read_only,
5410 allow_text_input,
5411 plugin_name,
5412 } => {
5413 assert_eq!(name, "test-mode");
5414 assert_eq!(bindings.len(), 2);
5415 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5416 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5417 assert!(!read_only);
5418 assert!(!allow_text_input);
5419 assert!(plugin_name.is_some());
5420 }
5421 _ => panic!("Expected DefineMode, got {:?}", cmd),
5422 }
5423 }
5424
5425 #[test]
5426 fn test_api_set_editor_mode() {
5427 let (mut backend, rx) = create_test_backend();
5428
5429 backend
5430 .execute_js(
5431 r#"
5432 const editor = getEditor();
5433 editor.setEditorMode("vi-normal");
5434 "#,
5435 "test.js",
5436 )
5437 .unwrap();
5438
5439 let cmd = rx.try_recv().unwrap();
5440 match cmd {
5441 PluginCommand::SetEditorMode { mode } => {
5442 assert_eq!(mode, Some("vi-normal".to_string()));
5443 }
5444 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5445 }
5446 }
5447
5448 #[test]
5449 fn test_api_clear_editor_mode() {
5450 let (mut backend, rx) = create_test_backend();
5451
5452 backend
5453 .execute_js(
5454 r#"
5455 const editor = getEditor();
5456 editor.setEditorMode(null);
5457 "#,
5458 "test.js",
5459 )
5460 .unwrap();
5461
5462 let cmd = rx.try_recv().unwrap();
5463 match cmd {
5464 PluginCommand::SetEditorMode { mode } => {
5465 assert!(mode.is_none());
5466 }
5467 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5468 }
5469 }
5470
5471 #[test]
5472 fn test_api_insert_at_cursor() {
5473 let (mut backend, rx) = create_test_backend();
5474
5475 backend
5476 .execute_js(
5477 r#"
5478 const editor = getEditor();
5479 editor.insertAtCursor("Hello, World!");
5480 "#,
5481 "test.js",
5482 )
5483 .unwrap();
5484
5485 let cmd = rx.try_recv().unwrap();
5486 match cmd {
5487 PluginCommand::InsertAtCursor { text } => {
5488 assert_eq!(text, "Hello, World!");
5489 }
5490 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5491 }
5492 }
5493
5494 #[test]
5495 fn test_api_set_context() {
5496 let (mut backend, rx) = create_test_backend();
5497
5498 backend
5499 .execute_js(
5500 r#"
5501 const editor = getEditor();
5502 editor.setContext("myContext", true);
5503 "#,
5504 "test.js",
5505 )
5506 .unwrap();
5507
5508 let cmd = rx.try_recv().unwrap();
5509 match cmd {
5510 PluginCommand::SetContext { name, active } => {
5511 assert_eq!(name, "myContext");
5512 assert!(active);
5513 }
5514 _ => panic!("Expected SetContext, got {:?}", cmd),
5515 }
5516 }
5517
5518 #[tokio::test]
5519 async fn test_execute_action_sync_function() {
5520 let (mut backend, rx) = create_test_backend();
5521
5522 backend.registered_actions.borrow_mut().insert(
5524 "my_sync_action".to_string(),
5525 PluginHandler {
5526 plugin_name: "test".to_string(),
5527 handler_name: "my_sync_action".to_string(),
5528 },
5529 );
5530
5531 backend
5533 .execute_js(
5534 r#"
5535 const editor = getEditor();
5536 globalThis.my_sync_action = function() {
5537 editor.setStatus("sync action executed");
5538 };
5539 "#,
5540 "test.js",
5541 )
5542 .unwrap();
5543
5544 while rx.try_recv().is_ok() {}
5546
5547 backend.execute_action("my_sync_action").await.unwrap();
5549
5550 let cmd = rx.try_recv().unwrap();
5552 match cmd {
5553 PluginCommand::SetStatus { message } => {
5554 assert_eq!(message, "sync action executed");
5555 }
5556 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5557 }
5558 }
5559
5560 #[tokio::test]
5561 async fn test_execute_action_async_function() {
5562 let (mut backend, rx) = create_test_backend();
5563
5564 backend.registered_actions.borrow_mut().insert(
5566 "my_async_action".to_string(),
5567 PluginHandler {
5568 plugin_name: "test".to_string(),
5569 handler_name: "my_async_action".to_string(),
5570 },
5571 );
5572
5573 backend
5575 .execute_js(
5576 r#"
5577 const editor = getEditor();
5578 globalThis.my_async_action = async function() {
5579 await Promise.resolve();
5580 editor.setStatus("async action executed");
5581 };
5582 "#,
5583 "test.js",
5584 )
5585 .unwrap();
5586
5587 while rx.try_recv().is_ok() {}
5589
5590 backend.execute_action("my_async_action").await.unwrap();
5592
5593 let cmd = rx.try_recv().unwrap();
5595 match cmd {
5596 PluginCommand::SetStatus { message } => {
5597 assert_eq!(message, "async action executed");
5598 }
5599 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5600 }
5601 }
5602
5603 #[tokio::test]
5604 async fn test_execute_action_with_registered_handler() {
5605 let (mut backend, rx) = create_test_backend();
5606
5607 backend.registered_actions.borrow_mut().insert(
5609 "my_action".to_string(),
5610 PluginHandler {
5611 plugin_name: "test".to_string(),
5612 handler_name: "actual_handler_function".to_string(),
5613 },
5614 );
5615
5616 backend
5617 .execute_js(
5618 r#"
5619 const editor = getEditor();
5620 globalThis.actual_handler_function = function() {
5621 editor.setStatus("handler executed");
5622 };
5623 "#,
5624 "test.js",
5625 )
5626 .unwrap();
5627
5628 while rx.try_recv().is_ok() {}
5630
5631 backend.execute_action("my_action").await.unwrap();
5633
5634 let cmd = rx.try_recv().unwrap();
5635 match cmd {
5636 PluginCommand::SetStatus { message } => {
5637 assert_eq!(message, "handler executed");
5638 }
5639 _ => panic!("Expected SetStatus, got {:?}", cmd),
5640 }
5641 }
5642
5643 #[test]
5644 fn test_api_on_event_registration() {
5645 let (mut backend, _rx) = create_test_backend();
5646
5647 backend
5648 .execute_js(
5649 r#"
5650 const editor = getEditor();
5651 globalThis.myEventHandler = function() { };
5652 editor.on("bufferSave", "myEventHandler");
5653 "#,
5654 "test.js",
5655 )
5656 .unwrap();
5657
5658 assert!(backend.has_handlers("bufferSave"));
5659 }
5660
5661 #[test]
5662 fn test_api_off_event_unregistration() {
5663 let (mut backend, _rx) = create_test_backend();
5664
5665 backend
5666 .execute_js(
5667 r#"
5668 const editor = getEditor();
5669 globalThis.myEventHandler = function() { };
5670 editor.on("bufferSave", "myEventHandler");
5671 editor.off("bufferSave", "myEventHandler");
5672 "#,
5673 "test.js",
5674 )
5675 .unwrap();
5676
5677 assert!(!backend.has_handlers("bufferSave"));
5679 }
5680
5681 #[tokio::test]
5682 async fn test_emit_event() {
5683 let (mut backend, rx) = create_test_backend();
5684
5685 backend
5686 .execute_js(
5687 r#"
5688 const editor = getEditor();
5689 globalThis.onSaveHandler = function(data) {
5690 editor.setStatus("saved: " + JSON.stringify(data));
5691 };
5692 editor.on("bufferSave", "onSaveHandler");
5693 "#,
5694 "test.js",
5695 )
5696 .unwrap();
5697
5698 while rx.try_recv().is_ok() {}
5700
5701 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5703 backend.emit("bufferSave", &event_data).await.unwrap();
5704
5705 let cmd = rx.try_recv().unwrap();
5706 match cmd {
5707 PluginCommand::SetStatus { message } => {
5708 assert!(message.contains("/test.txt"));
5709 }
5710 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5711 }
5712 }
5713
5714 #[test]
5715 fn test_api_copy_to_clipboard() {
5716 let (mut backend, rx) = create_test_backend();
5717
5718 backend
5719 .execute_js(
5720 r#"
5721 const editor = getEditor();
5722 editor.copyToClipboard("clipboard text");
5723 "#,
5724 "test.js",
5725 )
5726 .unwrap();
5727
5728 let cmd = rx.try_recv().unwrap();
5729 match cmd {
5730 PluginCommand::SetClipboard { text } => {
5731 assert_eq!(text, "clipboard text");
5732 }
5733 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5734 }
5735 }
5736
5737 #[test]
5738 fn test_api_open_file() {
5739 let (mut backend, rx) = create_test_backend();
5740
5741 backend
5743 .execute_js(
5744 r#"
5745 const editor = getEditor();
5746 editor.openFile("/path/to/file.txt", null, null);
5747 "#,
5748 "test.js",
5749 )
5750 .unwrap();
5751
5752 let cmd = rx.try_recv().unwrap();
5753 match cmd {
5754 PluginCommand::OpenFileAtLocation { path, line, column } => {
5755 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5756 assert!(line.is_none());
5757 assert!(column.is_none());
5758 }
5759 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5760 }
5761 }
5762
5763 #[test]
5764 fn test_api_delete_range() {
5765 let (mut backend, rx) = create_test_backend();
5766
5767 backend
5769 .execute_js(
5770 r#"
5771 const editor = getEditor();
5772 editor.deleteRange(0, 10, 20);
5773 "#,
5774 "test.js",
5775 )
5776 .unwrap();
5777
5778 let cmd = rx.try_recv().unwrap();
5779 match cmd {
5780 PluginCommand::DeleteRange { range, .. } => {
5781 assert_eq!(range.start, 10);
5782 assert_eq!(range.end, 20);
5783 }
5784 _ => panic!("Expected DeleteRange, got {:?}", cmd),
5785 }
5786 }
5787
5788 #[test]
5789 fn test_api_insert_text() {
5790 let (mut backend, rx) = create_test_backend();
5791
5792 backend
5794 .execute_js(
5795 r#"
5796 const editor = getEditor();
5797 editor.insertText(0, 5, "inserted");
5798 "#,
5799 "test.js",
5800 )
5801 .unwrap();
5802
5803 let cmd = rx.try_recv().unwrap();
5804 match cmd {
5805 PluginCommand::InsertText { position, text, .. } => {
5806 assert_eq!(position, 5);
5807 assert_eq!(text, "inserted");
5808 }
5809 _ => panic!("Expected InsertText, got {:?}", cmd),
5810 }
5811 }
5812
5813 #[test]
5814 fn test_api_set_buffer_cursor() {
5815 let (mut backend, rx) = create_test_backend();
5816
5817 backend
5819 .execute_js(
5820 r#"
5821 const editor = getEditor();
5822 editor.setBufferCursor(0, 100);
5823 "#,
5824 "test.js",
5825 )
5826 .unwrap();
5827
5828 let cmd = rx.try_recv().unwrap();
5829 match cmd {
5830 PluginCommand::SetBufferCursor { position, .. } => {
5831 assert_eq!(position, 100);
5832 }
5833 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
5834 }
5835 }
5836
5837 #[test]
5838 fn test_api_get_cursor_position_from_state() {
5839 let (tx, _rx) = mpsc::channel();
5840 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5841
5842 {
5844 let mut state = state_snapshot.write().unwrap();
5845 state.primary_cursor = Some(CursorInfo {
5846 position: 42,
5847 selection: None,
5848 });
5849 }
5850
5851 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5852 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5853
5854 backend
5856 .execute_js(
5857 r#"
5858 const editor = getEditor();
5859 const pos = editor.getCursorPosition();
5860 globalThis._testResult = pos;
5861 "#,
5862 "test.js",
5863 )
5864 .unwrap();
5865
5866 backend
5868 .plugin_contexts
5869 .borrow()
5870 .get("test")
5871 .unwrap()
5872 .clone()
5873 .with(|ctx| {
5874 let global = ctx.globals();
5875 let result: u32 = global.get("_testResult").unwrap();
5876 assert_eq!(result, 42);
5877 });
5878 }
5879
5880 #[test]
5881 fn test_api_path_functions() {
5882 let (mut backend, _rx) = create_test_backend();
5883
5884 #[cfg(windows)]
5887 let absolute_path = r#"C:\\foo\\bar"#;
5888 #[cfg(not(windows))]
5889 let absolute_path = "/foo/bar";
5890
5891 let js_code = format!(
5893 r#"
5894 const editor = getEditor();
5895 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
5896 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
5897 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
5898 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
5899 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
5900 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
5901 "#,
5902 absolute_path
5903 );
5904 backend.execute_js(&js_code, "test.js").unwrap();
5905
5906 backend
5907 .plugin_contexts
5908 .borrow()
5909 .get("test")
5910 .unwrap()
5911 .clone()
5912 .with(|ctx| {
5913 let global = ctx.globals();
5914 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
5915 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
5916 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
5917 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
5918 assert!(!global.get::<_, bool>("_isRelative").unwrap());
5919 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
5920 });
5921 }
5922
5923 #[test]
5924 fn test_file_uri_to_path_and_back() {
5925 let (mut backend, _rx) = create_test_backend();
5926
5927 #[cfg(not(windows))]
5929 let js_code = r#"
5930 const editor = getEditor();
5931 // Basic file URI to path
5932 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
5933 // Percent-encoded characters
5934 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
5935 // Invalid URI returns empty string
5936 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5937 // Path to file URI
5938 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
5939 // Round-trip
5940 globalThis._roundtrip = editor.fileUriToPath(
5941 editor.pathToFileUri("/home/user/file.txt")
5942 );
5943 "#;
5944
5945 #[cfg(windows)]
5946 let js_code = r#"
5947 const editor = getEditor();
5948 // Windows URI with encoded colon (the bug from issue #1071)
5949 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
5950 // Windows URI with normal colon
5951 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
5952 // Invalid URI returns empty string
5953 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5954 // Path to file URI
5955 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
5956 // Round-trip
5957 globalThis._roundtrip = editor.fileUriToPath(
5958 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
5959 );
5960 "#;
5961
5962 backend.execute_js(js_code, "test.js").unwrap();
5963
5964 backend
5965 .plugin_contexts
5966 .borrow()
5967 .get("test")
5968 .unwrap()
5969 .clone()
5970 .with(|ctx| {
5971 let global = ctx.globals();
5972
5973 #[cfg(not(windows))]
5974 {
5975 assert_eq!(
5976 global.get::<_, String>("_path1").unwrap(),
5977 "/home/user/file.txt"
5978 );
5979 assert_eq!(
5980 global.get::<_, String>("_path2").unwrap(),
5981 "/home/user/my file.txt"
5982 );
5983 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
5984 assert_eq!(
5985 global.get::<_, String>("_uri1").unwrap(),
5986 "file:///home/user/file.txt"
5987 );
5988 assert_eq!(
5989 global.get::<_, String>("_roundtrip").unwrap(),
5990 "/home/user/file.txt"
5991 );
5992 }
5993
5994 #[cfg(windows)]
5995 {
5996 assert_eq!(
5998 global.get::<_, String>("_path1").unwrap(),
5999 "C:\\Users\\admin\\Repos\\file.cs"
6000 );
6001 assert_eq!(
6002 global.get::<_, String>("_path2").unwrap(),
6003 "C:\\Users\\admin\\Repos\\file.cs"
6004 );
6005 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6006 assert_eq!(
6007 global.get::<_, String>("_uri1").unwrap(),
6008 "file:///C:/Users/admin/Repos/file.cs"
6009 );
6010 assert_eq!(
6011 global.get::<_, String>("_roundtrip").unwrap(),
6012 "C:\\Users\\admin\\Repos\\file.cs"
6013 );
6014 }
6015 });
6016 }
6017
6018 #[test]
6019 fn test_typescript_transpilation() {
6020 use fresh_parser_js::transpile_typescript;
6021
6022 let (mut backend, rx) = create_test_backend();
6023
6024 let ts_code = r#"
6026 const editor = getEditor();
6027 function greet(name: string): string {
6028 return "Hello, " + name;
6029 }
6030 editor.setStatus(greet("TypeScript"));
6031 "#;
6032
6033 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6035
6036 backend.execute_js(&js_code, "test.js").unwrap();
6038
6039 let cmd = rx.try_recv().unwrap();
6040 match cmd {
6041 PluginCommand::SetStatus { message } => {
6042 assert_eq!(message, "Hello, TypeScript");
6043 }
6044 _ => panic!("Expected SetStatus, got {:?}", cmd),
6045 }
6046 }
6047
6048 #[test]
6049 fn test_api_get_buffer_text_sends_command() {
6050 let (mut backend, rx) = create_test_backend();
6051
6052 backend
6054 .execute_js(
6055 r#"
6056 const editor = getEditor();
6057 // Store the promise for later
6058 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6059 "#,
6060 "test.js",
6061 )
6062 .unwrap();
6063
6064 let cmd = rx.try_recv().unwrap();
6066 match cmd {
6067 PluginCommand::GetBufferText {
6068 buffer_id,
6069 start,
6070 end,
6071 request_id,
6072 } => {
6073 assert_eq!(buffer_id.0, 0);
6074 assert_eq!(start, 10);
6075 assert_eq!(end, 20);
6076 assert!(request_id > 0); }
6078 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6079 }
6080 }
6081
6082 #[test]
6083 fn test_api_get_buffer_text_resolves_callback() {
6084 let (mut backend, rx) = create_test_backend();
6085
6086 backend
6088 .execute_js(
6089 r#"
6090 const editor = getEditor();
6091 globalThis._resolvedText = null;
6092 editor.getBufferText(0, 0, 100).then(text => {
6093 globalThis._resolvedText = text;
6094 });
6095 "#,
6096 "test.js",
6097 )
6098 .unwrap();
6099
6100 let request_id = match rx.try_recv().unwrap() {
6102 PluginCommand::GetBufferText { request_id, .. } => request_id,
6103 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6104 };
6105
6106 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6108
6109 backend
6111 .plugin_contexts
6112 .borrow()
6113 .get("test")
6114 .unwrap()
6115 .clone()
6116 .with(|ctx| {
6117 run_pending_jobs_checked(&ctx, "test async getText");
6118 });
6119
6120 backend
6122 .plugin_contexts
6123 .borrow()
6124 .get("test")
6125 .unwrap()
6126 .clone()
6127 .with(|ctx| {
6128 let global = ctx.globals();
6129 let result: String = global.get("_resolvedText").unwrap();
6130 assert_eq!(result, "hello world");
6131 });
6132 }
6133
6134 #[test]
6135 fn test_plugin_translation() {
6136 let (mut backend, _rx) = create_test_backend();
6137
6138 backend
6140 .execute_js(
6141 r#"
6142 const editor = getEditor();
6143 globalThis._translated = editor.t("test.key");
6144 "#,
6145 "test.js",
6146 )
6147 .unwrap();
6148
6149 backend
6150 .plugin_contexts
6151 .borrow()
6152 .get("test")
6153 .unwrap()
6154 .clone()
6155 .with(|ctx| {
6156 let global = ctx.globals();
6157 let result: String = global.get("_translated").unwrap();
6159 assert_eq!(result, "test.key");
6160 });
6161 }
6162
6163 #[test]
6164 fn test_plugin_translation_with_registered_strings() {
6165 let (mut backend, _rx) = create_test_backend();
6166
6167 let mut en_strings = std::collections::HashMap::new();
6169 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6170 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6171
6172 let mut strings = std::collections::HashMap::new();
6173 strings.insert("en".to_string(), en_strings);
6174
6175 if let Some(bridge) = backend
6177 .services
6178 .as_any()
6179 .downcast_ref::<TestServiceBridge>()
6180 {
6181 let mut en = bridge.en_strings.lock().unwrap();
6182 en.insert("greeting".to_string(), "Hello, World!".to_string());
6183 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6184 }
6185
6186 backend
6188 .execute_js(
6189 r#"
6190 const editor = getEditor();
6191 globalThis._greeting = editor.t("greeting");
6192 globalThis._prompt = editor.t("prompt.find_file");
6193 globalThis._missing = editor.t("nonexistent.key");
6194 "#,
6195 "test.js",
6196 )
6197 .unwrap();
6198
6199 backend
6200 .plugin_contexts
6201 .borrow()
6202 .get("test")
6203 .unwrap()
6204 .clone()
6205 .with(|ctx| {
6206 let global = ctx.globals();
6207 let greeting: String = global.get("_greeting").unwrap();
6208 assert_eq!(greeting, "Hello, World!");
6209
6210 let prompt: String = global.get("_prompt").unwrap();
6211 assert_eq!(prompt, "Find file: ");
6212
6213 let missing: String = global.get("_missing").unwrap();
6215 assert_eq!(missing, "nonexistent.key");
6216 });
6217 }
6218
6219 #[test]
6222 fn test_api_set_line_indicator() {
6223 let (mut backend, rx) = create_test_backend();
6224
6225 backend
6226 .execute_js(
6227 r#"
6228 const editor = getEditor();
6229 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6230 "#,
6231 "test.js",
6232 )
6233 .unwrap();
6234
6235 let cmd = rx.try_recv().unwrap();
6236 match cmd {
6237 PluginCommand::SetLineIndicator {
6238 buffer_id,
6239 line,
6240 namespace,
6241 symbol,
6242 color,
6243 priority,
6244 } => {
6245 assert_eq!(buffer_id.0, 1);
6246 assert_eq!(line, 5);
6247 assert_eq!(namespace, "test-ns");
6248 assert_eq!(symbol, "●");
6249 assert_eq!(color, (255, 0, 0));
6250 assert_eq!(priority, 10);
6251 }
6252 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6253 }
6254 }
6255
6256 #[test]
6257 fn test_api_clear_line_indicators() {
6258 let (mut backend, rx) = create_test_backend();
6259
6260 backend
6261 .execute_js(
6262 r#"
6263 const editor = getEditor();
6264 editor.clearLineIndicators(1, "test-ns");
6265 "#,
6266 "test.js",
6267 )
6268 .unwrap();
6269
6270 let cmd = rx.try_recv().unwrap();
6271 match cmd {
6272 PluginCommand::ClearLineIndicators {
6273 buffer_id,
6274 namespace,
6275 } => {
6276 assert_eq!(buffer_id.0, 1);
6277 assert_eq!(namespace, "test-ns");
6278 }
6279 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6280 }
6281 }
6282
6283 #[test]
6286 fn test_api_create_virtual_buffer_sends_command() {
6287 let (mut backend, rx) = create_test_backend();
6288
6289 backend
6290 .execute_js(
6291 r#"
6292 const editor = getEditor();
6293 editor.createVirtualBuffer({
6294 name: "*Test Buffer*",
6295 mode: "test-mode",
6296 readOnly: true,
6297 entries: [
6298 { text: "Line 1\n", properties: { type: "header" } },
6299 { text: "Line 2\n", properties: { type: "content" } }
6300 ],
6301 showLineNumbers: false,
6302 showCursors: true,
6303 editingDisabled: true
6304 });
6305 "#,
6306 "test.js",
6307 )
6308 .unwrap();
6309
6310 let cmd = rx.try_recv().unwrap();
6311 match cmd {
6312 PluginCommand::CreateVirtualBufferWithContent {
6313 name,
6314 mode,
6315 read_only,
6316 entries,
6317 show_line_numbers,
6318 show_cursors,
6319 editing_disabled,
6320 ..
6321 } => {
6322 assert_eq!(name, "*Test Buffer*");
6323 assert_eq!(mode, "test-mode");
6324 assert!(read_only);
6325 assert_eq!(entries.len(), 2);
6326 assert_eq!(entries[0].text, "Line 1\n");
6327 assert!(!show_line_numbers);
6328 assert!(show_cursors);
6329 assert!(editing_disabled);
6330 }
6331 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6332 }
6333 }
6334
6335 #[test]
6336 fn test_api_set_virtual_buffer_content() {
6337 let (mut backend, rx) = create_test_backend();
6338
6339 backend
6340 .execute_js(
6341 r#"
6342 const editor = getEditor();
6343 editor.setVirtualBufferContent(5, [
6344 { text: "New content\n", properties: { type: "updated" } }
6345 ]);
6346 "#,
6347 "test.js",
6348 )
6349 .unwrap();
6350
6351 let cmd = rx.try_recv().unwrap();
6352 match cmd {
6353 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6354 assert_eq!(buffer_id.0, 5);
6355 assert_eq!(entries.len(), 1);
6356 assert_eq!(entries[0].text, "New content\n");
6357 }
6358 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6359 }
6360 }
6361
6362 #[test]
6365 fn test_api_add_overlay() {
6366 let (mut backend, rx) = create_test_backend();
6367
6368 backend
6369 .execute_js(
6370 r#"
6371 const editor = getEditor();
6372 editor.addOverlay(1, "highlight", 10, 20, {
6373 fg: [255, 128, 0],
6374 bg: [50, 50, 50],
6375 bold: true,
6376 });
6377 "#,
6378 "test.js",
6379 )
6380 .unwrap();
6381
6382 let cmd = rx.try_recv().unwrap();
6383 match cmd {
6384 PluginCommand::AddOverlay {
6385 buffer_id,
6386 namespace,
6387 range,
6388 options,
6389 } => {
6390 use fresh_core::api::OverlayColorSpec;
6391 assert_eq!(buffer_id.0, 1);
6392 assert!(namespace.is_some());
6393 assert_eq!(namespace.unwrap().as_str(), "highlight");
6394 assert_eq!(range, 10..20);
6395 assert!(matches!(
6396 options.fg,
6397 Some(OverlayColorSpec::Rgb(255, 128, 0))
6398 ));
6399 assert!(matches!(
6400 options.bg,
6401 Some(OverlayColorSpec::Rgb(50, 50, 50))
6402 ));
6403 assert!(!options.underline);
6404 assert!(options.bold);
6405 assert!(!options.italic);
6406 assert!(!options.extend_to_line_end);
6407 }
6408 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6409 }
6410 }
6411
6412 #[test]
6413 fn test_api_add_overlay_with_theme_keys() {
6414 let (mut backend, rx) = create_test_backend();
6415
6416 backend
6417 .execute_js(
6418 r#"
6419 const editor = getEditor();
6420 // Test with theme keys for colors
6421 editor.addOverlay(1, "themed", 0, 10, {
6422 fg: "ui.status_bar_fg",
6423 bg: "editor.selection_bg",
6424 });
6425 "#,
6426 "test.js",
6427 )
6428 .unwrap();
6429
6430 let cmd = rx.try_recv().unwrap();
6431 match cmd {
6432 PluginCommand::AddOverlay {
6433 buffer_id,
6434 namespace,
6435 range,
6436 options,
6437 } => {
6438 use fresh_core::api::OverlayColorSpec;
6439 assert_eq!(buffer_id.0, 1);
6440 assert!(namespace.is_some());
6441 assert_eq!(namespace.unwrap().as_str(), "themed");
6442 assert_eq!(range, 0..10);
6443 assert!(matches!(
6444 &options.fg,
6445 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6446 ));
6447 assert!(matches!(
6448 &options.bg,
6449 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6450 ));
6451 assert!(!options.underline);
6452 assert!(!options.bold);
6453 assert!(!options.italic);
6454 assert!(!options.extend_to_line_end);
6455 }
6456 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6457 }
6458 }
6459
6460 #[test]
6461 fn test_api_clear_namespace() {
6462 let (mut backend, rx) = create_test_backend();
6463
6464 backend
6465 .execute_js(
6466 r#"
6467 const editor = getEditor();
6468 editor.clearNamespace(1, "highlight");
6469 "#,
6470 "test.js",
6471 )
6472 .unwrap();
6473
6474 let cmd = rx.try_recv().unwrap();
6475 match cmd {
6476 PluginCommand::ClearNamespace {
6477 buffer_id,
6478 namespace,
6479 } => {
6480 assert_eq!(buffer_id.0, 1);
6481 assert_eq!(namespace.as_str(), "highlight");
6482 }
6483 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6484 }
6485 }
6486
6487 #[test]
6490 fn test_api_get_theme_schema() {
6491 let (mut backend, _rx) = create_test_backend();
6492
6493 backend
6494 .execute_js(
6495 r#"
6496 const editor = getEditor();
6497 const schema = editor.getThemeSchema();
6498 globalThis._isObject = typeof schema === 'object' && schema !== null;
6499 "#,
6500 "test.js",
6501 )
6502 .unwrap();
6503
6504 backend
6505 .plugin_contexts
6506 .borrow()
6507 .get("test")
6508 .unwrap()
6509 .clone()
6510 .with(|ctx| {
6511 let global = ctx.globals();
6512 let is_object: bool = global.get("_isObject").unwrap();
6513 assert!(is_object);
6515 });
6516 }
6517
6518 #[test]
6519 fn test_api_get_builtin_themes() {
6520 let (mut backend, _rx) = create_test_backend();
6521
6522 backend
6523 .execute_js(
6524 r#"
6525 const editor = getEditor();
6526 const themes = editor.getBuiltinThemes();
6527 globalThis._isObject = typeof themes === 'object' && themes !== null;
6528 "#,
6529 "test.js",
6530 )
6531 .unwrap();
6532
6533 backend
6534 .plugin_contexts
6535 .borrow()
6536 .get("test")
6537 .unwrap()
6538 .clone()
6539 .with(|ctx| {
6540 let global = ctx.globals();
6541 let is_object: bool = global.get("_isObject").unwrap();
6542 assert!(is_object);
6544 });
6545 }
6546
6547 #[test]
6548 fn test_api_apply_theme() {
6549 let (mut backend, rx) = create_test_backend();
6550
6551 backend
6552 .execute_js(
6553 r#"
6554 const editor = getEditor();
6555 editor.applyTheme("dark");
6556 "#,
6557 "test.js",
6558 )
6559 .unwrap();
6560
6561 let cmd = rx.try_recv().unwrap();
6562 match cmd {
6563 PluginCommand::ApplyTheme { theme_name } => {
6564 assert_eq!(theme_name, "dark");
6565 }
6566 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6567 }
6568 }
6569
6570 #[test]
6571 fn test_api_get_theme_data_missing() {
6572 let (mut backend, _rx) = create_test_backend();
6573
6574 backend
6575 .execute_js(
6576 r#"
6577 const editor = getEditor();
6578 const data = editor.getThemeData("nonexistent");
6579 globalThis._isNull = data === null;
6580 "#,
6581 "test.js",
6582 )
6583 .unwrap();
6584
6585 backend
6586 .plugin_contexts
6587 .borrow()
6588 .get("test")
6589 .unwrap()
6590 .clone()
6591 .with(|ctx| {
6592 let global = ctx.globals();
6593 let is_null: bool = global.get("_isNull").unwrap();
6594 assert!(is_null);
6596 });
6597 }
6598
6599 #[test]
6600 fn test_api_get_theme_data_present() {
6601 let (tx, _rx) = mpsc::channel();
6603 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6604 let services = Arc::new(ThemeCacheTestBridge {
6605 inner: TestServiceBridge::new(),
6606 });
6607 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6608
6609 backend
6610 .execute_js(
6611 r#"
6612 const editor = getEditor();
6613 const data = editor.getThemeData("test-theme");
6614 globalThis._hasData = data !== null && typeof data === 'object';
6615 globalThis._name = data ? data.name : null;
6616 "#,
6617 "test.js",
6618 )
6619 .unwrap();
6620
6621 backend
6622 .plugin_contexts
6623 .borrow()
6624 .get("test")
6625 .unwrap()
6626 .clone()
6627 .with(|ctx| {
6628 let global = ctx.globals();
6629 let has_data: bool = global.get("_hasData").unwrap();
6630 assert!(has_data, "getThemeData should return theme object");
6631 let name: String = global.get("_name").unwrap();
6632 assert_eq!(name, "test-theme");
6633 });
6634 }
6635
6636 #[test]
6637 fn test_api_theme_file_exists() {
6638 let (mut backend, _rx) = create_test_backend();
6639
6640 backend
6641 .execute_js(
6642 r#"
6643 const editor = getEditor();
6644 globalThis._exists = editor.themeFileExists("anything");
6645 "#,
6646 "test.js",
6647 )
6648 .unwrap();
6649
6650 backend
6651 .plugin_contexts
6652 .borrow()
6653 .get("test")
6654 .unwrap()
6655 .clone()
6656 .with(|ctx| {
6657 let global = ctx.globals();
6658 let exists: bool = global.get("_exists").unwrap();
6659 assert!(!exists);
6661 });
6662 }
6663
6664 #[test]
6665 fn test_api_save_theme_file_error() {
6666 let (mut backend, _rx) = create_test_backend();
6667
6668 backend
6669 .execute_js(
6670 r#"
6671 const editor = getEditor();
6672 let threw = false;
6673 try {
6674 editor.saveThemeFile("test", "{}");
6675 } catch (e) {
6676 threw = true;
6677 }
6678 globalThis._threw = threw;
6679 "#,
6680 "test.js",
6681 )
6682 .unwrap();
6683
6684 backend
6685 .plugin_contexts
6686 .borrow()
6687 .get("test")
6688 .unwrap()
6689 .clone()
6690 .with(|ctx| {
6691 let global = ctx.globals();
6692 let threw: bool = global.get("_threw").unwrap();
6693 assert!(threw);
6695 });
6696 }
6697
6698 struct ThemeCacheTestBridge {
6700 inner: TestServiceBridge,
6701 }
6702
6703 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6704 fn as_any(&self) -> &dyn std::any::Any {
6705 self
6706 }
6707 fn translate(
6708 &self,
6709 plugin_name: &str,
6710 key: &str,
6711 args: &HashMap<String, String>,
6712 ) -> String {
6713 self.inner.translate(plugin_name, key, args)
6714 }
6715 fn current_locale(&self) -> String {
6716 self.inner.current_locale()
6717 }
6718 fn set_js_execution_state(&self, state: String) {
6719 self.inner.set_js_execution_state(state);
6720 }
6721 fn clear_js_execution_state(&self) {
6722 self.inner.clear_js_execution_state();
6723 }
6724 fn get_theme_schema(&self) -> serde_json::Value {
6725 self.inner.get_theme_schema()
6726 }
6727 fn get_builtin_themes(&self) -> serde_json::Value {
6728 self.inner.get_builtin_themes()
6729 }
6730 fn register_command(&self, command: fresh_core::command::Command) {
6731 self.inner.register_command(command);
6732 }
6733 fn unregister_command(&self, name: &str) {
6734 self.inner.unregister_command(name);
6735 }
6736 fn unregister_commands_by_prefix(&self, prefix: &str) {
6737 self.inner.unregister_commands_by_prefix(prefix);
6738 }
6739 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6740 self.inner.unregister_commands_by_plugin(plugin_name);
6741 }
6742 fn plugins_dir(&self) -> std::path::PathBuf {
6743 self.inner.plugins_dir()
6744 }
6745 fn config_dir(&self) -> std::path::PathBuf {
6746 self.inner.config_dir()
6747 }
6748 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6749 if name == "test-theme" {
6750 Some(serde_json::json!({
6751 "name": "test-theme",
6752 "editor": {},
6753 "ui": {},
6754 "syntax": {}
6755 }))
6756 } else {
6757 None
6758 }
6759 }
6760 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6761 Err("test bridge does not support save".to_string())
6762 }
6763 fn theme_file_exists(&self, name: &str) -> bool {
6764 name == "test-theme"
6765 }
6766 }
6767
6768 #[test]
6771 fn test_api_close_buffer() {
6772 let (mut backend, rx) = create_test_backend();
6773
6774 backend
6775 .execute_js(
6776 r#"
6777 const editor = getEditor();
6778 editor.closeBuffer(3);
6779 "#,
6780 "test.js",
6781 )
6782 .unwrap();
6783
6784 let cmd = rx.try_recv().unwrap();
6785 match cmd {
6786 PluginCommand::CloseBuffer { buffer_id } => {
6787 assert_eq!(buffer_id.0, 3);
6788 }
6789 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
6790 }
6791 }
6792
6793 #[test]
6794 fn test_api_focus_split() {
6795 let (mut backend, rx) = create_test_backend();
6796
6797 backend
6798 .execute_js(
6799 r#"
6800 const editor = getEditor();
6801 editor.focusSplit(2);
6802 "#,
6803 "test.js",
6804 )
6805 .unwrap();
6806
6807 let cmd = rx.try_recv().unwrap();
6808 match cmd {
6809 PluginCommand::FocusSplit { split_id } => {
6810 assert_eq!(split_id.0, 2);
6811 }
6812 _ => panic!("Expected FocusSplit, got {:?}", cmd),
6813 }
6814 }
6815
6816 #[test]
6817 fn test_api_list_buffers() {
6818 let (tx, _rx) = mpsc::channel();
6819 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6820
6821 {
6823 let mut state = state_snapshot.write().unwrap();
6824 state.buffers.insert(
6825 BufferId(0),
6826 BufferInfo {
6827 id: BufferId(0),
6828 path: Some(PathBuf::from("/test1.txt")),
6829 modified: false,
6830 length: 100,
6831 is_virtual: false,
6832 view_mode: "source".to_string(),
6833 is_composing_in_any_split: false,
6834 compose_width: None,
6835 language: "text".to_string(),
6836 },
6837 );
6838 state.buffers.insert(
6839 BufferId(1),
6840 BufferInfo {
6841 id: BufferId(1),
6842 path: Some(PathBuf::from("/test2.txt")),
6843 modified: true,
6844 length: 200,
6845 is_virtual: false,
6846 view_mode: "source".to_string(),
6847 is_composing_in_any_split: false,
6848 compose_width: None,
6849 language: "text".to_string(),
6850 },
6851 );
6852 }
6853
6854 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6855 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6856
6857 backend
6858 .execute_js(
6859 r#"
6860 const editor = getEditor();
6861 const buffers = editor.listBuffers();
6862 globalThis._isArray = Array.isArray(buffers);
6863 globalThis._length = buffers.length;
6864 "#,
6865 "test.js",
6866 )
6867 .unwrap();
6868
6869 backend
6870 .plugin_contexts
6871 .borrow()
6872 .get("test")
6873 .unwrap()
6874 .clone()
6875 .with(|ctx| {
6876 let global = ctx.globals();
6877 let is_array: bool = global.get("_isArray").unwrap();
6878 let length: u32 = global.get("_length").unwrap();
6879 assert!(is_array);
6880 assert_eq!(length, 2);
6881 });
6882 }
6883
6884 #[test]
6887 fn test_api_start_prompt() {
6888 let (mut backend, rx) = create_test_backend();
6889
6890 backend
6891 .execute_js(
6892 r#"
6893 const editor = getEditor();
6894 editor.startPrompt("Enter value:", "test-prompt");
6895 "#,
6896 "test.js",
6897 )
6898 .unwrap();
6899
6900 let cmd = rx.try_recv().unwrap();
6901 match cmd {
6902 PluginCommand::StartPrompt { label, prompt_type } => {
6903 assert_eq!(label, "Enter value:");
6904 assert_eq!(prompt_type, "test-prompt");
6905 }
6906 _ => panic!("Expected StartPrompt, got {:?}", cmd),
6907 }
6908 }
6909
6910 #[test]
6911 fn test_api_start_prompt_with_initial() {
6912 let (mut backend, rx) = create_test_backend();
6913
6914 backend
6915 .execute_js(
6916 r#"
6917 const editor = getEditor();
6918 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
6919 "#,
6920 "test.js",
6921 )
6922 .unwrap();
6923
6924 let cmd = rx.try_recv().unwrap();
6925 match cmd {
6926 PluginCommand::StartPromptWithInitial {
6927 label,
6928 prompt_type,
6929 initial_value,
6930 } => {
6931 assert_eq!(label, "Enter value:");
6932 assert_eq!(prompt_type, "test-prompt");
6933 assert_eq!(initial_value, "default");
6934 }
6935 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
6936 }
6937 }
6938
6939 #[test]
6940 fn test_api_set_prompt_suggestions() {
6941 let (mut backend, rx) = create_test_backend();
6942
6943 backend
6944 .execute_js(
6945 r#"
6946 const editor = getEditor();
6947 editor.setPromptSuggestions([
6948 { text: "Option 1", value: "opt1" },
6949 { text: "Option 2", value: "opt2" }
6950 ]);
6951 "#,
6952 "test.js",
6953 )
6954 .unwrap();
6955
6956 let cmd = rx.try_recv().unwrap();
6957 match cmd {
6958 PluginCommand::SetPromptSuggestions { suggestions } => {
6959 assert_eq!(suggestions.len(), 2);
6960 assert_eq!(suggestions[0].text, "Option 1");
6961 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
6962 }
6963 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
6964 }
6965 }
6966
6967 #[test]
6970 fn test_api_get_active_buffer_id() {
6971 let (tx, _rx) = mpsc::channel();
6972 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6973
6974 {
6975 let mut state = state_snapshot.write().unwrap();
6976 state.active_buffer_id = BufferId(42);
6977 }
6978
6979 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6980 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6981
6982 backend
6983 .execute_js(
6984 r#"
6985 const editor = getEditor();
6986 globalThis._activeId = editor.getActiveBufferId();
6987 "#,
6988 "test.js",
6989 )
6990 .unwrap();
6991
6992 backend
6993 .plugin_contexts
6994 .borrow()
6995 .get("test")
6996 .unwrap()
6997 .clone()
6998 .with(|ctx| {
6999 let global = ctx.globals();
7000 let result: u32 = global.get("_activeId").unwrap();
7001 assert_eq!(result, 42);
7002 });
7003 }
7004
7005 #[test]
7006 fn test_api_get_active_split_id() {
7007 let (tx, _rx) = mpsc::channel();
7008 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7009
7010 {
7011 let mut state = state_snapshot.write().unwrap();
7012 state.active_split_id = 7;
7013 }
7014
7015 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7016 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7017
7018 backend
7019 .execute_js(
7020 r#"
7021 const editor = getEditor();
7022 globalThis._splitId = editor.getActiveSplitId();
7023 "#,
7024 "test.js",
7025 )
7026 .unwrap();
7027
7028 backend
7029 .plugin_contexts
7030 .borrow()
7031 .get("test")
7032 .unwrap()
7033 .clone()
7034 .with(|ctx| {
7035 let global = ctx.globals();
7036 let result: u32 = global.get("_splitId").unwrap();
7037 assert_eq!(result, 7);
7038 });
7039 }
7040
7041 #[test]
7044 fn test_api_file_exists() {
7045 let (mut backend, _rx) = create_test_backend();
7046
7047 backend
7048 .execute_js(
7049 r#"
7050 const editor = getEditor();
7051 // Test with a path that definitely exists
7052 globalThis._exists = editor.fileExists("/");
7053 "#,
7054 "test.js",
7055 )
7056 .unwrap();
7057
7058 backend
7059 .plugin_contexts
7060 .borrow()
7061 .get("test")
7062 .unwrap()
7063 .clone()
7064 .with(|ctx| {
7065 let global = ctx.globals();
7066 let result: bool = global.get("_exists").unwrap();
7067 assert!(result);
7068 });
7069 }
7070
7071 #[test]
7072 fn test_api_get_cwd() {
7073 let (mut backend, _rx) = create_test_backend();
7074
7075 backend
7076 .execute_js(
7077 r#"
7078 const editor = getEditor();
7079 globalThis._cwd = editor.getCwd();
7080 "#,
7081 "test.js",
7082 )
7083 .unwrap();
7084
7085 backend
7086 .plugin_contexts
7087 .borrow()
7088 .get("test")
7089 .unwrap()
7090 .clone()
7091 .with(|ctx| {
7092 let global = ctx.globals();
7093 let result: String = global.get("_cwd").unwrap();
7094 assert!(!result.is_empty());
7096 });
7097 }
7098
7099 #[test]
7100 fn test_api_get_env() {
7101 let (mut backend, _rx) = create_test_backend();
7102
7103 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
7105
7106 backend
7107 .execute_js(
7108 r#"
7109 const editor = getEditor();
7110 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
7111 "#,
7112 "test.js",
7113 )
7114 .unwrap();
7115
7116 backend
7117 .plugin_contexts
7118 .borrow()
7119 .get("test")
7120 .unwrap()
7121 .clone()
7122 .with(|ctx| {
7123 let global = ctx.globals();
7124 let result: Option<String> = global.get("_envVal").unwrap();
7125 assert_eq!(result, Some("test_value".to_string()));
7126 });
7127
7128 std::env::remove_var("TEST_PLUGIN_VAR");
7129 }
7130
7131 #[test]
7132 fn test_api_get_config() {
7133 let (mut backend, _rx) = create_test_backend();
7134
7135 backend
7136 .execute_js(
7137 r#"
7138 const editor = getEditor();
7139 const config = editor.getConfig();
7140 globalThis._isObject = typeof config === 'object';
7141 "#,
7142 "test.js",
7143 )
7144 .unwrap();
7145
7146 backend
7147 .plugin_contexts
7148 .borrow()
7149 .get("test")
7150 .unwrap()
7151 .clone()
7152 .with(|ctx| {
7153 let global = ctx.globals();
7154 let is_object: bool = global.get("_isObject").unwrap();
7155 assert!(is_object);
7157 });
7158 }
7159
7160 #[test]
7161 fn test_api_get_themes_dir() {
7162 let (mut backend, _rx) = create_test_backend();
7163
7164 backend
7165 .execute_js(
7166 r#"
7167 const editor = getEditor();
7168 globalThis._themesDir = editor.getThemesDir();
7169 "#,
7170 "test.js",
7171 )
7172 .unwrap();
7173
7174 backend
7175 .plugin_contexts
7176 .borrow()
7177 .get("test")
7178 .unwrap()
7179 .clone()
7180 .with(|ctx| {
7181 let global = ctx.globals();
7182 let result: String = global.get("_themesDir").unwrap();
7183 assert!(!result.is_empty());
7185 });
7186 }
7187
7188 #[test]
7191 fn test_api_read_dir() {
7192 let (mut backend, _rx) = create_test_backend();
7193
7194 backend
7195 .execute_js(
7196 r#"
7197 const editor = getEditor();
7198 const entries = editor.readDir("/tmp");
7199 globalThis._isArray = Array.isArray(entries);
7200 globalThis._length = entries.length;
7201 "#,
7202 "test.js",
7203 )
7204 .unwrap();
7205
7206 backend
7207 .plugin_contexts
7208 .borrow()
7209 .get("test")
7210 .unwrap()
7211 .clone()
7212 .with(|ctx| {
7213 let global = ctx.globals();
7214 let is_array: bool = global.get("_isArray").unwrap();
7215 let length: u32 = global.get("_length").unwrap();
7216 assert!(is_array);
7218 let _ = length;
7220 });
7221 }
7222
7223 #[test]
7226 fn test_api_execute_action() {
7227 let (mut backend, rx) = create_test_backend();
7228
7229 backend
7230 .execute_js(
7231 r#"
7232 const editor = getEditor();
7233 editor.executeAction("move_cursor_up");
7234 "#,
7235 "test.js",
7236 )
7237 .unwrap();
7238
7239 let cmd = rx.try_recv().unwrap();
7240 match cmd {
7241 PluginCommand::ExecuteAction { action_name } => {
7242 assert_eq!(action_name, "move_cursor_up");
7243 }
7244 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7245 }
7246 }
7247
7248 #[test]
7251 fn test_api_debug() {
7252 let (mut backend, _rx) = create_test_backend();
7253
7254 backend
7256 .execute_js(
7257 r#"
7258 const editor = getEditor();
7259 editor.debug("Test debug message");
7260 editor.debug("Another message with special chars: <>&\"'");
7261 "#,
7262 "test.js",
7263 )
7264 .unwrap();
7265 }
7267
7268 #[test]
7271 fn test_typescript_preamble_generated() {
7272 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7274 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7275 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7276 println!(
7277 "Generated {} bytes of TypeScript preamble",
7278 JSEDITORAPI_TS_PREAMBLE.len()
7279 );
7280 }
7281
7282 #[test]
7283 fn test_typescript_editor_api_generated() {
7284 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7286 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7287 println!(
7288 "Generated {} bytes of EditorAPI interface",
7289 JSEDITORAPI_TS_EDITOR_API.len()
7290 );
7291 }
7292
7293 #[test]
7294 fn test_js_methods_list() {
7295 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7297 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7298 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7300 if i < 20 {
7301 println!(" - {}", method);
7302 }
7303 }
7304 if JSEDITORAPI_JS_METHODS.len() > 20 {
7305 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7306 }
7307 }
7308
7309 #[test]
7312 fn test_api_load_plugin_sends_command() {
7313 let (mut backend, rx) = create_test_backend();
7314
7315 backend
7317 .execute_js(
7318 r#"
7319 const editor = getEditor();
7320 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7321 "#,
7322 "test.js",
7323 )
7324 .unwrap();
7325
7326 let cmd = rx.try_recv().unwrap();
7328 match cmd {
7329 PluginCommand::LoadPlugin { path, callback_id } => {
7330 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7331 assert!(callback_id.0 > 0); }
7333 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7334 }
7335 }
7336
7337 #[test]
7338 fn test_api_unload_plugin_sends_command() {
7339 let (mut backend, rx) = create_test_backend();
7340
7341 backend
7343 .execute_js(
7344 r#"
7345 const editor = getEditor();
7346 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7347 "#,
7348 "test.js",
7349 )
7350 .unwrap();
7351
7352 let cmd = rx.try_recv().unwrap();
7354 match cmd {
7355 PluginCommand::UnloadPlugin { name, callback_id } => {
7356 assert_eq!(name, "my-plugin");
7357 assert!(callback_id.0 > 0); }
7359 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7360 }
7361 }
7362
7363 #[test]
7364 fn test_api_reload_plugin_sends_command() {
7365 let (mut backend, rx) = create_test_backend();
7366
7367 backend
7369 .execute_js(
7370 r#"
7371 const editor = getEditor();
7372 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7373 "#,
7374 "test.js",
7375 )
7376 .unwrap();
7377
7378 let cmd = rx.try_recv().unwrap();
7380 match cmd {
7381 PluginCommand::ReloadPlugin { name, callback_id } => {
7382 assert_eq!(name, "my-plugin");
7383 assert!(callback_id.0 > 0); }
7385 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7386 }
7387 }
7388
7389 #[test]
7390 fn test_api_load_plugin_resolves_callback() {
7391 let (mut backend, rx) = create_test_backend();
7392
7393 backend
7395 .execute_js(
7396 r#"
7397 const editor = getEditor();
7398 globalThis._loadResult = null;
7399 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7400 globalThis._loadResult = result;
7401 });
7402 "#,
7403 "test.js",
7404 )
7405 .unwrap();
7406
7407 let callback_id = match rx.try_recv().unwrap() {
7409 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7410 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7411 };
7412
7413 backend.resolve_callback(callback_id, "true");
7415
7416 backend
7418 .plugin_contexts
7419 .borrow()
7420 .get("test")
7421 .unwrap()
7422 .clone()
7423 .with(|ctx| {
7424 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7425 });
7426
7427 backend
7429 .plugin_contexts
7430 .borrow()
7431 .get("test")
7432 .unwrap()
7433 .clone()
7434 .with(|ctx| {
7435 let global = ctx.globals();
7436 let result: bool = global.get("_loadResult").unwrap();
7437 assert!(result);
7438 });
7439 }
7440
7441 #[test]
7442 fn test_api_version() {
7443 let (mut backend, _rx) = create_test_backend();
7444
7445 backend
7446 .execute_js(
7447 r#"
7448 const editor = getEditor();
7449 globalThis._apiVersion = editor.apiVersion();
7450 "#,
7451 "test.js",
7452 )
7453 .unwrap();
7454
7455 backend
7456 .plugin_contexts
7457 .borrow()
7458 .get("test")
7459 .unwrap()
7460 .clone()
7461 .with(|ctx| {
7462 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7463 assert_eq!(version, 2);
7464 });
7465 }
7466
7467 #[test]
7468 fn test_api_unload_plugin_rejects_on_error() {
7469 let (mut backend, rx) = create_test_backend();
7470
7471 backend
7473 .execute_js(
7474 r#"
7475 const editor = getEditor();
7476 globalThis._unloadError = null;
7477 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7478 globalThis._unloadError = err.message || String(err);
7479 });
7480 "#,
7481 "test.js",
7482 )
7483 .unwrap();
7484
7485 let callback_id = match rx.try_recv().unwrap() {
7487 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7488 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7489 };
7490
7491 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7493
7494 backend
7496 .plugin_contexts
7497 .borrow()
7498 .get("test")
7499 .unwrap()
7500 .clone()
7501 .with(|ctx| {
7502 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7503 });
7504
7505 backend
7507 .plugin_contexts
7508 .borrow()
7509 .get("test")
7510 .unwrap()
7511 .clone()
7512 .with(|ctx| {
7513 let global = ctx.globals();
7514 let error: String = global.get("_unloadError").unwrap();
7515 assert!(error.contains("nonexistent-plugin"));
7516 });
7517 }
7518
7519 #[test]
7520 fn test_api_set_global_state() {
7521 let (mut backend, rx) = create_test_backend();
7522
7523 backend
7524 .execute_js(
7525 r#"
7526 const editor = getEditor();
7527 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7528 "#,
7529 "test_plugin.js",
7530 )
7531 .unwrap();
7532
7533 let cmd = rx.try_recv().unwrap();
7534 match cmd {
7535 PluginCommand::SetGlobalState {
7536 plugin_name,
7537 key,
7538 value,
7539 } => {
7540 assert_eq!(plugin_name, "test_plugin");
7541 assert_eq!(key, "myKey");
7542 let v = value.unwrap();
7543 assert_eq!(v["enabled"], serde_json::json!(true));
7544 assert_eq!(v["count"], serde_json::json!(42));
7545 }
7546 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7547 }
7548 }
7549
7550 #[test]
7551 fn test_api_set_global_state_delete() {
7552 let (mut backend, rx) = create_test_backend();
7553
7554 backend
7555 .execute_js(
7556 r#"
7557 const editor = getEditor();
7558 editor.setGlobalState("myKey", null);
7559 "#,
7560 "test_plugin.js",
7561 )
7562 .unwrap();
7563
7564 let cmd = rx.try_recv().unwrap();
7565 match cmd {
7566 PluginCommand::SetGlobalState {
7567 plugin_name,
7568 key,
7569 value,
7570 } => {
7571 assert_eq!(plugin_name, "test_plugin");
7572 assert_eq!(key, "myKey");
7573 assert!(value.is_none(), "null should delete the key");
7574 }
7575 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7576 }
7577 }
7578
7579 #[test]
7580 fn test_api_get_global_state_roundtrip() {
7581 let (mut backend, _rx) = create_test_backend();
7582
7583 backend
7585 .execute_js(
7586 r#"
7587 const editor = getEditor();
7588 editor.setGlobalState("flag", true);
7589 globalThis._result = editor.getGlobalState("flag");
7590 "#,
7591 "test_plugin.js",
7592 )
7593 .unwrap();
7594
7595 backend
7596 .plugin_contexts
7597 .borrow()
7598 .get("test_plugin")
7599 .unwrap()
7600 .clone()
7601 .with(|ctx| {
7602 let global = ctx.globals();
7603 let result: bool = global.get("_result").unwrap();
7604 assert!(
7605 result,
7606 "getGlobalState should return the value set by setGlobalState"
7607 );
7608 });
7609 }
7610
7611 #[test]
7612 fn test_api_get_global_state_missing_key() {
7613 let (mut backend, _rx) = create_test_backend();
7614
7615 backend
7616 .execute_js(
7617 r#"
7618 const editor = getEditor();
7619 globalThis._result = editor.getGlobalState("nonexistent");
7620 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7621 "#,
7622 "test_plugin.js",
7623 )
7624 .unwrap();
7625
7626 backend
7627 .plugin_contexts
7628 .borrow()
7629 .get("test_plugin")
7630 .unwrap()
7631 .clone()
7632 .with(|ctx| {
7633 let global = ctx.globals();
7634 let is_undefined: bool = global.get("_isUndefined").unwrap();
7635 assert!(
7636 is_undefined,
7637 "getGlobalState for missing key should return undefined"
7638 );
7639 });
7640 }
7641
7642 #[test]
7643 fn test_api_global_state_isolation_between_plugins() {
7644 let (tx, _rx) = mpsc::channel();
7646 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7647 let services = Arc::new(TestServiceBridge::new());
7648
7649 let mut backend_a =
7651 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7652 .unwrap();
7653 backend_a
7654 .execute_js(
7655 r#"
7656 const editor = getEditor();
7657 editor.setGlobalState("flag", "from_plugin_a");
7658 "#,
7659 "plugin_a.js",
7660 )
7661 .unwrap();
7662
7663 let mut backend_b =
7665 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7666 .unwrap();
7667 backend_b
7668 .execute_js(
7669 r#"
7670 const editor = getEditor();
7671 editor.setGlobalState("flag", "from_plugin_b");
7672 "#,
7673 "plugin_b.js",
7674 )
7675 .unwrap();
7676
7677 backend_a
7679 .execute_js(
7680 r#"
7681 const editor = getEditor();
7682 globalThis._aValue = editor.getGlobalState("flag");
7683 "#,
7684 "plugin_a.js",
7685 )
7686 .unwrap();
7687
7688 backend_a
7689 .plugin_contexts
7690 .borrow()
7691 .get("plugin_a")
7692 .unwrap()
7693 .clone()
7694 .with(|ctx| {
7695 let global = ctx.globals();
7696 let a_value: String = global.get("_aValue").unwrap();
7697 assert_eq!(
7698 a_value, "from_plugin_a",
7699 "Plugin A should see its own value, not plugin B's"
7700 );
7701 });
7702
7703 backend_b
7705 .execute_js(
7706 r#"
7707 const editor = getEditor();
7708 globalThis._bValue = editor.getGlobalState("flag");
7709 "#,
7710 "plugin_b.js",
7711 )
7712 .unwrap();
7713
7714 backend_b
7715 .plugin_contexts
7716 .borrow()
7717 .get("plugin_b")
7718 .unwrap()
7719 .clone()
7720 .with(|ctx| {
7721 let global = ctx.globals();
7722 let b_value: String = global.get("_bValue").unwrap();
7723 assert_eq!(
7724 b_value, "from_plugin_b",
7725 "Plugin B should see its own value, not plugin A's"
7726 );
7727 });
7728 }
7729
7730 #[test]
7731 fn test_register_command_collision_different_plugins() {
7732 let (mut backend, _rx) = create_test_backend();
7733
7734 backend
7736 .execute_js(
7737 r#"
7738 const editor = getEditor();
7739 globalThis.handlerA = function() { };
7740 editor.registerCommand("My Command", "From A", "handlerA", null);
7741 "#,
7742 "plugin_a.js",
7743 )
7744 .unwrap();
7745
7746 let result = backend.execute_js(
7748 r#"
7749 const editor = getEditor();
7750 globalThis.handlerB = function() { };
7751 editor.registerCommand("My Command", "From B", "handlerB", null);
7752 "#,
7753 "plugin_b.js",
7754 );
7755
7756 assert!(
7757 result.is_err(),
7758 "Second plugin registering the same command name should fail"
7759 );
7760 let err_msg = result.unwrap_err().to_string();
7761 assert!(
7762 err_msg.contains("already registered"),
7763 "Error should mention collision: {}",
7764 err_msg
7765 );
7766 }
7767
7768 #[test]
7769 fn test_register_command_same_plugin_allowed() {
7770 let (mut backend, _rx) = create_test_backend();
7771
7772 backend
7774 .execute_js(
7775 r#"
7776 const editor = getEditor();
7777 globalThis.handler1 = function() { };
7778 editor.registerCommand("My Command", "Version 1", "handler1", null);
7779 globalThis.handler2 = function() { };
7780 editor.registerCommand("My Command", "Version 2", "handler2", null);
7781 "#,
7782 "plugin_a.js",
7783 )
7784 .unwrap();
7785 }
7786
7787 #[test]
7788 fn test_register_command_after_unregister() {
7789 let (mut backend, _rx) = create_test_backend();
7790
7791 backend
7793 .execute_js(
7794 r#"
7795 const editor = getEditor();
7796 globalThis.handlerA = function() { };
7797 editor.registerCommand("My Command", "From A", "handlerA", null);
7798 editor.unregisterCommand("My Command");
7799 "#,
7800 "plugin_a.js",
7801 )
7802 .unwrap();
7803
7804 backend
7806 .execute_js(
7807 r#"
7808 const editor = getEditor();
7809 globalThis.handlerB = function() { };
7810 editor.registerCommand("My Command", "From B", "handlerB", null);
7811 "#,
7812 "plugin_b.js",
7813 )
7814 .unwrap();
7815 }
7816
7817 #[test]
7818 fn test_register_command_collision_caught_in_try_catch() {
7819 let (mut backend, _rx) = create_test_backend();
7820
7821 backend
7823 .execute_js(
7824 r#"
7825 const editor = getEditor();
7826 globalThis.handlerA = function() { };
7827 editor.registerCommand("My Command", "From A", "handlerA", null);
7828 "#,
7829 "plugin_a.js",
7830 )
7831 .unwrap();
7832
7833 backend
7835 .execute_js(
7836 r#"
7837 const editor = getEditor();
7838 globalThis.handlerB = function() { };
7839 let caught = false;
7840 try {
7841 editor.registerCommand("My Command", "From B", "handlerB", null);
7842 } catch (e) {
7843 caught = true;
7844 }
7845 if (!caught) throw new Error("Expected collision error");
7846 "#,
7847 "plugin_b.js",
7848 )
7849 .unwrap();
7850 }
7851
7852 #[test]
7853 fn test_register_command_i18n_key_no_collision_across_plugins() {
7854 let (mut backend, _rx) = create_test_backend();
7855
7856 backend
7858 .execute_js(
7859 r#"
7860 const editor = getEditor();
7861 globalThis.handlerA = function() { };
7862 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
7863 "#,
7864 "plugin_a.js",
7865 )
7866 .unwrap();
7867
7868 backend
7871 .execute_js(
7872 r#"
7873 const editor = getEditor();
7874 globalThis.handlerB = function() { };
7875 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
7876 "#,
7877 "plugin_b.js",
7878 )
7879 .unwrap();
7880 }
7881
7882 #[test]
7883 fn test_register_command_non_i18n_still_collides() {
7884 let (mut backend, _rx) = create_test_backend();
7885
7886 backend
7888 .execute_js(
7889 r#"
7890 const editor = getEditor();
7891 globalThis.handlerA = function() { };
7892 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
7893 "#,
7894 "plugin_a.js",
7895 )
7896 .unwrap();
7897
7898 let result = backend.execute_js(
7900 r#"
7901 const editor = getEditor();
7902 globalThis.handlerB = function() { };
7903 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
7904 "#,
7905 "plugin_b.js",
7906 );
7907
7908 assert!(
7909 result.is_err(),
7910 "Non-%-prefixed names should still collide across plugins"
7911 );
7912 }
7913}