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