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 initial_focus_hunk: opts.initial_focus_hunk,
1962 request_id: Some(id),
1963 });
1964
1965 id
1966 }
1967
1968 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1972 self.command_sender
1973 .send(PluginCommand::UpdateCompositeAlignment {
1974 buffer_id: BufferId(buffer_id as usize),
1975 hunks,
1976 })
1977 .is_ok()
1978 }
1979
1980 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1982 self.command_sender
1983 .send(PluginCommand::CloseCompositeBuffer {
1984 buffer_id: BufferId(buffer_id as usize),
1985 })
1986 .is_ok()
1987 }
1988
1989 pub fn flush_layout(&self) -> bool {
1993 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
1994 }
1995
1996 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
1998 self.command_sender
1999 .send(PluginCommand::CompositeNextHunk {
2000 buffer_id: BufferId(buffer_id as usize),
2001 })
2002 .is_ok()
2003 }
2004
2005 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2007 self.command_sender
2008 .send(PluginCommand::CompositePrevHunk {
2009 buffer_id: BufferId(buffer_id as usize),
2010 })
2011 .is_ok()
2012 }
2013
2014 #[plugin_api(
2018 async_promise,
2019 js_name = "getHighlights",
2020 ts_return = "TsHighlightSpan[]"
2021 )]
2022 #[qjs(rename = "_getHighlightsStart")]
2023 pub fn get_highlights_start<'js>(
2024 &self,
2025 _ctx: rquickjs::Ctx<'js>,
2026 buffer_id: u32,
2027 start: u32,
2028 end: u32,
2029 ) -> rquickjs::Result<u64> {
2030 let id = {
2031 let mut id_ref = self.next_request_id.borrow_mut();
2032 let id = *id_ref;
2033 *id_ref += 1;
2034 self.callback_contexts
2036 .borrow_mut()
2037 .insert(id, self.plugin_name.clone());
2038 id
2039 };
2040
2041 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2042 buffer_id: BufferId(buffer_id as usize),
2043 range: (start as usize)..(end as usize),
2044 request_id: id,
2045 });
2046
2047 Ok(id)
2048 }
2049
2050 pub fn add_overlay<'js>(
2072 &self,
2073 _ctx: rquickjs::Ctx<'js>,
2074 buffer_id: u32,
2075 namespace: String,
2076 start: u32,
2077 end: u32,
2078 options: rquickjs::Object<'js>,
2079 ) -> rquickjs::Result<bool> {
2080 use fresh_core::api::OverlayColorSpec;
2081
2082 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2084 if let Ok(theme_key) = obj.get::<_, String>(key) {
2086 if !theme_key.is_empty() {
2087 return Some(OverlayColorSpec::ThemeKey(theme_key));
2088 }
2089 }
2090 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2092 if arr.len() >= 3 {
2093 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2094 }
2095 }
2096 None
2097 }
2098
2099 let fg = parse_color_spec("fg", &options);
2100 let bg = parse_color_spec("bg", &options);
2101 let underline: bool = options.get("underline").unwrap_or(false);
2102 let bold: bool = options.get("bold").unwrap_or(false);
2103 let italic: bool = options.get("italic").unwrap_or(false);
2104 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2105 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2106 let url: Option<String> = options.get("url").ok();
2107
2108 let options = OverlayOptions {
2109 fg,
2110 bg,
2111 underline,
2112 bold,
2113 italic,
2114 strikethrough,
2115 extend_to_line_end,
2116 url,
2117 };
2118
2119 self.plugin_tracked_state
2121 .borrow_mut()
2122 .entry(self.plugin_name.clone())
2123 .or_default()
2124 .overlay_namespaces
2125 .push((BufferId(buffer_id as usize), namespace.clone()));
2126
2127 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2128 buffer_id: BufferId(buffer_id as usize),
2129 namespace: Some(OverlayNamespace::from_string(namespace)),
2130 range: (start as usize)..(end as usize),
2131 options,
2132 });
2133
2134 Ok(true)
2135 }
2136
2137 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2139 self.command_sender
2140 .send(PluginCommand::ClearNamespace {
2141 buffer_id: BufferId(buffer_id as usize),
2142 namespace: OverlayNamespace::from_string(namespace),
2143 })
2144 .is_ok()
2145 }
2146
2147 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2149 self.command_sender
2150 .send(PluginCommand::ClearAllOverlays {
2151 buffer_id: BufferId(buffer_id as usize),
2152 })
2153 .is_ok()
2154 }
2155
2156 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2158 self.command_sender
2159 .send(PluginCommand::ClearOverlaysInRange {
2160 buffer_id: BufferId(buffer_id as usize),
2161 start: start as usize,
2162 end: end as usize,
2163 })
2164 .is_ok()
2165 }
2166
2167 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2169 use fresh_core::overlay::OverlayHandle;
2170 self.command_sender
2171 .send(PluginCommand::RemoveOverlay {
2172 buffer_id: BufferId(buffer_id as usize),
2173 handle: OverlayHandle(handle),
2174 })
2175 .is_ok()
2176 }
2177
2178 pub fn add_conceal(
2182 &self,
2183 buffer_id: u32,
2184 namespace: String,
2185 start: u32,
2186 end: u32,
2187 replacement: Option<String>,
2188 ) -> bool {
2189 self.plugin_tracked_state
2191 .borrow_mut()
2192 .entry(self.plugin_name.clone())
2193 .or_default()
2194 .overlay_namespaces
2195 .push((BufferId(buffer_id as usize), namespace.clone()));
2196
2197 self.command_sender
2198 .send(PluginCommand::AddConceal {
2199 buffer_id: BufferId(buffer_id as usize),
2200 namespace: OverlayNamespace::from_string(namespace),
2201 start: start as usize,
2202 end: end as usize,
2203 replacement,
2204 })
2205 .is_ok()
2206 }
2207
2208 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2210 self.command_sender
2211 .send(PluginCommand::ClearConcealNamespace {
2212 buffer_id: BufferId(buffer_id as usize),
2213 namespace: OverlayNamespace::from_string(namespace),
2214 })
2215 .is_ok()
2216 }
2217
2218 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2220 self.command_sender
2221 .send(PluginCommand::ClearConcealsInRange {
2222 buffer_id: BufferId(buffer_id as usize),
2223 start: start as usize,
2224 end: end as usize,
2225 })
2226 .is_ok()
2227 }
2228
2229 pub fn add_soft_break(
2233 &self,
2234 buffer_id: u32,
2235 namespace: String,
2236 position: u32,
2237 indent: u32,
2238 ) -> bool {
2239 self.plugin_tracked_state
2241 .borrow_mut()
2242 .entry(self.plugin_name.clone())
2243 .or_default()
2244 .overlay_namespaces
2245 .push((BufferId(buffer_id as usize), namespace.clone()));
2246
2247 self.command_sender
2248 .send(PluginCommand::AddSoftBreak {
2249 buffer_id: BufferId(buffer_id as usize),
2250 namespace: OverlayNamespace::from_string(namespace),
2251 position: position as usize,
2252 indent: indent as u16,
2253 })
2254 .is_ok()
2255 }
2256
2257 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2259 self.command_sender
2260 .send(PluginCommand::ClearSoftBreakNamespace {
2261 buffer_id: BufferId(buffer_id as usize),
2262 namespace: OverlayNamespace::from_string(namespace),
2263 })
2264 .is_ok()
2265 }
2266
2267 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2269 self.command_sender
2270 .send(PluginCommand::ClearSoftBreaksInRange {
2271 buffer_id: BufferId(buffer_id as usize),
2272 start: start as usize,
2273 end: end as usize,
2274 })
2275 .is_ok()
2276 }
2277
2278 #[allow(clippy::too_many_arguments)]
2288 pub fn submit_view_transform<'js>(
2289 &self,
2290 _ctx: rquickjs::Ctx<'js>,
2291 buffer_id: u32,
2292 split_id: Option<u32>,
2293 start: u32,
2294 end: u32,
2295 tokens: Vec<rquickjs::Object<'js>>,
2296 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2297 ) -> rquickjs::Result<bool> {
2298 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2299
2300 let tokens: Vec<ViewTokenWire> = tokens
2301 .into_iter()
2302 .enumerate()
2303 .map(|(idx, obj)| {
2304 parse_view_token(&obj, idx)
2306 })
2307 .collect::<rquickjs::Result<Vec<_>>>()?;
2308
2309 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2311 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2312 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2313 Some(LayoutHints {
2314 compose_width,
2315 column_guides,
2316 })
2317 } else {
2318 None
2319 };
2320
2321 let payload = ViewTransformPayload {
2322 range: (start as usize)..(end as usize),
2323 tokens,
2324 layout_hints: parsed_layout_hints,
2325 };
2326
2327 Ok(self
2328 .command_sender
2329 .send(PluginCommand::SubmitViewTransform {
2330 buffer_id: BufferId(buffer_id as usize),
2331 split_id: split_id.map(|id| SplitId(id as usize)),
2332 payload,
2333 })
2334 .is_ok())
2335 }
2336
2337 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2339 self.command_sender
2340 .send(PluginCommand::ClearViewTransform {
2341 buffer_id: BufferId(buffer_id as usize),
2342 split_id: split_id.map(|id| SplitId(id as usize)),
2343 })
2344 .is_ok()
2345 }
2346
2347 pub fn set_layout_hints<'js>(
2350 &self,
2351 buffer_id: u32,
2352 split_id: Option<u32>,
2353 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2354 ) -> rquickjs::Result<bool> {
2355 use fresh_core::api::LayoutHints;
2356
2357 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2358 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2359 let parsed_hints = LayoutHints {
2360 compose_width,
2361 column_guides,
2362 };
2363
2364 Ok(self
2365 .command_sender
2366 .send(PluginCommand::SetLayoutHints {
2367 buffer_id: BufferId(buffer_id as usize),
2368 split_id: split_id.map(|id| SplitId(id as usize)),
2369 range: 0..0,
2370 hints: parsed_hints,
2371 })
2372 .is_ok())
2373 }
2374
2375 pub fn set_file_explorer_decorations<'js>(
2379 &self,
2380 _ctx: rquickjs::Ctx<'js>,
2381 namespace: String,
2382 decorations: Vec<rquickjs::Object<'js>>,
2383 ) -> rquickjs::Result<bool> {
2384 use fresh_core::file_explorer::FileExplorerDecoration;
2385
2386 let decorations: Vec<FileExplorerDecoration> = decorations
2387 .into_iter()
2388 .map(|obj| {
2389 let path: String = obj.get("path")?;
2390 let symbol: String = obj.get("symbol")?;
2391 let color: Vec<u8> = obj.get("color")?;
2392 let priority: i32 = obj.get("priority").unwrap_or(0);
2393
2394 if color.len() < 3 {
2395 return Err(rquickjs::Error::FromJs {
2396 from: "array",
2397 to: "color",
2398 message: Some(format!(
2399 "color array must have at least 3 elements, got {}",
2400 color.len()
2401 )),
2402 });
2403 }
2404
2405 Ok(FileExplorerDecoration {
2406 path: std::path::PathBuf::from(path),
2407 symbol,
2408 color: [color[0], color[1], color[2]],
2409 priority,
2410 })
2411 })
2412 .collect::<rquickjs::Result<Vec<_>>>()?;
2413
2414 self.plugin_tracked_state
2416 .borrow_mut()
2417 .entry(self.plugin_name.clone())
2418 .or_default()
2419 .file_explorer_namespaces
2420 .push(namespace.clone());
2421
2422 Ok(self
2423 .command_sender
2424 .send(PluginCommand::SetFileExplorerDecorations {
2425 namespace,
2426 decorations,
2427 })
2428 .is_ok())
2429 }
2430
2431 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2433 self.command_sender
2434 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2435 .is_ok()
2436 }
2437
2438 #[allow(clippy::too_many_arguments)]
2442 pub fn add_virtual_text(
2443 &self,
2444 buffer_id: u32,
2445 virtual_text_id: String,
2446 position: u32,
2447 text: String,
2448 r: u8,
2449 g: u8,
2450 b: u8,
2451 before: bool,
2452 use_bg: bool,
2453 ) -> bool {
2454 self.plugin_tracked_state
2456 .borrow_mut()
2457 .entry(self.plugin_name.clone())
2458 .or_default()
2459 .virtual_text_ids
2460 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2461
2462 self.command_sender
2463 .send(PluginCommand::AddVirtualText {
2464 buffer_id: BufferId(buffer_id as usize),
2465 virtual_text_id,
2466 position: position as usize,
2467 text,
2468 color: (r, g, b),
2469 use_bg,
2470 before,
2471 })
2472 .is_ok()
2473 }
2474
2475 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2477 self.command_sender
2478 .send(PluginCommand::RemoveVirtualText {
2479 buffer_id: BufferId(buffer_id as usize),
2480 virtual_text_id,
2481 })
2482 .is_ok()
2483 }
2484
2485 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2487 self.command_sender
2488 .send(PluginCommand::RemoveVirtualTextsByPrefix {
2489 buffer_id: BufferId(buffer_id as usize),
2490 prefix,
2491 })
2492 .is_ok()
2493 }
2494
2495 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2497 self.command_sender
2498 .send(PluginCommand::ClearVirtualTexts {
2499 buffer_id: BufferId(buffer_id as usize),
2500 })
2501 .is_ok()
2502 }
2503
2504 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2506 self.command_sender
2507 .send(PluginCommand::ClearVirtualTextNamespace {
2508 buffer_id: BufferId(buffer_id as usize),
2509 namespace,
2510 })
2511 .is_ok()
2512 }
2513
2514 #[allow(clippy::too_many_arguments)]
2516 pub fn add_virtual_line(
2517 &self,
2518 buffer_id: u32,
2519 position: u32,
2520 text: String,
2521 fg_r: u8,
2522 fg_g: u8,
2523 fg_b: u8,
2524 bg_r: u8,
2525 bg_g: u8,
2526 bg_b: u8,
2527 above: bool,
2528 namespace: String,
2529 priority: i32,
2530 ) -> bool {
2531 self.plugin_tracked_state
2533 .borrow_mut()
2534 .entry(self.plugin_name.clone())
2535 .or_default()
2536 .virtual_line_namespaces
2537 .push((BufferId(buffer_id as usize), namespace.clone()));
2538
2539 self.command_sender
2540 .send(PluginCommand::AddVirtualLine {
2541 buffer_id: BufferId(buffer_id as usize),
2542 position: position as usize,
2543 text,
2544 fg_color: (fg_r, fg_g, fg_b),
2545 bg_color: Some((bg_r, bg_g, bg_b)),
2546 above,
2547 namespace,
2548 priority,
2549 })
2550 .is_ok()
2551 }
2552
2553 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2558 #[qjs(rename = "_promptStart")]
2559 pub fn prompt_start(
2560 &self,
2561 _ctx: rquickjs::Ctx<'_>,
2562 label: String,
2563 initial_value: String,
2564 ) -> u64 {
2565 let id = {
2566 let mut id_ref = self.next_request_id.borrow_mut();
2567 let id = *id_ref;
2568 *id_ref += 1;
2569 self.callback_contexts
2571 .borrow_mut()
2572 .insert(id, self.plugin_name.clone());
2573 id
2574 };
2575
2576 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2577 label,
2578 initial_value,
2579 callback_id: JsCallbackId::new(id),
2580 });
2581
2582 id
2583 }
2584
2585 pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2587 self.command_sender
2588 .send(PluginCommand::StartPrompt { label, prompt_type })
2589 .is_ok()
2590 }
2591
2592 pub fn start_prompt_with_initial(
2594 &self,
2595 label: String,
2596 prompt_type: String,
2597 initial_value: String,
2598 ) -> bool {
2599 self.command_sender
2600 .send(PluginCommand::StartPromptWithInitial {
2601 label,
2602 prompt_type,
2603 initial_value,
2604 })
2605 .is_ok()
2606 }
2607
2608 pub fn set_prompt_suggestions(
2612 &self,
2613 suggestions: Vec<fresh_core::command::Suggestion>,
2614 ) -> bool {
2615 self.command_sender
2616 .send(PluginCommand::SetPromptSuggestions { suggestions })
2617 .is_ok()
2618 }
2619
2620 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2621 self.command_sender
2622 .send(PluginCommand::SetPromptInputSync { sync })
2623 .is_ok()
2624 }
2625
2626 pub fn define_mode(
2630 &self,
2631 name: String,
2632 bindings_arr: Vec<Vec<String>>,
2633 read_only: rquickjs::function::Opt<bool>,
2634 allow_text_input: rquickjs::function::Opt<bool>,
2635 ) -> bool {
2636 let bindings: Vec<(String, String)> = bindings_arr
2637 .into_iter()
2638 .filter_map(|arr| {
2639 if arr.len() >= 2 {
2640 Some((arr[0].clone(), arr[1].clone()))
2641 } else {
2642 None
2643 }
2644 })
2645 .collect();
2646
2647 {
2650 let mut registered = self.registered_actions.borrow_mut();
2651 for (_, cmd_name) in &bindings {
2652 registered.insert(
2653 cmd_name.clone(),
2654 PluginHandler {
2655 plugin_name: self.plugin_name.clone(),
2656 handler_name: cmd_name.clone(),
2657 },
2658 );
2659 }
2660 }
2661
2662 let allow_text = allow_text_input.0.unwrap_or(false);
2665 if allow_text {
2666 let mut registered = self.registered_actions.borrow_mut();
2667 registered.insert(
2668 "mode_text_input".to_string(),
2669 PluginHandler {
2670 plugin_name: self.plugin_name.clone(),
2671 handler_name: "mode_text_input".to_string(),
2672 },
2673 );
2674 }
2675
2676 self.command_sender
2677 .send(PluginCommand::DefineMode {
2678 name,
2679 bindings,
2680 read_only: read_only.0.unwrap_or(false),
2681 allow_text_input: allow_text,
2682 plugin_name: Some(self.plugin_name.clone()),
2683 })
2684 .is_ok()
2685 }
2686
2687 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2689 self.command_sender
2690 .send(PluginCommand::SetEditorMode { mode })
2691 .is_ok()
2692 }
2693
2694 pub fn get_editor_mode(&self) -> Option<String> {
2696 self.state_snapshot
2697 .read()
2698 .ok()
2699 .and_then(|s| s.editor_mode.clone())
2700 }
2701
2702 pub fn close_split(&self, split_id: u32) -> bool {
2706 self.command_sender
2707 .send(PluginCommand::CloseSplit {
2708 split_id: SplitId(split_id as usize),
2709 })
2710 .is_ok()
2711 }
2712
2713 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2715 self.command_sender
2716 .send(PluginCommand::SetSplitBuffer {
2717 split_id: SplitId(split_id as usize),
2718 buffer_id: BufferId(buffer_id as usize),
2719 })
2720 .is_ok()
2721 }
2722
2723 pub fn focus_split(&self, split_id: u32) -> bool {
2725 self.command_sender
2726 .send(PluginCommand::FocusSplit {
2727 split_id: SplitId(split_id as usize),
2728 })
2729 .is_ok()
2730 }
2731
2732 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2734 self.command_sender
2735 .send(PluginCommand::SetSplitScroll {
2736 split_id: SplitId(split_id as usize),
2737 top_byte: top_byte as usize,
2738 })
2739 .is_ok()
2740 }
2741
2742 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2744 self.command_sender
2745 .send(PluginCommand::SetSplitRatio {
2746 split_id: SplitId(split_id as usize),
2747 ratio,
2748 })
2749 .is_ok()
2750 }
2751
2752 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2754 self.command_sender
2755 .send(PluginCommand::SetSplitLabel {
2756 split_id: SplitId(split_id as usize),
2757 label,
2758 })
2759 .is_ok()
2760 }
2761
2762 pub fn clear_split_label(&self, split_id: u32) -> bool {
2764 self.command_sender
2765 .send(PluginCommand::ClearSplitLabel {
2766 split_id: SplitId(split_id as usize),
2767 })
2768 .is_ok()
2769 }
2770
2771 #[plugin_api(
2773 async_promise,
2774 js_name = "getSplitByLabel",
2775 ts_return = "number | null"
2776 )]
2777 #[qjs(rename = "_getSplitByLabelStart")]
2778 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2779 let id = {
2780 let mut id_ref = self.next_request_id.borrow_mut();
2781 let id = *id_ref;
2782 *id_ref += 1;
2783 self.callback_contexts
2784 .borrow_mut()
2785 .insert(id, self.plugin_name.clone());
2786 id
2787 };
2788 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2789 label,
2790 request_id: id,
2791 });
2792 id
2793 }
2794
2795 pub fn distribute_splits_evenly(&self) -> bool {
2797 self.command_sender
2799 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2800 .is_ok()
2801 }
2802
2803 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2805 self.command_sender
2806 .send(PluginCommand::SetBufferCursor {
2807 buffer_id: BufferId(buffer_id as usize),
2808 position: position as usize,
2809 })
2810 .is_ok()
2811 }
2812
2813 #[allow(clippy::too_many_arguments)]
2817 pub fn set_line_indicator(
2818 &self,
2819 buffer_id: u32,
2820 line: u32,
2821 namespace: String,
2822 symbol: String,
2823 r: u8,
2824 g: u8,
2825 b: u8,
2826 priority: i32,
2827 ) -> bool {
2828 self.plugin_tracked_state
2830 .borrow_mut()
2831 .entry(self.plugin_name.clone())
2832 .or_default()
2833 .line_indicator_namespaces
2834 .push((BufferId(buffer_id as usize), namespace.clone()));
2835
2836 self.command_sender
2837 .send(PluginCommand::SetLineIndicator {
2838 buffer_id: BufferId(buffer_id as usize),
2839 line: line as usize,
2840 namespace,
2841 symbol,
2842 color: (r, g, b),
2843 priority,
2844 })
2845 .is_ok()
2846 }
2847
2848 #[allow(clippy::too_many_arguments)]
2850 pub fn set_line_indicators(
2851 &self,
2852 buffer_id: u32,
2853 lines: Vec<u32>,
2854 namespace: String,
2855 symbol: String,
2856 r: u8,
2857 g: u8,
2858 b: u8,
2859 priority: i32,
2860 ) -> bool {
2861 self.plugin_tracked_state
2863 .borrow_mut()
2864 .entry(self.plugin_name.clone())
2865 .or_default()
2866 .line_indicator_namespaces
2867 .push((BufferId(buffer_id as usize), namespace.clone()));
2868
2869 self.command_sender
2870 .send(PluginCommand::SetLineIndicators {
2871 buffer_id: BufferId(buffer_id as usize),
2872 lines: lines.into_iter().map(|l| l as usize).collect(),
2873 namespace,
2874 symbol,
2875 color: (r, g, b),
2876 priority,
2877 })
2878 .is_ok()
2879 }
2880
2881 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2883 self.command_sender
2884 .send(PluginCommand::ClearLineIndicators {
2885 buffer_id: BufferId(buffer_id as usize),
2886 namespace,
2887 })
2888 .is_ok()
2889 }
2890
2891 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2893 self.command_sender
2894 .send(PluginCommand::SetLineNumbers {
2895 buffer_id: BufferId(buffer_id as usize),
2896 enabled,
2897 })
2898 .is_ok()
2899 }
2900
2901 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
2903 self.command_sender
2904 .send(PluginCommand::SetViewMode {
2905 buffer_id: BufferId(buffer_id as usize),
2906 mode,
2907 })
2908 .is_ok()
2909 }
2910
2911 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2913 self.command_sender
2914 .send(PluginCommand::SetLineWrap {
2915 buffer_id: BufferId(buffer_id as usize),
2916 split_id: split_id.map(|s| SplitId(s as usize)),
2917 enabled,
2918 })
2919 .is_ok()
2920 }
2921
2922 pub fn set_view_state<'js>(
2926 &self,
2927 ctx: rquickjs::Ctx<'js>,
2928 buffer_id: u32,
2929 key: String,
2930 value: Value<'js>,
2931 ) -> bool {
2932 let bid = BufferId(buffer_id as usize);
2933
2934 let json_value = if value.is_undefined() || value.is_null() {
2936 None
2937 } else {
2938 Some(js_to_json(&ctx, value))
2939 };
2940
2941 if let Ok(mut snapshot) = self.state_snapshot.write() {
2943 if let Some(ref json_val) = json_value {
2944 snapshot
2945 .plugin_view_states
2946 .entry(bid)
2947 .or_default()
2948 .insert(key.clone(), json_val.clone());
2949 } else {
2950 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
2952 map.remove(&key);
2953 if map.is_empty() {
2954 snapshot.plugin_view_states.remove(&bid);
2955 }
2956 }
2957 }
2958 }
2959
2960 self.command_sender
2962 .send(PluginCommand::SetViewState {
2963 buffer_id: bid,
2964 key,
2965 value: json_value,
2966 })
2967 .is_ok()
2968 }
2969
2970 pub fn get_view_state<'js>(
2972 &self,
2973 ctx: rquickjs::Ctx<'js>,
2974 buffer_id: u32,
2975 key: String,
2976 ) -> rquickjs::Result<Value<'js>> {
2977 let bid = BufferId(buffer_id as usize);
2978 if let Ok(snapshot) = self.state_snapshot.read() {
2979 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
2980 if let Some(json_val) = map.get(&key) {
2981 return json_to_js_value(&ctx, json_val);
2982 }
2983 }
2984 }
2985 Ok(Value::new_undefined(ctx.clone()))
2986 }
2987
2988 pub fn set_global_state<'js>(
2994 &self,
2995 ctx: rquickjs::Ctx<'js>,
2996 key: String,
2997 value: Value<'js>,
2998 ) -> bool {
2999 let json_value = if value.is_undefined() || value.is_null() {
3001 None
3002 } else {
3003 Some(js_to_json(&ctx, value))
3004 };
3005
3006 if let Ok(mut snapshot) = self.state_snapshot.write() {
3008 if let Some(ref json_val) = json_value {
3009 snapshot
3010 .plugin_global_states
3011 .entry(self.plugin_name.clone())
3012 .or_default()
3013 .insert(key.clone(), json_val.clone());
3014 } else {
3015 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3017 map.remove(&key);
3018 if map.is_empty() {
3019 snapshot.plugin_global_states.remove(&self.plugin_name);
3020 }
3021 }
3022 }
3023 }
3024
3025 self.command_sender
3027 .send(PluginCommand::SetGlobalState {
3028 plugin_name: self.plugin_name.clone(),
3029 key,
3030 value: json_value,
3031 })
3032 .is_ok()
3033 }
3034
3035 pub fn get_global_state<'js>(
3039 &self,
3040 ctx: rquickjs::Ctx<'js>,
3041 key: String,
3042 ) -> rquickjs::Result<Value<'js>> {
3043 if let Ok(snapshot) = self.state_snapshot.read() {
3044 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3045 if let Some(json_val) = map.get(&key) {
3046 return json_to_js_value(&ctx, json_val);
3047 }
3048 }
3049 }
3050 Ok(Value::new_undefined(ctx.clone()))
3051 }
3052
3053 pub fn create_scroll_sync_group(
3057 &self,
3058 group_id: u32,
3059 left_split: u32,
3060 right_split: u32,
3061 ) -> bool {
3062 self.plugin_tracked_state
3064 .borrow_mut()
3065 .entry(self.plugin_name.clone())
3066 .or_default()
3067 .scroll_sync_group_ids
3068 .push(group_id);
3069 self.command_sender
3070 .send(PluginCommand::CreateScrollSyncGroup {
3071 group_id,
3072 left_split: SplitId(left_split as usize),
3073 right_split: SplitId(right_split as usize),
3074 })
3075 .is_ok()
3076 }
3077
3078 pub fn set_scroll_sync_anchors<'js>(
3080 &self,
3081 _ctx: rquickjs::Ctx<'js>,
3082 group_id: u32,
3083 anchors: Vec<Vec<u32>>,
3084 ) -> bool {
3085 let anchors: Vec<(usize, usize)> = anchors
3086 .into_iter()
3087 .filter_map(|pair| {
3088 if pair.len() >= 2 {
3089 Some((pair[0] as usize, pair[1] as usize))
3090 } else {
3091 None
3092 }
3093 })
3094 .collect();
3095 self.command_sender
3096 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3097 .is_ok()
3098 }
3099
3100 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3102 self.command_sender
3103 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3104 .is_ok()
3105 }
3106
3107 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3113 self.command_sender
3114 .send(PluginCommand::ExecuteActions { actions })
3115 .is_ok()
3116 }
3117
3118 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3122 self.command_sender
3123 .send(PluginCommand::ShowActionPopup {
3124 popup_id: opts.id,
3125 title: opts.title,
3126 message: opts.message,
3127 actions: opts.actions,
3128 })
3129 .is_ok()
3130 }
3131
3132 pub fn disable_lsp_for_language(&self, language: String) -> bool {
3134 self.command_sender
3135 .send(PluginCommand::DisableLspForLanguage { language })
3136 .is_ok()
3137 }
3138
3139 pub fn restart_lsp_for_language(&self, language: String) -> bool {
3141 self.command_sender
3142 .send(PluginCommand::RestartLspForLanguage { language })
3143 .is_ok()
3144 }
3145
3146 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3149 self.command_sender
3150 .send(PluginCommand::SetLspRootUri { language, uri })
3151 .is_ok()
3152 }
3153
3154 #[plugin_api(ts_return = "JsDiagnostic[]")]
3156 pub fn get_all_diagnostics<'js>(
3157 &self,
3158 ctx: rquickjs::Ctx<'js>,
3159 ) -> rquickjs::Result<Value<'js>> {
3160 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3161
3162 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3163 let mut result: Vec<JsDiagnostic> = Vec::new();
3165 for (uri, diags) in &s.diagnostics {
3166 for diag in diags {
3167 result.push(JsDiagnostic {
3168 uri: uri.clone(),
3169 message: diag.message.clone(),
3170 severity: diag.severity.map(|s| match s {
3171 lsp_types::DiagnosticSeverity::ERROR => 1,
3172 lsp_types::DiagnosticSeverity::WARNING => 2,
3173 lsp_types::DiagnosticSeverity::INFORMATION => 3,
3174 lsp_types::DiagnosticSeverity::HINT => 4,
3175 _ => 0,
3176 }),
3177 range: JsRange {
3178 start: JsPosition {
3179 line: diag.range.start.line,
3180 character: diag.range.start.character,
3181 },
3182 end: JsPosition {
3183 line: diag.range.end.line,
3184 character: diag.range.end.character,
3185 },
3186 },
3187 source: diag.source.clone(),
3188 });
3189 }
3190 }
3191 result
3192 } else {
3193 Vec::new()
3194 };
3195 rquickjs_serde::to_value(ctx, &diagnostics)
3196 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3197 }
3198
3199 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3201 self.event_handlers
3202 .borrow()
3203 .get(&event_name)
3204 .cloned()
3205 .unwrap_or_default()
3206 .into_iter()
3207 .map(|h| h.handler_name)
3208 .collect()
3209 }
3210
3211 #[plugin_api(
3215 async_promise,
3216 js_name = "createVirtualBuffer",
3217 ts_return = "VirtualBufferResult"
3218 )]
3219 #[qjs(rename = "_createVirtualBufferStart")]
3220 pub fn create_virtual_buffer_start(
3221 &self,
3222 _ctx: rquickjs::Ctx<'_>,
3223 opts: fresh_core::api::CreateVirtualBufferOptions,
3224 ) -> rquickjs::Result<u64> {
3225 let id = {
3226 let mut id_ref = self.next_request_id.borrow_mut();
3227 let id = *id_ref;
3228 *id_ref += 1;
3229 self.callback_contexts
3231 .borrow_mut()
3232 .insert(id, self.plugin_name.clone());
3233 id
3234 };
3235
3236 let entries: Vec<TextPropertyEntry> = opts
3238 .entries
3239 .unwrap_or_default()
3240 .into_iter()
3241 .map(|e| TextPropertyEntry {
3242 text: e.text,
3243 properties: e.properties.unwrap_or_default(),
3244 style: e.style,
3245 inline_overlays: e.inline_overlays.unwrap_or_default(),
3246 })
3247 .collect();
3248
3249 tracing::debug!(
3250 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3251 id
3252 );
3253 if let Ok(mut owners) = self.async_resource_owners.lock() {
3255 owners.insert(id, self.plugin_name.clone());
3256 }
3257 let _ = self
3258 .command_sender
3259 .send(PluginCommand::CreateVirtualBufferWithContent {
3260 name: opts.name,
3261 mode: opts.mode.unwrap_or_default(),
3262 read_only: opts.read_only.unwrap_or(false),
3263 entries,
3264 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3265 show_cursors: opts.show_cursors.unwrap_or(true),
3266 editing_disabled: opts.editing_disabled.unwrap_or(false),
3267 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3268 request_id: Some(id),
3269 });
3270 Ok(id)
3271 }
3272
3273 #[plugin_api(
3275 async_promise,
3276 js_name = "createVirtualBufferInSplit",
3277 ts_return = "VirtualBufferResult"
3278 )]
3279 #[qjs(rename = "_createVirtualBufferInSplitStart")]
3280 pub fn create_virtual_buffer_in_split_start(
3281 &self,
3282 _ctx: rquickjs::Ctx<'_>,
3283 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3284 ) -> rquickjs::Result<u64> {
3285 let id = {
3286 let mut id_ref = self.next_request_id.borrow_mut();
3287 let id = *id_ref;
3288 *id_ref += 1;
3289 self.callback_contexts
3291 .borrow_mut()
3292 .insert(id, self.plugin_name.clone());
3293 id
3294 };
3295
3296 let entries: Vec<TextPropertyEntry> = opts
3298 .entries
3299 .unwrap_or_default()
3300 .into_iter()
3301 .map(|e| TextPropertyEntry {
3302 text: e.text,
3303 properties: e.properties.unwrap_or_default(),
3304 style: e.style,
3305 inline_overlays: e.inline_overlays.unwrap_or_default(),
3306 })
3307 .collect();
3308
3309 if let Ok(mut owners) = self.async_resource_owners.lock() {
3311 owners.insert(id, self.plugin_name.clone());
3312 }
3313 let _ = self
3314 .command_sender
3315 .send(PluginCommand::CreateVirtualBufferInSplit {
3316 name: opts.name,
3317 mode: opts.mode.unwrap_or_default(),
3318 read_only: opts.read_only.unwrap_or(false),
3319 entries,
3320 ratio: opts.ratio.unwrap_or(0.5),
3321 direction: opts.direction,
3322 panel_id: opts.panel_id,
3323 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3324 show_cursors: opts.show_cursors.unwrap_or(true),
3325 editing_disabled: opts.editing_disabled.unwrap_or(false),
3326 line_wrap: opts.line_wrap,
3327 before: opts.before.unwrap_or(false),
3328 request_id: Some(id),
3329 });
3330 Ok(id)
3331 }
3332
3333 #[plugin_api(
3335 async_promise,
3336 js_name = "createVirtualBufferInExistingSplit",
3337 ts_return = "VirtualBufferResult"
3338 )]
3339 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3340 pub fn create_virtual_buffer_in_existing_split_start(
3341 &self,
3342 _ctx: rquickjs::Ctx<'_>,
3343 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3344 ) -> rquickjs::Result<u64> {
3345 let id = {
3346 let mut id_ref = self.next_request_id.borrow_mut();
3347 let id = *id_ref;
3348 *id_ref += 1;
3349 self.callback_contexts
3351 .borrow_mut()
3352 .insert(id, self.plugin_name.clone());
3353 id
3354 };
3355
3356 let entries: Vec<TextPropertyEntry> = opts
3358 .entries
3359 .unwrap_or_default()
3360 .into_iter()
3361 .map(|e| TextPropertyEntry {
3362 text: e.text,
3363 properties: e.properties.unwrap_or_default(),
3364 style: e.style,
3365 inline_overlays: e.inline_overlays.unwrap_or_default(),
3366 })
3367 .collect();
3368
3369 if let Ok(mut owners) = self.async_resource_owners.lock() {
3371 owners.insert(id, self.plugin_name.clone());
3372 }
3373 let _ = self
3374 .command_sender
3375 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3376 name: opts.name,
3377 mode: opts.mode.unwrap_or_default(),
3378 read_only: opts.read_only.unwrap_or(false),
3379 entries,
3380 split_id: SplitId(opts.split_id),
3381 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3382 show_cursors: opts.show_cursors.unwrap_or(true),
3383 editing_disabled: opts.editing_disabled.unwrap_or(false),
3384 line_wrap: opts.line_wrap,
3385 request_id: Some(id),
3386 });
3387 Ok(id)
3388 }
3389
3390 pub fn set_virtual_buffer_content<'js>(
3394 &self,
3395 ctx: rquickjs::Ctx<'js>,
3396 buffer_id: u32,
3397 entries_arr: Vec<rquickjs::Object<'js>>,
3398 ) -> rquickjs::Result<bool> {
3399 let entries: Vec<TextPropertyEntry> = entries_arr
3400 .iter()
3401 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3402 .collect();
3403 Ok(self
3404 .command_sender
3405 .send(PluginCommand::SetVirtualBufferContent {
3406 buffer_id: BufferId(buffer_id as usize),
3407 entries,
3408 })
3409 .is_ok())
3410 }
3411
3412 pub fn get_text_properties_at_cursor(
3414 &self,
3415 buffer_id: u32,
3416 ) -> fresh_core::api::TextPropertiesAtCursor {
3417 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3418 }
3419
3420 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3424 #[qjs(rename = "_spawnProcessStart")]
3425 pub fn spawn_process_start(
3426 &self,
3427 _ctx: rquickjs::Ctx<'_>,
3428 command: String,
3429 args: Vec<String>,
3430 cwd: rquickjs::function::Opt<String>,
3431 ) -> u64 {
3432 let id = {
3433 let mut id_ref = self.next_request_id.borrow_mut();
3434 let id = *id_ref;
3435 *id_ref += 1;
3436 self.callback_contexts
3438 .borrow_mut()
3439 .insert(id, self.plugin_name.clone());
3440 id
3441 };
3442 let effective_cwd = cwd.0.or_else(|| {
3444 self.state_snapshot
3445 .read()
3446 .ok()
3447 .map(|s| s.working_dir.to_string_lossy().to_string())
3448 });
3449 tracing::info!(
3450 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3451 self.plugin_name,
3452 command,
3453 args,
3454 effective_cwd,
3455 id
3456 );
3457 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3458 callback_id: JsCallbackId::new(id),
3459 command,
3460 args,
3461 cwd: effective_cwd,
3462 });
3463 id
3464 }
3465
3466 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3468 #[qjs(rename = "_spawnProcessWaitStart")]
3469 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3470 let id = {
3471 let mut id_ref = self.next_request_id.borrow_mut();
3472 let id = *id_ref;
3473 *id_ref += 1;
3474 self.callback_contexts
3476 .borrow_mut()
3477 .insert(id, self.plugin_name.clone());
3478 id
3479 };
3480 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3481 process_id,
3482 callback_id: JsCallbackId::new(id),
3483 });
3484 id
3485 }
3486
3487 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3489 #[qjs(rename = "_getBufferTextStart")]
3490 pub fn get_buffer_text_start(
3491 &self,
3492 _ctx: rquickjs::Ctx<'_>,
3493 buffer_id: u32,
3494 start: u32,
3495 end: u32,
3496 ) -> u64 {
3497 let id = {
3498 let mut id_ref = self.next_request_id.borrow_mut();
3499 let id = *id_ref;
3500 *id_ref += 1;
3501 self.callback_contexts
3503 .borrow_mut()
3504 .insert(id, self.plugin_name.clone());
3505 id
3506 };
3507 let _ = self.command_sender.send(PluginCommand::GetBufferText {
3508 buffer_id: BufferId(buffer_id as usize),
3509 start: start as usize,
3510 end: end as usize,
3511 request_id: id,
3512 });
3513 id
3514 }
3515
3516 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3518 #[qjs(rename = "_delayStart")]
3519 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3520 let id = {
3521 let mut id_ref = self.next_request_id.borrow_mut();
3522 let id = *id_ref;
3523 *id_ref += 1;
3524 self.callback_contexts
3526 .borrow_mut()
3527 .insert(id, self.plugin_name.clone());
3528 id
3529 };
3530 let _ = self.command_sender.send(PluginCommand::Delay {
3531 callback_id: JsCallbackId::new(id),
3532 duration_ms,
3533 });
3534 id
3535 }
3536
3537 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3541 #[qjs(rename = "_grepProjectStart")]
3542 pub fn grep_project_start(
3543 &self,
3544 _ctx: rquickjs::Ctx<'_>,
3545 pattern: String,
3546 fixed_string: Option<bool>,
3547 case_sensitive: Option<bool>,
3548 max_results: Option<u32>,
3549 whole_words: Option<bool>,
3550 ) -> u64 {
3551 let id = {
3552 let mut id_ref = self.next_request_id.borrow_mut();
3553 let id = *id_ref;
3554 *id_ref += 1;
3555 self.callback_contexts
3556 .borrow_mut()
3557 .insert(id, self.plugin_name.clone());
3558 id
3559 };
3560 let _ = self.command_sender.send(PluginCommand::GrepProject {
3561 pattern,
3562 fixed_string: fixed_string.unwrap_or(true),
3563 case_sensitive: case_sensitive.unwrap_or(true),
3564 max_results: max_results.unwrap_or(200) as usize,
3565 whole_words: whole_words.unwrap_or(false),
3566 callback_id: JsCallbackId::new(id),
3567 });
3568 id
3569 }
3570
3571 #[plugin_api(
3575 js_name = "grepProjectStreaming",
3576 ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3577 )]
3578 #[qjs(rename = "_grepProjectStreamingStart")]
3579 pub fn grep_project_streaming_start(
3580 &self,
3581 _ctx: rquickjs::Ctx<'_>,
3582 pattern: String,
3583 fixed_string: bool,
3584 case_sensitive: bool,
3585 max_results: u32,
3586 whole_words: bool,
3587 ) -> u64 {
3588 let id = {
3589 let mut id_ref = self.next_request_id.borrow_mut();
3590 let id = *id_ref;
3591 *id_ref += 1;
3592 self.callback_contexts
3593 .borrow_mut()
3594 .insert(id, self.plugin_name.clone());
3595 id
3596 };
3597 let _ = self
3598 .command_sender
3599 .send(PluginCommand::GrepProjectStreaming {
3600 pattern,
3601 fixed_string,
3602 case_sensitive,
3603 max_results: max_results as usize,
3604 whole_words,
3605 search_id: id,
3606 callback_id: JsCallbackId::new(id),
3607 });
3608 id
3609 }
3610
3611 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3615 #[qjs(rename = "_replaceInFileStart")]
3616 pub fn replace_in_file_start(
3617 &self,
3618 _ctx: rquickjs::Ctx<'_>,
3619 file_path: String,
3620 matches: Vec<Vec<u32>>,
3621 replacement: String,
3622 ) -> u64 {
3623 let id = {
3624 let mut id_ref = self.next_request_id.borrow_mut();
3625 let id = *id_ref;
3626 *id_ref += 1;
3627 self.callback_contexts
3628 .borrow_mut()
3629 .insert(id, self.plugin_name.clone());
3630 id
3631 };
3632 let match_pairs: Vec<(usize, usize)> = matches
3634 .iter()
3635 .map(|m| (m[0] as usize, m[1] as usize))
3636 .collect();
3637 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3638 file_path: PathBuf::from(file_path),
3639 matches: match_pairs,
3640 replacement,
3641 callback_id: JsCallbackId::new(id),
3642 });
3643 id
3644 }
3645
3646 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3648 #[qjs(rename = "_sendLspRequestStart")]
3649 pub fn send_lsp_request_start<'js>(
3650 &self,
3651 ctx: rquickjs::Ctx<'js>,
3652 language: String,
3653 method: String,
3654 params: Option<rquickjs::Object<'js>>,
3655 ) -> rquickjs::Result<u64> {
3656 let id = {
3657 let mut id_ref = self.next_request_id.borrow_mut();
3658 let id = *id_ref;
3659 *id_ref += 1;
3660 self.callback_contexts
3662 .borrow_mut()
3663 .insert(id, self.plugin_name.clone());
3664 id
3665 };
3666 let params_json: Option<serde_json::Value> = params.map(|obj| {
3668 let val = obj.into_value();
3669 js_to_json(&ctx, val)
3670 });
3671 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3672 request_id: id,
3673 language,
3674 method,
3675 params: params_json,
3676 });
3677 Ok(id)
3678 }
3679
3680 #[plugin_api(
3682 async_thenable,
3683 js_name = "spawnBackgroundProcess",
3684 ts_return = "BackgroundProcessResult"
3685 )]
3686 #[qjs(rename = "_spawnBackgroundProcessStart")]
3687 pub fn spawn_background_process_start(
3688 &self,
3689 _ctx: rquickjs::Ctx<'_>,
3690 command: String,
3691 args: Vec<String>,
3692 cwd: rquickjs::function::Opt<String>,
3693 ) -> u64 {
3694 let id = {
3695 let mut id_ref = self.next_request_id.borrow_mut();
3696 let id = *id_ref;
3697 *id_ref += 1;
3698 self.callback_contexts
3700 .borrow_mut()
3701 .insert(id, self.plugin_name.clone());
3702 id
3703 };
3704 let process_id = id;
3706 self.plugin_tracked_state
3708 .borrow_mut()
3709 .entry(self.plugin_name.clone())
3710 .or_default()
3711 .background_process_ids
3712 .push(process_id);
3713 let _ = self
3714 .command_sender
3715 .send(PluginCommand::SpawnBackgroundProcess {
3716 process_id,
3717 command,
3718 args,
3719 cwd: cwd.0,
3720 callback_id: JsCallbackId::new(id),
3721 });
3722 id
3723 }
3724
3725 pub fn kill_background_process(&self, process_id: u64) -> bool {
3727 self.command_sender
3728 .send(PluginCommand::KillBackgroundProcess { process_id })
3729 .is_ok()
3730 }
3731
3732 #[plugin_api(
3736 async_promise,
3737 js_name = "createTerminal",
3738 ts_return = "TerminalResult"
3739 )]
3740 #[qjs(rename = "_createTerminalStart")]
3741 pub fn create_terminal_start(
3742 &self,
3743 _ctx: rquickjs::Ctx<'_>,
3744 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3745 ) -> rquickjs::Result<u64> {
3746 let id = {
3747 let mut id_ref = self.next_request_id.borrow_mut();
3748 let id = *id_ref;
3749 *id_ref += 1;
3750 self.callback_contexts
3751 .borrow_mut()
3752 .insert(id, self.plugin_name.clone());
3753 id
3754 };
3755
3756 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3757 cwd: None,
3758 direction: None,
3759 ratio: None,
3760 focus: None,
3761 });
3762
3763 if let Ok(mut owners) = self.async_resource_owners.lock() {
3765 owners.insert(id, self.plugin_name.clone());
3766 }
3767 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3768 cwd: opts.cwd,
3769 direction: opts.direction,
3770 ratio: opts.ratio,
3771 focus: opts.focus,
3772 request_id: id,
3773 });
3774 Ok(id)
3775 }
3776
3777 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3779 self.command_sender
3780 .send(PluginCommand::SendTerminalInput {
3781 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3782 data,
3783 })
3784 .is_ok()
3785 }
3786
3787 pub fn close_terminal(&self, terminal_id: u64) -> bool {
3789 self.command_sender
3790 .send(PluginCommand::CloseTerminal {
3791 terminal_id: fresh_core::TerminalId(terminal_id as usize),
3792 })
3793 .is_ok()
3794 }
3795
3796 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
3800 self.command_sender
3801 .send(PluginCommand::RefreshLines {
3802 buffer_id: BufferId(buffer_id as usize),
3803 })
3804 .is_ok()
3805 }
3806
3807 pub fn get_current_locale(&self) -> String {
3809 self.services.current_locale()
3810 }
3811
3812 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
3816 #[qjs(rename = "_loadPluginStart")]
3817 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
3818 let id = {
3819 let mut id_ref = self.next_request_id.borrow_mut();
3820 let id = *id_ref;
3821 *id_ref += 1;
3822 self.callback_contexts
3823 .borrow_mut()
3824 .insert(id, self.plugin_name.clone());
3825 id
3826 };
3827 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
3828 path: std::path::PathBuf::from(path),
3829 callback_id: JsCallbackId::new(id),
3830 });
3831 id
3832 }
3833
3834 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
3836 #[qjs(rename = "_unloadPluginStart")]
3837 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3838 let id = {
3839 let mut id_ref = self.next_request_id.borrow_mut();
3840 let id = *id_ref;
3841 *id_ref += 1;
3842 self.callback_contexts
3843 .borrow_mut()
3844 .insert(id, self.plugin_name.clone());
3845 id
3846 };
3847 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
3848 name,
3849 callback_id: JsCallbackId::new(id),
3850 });
3851 id
3852 }
3853
3854 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
3856 #[qjs(rename = "_reloadPluginStart")]
3857 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
3858 let id = {
3859 let mut id_ref = self.next_request_id.borrow_mut();
3860 let id = *id_ref;
3861 *id_ref += 1;
3862 self.callback_contexts
3863 .borrow_mut()
3864 .insert(id, self.plugin_name.clone());
3865 id
3866 };
3867 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
3868 name,
3869 callback_id: JsCallbackId::new(id),
3870 });
3871 id
3872 }
3873
3874 #[plugin_api(
3877 async_promise,
3878 js_name = "listPlugins",
3879 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
3880 )]
3881 #[qjs(rename = "_listPluginsStart")]
3882 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3883 let id = {
3884 let mut id_ref = self.next_request_id.borrow_mut();
3885 let id = *id_ref;
3886 *id_ref += 1;
3887 self.callback_contexts
3888 .borrow_mut()
3889 .insert(id, self.plugin_name.clone());
3890 id
3891 };
3892 let _ = self.command_sender.send(PluginCommand::ListPlugins {
3893 callback_id: JsCallbackId::new(id),
3894 });
3895 id
3896 }
3897}
3898
3899fn parse_view_token(
3906 obj: &rquickjs::Object<'_>,
3907 idx: usize,
3908) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
3909 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
3910
3911 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
3913 from: "object",
3914 to: "ViewTokenWire",
3915 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
3916 })?;
3917
3918 let source_offset: Option<usize> = obj
3920 .get("sourceOffset")
3921 .ok()
3922 .or_else(|| obj.get("source_offset").ok());
3923
3924 let kind = if kind_value.is_string() {
3926 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3929 from: "value",
3930 to: "string",
3931 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
3932 })?;
3933
3934 match kind_str.to_lowercase().as_str() {
3935 "text" => {
3936 let text: String = obj.get("text").unwrap_or_default();
3937 ViewTokenWireKind::Text(text)
3938 }
3939 "newline" => ViewTokenWireKind::Newline,
3940 "space" => ViewTokenWireKind::Space,
3941 "break" => ViewTokenWireKind::Break,
3942 _ => {
3943 tracing::warn!(
3945 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
3946 idx, kind_str
3947 );
3948 return Err(rquickjs::Error::FromJs {
3949 from: "string",
3950 to: "ViewTokenWireKind",
3951 message: Some(format!(
3952 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
3953 idx, kind_str
3954 )),
3955 });
3956 }
3957 }
3958 } else if kind_value.is_object() {
3959 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
3961 from: "value",
3962 to: "object",
3963 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
3964 })?;
3965
3966 if let Ok(text) = kind_obj.get::<_, String>("Text") {
3967 ViewTokenWireKind::Text(text)
3968 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
3969 ViewTokenWireKind::BinaryByte(byte)
3970 } else {
3971 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
3973 tracing::warn!(
3974 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
3975 idx,
3976 keys
3977 );
3978 return Err(rquickjs::Error::FromJs {
3979 from: "object",
3980 to: "ViewTokenWireKind",
3981 message: Some(format!(
3982 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
3983 idx, keys
3984 )),
3985 });
3986 }
3987 } else {
3988 tracing::warn!(
3989 "token[{}]: 'kind' field must be a string or object, got: {:?}",
3990 idx,
3991 kind_value.type_of()
3992 );
3993 return Err(rquickjs::Error::FromJs {
3994 from: "value",
3995 to: "ViewTokenWireKind",
3996 message: Some(format!(
3997 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
3998 idx
3999 )),
4000 });
4001 };
4002
4003 let style = parse_view_token_style(obj, idx)?;
4005
4006 Ok(ViewTokenWire {
4007 source_offset,
4008 kind,
4009 style,
4010 })
4011}
4012
4013fn parse_view_token_style(
4015 obj: &rquickjs::Object<'_>,
4016 idx: usize,
4017) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4018 use fresh_core::api::ViewTokenStyle;
4019
4020 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4021 let Some(s) = style_obj else {
4022 return Ok(None);
4023 };
4024
4025 let fg: Option<Vec<u8>> = s.get("fg").ok();
4026 let bg: Option<Vec<u8>> = s.get("bg").ok();
4027
4028 let fg_color = if let Some(ref c) = fg {
4030 if c.len() < 3 {
4031 tracing::warn!(
4032 "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4033 idx,
4034 c.len()
4035 );
4036 None
4037 } else {
4038 Some((c[0], c[1], c[2]))
4039 }
4040 } else {
4041 None
4042 };
4043
4044 let bg_color = if let Some(ref c) = bg {
4045 if c.len() < 3 {
4046 tracing::warn!(
4047 "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4048 idx,
4049 c.len()
4050 );
4051 None
4052 } else {
4053 Some((c[0], c[1], c[2]))
4054 }
4055 } else {
4056 None
4057 };
4058
4059 Ok(Some(ViewTokenStyle {
4060 fg: fg_color,
4061 bg: bg_color,
4062 bold: s.get("bold").unwrap_or(false),
4063 italic: s.get("italic").unwrap_or(false),
4064 }))
4065}
4066
4067pub struct QuickJsBackend {
4069 runtime: Runtime,
4070 main_context: Context,
4072 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4074 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4076 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4078 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4080 command_sender: mpsc::Sender<PluginCommand>,
4082 #[allow(dead_code)]
4084 pending_responses: PendingResponses,
4085 next_request_id: Rc<RefCell<u64>>,
4087 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4089 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4091 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4093 async_resource_owners: AsyncResourceOwners,
4096 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4098 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4100 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4102 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4104}
4105
4106impl QuickJsBackend {
4107 pub fn new() -> Result<Self> {
4109 let (tx, _rx) = mpsc::channel();
4110 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4111 let services = Arc::new(fresh_core::services::NoopServiceBridge);
4112 Self::with_state(state_snapshot, tx, services)
4113 }
4114
4115 pub fn with_state(
4117 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4118 command_sender: mpsc::Sender<PluginCommand>,
4119 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4120 ) -> Result<Self> {
4121 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4122 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4123 }
4124
4125 pub fn with_state_and_responses(
4127 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4128 command_sender: mpsc::Sender<PluginCommand>,
4129 pending_responses: PendingResponses,
4130 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4131 ) -> Result<Self> {
4132 let async_resource_owners: AsyncResourceOwners =
4133 Arc::new(std::sync::Mutex::new(HashMap::new()));
4134 Self::with_state_responses_and_resources(
4135 state_snapshot,
4136 command_sender,
4137 pending_responses,
4138 services,
4139 async_resource_owners,
4140 )
4141 }
4142
4143 pub fn with_state_responses_and_resources(
4146 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4147 command_sender: mpsc::Sender<PluginCommand>,
4148 pending_responses: PendingResponses,
4149 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4150 async_resource_owners: AsyncResourceOwners,
4151 ) -> Result<Self> {
4152 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4153
4154 let runtime =
4155 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4156
4157 runtime.set_host_promise_rejection_tracker(Some(Box::new(
4159 |_ctx, _promise, reason, is_handled| {
4160 if !is_handled {
4161 let error_msg = if let Some(exc) = reason.as_exception() {
4163 format!(
4164 "{}: {}",
4165 exc.message().unwrap_or_default(),
4166 exc.stack().unwrap_or_default()
4167 )
4168 } else {
4169 format!("{:?}", reason)
4170 };
4171
4172 tracing::error!("Unhandled Promise rejection: {}", error_msg);
4173
4174 if should_panic_on_js_errors() {
4175 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4178 set_fatal_js_error(full_msg);
4179 }
4180 }
4181 },
4182 )));
4183
4184 let main_context = Context::full(&runtime)
4185 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4186
4187 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4188 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4189 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4190 let next_request_id = Rc::new(RefCell::new(1u64));
4191 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4192 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4193 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4194 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4195 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4196 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4197
4198 let backend = Self {
4199 runtime,
4200 main_context,
4201 plugin_contexts,
4202 event_handlers,
4203 registered_actions,
4204 state_snapshot,
4205 command_sender,
4206 pending_responses,
4207 next_request_id,
4208 callback_contexts,
4209 services,
4210 plugin_tracked_state,
4211 async_resource_owners,
4212 registered_command_names,
4213 registered_grammar_languages,
4214 registered_language_configs,
4215 registered_lsp_servers,
4216 };
4217
4218 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4220
4221 tracing::debug!("QuickJsBackend::new: runtime created successfully");
4222 Ok(backend)
4223 }
4224
4225 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4227 let state_snapshot = Arc::clone(&self.state_snapshot);
4228 let command_sender = self.command_sender.clone();
4229 let event_handlers = Rc::clone(&self.event_handlers);
4230 let registered_actions = Rc::clone(&self.registered_actions);
4231 let next_request_id = Rc::clone(&self.next_request_id);
4232 let registered_command_names = Rc::clone(&self.registered_command_names);
4233 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4234 let registered_language_configs = Rc::clone(&self.registered_language_configs);
4235 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4236
4237 context.with(|ctx| {
4238 let globals = ctx.globals();
4239
4240 globals.set("__pluginName__", plugin_name)?;
4242
4243 let js_api = JsEditorApi {
4246 state_snapshot: Arc::clone(&state_snapshot),
4247 command_sender: command_sender.clone(),
4248 registered_actions: Rc::clone(®istered_actions),
4249 event_handlers: Rc::clone(&event_handlers),
4250 next_request_id: Rc::clone(&next_request_id),
4251 callback_contexts: Rc::clone(&self.callback_contexts),
4252 services: self.services.clone(),
4253 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4254 async_resource_owners: Arc::clone(&self.async_resource_owners),
4255 registered_command_names: Rc::clone(®istered_command_names),
4256 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
4257 registered_language_configs: Rc::clone(®istered_language_configs),
4258 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
4259 plugin_name: plugin_name.to_string(),
4260 };
4261 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4262
4263 globals.set("editor", editor)?;
4265
4266 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4268
4269 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4271
4272 let console = Object::new(ctx.clone())?;
4275 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4276 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4277 tracing::info!("console.log: {}", parts.join(" "));
4278 })?)?;
4279 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4280 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4281 tracing::warn!("console.warn: {}", parts.join(" "));
4282 })?)?;
4283 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4284 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4285 tracing::error!("console.error: {}", parts.join(" "));
4286 })?)?;
4287 globals.set("console", console)?;
4288
4289 ctx.eval::<(), _>(r#"
4291 // Pending promise callbacks: callbackId -> { resolve, reject }
4292 globalThis._pendingCallbacks = new Map();
4293
4294 // Resolve a pending callback (called from Rust)
4295 globalThis._resolveCallback = function(callbackId, result) {
4296 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4297 const cb = globalThis._pendingCallbacks.get(callbackId);
4298 if (cb) {
4299 console.log('[JS] _resolveCallback: found callback, calling resolve()');
4300 globalThis._pendingCallbacks.delete(callbackId);
4301 cb.resolve(result);
4302 console.log('[JS] _resolveCallback: resolve() called');
4303 } else {
4304 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4305 }
4306 };
4307
4308 // Reject a pending callback (called from Rust)
4309 globalThis._rejectCallback = function(callbackId, error) {
4310 const cb = globalThis._pendingCallbacks.get(callbackId);
4311 if (cb) {
4312 globalThis._pendingCallbacks.delete(callbackId);
4313 cb.reject(new Error(error));
4314 }
4315 };
4316
4317 // Streaming callbacks: called multiple times with partial results
4318 globalThis._streamingCallbacks = new Map();
4319
4320 // Called from Rust with partial data. When done=true, cleans up.
4321 globalThis._callStreamingCallback = function(callbackId, result, done) {
4322 const cb = globalThis._streamingCallbacks.get(callbackId);
4323 if (cb) {
4324 cb(result, done);
4325 if (done) {
4326 globalThis._streamingCallbacks.delete(callbackId);
4327 }
4328 }
4329 };
4330
4331 // Generic async wrapper decorator
4332 // Wraps a function that returns a callbackId into a promise-returning function
4333 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4334 // NOTE: We pass the method name as a string and call via bracket notation
4335 // to preserve rquickjs's automatic Ctx injection for methods
4336 globalThis._wrapAsync = function(methodName, fnName) {
4337 const startFn = editor[methodName];
4338 if (typeof startFn !== 'function') {
4339 // Return a function that always throws - catches missing implementations
4340 return function(...args) {
4341 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4342 editor.debug(`[ASYNC ERROR] ${error.message}`);
4343 throw error;
4344 };
4345 }
4346 return function(...args) {
4347 // Call via bracket notation to preserve method binding and Ctx injection
4348 const callbackId = editor[methodName](...args);
4349 return new Promise((resolve, reject) => {
4350 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4351 // TODO: Implement setTimeout polyfill using editor.delay() or similar
4352 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4353 });
4354 };
4355 };
4356
4357 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4358 // The returned object has .result promise and is itself thenable
4359 globalThis._wrapAsyncThenable = function(methodName, fnName) {
4360 const startFn = editor[methodName];
4361 if (typeof startFn !== 'function') {
4362 // Return a function that always throws - catches missing implementations
4363 return function(...args) {
4364 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4365 editor.debug(`[ASYNC ERROR] ${error.message}`);
4366 throw error;
4367 };
4368 }
4369 return function(...args) {
4370 // Call via bracket notation to preserve method binding and Ctx injection
4371 const callbackId = editor[methodName](...args);
4372 const resultPromise = new Promise((resolve, reject) => {
4373 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4374 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4375 });
4376 return {
4377 get result() { return resultPromise; },
4378 then(onFulfilled, onRejected) {
4379 return resultPromise.then(onFulfilled, onRejected);
4380 },
4381 catch(onRejected) {
4382 return resultPromise.catch(onRejected);
4383 }
4384 };
4385 };
4386 };
4387
4388 // Apply wrappers to async functions on editor
4389 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4390 editor.delay = _wrapAsync("_delayStart", "delay");
4391 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4392 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4393 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4394 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4395 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4396 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4397 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4398 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4399 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4400 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4401 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4402 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4403 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4404 editor.prompt = _wrapAsync("_promptStart", "prompt");
4405 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4406 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4407 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4408 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4409 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4410 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4411
4412 // Streaming grep: takes a progress callback, returns a thenable with searchId
4413 editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4414 opts = opts || {};
4415 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4416 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4417 const maxResults = opts.maxResults || 10000;
4418 const wholeWords = opts.wholeWords || false;
4419
4420 const searchId = editor._grepProjectStreamingStart(
4421 pattern, fixedString, caseSensitive, maxResults, wholeWords
4422 );
4423
4424 // Register streaming callback
4425 if (progressCallback) {
4426 globalThis._streamingCallbacks.set(searchId, progressCallback);
4427 }
4428
4429 // Create completion promise (resolved via _resolveCallback when search finishes)
4430 const resultPromise = new Promise(function(resolve, reject) {
4431 globalThis._pendingCallbacks.set(searchId, {
4432 resolve: function(result) {
4433 globalThis._streamingCallbacks.delete(searchId);
4434 resolve(result);
4435 },
4436 reject: function(err) {
4437 globalThis._streamingCallbacks.delete(searchId);
4438 reject(err);
4439 }
4440 });
4441 });
4442
4443 return {
4444 searchId: searchId,
4445 get result() { return resultPromise; },
4446 then: function(f, r) { return resultPromise.then(f, r); },
4447 catch: function(r) { return resultPromise.catch(r); }
4448 };
4449 };
4450
4451 // Wrapper for deleteTheme - wraps sync function in Promise
4452 editor.deleteTheme = function(name) {
4453 return new Promise(function(resolve, reject) {
4454 const success = editor._deleteThemeSync(name);
4455 if (success) {
4456 resolve();
4457 } else {
4458 reject(new Error("Failed to delete theme: " + name));
4459 }
4460 });
4461 };
4462 "#.as_bytes())?;
4463
4464 Ok::<_, rquickjs::Error>(())
4465 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4466
4467 Ok(())
4468 }
4469
4470 pub async fn load_module_with_source(
4472 &mut self,
4473 path: &str,
4474 _plugin_source: &str,
4475 ) -> Result<()> {
4476 let path_buf = PathBuf::from(path);
4477 let source = std::fs::read_to_string(&path_buf)
4478 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4479
4480 let filename = path_buf
4481 .file_name()
4482 .and_then(|s| s.to_str())
4483 .unwrap_or("plugin.ts");
4484
4485 if has_es_imports(&source) {
4487 match bundle_module(&path_buf) {
4489 Ok(bundled) => {
4490 self.execute_js(&bundled, path)?;
4491 }
4492 Err(e) => {
4493 tracing::warn!(
4494 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4495 path,
4496 e
4497 );
4498 return Ok(()); }
4500 }
4501 } else if has_es_module_syntax(&source) {
4502 let stripped = strip_imports_and_exports(&source);
4504 let js_code = if filename.ends_with(".ts") {
4505 transpile_typescript(&stripped, filename)?
4506 } else {
4507 stripped
4508 };
4509 self.execute_js(&js_code, path)?;
4510 } else {
4511 let js_code = if filename.ends_with(".ts") {
4513 transpile_typescript(&source, filename)?
4514 } else {
4515 source
4516 };
4517 self.execute_js(&js_code, path)?;
4518 }
4519
4520 Ok(())
4521 }
4522
4523 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4525 let plugin_name = Path::new(source_name)
4527 .file_stem()
4528 .and_then(|s| s.to_str())
4529 .unwrap_or("unknown");
4530
4531 tracing::debug!(
4532 "execute_js: starting for plugin '{}' from '{}'",
4533 plugin_name,
4534 source_name
4535 );
4536
4537 let context = {
4539 let mut contexts = self.plugin_contexts.borrow_mut();
4540 if let Some(ctx) = contexts.get(plugin_name) {
4541 ctx.clone()
4542 } else {
4543 let ctx = Context::full(&self.runtime).map_err(|e| {
4544 anyhow!(
4545 "Failed to create QuickJS context for plugin {}: {}",
4546 plugin_name,
4547 e
4548 )
4549 })?;
4550 self.setup_context_api(&ctx, plugin_name)?;
4551 contexts.insert(plugin_name.to_string(), ctx.clone());
4552 ctx
4553 }
4554 };
4555
4556 let wrapped_code = format!("(function() {{ {} }})();", code);
4560 let wrapped = wrapped_code.as_str();
4561
4562 context.with(|ctx| {
4563 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4564
4565 let mut eval_options = rquickjs::context::EvalOptions::default();
4567 eval_options.global = true;
4568 eval_options.filename = Some(source_name.to_string());
4569 let result = ctx
4570 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4571 .map_err(|e| format_js_error(&ctx, e, source_name));
4572
4573 tracing::debug!(
4574 "execute_js: plugin code execution finished for '{}', result: {:?}",
4575 plugin_name,
4576 result.is_ok()
4577 );
4578
4579 result
4580 })
4581 }
4582
4583 pub fn execute_source(
4589 &mut self,
4590 source: &str,
4591 plugin_name: &str,
4592 is_typescript: bool,
4593 ) -> Result<()> {
4594 use fresh_parser_js::{
4595 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4596 };
4597
4598 if has_es_imports(source) {
4599 tracing::warn!(
4600 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4601 plugin_name
4602 );
4603 }
4604
4605 let js_code = if has_es_module_syntax(source) {
4606 let stripped = strip_imports_and_exports(source);
4607 if is_typescript {
4608 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4609 } else {
4610 stripped
4611 }
4612 } else if is_typescript {
4613 transpile_typescript(source, &format!("{}.ts", plugin_name))?
4614 } else {
4615 source.to_string()
4616 };
4617
4618 let source_name = format!(
4620 "{}.{}",
4621 plugin_name,
4622 if is_typescript { "ts" } else { "js" }
4623 );
4624 self.execute_js(&js_code, &source_name)
4625 }
4626
4627 pub fn cleanup_plugin(&self, plugin_name: &str) {
4633 self.plugin_contexts.borrow_mut().remove(plugin_name);
4635
4636 for handlers in self.event_handlers.borrow_mut().values_mut() {
4638 handlers.retain(|h| h.plugin_name != plugin_name);
4639 }
4640
4641 self.registered_actions
4643 .borrow_mut()
4644 .retain(|_, h| h.plugin_name != plugin_name);
4645
4646 self.callback_contexts
4648 .borrow_mut()
4649 .retain(|_, pname| pname != plugin_name);
4650
4651 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4653 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4655 std::collections::HashSet::new();
4656 for (buf_id, ns) in &tracked.overlay_namespaces {
4657 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4658 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4660 buffer_id: *buf_id,
4661 namespace: OverlayNamespace::from_string(ns.clone()),
4662 });
4663 let _ = self
4665 .command_sender
4666 .send(PluginCommand::ClearConcealNamespace {
4667 buffer_id: *buf_id,
4668 namespace: OverlayNamespace::from_string(ns.clone()),
4669 });
4670 let _ = self
4671 .command_sender
4672 .send(PluginCommand::ClearSoftBreakNamespace {
4673 buffer_id: *buf_id,
4674 namespace: OverlayNamespace::from_string(ns.clone()),
4675 });
4676 }
4677 }
4678
4679 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4685 std::collections::HashSet::new();
4686 for (buf_id, ns) in &tracked.line_indicator_namespaces {
4687 if seen_li_ns.insert((buf_id.0, ns.clone())) {
4688 let _ = self
4689 .command_sender
4690 .send(PluginCommand::ClearLineIndicators {
4691 buffer_id: *buf_id,
4692 namespace: ns.clone(),
4693 });
4694 }
4695 }
4696
4697 let mut seen_vt: std::collections::HashSet<(usize, String)> =
4699 std::collections::HashSet::new();
4700 for (buf_id, vt_id) in &tracked.virtual_text_ids {
4701 if seen_vt.insert((buf_id.0, vt_id.clone())) {
4702 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4703 buffer_id: *buf_id,
4704 virtual_text_id: vt_id.clone(),
4705 });
4706 }
4707 }
4708
4709 let mut seen_fe_ns: std::collections::HashSet<String> =
4711 std::collections::HashSet::new();
4712 for ns in &tracked.file_explorer_namespaces {
4713 if seen_fe_ns.insert(ns.clone()) {
4714 let _ = self
4715 .command_sender
4716 .send(PluginCommand::ClearFileExplorerDecorations {
4717 namespace: ns.clone(),
4718 });
4719 }
4720 }
4721
4722 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4724 for ctx_name in &tracked.contexts_set {
4725 if seen_ctx.insert(ctx_name.clone()) {
4726 let _ = self.command_sender.send(PluginCommand::SetContext {
4727 name: ctx_name.clone(),
4728 active: false,
4729 });
4730 }
4731 }
4732
4733 for process_id in &tracked.background_process_ids {
4737 let _ = self
4738 .command_sender
4739 .send(PluginCommand::KillBackgroundProcess {
4740 process_id: *process_id,
4741 });
4742 }
4743
4744 for group_id in &tracked.scroll_sync_group_ids {
4746 let _ = self
4747 .command_sender
4748 .send(PluginCommand::RemoveScrollSyncGroup {
4749 group_id: *group_id,
4750 });
4751 }
4752
4753 for buffer_id in &tracked.virtual_buffer_ids {
4755 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4756 buffer_id: *buffer_id,
4757 });
4758 }
4759
4760 for buffer_id in &tracked.composite_buffer_ids {
4762 let _ = self
4763 .command_sender
4764 .send(PluginCommand::CloseCompositeBuffer {
4765 buffer_id: *buffer_id,
4766 });
4767 }
4768
4769 for terminal_id in &tracked.terminal_ids {
4771 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4772 terminal_id: *terminal_id,
4773 });
4774 }
4775 }
4776
4777 if let Ok(mut owners) = self.async_resource_owners.lock() {
4779 owners.retain(|_, name| name != plugin_name);
4780 }
4781
4782 self.registered_command_names
4784 .borrow_mut()
4785 .retain(|_, pname| pname != plugin_name);
4786 self.registered_grammar_languages
4787 .borrow_mut()
4788 .retain(|_, pname| pname != plugin_name);
4789 self.registered_language_configs
4790 .borrow_mut()
4791 .retain(|_, pname| pname != plugin_name);
4792 self.registered_lsp_servers
4793 .borrow_mut()
4794 .retain(|_, pname| pname != plugin_name);
4795
4796 tracing::debug!(
4797 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
4798 plugin_name
4799 );
4800 }
4801
4802 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
4804 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
4805
4806 self.services
4807 .set_js_execution_state(format!("hook '{}'", event_name));
4808
4809 let handlers = self.event_handlers.borrow().get(event_name).cloned();
4810 if let Some(handler_pairs) = handlers {
4811 let plugin_contexts = self.plugin_contexts.borrow();
4812 for handler in &handler_pairs {
4813 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
4814 continue;
4815 };
4816 context.with(|ctx| {
4817 call_handler(&ctx, &handler.handler_name, event_data);
4818 });
4819 }
4820 }
4821
4822 self.services.clear_js_execution_state();
4823 Ok(true)
4824 }
4825
4826 pub fn has_handlers(&self, event_name: &str) -> bool {
4828 self.event_handlers
4829 .borrow()
4830 .get(event_name)
4831 .map(|v| !v.is_empty())
4832 .unwrap_or(false)
4833 }
4834
4835 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
4839 let (lookup_name, text_input_char) =
4842 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
4843 ("mode_text_input", Some(ch.to_string()))
4844 } else {
4845 (action_name, None)
4846 };
4847
4848 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
4849 let (plugin_name, function_name) = match pair {
4850 Some(handler) => (handler.plugin_name, handler.handler_name),
4851 None => ("main".to_string(), lookup_name.to_string()),
4852 };
4853
4854 let plugin_contexts = self.plugin_contexts.borrow();
4855 let context = plugin_contexts
4856 .get(&plugin_name)
4857 .unwrap_or(&self.main_context);
4858
4859 self.services
4861 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
4862
4863 tracing::info!(
4864 "start_action: BEGIN '{}' -> function '{}'",
4865 action_name,
4866 function_name
4867 );
4868
4869 let call_args = if let Some(ref ch) = text_input_char {
4872 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
4873 format!("({{text:\"{}\"}})", escaped)
4874 } else {
4875 "()".to_string()
4876 };
4877
4878 let code = format!(
4879 r#"
4880 (function() {{
4881 console.log('[JS] start_action: calling {fn}');
4882 try {{
4883 if (typeof globalThis.{fn} === 'function') {{
4884 console.log('[JS] start_action: {fn} is a function, invoking...');
4885 globalThis.{fn}{args};
4886 console.log('[JS] start_action: {fn} invoked (may be async)');
4887 }} else {{
4888 console.error('[JS] Action {action} is not defined as a global function');
4889 }}
4890 }} catch (e) {{
4891 console.error('[JS] Action {action} error:', e);
4892 }}
4893 }})();
4894 "#,
4895 fn = function_name,
4896 action = action_name,
4897 args = call_args
4898 );
4899
4900 tracing::info!("start_action: evaluating JS code");
4901 context.with(|ctx| {
4902 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4903 log_js_error(&ctx, e, &format!("action {}", action_name));
4904 }
4905 tracing::info!("start_action: running pending microtasks");
4906 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
4908 tracing::info!("start_action: executed {} pending jobs", count);
4909 });
4910
4911 tracing::info!("start_action: END '{}'", action_name);
4912
4913 self.services.clear_js_execution_state();
4915
4916 Ok(())
4917 }
4918
4919 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
4921 let pair = self.registered_actions.borrow().get(action_name).cloned();
4923 let (plugin_name, function_name) = match pair {
4924 Some(handler) => (handler.plugin_name, handler.handler_name),
4925 None => ("main".to_string(), action_name.to_string()),
4926 };
4927
4928 let plugin_contexts = self.plugin_contexts.borrow();
4929 let context = plugin_contexts
4930 .get(&plugin_name)
4931 .unwrap_or(&self.main_context);
4932
4933 tracing::debug!(
4934 "execute_action: '{}' -> function '{}'",
4935 action_name,
4936 function_name
4937 );
4938
4939 let code = format!(
4942 r#"
4943 (async function() {{
4944 try {{
4945 if (typeof globalThis.{fn} === 'function') {{
4946 const result = globalThis.{fn}();
4947 // If it's a Promise, await it
4948 if (result && typeof result.then === 'function') {{
4949 await result;
4950 }}
4951 }} else {{
4952 console.error('Action {action} is not defined as a global function');
4953 }}
4954 }} catch (e) {{
4955 console.error('Action {action} error:', e);
4956 }}
4957 }})();
4958 "#,
4959 fn = function_name,
4960 action = action_name
4961 );
4962
4963 context.with(|ctx| {
4964 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
4966 Ok(value) => {
4967 if value.is_object() {
4969 if let Some(obj) = value.as_object() {
4970 if obj.get::<_, rquickjs::Function>("then").is_ok() {
4972 run_pending_jobs_checked(
4975 &ctx,
4976 &format!("execute_action {} promise", action_name),
4977 );
4978 }
4979 }
4980 }
4981 }
4982 Err(e) => {
4983 log_js_error(&ctx, e, &format!("action {}", action_name));
4984 }
4985 }
4986 });
4987
4988 Ok(())
4989 }
4990
4991 pub fn poll_event_loop_once(&mut self) -> bool {
4993 let mut had_work = false;
4994
4995 self.main_context.with(|ctx| {
4997 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
4998 if count > 0 {
4999 had_work = true;
5000 }
5001 });
5002
5003 let contexts = self.plugin_contexts.borrow().clone();
5005 for (name, context) in contexts {
5006 context.with(|ctx| {
5007 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5008 if count > 0 {
5009 had_work = true;
5010 }
5011 });
5012 }
5013 had_work
5014 }
5015
5016 pub fn send_status(&self, message: String) {
5018 let _ = self
5019 .command_sender
5020 .send(PluginCommand::SetStatus { message });
5021 }
5022
5023 pub fn send_hook_completed(&self, hook_name: String) {
5027 let _ = self
5028 .command_sender
5029 .send(PluginCommand::HookCompleted { hook_name });
5030 }
5031
5032 pub fn resolve_callback(
5037 &mut self,
5038 callback_id: fresh_core::api::JsCallbackId,
5039 result_json: &str,
5040 ) {
5041 let id = callback_id.as_u64();
5042 tracing::debug!("resolve_callback: starting for callback_id={}", id);
5043
5044 let plugin_name = {
5046 let mut contexts = self.callback_contexts.borrow_mut();
5047 contexts.remove(&id)
5048 };
5049
5050 let Some(name) = plugin_name else {
5051 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5052 return;
5053 };
5054
5055 let plugin_contexts = self.plugin_contexts.borrow();
5056 let Some(context) = plugin_contexts.get(&name) else {
5057 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5058 return;
5059 };
5060
5061 context.with(|ctx| {
5062 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5064 Ok(v) => v,
5065 Err(e) => {
5066 tracing::error!(
5067 "resolve_callback: failed to parse JSON for callback_id={}: {}",
5068 id,
5069 e
5070 );
5071 return;
5072 }
5073 };
5074
5075 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5077 Ok(v) => v,
5078 Err(e) => {
5079 tracing::error!(
5080 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5081 id,
5082 e
5083 );
5084 return;
5085 }
5086 };
5087
5088 let globals = ctx.globals();
5090 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5091 Ok(f) => f,
5092 Err(e) => {
5093 tracing::error!(
5094 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5095 id,
5096 e
5097 );
5098 return;
5099 }
5100 };
5101
5102 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5104 log_js_error(&ctx, e, &format!("resolving callback {}", id));
5105 }
5106
5107 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5109 tracing::info!(
5110 "resolve_callback: executed {} pending jobs for callback_id={}",
5111 job_count,
5112 id
5113 );
5114 });
5115 }
5116
5117 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5119 let id = callback_id.as_u64();
5120
5121 let plugin_name = {
5123 let mut contexts = self.callback_contexts.borrow_mut();
5124 contexts.remove(&id)
5125 };
5126
5127 let Some(name) = plugin_name else {
5128 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5129 return;
5130 };
5131
5132 let plugin_contexts = self.plugin_contexts.borrow();
5133 let Some(context) = plugin_contexts.get(&name) else {
5134 tracing::warn!("reject_callback: Context lost for plugin {}", name);
5135 return;
5136 };
5137
5138 context.with(|ctx| {
5139 let globals = ctx.globals();
5141 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5142 Ok(f) => f,
5143 Err(e) => {
5144 tracing::error!(
5145 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5146 id,
5147 e
5148 );
5149 return;
5150 }
5151 };
5152
5153 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5155 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5156 }
5157
5158 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5160 });
5161 }
5162
5163 pub fn call_streaming_callback(
5167 &mut self,
5168 callback_id: fresh_core::api::JsCallbackId,
5169 result_json: &str,
5170 done: bool,
5171 ) {
5172 let id = callback_id.as_u64();
5173
5174 let plugin_name = {
5176 let contexts = self.callback_contexts.borrow();
5177 contexts.get(&id).cloned()
5178 };
5179
5180 let Some(name) = plugin_name else {
5181 tracing::warn!(
5182 "call_streaming_callback: No plugin found for callback_id={}",
5183 id
5184 );
5185 return;
5186 };
5187
5188 if done {
5190 self.callback_contexts.borrow_mut().remove(&id);
5191 }
5192
5193 let plugin_contexts = self.plugin_contexts.borrow();
5194 let Some(context) = plugin_contexts.get(&name) else {
5195 tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5196 return;
5197 };
5198
5199 context.with(|ctx| {
5200 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5201 Ok(v) => v,
5202 Err(e) => {
5203 tracing::error!(
5204 "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5205 id,
5206 e
5207 );
5208 return;
5209 }
5210 };
5211
5212 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5213 Ok(v) => v,
5214 Err(e) => {
5215 tracing::error!(
5216 "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5217 id,
5218 e
5219 );
5220 return;
5221 }
5222 };
5223
5224 let globals = ctx.globals();
5225 let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5226 Ok(f) => f,
5227 Err(e) => {
5228 tracing::error!(
5229 "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5230 id,
5231 e
5232 );
5233 return;
5234 }
5235 };
5236
5237 if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5238 log_js_error(
5239 &ctx,
5240 e,
5241 &format!("calling streaming callback {}", id),
5242 );
5243 }
5244
5245 run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5246 });
5247 }
5248}
5249
5250#[cfg(test)]
5251mod tests {
5252 use super::*;
5253 use fresh_core::api::{BufferInfo, CursorInfo};
5254 use std::sync::mpsc;
5255
5256 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5258 let (tx, rx) = mpsc::channel();
5259 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5260 let services = Arc::new(TestServiceBridge::new());
5261 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5262 (backend, rx)
5263 }
5264
5265 struct TestServiceBridge {
5266 en_strings: std::sync::Mutex<HashMap<String, String>>,
5267 }
5268
5269 impl TestServiceBridge {
5270 fn new() -> Self {
5271 Self {
5272 en_strings: std::sync::Mutex::new(HashMap::new()),
5273 }
5274 }
5275 }
5276
5277 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5278 fn as_any(&self) -> &dyn std::any::Any {
5279 self
5280 }
5281 fn translate(
5282 &self,
5283 _plugin_name: &str,
5284 key: &str,
5285 _args: &HashMap<String, String>,
5286 ) -> String {
5287 self.en_strings
5288 .lock()
5289 .unwrap()
5290 .get(key)
5291 .cloned()
5292 .unwrap_or_else(|| key.to_string())
5293 }
5294 fn current_locale(&self) -> String {
5295 "en".to_string()
5296 }
5297 fn set_js_execution_state(&self, _state: String) {}
5298 fn clear_js_execution_state(&self) {}
5299 fn get_theme_schema(&self) -> serde_json::Value {
5300 serde_json::json!({})
5301 }
5302 fn get_builtin_themes(&self) -> serde_json::Value {
5303 serde_json::json!([])
5304 }
5305 fn register_command(&self, _command: fresh_core::command::Command) {}
5306 fn unregister_command(&self, _name: &str) {}
5307 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5308 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5309 fn plugins_dir(&self) -> std::path::PathBuf {
5310 std::path::PathBuf::from("/tmp/plugins")
5311 }
5312 fn config_dir(&self) -> std::path::PathBuf {
5313 std::path::PathBuf::from("/tmp/config")
5314 }
5315 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5316 None
5317 }
5318 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5319 Err("not implemented in test".to_string())
5320 }
5321 fn theme_file_exists(&self, _name: &str) -> bool {
5322 false
5323 }
5324 }
5325
5326 #[test]
5327 fn test_quickjs_backend_creation() {
5328 let backend = QuickJsBackend::new();
5329 assert!(backend.is_ok());
5330 }
5331
5332 #[test]
5333 fn test_execute_simple_js() {
5334 let mut backend = QuickJsBackend::new().unwrap();
5335 let result = backend.execute_js("const x = 1 + 2;", "test.js");
5336 assert!(result.is_ok());
5337 }
5338
5339 #[test]
5340 fn test_event_handler_registration() {
5341 let backend = QuickJsBackend::new().unwrap();
5342
5343 assert!(!backend.has_handlers("test_event"));
5345
5346 backend
5348 .event_handlers
5349 .borrow_mut()
5350 .entry("test_event".to_string())
5351 .or_default()
5352 .push(PluginHandler {
5353 plugin_name: "test".to_string(),
5354 handler_name: "testHandler".to_string(),
5355 });
5356
5357 assert!(backend.has_handlers("test_event"));
5359 }
5360
5361 #[test]
5364 fn test_api_set_status() {
5365 let (mut backend, rx) = create_test_backend();
5366
5367 backend
5368 .execute_js(
5369 r#"
5370 const editor = getEditor();
5371 editor.setStatus("Hello from test");
5372 "#,
5373 "test.js",
5374 )
5375 .unwrap();
5376
5377 let cmd = rx.try_recv().unwrap();
5378 match cmd {
5379 PluginCommand::SetStatus { message } => {
5380 assert_eq!(message, "Hello from test");
5381 }
5382 _ => panic!("Expected SetStatus command, got {:?}", cmd),
5383 }
5384 }
5385
5386 #[test]
5387 fn test_api_register_command() {
5388 let (mut backend, rx) = create_test_backend();
5389
5390 backend
5391 .execute_js(
5392 r#"
5393 const editor = getEditor();
5394 globalThis.myTestHandler = function() { };
5395 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5396 "#,
5397 "test_plugin.js",
5398 )
5399 .unwrap();
5400
5401 let cmd = rx.try_recv().unwrap();
5402 match cmd {
5403 PluginCommand::RegisterCommand { command } => {
5404 assert_eq!(command.name, "Test Command");
5405 assert_eq!(command.description, "A test command");
5406 assert_eq!(command.plugin_name, "test_plugin");
5408 }
5409 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5410 }
5411 }
5412
5413 #[test]
5414 fn test_api_define_mode() {
5415 let (mut backend, rx) = create_test_backend();
5416
5417 backend
5418 .execute_js(
5419 r#"
5420 const editor = getEditor();
5421 editor.defineMode("test-mode", [
5422 ["a", "action_a"],
5423 ["b", "action_b"]
5424 ]);
5425 "#,
5426 "test.js",
5427 )
5428 .unwrap();
5429
5430 let cmd = rx.try_recv().unwrap();
5431 match cmd {
5432 PluginCommand::DefineMode {
5433 name,
5434 bindings,
5435 read_only,
5436 allow_text_input,
5437 plugin_name,
5438 } => {
5439 assert_eq!(name, "test-mode");
5440 assert_eq!(bindings.len(), 2);
5441 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5442 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5443 assert!(!read_only);
5444 assert!(!allow_text_input);
5445 assert!(plugin_name.is_some());
5446 }
5447 _ => panic!("Expected DefineMode, got {:?}", cmd),
5448 }
5449 }
5450
5451 #[test]
5452 fn test_api_set_editor_mode() {
5453 let (mut backend, rx) = create_test_backend();
5454
5455 backend
5456 .execute_js(
5457 r#"
5458 const editor = getEditor();
5459 editor.setEditorMode("vi-normal");
5460 "#,
5461 "test.js",
5462 )
5463 .unwrap();
5464
5465 let cmd = rx.try_recv().unwrap();
5466 match cmd {
5467 PluginCommand::SetEditorMode { mode } => {
5468 assert_eq!(mode, Some("vi-normal".to_string()));
5469 }
5470 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5471 }
5472 }
5473
5474 #[test]
5475 fn test_api_clear_editor_mode() {
5476 let (mut backend, rx) = create_test_backend();
5477
5478 backend
5479 .execute_js(
5480 r#"
5481 const editor = getEditor();
5482 editor.setEditorMode(null);
5483 "#,
5484 "test.js",
5485 )
5486 .unwrap();
5487
5488 let cmd = rx.try_recv().unwrap();
5489 match cmd {
5490 PluginCommand::SetEditorMode { mode } => {
5491 assert!(mode.is_none());
5492 }
5493 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5494 }
5495 }
5496
5497 #[test]
5498 fn test_api_insert_at_cursor() {
5499 let (mut backend, rx) = create_test_backend();
5500
5501 backend
5502 .execute_js(
5503 r#"
5504 const editor = getEditor();
5505 editor.insertAtCursor("Hello, World!");
5506 "#,
5507 "test.js",
5508 )
5509 .unwrap();
5510
5511 let cmd = rx.try_recv().unwrap();
5512 match cmd {
5513 PluginCommand::InsertAtCursor { text } => {
5514 assert_eq!(text, "Hello, World!");
5515 }
5516 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5517 }
5518 }
5519
5520 #[test]
5521 fn test_api_set_context() {
5522 let (mut backend, rx) = create_test_backend();
5523
5524 backend
5525 .execute_js(
5526 r#"
5527 const editor = getEditor();
5528 editor.setContext("myContext", true);
5529 "#,
5530 "test.js",
5531 )
5532 .unwrap();
5533
5534 let cmd = rx.try_recv().unwrap();
5535 match cmd {
5536 PluginCommand::SetContext { name, active } => {
5537 assert_eq!(name, "myContext");
5538 assert!(active);
5539 }
5540 _ => panic!("Expected SetContext, got {:?}", cmd),
5541 }
5542 }
5543
5544 #[tokio::test]
5545 async fn test_execute_action_sync_function() {
5546 let (mut backend, rx) = create_test_backend();
5547
5548 backend.registered_actions.borrow_mut().insert(
5550 "my_sync_action".to_string(),
5551 PluginHandler {
5552 plugin_name: "test".to_string(),
5553 handler_name: "my_sync_action".to_string(),
5554 },
5555 );
5556
5557 backend
5559 .execute_js(
5560 r#"
5561 const editor = getEditor();
5562 globalThis.my_sync_action = function() {
5563 editor.setStatus("sync action executed");
5564 };
5565 "#,
5566 "test.js",
5567 )
5568 .unwrap();
5569
5570 while rx.try_recv().is_ok() {}
5572
5573 backend.execute_action("my_sync_action").await.unwrap();
5575
5576 let cmd = rx.try_recv().unwrap();
5578 match cmd {
5579 PluginCommand::SetStatus { message } => {
5580 assert_eq!(message, "sync action executed");
5581 }
5582 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5583 }
5584 }
5585
5586 #[tokio::test]
5587 async fn test_execute_action_async_function() {
5588 let (mut backend, rx) = create_test_backend();
5589
5590 backend.registered_actions.borrow_mut().insert(
5592 "my_async_action".to_string(),
5593 PluginHandler {
5594 plugin_name: "test".to_string(),
5595 handler_name: "my_async_action".to_string(),
5596 },
5597 );
5598
5599 backend
5601 .execute_js(
5602 r#"
5603 const editor = getEditor();
5604 globalThis.my_async_action = async function() {
5605 await Promise.resolve();
5606 editor.setStatus("async action executed");
5607 };
5608 "#,
5609 "test.js",
5610 )
5611 .unwrap();
5612
5613 while rx.try_recv().is_ok() {}
5615
5616 backend.execute_action("my_async_action").await.unwrap();
5618
5619 let cmd = rx.try_recv().unwrap();
5621 match cmd {
5622 PluginCommand::SetStatus { message } => {
5623 assert_eq!(message, "async action executed");
5624 }
5625 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5626 }
5627 }
5628
5629 #[tokio::test]
5630 async fn test_execute_action_with_registered_handler() {
5631 let (mut backend, rx) = create_test_backend();
5632
5633 backend.registered_actions.borrow_mut().insert(
5635 "my_action".to_string(),
5636 PluginHandler {
5637 plugin_name: "test".to_string(),
5638 handler_name: "actual_handler_function".to_string(),
5639 },
5640 );
5641
5642 backend
5643 .execute_js(
5644 r#"
5645 const editor = getEditor();
5646 globalThis.actual_handler_function = function() {
5647 editor.setStatus("handler executed");
5648 };
5649 "#,
5650 "test.js",
5651 )
5652 .unwrap();
5653
5654 while rx.try_recv().is_ok() {}
5656
5657 backend.execute_action("my_action").await.unwrap();
5659
5660 let cmd = rx.try_recv().unwrap();
5661 match cmd {
5662 PluginCommand::SetStatus { message } => {
5663 assert_eq!(message, "handler executed");
5664 }
5665 _ => panic!("Expected SetStatus, got {:?}", cmd),
5666 }
5667 }
5668
5669 #[test]
5670 fn test_api_on_event_registration() {
5671 let (mut backend, _rx) = create_test_backend();
5672
5673 backend
5674 .execute_js(
5675 r#"
5676 const editor = getEditor();
5677 globalThis.myEventHandler = function() { };
5678 editor.on("bufferSave", "myEventHandler");
5679 "#,
5680 "test.js",
5681 )
5682 .unwrap();
5683
5684 assert!(backend.has_handlers("bufferSave"));
5685 }
5686
5687 #[test]
5688 fn test_api_off_event_unregistration() {
5689 let (mut backend, _rx) = create_test_backend();
5690
5691 backend
5692 .execute_js(
5693 r#"
5694 const editor = getEditor();
5695 globalThis.myEventHandler = function() { };
5696 editor.on("bufferSave", "myEventHandler");
5697 editor.off("bufferSave", "myEventHandler");
5698 "#,
5699 "test.js",
5700 )
5701 .unwrap();
5702
5703 assert!(!backend.has_handlers("bufferSave"));
5705 }
5706
5707 #[tokio::test]
5708 async fn test_emit_event() {
5709 let (mut backend, rx) = create_test_backend();
5710
5711 backend
5712 .execute_js(
5713 r#"
5714 const editor = getEditor();
5715 globalThis.onSaveHandler = function(data) {
5716 editor.setStatus("saved: " + JSON.stringify(data));
5717 };
5718 editor.on("bufferSave", "onSaveHandler");
5719 "#,
5720 "test.js",
5721 )
5722 .unwrap();
5723
5724 while rx.try_recv().is_ok() {}
5726
5727 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5729 backend.emit("bufferSave", &event_data).await.unwrap();
5730
5731 let cmd = rx.try_recv().unwrap();
5732 match cmd {
5733 PluginCommand::SetStatus { message } => {
5734 assert!(message.contains("/test.txt"));
5735 }
5736 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5737 }
5738 }
5739
5740 #[test]
5741 fn test_api_copy_to_clipboard() {
5742 let (mut backend, rx) = create_test_backend();
5743
5744 backend
5745 .execute_js(
5746 r#"
5747 const editor = getEditor();
5748 editor.copyToClipboard("clipboard text");
5749 "#,
5750 "test.js",
5751 )
5752 .unwrap();
5753
5754 let cmd = rx.try_recv().unwrap();
5755 match cmd {
5756 PluginCommand::SetClipboard { text } => {
5757 assert_eq!(text, "clipboard text");
5758 }
5759 _ => panic!("Expected SetClipboard, got {:?}", cmd),
5760 }
5761 }
5762
5763 #[test]
5764 fn test_api_open_file() {
5765 let (mut backend, rx) = create_test_backend();
5766
5767 backend
5769 .execute_js(
5770 r#"
5771 const editor = getEditor();
5772 editor.openFile("/path/to/file.txt", null, null);
5773 "#,
5774 "test.js",
5775 )
5776 .unwrap();
5777
5778 let cmd = rx.try_recv().unwrap();
5779 match cmd {
5780 PluginCommand::OpenFileAtLocation { path, line, column } => {
5781 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
5782 assert!(line.is_none());
5783 assert!(column.is_none());
5784 }
5785 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
5786 }
5787 }
5788
5789 #[test]
5790 fn test_api_delete_range() {
5791 let (mut backend, rx) = create_test_backend();
5792
5793 backend
5795 .execute_js(
5796 r#"
5797 const editor = getEditor();
5798 editor.deleteRange(0, 10, 20);
5799 "#,
5800 "test.js",
5801 )
5802 .unwrap();
5803
5804 let cmd = rx.try_recv().unwrap();
5805 match cmd {
5806 PluginCommand::DeleteRange { range, .. } => {
5807 assert_eq!(range.start, 10);
5808 assert_eq!(range.end, 20);
5809 }
5810 _ => panic!("Expected DeleteRange, got {:?}", cmd),
5811 }
5812 }
5813
5814 #[test]
5815 fn test_api_insert_text() {
5816 let (mut backend, rx) = create_test_backend();
5817
5818 backend
5820 .execute_js(
5821 r#"
5822 const editor = getEditor();
5823 editor.insertText(0, 5, "inserted");
5824 "#,
5825 "test.js",
5826 )
5827 .unwrap();
5828
5829 let cmd = rx.try_recv().unwrap();
5830 match cmd {
5831 PluginCommand::InsertText { position, text, .. } => {
5832 assert_eq!(position, 5);
5833 assert_eq!(text, "inserted");
5834 }
5835 _ => panic!("Expected InsertText, got {:?}", cmd),
5836 }
5837 }
5838
5839 #[test]
5840 fn test_api_set_buffer_cursor() {
5841 let (mut backend, rx) = create_test_backend();
5842
5843 backend
5845 .execute_js(
5846 r#"
5847 const editor = getEditor();
5848 editor.setBufferCursor(0, 100);
5849 "#,
5850 "test.js",
5851 )
5852 .unwrap();
5853
5854 let cmd = rx.try_recv().unwrap();
5855 match cmd {
5856 PluginCommand::SetBufferCursor { position, .. } => {
5857 assert_eq!(position, 100);
5858 }
5859 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
5860 }
5861 }
5862
5863 #[test]
5864 fn test_api_get_cursor_position_from_state() {
5865 let (tx, _rx) = mpsc::channel();
5866 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5867
5868 {
5870 let mut state = state_snapshot.write().unwrap();
5871 state.primary_cursor = Some(CursorInfo {
5872 position: 42,
5873 selection: None,
5874 });
5875 }
5876
5877 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5878 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5879
5880 backend
5882 .execute_js(
5883 r#"
5884 const editor = getEditor();
5885 const pos = editor.getCursorPosition();
5886 globalThis._testResult = pos;
5887 "#,
5888 "test.js",
5889 )
5890 .unwrap();
5891
5892 backend
5894 .plugin_contexts
5895 .borrow()
5896 .get("test")
5897 .unwrap()
5898 .clone()
5899 .with(|ctx| {
5900 let global = ctx.globals();
5901 let result: u32 = global.get("_testResult").unwrap();
5902 assert_eq!(result, 42);
5903 });
5904 }
5905
5906 #[test]
5907 fn test_api_path_functions() {
5908 let (mut backend, _rx) = create_test_backend();
5909
5910 #[cfg(windows)]
5913 let absolute_path = r#"C:\\foo\\bar"#;
5914 #[cfg(not(windows))]
5915 let absolute_path = "/foo/bar";
5916
5917 let js_code = format!(
5919 r#"
5920 const editor = getEditor();
5921 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
5922 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
5923 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
5924 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
5925 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
5926 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
5927 "#,
5928 absolute_path
5929 );
5930 backend.execute_js(&js_code, "test.js").unwrap();
5931
5932 backend
5933 .plugin_contexts
5934 .borrow()
5935 .get("test")
5936 .unwrap()
5937 .clone()
5938 .with(|ctx| {
5939 let global = ctx.globals();
5940 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
5941 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
5942 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
5943 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
5944 assert!(!global.get::<_, bool>("_isRelative").unwrap());
5945 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
5946 });
5947 }
5948
5949 #[test]
5950 fn test_file_uri_to_path_and_back() {
5951 let (mut backend, _rx) = create_test_backend();
5952
5953 #[cfg(not(windows))]
5955 let js_code = r#"
5956 const editor = getEditor();
5957 // Basic file URI to path
5958 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
5959 // Percent-encoded characters
5960 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
5961 // Invalid URI returns empty string
5962 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5963 // Path to file URI
5964 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
5965 // Round-trip
5966 globalThis._roundtrip = editor.fileUriToPath(
5967 editor.pathToFileUri("/home/user/file.txt")
5968 );
5969 "#;
5970
5971 #[cfg(windows)]
5972 let js_code = r#"
5973 const editor = getEditor();
5974 // Windows URI with encoded colon (the bug from issue #1071)
5975 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
5976 // Windows URI with normal colon
5977 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
5978 // Invalid URI returns empty string
5979 globalThis._path3 = editor.fileUriToPath("not-a-uri");
5980 // Path to file URI
5981 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
5982 // Round-trip
5983 globalThis._roundtrip = editor.fileUriToPath(
5984 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
5985 );
5986 "#;
5987
5988 backend.execute_js(js_code, "test.js").unwrap();
5989
5990 backend
5991 .plugin_contexts
5992 .borrow()
5993 .get("test")
5994 .unwrap()
5995 .clone()
5996 .with(|ctx| {
5997 let global = ctx.globals();
5998
5999 #[cfg(not(windows))]
6000 {
6001 assert_eq!(
6002 global.get::<_, String>("_path1").unwrap(),
6003 "/home/user/file.txt"
6004 );
6005 assert_eq!(
6006 global.get::<_, String>("_path2").unwrap(),
6007 "/home/user/my file.txt"
6008 );
6009 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6010 assert_eq!(
6011 global.get::<_, String>("_uri1").unwrap(),
6012 "file:///home/user/file.txt"
6013 );
6014 assert_eq!(
6015 global.get::<_, String>("_roundtrip").unwrap(),
6016 "/home/user/file.txt"
6017 );
6018 }
6019
6020 #[cfg(windows)]
6021 {
6022 assert_eq!(
6024 global.get::<_, String>("_path1").unwrap(),
6025 "C:\\Users\\admin\\Repos\\file.cs"
6026 );
6027 assert_eq!(
6028 global.get::<_, String>("_path2").unwrap(),
6029 "C:\\Users\\admin\\Repos\\file.cs"
6030 );
6031 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6032 assert_eq!(
6033 global.get::<_, String>("_uri1").unwrap(),
6034 "file:///C:/Users/admin/Repos/file.cs"
6035 );
6036 assert_eq!(
6037 global.get::<_, String>("_roundtrip").unwrap(),
6038 "C:\\Users\\admin\\Repos\\file.cs"
6039 );
6040 }
6041 });
6042 }
6043
6044 #[test]
6045 fn test_typescript_transpilation() {
6046 use fresh_parser_js::transpile_typescript;
6047
6048 let (mut backend, rx) = create_test_backend();
6049
6050 let ts_code = r#"
6052 const editor = getEditor();
6053 function greet(name: string): string {
6054 return "Hello, " + name;
6055 }
6056 editor.setStatus(greet("TypeScript"));
6057 "#;
6058
6059 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6061
6062 backend.execute_js(&js_code, "test.js").unwrap();
6064
6065 let cmd = rx.try_recv().unwrap();
6066 match cmd {
6067 PluginCommand::SetStatus { message } => {
6068 assert_eq!(message, "Hello, TypeScript");
6069 }
6070 _ => panic!("Expected SetStatus, got {:?}", cmd),
6071 }
6072 }
6073
6074 #[test]
6075 fn test_api_get_buffer_text_sends_command() {
6076 let (mut backend, rx) = create_test_backend();
6077
6078 backend
6080 .execute_js(
6081 r#"
6082 const editor = getEditor();
6083 // Store the promise for later
6084 globalThis._textPromise = editor.getBufferText(0, 10, 20);
6085 "#,
6086 "test.js",
6087 )
6088 .unwrap();
6089
6090 let cmd = rx.try_recv().unwrap();
6092 match cmd {
6093 PluginCommand::GetBufferText {
6094 buffer_id,
6095 start,
6096 end,
6097 request_id,
6098 } => {
6099 assert_eq!(buffer_id.0, 0);
6100 assert_eq!(start, 10);
6101 assert_eq!(end, 20);
6102 assert!(request_id > 0); }
6104 _ => panic!("Expected GetBufferText, got {:?}", cmd),
6105 }
6106 }
6107
6108 #[test]
6109 fn test_api_get_buffer_text_resolves_callback() {
6110 let (mut backend, rx) = create_test_backend();
6111
6112 backend
6114 .execute_js(
6115 r#"
6116 const editor = getEditor();
6117 globalThis._resolvedText = null;
6118 editor.getBufferText(0, 0, 100).then(text => {
6119 globalThis._resolvedText = text;
6120 });
6121 "#,
6122 "test.js",
6123 )
6124 .unwrap();
6125
6126 let request_id = match rx.try_recv().unwrap() {
6128 PluginCommand::GetBufferText { request_id, .. } => request_id,
6129 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6130 };
6131
6132 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6134
6135 backend
6137 .plugin_contexts
6138 .borrow()
6139 .get("test")
6140 .unwrap()
6141 .clone()
6142 .with(|ctx| {
6143 run_pending_jobs_checked(&ctx, "test async getText");
6144 });
6145
6146 backend
6148 .plugin_contexts
6149 .borrow()
6150 .get("test")
6151 .unwrap()
6152 .clone()
6153 .with(|ctx| {
6154 let global = ctx.globals();
6155 let result: String = global.get("_resolvedText").unwrap();
6156 assert_eq!(result, "hello world");
6157 });
6158 }
6159
6160 #[test]
6161 fn test_plugin_translation() {
6162 let (mut backend, _rx) = create_test_backend();
6163
6164 backend
6166 .execute_js(
6167 r#"
6168 const editor = getEditor();
6169 globalThis._translated = editor.t("test.key");
6170 "#,
6171 "test.js",
6172 )
6173 .unwrap();
6174
6175 backend
6176 .plugin_contexts
6177 .borrow()
6178 .get("test")
6179 .unwrap()
6180 .clone()
6181 .with(|ctx| {
6182 let global = ctx.globals();
6183 let result: String = global.get("_translated").unwrap();
6185 assert_eq!(result, "test.key");
6186 });
6187 }
6188
6189 #[test]
6190 fn test_plugin_translation_with_registered_strings() {
6191 let (mut backend, _rx) = create_test_backend();
6192
6193 let mut en_strings = std::collections::HashMap::new();
6195 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6196 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6197
6198 let mut strings = std::collections::HashMap::new();
6199 strings.insert("en".to_string(), en_strings);
6200
6201 if let Some(bridge) = backend
6203 .services
6204 .as_any()
6205 .downcast_ref::<TestServiceBridge>()
6206 {
6207 let mut en = bridge.en_strings.lock().unwrap();
6208 en.insert("greeting".to_string(), "Hello, World!".to_string());
6209 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6210 }
6211
6212 backend
6214 .execute_js(
6215 r#"
6216 const editor = getEditor();
6217 globalThis._greeting = editor.t("greeting");
6218 globalThis._prompt = editor.t("prompt.find_file");
6219 globalThis._missing = editor.t("nonexistent.key");
6220 "#,
6221 "test.js",
6222 )
6223 .unwrap();
6224
6225 backend
6226 .plugin_contexts
6227 .borrow()
6228 .get("test")
6229 .unwrap()
6230 .clone()
6231 .with(|ctx| {
6232 let global = ctx.globals();
6233 let greeting: String = global.get("_greeting").unwrap();
6234 assert_eq!(greeting, "Hello, World!");
6235
6236 let prompt: String = global.get("_prompt").unwrap();
6237 assert_eq!(prompt, "Find file: ");
6238
6239 let missing: String = global.get("_missing").unwrap();
6241 assert_eq!(missing, "nonexistent.key");
6242 });
6243 }
6244
6245 #[test]
6248 fn test_api_set_line_indicator() {
6249 let (mut backend, rx) = create_test_backend();
6250
6251 backend
6252 .execute_js(
6253 r#"
6254 const editor = getEditor();
6255 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6256 "#,
6257 "test.js",
6258 )
6259 .unwrap();
6260
6261 let cmd = rx.try_recv().unwrap();
6262 match cmd {
6263 PluginCommand::SetLineIndicator {
6264 buffer_id,
6265 line,
6266 namespace,
6267 symbol,
6268 color,
6269 priority,
6270 } => {
6271 assert_eq!(buffer_id.0, 1);
6272 assert_eq!(line, 5);
6273 assert_eq!(namespace, "test-ns");
6274 assert_eq!(symbol, "●");
6275 assert_eq!(color, (255, 0, 0));
6276 assert_eq!(priority, 10);
6277 }
6278 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6279 }
6280 }
6281
6282 #[test]
6283 fn test_api_clear_line_indicators() {
6284 let (mut backend, rx) = create_test_backend();
6285
6286 backend
6287 .execute_js(
6288 r#"
6289 const editor = getEditor();
6290 editor.clearLineIndicators(1, "test-ns");
6291 "#,
6292 "test.js",
6293 )
6294 .unwrap();
6295
6296 let cmd = rx.try_recv().unwrap();
6297 match cmd {
6298 PluginCommand::ClearLineIndicators {
6299 buffer_id,
6300 namespace,
6301 } => {
6302 assert_eq!(buffer_id.0, 1);
6303 assert_eq!(namespace, "test-ns");
6304 }
6305 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6306 }
6307 }
6308
6309 #[test]
6312 fn test_api_create_virtual_buffer_sends_command() {
6313 let (mut backend, rx) = create_test_backend();
6314
6315 backend
6316 .execute_js(
6317 r#"
6318 const editor = getEditor();
6319 editor.createVirtualBuffer({
6320 name: "*Test Buffer*",
6321 mode: "test-mode",
6322 readOnly: true,
6323 entries: [
6324 { text: "Line 1\n", properties: { type: "header" } },
6325 { text: "Line 2\n", properties: { type: "content" } }
6326 ],
6327 showLineNumbers: false,
6328 showCursors: true,
6329 editingDisabled: true
6330 });
6331 "#,
6332 "test.js",
6333 )
6334 .unwrap();
6335
6336 let cmd = rx.try_recv().unwrap();
6337 match cmd {
6338 PluginCommand::CreateVirtualBufferWithContent {
6339 name,
6340 mode,
6341 read_only,
6342 entries,
6343 show_line_numbers,
6344 show_cursors,
6345 editing_disabled,
6346 ..
6347 } => {
6348 assert_eq!(name, "*Test Buffer*");
6349 assert_eq!(mode, "test-mode");
6350 assert!(read_only);
6351 assert_eq!(entries.len(), 2);
6352 assert_eq!(entries[0].text, "Line 1\n");
6353 assert!(!show_line_numbers);
6354 assert!(show_cursors);
6355 assert!(editing_disabled);
6356 }
6357 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6358 }
6359 }
6360
6361 #[test]
6362 fn test_api_set_virtual_buffer_content() {
6363 let (mut backend, rx) = create_test_backend();
6364
6365 backend
6366 .execute_js(
6367 r#"
6368 const editor = getEditor();
6369 editor.setVirtualBufferContent(5, [
6370 { text: "New content\n", properties: { type: "updated" } }
6371 ]);
6372 "#,
6373 "test.js",
6374 )
6375 .unwrap();
6376
6377 let cmd = rx.try_recv().unwrap();
6378 match cmd {
6379 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6380 assert_eq!(buffer_id.0, 5);
6381 assert_eq!(entries.len(), 1);
6382 assert_eq!(entries[0].text, "New content\n");
6383 }
6384 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6385 }
6386 }
6387
6388 #[test]
6391 fn test_api_add_overlay() {
6392 let (mut backend, rx) = create_test_backend();
6393
6394 backend
6395 .execute_js(
6396 r#"
6397 const editor = getEditor();
6398 editor.addOverlay(1, "highlight", 10, 20, {
6399 fg: [255, 128, 0],
6400 bg: [50, 50, 50],
6401 bold: true,
6402 });
6403 "#,
6404 "test.js",
6405 )
6406 .unwrap();
6407
6408 let cmd = rx.try_recv().unwrap();
6409 match cmd {
6410 PluginCommand::AddOverlay {
6411 buffer_id,
6412 namespace,
6413 range,
6414 options,
6415 } => {
6416 use fresh_core::api::OverlayColorSpec;
6417 assert_eq!(buffer_id.0, 1);
6418 assert!(namespace.is_some());
6419 assert_eq!(namespace.unwrap().as_str(), "highlight");
6420 assert_eq!(range, 10..20);
6421 assert!(matches!(
6422 options.fg,
6423 Some(OverlayColorSpec::Rgb(255, 128, 0))
6424 ));
6425 assert!(matches!(
6426 options.bg,
6427 Some(OverlayColorSpec::Rgb(50, 50, 50))
6428 ));
6429 assert!(!options.underline);
6430 assert!(options.bold);
6431 assert!(!options.italic);
6432 assert!(!options.extend_to_line_end);
6433 }
6434 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6435 }
6436 }
6437
6438 #[test]
6439 fn test_api_add_overlay_with_theme_keys() {
6440 let (mut backend, rx) = create_test_backend();
6441
6442 backend
6443 .execute_js(
6444 r#"
6445 const editor = getEditor();
6446 // Test with theme keys for colors
6447 editor.addOverlay(1, "themed", 0, 10, {
6448 fg: "ui.status_bar_fg",
6449 bg: "editor.selection_bg",
6450 });
6451 "#,
6452 "test.js",
6453 )
6454 .unwrap();
6455
6456 let cmd = rx.try_recv().unwrap();
6457 match cmd {
6458 PluginCommand::AddOverlay {
6459 buffer_id,
6460 namespace,
6461 range,
6462 options,
6463 } => {
6464 use fresh_core::api::OverlayColorSpec;
6465 assert_eq!(buffer_id.0, 1);
6466 assert!(namespace.is_some());
6467 assert_eq!(namespace.unwrap().as_str(), "themed");
6468 assert_eq!(range, 0..10);
6469 assert!(matches!(
6470 &options.fg,
6471 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6472 ));
6473 assert!(matches!(
6474 &options.bg,
6475 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6476 ));
6477 assert!(!options.underline);
6478 assert!(!options.bold);
6479 assert!(!options.italic);
6480 assert!(!options.extend_to_line_end);
6481 }
6482 _ => panic!("Expected AddOverlay, got {:?}", cmd),
6483 }
6484 }
6485
6486 #[test]
6487 fn test_api_clear_namespace() {
6488 let (mut backend, rx) = create_test_backend();
6489
6490 backend
6491 .execute_js(
6492 r#"
6493 const editor = getEditor();
6494 editor.clearNamespace(1, "highlight");
6495 "#,
6496 "test.js",
6497 )
6498 .unwrap();
6499
6500 let cmd = rx.try_recv().unwrap();
6501 match cmd {
6502 PluginCommand::ClearNamespace {
6503 buffer_id,
6504 namespace,
6505 } => {
6506 assert_eq!(buffer_id.0, 1);
6507 assert_eq!(namespace.as_str(), "highlight");
6508 }
6509 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6510 }
6511 }
6512
6513 #[test]
6516 fn test_api_get_theme_schema() {
6517 let (mut backend, _rx) = create_test_backend();
6518
6519 backend
6520 .execute_js(
6521 r#"
6522 const editor = getEditor();
6523 const schema = editor.getThemeSchema();
6524 globalThis._isObject = typeof schema === 'object' && schema !== null;
6525 "#,
6526 "test.js",
6527 )
6528 .unwrap();
6529
6530 backend
6531 .plugin_contexts
6532 .borrow()
6533 .get("test")
6534 .unwrap()
6535 .clone()
6536 .with(|ctx| {
6537 let global = ctx.globals();
6538 let is_object: bool = global.get("_isObject").unwrap();
6539 assert!(is_object);
6541 });
6542 }
6543
6544 #[test]
6545 fn test_api_get_builtin_themes() {
6546 let (mut backend, _rx) = create_test_backend();
6547
6548 backend
6549 .execute_js(
6550 r#"
6551 const editor = getEditor();
6552 const themes = editor.getBuiltinThemes();
6553 globalThis._isObject = typeof themes === 'object' && themes !== null;
6554 "#,
6555 "test.js",
6556 )
6557 .unwrap();
6558
6559 backend
6560 .plugin_contexts
6561 .borrow()
6562 .get("test")
6563 .unwrap()
6564 .clone()
6565 .with(|ctx| {
6566 let global = ctx.globals();
6567 let is_object: bool = global.get("_isObject").unwrap();
6568 assert!(is_object);
6570 });
6571 }
6572
6573 #[test]
6574 fn test_api_apply_theme() {
6575 let (mut backend, rx) = create_test_backend();
6576
6577 backend
6578 .execute_js(
6579 r#"
6580 const editor = getEditor();
6581 editor.applyTheme("dark");
6582 "#,
6583 "test.js",
6584 )
6585 .unwrap();
6586
6587 let cmd = rx.try_recv().unwrap();
6588 match cmd {
6589 PluginCommand::ApplyTheme { theme_name } => {
6590 assert_eq!(theme_name, "dark");
6591 }
6592 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6593 }
6594 }
6595
6596 #[test]
6597 fn test_api_get_theme_data_missing() {
6598 let (mut backend, _rx) = create_test_backend();
6599
6600 backend
6601 .execute_js(
6602 r#"
6603 const editor = getEditor();
6604 const data = editor.getThemeData("nonexistent");
6605 globalThis._isNull = data === null;
6606 "#,
6607 "test.js",
6608 )
6609 .unwrap();
6610
6611 backend
6612 .plugin_contexts
6613 .borrow()
6614 .get("test")
6615 .unwrap()
6616 .clone()
6617 .with(|ctx| {
6618 let global = ctx.globals();
6619 let is_null: bool = global.get("_isNull").unwrap();
6620 assert!(is_null);
6622 });
6623 }
6624
6625 #[test]
6626 fn test_api_get_theme_data_present() {
6627 let (tx, _rx) = mpsc::channel();
6629 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6630 let services = Arc::new(ThemeCacheTestBridge {
6631 inner: TestServiceBridge::new(),
6632 });
6633 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6634
6635 backend
6636 .execute_js(
6637 r#"
6638 const editor = getEditor();
6639 const data = editor.getThemeData("test-theme");
6640 globalThis._hasData = data !== null && typeof data === 'object';
6641 globalThis._name = data ? data.name : null;
6642 "#,
6643 "test.js",
6644 )
6645 .unwrap();
6646
6647 backend
6648 .plugin_contexts
6649 .borrow()
6650 .get("test")
6651 .unwrap()
6652 .clone()
6653 .with(|ctx| {
6654 let global = ctx.globals();
6655 let has_data: bool = global.get("_hasData").unwrap();
6656 assert!(has_data, "getThemeData should return theme object");
6657 let name: String = global.get("_name").unwrap();
6658 assert_eq!(name, "test-theme");
6659 });
6660 }
6661
6662 #[test]
6663 fn test_api_theme_file_exists() {
6664 let (mut backend, _rx) = create_test_backend();
6665
6666 backend
6667 .execute_js(
6668 r#"
6669 const editor = getEditor();
6670 globalThis._exists = editor.themeFileExists("anything");
6671 "#,
6672 "test.js",
6673 )
6674 .unwrap();
6675
6676 backend
6677 .plugin_contexts
6678 .borrow()
6679 .get("test")
6680 .unwrap()
6681 .clone()
6682 .with(|ctx| {
6683 let global = ctx.globals();
6684 let exists: bool = global.get("_exists").unwrap();
6685 assert!(!exists);
6687 });
6688 }
6689
6690 #[test]
6691 fn test_api_save_theme_file_error() {
6692 let (mut backend, _rx) = create_test_backend();
6693
6694 backend
6695 .execute_js(
6696 r#"
6697 const editor = getEditor();
6698 let threw = false;
6699 try {
6700 editor.saveThemeFile("test", "{}");
6701 } catch (e) {
6702 threw = true;
6703 }
6704 globalThis._threw = threw;
6705 "#,
6706 "test.js",
6707 )
6708 .unwrap();
6709
6710 backend
6711 .plugin_contexts
6712 .borrow()
6713 .get("test")
6714 .unwrap()
6715 .clone()
6716 .with(|ctx| {
6717 let global = ctx.globals();
6718 let threw: bool = global.get("_threw").unwrap();
6719 assert!(threw);
6721 });
6722 }
6723
6724 struct ThemeCacheTestBridge {
6726 inner: TestServiceBridge,
6727 }
6728
6729 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6730 fn as_any(&self) -> &dyn std::any::Any {
6731 self
6732 }
6733 fn translate(
6734 &self,
6735 plugin_name: &str,
6736 key: &str,
6737 args: &HashMap<String, String>,
6738 ) -> String {
6739 self.inner.translate(plugin_name, key, args)
6740 }
6741 fn current_locale(&self) -> String {
6742 self.inner.current_locale()
6743 }
6744 fn set_js_execution_state(&self, state: String) {
6745 self.inner.set_js_execution_state(state);
6746 }
6747 fn clear_js_execution_state(&self) {
6748 self.inner.clear_js_execution_state();
6749 }
6750 fn get_theme_schema(&self) -> serde_json::Value {
6751 self.inner.get_theme_schema()
6752 }
6753 fn get_builtin_themes(&self) -> serde_json::Value {
6754 self.inner.get_builtin_themes()
6755 }
6756 fn register_command(&self, command: fresh_core::command::Command) {
6757 self.inner.register_command(command);
6758 }
6759 fn unregister_command(&self, name: &str) {
6760 self.inner.unregister_command(name);
6761 }
6762 fn unregister_commands_by_prefix(&self, prefix: &str) {
6763 self.inner.unregister_commands_by_prefix(prefix);
6764 }
6765 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6766 self.inner.unregister_commands_by_plugin(plugin_name);
6767 }
6768 fn plugins_dir(&self) -> std::path::PathBuf {
6769 self.inner.plugins_dir()
6770 }
6771 fn config_dir(&self) -> std::path::PathBuf {
6772 self.inner.config_dir()
6773 }
6774 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6775 if name == "test-theme" {
6776 Some(serde_json::json!({
6777 "name": "test-theme",
6778 "editor": {},
6779 "ui": {},
6780 "syntax": {}
6781 }))
6782 } else {
6783 None
6784 }
6785 }
6786 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6787 Err("test bridge does not support save".to_string())
6788 }
6789 fn theme_file_exists(&self, name: &str) -> bool {
6790 name == "test-theme"
6791 }
6792 }
6793
6794 #[test]
6797 fn test_api_close_buffer() {
6798 let (mut backend, rx) = create_test_backend();
6799
6800 backend
6801 .execute_js(
6802 r#"
6803 const editor = getEditor();
6804 editor.closeBuffer(3);
6805 "#,
6806 "test.js",
6807 )
6808 .unwrap();
6809
6810 let cmd = rx.try_recv().unwrap();
6811 match cmd {
6812 PluginCommand::CloseBuffer { buffer_id } => {
6813 assert_eq!(buffer_id.0, 3);
6814 }
6815 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
6816 }
6817 }
6818
6819 #[test]
6820 fn test_api_focus_split() {
6821 let (mut backend, rx) = create_test_backend();
6822
6823 backend
6824 .execute_js(
6825 r#"
6826 const editor = getEditor();
6827 editor.focusSplit(2);
6828 "#,
6829 "test.js",
6830 )
6831 .unwrap();
6832
6833 let cmd = rx.try_recv().unwrap();
6834 match cmd {
6835 PluginCommand::FocusSplit { split_id } => {
6836 assert_eq!(split_id.0, 2);
6837 }
6838 _ => panic!("Expected FocusSplit, got {:?}", cmd),
6839 }
6840 }
6841
6842 #[test]
6843 fn test_api_list_buffers() {
6844 let (tx, _rx) = mpsc::channel();
6845 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6846
6847 {
6849 let mut state = state_snapshot.write().unwrap();
6850 state.buffers.insert(
6851 BufferId(0),
6852 BufferInfo {
6853 id: BufferId(0),
6854 path: Some(PathBuf::from("/test1.txt")),
6855 modified: false,
6856 length: 100,
6857 is_virtual: false,
6858 view_mode: "source".to_string(),
6859 is_composing_in_any_split: false,
6860 compose_width: None,
6861 language: "text".to_string(),
6862 },
6863 );
6864 state.buffers.insert(
6865 BufferId(1),
6866 BufferInfo {
6867 id: BufferId(1),
6868 path: Some(PathBuf::from("/test2.txt")),
6869 modified: true,
6870 length: 200,
6871 is_virtual: false,
6872 view_mode: "source".to_string(),
6873 is_composing_in_any_split: false,
6874 compose_width: None,
6875 language: "text".to_string(),
6876 },
6877 );
6878 }
6879
6880 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6881 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6882
6883 backend
6884 .execute_js(
6885 r#"
6886 const editor = getEditor();
6887 const buffers = editor.listBuffers();
6888 globalThis._isArray = Array.isArray(buffers);
6889 globalThis._length = buffers.length;
6890 "#,
6891 "test.js",
6892 )
6893 .unwrap();
6894
6895 backend
6896 .plugin_contexts
6897 .borrow()
6898 .get("test")
6899 .unwrap()
6900 .clone()
6901 .with(|ctx| {
6902 let global = ctx.globals();
6903 let is_array: bool = global.get("_isArray").unwrap();
6904 let length: u32 = global.get("_length").unwrap();
6905 assert!(is_array);
6906 assert_eq!(length, 2);
6907 });
6908 }
6909
6910 #[test]
6913 fn test_api_start_prompt() {
6914 let (mut backend, rx) = create_test_backend();
6915
6916 backend
6917 .execute_js(
6918 r#"
6919 const editor = getEditor();
6920 editor.startPrompt("Enter value:", "test-prompt");
6921 "#,
6922 "test.js",
6923 )
6924 .unwrap();
6925
6926 let cmd = rx.try_recv().unwrap();
6927 match cmd {
6928 PluginCommand::StartPrompt { label, prompt_type } => {
6929 assert_eq!(label, "Enter value:");
6930 assert_eq!(prompt_type, "test-prompt");
6931 }
6932 _ => panic!("Expected StartPrompt, got {:?}", cmd),
6933 }
6934 }
6935
6936 #[test]
6937 fn test_api_start_prompt_with_initial() {
6938 let (mut backend, rx) = create_test_backend();
6939
6940 backend
6941 .execute_js(
6942 r#"
6943 const editor = getEditor();
6944 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
6945 "#,
6946 "test.js",
6947 )
6948 .unwrap();
6949
6950 let cmd = rx.try_recv().unwrap();
6951 match cmd {
6952 PluginCommand::StartPromptWithInitial {
6953 label,
6954 prompt_type,
6955 initial_value,
6956 } => {
6957 assert_eq!(label, "Enter value:");
6958 assert_eq!(prompt_type, "test-prompt");
6959 assert_eq!(initial_value, "default");
6960 }
6961 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
6962 }
6963 }
6964
6965 #[test]
6966 fn test_api_set_prompt_suggestions() {
6967 let (mut backend, rx) = create_test_backend();
6968
6969 backend
6970 .execute_js(
6971 r#"
6972 const editor = getEditor();
6973 editor.setPromptSuggestions([
6974 { text: "Option 1", value: "opt1" },
6975 { text: "Option 2", value: "opt2" }
6976 ]);
6977 "#,
6978 "test.js",
6979 )
6980 .unwrap();
6981
6982 let cmd = rx.try_recv().unwrap();
6983 match cmd {
6984 PluginCommand::SetPromptSuggestions { suggestions } => {
6985 assert_eq!(suggestions.len(), 2);
6986 assert_eq!(suggestions[0].text, "Option 1");
6987 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
6988 }
6989 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
6990 }
6991 }
6992
6993 #[test]
6996 fn test_api_get_active_buffer_id() {
6997 let (tx, _rx) = mpsc::channel();
6998 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6999
7000 {
7001 let mut state = state_snapshot.write().unwrap();
7002 state.active_buffer_id = BufferId(42);
7003 }
7004
7005 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7006 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7007
7008 backend
7009 .execute_js(
7010 r#"
7011 const editor = getEditor();
7012 globalThis._activeId = editor.getActiveBufferId();
7013 "#,
7014 "test.js",
7015 )
7016 .unwrap();
7017
7018 backend
7019 .plugin_contexts
7020 .borrow()
7021 .get("test")
7022 .unwrap()
7023 .clone()
7024 .with(|ctx| {
7025 let global = ctx.globals();
7026 let result: u32 = global.get("_activeId").unwrap();
7027 assert_eq!(result, 42);
7028 });
7029 }
7030
7031 #[test]
7032 fn test_api_get_active_split_id() {
7033 let (tx, _rx) = mpsc::channel();
7034 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7035
7036 {
7037 let mut state = state_snapshot.write().unwrap();
7038 state.active_split_id = 7;
7039 }
7040
7041 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7042 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7043
7044 backend
7045 .execute_js(
7046 r#"
7047 const editor = getEditor();
7048 globalThis._splitId = editor.getActiveSplitId();
7049 "#,
7050 "test.js",
7051 )
7052 .unwrap();
7053
7054 backend
7055 .plugin_contexts
7056 .borrow()
7057 .get("test")
7058 .unwrap()
7059 .clone()
7060 .with(|ctx| {
7061 let global = ctx.globals();
7062 let result: u32 = global.get("_splitId").unwrap();
7063 assert_eq!(result, 7);
7064 });
7065 }
7066
7067 #[test]
7070 fn test_api_file_exists() {
7071 let (mut backend, _rx) = create_test_backend();
7072
7073 backend
7074 .execute_js(
7075 r#"
7076 const editor = getEditor();
7077 // Test with a path that definitely exists
7078 globalThis._exists = editor.fileExists("/");
7079 "#,
7080 "test.js",
7081 )
7082 .unwrap();
7083
7084 backend
7085 .plugin_contexts
7086 .borrow()
7087 .get("test")
7088 .unwrap()
7089 .clone()
7090 .with(|ctx| {
7091 let global = ctx.globals();
7092 let result: bool = global.get("_exists").unwrap();
7093 assert!(result);
7094 });
7095 }
7096
7097 #[test]
7098 fn test_api_get_cwd() {
7099 let (mut backend, _rx) = create_test_backend();
7100
7101 backend
7102 .execute_js(
7103 r#"
7104 const editor = getEditor();
7105 globalThis._cwd = editor.getCwd();
7106 "#,
7107 "test.js",
7108 )
7109 .unwrap();
7110
7111 backend
7112 .plugin_contexts
7113 .borrow()
7114 .get("test")
7115 .unwrap()
7116 .clone()
7117 .with(|ctx| {
7118 let global = ctx.globals();
7119 let result: String = global.get("_cwd").unwrap();
7120 assert!(!result.is_empty());
7122 });
7123 }
7124
7125 #[test]
7126 fn test_api_get_env() {
7127 let (mut backend, _rx) = create_test_backend();
7128
7129 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
7131
7132 backend
7133 .execute_js(
7134 r#"
7135 const editor = getEditor();
7136 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
7137 "#,
7138 "test.js",
7139 )
7140 .unwrap();
7141
7142 backend
7143 .plugin_contexts
7144 .borrow()
7145 .get("test")
7146 .unwrap()
7147 .clone()
7148 .with(|ctx| {
7149 let global = ctx.globals();
7150 let result: Option<String> = global.get("_envVal").unwrap();
7151 assert_eq!(result, Some("test_value".to_string()));
7152 });
7153
7154 std::env::remove_var("TEST_PLUGIN_VAR");
7155 }
7156
7157 #[test]
7158 fn test_api_get_config() {
7159 let (mut backend, _rx) = create_test_backend();
7160
7161 backend
7162 .execute_js(
7163 r#"
7164 const editor = getEditor();
7165 const config = editor.getConfig();
7166 globalThis._isObject = typeof config === 'object';
7167 "#,
7168 "test.js",
7169 )
7170 .unwrap();
7171
7172 backend
7173 .plugin_contexts
7174 .borrow()
7175 .get("test")
7176 .unwrap()
7177 .clone()
7178 .with(|ctx| {
7179 let global = ctx.globals();
7180 let is_object: bool = global.get("_isObject").unwrap();
7181 assert!(is_object);
7183 });
7184 }
7185
7186 #[test]
7187 fn test_api_get_themes_dir() {
7188 let (mut backend, _rx) = create_test_backend();
7189
7190 backend
7191 .execute_js(
7192 r#"
7193 const editor = getEditor();
7194 globalThis._themesDir = editor.getThemesDir();
7195 "#,
7196 "test.js",
7197 )
7198 .unwrap();
7199
7200 backend
7201 .plugin_contexts
7202 .borrow()
7203 .get("test")
7204 .unwrap()
7205 .clone()
7206 .with(|ctx| {
7207 let global = ctx.globals();
7208 let result: String = global.get("_themesDir").unwrap();
7209 assert!(!result.is_empty());
7211 });
7212 }
7213
7214 #[test]
7217 fn test_api_read_dir() {
7218 let (mut backend, _rx) = create_test_backend();
7219
7220 backend
7221 .execute_js(
7222 r#"
7223 const editor = getEditor();
7224 const entries = editor.readDir("/tmp");
7225 globalThis._isArray = Array.isArray(entries);
7226 globalThis._length = entries.length;
7227 "#,
7228 "test.js",
7229 )
7230 .unwrap();
7231
7232 backend
7233 .plugin_contexts
7234 .borrow()
7235 .get("test")
7236 .unwrap()
7237 .clone()
7238 .with(|ctx| {
7239 let global = ctx.globals();
7240 let is_array: bool = global.get("_isArray").unwrap();
7241 let length: u32 = global.get("_length").unwrap();
7242 assert!(is_array);
7244 let _ = length;
7246 });
7247 }
7248
7249 #[test]
7252 fn test_api_execute_action() {
7253 let (mut backend, rx) = create_test_backend();
7254
7255 backend
7256 .execute_js(
7257 r#"
7258 const editor = getEditor();
7259 editor.executeAction("move_cursor_up");
7260 "#,
7261 "test.js",
7262 )
7263 .unwrap();
7264
7265 let cmd = rx.try_recv().unwrap();
7266 match cmd {
7267 PluginCommand::ExecuteAction { action_name } => {
7268 assert_eq!(action_name, "move_cursor_up");
7269 }
7270 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7271 }
7272 }
7273
7274 #[test]
7277 fn test_api_debug() {
7278 let (mut backend, _rx) = create_test_backend();
7279
7280 backend
7282 .execute_js(
7283 r#"
7284 const editor = getEditor();
7285 editor.debug("Test debug message");
7286 editor.debug("Another message with special chars: <>&\"'");
7287 "#,
7288 "test.js",
7289 )
7290 .unwrap();
7291 }
7293
7294 #[test]
7297 fn test_typescript_preamble_generated() {
7298 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7300 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7301 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7302 println!(
7303 "Generated {} bytes of TypeScript preamble",
7304 JSEDITORAPI_TS_PREAMBLE.len()
7305 );
7306 }
7307
7308 #[test]
7309 fn test_typescript_editor_api_generated() {
7310 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7312 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7313 println!(
7314 "Generated {} bytes of EditorAPI interface",
7315 JSEDITORAPI_TS_EDITOR_API.len()
7316 );
7317 }
7318
7319 #[test]
7320 fn test_js_methods_list() {
7321 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7323 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7324 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7326 if i < 20 {
7327 println!(" - {}", method);
7328 }
7329 }
7330 if JSEDITORAPI_JS_METHODS.len() > 20 {
7331 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7332 }
7333 }
7334
7335 #[test]
7338 fn test_api_load_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._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7347 "#,
7348 "test.js",
7349 )
7350 .unwrap();
7351
7352 let cmd = rx.try_recv().unwrap();
7354 match cmd {
7355 PluginCommand::LoadPlugin { path, callback_id } => {
7356 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7357 assert!(callback_id.0 > 0); }
7359 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7360 }
7361 }
7362
7363 #[test]
7364 fn test_api_unload_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._unloadPromise = editor.unloadPlugin("my-plugin");
7373 "#,
7374 "test.js",
7375 )
7376 .unwrap();
7377
7378 let cmd = rx.try_recv().unwrap();
7380 match cmd {
7381 PluginCommand::UnloadPlugin { name, callback_id } => {
7382 assert_eq!(name, "my-plugin");
7383 assert!(callback_id.0 > 0); }
7385 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7386 }
7387 }
7388
7389 #[test]
7390 fn test_api_reload_plugin_sends_command() {
7391 let (mut backend, rx) = create_test_backend();
7392
7393 backend
7395 .execute_js(
7396 r#"
7397 const editor = getEditor();
7398 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7399 "#,
7400 "test.js",
7401 )
7402 .unwrap();
7403
7404 let cmd = rx.try_recv().unwrap();
7406 match cmd {
7407 PluginCommand::ReloadPlugin { name, callback_id } => {
7408 assert_eq!(name, "my-plugin");
7409 assert!(callback_id.0 > 0); }
7411 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7412 }
7413 }
7414
7415 #[test]
7416 fn test_api_load_plugin_resolves_callback() {
7417 let (mut backend, rx) = create_test_backend();
7418
7419 backend
7421 .execute_js(
7422 r#"
7423 const editor = getEditor();
7424 globalThis._loadResult = null;
7425 editor.loadPlugin("/path/to/plugin.ts").then(result => {
7426 globalThis._loadResult = result;
7427 });
7428 "#,
7429 "test.js",
7430 )
7431 .unwrap();
7432
7433 let callback_id = match rx.try_recv().unwrap() {
7435 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7436 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7437 };
7438
7439 backend.resolve_callback(callback_id, "true");
7441
7442 backend
7444 .plugin_contexts
7445 .borrow()
7446 .get("test")
7447 .unwrap()
7448 .clone()
7449 .with(|ctx| {
7450 run_pending_jobs_checked(&ctx, "test async loadPlugin");
7451 });
7452
7453 backend
7455 .plugin_contexts
7456 .borrow()
7457 .get("test")
7458 .unwrap()
7459 .clone()
7460 .with(|ctx| {
7461 let global = ctx.globals();
7462 let result: bool = global.get("_loadResult").unwrap();
7463 assert!(result);
7464 });
7465 }
7466
7467 #[test]
7468 fn test_api_version() {
7469 let (mut backend, _rx) = create_test_backend();
7470
7471 backend
7472 .execute_js(
7473 r#"
7474 const editor = getEditor();
7475 globalThis._apiVersion = editor.apiVersion();
7476 "#,
7477 "test.js",
7478 )
7479 .unwrap();
7480
7481 backend
7482 .plugin_contexts
7483 .borrow()
7484 .get("test")
7485 .unwrap()
7486 .clone()
7487 .with(|ctx| {
7488 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7489 assert_eq!(version, 2);
7490 });
7491 }
7492
7493 #[test]
7494 fn test_api_unload_plugin_rejects_on_error() {
7495 let (mut backend, rx) = create_test_backend();
7496
7497 backend
7499 .execute_js(
7500 r#"
7501 const editor = getEditor();
7502 globalThis._unloadError = null;
7503 editor.unloadPlugin("nonexistent-plugin").catch(err => {
7504 globalThis._unloadError = err.message || String(err);
7505 });
7506 "#,
7507 "test.js",
7508 )
7509 .unwrap();
7510
7511 let callback_id = match rx.try_recv().unwrap() {
7513 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7514 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7515 };
7516
7517 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7519
7520 backend
7522 .plugin_contexts
7523 .borrow()
7524 .get("test")
7525 .unwrap()
7526 .clone()
7527 .with(|ctx| {
7528 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7529 });
7530
7531 backend
7533 .plugin_contexts
7534 .borrow()
7535 .get("test")
7536 .unwrap()
7537 .clone()
7538 .with(|ctx| {
7539 let global = ctx.globals();
7540 let error: String = global.get("_unloadError").unwrap();
7541 assert!(error.contains("nonexistent-plugin"));
7542 });
7543 }
7544
7545 #[test]
7546 fn test_api_set_global_state() {
7547 let (mut backend, rx) = create_test_backend();
7548
7549 backend
7550 .execute_js(
7551 r#"
7552 const editor = getEditor();
7553 editor.setGlobalState("myKey", { enabled: true, count: 42 });
7554 "#,
7555 "test_plugin.js",
7556 )
7557 .unwrap();
7558
7559 let cmd = rx.try_recv().unwrap();
7560 match cmd {
7561 PluginCommand::SetGlobalState {
7562 plugin_name,
7563 key,
7564 value,
7565 } => {
7566 assert_eq!(plugin_name, "test_plugin");
7567 assert_eq!(key, "myKey");
7568 let v = value.unwrap();
7569 assert_eq!(v["enabled"], serde_json::json!(true));
7570 assert_eq!(v["count"], serde_json::json!(42));
7571 }
7572 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7573 }
7574 }
7575
7576 #[test]
7577 fn test_api_set_global_state_delete() {
7578 let (mut backend, rx) = create_test_backend();
7579
7580 backend
7581 .execute_js(
7582 r#"
7583 const editor = getEditor();
7584 editor.setGlobalState("myKey", null);
7585 "#,
7586 "test_plugin.js",
7587 )
7588 .unwrap();
7589
7590 let cmd = rx.try_recv().unwrap();
7591 match cmd {
7592 PluginCommand::SetGlobalState {
7593 plugin_name,
7594 key,
7595 value,
7596 } => {
7597 assert_eq!(plugin_name, "test_plugin");
7598 assert_eq!(key, "myKey");
7599 assert!(value.is_none(), "null should delete the key");
7600 }
7601 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7602 }
7603 }
7604
7605 #[test]
7606 fn test_api_get_global_state_roundtrip() {
7607 let (mut backend, _rx) = create_test_backend();
7608
7609 backend
7611 .execute_js(
7612 r#"
7613 const editor = getEditor();
7614 editor.setGlobalState("flag", true);
7615 globalThis._result = editor.getGlobalState("flag");
7616 "#,
7617 "test_plugin.js",
7618 )
7619 .unwrap();
7620
7621 backend
7622 .plugin_contexts
7623 .borrow()
7624 .get("test_plugin")
7625 .unwrap()
7626 .clone()
7627 .with(|ctx| {
7628 let global = ctx.globals();
7629 let result: bool = global.get("_result").unwrap();
7630 assert!(
7631 result,
7632 "getGlobalState should return the value set by setGlobalState"
7633 );
7634 });
7635 }
7636
7637 #[test]
7638 fn test_api_get_global_state_missing_key() {
7639 let (mut backend, _rx) = create_test_backend();
7640
7641 backend
7642 .execute_js(
7643 r#"
7644 const editor = getEditor();
7645 globalThis._result = editor.getGlobalState("nonexistent");
7646 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7647 "#,
7648 "test_plugin.js",
7649 )
7650 .unwrap();
7651
7652 backend
7653 .plugin_contexts
7654 .borrow()
7655 .get("test_plugin")
7656 .unwrap()
7657 .clone()
7658 .with(|ctx| {
7659 let global = ctx.globals();
7660 let is_undefined: bool = global.get("_isUndefined").unwrap();
7661 assert!(
7662 is_undefined,
7663 "getGlobalState for missing key should return undefined"
7664 );
7665 });
7666 }
7667
7668 #[test]
7669 fn test_api_global_state_isolation_between_plugins() {
7670 let (tx, _rx) = mpsc::channel();
7672 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7673 let services = Arc::new(TestServiceBridge::new());
7674
7675 let mut backend_a =
7677 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7678 .unwrap();
7679 backend_a
7680 .execute_js(
7681 r#"
7682 const editor = getEditor();
7683 editor.setGlobalState("flag", "from_plugin_a");
7684 "#,
7685 "plugin_a.js",
7686 )
7687 .unwrap();
7688
7689 let mut backend_b =
7691 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7692 .unwrap();
7693 backend_b
7694 .execute_js(
7695 r#"
7696 const editor = getEditor();
7697 editor.setGlobalState("flag", "from_plugin_b");
7698 "#,
7699 "plugin_b.js",
7700 )
7701 .unwrap();
7702
7703 backend_a
7705 .execute_js(
7706 r#"
7707 const editor = getEditor();
7708 globalThis._aValue = editor.getGlobalState("flag");
7709 "#,
7710 "plugin_a.js",
7711 )
7712 .unwrap();
7713
7714 backend_a
7715 .plugin_contexts
7716 .borrow()
7717 .get("plugin_a")
7718 .unwrap()
7719 .clone()
7720 .with(|ctx| {
7721 let global = ctx.globals();
7722 let a_value: String = global.get("_aValue").unwrap();
7723 assert_eq!(
7724 a_value, "from_plugin_a",
7725 "Plugin A should see its own value, not plugin B's"
7726 );
7727 });
7728
7729 backend_b
7731 .execute_js(
7732 r#"
7733 const editor = getEditor();
7734 globalThis._bValue = editor.getGlobalState("flag");
7735 "#,
7736 "plugin_b.js",
7737 )
7738 .unwrap();
7739
7740 backend_b
7741 .plugin_contexts
7742 .borrow()
7743 .get("plugin_b")
7744 .unwrap()
7745 .clone()
7746 .with(|ctx| {
7747 let global = ctx.globals();
7748 let b_value: String = global.get("_bValue").unwrap();
7749 assert_eq!(
7750 b_value, "from_plugin_b",
7751 "Plugin B should see its own value, not plugin A's"
7752 );
7753 });
7754 }
7755
7756 #[test]
7757 fn test_register_command_collision_different_plugins() {
7758 let (mut backend, _rx) = create_test_backend();
7759
7760 backend
7762 .execute_js(
7763 r#"
7764 const editor = getEditor();
7765 globalThis.handlerA = function() { };
7766 editor.registerCommand("My Command", "From A", "handlerA", null);
7767 "#,
7768 "plugin_a.js",
7769 )
7770 .unwrap();
7771
7772 let result = backend.execute_js(
7774 r#"
7775 const editor = getEditor();
7776 globalThis.handlerB = function() { };
7777 editor.registerCommand("My Command", "From B", "handlerB", null);
7778 "#,
7779 "plugin_b.js",
7780 );
7781
7782 assert!(
7783 result.is_err(),
7784 "Second plugin registering the same command name should fail"
7785 );
7786 let err_msg = result.unwrap_err().to_string();
7787 assert!(
7788 err_msg.contains("already registered"),
7789 "Error should mention collision: {}",
7790 err_msg
7791 );
7792 }
7793
7794 #[test]
7795 fn test_register_command_same_plugin_allowed() {
7796 let (mut backend, _rx) = create_test_backend();
7797
7798 backend
7800 .execute_js(
7801 r#"
7802 const editor = getEditor();
7803 globalThis.handler1 = function() { };
7804 editor.registerCommand("My Command", "Version 1", "handler1", null);
7805 globalThis.handler2 = function() { };
7806 editor.registerCommand("My Command", "Version 2", "handler2", null);
7807 "#,
7808 "plugin_a.js",
7809 )
7810 .unwrap();
7811 }
7812
7813 #[test]
7814 fn test_register_command_after_unregister() {
7815 let (mut backend, _rx) = create_test_backend();
7816
7817 backend
7819 .execute_js(
7820 r#"
7821 const editor = getEditor();
7822 globalThis.handlerA = function() { };
7823 editor.registerCommand("My Command", "From A", "handlerA", null);
7824 editor.unregisterCommand("My Command");
7825 "#,
7826 "plugin_a.js",
7827 )
7828 .unwrap();
7829
7830 backend
7832 .execute_js(
7833 r#"
7834 const editor = getEditor();
7835 globalThis.handlerB = function() { };
7836 editor.registerCommand("My Command", "From B", "handlerB", null);
7837 "#,
7838 "plugin_b.js",
7839 )
7840 .unwrap();
7841 }
7842
7843 #[test]
7844 fn test_register_command_collision_caught_in_try_catch() {
7845 let (mut backend, _rx) = create_test_backend();
7846
7847 backend
7849 .execute_js(
7850 r#"
7851 const editor = getEditor();
7852 globalThis.handlerA = function() { };
7853 editor.registerCommand("My Command", "From A", "handlerA", null);
7854 "#,
7855 "plugin_a.js",
7856 )
7857 .unwrap();
7858
7859 backend
7861 .execute_js(
7862 r#"
7863 const editor = getEditor();
7864 globalThis.handlerB = function() { };
7865 let caught = false;
7866 try {
7867 editor.registerCommand("My Command", "From B", "handlerB", null);
7868 } catch (e) {
7869 caught = true;
7870 }
7871 if (!caught) throw new Error("Expected collision error");
7872 "#,
7873 "plugin_b.js",
7874 )
7875 .unwrap();
7876 }
7877
7878 #[test]
7879 fn test_register_command_i18n_key_no_collision_across_plugins() {
7880 let (mut backend, _rx) = create_test_backend();
7881
7882 backend
7884 .execute_js(
7885 r#"
7886 const editor = getEditor();
7887 globalThis.handlerA = function() { };
7888 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
7889 "#,
7890 "plugin_a.js",
7891 )
7892 .unwrap();
7893
7894 backend
7897 .execute_js(
7898 r#"
7899 const editor = getEditor();
7900 globalThis.handlerB = function() { };
7901 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
7902 "#,
7903 "plugin_b.js",
7904 )
7905 .unwrap();
7906 }
7907
7908 #[test]
7909 fn test_register_command_non_i18n_still_collides() {
7910 let (mut backend, _rx) = create_test_backend();
7911
7912 backend
7914 .execute_js(
7915 r#"
7916 const editor = getEditor();
7917 globalThis.handlerA = function() { };
7918 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
7919 "#,
7920 "plugin_a.js",
7921 )
7922 .unwrap();
7923
7924 let result = backend.execute_js(
7926 r#"
7927 const editor = getEditor();
7928 globalThis.handlerB = function() { };
7929 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
7930 "#,
7931 "plugin_b.js",
7932 );
7933
7934 assert!(
7935 result.is_err(),
7936 "Non-%-prefixed names should still collide across plugins"
7937 );
7938 }
7939}