fresh_plugin_runtime/backend/
quickjs_backend.rs

1//! QuickJS JavaScript runtime backend for TypeScript plugins
2//!
3//! This module provides a JavaScript runtime using QuickJS for executing
4//! TypeScript plugins. TypeScript is transpiled to JavaScript using oxc.
5
6use anyhow::{anyhow, Result};
7use fresh_core::api::{
8    ActionPopupAction, ActionSpec, BufferInfo, EditorStateSnapshot, JsCallbackId, PluginCommand,
9    PluginResponse,
10};
11use fresh_core::command::Command;
12use fresh_core::overlay::OverlayNamespace;
13use fresh_core::text_property::TextPropertyEntry;
14use fresh_core::{BufferId, SplitId};
15use fresh_parser_js::{
16    bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
17    transpile_typescript,
18};
19use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
20use rquickjs::{Context, Function, Object, Runtime, Value};
21use std::cell::RefCell;
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24use std::rc::Rc;
25use std::sync::{mpsc, Arc, RwLock};
26
27/// Convert a QuickJS Value to serde_json::Value
28fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
29    use rquickjs::Type;
30    match val.type_of() {
31        Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
32        Type::Bool => val
33            .as_bool()
34            .map(serde_json::Value::Bool)
35            .unwrap_or(serde_json::Value::Null),
36        Type::Int => val
37            .as_int()
38            .map(|n| serde_json::Value::Number(n.into()))
39            .unwrap_or(serde_json::Value::Null),
40        Type::Float => val
41            .as_float()
42            .and_then(|f| serde_json::Number::from_f64(f))
43            .map(serde_json::Value::Number)
44            .unwrap_or(serde_json::Value::Null),
45        Type::String => val
46            .as_string()
47            .and_then(|s| s.to_string().ok())
48            .map(serde_json::Value::String)
49            .unwrap_or(serde_json::Value::Null),
50        Type::Array => {
51            if let Some(arr) = val.as_array() {
52                let items: Vec<serde_json::Value> = arr
53                    .iter()
54                    .filter_map(|item| item.ok())
55                    .map(|item| js_to_json(ctx, item))
56                    .collect();
57                serde_json::Value::Array(items)
58            } else {
59                serde_json::Value::Null
60            }
61        }
62        Type::Object | Type::Constructor | Type::Function => {
63            if let Some(obj) = val.as_object() {
64                let mut map = serde_json::Map::new();
65                for key in obj.keys::<String>().flatten() {
66                    if let Ok(v) = obj.get::<_, Value>(&key) {
67                        map.insert(key, js_to_json(ctx, v));
68                    }
69                }
70                serde_json::Value::Object(map)
71            } else {
72                serde_json::Value::Null
73            }
74        }
75        _ => serde_json::Value::Null,
76    }
77}
78
79/// Get text properties at cursor position
80fn get_text_properties_at_cursor_typed(
81    snapshot: &Arc<RwLock<EditorStateSnapshot>>,
82    buffer_id: u32,
83) -> fresh_core::api::TextPropertiesAtCursor {
84    use fresh_core::api::TextPropertiesAtCursor;
85
86    let snap = match snapshot.read() {
87        Ok(s) => s,
88        Err(_) => return TextPropertiesAtCursor(Vec::new()),
89    };
90    let buffer_id_typed = BufferId(buffer_id as usize);
91    let cursor_pos = match snap
92        .buffer_cursor_positions
93        .get(&buffer_id_typed)
94        .copied()
95        .or_else(|| {
96            if snap.active_buffer_id == buffer_id_typed {
97                snap.primary_cursor.as_ref().map(|c| c.position)
98            } else {
99                None
100            }
101        }) {
102        Some(pos) => pos,
103        None => return TextPropertiesAtCursor(Vec::new()),
104    };
105
106    let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
107        Some(p) => p,
108        None => return TextPropertiesAtCursor(Vec::new()),
109    };
110
111    // Find all properties at cursor position
112    let result: Vec<_> = properties
113        .iter()
114        .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
115        .map(|prop| prop.properties.clone())
116        .collect();
117
118    TextPropertiesAtCursor(result)
119}
120
121/// Convert a JavaScript value to a string representation for console output
122fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
123    use rquickjs::Type;
124    match val.type_of() {
125        Type::Null => "null".to_string(),
126        Type::Undefined => "undefined".to_string(),
127        Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
128        Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
129        Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
130        Type::String => val
131            .as_string()
132            .and_then(|s| s.to_string().ok())
133            .unwrap_or_default(),
134        Type::Object | Type::Exception => {
135            // Check if this is an Error object (has message/stack properties)
136            if let Some(obj) = val.as_object() {
137                // Try to get error properties
138                let name: Option<String> = obj.get("name").ok();
139                let message: Option<String> = obj.get("message").ok();
140                let stack: Option<String> = obj.get("stack").ok();
141
142                if message.is_some() || name.is_some() {
143                    // This looks like an Error object
144                    let name = name.unwrap_or_else(|| "Error".to_string());
145                    let message = message.unwrap_or_default();
146                    if let Some(stack) = stack {
147                        return format!("{}: {}\n{}", name, message, stack);
148                    } else {
149                        return format!("{}: {}", name, message);
150                    }
151                }
152
153                // Regular object - convert to JSON
154                let json = js_to_json(ctx, val.clone());
155                serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
156            } else {
157                "[object]".to_string()
158            }
159        }
160        Type::Array => {
161            let json = js_to_json(ctx, val.clone());
162            serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
163        }
164        Type::Function | Type::Constructor => "[function]".to_string(),
165        Type::Symbol => "[symbol]".to_string(),
166        Type::BigInt => val
167            .as_big_int()
168            .and_then(|b| b.clone().to_i64().ok())
169            .map(|n| n.to_string())
170            .unwrap_or_else(|| "[bigint]".to_string()),
171        _ => format!("[{}]", val.type_name()),
172    }
173}
174
175/// Format a JavaScript error with full details including stack trace
176fn format_js_error(
177    ctx: &rquickjs::Ctx<'_>,
178    err: rquickjs::Error,
179    source_name: &str,
180) -> anyhow::Error {
181    // Check if this is an exception that we can catch for more details
182    if err.is_exception() {
183        // Try to catch the exception to get the full error object
184        let exc = ctx.catch();
185        if !exc.is_undefined() && !exc.is_null() {
186            // Try to get error message and stack from the exception object
187            if let Some(exc_obj) = exc.as_object() {
188                let message: String = exc_obj
189                    .get::<_, String>("message")
190                    .unwrap_or_else(|_| "Unknown error".to_string());
191                let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
192                let name: String = exc_obj
193                    .get::<_, String>("name")
194                    .unwrap_or_else(|_| "Error".to_string());
195
196                if !stack.is_empty() {
197                    return anyhow::anyhow!(
198                        "JS error in {}: {}: {}\nStack trace:\n{}",
199                        source_name,
200                        name,
201                        message,
202                        stack
203                    );
204                } else {
205                    return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
206                }
207            } else {
208                // Exception is not an object, try to convert to string
209                let exc_str: String = exc
210                    .as_string()
211                    .and_then(|s: &rquickjs::String| s.to_string().ok())
212                    .unwrap_or_else(|| format!("{:?}", exc));
213                return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
214            }
215        }
216    }
217
218    // Fall back to the basic error message
219    anyhow::anyhow!("JS error in {}: {}", source_name, err)
220}
221
222/// Log a JavaScript error with full details
223/// If panic_on_js_errors is enabled, this will panic to surface JS errors immediately
224fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
225    let error = format_js_error(ctx, err, context);
226    tracing::error!("{}", error);
227
228    // When enabled, panic on JS errors to make them visible and fail fast
229    if should_panic_on_js_errors() {
230        panic!("JavaScript error in {}: {}", context, error);
231    }
232}
233
234/// Global flag to panic on JS errors (enabled during testing)
235static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
236    std::sync::atomic::AtomicBool::new(false);
237
238/// Enable panicking on JS errors (call this from test setup)
239pub fn set_panic_on_js_errors(enabled: bool) {
240    PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
241}
242
243/// Check if panic on JS errors is enabled
244fn should_panic_on_js_errors() -> bool {
245    PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
246}
247
248/// Run all pending jobs and check for unhandled exceptions
249/// If panic_on_js_errors is enabled, this will panic on unhandled exceptions
250fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
251    let mut count = 0;
252    loop {
253        // Check for unhandled exception before running more jobs
254        let exc: rquickjs::Value = ctx.catch();
255        // Only treat it as an exception if it's actually an Error object
256        if exc.is_exception() {
257            let error_msg = if let Some(err) = exc.as_exception() {
258                format!(
259                    "{}: {}",
260                    err.message().unwrap_or_default(),
261                    err.stack().unwrap_or_default()
262                )
263            } else {
264                format!("{:?}", exc)
265            };
266            tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
267            if should_panic_on_js_errors() {
268                panic!("Unhandled JS exception during {}: {}", context, error_msg);
269            }
270        }
271
272        if !ctx.execute_pending_job() {
273            break;
274        }
275        count += 1;
276    }
277
278    // Final check for exceptions after all jobs completed
279    let exc: rquickjs::Value = ctx.catch();
280    if exc.is_exception() {
281        let error_msg = if let Some(err) = exc.as_exception() {
282            format!(
283                "{}: {}",
284                err.message().unwrap_or_default(),
285                err.stack().unwrap_or_default()
286            )
287        } else {
288            format!("{:?}", exc)
289        };
290        tracing::error!(
291            "Unhandled JS exception after running jobs in {}: {}",
292            context,
293            error_msg
294        );
295        if should_panic_on_js_errors() {
296            panic!(
297                "Unhandled JS exception after running jobs in {}: {}",
298                context, error_msg
299            );
300        }
301    }
302
303    count
304}
305
306/// Parse a TextPropertyEntry from a JS Object
307fn parse_text_property_entry(
308    ctx: &rquickjs::Ctx<'_>,
309    obj: &Object<'_>,
310) -> Option<TextPropertyEntry> {
311    let text: String = obj.get("text").ok()?;
312    let properties: HashMap<String, serde_json::Value> = obj
313        .get::<_, Object>("properties")
314        .ok()
315        .map(|props_obj| {
316            let mut map = HashMap::new();
317            for key in props_obj.keys::<String>().flatten() {
318                if let Ok(v) = props_obj.get::<_, Value>(&key) {
319                    map.insert(key, js_to_json(ctx, v));
320                }
321            }
322            map
323        })
324        .unwrap_or_default();
325    Some(TextPropertyEntry { text, properties })
326}
327
328/// Pending response senders type alias
329pub type PendingResponses =
330    Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
331
332/// Information about a loaded plugin
333#[derive(Debug, Clone)]
334pub struct TsPluginInfo {
335    pub name: String,
336    pub path: PathBuf,
337    pub enabled: bool,
338}
339
340/// Handler information for events and actions
341#[derive(Debug, Clone)]
342pub struct PluginHandler {
343    pub plugin_name: String,
344    pub handler_name: String,
345}
346
347/// JavaScript-exposed Editor API using rquickjs class system
348/// This allows proper lifetime handling for methods returning JS values
349#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
350#[rquickjs::class]
351pub struct JsEditorApi {
352    #[qjs(skip_trace)]
353    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
354    #[qjs(skip_trace)]
355    command_sender: mpsc::Sender<PluginCommand>,
356    #[qjs(skip_trace)]
357    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
358    #[qjs(skip_trace)]
359    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
360    #[qjs(skip_trace)]
361    next_request_id: Rc<RefCell<u64>>,
362    #[qjs(skip_trace)]
363    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
364    #[qjs(skip_trace)]
365    services: Arc<dyn fresh_core::services::PluginServiceBridge>,
366    pub plugin_name: String,
367}
368
369#[plugin_api_impl]
370#[rquickjs::methods(rename_all = "camelCase")]
371impl JsEditorApi {
372    // === Buffer Queries ===
373
374    /// Get the active buffer ID (0 if none)
375    pub fn get_active_buffer_id(&self) -> u32 {
376        self.state_snapshot
377            .read()
378            .map(|s| s.active_buffer_id.0 as u32)
379            .unwrap_or(0)
380    }
381
382    /// Get the active split ID
383    pub fn get_active_split_id(&self) -> u32 {
384        self.state_snapshot
385            .read()
386            .map(|s| s.active_split_id as u32)
387            .unwrap_or(0)
388    }
389
390    /// List all open buffers - returns array of BufferInfo objects
391    pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
392        let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
393            s.buffers.values().cloned().collect()
394        } else {
395            Vec::new()
396        };
397        rquickjs_serde::to_value(ctx, &buffers)
398            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
399    }
400
401    // === Logging ===
402
403    pub fn debug(&self, msg: String) {
404        tracing::info!("Plugin.debug: {}", msg);
405    }
406
407    pub fn info(&self, msg: String) {
408        tracing::info!("Plugin: {}", msg);
409    }
410
411    pub fn warn(&self, msg: String) {
412        tracing::warn!("Plugin: {}", msg);
413    }
414
415    pub fn error(&self, msg: String) {
416        tracing::error!("Plugin: {}", msg);
417    }
418
419    // === Status ===
420
421    pub fn set_status(&self, msg: String) {
422        let _ = self
423            .command_sender
424            .send(PluginCommand::SetStatus { message: msg });
425    }
426
427    // === Clipboard ===
428
429    pub fn copy_to_clipboard(&self, text: String) {
430        let _ = self
431            .command_sender
432            .send(PluginCommand::SetClipboard { text });
433    }
434
435    pub fn set_clipboard(&self, text: String) {
436        let _ = self
437            .command_sender
438            .send(PluginCommand::SetClipboard { text: text });
439    }
440
441    // === Command Registration ===
442
443    /// Register a command - reads plugin name from __pluginName__ global
444    /// context is optional - can be omitted, null, undefined, or a string
445    pub fn register_command<'js>(
446        &self,
447        _ctx: rquickjs::Ctx<'js>,
448        name: String,
449        description: String,
450        handler_name: String,
451        context: rquickjs::function::Opt<rquickjs::Value<'js>>,
452    ) -> rquickjs::Result<bool> {
453        // Use stored plugin name instead of global lookup
454        let plugin_name = self.plugin_name.clone();
455        // Extract context string - handle null, undefined, or missing
456        let context_str: Option<String> = context.0.and_then(|v| {
457            if v.is_null() || v.is_undefined() {
458                None
459            } else {
460                v.as_string().and_then(|s| s.to_string().ok())
461            }
462        });
463
464        tracing::debug!(
465            "registerCommand: plugin='{}', name='{}', handler='{}'",
466            plugin_name,
467            name,
468            handler_name
469        );
470
471        // Store action handler mapping with its plugin name
472        self.registered_actions.borrow_mut().insert(
473            handler_name.clone(),
474            PluginHandler {
475                plugin_name: self.plugin_name.clone(),
476                handler_name: handler_name.clone(),
477            },
478        );
479
480        // Register with editor
481        let command = Command {
482            name: name.clone(),
483            description,
484            action_name: handler_name,
485            plugin_name,
486            custom_contexts: context_str.into_iter().collect(),
487        };
488
489        Ok(self
490            .command_sender
491            .send(PluginCommand::RegisterCommand { command })
492            .is_ok())
493    }
494
495    /// Unregister a command by name
496    pub fn unregister_command(&self, name: String) -> bool {
497        self.command_sender
498            .send(PluginCommand::UnregisterCommand { name })
499            .is_ok()
500    }
501
502    /// Set a context (for keybinding conditions)
503    pub fn set_context(&self, name: String, active: bool) -> bool {
504        self.command_sender
505            .send(PluginCommand::SetContext { name, active })
506            .is_ok()
507    }
508
509    /// Execute a built-in action
510    pub fn execute_action(&self, action_name: String) -> bool {
511        self.command_sender
512            .send(PluginCommand::ExecuteAction { action_name })
513            .is_ok()
514    }
515
516    // === Translation ===
517
518    /// Translate a string - reads plugin name from __pluginName__ global
519    /// Args is optional - can be omitted, undefined, null, or an object
520    pub fn t<'js>(
521        &self,
522        _ctx: rquickjs::Ctx<'js>,
523        key: String,
524        args: rquickjs::function::Rest<Value<'js>>,
525    ) -> String {
526        // Use stored plugin name instead of global lookup
527        let plugin_name = self.plugin_name.clone();
528        // Convert args to HashMap - args.0 is a Vec of the rest arguments
529        let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
530            if let Some(obj) = first_arg.as_object() {
531                let mut map = HashMap::new();
532                for key_result in obj.keys::<String>() {
533                    if let Ok(k) = key_result {
534                        if let Ok(v) = obj.get::<_, String>(&k) {
535                            map.insert(k, v);
536                        }
537                    }
538                }
539                map
540            } else {
541                HashMap::new()
542            }
543        } else {
544            HashMap::new()
545        };
546        let res = self.services.translate(&plugin_name, &key, &args_map);
547
548        tracing::info!(
549            "Translating: key={}, plugin={}, args={:?} => res='{}'",
550            key,
551            plugin_name,
552            args_map,
553            res
554        );
555        res
556    }
557
558    // === Buffer Queries (additional) ===
559
560    /// Get cursor position in active buffer
561    pub fn get_cursor_position(&self) -> u32 {
562        self.state_snapshot
563            .read()
564            .ok()
565            .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
566            .unwrap_or(0)
567    }
568
569    /// Get file path for a buffer
570    pub fn get_buffer_path(&self, buffer_id: u32) -> String {
571        if let Ok(s) = self.state_snapshot.read() {
572            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
573                if let Some(p) = &b.path {
574                    return p.to_string_lossy().to_string();
575                }
576            }
577        }
578        String::new()
579    }
580
581    /// Get buffer length in bytes
582    pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
583        if let Ok(s) = self.state_snapshot.read() {
584            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
585                return b.length as u32;
586            }
587        }
588        0
589    }
590
591    /// Check if buffer has unsaved changes
592    pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
593        if let Ok(s) = self.state_snapshot.read() {
594            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
595                return b.modified;
596            }
597        }
598        false
599    }
600
601    /// Get buffer info by ID
602    pub fn get_buffer_info<'js>(
603        &self,
604        ctx: rquickjs::Ctx<'js>,
605        buffer_id: u32,
606    ) -> rquickjs::Result<Value<'js>> {
607        let info = if let Ok(s) = self.state_snapshot.read() {
608            s.buffers.get(&BufferId(buffer_id as usize)).cloned()
609        } else {
610            None
611        };
612        rquickjs_serde::to_value(ctx, &info)
613            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
614    }
615
616    /// Get primary cursor info for active buffer
617    pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
618        let cursor = if let Ok(s) = self.state_snapshot.read() {
619            s.primary_cursor.clone()
620        } else {
621            None
622        };
623        rquickjs_serde::to_value(ctx, &cursor)
624            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
625    }
626
627    /// Get all cursors for active buffer
628    pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
629        let cursors = if let Ok(s) = self.state_snapshot.read() {
630            s.all_cursors.clone()
631        } else {
632            Vec::new()
633        };
634        rquickjs_serde::to_value(ctx, &cursors)
635            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
636    }
637
638    /// Get all cursor positions as byte offsets
639    pub fn get_all_cursor_positions<'js>(
640        &self,
641        ctx: rquickjs::Ctx<'js>,
642    ) -> rquickjs::Result<Value<'js>> {
643        let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
644            s.all_cursors.iter().map(|c| c.position as u32).collect()
645        } else {
646            Vec::new()
647        };
648        rquickjs_serde::to_value(ctx, &positions)
649            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
650    }
651
652    /// Get viewport info for active buffer
653    pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
654        let viewport = if let Ok(s) = self.state_snapshot.read() {
655            s.viewport.clone()
656        } else {
657            None
658        };
659        rquickjs_serde::to_value(ctx, &viewport)
660            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
661    }
662
663    /// Get the line number (0-indexed) of the primary cursor
664    pub fn get_cursor_line(&self) -> u32 {
665        // This would require line counting from the buffer
666        // For now, return 0 - proper implementation needs buffer access
667        // TODO: Add line number tracking to EditorStateSnapshot
668        0
669    }
670
671    /// Find buffer by file path, returns buffer ID or 0 if not found
672    pub fn find_buffer_by_path(&self, path: String) -> u32 {
673        let path_buf = std::path::PathBuf::from(&path);
674        if let Ok(s) = self.state_snapshot.read() {
675            for (id, info) in &s.buffers {
676                if let Some(buf_path) = &info.path {
677                    if buf_path == &path_buf {
678                        return id.0 as u32;
679                    }
680                }
681            }
682        }
683        0
684    }
685
686    /// Get diff between buffer content and last saved version
687    #[plugin_api(ts_return = "BufferSavedDiff | null")]
688    pub fn get_buffer_saved_diff<'js>(
689        &self,
690        ctx: rquickjs::Ctx<'js>,
691        buffer_id: u32,
692    ) -> rquickjs::Result<Value<'js>> {
693        let diff = if let Ok(s) = self.state_snapshot.read() {
694            s.buffer_saved_diffs
695                .get(&BufferId(buffer_id as usize))
696                .cloned()
697        } else {
698            None
699        };
700        rquickjs_serde::to_value(ctx, &diff)
701            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
702    }
703
704    // === Text Editing ===
705
706    /// Insert text at a position in a buffer
707    pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
708        self.command_sender
709            .send(PluginCommand::InsertText {
710                buffer_id: BufferId(buffer_id as usize),
711                position: position as usize,
712                text,
713            })
714            .is_ok()
715    }
716
717    /// Delete a range from a buffer
718    pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
719        self.command_sender
720            .send(PluginCommand::DeleteRange {
721                buffer_id: BufferId(buffer_id as usize),
722                range: (start as usize)..(end as usize),
723            })
724            .is_ok()
725    }
726
727    /// Insert text at cursor position in active buffer
728    pub fn insert_at_cursor(&self, text: String) -> bool {
729        self.command_sender
730            .send(PluginCommand::InsertAtCursor { text })
731            .is_ok()
732    }
733
734    // === File Operations ===
735
736    /// Open a file, optionally at a specific line/column
737    pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
738        self.command_sender
739            .send(PluginCommand::OpenFileAtLocation {
740                path: PathBuf::from(path),
741                line: line.map(|l| l as usize),
742                column: column.map(|c| c as usize),
743            })
744            .is_ok()
745    }
746
747    /// Open a file in a specific split
748    pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
749        self.command_sender
750            .send(PluginCommand::OpenFileInSplit {
751                split_id: split_id as usize,
752                path: PathBuf::from(path),
753                line: Some(line as usize),
754                column: Some(column as usize),
755            })
756            .is_ok()
757    }
758
759    /// Show a buffer in the current split
760    pub fn show_buffer(&self, buffer_id: u32) -> bool {
761        self.command_sender
762            .send(PluginCommand::ShowBuffer {
763                buffer_id: BufferId(buffer_id as usize),
764            })
765            .is_ok()
766    }
767
768    /// Close a buffer
769    pub fn close_buffer(&self, buffer_id: u32) -> bool {
770        self.command_sender
771            .send(PluginCommand::CloseBuffer {
772                buffer_id: BufferId(buffer_id as usize),
773            })
774            .is_ok()
775    }
776
777    // === Event Handling ===
778
779    /// Subscribe to an editor event
780    pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
781        self.event_handlers
782            .borrow_mut()
783            .entry(event_name)
784            .or_default()
785            .push(PluginHandler {
786                plugin_name: self.plugin_name.clone(),
787                handler_name,
788            });
789    }
790
791    /// Unsubscribe from an event
792    pub fn off(&self, event_name: String, handler_name: String) {
793        if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
794            list.retain(|h| h.handler_name != handler_name);
795        }
796    }
797
798    // === Environment ===
799
800    /// Get an environment variable
801    pub fn get_env(&self, name: String) -> Option<String> {
802        std::env::var(&name).ok()
803    }
804
805    /// Get current working directory
806    pub fn get_cwd(&self) -> String {
807        self.state_snapshot
808            .read()
809            .map(|s| s.working_dir.to_string_lossy().to_string())
810            .unwrap_or_else(|_| ".".to_string())
811    }
812
813    // === Path Operations ===
814
815    /// Join path components (variadic - accepts multiple string arguments)
816    /// Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
817    pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
818        let mut result_parts: Vec<String> = Vec::new();
819        let mut has_leading_slash = false;
820
821        for part in &parts.0 {
822            // Normalize separators to forward slashes
823            let normalized = part.replace('\\', "/");
824
825            // Check if this is an absolute path (starts with / or has drive letter like C:/)
826            let is_absolute = normalized.starts_with('/')
827                || (normalized.len() >= 2
828                    && normalized
829                        .chars()
830                        .next()
831                        .map(|c| c.is_ascii_alphabetic())
832                        .unwrap_or(false)
833                    && normalized.chars().nth(1) == Some(':'));
834
835            if is_absolute {
836                // Reset for absolute paths
837                result_parts.clear();
838                has_leading_slash = normalized.starts_with('/');
839            }
840
841            // Split and add non-empty parts
842            for segment in normalized.split('/') {
843                if !segment.is_empty() && segment != "." {
844                    if segment == ".." {
845                        result_parts.pop();
846                    } else {
847                        result_parts.push(segment.to_string());
848                    }
849                }
850            }
851        }
852
853        // Reconstruct with forward slashes
854        let joined = result_parts.join("/");
855
856        // Preserve leading slash for Unix absolute paths
857        if has_leading_slash && !joined.is_empty() {
858            format!("/{}", joined)
859        } else {
860            joined
861        }
862    }
863
864    /// Get directory name from path
865    pub fn path_dirname(&self, path: String) -> String {
866        Path::new(&path)
867            .parent()
868            .map(|p| p.to_string_lossy().to_string())
869            .unwrap_or_default()
870    }
871
872    /// Get file name from path
873    pub fn path_basename(&self, path: String) -> String {
874        Path::new(&path)
875            .file_name()
876            .map(|s| s.to_string_lossy().to_string())
877            .unwrap_or_default()
878    }
879
880    /// Get file extension
881    pub fn path_extname(&self, path: String) -> String {
882        Path::new(&path)
883            .extension()
884            .map(|s| format!(".{}", s.to_string_lossy()))
885            .unwrap_or_default()
886    }
887
888    /// Check if path is absolute
889    pub fn path_is_absolute(&self, path: String) -> bool {
890        Path::new(&path).is_absolute()
891    }
892
893    // === File System ===
894
895    /// Check if file exists
896    pub fn file_exists(&self, path: String) -> bool {
897        Path::new(&path).exists()
898    }
899
900    /// Read file contents
901    pub fn read_file(&self, path: String) -> Option<String> {
902        std::fs::read_to_string(&path).ok()
903    }
904
905    /// Write file contents
906    pub fn write_file(&self, path: String, content: String) -> bool {
907        std::fs::write(&path, content).is_ok()
908    }
909
910    /// Read directory contents (returns array of {name, is_file, is_dir})
911    pub fn read_dir<'js>(
912        &self,
913        ctx: rquickjs::Ctx<'js>,
914        path: String,
915    ) -> rquickjs::Result<Value<'js>> {
916        #[derive(serde::Serialize)]
917        struct DirEntry {
918            name: String,
919            is_file: bool,
920            is_dir: bool,
921        }
922
923        let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
924            Ok(entries) => entries
925                .filter_map(|e| e.ok())
926                .map(|entry| {
927                    let file_type = entry.file_type().ok();
928                    DirEntry {
929                        name: entry.file_name().to_string_lossy().to_string(),
930                        is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
931                        is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
932                    }
933                })
934                .collect(),
935            Err(e) => {
936                tracing::warn!("readDir failed for '{}': {}", path, e);
937                Vec::new()
938            }
939        };
940
941        rquickjs_serde::to_value(ctx, &entries)
942            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
943    }
944
945    // === Config ===
946
947    /// Get current config as JS object
948    pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
949        let config: serde_json::Value = self
950            .state_snapshot
951            .read()
952            .map(|s| s.config.clone())
953            .unwrap_or_else(|_| serde_json::json!({}));
954
955        rquickjs_serde::to_value(ctx, &config)
956            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
957    }
958
959    /// Get user config as JS object
960    pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
961        let config: serde_json::Value = self
962            .state_snapshot
963            .read()
964            .map(|s| s.user_config.clone())
965            .unwrap_or_else(|_| serde_json::json!({}));
966
967        rquickjs_serde::to_value(ctx, &config)
968            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
969    }
970
971    /// Reload configuration from file
972    pub fn reload_config(&self) {
973        let _ = self.command_sender.send(PluginCommand::ReloadConfig);
974    }
975
976    /// Get config directory path
977    pub fn get_config_dir(&self) -> String {
978        self.services.config_dir().to_string_lossy().to_string()
979    }
980
981    /// Get themes directory path
982    pub fn get_themes_dir(&self) -> String {
983        self.services
984            .config_dir()
985            .join("themes")
986            .to_string_lossy()
987            .to_string()
988    }
989
990    /// Apply a theme by name
991    pub fn apply_theme(&self, theme_name: String) -> bool {
992        self.command_sender
993            .send(PluginCommand::ApplyTheme { theme_name })
994            .is_ok()
995    }
996
997    /// Get theme schema as JS object
998    pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
999        let schema = self.services.get_theme_schema();
1000        rquickjs_serde::to_value(ctx, &schema)
1001            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1002    }
1003
1004    /// Get list of builtin themes as JS object
1005    pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1006        let themes = self.services.get_builtin_themes();
1007        rquickjs_serde::to_value(ctx, &themes)
1008            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1009    }
1010
1011    /// Delete a custom theme file (sync)
1012    #[qjs(rename = "_deleteThemeSync")]
1013    pub fn delete_theme_sync(&self, name: String) -> bool {
1014        // Security: only allow deleting from the themes directory
1015        let themes_dir = self.services.config_dir().join("themes");
1016        let theme_path = themes_dir.join(format!("{}.json", name));
1017
1018        // Verify the file is actually in the themes directory (prevent path traversal)
1019        if let Ok(canonical) = theme_path.canonicalize() {
1020            if let Ok(themes_canonical) = themes_dir.canonicalize() {
1021                if canonical.starts_with(&themes_canonical) {
1022                    return std::fs::remove_file(&canonical).is_ok();
1023                }
1024            }
1025        }
1026        false
1027    }
1028
1029    /// Delete a custom theme (alias for deleteThemeSync)
1030    pub fn delete_theme(&self, name: String) -> bool {
1031        self.delete_theme_sync(name)
1032    }
1033
1034    // === File Stats ===
1035
1036    /// Get file stat information
1037    pub fn file_stat<'js>(
1038        &self,
1039        ctx: rquickjs::Ctx<'js>,
1040        path: String,
1041    ) -> rquickjs::Result<Value<'js>> {
1042        let metadata = std::fs::metadata(&path).ok();
1043        let stat = metadata.map(|m| {
1044            serde_json::json!({
1045                "isFile": m.is_file(),
1046                "isDir": m.is_dir(),
1047                "size": m.len(),
1048                "readonly": m.permissions().readonly(),
1049            })
1050        });
1051        rquickjs_serde::to_value(ctx, &stat)
1052            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1053    }
1054
1055    // === Process Management ===
1056
1057    /// Check if a background process is still running
1058    pub fn is_process_running(&self, _process_id: u64) -> bool {
1059        // This would need to check against tracked processes
1060        // For now, return false - proper implementation needs process tracking
1061        false
1062    }
1063
1064    /// Kill a process by ID (alias for killBackgroundProcess)
1065    pub fn kill_process(&self, process_id: u64) -> bool {
1066        self.command_sender
1067            .send(PluginCommand::KillBackgroundProcess { process_id })
1068            .is_ok()
1069    }
1070
1071    // === Translation ===
1072
1073    /// Translate a key for a specific plugin
1074    pub fn plugin_translate<'js>(
1075        &self,
1076        _ctx: rquickjs::Ctx<'js>,
1077        plugin_name: String,
1078        key: String,
1079        args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1080    ) -> String {
1081        let args_map: HashMap<String, String> = args
1082            .0
1083            .map(|obj| {
1084                let mut map = HashMap::new();
1085                for result in obj.props::<String, String>() {
1086                    if let Ok((k, v)) = result {
1087                        map.insert(k, v);
1088                    }
1089                }
1090                map
1091            })
1092            .unwrap_or_default();
1093
1094        self.services.translate(&plugin_name, &key, &args_map)
1095    }
1096
1097    // === Composite Buffers ===
1098
1099    /// Create a composite buffer (async)
1100    #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1101    #[qjs(rename = "_createCompositeBufferStart")]
1102    pub fn create_composite_buffer_start<'js>(
1103        &self,
1104        _ctx: rquickjs::Ctx<'js>,
1105        opts: rquickjs::Object<'js>,
1106    ) -> rquickjs::Result<u64> {
1107        use fresh_core::api::{
1108            CompositeHunk, CompositeLayoutConfig, CompositePaneStyle, CompositeSourceConfig,
1109        };
1110
1111        let id = {
1112            let mut id_ref = self.next_request_id.borrow_mut();
1113            let id = *id_ref;
1114            *id_ref += 1;
1115            // Record context for this callback
1116            self.callback_contexts
1117                .borrow_mut()
1118                .insert(id, self.plugin_name.clone());
1119            id
1120        };
1121
1122        let name: String = opts.get("name").unwrap_or_default();
1123        let mode: String = opts.get("mode").unwrap_or_default();
1124
1125        // Parse layout
1126        let layout_obj: rquickjs::Object = opts.get("layout")?;
1127        let layout = CompositeLayoutConfig {
1128            layout_type: layout_obj
1129                .get("type")
1130                .unwrap_or_else(|_| "side-by-side".to_string()),
1131            ratios: layout_obj.get("ratios").ok(),
1132            show_separator: layout_obj.get("showSeparator").unwrap_or(true),
1133            spacing: layout_obj.get("spacing").ok(),
1134        };
1135
1136        // Parse sources
1137        let sources_arr: Vec<rquickjs::Object> = opts.get("sources").unwrap_or_default();
1138        let sources: Vec<CompositeSourceConfig> = sources_arr
1139            .into_iter()
1140            .map(|obj| {
1141                let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
1142                let style = style_obj.map(|s| CompositePaneStyle {
1143                    add_bg: None,
1144                    remove_bg: None,
1145                    modify_bg: None,
1146                    gutter_style: s.get("gutterStyle").ok(),
1147                });
1148                CompositeSourceConfig {
1149                    buffer_id: obj.get::<_, usize>("bufferId").unwrap_or(0),
1150                    label: obj.get("label").unwrap_or_default(),
1151                    editable: obj.get("editable").unwrap_or(false),
1152                    style,
1153                }
1154            })
1155            .collect();
1156
1157        // Parse hunks (optional)
1158        let hunks: Option<Vec<CompositeHunk>> = opts
1159            .get::<_, Vec<rquickjs::Object>>("hunks")
1160            .ok()
1161            .map(|arr| {
1162                arr.into_iter()
1163                    .map(|obj| CompositeHunk {
1164                        old_start: obj.get("oldStart").unwrap_or(0),
1165                        old_count: obj.get("oldCount").unwrap_or(0),
1166                        new_start: obj.get("newStart").unwrap_or(0),
1167                        new_count: obj.get("newCount").unwrap_or(0),
1168                    })
1169                    .collect()
1170            });
1171
1172        let _ = self
1173            .command_sender
1174            .send(PluginCommand::CreateCompositeBuffer {
1175                name,
1176                mode,
1177                layout,
1178                sources,
1179                hunks,
1180                request_id: Some(id),
1181            });
1182
1183        Ok(id)
1184    }
1185
1186    /// Update alignment hunks for a composite buffer
1187    pub fn update_composite_alignment<'js>(
1188        &self,
1189        _ctx: rquickjs::Ctx<'js>,
1190        buffer_id: u32,
1191        hunks: Vec<rquickjs::Object<'js>>,
1192    ) -> rquickjs::Result<bool> {
1193        use fresh_core::api::CompositeHunk;
1194
1195        let hunks: Vec<CompositeHunk> = hunks
1196            .into_iter()
1197            .map(|obj| CompositeHunk {
1198                old_start: obj.get("oldStart").unwrap_or(0),
1199                old_count: obj.get("oldCount").unwrap_or(0),
1200                new_start: obj.get("newStart").unwrap_or(0),
1201                new_count: obj.get("newCount").unwrap_or(0),
1202            })
1203            .collect();
1204
1205        Ok(self
1206            .command_sender
1207            .send(PluginCommand::UpdateCompositeAlignment {
1208                buffer_id: BufferId(buffer_id as usize),
1209                hunks,
1210            })
1211            .is_ok())
1212    }
1213
1214    /// Close a composite buffer
1215    pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1216        self.command_sender
1217            .send(PluginCommand::CloseCompositeBuffer {
1218                buffer_id: BufferId(buffer_id as usize),
1219            })
1220            .is_ok()
1221    }
1222
1223    // === Highlights ===
1224
1225    /// Request syntax highlights for a buffer range (async)
1226    #[plugin_api(
1227        async_promise,
1228        js_name = "getHighlights",
1229        ts_return = "TsHighlightSpan[]"
1230    )]
1231    #[qjs(rename = "_getHighlightsStart")]
1232    pub fn get_highlights_start<'js>(
1233        &self,
1234        _ctx: rquickjs::Ctx<'js>,
1235        buffer_id: u32,
1236        start: u32,
1237        end: u32,
1238    ) -> rquickjs::Result<u64> {
1239        let id = {
1240            let mut id_ref = self.next_request_id.borrow_mut();
1241            let id = *id_ref;
1242            *id_ref += 1;
1243            // Record plugin name for this callback
1244            self.callback_contexts
1245                .borrow_mut()
1246                .insert(id, self.plugin_name.clone());
1247            id
1248        };
1249
1250        let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1251            buffer_id: BufferId(buffer_id as usize),
1252            range: (start as usize)..(end as usize),
1253            request_id: id,
1254        });
1255
1256        Ok(id)
1257    }
1258
1259    // === Overlays ===
1260
1261    /// Add an overlay with styling
1262    #[allow(clippy::too_many_arguments)]
1263    pub fn add_overlay(
1264        &self,
1265        buffer_id: u32,
1266        namespace: String,
1267        start: u32,
1268        end: u32,
1269        r: i32,
1270        g: i32,
1271        b: i32,
1272        underline: rquickjs::function::Opt<bool>,
1273        bold: rquickjs::function::Opt<bool>,
1274        italic: rquickjs::function::Opt<bool>,
1275        bg_r: rquickjs::function::Opt<i32>,
1276        bg_g: rquickjs::function::Opt<i32>,
1277        bg_b: rquickjs::function::Opt<i32>,
1278        extend_to_line_end: rquickjs::function::Opt<bool>,
1279    ) -> bool {
1280        // -1 means use default color (white)
1281        let color = if r >= 0 && g >= 0 && b >= 0 {
1282            (r as u8, g as u8, b as u8)
1283        } else {
1284            (255, 255, 255)
1285        };
1286
1287        // -1 for bg means no background, also None if not provided
1288        let bg_r = bg_r.0.unwrap_or(-1);
1289        let bg_g = bg_g.0.unwrap_or(-1);
1290        let bg_b = bg_b.0.unwrap_or(-1);
1291        let bg_color = if bg_r >= 0 && bg_g >= 0 && bg_b >= 0 {
1292            Some((bg_r as u8, bg_g as u8, bg_b as u8))
1293        } else {
1294            None
1295        };
1296
1297        self.command_sender
1298            .send(PluginCommand::AddOverlay {
1299                buffer_id: BufferId(buffer_id as usize),
1300                namespace: Some(OverlayNamespace::from_string(namespace)),
1301                range: (start as usize)..(end as usize),
1302                color,
1303                bg_color,
1304                underline: underline.0.unwrap_or(false),
1305                bold: bold.0.unwrap_or(false),
1306                italic: italic.0.unwrap_or(false),
1307                extend_to_line_end: extend_to_line_end.0.unwrap_or(false),
1308            })
1309            .is_ok()
1310    }
1311
1312    /// Clear all overlays in a namespace
1313    pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1314        self.command_sender
1315            .send(PluginCommand::ClearNamespace {
1316                buffer_id: BufferId(buffer_id as usize),
1317                namespace: OverlayNamespace::from_string(namespace),
1318            })
1319            .is_ok()
1320    }
1321
1322    /// Clear all overlays from a buffer
1323    pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1324        self.command_sender
1325            .send(PluginCommand::ClearAllOverlays {
1326                buffer_id: BufferId(buffer_id as usize),
1327            })
1328            .is_ok()
1329    }
1330
1331    /// Clear all overlays that overlap with a byte range
1332    pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1333        self.command_sender
1334            .send(PluginCommand::ClearOverlaysInRange {
1335                buffer_id: BufferId(buffer_id as usize),
1336                start: start as usize,
1337                end: end as usize,
1338            })
1339            .is_ok()
1340    }
1341
1342    /// Remove an overlay by its handle
1343    pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1344        use fresh_core::overlay::OverlayHandle;
1345        self.command_sender
1346            .send(PluginCommand::RemoveOverlay {
1347                buffer_id: BufferId(buffer_id as usize),
1348                handle: OverlayHandle(handle),
1349            })
1350            .is_ok()
1351    }
1352
1353    // === View Transform ===
1354
1355    /// Submit a view transform for a buffer/split
1356    #[allow(clippy::too_many_arguments)]
1357    pub fn submit_view_transform<'js>(
1358        &self,
1359        _ctx: rquickjs::Ctx<'js>,
1360        buffer_id: u32,
1361        split_id: Option<u32>,
1362        start: u32,
1363        end: u32,
1364        tokens: Vec<rquickjs::Object<'js>>,
1365        _layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1366    ) -> rquickjs::Result<bool> {
1367        use fresh_core::api::{
1368            ViewTokenStyle, ViewTokenWire, ViewTokenWireKind, ViewTransformPayload,
1369        };
1370
1371        let tokens: Vec<ViewTokenWire> = tokens
1372            .into_iter()
1373            .map(|obj| {
1374                let kind_str: String = obj.get("kind").unwrap_or_default();
1375                let text: String = obj.get("text").unwrap_or_default();
1376                let source_offset: Option<usize> = obj.get("sourceOffset").ok();
1377
1378                let kind = match kind_str.as_str() {
1379                    "text" => ViewTokenWireKind::Text(text),
1380                    "newline" => ViewTokenWireKind::Newline,
1381                    "space" => ViewTokenWireKind::Space,
1382                    "break" => ViewTokenWireKind::Break,
1383                    _ => ViewTokenWireKind::Text(text),
1384                };
1385
1386                let style = obj.get::<_, rquickjs::Object>("style").ok().map(|s| {
1387                    let fg: Option<Vec<u8>> = s.get("fg").ok();
1388                    let bg: Option<Vec<u8>> = s.get("bg").ok();
1389                    ViewTokenStyle {
1390                        fg: fg.and_then(|c| {
1391                            if c.len() >= 3 {
1392                                Some((c[0], c[1], c[2]))
1393                            } else {
1394                                None
1395                            }
1396                        }),
1397                        bg: bg.and_then(|c| {
1398                            if c.len() >= 3 {
1399                                Some((c[0], c[1], c[2]))
1400                            } else {
1401                                None
1402                            }
1403                        }),
1404                        bold: s.get("bold").unwrap_or(false),
1405                        italic: s.get("italic").unwrap_or(false),
1406                    }
1407                });
1408
1409                ViewTokenWire {
1410                    source_offset,
1411                    kind,
1412                    style,
1413                }
1414            })
1415            .collect();
1416
1417        let payload = ViewTransformPayload {
1418            range: (start as usize)..(end as usize),
1419            tokens,
1420            layout_hints: None,
1421        };
1422
1423        Ok(self
1424            .command_sender
1425            .send(PluginCommand::SubmitViewTransform {
1426                buffer_id: BufferId(buffer_id as usize),
1427                split_id: split_id.map(|id| SplitId(id as usize)),
1428                payload,
1429            })
1430            .is_ok())
1431    }
1432
1433    /// Clear view transform for a buffer/split
1434    pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1435        self.command_sender
1436            .send(PluginCommand::ClearViewTransform {
1437                buffer_id: BufferId(buffer_id as usize),
1438                split_id: split_id.map(|id| SplitId(id as usize)),
1439            })
1440            .is_ok()
1441    }
1442
1443    // === File Explorer ===
1444
1445    /// Set file explorer decorations for a namespace
1446    pub fn set_file_explorer_decorations<'js>(
1447        &self,
1448        _ctx: rquickjs::Ctx<'js>,
1449        namespace: String,
1450        decorations: Vec<rquickjs::Object<'js>>,
1451    ) -> rquickjs::Result<bool> {
1452        use fresh_core::file_explorer::FileExplorerDecoration;
1453
1454        let decorations: Vec<FileExplorerDecoration> = decorations
1455            .into_iter()
1456            .map(|obj| {
1457                let color: Vec<u8> = obj.get("color").unwrap_or_else(|_| vec![128, 128, 128]);
1458                FileExplorerDecoration {
1459                    path: std::path::PathBuf::from(
1460                        obj.get::<_, String>("path").unwrap_or_default(),
1461                    ),
1462                    symbol: obj.get("symbol").unwrap_or_default(),
1463                    color: if color.len() >= 3 {
1464                        (color[0], color[1], color[2])
1465                    } else {
1466                        (128, 128, 128)
1467                    },
1468                    priority: obj.get("priority").unwrap_or(0),
1469                }
1470            })
1471            .collect();
1472
1473        Ok(self
1474            .command_sender
1475            .send(PluginCommand::SetFileExplorerDecorations {
1476                namespace,
1477                decorations,
1478            })
1479            .is_ok())
1480    }
1481
1482    /// Clear file explorer decorations for a namespace
1483    pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1484        self.command_sender
1485            .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1486            .is_ok()
1487    }
1488
1489    // === Virtual Text ===
1490
1491    /// Add virtual text (inline text that doesn't exist in the buffer)
1492    #[allow(clippy::too_many_arguments)]
1493    pub fn add_virtual_text(
1494        &self,
1495        buffer_id: u32,
1496        virtual_text_id: String,
1497        position: u32,
1498        text: String,
1499        r: u8,
1500        g: u8,
1501        b: u8,
1502        before: bool,
1503        use_bg: bool,
1504    ) -> bool {
1505        self.command_sender
1506            .send(PluginCommand::AddVirtualText {
1507                buffer_id: BufferId(buffer_id as usize),
1508                virtual_text_id,
1509                position: position as usize,
1510                text,
1511                color: (r, g, b),
1512                use_bg,
1513                before,
1514            })
1515            .is_ok()
1516    }
1517
1518    /// Remove a virtual text by ID
1519    pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1520        self.command_sender
1521            .send(PluginCommand::RemoveVirtualText {
1522                buffer_id: BufferId(buffer_id as usize),
1523                virtual_text_id,
1524            })
1525            .is_ok()
1526    }
1527
1528    /// Remove virtual texts whose ID starts with the given prefix
1529    pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1530        self.command_sender
1531            .send(PluginCommand::RemoveVirtualTextsByPrefix {
1532                buffer_id: BufferId(buffer_id as usize),
1533                prefix,
1534            })
1535            .is_ok()
1536    }
1537
1538    /// Clear all virtual texts from a buffer
1539    pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1540        self.command_sender
1541            .send(PluginCommand::ClearVirtualTexts {
1542                buffer_id: BufferId(buffer_id as usize),
1543            })
1544            .is_ok()
1545    }
1546
1547    /// Clear all virtual texts in a namespace
1548    pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1549        self.command_sender
1550            .send(PluginCommand::ClearVirtualTextNamespace {
1551                buffer_id: BufferId(buffer_id as usize),
1552                namespace,
1553            })
1554            .is_ok()
1555    }
1556
1557    /// Add a virtual line (full line above/below a position)
1558    #[allow(clippy::too_many_arguments)]
1559    pub fn add_virtual_line(
1560        &self,
1561        buffer_id: u32,
1562        position: u32,
1563        text: String,
1564        fg_r: u8,
1565        fg_g: u8,
1566        fg_b: u8,
1567        bg_r: u8,
1568        bg_g: u8,
1569        bg_b: u8,
1570        above: bool,
1571        namespace: String,
1572        priority: i32,
1573    ) -> bool {
1574        self.command_sender
1575            .send(PluginCommand::AddVirtualLine {
1576                buffer_id: BufferId(buffer_id as usize),
1577                position: position as usize,
1578                text,
1579                fg_color: (fg_r, fg_g, fg_b),
1580                bg_color: Some((bg_r, bg_g, bg_b)),
1581                above,
1582                namespace,
1583                priority,
1584            })
1585            .is_ok()
1586    }
1587
1588    // === Prompts ===
1589
1590    /// Start an interactive prompt
1591    pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1592        self.command_sender
1593            .send(PluginCommand::StartPrompt { label, prompt_type })
1594            .is_ok()
1595    }
1596
1597    /// Start a prompt with initial value
1598    pub fn start_prompt_with_initial(
1599        &self,
1600        label: String,
1601        prompt_type: String,
1602        initial_value: String,
1603    ) -> bool {
1604        self.command_sender
1605            .send(PluginCommand::StartPromptWithInitial {
1606                label,
1607                prompt_type,
1608                initial_value,
1609            })
1610            .is_ok()
1611    }
1612
1613    /// Set suggestions for the current prompt (takes array of suggestion objects)
1614    pub fn set_prompt_suggestions<'js>(
1615        &self,
1616        _ctx: rquickjs::Ctx<'js>,
1617        suggestions_arr: Vec<rquickjs::Object<'js>>,
1618    ) -> rquickjs::Result<bool> {
1619        let suggestions: Vec<fresh_core::command::Suggestion> = suggestions_arr
1620            .into_iter()
1621            .map(|obj| fresh_core::command::Suggestion {
1622                text: obj.get("text").unwrap_or_default(),
1623                description: obj.get("description").ok(),
1624                value: obj.get("value").ok(),
1625                disabled: obj.get("disabled").unwrap_or(false),
1626                keybinding: obj.get("keybinding").ok(),
1627                source: None,
1628            })
1629            .collect();
1630        Ok(self
1631            .command_sender
1632            .send(PluginCommand::SetPromptSuggestions { suggestions })
1633            .is_ok())
1634    }
1635
1636    // === Modes ===
1637
1638    /// Define a buffer mode (takes bindings as array of [key, command] pairs)
1639    pub fn define_mode(
1640        &self,
1641        name: String,
1642        parent: Option<String>,
1643        bindings_arr: Vec<Vec<String>>,
1644        read_only: rquickjs::function::Opt<bool>,
1645    ) -> bool {
1646        let bindings: Vec<(String, String)> = bindings_arr
1647            .into_iter()
1648            .filter_map(|arr| {
1649                if arr.len() >= 2 {
1650                    Some((arr[0].clone(), arr[1].clone()))
1651                } else {
1652                    None
1653                }
1654            })
1655            .collect();
1656
1657        // Register commands associated with this mode so start_action can find them
1658        // and execute them in the correct plugin context
1659        {
1660            let mut registered = self.registered_actions.borrow_mut();
1661            for (_, cmd_name) in &bindings {
1662                registered.insert(
1663                    cmd_name.clone(),
1664                    PluginHandler {
1665                        plugin_name: self.plugin_name.clone(),
1666                        handler_name: cmd_name.clone(),
1667                    },
1668                );
1669            }
1670        }
1671
1672        self.command_sender
1673            .send(PluginCommand::DefineMode {
1674                name,
1675                parent,
1676                bindings,
1677                read_only: read_only.0.unwrap_or(false),
1678            })
1679            .is_ok()
1680    }
1681
1682    /// Set the global editor mode
1683    pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1684        self.command_sender
1685            .send(PluginCommand::SetEditorMode { mode })
1686            .is_ok()
1687    }
1688
1689    /// Get the current editor mode
1690    pub fn get_editor_mode(&self) -> Option<String> {
1691        self.state_snapshot
1692            .read()
1693            .ok()
1694            .and_then(|s| s.editor_mode.clone())
1695    }
1696
1697    // === Splits ===
1698
1699    /// Close a split
1700    pub fn close_split(&self, split_id: u32) -> bool {
1701        self.command_sender
1702            .send(PluginCommand::CloseSplit {
1703                split_id: SplitId(split_id as usize),
1704            })
1705            .is_ok()
1706    }
1707
1708    /// Set the buffer displayed in a split
1709    pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1710        self.command_sender
1711            .send(PluginCommand::SetSplitBuffer {
1712                split_id: SplitId(split_id as usize),
1713                buffer_id: BufferId(buffer_id as usize),
1714            })
1715            .is_ok()
1716    }
1717
1718    /// Focus a specific split
1719    pub fn focus_split(&self, split_id: u32) -> bool {
1720        self.command_sender
1721            .send(PluginCommand::FocusSplit {
1722                split_id: SplitId(split_id as usize),
1723            })
1724            .is_ok()
1725    }
1726
1727    /// Set scroll position of a split
1728    pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1729        self.command_sender
1730            .send(PluginCommand::SetSplitScroll {
1731                split_id: SplitId(split_id as usize),
1732                top_byte: top_byte as usize,
1733            })
1734            .is_ok()
1735    }
1736
1737    /// Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
1738    pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1739        self.command_sender
1740            .send(PluginCommand::SetSplitRatio {
1741                split_id: SplitId(split_id as usize),
1742                ratio,
1743            })
1744            .is_ok()
1745    }
1746
1747    /// Distribute all splits evenly
1748    pub fn distribute_splits_evenly(&self) -> bool {
1749        // Get all split IDs - for now send empty vec (app will handle)
1750        self.command_sender
1751            .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1752            .is_ok()
1753    }
1754
1755    /// Set cursor position in a buffer
1756    pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1757        self.command_sender
1758            .send(PluginCommand::SetBufferCursor {
1759                buffer_id: BufferId(buffer_id as usize),
1760                position: position as usize,
1761            })
1762            .is_ok()
1763    }
1764
1765    // === Line Indicators ===
1766
1767    /// Set a line indicator in the gutter
1768    #[allow(clippy::too_many_arguments)]
1769    pub fn set_line_indicator(
1770        &self,
1771        buffer_id: u32,
1772        line: u32,
1773        namespace: String,
1774        symbol: String,
1775        r: u8,
1776        g: u8,
1777        b: u8,
1778        priority: i32,
1779    ) -> bool {
1780        self.command_sender
1781            .send(PluginCommand::SetLineIndicator {
1782                buffer_id: BufferId(buffer_id as usize),
1783                line: line as usize,
1784                namespace,
1785                symbol,
1786                color: (r, g, b),
1787                priority,
1788            })
1789            .is_ok()
1790    }
1791
1792    /// Clear line indicators in a namespace
1793    pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
1794        self.command_sender
1795            .send(PluginCommand::ClearLineIndicators {
1796                buffer_id: BufferId(buffer_id as usize),
1797                namespace,
1798            })
1799            .is_ok()
1800    }
1801
1802    /// Enable or disable line numbers for a buffer
1803    pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
1804        self.command_sender
1805            .send(PluginCommand::SetLineNumbers {
1806                buffer_id: BufferId(buffer_id as usize),
1807                enabled,
1808            })
1809            .is_ok()
1810    }
1811
1812    // === Scroll Sync ===
1813
1814    /// Create a scroll sync group for anchor-based synchronized scrolling
1815    pub fn create_scroll_sync_group(
1816        &self,
1817        group_id: u32,
1818        left_split: u32,
1819        right_split: u32,
1820    ) -> bool {
1821        self.command_sender
1822            .send(PluginCommand::CreateScrollSyncGroup {
1823                group_id,
1824                left_split: SplitId(left_split as usize),
1825                right_split: SplitId(right_split as usize),
1826            })
1827            .is_ok()
1828    }
1829
1830    /// Set sync anchors for a scroll sync group
1831    pub fn set_scroll_sync_anchors<'js>(
1832        &self,
1833        _ctx: rquickjs::Ctx<'js>,
1834        group_id: u32,
1835        anchors: Vec<Vec<u32>>,
1836    ) -> bool {
1837        let anchors: Vec<(usize, usize)> = anchors
1838            .into_iter()
1839            .filter_map(|pair| {
1840                if pair.len() >= 2 {
1841                    Some((pair[0] as usize, pair[1] as usize))
1842                } else {
1843                    None
1844                }
1845            })
1846            .collect();
1847        self.command_sender
1848            .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
1849            .is_ok()
1850    }
1851
1852    /// Remove a scroll sync group
1853    pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
1854        self.command_sender
1855            .send(PluginCommand::RemoveScrollSyncGroup { group_id })
1856            .is_ok()
1857    }
1858
1859    // === Actions ===
1860
1861    /// Execute multiple actions in sequence
1862    pub fn execute_actions<'js>(
1863        &self,
1864        _ctx: rquickjs::Ctx<'js>,
1865        actions: Vec<rquickjs::Object<'js>>,
1866    ) -> rquickjs::Result<bool> {
1867        let specs: Vec<ActionSpec> = actions
1868            .into_iter()
1869            .map(|obj| ActionSpec {
1870                action: obj.get("action").unwrap_or_default(),
1871                count: obj.get("count").unwrap_or(1),
1872            })
1873            .collect();
1874        Ok(self
1875            .command_sender
1876            .send(PluginCommand::ExecuteActions { actions: specs })
1877            .is_ok())
1878    }
1879
1880    /// Show an action popup
1881    pub fn show_action_popup<'js>(
1882        &self,
1883        _ctx: rquickjs::Ctx<'js>,
1884        opts: rquickjs::Object<'js>,
1885    ) -> rquickjs::Result<bool> {
1886        let popup_id: String = opts.get("popupId").unwrap_or_default();
1887        let title: String = opts.get("title").unwrap_or_default();
1888        let message: String = opts.get("message").unwrap_or_default();
1889        let actions_arr: Vec<rquickjs::Object> = opts.get("actions").unwrap_or_default();
1890
1891        let actions: Vec<ActionPopupAction> = actions_arr
1892            .into_iter()
1893            .map(|obj| ActionPopupAction {
1894                id: obj.get("id").unwrap_or_default(),
1895                label: obj.get("label").unwrap_or_default(),
1896            })
1897            .collect();
1898
1899        Ok(self
1900            .command_sender
1901            .send(PluginCommand::ShowActionPopup {
1902                popup_id,
1903                title,
1904                message,
1905                actions,
1906            })
1907            .is_ok())
1908    }
1909
1910    /// Disable LSP for a specific language
1911    pub fn disable_lsp_for_language(&self, language: String) -> bool {
1912        self.command_sender
1913            .send(PluginCommand::DisableLspForLanguage { language })
1914            .is_ok()
1915    }
1916
1917    /// Get all diagnostics from LSP
1918    pub fn get_all_diagnostics<'js>(
1919        &self,
1920        ctx: rquickjs::Ctx<'js>,
1921    ) -> rquickjs::Result<Value<'js>> {
1922        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
1923            // Convert to a simpler format for JS
1924            let mut result: Vec<serde_json::Value> = Vec::new();
1925            for (uri, diags) in &s.diagnostics {
1926                for diag in diags {
1927                    result.push(serde_json::json!({
1928                        "uri": uri,
1929                        "message": diag.message,
1930                        "severity": diag.severity.map(|s| match s {
1931                            lsp_types::DiagnosticSeverity::ERROR => 1,
1932                            lsp_types::DiagnosticSeverity::WARNING => 2,
1933                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
1934                            lsp_types::DiagnosticSeverity::HINT => 4,
1935                            _ => 0,
1936                        }),
1937                        "range": {
1938                            "start": {"line": diag.range.start.line, "character": diag.range.start.character},
1939                            "end": {"line": diag.range.end.line, "character": diag.range.end.character}
1940                        }
1941                    }));
1942                }
1943            }
1944            result
1945        } else {
1946            Vec::new()
1947        };
1948        rquickjs_serde::to_value(ctx, &diagnostics)
1949            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1950    }
1951
1952    /// Get registered event handlers for an event
1953    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
1954        self.event_handlers
1955            .borrow()
1956            .get(&event_name)
1957            .cloned()
1958            .unwrap_or_default()
1959            .into_iter()
1960            .map(|h| h.handler_name)
1961            .collect()
1962    }
1963
1964    // === Virtual Buffers ===
1965
1966    /// Create a virtual buffer in current split (async, returns buffer ID)
1967    #[plugin_api(async_promise, js_name = "createVirtualBuffer", ts_return = "number")]
1968    #[qjs(rename = "_createVirtualBufferStart")]
1969    pub fn create_virtual_buffer_start(
1970        &self,
1971        _ctx: rquickjs::Ctx<'_>,
1972        opts: fresh_core::api::CreateVirtualBufferOptions,
1973    ) -> rquickjs::Result<u64> {
1974        let id = {
1975            let mut id_ref = self.next_request_id.borrow_mut();
1976            let id = *id_ref;
1977            *id_ref += 1;
1978            // Record context for this callback
1979            self.callback_contexts
1980                .borrow_mut()
1981                .insert(id, self.plugin_name.clone());
1982            id
1983        };
1984
1985        // Convert JsTextPropertyEntry to TextPropertyEntry
1986        let entries: Vec<TextPropertyEntry> = opts
1987            .entries
1988            .unwrap_or_default()
1989            .into_iter()
1990            .map(|e| TextPropertyEntry {
1991                text: e.text,
1992                properties: e.properties.unwrap_or_default(),
1993            })
1994            .collect();
1995
1996        tracing::debug!(
1997            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
1998            id
1999        );
2000        let _ = self
2001            .command_sender
2002            .send(PluginCommand::CreateVirtualBufferWithContent {
2003                name: opts.name,
2004                mode: opts.mode.unwrap_or_default(),
2005                read_only: opts.read_only.unwrap_or(false),
2006                entries,
2007                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2008                show_cursors: opts.show_cursors.unwrap_or(true),
2009                editing_disabled: opts.editing_disabled.unwrap_or(false),
2010                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2011                request_id: Some(id),
2012            });
2013        Ok(id)
2014    }
2015
2016    /// Create a virtual buffer in a new split (async, returns request_id)
2017    #[plugin_api(
2018        async_promise,
2019        js_name = "createVirtualBufferInSplit",
2020        ts_return = "number"
2021    )]
2022    #[qjs(rename = "_createVirtualBufferInSplitStart")]
2023    pub fn create_virtual_buffer_in_split_start(
2024        &self,
2025        _ctx: rquickjs::Ctx<'_>,
2026        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2027    ) -> rquickjs::Result<u64> {
2028        let id = {
2029            let mut id_ref = self.next_request_id.borrow_mut();
2030            let id = *id_ref;
2031            *id_ref += 1;
2032            // Record context for this callback
2033            self.callback_contexts
2034                .borrow_mut()
2035                .insert(id, self.plugin_name.clone());
2036            id
2037        };
2038
2039        // Convert JsTextPropertyEntry to TextPropertyEntry
2040        let entries: Vec<TextPropertyEntry> = opts
2041            .entries
2042            .unwrap_or_default()
2043            .into_iter()
2044            .map(|e| TextPropertyEntry {
2045                text: e.text,
2046                properties: e.properties.unwrap_or_default(),
2047            })
2048            .collect();
2049
2050        let _ = self
2051            .command_sender
2052            .send(PluginCommand::CreateVirtualBufferInSplit {
2053                name: opts.name,
2054                mode: opts.mode.unwrap_or_default(),
2055                read_only: opts.read_only.unwrap_or(false),
2056                entries,
2057                ratio: opts.ratio.unwrap_or(0.5),
2058                direction: opts.direction,
2059                panel_id: opts.panel_id,
2060                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2061                show_cursors: opts.show_cursors.unwrap_or(true),
2062                editing_disabled: opts.editing_disabled.unwrap_or(false),
2063                line_wrap: opts.line_wrap,
2064                request_id: Some(id),
2065            });
2066        Ok(id)
2067    }
2068
2069    /// Create a virtual buffer in an existing split (async, returns request_id)
2070    #[plugin_api(
2071        async_promise,
2072        js_name = "createVirtualBufferInExistingSplit",
2073        ts_return = "number"
2074    )]
2075    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2076    pub fn create_virtual_buffer_in_existing_split_start(
2077        &self,
2078        _ctx: rquickjs::Ctx<'_>,
2079        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2080    ) -> rquickjs::Result<u64> {
2081        let id = {
2082            let mut id_ref = self.next_request_id.borrow_mut();
2083            let id = *id_ref;
2084            *id_ref += 1;
2085            // Record context for this callback
2086            self.callback_contexts
2087                .borrow_mut()
2088                .insert(id, self.plugin_name.clone());
2089            id
2090        };
2091
2092        // Convert JsTextPropertyEntry to TextPropertyEntry
2093        let entries: Vec<TextPropertyEntry> = opts
2094            .entries
2095            .unwrap_or_default()
2096            .into_iter()
2097            .map(|e| TextPropertyEntry {
2098                text: e.text,
2099                properties: e.properties.unwrap_or_default(),
2100            })
2101            .collect();
2102
2103        let _ = self
2104            .command_sender
2105            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2106                name: opts.name,
2107                mode: opts.mode.unwrap_or_default(),
2108                read_only: opts.read_only.unwrap_or(false),
2109                entries,
2110                split_id: SplitId(opts.split_id),
2111                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2112                show_cursors: opts.show_cursors.unwrap_or(true),
2113                editing_disabled: opts.editing_disabled.unwrap_or(false),
2114                line_wrap: opts.line_wrap,
2115                request_id: Some(id),
2116            });
2117        Ok(id)
2118    }
2119
2120    /// Set virtual buffer content (takes array of entry objects)
2121    pub fn set_virtual_buffer_content<'js>(
2122        &self,
2123        ctx: rquickjs::Ctx<'js>,
2124        buffer_id: u32,
2125        entries_arr: Vec<rquickjs::Object<'js>>,
2126    ) -> rquickjs::Result<bool> {
2127        let entries: Vec<TextPropertyEntry> = entries_arr
2128            .iter()
2129            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2130            .collect();
2131        Ok(self
2132            .command_sender
2133            .send(PluginCommand::SetVirtualBufferContent {
2134                buffer_id: BufferId(buffer_id as usize),
2135                entries,
2136            })
2137            .is_ok())
2138    }
2139
2140    /// Get text properties at cursor position (returns JS array)
2141    pub fn get_text_properties_at_cursor(
2142        &self,
2143        buffer_id: u32,
2144    ) -> fresh_core::api::TextPropertiesAtCursor {
2145        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2146    }
2147
2148    // === Async Operations ===
2149
2150    /// Spawn a process (async, returns request_id)
2151    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2152    #[qjs(rename = "_spawnProcessStart")]
2153    pub fn spawn_process_start(
2154        &self,
2155        _ctx: rquickjs::Ctx<'_>,
2156        command: String,
2157        args: Vec<String>,
2158        cwd: rquickjs::function::Opt<String>,
2159    ) -> u64 {
2160        let id = {
2161            let mut id_ref = self.next_request_id.borrow_mut();
2162            let id = *id_ref;
2163            *id_ref += 1;
2164            // Record context for this callback
2165            self.callback_contexts
2166                .borrow_mut()
2167                .insert(id, self.plugin_name.clone());
2168            id
2169        };
2170        // Use provided cwd, or fall back to snapshot's working_dir
2171        let effective_cwd = cwd.0.or_else(|| {
2172            self.state_snapshot
2173                .read()
2174                .ok()
2175                .map(|s| s.working_dir.to_string_lossy().to_string())
2176        });
2177        tracing::info!(
2178            "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2179            command,
2180            args,
2181            effective_cwd,
2182            id
2183        );
2184        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2185            callback_id: JsCallbackId::new(id),
2186            command,
2187            args,
2188            cwd: effective_cwd,
2189        });
2190        id
2191    }
2192
2193    /// Wait for a process to complete and get its result (async)
2194    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2195    #[qjs(rename = "_spawnProcessWaitStart")]
2196    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2197        let id = {
2198            let mut id_ref = self.next_request_id.borrow_mut();
2199            let id = *id_ref;
2200            *id_ref += 1;
2201            // Record context for this callback
2202            self.callback_contexts
2203                .borrow_mut()
2204                .insert(id, self.plugin_name.clone());
2205            id
2206        };
2207        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2208            process_id,
2209            callback_id: JsCallbackId::new(id),
2210        });
2211        id
2212    }
2213
2214    /// Get buffer text range (async, returns request_id)
2215    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2216    #[qjs(rename = "_getBufferTextStart")]
2217    pub fn get_buffer_text_start(
2218        &self,
2219        _ctx: rquickjs::Ctx<'_>,
2220        buffer_id: u32,
2221        start: u32,
2222        end: u32,
2223    ) -> u64 {
2224        let id = {
2225            let mut id_ref = self.next_request_id.borrow_mut();
2226            let id = *id_ref;
2227            *id_ref += 1;
2228            // Record context for this callback
2229            self.callback_contexts
2230                .borrow_mut()
2231                .insert(id, self.plugin_name.clone());
2232            id
2233        };
2234        let _ = self.command_sender.send(PluginCommand::GetBufferText {
2235            buffer_id: BufferId(buffer_id as usize),
2236            start: start as usize,
2237            end: end as usize,
2238            request_id: id,
2239        });
2240        id
2241    }
2242
2243    /// Delay/sleep (async, returns request_id)
2244    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2245    #[qjs(rename = "_delayStart")]
2246    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2247        let id = {
2248            let mut id_ref = self.next_request_id.borrow_mut();
2249            let id = *id_ref;
2250            *id_ref += 1;
2251            // Record context for this callback
2252            self.callback_contexts
2253                .borrow_mut()
2254                .insert(id, self.plugin_name.clone());
2255            id
2256        };
2257        let _ = self.command_sender.send(PluginCommand::Delay {
2258            callback_id: JsCallbackId::new(id),
2259            duration_ms,
2260        });
2261        id
2262    }
2263
2264    /// Send LSP request (async, returns request_id)
2265    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2266    #[qjs(rename = "_sendLspRequestStart")]
2267    pub fn send_lsp_request_start<'js>(
2268        &self,
2269        ctx: rquickjs::Ctx<'js>,
2270        language: String,
2271        method: String,
2272        params: Option<rquickjs::Object<'js>>,
2273    ) -> rquickjs::Result<u64> {
2274        let id = {
2275            let mut id_ref = self.next_request_id.borrow_mut();
2276            let id = *id_ref;
2277            *id_ref += 1;
2278            // Record context for this callback
2279            self.callback_contexts
2280                .borrow_mut()
2281                .insert(id, self.plugin_name.clone());
2282            id
2283        };
2284        // Convert params object to serde_json::Value
2285        let params_json: Option<serde_json::Value> = params.map(|obj| {
2286            let val = obj.into_value();
2287            js_to_json(&ctx, val)
2288        });
2289        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2290            request_id: id,
2291            language,
2292            method,
2293            params: params_json,
2294        });
2295        Ok(id)
2296    }
2297
2298    /// Spawn a background process (async, returns request_id which is also process_id)
2299    #[plugin_api(
2300        async_thenable,
2301        js_name = "spawnBackgroundProcess",
2302        ts_return = "BackgroundProcessResult"
2303    )]
2304    #[qjs(rename = "_spawnBackgroundProcessStart")]
2305    pub fn spawn_background_process_start(
2306        &self,
2307        _ctx: rquickjs::Ctx<'_>,
2308        command: String,
2309        args: Vec<String>,
2310        cwd: rquickjs::function::Opt<String>,
2311    ) -> u64 {
2312        let id = {
2313            let mut id_ref = self.next_request_id.borrow_mut();
2314            let id = *id_ref;
2315            *id_ref += 1;
2316            // Record context for this callback
2317            self.callback_contexts
2318                .borrow_mut()
2319                .insert(id, self.plugin_name.clone());
2320            id
2321        };
2322        // Use id as process_id for simplicity
2323        let process_id = id;
2324        let _ = self
2325            .command_sender
2326            .send(PluginCommand::SpawnBackgroundProcess {
2327                process_id,
2328                command,
2329                args,
2330                cwd: cwd.0,
2331                callback_id: JsCallbackId::new(id),
2332            });
2333        id
2334    }
2335
2336    /// Kill a background process
2337    pub fn kill_background_process(&self, process_id: u64) -> bool {
2338        self.command_sender
2339            .send(PluginCommand::KillBackgroundProcess { process_id })
2340            .is_ok()
2341    }
2342
2343    // === Misc ===
2344
2345    /// Force refresh of line display
2346    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2347        self.command_sender
2348            .send(PluginCommand::RefreshLines {
2349                buffer_id: BufferId(buffer_id as usize),
2350            })
2351            .is_ok()
2352    }
2353
2354    /// Get the current locale
2355    pub fn get_current_locale(&self) -> String {
2356        self.services.current_locale()
2357    }
2358}
2359
2360/// QuickJS-based JavaScript runtime for plugins
2361pub struct QuickJsBackend {
2362    runtime: Runtime,
2363    /// Main context for shared/internal operations
2364    main_context: Context,
2365    /// Plugin-specific contexts: plugin_name -> Context
2366    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2367    /// Event handlers: event_name -> list of PluginHandler
2368    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2369    /// Registered actions: action_name -> PluginHandler
2370    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2371    /// Editor state snapshot (read-only access)
2372    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2373    /// Command sender for write operations
2374    command_sender: mpsc::Sender<PluginCommand>,
2375    /// Pending response senders for async operations (held to keep Arc alive)
2376    #[allow(dead_code)]
2377    pending_responses: PendingResponses,
2378    /// Next request ID for async operations
2379    next_request_id: Rc<RefCell<u64>>,
2380    /// Plugin name for each pending callback ID
2381    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2382    /// Bridge for editor services (i18n, theme, etc.)
2383    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2384}
2385
2386impl QuickJsBackend {
2387    /// Create a new QuickJS backend (standalone, for testing)
2388    pub fn new() -> Result<Self> {
2389        let (tx, _rx) = mpsc::channel();
2390        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2391        let services = Arc::new(fresh_core::services::NoopServiceBridge);
2392        Self::with_state(state_snapshot, tx, services)
2393    }
2394
2395    /// Create a new QuickJS backend with editor state
2396    pub fn with_state(
2397        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2398        command_sender: mpsc::Sender<PluginCommand>,
2399        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2400    ) -> Result<Self> {
2401        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2402        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2403    }
2404
2405    /// Create a new QuickJS backend with editor state and shared pending responses
2406    pub fn with_state_and_responses(
2407        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2408        command_sender: mpsc::Sender<PluginCommand>,
2409        pending_responses: PendingResponses,
2410        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2411    ) -> Result<Self> {
2412        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2413
2414        let runtime =
2415            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2416
2417        // Set up promise rejection tracker to catch unhandled rejections
2418        runtime.set_host_promise_rejection_tracker(Some(Box::new(
2419            |_ctx, _promise, reason, is_handled| {
2420                if !is_handled {
2421                    // Format the rejection reason
2422                    let error_msg = if let Some(exc) = reason.as_exception() {
2423                        format!(
2424                            "{}: {}",
2425                            exc.message().unwrap_or_default(),
2426                            exc.stack().unwrap_or_default()
2427                        )
2428                    } else {
2429                        format!("{:?}", reason)
2430                    };
2431
2432                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
2433
2434                    if should_panic_on_js_errors() {
2435                        panic!("Unhandled Promise rejection: {}", error_msg);
2436                    }
2437                }
2438            },
2439        )));
2440
2441        let main_context = Context::full(&runtime)
2442            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2443
2444        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2445        let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2446        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2447        let next_request_id = Rc::new(RefCell::new(1u64));
2448        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2449
2450        let backend = Self {
2451            runtime,
2452            main_context,
2453            plugin_contexts,
2454            event_handlers,
2455            registered_actions,
2456            state_snapshot,
2457            command_sender,
2458            pending_responses,
2459            next_request_id,
2460            callback_contexts,
2461            services,
2462        };
2463
2464        // Initialize main context (for internal utilities if needed)
2465        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2466
2467        tracing::debug!("QuickJsBackend::new: runtime created successfully");
2468        Ok(backend)
2469    }
2470
2471    /// Set up the editor API in a specific JavaScript context
2472    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2473        let state_snapshot = Arc::clone(&self.state_snapshot);
2474        let command_sender = self.command_sender.clone();
2475        let event_handlers = Rc::clone(&self.event_handlers);
2476        let registered_actions = Rc::clone(&self.registered_actions);
2477        let next_request_id = Rc::clone(&self.next_request_id);
2478
2479        context.with(|ctx| {
2480            let globals = ctx.globals();
2481
2482            // Set the plugin name global
2483            globals.set("__pluginName__", plugin_name)?;
2484
2485            // Create the editor object using JsEditorApi class
2486            // This provides proper lifetime handling for methods returning JS values
2487            let js_api = JsEditorApi {
2488                state_snapshot: Arc::clone(&state_snapshot),
2489                command_sender: command_sender.clone(),
2490                registered_actions: Rc::clone(&registered_actions),
2491                event_handlers: Rc::clone(&event_handlers),
2492                next_request_id: Rc::clone(&next_request_id),
2493                callback_contexts: Rc::clone(&self.callback_contexts),
2494                services: self.services.clone(),
2495                plugin_name: plugin_name.to_string(),
2496            };
2497            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2498
2499            // All methods are now in JsEditorApi - export editor as global
2500            globals.set("editor", editor)?;
2501
2502            // Define getEditor() globally
2503            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2504
2505            // Provide console.log for debugging
2506            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
2507            let console = Object::new(ctx.clone())?;
2508            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2509                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2510                tracing::info!("console.log: {}", parts.join(" "));
2511            })?)?;
2512            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2513                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2514                tracing::warn!("console.warn: {}", parts.join(" "));
2515            })?)?;
2516            console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2517                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2518                tracing::error!("console.error: {}", parts.join(" "));
2519            })?)?;
2520            globals.set("console", console)?;
2521
2522            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
2523            ctx.eval::<(), _>(r#"
2524                // Pending promise callbacks: callbackId -> { resolve, reject }
2525                globalThis._pendingCallbacks = new Map();
2526
2527                // Resolve a pending callback (called from Rust)
2528                globalThis._resolveCallback = function(callbackId, result) {
2529                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2530                    const cb = globalThis._pendingCallbacks.get(callbackId);
2531                    if (cb) {
2532                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
2533                        globalThis._pendingCallbacks.delete(callbackId);
2534                        cb.resolve(result);
2535                        console.log('[JS] _resolveCallback: resolve() called');
2536                    } else {
2537                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2538                    }
2539                };
2540
2541                // Reject a pending callback (called from Rust)
2542                globalThis._rejectCallback = function(callbackId, error) {
2543                    const cb = globalThis._pendingCallbacks.get(callbackId);
2544                    if (cb) {
2545                        globalThis._pendingCallbacks.delete(callbackId);
2546                        cb.reject(new Error(error));
2547                    }
2548                };
2549
2550                // Generic async wrapper decorator
2551                // Wraps a function that returns a callbackId into a promise-returning function
2552                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2553                // NOTE: We pass the method name as a string and call via bracket notation
2554                // to preserve rquickjs's automatic Ctx injection for methods
2555                globalThis._wrapAsync = function(methodName, fnName) {
2556                    const startFn = editor[methodName];
2557                    if (typeof startFn !== 'function') {
2558                        // Return a function that always throws - catches missing implementations
2559                        return function(...args) {
2560                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2561                            editor.debug(`[ASYNC ERROR] ${error.message}`);
2562                            throw error;
2563                        };
2564                    }
2565                    return function(...args) {
2566                        // Call via bracket notation to preserve method binding and Ctx injection
2567                        const callbackId = editor[methodName](...args);
2568                        return new Promise((resolve, reject) => {
2569                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2570                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
2571                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2572                        });
2573                    };
2574                };
2575
2576                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2577                // The returned object has .result promise and is itself thenable
2578                globalThis._wrapAsyncThenable = function(methodName, fnName) {
2579                    const startFn = editor[methodName];
2580                    if (typeof startFn !== 'function') {
2581                        // Return a function that always throws - catches missing implementations
2582                        return function(...args) {
2583                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2584                            editor.debug(`[ASYNC ERROR] ${error.message}`);
2585                            throw error;
2586                        };
2587                    }
2588                    return function(...args) {
2589                        // Call via bracket notation to preserve method binding and Ctx injection
2590                        const callbackId = editor[methodName](...args);
2591                        const resultPromise = new Promise((resolve, reject) => {
2592                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2593                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2594                        });
2595                        return {
2596                            get result() { return resultPromise; },
2597                            then(onFulfilled, onRejected) {
2598                                return resultPromise.then(onFulfilled, onRejected);
2599                            },
2600                            catch(onRejected) {
2601                                return resultPromise.catch(onRejected);
2602                            }
2603                        };
2604                    };
2605                };
2606
2607                // Apply wrappers to async functions on editor
2608                editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
2609                editor.delay = _wrapAsync("_delayStart", "delay");
2610                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
2611                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
2612                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
2613                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
2614                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
2615                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
2616                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
2617                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
2618                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
2619
2620                // Wrapper for deleteTheme - wraps sync function in Promise
2621                editor.deleteTheme = function(name) {
2622                    return new Promise(function(resolve, reject) {
2623                        const success = editor._deleteThemeSync(name);
2624                        if (success) {
2625                            resolve();
2626                        } else {
2627                            reject(new Error("Failed to delete theme: " + name));
2628                        }
2629                    });
2630                };
2631            "#.as_bytes())?;
2632
2633            Ok::<_, rquickjs::Error>(())
2634        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
2635
2636        Ok(())
2637    }
2638
2639    /// Load and execute a TypeScript/JavaScript plugin from a file path
2640    pub async fn load_module_with_source(
2641        &mut self,
2642        path: &str,
2643        _plugin_source: &str,
2644    ) -> Result<()> {
2645        let path_buf = PathBuf::from(path);
2646        let source = std::fs::read_to_string(&path_buf)
2647            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
2648
2649        let filename = path_buf
2650            .file_name()
2651            .and_then(|s| s.to_str())
2652            .unwrap_or("plugin.ts");
2653
2654        // Check for ES imports - these need bundling to resolve dependencies
2655        if has_es_imports(&source) {
2656            // Try to bundle (this also strips imports and exports)
2657            match bundle_module(&path_buf) {
2658                Ok(bundled) => {
2659                    self.execute_js(&bundled, path)?;
2660                }
2661                Err(e) => {
2662                    tracing::warn!(
2663                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
2664                        path,
2665                        e
2666                    );
2667                    return Ok(()); // Skip plugins with unresolvable imports
2668                }
2669            }
2670        } else if has_es_module_syntax(&source) {
2671            // Has exports but no imports - strip exports and transpile
2672            let stripped = strip_imports_and_exports(&source);
2673            let js_code = if filename.ends_with(".ts") {
2674                transpile_typescript(&stripped, filename)?
2675            } else {
2676                stripped
2677            };
2678            self.execute_js(&js_code, path)?;
2679        } else {
2680            // Plain code - just transpile if TypeScript
2681            let js_code = if filename.ends_with(".ts") {
2682                transpile_typescript(&source, filename)?
2683            } else {
2684                source
2685            };
2686            self.execute_js(&js_code, path)?;
2687        }
2688
2689        Ok(())
2690    }
2691
2692    /// Execute JavaScript code in the context
2693    fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
2694        // Extract plugin name from path (filename without extension)
2695        let plugin_name = Path::new(source_name)
2696            .file_stem()
2697            .and_then(|s| s.to_str())
2698            .unwrap_or("unknown");
2699
2700        tracing::debug!(
2701            "execute_js: starting for plugin '{}' from '{}'",
2702            plugin_name,
2703            source_name
2704        );
2705
2706        // Get or create context for this plugin
2707        let context = {
2708            let mut contexts = self.plugin_contexts.borrow_mut();
2709            if let Some(ctx) = contexts.get(plugin_name) {
2710                ctx.clone()
2711            } else {
2712                let ctx = Context::full(&self.runtime).map_err(|e| {
2713                    anyhow!(
2714                        "Failed to create QuickJS context for plugin {}: {}",
2715                        plugin_name,
2716                        e
2717                    )
2718                })?;
2719                self.setup_context_api(&ctx, plugin_name)?;
2720                contexts.insert(plugin_name.to_string(), ctx.clone());
2721                ctx
2722            }
2723        };
2724
2725        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
2726        // This is critical for plugins like vi_mode that declare `const editor = ...`
2727        // which shadows the global `editor` causing TDZ if not wrapped.
2728        let wrapped_code = format!("(function() {{ {} }})();", code);
2729        let wrapped = wrapped_code.as_str();
2730
2731        context.with(|ctx| {
2732            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
2733
2734            // Execute the plugin code with filename for better stack traces
2735            let mut eval_options = rquickjs::context::EvalOptions::default();
2736            eval_options.global = true;
2737            eval_options.filename = Some(source_name.to_string());
2738            let result = ctx
2739                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
2740                .map_err(|e| format_js_error(&ctx, e, source_name));
2741
2742            tracing::debug!(
2743                "execute_js: plugin code execution finished for '{}', result: {:?}",
2744                plugin_name,
2745                result.is_ok()
2746            );
2747
2748            result
2749        })
2750    }
2751
2752    /// Emit an event to all registered handlers
2753    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
2754        let _event_data_str = event_data.to_string();
2755        tracing::debug!("emit: event '{}' with data: {:?}", event_name, event_data);
2756
2757        // Track execution state for signal handler debugging
2758        self.services
2759            .set_js_execution_state(format!("hook '{}'", event_name));
2760
2761        let handlers = self.event_handlers.borrow().get(event_name).cloned();
2762
2763        if let Some(handler_pairs) = handlers {
2764            if handler_pairs.is_empty() {
2765                self.services.clear_js_execution_state();
2766                return Ok(true);
2767            }
2768
2769            let plugin_contexts = self.plugin_contexts.borrow();
2770            for handler in handler_pairs {
2771                let context_opt = plugin_contexts.get(&handler.plugin_name);
2772                if let Some(context) = context_opt {
2773                    let handler_name = &handler.handler_name;
2774                    // Call the handler and properly handle both sync and async errors
2775                    // Async handlers return Promises - we attach .catch() to surface rejections
2776                    // Double-encode the JSON to produce a valid JavaScript string literal:
2777                    // event_data = {"path": "/test"} -> first to_string = {"path": "/test"}
2778                    // -> second to_string = "{\"path\": \"/test\"}" (properly quoted for JS)
2779                    let json_string = serde_json::to_string(event_data)?;
2780                    let js_string_literal = serde_json::to_string(&json_string)?;
2781                    let code = format!(
2782                        r#"
2783                        (function() {{
2784                            try {{
2785                                const data = JSON.parse({});
2786                                if (typeof globalThis["{}"] === 'function') {{
2787                                    const result = globalThis["{}"](data);
2788                                    // If handler returns a Promise, catch rejections
2789                                    if (result && typeof result.then === 'function') {{
2790                                        result.catch(function(e) {{
2791                                            console.error('Handler {} async error:', e);
2792                                            // Re-throw to make it an unhandled rejection for the runtime to catch
2793                                            throw e;
2794                                        }});
2795                                    }}
2796                                }}
2797                            }} catch (e) {{
2798                                console.error('Handler {} sync error:', e);
2799                                throw e;
2800                            }}
2801                        }})();
2802                        "#,
2803                        js_string_literal, handler_name, handler_name, handler_name, handler_name
2804                    );
2805
2806                    context.with(|ctx| {
2807                        if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
2808                            log_js_error(&ctx, e, &format!("handler {}", handler_name));
2809                        }
2810                        // Run pending jobs to process any Promise continuations and catch errors
2811                        run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
2812                    });
2813                }
2814            }
2815        }
2816
2817        self.services.clear_js_execution_state();
2818        Ok(true)
2819    }
2820
2821    /// Check if any handlers are registered for an event
2822    pub fn has_handlers(&self, event_name: &str) -> bool {
2823        self.event_handlers
2824            .borrow()
2825            .get(event_name)
2826            .map(|v| !v.is_empty())
2827            .unwrap_or(false)
2828    }
2829
2830    /// Start an action without waiting for async operations to complete.
2831    /// This is useful when the calling thread needs to continue processing
2832    /// ResolveCallback requests that the action may be waiting for.
2833    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
2834        let pair = self.registered_actions.borrow().get(action_name).cloned();
2835        let (plugin_name, function_name) = match pair {
2836            Some(handler) => (handler.plugin_name, handler.handler_name),
2837            None => ("main".to_string(), action_name.to_string()),
2838        };
2839
2840        let plugin_contexts = self.plugin_contexts.borrow();
2841        let context = plugin_contexts
2842            .get(&plugin_name)
2843            .unwrap_or(&self.main_context);
2844
2845        // Track execution state for signal handler debugging
2846        self.services
2847            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
2848
2849        tracing::info!(
2850            "start_action: BEGIN '{}' -> function '{}'",
2851            action_name,
2852            function_name
2853        );
2854
2855        // Just call the function - don't try to await or drive Promises
2856        let code = format!(
2857            r#"
2858            (function() {{
2859                console.log('[JS] start_action: calling {fn}');
2860                try {{
2861                    if (typeof globalThis.{fn} === 'function') {{
2862                        console.log('[JS] start_action: {fn} is a function, invoking...');
2863                        globalThis.{fn}();
2864                        console.log('[JS] start_action: {fn} invoked (may be async)');
2865                    }} else {{
2866                        console.error('[JS] Action {action} is not defined as a global function');
2867                    }}
2868                }} catch (e) {{
2869                    console.error('[JS] Action {action} error:', e);
2870                }}
2871            }})();
2872            "#,
2873            fn = function_name,
2874            action = action_name
2875        );
2876
2877        tracing::info!("start_action: evaluating JS code");
2878        context.with(|ctx| {
2879            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2880                log_js_error(&ctx, e, &format!("action {}", action_name));
2881            }
2882            tracing::info!("start_action: running pending microtasks");
2883            // Run any immediate microtasks
2884            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
2885            tracing::info!("start_action: executed {} pending jobs", count);
2886        });
2887
2888        tracing::info!("start_action: END '{}'", action_name);
2889
2890        // Clear execution state (action started, may still be running async)
2891        self.services.clear_js_execution_state();
2892
2893        Ok(())
2894    }
2895
2896    /// Execute a registered action by name
2897    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
2898        // First check if there's a registered command mapping
2899        let pair = self.registered_actions.borrow().get(action_name).cloned();
2900        let (plugin_name, function_name) = match pair {
2901            Some(handler) => (handler.plugin_name, handler.handler_name),
2902            None => ("main".to_string(), action_name.to_string()),
2903        };
2904
2905        let plugin_contexts = self.plugin_contexts.borrow();
2906        let context = plugin_contexts
2907            .get(&plugin_name)
2908            .unwrap_or(&self.main_context);
2909
2910        tracing::debug!(
2911            "execute_action: '{}' -> function '{}'",
2912            action_name,
2913            function_name
2914        );
2915
2916        // Call the function and await if it returns a Promise
2917        // We use a global _executeActionResult to pass the result back
2918        let code = format!(
2919            r#"
2920            (async function() {{
2921                try {{
2922                    if (typeof globalThis.{fn} === 'function') {{
2923                        const result = globalThis.{fn}();
2924                        // If it's a Promise, await it
2925                        if (result && typeof result.then === 'function') {{
2926                            await result;
2927                        }}
2928                    }} else {{
2929                        console.error('Action {action} is not defined as a global function');
2930                    }}
2931                }} catch (e) {{
2932                    console.error('Action {action} error:', e);
2933                }}
2934            }})();
2935            "#,
2936            fn = function_name,
2937            action = action_name
2938        );
2939
2940        context.with(|ctx| {
2941            // Eval returns a Promise for the async IIFE, which we need to drive
2942            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2943                Ok(value) => {
2944                    // If it's a Promise, we need to drive the runtime to completion
2945                    if value.is_object() {
2946                        if let Some(obj) = value.as_object() {
2947                            // Check if it's a Promise by looking for 'then' method
2948                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
2949                                // Drive the runtime to process the promise
2950                                // QuickJS processes promises synchronously when we call execute_pending_job
2951                                run_pending_jobs_checked(
2952                                    &ctx,
2953                                    &format!("execute_action {} promise", action_name),
2954                                );
2955                            }
2956                        }
2957                    }
2958                }
2959                Err(e) => {
2960                    log_js_error(&ctx, e, &format!("action {}", action_name));
2961                }
2962            }
2963        });
2964
2965        Ok(())
2966    }
2967
2968    /// Poll the event loop once to run any pending microtasks
2969    pub fn poll_event_loop_once(&mut self) -> bool {
2970        let mut had_work = false;
2971
2972        // Poll main context
2973        self.main_context.with(|ctx| {
2974            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
2975            if count > 0 {
2976                had_work = true;
2977            }
2978        });
2979
2980        // Poll all plugin contexts
2981        let contexts = self.plugin_contexts.borrow().clone();
2982        for (name, context) in contexts {
2983            context.with(|ctx| {
2984                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
2985                if count > 0 {
2986                    had_work = true;
2987                }
2988            });
2989        }
2990        had_work
2991    }
2992
2993    /// Send a status message to the editor
2994    pub fn send_status(&self, message: String) {
2995        let _ = self
2996            .command_sender
2997            .send(PluginCommand::SetStatus { message });
2998    }
2999
3000    /// Resolve a pending async callback with a result (called from Rust when async op completes)
3001    ///
3002    /// Takes a JSON string which is parsed and converted to a proper JS value.
3003    /// This avoids string interpolation with eval for better type safety.
3004    pub fn resolve_callback(
3005        &mut self,
3006        callback_id: fresh_core::api::JsCallbackId,
3007        result_json: &str,
3008    ) {
3009        let id = callback_id.as_u64();
3010        tracing::debug!("resolve_callback: starting for callback_id={}", id);
3011
3012        // Find the plugin name and then context for this callback
3013        let plugin_name = {
3014            let mut contexts = self.callback_contexts.borrow_mut();
3015            contexts.remove(&id)
3016        };
3017
3018        let Some(name) = plugin_name else {
3019            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3020            return;
3021        };
3022
3023        let plugin_contexts = self.plugin_contexts.borrow();
3024        let Some(context) = plugin_contexts.get(&name) else {
3025            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3026            return;
3027        };
3028
3029        context.with(|ctx| {
3030            // Parse JSON string to serde_json::Value
3031            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3032                Ok(v) => v,
3033                Err(e) => {
3034                    tracing::error!(
3035                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
3036                        id,
3037                        e
3038                    );
3039                    return;
3040                }
3041            };
3042
3043            // Convert to JS value using rquickjs_serde
3044            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3045                Ok(v) => v,
3046                Err(e) => {
3047                    tracing::error!(
3048                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3049                        id,
3050                        e
3051                    );
3052                    return;
3053                }
3054            };
3055
3056            // Get _resolveCallback function from globalThis
3057            let globals = ctx.globals();
3058            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3059                Ok(f) => f,
3060                Err(e) => {
3061                    tracing::error!(
3062                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3063                        id,
3064                        e
3065                    );
3066                    return;
3067                }
3068            };
3069
3070            // Call the function with callback_id (as u64) and the JS value
3071            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3072                log_js_error(&ctx, e, &format!("resolving callback {}", id));
3073            }
3074
3075            // IMPORTANT: Run pending jobs to process Promise continuations
3076            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3077            tracing::info!(
3078                "resolve_callback: executed {} pending jobs for callback_id={}",
3079                job_count,
3080                id
3081            );
3082        });
3083    }
3084
3085    /// Reject a pending async callback with an error (called from Rust when async op fails)
3086    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3087        let id = callback_id.as_u64();
3088
3089        // Find the plugin name and then context for this callback
3090        let plugin_name = {
3091            let mut contexts = self.callback_contexts.borrow_mut();
3092            contexts.remove(&id)
3093        };
3094
3095        let Some(name) = plugin_name else {
3096            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3097            return;
3098        };
3099
3100        let plugin_contexts = self.plugin_contexts.borrow();
3101        let Some(context) = plugin_contexts.get(&name) else {
3102            tracing::warn!("reject_callback: Context lost for plugin {}", name);
3103            return;
3104        };
3105
3106        context.with(|ctx| {
3107            // Get _rejectCallback function from globalThis
3108            let globals = ctx.globals();
3109            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3110                Ok(f) => f,
3111                Err(e) => {
3112                    tracing::error!(
3113                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3114                        id,
3115                        e
3116                    );
3117                    return;
3118                }
3119            };
3120
3121            // Call the function with callback_id (as u64) and error string
3122            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3123                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3124            }
3125
3126            // IMPORTANT: Run pending jobs to process Promise continuations
3127            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3128        });
3129    }
3130}
3131
3132#[cfg(test)]
3133mod tests {
3134    use super::*;
3135    use fresh_core::api::{BufferInfo, CursorInfo};
3136    use std::sync::mpsc;
3137
3138    /// Helper to create a backend with a command receiver for testing
3139    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3140        let (tx, rx) = mpsc::channel();
3141        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3142        let services = Arc::new(TestServiceBridge::new());
3143        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3144        (backend, rx)
3145    }
3146
3147    struct TestServiceBridge {
3148        en_strings: std::sync::Mutex<HashMap<String, String>>,
3149    }
3150
3151    impl TestServiceBridge {
3152        fn new() -> Self {
3153            Self {
3154                en_strings: std::sync::Mutex::new(HashMap::new()),
3155            }
3156        }
3157    }
3158
3159    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3160        fn as_any(&self) -> &dyn std::any::Any {
3161            self
3162        }
3163        fn translate(
3164            &self,
3165            _plugin_name: &str,
3166            key: &str,
3167            _args: &HashMap<String, String>,
3168        ) -> String {
3169            self.en_strings
3170                .lock()
3171                .unwrap()
3172                .get(key)
3173                .cloned()
3174                .unwrap_or_else(|| key.to_string())
3175        }
3176        fn current_locale(&self) -> String {
3177            "en".to_string()
3178        }
3179        fn set_js_execution_state(&self, _state: String) {}
3180        fn clear_js_execution_state(&self) {}
3181        fn get_theme_schema(&self) -> serde_json::Value {
3182            serde_json::json!({})
3183        }
3184        fn get_builtin_themes(&self) -> serde_json::Value {
3185            serde_json::json!([])
3186        }
3187        fn register_command(&self, _command: fresh_core::command::Command) {}
3188        fn unregister_command(&self, _name: &str) {}
3189        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3190        fn plugins_dir(&self) -> std::path::PathBuf {
3191            std::path::PathBuf::from("/tmp/plugins")
3192        }
3193        fn config_dir(&self) -> std::path::PathBuf {
3194            std::path::PathBuf::from("/tmp/config")
3195        }
3196    }
3197
3198    #[test]
3199    fn test_quickjs_backend_creation() {
3200        let backend = QuickJsBackend::new();
3201        assert!(backend.is_ok());
3202    }
3203
3204    #[test]
3205    fn test_execute_simple_js() {
3206        let mut backend = QuickJsBackend::new().unwrap();
3207        let result = backend.execute_js("const x = 1 + 2;", "test.js");
3208        assert!(result.is_ok());
3209    }
3210
3211    #[test]
3212    fn test_event_handler_registration() {
3213        let backend = QuickJsBackend::new().unwrap();
3214
3215        // Initially no handlers
3216        assert!(!backend.has_handlers("test_event"));
3217
3218        // Register a handler
3219        backend
3220            .event_handlers
3221            .borrow_mut()
3222            .entry("test_event".to_string())
3223            .or_default()
3224            .push(PluginHandler {
3225                plugin_name: "test".to_string(),
3226                handler_name: "testHandler".to_string(),
3227            });
3228
3229        // Now has handlers
3230        assert!(backend.has_handlers("test_event"));
3231    }
3232
3233    // ==================== API Tests ====================
3234
3235    #[test]
3236    fn test_api_set_status() {
3237        let (mut backend, rx) = create_test_backend();
3238
3239        backend
3240            .execute_js(
3241                r#"
3242            const editor = getEditor();
3243            editor.setStatus("Hello from test");
3244        "#,
3245                "test.js",
3246            )
3247            .unwrap();
3248
3249        let cmd = rx.try_recv().unwrap();
3250        match cmd {
3251            PluginCommand::SetStatus { message } => {
3252                assert_eq!(message, "Hello from test");
3253            }
3254            _ => panic!("Expected SetStatus command, got {:?}", cmd),
3255        }
3256    }
3257
3258    #[test]
3259    fn test_api_register_command() {
3260        let (mut backend, rx) = create_test_backend();
3261
3262        backend
3263            .execute_js(
3264                r#"
3265            const editor = getEditor();
3266            globalThis.myTestHandler = function() { };
3267            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3268        "#,
3269                "test_plugin.js",
3270            )
3271            .unwrap();
3272
3273        let cmd = rx.try_recv().unwrap();
3274        match cmd {
3275            PluginCommand::RegisterCommand { command } => {
3276                assert_eq!(command.name, "Test Command");
3277                assert_eq!(command.description, "A test command");
3278                // Check that plugin_name contains the plugin name (derived from filename)
3279                assert_eq!(command.plugin_name, "test_plugin");
3280            }
3281            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3282        }
3283    }
3284
3285    #[test]
3286    fn test_api_define_mode() {
3287        let (mut backend, rx) = create_test_backend();
3288
3289        backend
3290            .execute_js(
3291                r#"
3292            const editor = getEditor();
3293            editor.defineMode("test-mode", null, [
3294                ["a", "action_a"],
3295                ["b", "action_b"]
3296            ]);
3297        "#,
3298                "test.js",
3299            )
3300            .unwrap();
3301
3302        let cmd = rx.try_recv().unwrap();
3303        match cmd {
3304            PluginCommand::DefineMode {
3305                name,
3306                parent,
3307                bindings,
3308                read_only,
3309            } => {
3310                assert_eq!(name, "test-mode");
3311                assert!(parent.is_none());
3312                assert_eq!(bindings.len(), 2);
3313                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3314                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3315                assert!(!read_only);
3316            }
3317            _ => panic!("Expected DefineMode, got {:?}", cmd),
3318        }
3319    }
3320
3321    #[test]
3322    fn test_api_set_editor_mode() {
3323        let (mut backend, rx) = create_test_backend();
3324
3325        backend
3326            .execute_js(
3327                r#"
3328            const editor = getEditor();
3329            editor.setEditorMode("vi-normal");
3330        "#,
3331                "test.js",
3332            )
3333            .unwrap();
3334
3335        let cmd = rx.try_recv().unwrap();
3336        match cmd {
3337            PluginCommand::SetEditorMode { mode } => {
3338                assert_eq!(mode, Some("vi-normal".to_string()));
3339            }
3340            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3341        }
3342    }
3343
3344    #[test]
3345    fn test_api_clear_editor_mode() {
3346        let (mut backend, rx) = create_test_backend();
3347
3348        backend
3349            .execute_js(
3350                r#"
3351            const editor = getEditor();
3352            editor.setEditorMode(null);
3353        "#,
3354                "test.js",
3355            )
3356            .unwrap();
3357
3358        let cmd = rx.try_recv().unwrap();
3359        match cmd {
3360            PluginCommand::SetEditorMode { mode } => {
3361                assert!(mode.is_none());
3362            }
3363            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3364        }
3365    }
3366
3367    #[test]
3368    fn test_api_insert_at_cursor() {
3369        let (mut backend, rx) = create_test_backend();
3370
3371        backend
3372            .execute_js(
3373                r#"
3374            const editor = getEditor();
3375            editor.insertAtCursor("Hello, World!");
3376        "#,
3377                "test.js",
3378            )
3379            .unwrap();
3380
3381        let cmd = rx.try_recv().unwrap();
3382        match cmd {
3383            PluginCommand::InsertAtCursor { text } => {
3384                assert_eq!(text, "Hello, World!");
3385            }
3386            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3387        }
3388    }
3389
3390    #[test]
3391    fn test_api_set_context() {
3392        let (mut backend, rx) = create_test_backend();
3393
3394        backend
3395            .execute_js(
3396                r#"
3397            const editor = getEditor();
3398            editor.setContext("myContext", true);
3399        "#,
3400                "test.js",
3401            )
3402            .unwrap();
3403
3404        let cmd = rx.try_recv().unwrap();
3405        match cmd {
3406            PluginCommand::SetContext { name, active } => {
3407                assert_eq!(name, "myContext");
3408                assert!(active);
3409            }
3410            _ => panic!("Expected SetContext, got {:?}", cmd),
3411        }
3412    }
3413
3414    #[tokio::test]
3415    async fn test_execute_action_sync_function() {
3416        let (mut backend, rx) = create_test_backend();
3417
3418        // Register the action explicitly so it knows to look in "test" plugin
3419        backend.registered_actions.borrow_mut().insert(
3420            "my_sync_action".to_string(),
3421            PluginHandler {
3422                plugin_name: "test".to_string(),
3423                handler_name: "my_sync_action".to_string(),
3424            },
3425        );
3426
3427        // Define a sync function and register it
3428        backend
3429            .execute_js(
3430                r#"
3431            const editor = getEditor();
3432            globalThis.my_sync_action = function() {
3433                editor.setStatus("sync action executed");
3434            };
3435        "#,
3436                "test.js",
3437            )
3438            .unwrap();
3439
3440        // Drain any setup commands
3441        while rx.try_recv().is_ok() {}
3442
3443        // Execute the action
3444        backend.execute_action("my_sync_action").await.unwrap();
3445
3446        // Check the command was sent
3447        let cmd = rx.try_recv().unwrap();
3448        match cmd {
3449            PluginCommand::SetStatus { message } => {
3450                assert_eq!(message, "sync action executed");
3451            }
3452            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3453        }
3454    }
3455
3456    #[tokio::test]
3457    async fn test_execute_action_async_function() {
3458        let (mut backend, rx) = create_test_backend();
3459
3460        // Register the action explicitly
3461        backend.registered_actions.borrow_mut().insert(
3462            "my_async_action".to_string(),
3463            PluginHandler {
3464                plugin_name: "test".to_string(),
3465                handler_name: "my_async_action".to_string(),
3466            },
3467        );
3468
3469        // Define an async function
3470        backend
3471            .execute_js(
3472                r#"
3473            const editor = getEditor();
3474            globalThis.my_async_action = async function() {
3475                await Promise.resolve();
3476                editor.setStatus("async action executed");
3477            };
3478        "#,
3479                "test.js",
3480            )
3481            .unwrap();
3482
3483        // Drain any setup commands
3484        while rx.try_recv().is_ok() {}
3485
3486        // Execute the action
3487        backend.execute_action("my_async_action").await.unwrap();
3488
3489        // Check the command was sent (async should complete)
3490        let cmd = rx.try_recv().unwrap();
3491        match cmd {
3492            PluginCommand::SetStatus { message } => {
3493                assert_eq!(message, "async action executed");
3494            }
3495            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3496        }
3497    }
3498
3499    #[tokio::test]
3500    async fn test_execute_action_with_registered_handler() {
3501        let (mut backend, rx) = create_test_backend();
3502
3503        // Register an action with a different handler name
3504        backend.registered_actions.borrow_mut().insert(
3505            "my_action".to_string(),
3506            PluginHandler {
3507                plugin_name: "test".to_string(),
3508                handler_name: "actual_handler_function".to_string(),
3509            },
3510        );
3511
3512        backend
3513            .execute_js(
3514                r#"
3515            const editor = getEditor();
3516            globalThis.actual_handler_function = function() {
3517                editor.setStatus("handler executed");
3518            };
3519        "#,
3520                "test.js",
3521            )
3522            .unwrap();
3523
3524        // Drain any setup commands
3525        while rx.try_recv().is_ok() {}
3526
3527        // Execute the action by name (should resolve to handler)
3528        backend.execute_action("my_action").await.unwrap();
3529
3530        let cmd = rx.try_recv().unwrap();
3531        match cmd {
3532            PluginCommand::SetStatus { message } => {
3533                assert_eq!(message, "handler executed");
3534            }
3535            _ => panic!("Expected SetStatus, got {:?}", cmd),
3536        }
3537    }
3538
3539    #[test]
3540    fn test_api_on_event_registration() {
3541        let (mut backend, _rx) = create_test_backend();
3542
3543        backend
3544            .execute_js(
3545                r#"
3546            const editor = getEditor();
3547            globalThis.myEventHandler = function() { };
3548            editor.on("bufferSave", "myEventHandler");
3549        "#,
3550                "test.js",
3551            )
3552            .unwrap();
3553
3554        assert!(backend.has_handlers("bufferSave"));
3555    }
3556
3557    #[test]
3558    fn test_api_off_event_unregistration() {
3559        let (mut backend, _rx) = create_test_backend();
3560
3561        backend
3562            .execute_js(
3563                r#"
3564            const editor = getEditor();
3565            globalThis.myEventHandler = function() { };
3566            editor.on("bufferSave", "myEventHandler");
3567            editor.off("bufferSave", "myEventHandler");
3568        "#,
3569                "test.js",
3570            )
3571            .unwrap();
3572
3573        // Handler should be removed
3574        assert!(!backend.has_handlers("bufferSave"));
3575    }
3576
3577    #[tokio::test]
3578    async fn test_emit_event() {
3579        let (mut backend, rx) = create_test_backend();
3580
3581        backend
3582            .execute_js(
3583                r#"
3584            const editor = getEditor();
3585            globalThis.onSaveHandler = function(data) {
3586                editor.setStatus("saved: " + JSON.stringify(data));
3587            };
3588            editor.on("bufferSave", "onSaveHandler");
3589        "#,
3590                "test.js",
3591            )
3592            .unwrap();
3593
3594        // Drain setup commands
3595        while rx.try_recv().is_ok() {}
3596
3597        // Emit the event
3598        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
3599        backend.emit("bufferSave", &event_data).await.unwrap();
3600
3601        let cmd = rx.try_recv().unwrap();
3602        match cmd {
3603            PluginCommand::SetStatus { message } => {
3604                assert!(message.contains("/test.txt"));
3605            }
3606            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
3607        }
3608    }
3609
3610    #[test]
3611    fn test_api_copy_to_clipboard() {
3612        let (mut backend, rx) = create_test_backend();
3613
3614        backend
3615            .execute_js(
3616                r#"
3617            const editor = getEditor();
3618            editor.copyToClipboard("clipboard text");
3619        "#,
3620                "test.js",
3621            )
3622            .unwrap();
3623
3624        let cmd = rx.try_recv().unwrap();
3625        match cmd {
3626            PluginCommand::SetClipboard { text } => {
3627                assert_eq!(text, "clipboard text");
3628            }
3629            _ => panic!("Expected SetClipboard, got {:?}", cmd),
3630        }
3631    }
3632
3633    #[test]
3634    fn test_api_open_file() {
3635        let (mut backend, rx) = create_test_backend();
3636
3637        // openFile takes (path, line?, column?)
3638        backend
3639            .execute_js(
3640                r#"
3641            const editor = getEditor();
3642            editor.openFile("/path/to/file.txt", null, null);
3643        "#,
3644                "test.js",
3645            )
3646            .unwrap();
3647
3648        let cmd = rx.try_recv().unwrap();
3649        match cmd {
3650            PluginCommand::OpenFileAtLocation { path, line, column } => {
3651                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
3652                assert!(line.is_none());
3653                assert!(column.is_none());
3654            }
3655            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
3656        }
3657    }
3658
3659    #[test]
3660    fn test_api_delete_range() {
3661        let (mut backend, rx) = create_test_backend();
3662
3663        // deleteRange takes (buffer_id, start, end)
3664        backend
3665            .execute_js(
3666                r#"
3667            const editor = getEditor();
3668            editor.deleteRange(0, 10, 20);
3669        "#,
3670                "test.js",
3671            )
3672            .unwrap();
3673
3674        let cmd = rx.try_recv().unwrap();
3675        match cmd {
3676            PluginCommand::DeleteRange { range, .. } => {
3677                assert_eq!(range.start, 10);
3678                assert_eq!(range.end, 20);
3679            }
3680            _ => panic!("Expected DeleteRange, got {:?}", cmd),
3681        }
3682    }
3683
3684    #[test]
3685    fn test_api_insert_text() {
3686        let (mut backend, rx) = create_test_backend();
3687
3688        // insertText takes (buffer_id, position, text)
3689        backend
3690            .execute_js(
3691                r#"
3692            const editor = getEditor();
3693            editor.insertText(0, 5, "inserted");
3694        "#,
3695                "test.js",
3696            )
3697            .unwrap();
3698
3699        let cmd = rx.try_recv().unwrap();
3700        match cmd {
3701            PluginCommand::InsertText { position, text, .. } => {
3702                assert_eq!(position, 5);
3703                assert_eq!(text, "inserted");
3704            }
3705            _ => panic!("Expected InsertText, got {:?}", cmd),
3706        }
3707    }
3708
3709    #[test]
3710    fn test_api_set_buffer_cursor() {
3711        let (mut backend, rx) = create_test_backend();
3712
3713        // setBufferCursor takes (buffer_id, position)
3714        backend
3715            .execute_js(
3716                r#"
3717            const editor = getEditor();
3718            editor.setBufferCursor(0, 100);
3719        "#,
3720                "test.js",
3721            )
3722            .unwrap();
3723
3724        let cmd = rx.try_recv().unwrap();
3725        match cmd {
3726            PluginCommand::SetBufferCursor { position, .. } => {
3727                assert_eq!(position, 100);
3728            }
3729            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
3730        }
3731    }
3732
3733    #[test]
3734    fn test_api_get_cursor_position_from_state() {
3735        let (tx, _rx) = mpsc::channel();
3736        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3737
3738        // Set up cursor position in state
3739        {
3740            let mut state = state_snapshot.write().unwrap();
3741            state.primary_cursor = Some(CursorInfo {
3742                position: 42,
3743                selection: None,
3744            });
3745        }
3746
3747        let services = Arc::new(fresh_core::services::NoopServiceBridge);
3748        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3749
3750        // Execute JS that reads and stores cursor position
3751        backend
3752            .execute_js(
3753                r#"
3754            const editor = getEditor();
3755            const pos = editor.getCursorPosition();
3756            globalThis._testResult = pos;
3757        "#,
3758                "test.js",
3759            )
3760            .unwrap();
3761
3762        // Verify by reading back - getCursorPosition returns byte offset as u32
3763        backend
3764            .plugin_contexts
3765            .borrow()
3766            .get("test")
3767            .unwrap()
3768            .clone()
3769            .with(|ctx| {
3770                let global = ctx.globals();
3771                let result: u32 = global.get("_testResult").unwrap();
3772                assert_eq!(result, 42);
3773            });
3774    }
3775
3776    #[test]
3777    fn test_api_path_functions() {
3778        let (mut backend, _rx) = create_test_backend();
3779
3780        // Use platform-appropriate absolute path for isAbsolute test
3781        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
3782        #[cfg(windows)]
3783        let absolute_path = r#"C:\\foo\\bar"#;
3784        #[cfg(not(windows))]
3785        let absolute_path = "/foo/bar";
3786
3787        // pathJoin takes an array of path parts
3788        let js_code = format!(
3789            r#"
3790            const editor = getEditor();
3791            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
3792            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
3793            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
3794            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
3795            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
3796            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
3797        "#,
3798            absolute_path
3799        );
3800        backend.execute_js(&js_code, "test.js").unwrap();
3801
3802        backend
3803            .plugin_contexts
3804            .borrow()
3805            .get("test")
3806            .unwrap()
3807            .clone()
3808            .with(|ctx| {
3809                let global = ctx.globals();
3810                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
3811                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
3812                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
3813                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
3814                assert!(!global.get::<_, bool>("_isRelative").unwrap());
3815                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
3816            });
3817    }
3818
3819    #[test]
3820    fn test_typescript_transpilation() {
3821        use fresh_parser_js::transpile_typescript;
3822
3823        let (mut backend, rx) = create_test_backend();
3824
3825        // TypeScript code with type annotations
3826        let ts_code = r#"
3827            const editor = getEditor();
3828            function greet(name: string): string {
3829                return "Hello, " + name;
3830            }
3831            editor.setStatus(greet("TypeScript"));
3832        "#;
3833
3834        // Transpile to JavaScript first
3835        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
3836
3837        // Execute the transpiled JavaScript
3838        backend.execute_js(&js_code, "test.js").unwrap();
3839
3840        let cmd = rx.try_recv().unwrap();
3841        match cmd {
3842            PluginCommand::SetStatus { message } => {
3843                assert_eq!(message, "Hello, TypeScript");
3844            }
3845            _ => panic!("Expected SetStatus, got {:?}", cmd),
3846        }
3847    }
3848
3849    #[test]
3850    fn test_api_get_buffer_text_sends_command() {
3851        let (mut backend, rx) = create_test_backend();
3852
3853        // Call getBufferText - this returns a Promise and sends the command
3854        backend
3855            .execute_js(
3856                r#"
3857            const editor = getEditor();
3858            // Store the promise for later
3859            globalThis._textPromise = editor.getBufferText(0, 10, 20);
3860        "#,
3861                "test.js",
3862            )
3863            .unwrap();
3864
3865        // Verify the GetBufferText command was sent
3866        let cmd = rx.try_recv().unwrap();
3867        match cmd {
3868            PluginCommand::GetBufferText {
3869                buffer_id,
3870                start,
3871                end,
3872                request_id,
3873            } => {
3874                assert_eq!(buffer_id.0, 0);
3875                assert_eq!(start, 10);
3876                assert_eq!(end, 20);
3877                assert!(request_id > 0); // Should have a valid request ID
3878            }
3879            _ => panic!("Expected GetBufferText, got {:?}", cmd),
3880        }
3881    }
3882
3883    #[test]
3884    fn test_api_get_buffer_text_resolves_callback() {
3885        let (mut backend, rx) = create_test_backend();
3886
3887        // Call getBufferText and set up a handler for when it resolves
3888        backend
3889            .execute_js(
3890                r#"
3891            const editor = getEditor();
3892            globalThis._resolvedText = null;
3893            editor.getBufferText(0, 0, 100).then(text => {
3894                globalThis._resolvedText = text;
3895            });
3896        "#,
3897                "test.js",
3898            )
3899            .unwrap();
3900
3901        // Get the request_id from the command
3902        let request_id = match rx.try_recv().unwrap() {
3903            PluginCommand::GetBufferText { request_id, .. } => request_id,
3904            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
3905        };
3906
3907        // Simulate the editor responding with the text
3908        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
3909
3910        // Drive the Promise to completion
3911        backend
3912            .plugin_contexts
3913            .borrow()
3914            .get("test")
3915            .unwrap()
3916            .clone()
3917            .with(|ctx| {
3918                run_pending_jobs_checked(&ctx, "test async getText");
3919            });
3920
3921        // Verify the Promise resolved with the text
3922        backend
3923            .plugin_contexts
3924            .borrow()
3925            .get("test")
3926            .unwrap()
3927            .clone()
3928            .with(|ctx| {
3929                let global = ctx.globals();
3930                let result: String = global.get("_resolvedText").unwrap();
3931                assert_eq!(result, "hello world");
3932            });
3933    }
3934
3935    #[test]
3936    fn test_plugin_translation() {
3937        let (mut backend, _rx) = create_test_backend();
3938
3939        // The t() function should work (returns key if translation not found)
3940        backend
3941            .execute_js(
3942                r#"
3943            const editor = getEditor();
3944            globalThis._translated = editor.t("test.key");
3945        "#,
3946                "test.js",
3947            )
3948            .unwrap();
3949
3950        backend
3951            .plugin_contexts
3952            .borrow()
3953            .get("test")
3954            .unwrap()
3955            .clone()
3956            .with(|ctx| {
3957                let global = ctx.globals();
3958                // Without actual translations, it returns the key
3959                let result: String = global.get("_translated").unwrap();
3960                assert_eq!(result, "test.key");
3961            });
3962    }
3963
3964    #[test]
3965    fn test_plugin_translation_with_registered_strings() {
3966        let (mut backend, _rx) = create_test_backend();
3967
3968        // Register translations for the test plugin
3969        let mut en_strings = std::collections::HashMap::new();
3970        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
3971        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
3972
3973        let mut strings = std::collections::HashMap::new();
3974        strings.insert("en".to_string(), en_strings);
3975
3976        // Register for "test" plugin
3977        if let Some(bridge) = backend
3978            .services
3979            .as_any()
3980            .downcast_ref::<TestServiceBridge>()
3981        {
3982            let mut en = bridge.en_strings.lock().unwrap();
3983            en.insert("greeting".to_string(), "Hello, World!".to_string());
3984            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
3985        }
3986
3987        // Test translation
3988        backend
3989            .execute_js(
3990                r#"
3991            const editor = getEditor();
3992            globalThis._greeting = editor.t("greeting");
3993            globalThis._prompt = editor.t("prompt.find_file");
3994            globalThis._missing = editor.t("nonexistent.key");
3995        "#,
3996                "test.js",
3997            )
3998            .unwrap();
3999
4000        backend
4001            .plugin_contexts
4002            .borrow()
4003            .get("test")
4004            .unwrap()
4005            .clone()
4006            .with(|ctx| {
4007                let global = ctx.globals();
4008                let greeting: String = global.get("_greeting").unwrap();
4009                assert_eq!(greeting, "Hello, World!");
4010
4011                let prompt: String = global.get("_prompt").unwrap();
4012                assert_eq!(prompt, "Find file: ");
4013
4014                // Missing key should return the key itself
4015                let missing: String = global.get("_missing").unwrap();
4016                assert_eq!(missing, "nonexistent.key");
4017            });
4018    }
4019
4020    // ==================== Line Indicator Tests ====================
4021
4022    #[test]
4023    fn test_api_set_line_indicator() {
4024        let (mut backend, rx) = create_test_backend();
4025
4026        backend
4027            .execute_js(
4028                r#"
4029            const editor = getEditor();
4030            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4031        "#,
4032                "test.js",
4033            )
4034            .unwrap();
4035
4036        let cmd = rx.try_recv().unwrap();
4037        match cmd {
4038            PluginCommand::SetLineIndicator {
4039                buffer_id,
4040                line,
4041                namespace,
4042                symbol,
4043                color,
4044                priority,
4045            } => {
4046                assert_eq!(buffer_id.0, 1);
4047                assert_eq!(line, 5);
4048                assert_eq!(namespace, "test-ns");
4049                assert_eq!(symbol, "●");
4050                assert_eq!(color, (255, 0, 0));
4051                assert_eq!(priority, 10);
4052            }
4053            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4054        }
4055    }
4056
4057    #[test]
4058    fn test_api_clear_line_indicators() {
4059        let (mut backend, rx) = create_test_backend();
4060
4061        backend
4062            .execute_js(
4063                r#"
4064            const editor = getEditor();
4065            editor.clearLineIndicators(1, "test-ns");
4066        "#,
4067                "test.js",
4068            )
4069            .unwrap();
4070
4071        let cmd = rx.try_recv().unwrap();
4072        match cmd {
4073            PluginCommand::ClearLineIndicators {
4074                buffer_id,
4075                namespace,
4076            } => {
4077                assert_eq!(buffer_id.0, 1);
4078                assert_eq!(namespace, "test-ns");
4079            }
4080            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4081        }
4082    }
4083
4084    // ==================== Virtual Buffer Tests ====================
4085
4086    #[test]
4087    fn test_api_create_virtual_buffer_sends_command() {
4088        let (mut backend, rx) = create_test_backend();
4089
4090        backend
4091            .execute_js(
4092                r#"
4093            const editor = getEditor();
4094            editor.createVirtualBuffer({
4095                name: "*Test Buffer*",
4096                mode: "test-mode",
4097                readOnly: true,
4098                entries: [
4099                    { text: "Line 1\n", properties: { type: "header" } },
4100                    { text: "Line 2\n", properties: { type: "content" } }
4101                ],
4102                showLineNumbers: false,
4103                showCursors: true,
4104                editingDisabled: true
4105            });
4106        "#,
4107                "test.js",
4108            )
4109            .unwrap();
4110
4111        let cmd = rx.try_recv().unwrap();
4112        match cmd {
4113            PluginCommand::CreateVirtualBufferWithContent {
4114                name,
4115                mode,
4116                read_only,
4117                entries,
4118                show_line_numbers,
4119                show_cursors,
4120                editing_disabled,
4121                ..
4122            } => {
4123                assert_eq!(name, "*Test Buffer*");
4124                assert_eq!(mode, "test-mode");
4125                assert!(read_only);
4126                assert_eq!(entries.len(), 2);
4127                assert_eq!(entries[0].text, "Line 1\n");
4128                assert!(!show_line_numbers);
4129                assert!(show_cursors);
4130                assert!(editing_disabled);
4131            }
4132            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4133        }
4134    }
4135
4136    #[test]
4137    fn test_api_set_virtual_buffer_content() {
4138        let (mut backend, rx) = create_test_backend();
4139
4140        backend
4141            .execute_js(
4142                r#"
4143            const editor = getEditor();
4144            editor.setVirtualBufferContent(5, [
4145                { text: "New content\n", properties: { type: "updated" } }
4146            ]);
4147        "#,
4148                "test.js",
4149            )
4150            .unwrap();
4151
4152        let cmd = rx.try_recv().unwrap();
4153        match cmd {
4154            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4155                assert_eq!(buffer_id.0, 5);
4156                assert_eq!(entries.len(), 1);
4157                assert_eq!(entries[0].text, "New content\n");
4158            }
4159            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4160        }
4161    }
4162
4163    // ==================== Overlay Tests ====================
4164
4165    #[test]
4166    fn test_api_add_overlay() {
4167        let (mut backend, rx) = create_test_backend();
4168
4169        backend.execute_js(r#"
4170            const editor = getEditor();
4171            editor.addOverlay(1, "highlight", 10, 20, 255, 128, 0, false, true, false, 50, 50, 50, false);
4172        "#, "test.js").unwrap();
4173
4174        let cmd = rx.try_recv().unwrap();
4175        match cmd {
4176            PluginCommand::AddOverlay {
4177                buffer_id,
4178                namespace,
4179                range,
4180                color,
4181                bg_color,
4182                underline,
4183                bold,
4184                italic,
4185                extend_to_line_end,
4186            } => {
4187                assert_eq!(buffer_id.0, 1);
4188                assert!(namespace.is_some());
4189                assert_eq!(namespace.unwrap().as_str(), "highlight");
4190                assert_eq!(range, 10..20);
4191                assert_eq!(color, (255, 128, 0));
4192                assert_eq!(bg_color, Some((50, 50, 50)));
4193                assert!(!underline);
4194                assert!(bold);
4195                assert!(!italic);
4196                assert!(!extend_to_line_end);
4197            }
4198            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4199        }
4200    }
4201
4202    #[test]
4203    fn test_api_clear_namespace() {
4204        let (mut backend, rx) = create_test_backend();
4205
4206        backend
4207            .execute_js(
4208                r#"
4209            const editor = getEditor();
4210            editor.clearNamespace(1, "highlight");
4211        "#,
4212                "test.js",
4213            )
4214            .unwrap();
4215
4216        let cmd = rx.try_recv().unwrap();
4217        match cmd {
4218            PluginCommand::ClearNamespace {
4219                buffer_id,
4220                namespace,
4221            } => {
4222                assert_eq!(buffer_id.0, 1);
4223                assert_eq!(namespace.as_str(), "highlight");
4224            }
4225            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4226        }
4227    }
4228
4229    // ==================== Theme Tests ====================
4230
4231    #[test]
4232    fn test_api_get_theme_schema() {
4233        let (mut backend, _rx) = create_test_backend();
4234
4235        backend
4236            .execute_js(
4237                r#"
4238            const editor = getEditor();
4239            const schema = editor.getThemeSchema();
4240            globalThis._isObject = typeof schema === 'object' && schema !== null;
4241        "#,
4242                "test.js",
4243            )
4244            .unwrap();
4245
4246        backend
4247            .plugin_contexts
4248            .borrow()
4249            .get("test")
4250            .unwrap()
4251            .clone()
4252            .with(|ctx| {
4253                let global = ctx.globals();
4254                let is_object: bool = global.get("_isObject").unwrap();
4255                // getThemeSchema should return an object
4256                assert!(is_object);
4257            });
4258    }
4259
4260    #[test]
4261    fn test_api_get_builtin_themes() {
4262        let (mut backend, _rx) = create_test_backend();
4263
4264        backend
4265            .execute_js(
4266                r#"
4267            const editor = getEditor();
4268            const themes = editor.getBuiltinThemes();
4269            globalThis._isObject = typeof themes === 'object' && themes !== null;
4270        "#,
4271                "test.js",
4272            )
4273            .unwrap();
4274
4275        backend
4276            .plugin_contexts
4277            .borrow()
4278            .get("test")
4279            .unwrap()
4280            .clone()
4281            .with(|ctx| {
4282                let global = ctx.globals();
4283                let is_object: bool = global.get("_isObject").unwrap();
4284                // getBuiltinThemes should return an object
4285                assert!(is_object);
4286            });
4287    }
4288
4289    #[test]
4290    fn test_api_apply_theme() {
4291        let (mut backend, rx) = create_test_backend();
4292
4293        backend
4294            .execute_js(
4295                r#"
4296            const editor = getEditor();
4297            editor.applyTheme("dark");
4298        "#,
4299                "test.js",
4300            )
4301            .unwrap();
4302
4303        let cmd = rx.try_recv().unwrap();
4304        match cmd {
4305            PluginCommand::ApplyTheme { theme_name } => {
4306                assert_eq!(theme_name, "dark");
4307            }
4308            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4309        }
4310    }
4311
4312    // ==================== Buffer Operations Tests ====================
4313
4314    #[test]
4315    fn test_api_close_buffer() {
4316        let (mut backend, rx) = create_test_backend();
4317
4318        backend
4319            .execute_js(
4320                r#"
4321            const editor = getEditor();
4322            editor.closeBuffer(3);
4323        "#,
4324                "test.js",
4325            )
4326            .unwrap();
4327
4328        let cmd = rx.try_recv().unwrap();
4329        match cmd {
4330            PluginCommand::CloseBuffer { buffer_id } => {
4331                assert_eq!(buffer_id.0, 3);
4332            }
4333            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4334        }
4335    }
4336
4337    #[test]
4338    fn test_api_focus_split() {
4339        let (mut backend, rx) = create_test_backend();
4340
4341        backend
4342            .execute_js(
4343                r#"
4344            const editor = getEditor();
4345            editor.focusSplit(2);
4346        "#,
4347                "test.js",
4348            )
4349            .unwrap();
4350
4351        let cmd = rx.try_recv().unwrap();
4352        match cmd {
4353            PluginCommand::FocusSplit { split_id } => {
4354                assert_eq!(split_id.0, 2);
4355            }
4356            _ => panic!("Expected FocusSplit, got {:?}", cmd),
4357        }
4358    }
4359
4360    #[test]
4361    fn test_api_list_buffers() {
4362        let (tx, _rx) = mpsc::channel();
4363        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4364
4365        // Add some buffers to state
4366        {
4367            let mut state = state_snapshot.write().unwrap();
4368            state.buffers.insert(
4369                BufferId(0),
4370                BufferInfo {
4371                    id: BufferId(0),
4372                    path: Some(PathBuf::from("/test1.txt")),
4373                    modified: false,
4374                    length: 100,
4375                },
4376            );
4377            state.buffers.insert(
4378                BufferId(1),
4379                BufferInfo {
4380                    id: BufferId(1),
4381                    path: Some(PathBuf::from("/test2.txt")),
4382                    modified: true,
4383                    length: 200,
4384                },
4385            );
4386        }
4387
4388        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4389        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4390
4391        backend
4392            .execute_js(
4393                r#"
4394            const editor = getEditor();
4395            const buffers = editor.listBuffers();
4396            globalThis._isArray = Array.isArray(buffers);
4397            globalThis._length = buffers.length;
4398        "#,
4399                "test.js",
4400            )
4401            .unwrap();
4402
4403        backend
4404            .plugin_contexts
4405            .borrow()
4406            .get("test")
4407            .unwrap()
4408            .clone()
4409            .with(|ctx| {
4410                let global = ctx.globals();
4411                let is_array: bool = global.get("_isArray").unwrap();
4412                let length: u32 = global.get("_length").unwrap();
4413                assert!(is_array);
4414                assert_eq!(length, 2);
4415            });
4416    }
4417
4418    // ==================== Prompt Tests ====================
4419
4420    #[test]
4421    fn test_api_start_prompt() {
4422        let (mut backend, rx) = create_test_backend();
4423
4424        backend
4425            .execute_js(
4426                r#"
4427            const editor = getEditor();
4428            editor.startPrompt("Enter value:", "test-prompt");
4429        "#,
4430                "test.js",
4431            )
4432            .unwrap();
4433
4434        let cmd = rx.try_recv().unwrap();
4435        match cmd {
4436            PluginCommand::StartPrompt { label, prompt_type } => {
4437                assert_eq!(label, "Enter value:");
4438                assert_eq!(prompt_type, "test-prompt");
4439            }
4440            _ => panic!("Expected StartPrompt, got {:?}", cmd),
4441        }
4442    }
4443
4444    #[test]
4445    fn test_api_start_prompt_with_initial() {
4446        let (mut backend, rx) = create_test_backend();
4447
4448        backend
4449            .execute_js(
4450                r#"
4451            const editor = getEditor();
4452            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4453        "#,
4454                "test.js",
4455            )
4456            .unwrap();
4457
4458        let cmd = rx.try_recv().unwrap();
4459        match cmd {
4460            PluginCommand::StartPromptWithInitial {
4461                label,
4462                prompt_type,
4463                initial_value,
4464            } => {
4465                assert_eq!(label, "Enter value:");
4466                assert_eq!(prompt_type, "test-prompt");
4467                assert_eq!(initial_value, "default");
4468            }
4469            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4470        }
4471    }
4472
4473    #[test]
4474    fn test_api_set_prompt_suggestions() {
4475        let (mut backend, rx) = create_test_backend();
4476
4477        backend
4478            .execute_js(
4479                r#"
4480            const editor = getEditor();
4481            editor.setPromptSuggestions([
4482                { text: "Option 1", value: "opt1" },
4483                { text: "Option 2", value: "opt2" }
4484            ]);
4485        "#,
4486                "test.js",
4487            )
4488            .unwrap();
4489
4490        let cmd = rx.try_recv().unwrap();
4491        match cmd {
4492            PluginCommand::SetPromptSuggestions { suggestions } => {
4493                assert_eq!(suggestions.len(), 2);
4494                assert_eq!(suggestions[0].text, "Option 1");
4495                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4496            }
4497            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4498        }
4499    }
4500
4501    // ==================== State Query Tests ====================
4502
4503    #[test]
4504    fn test_api_get_active_buffer_id() {
4505        let (tx, _rx) = mpsc::channel();
4506        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4507
4508        {
4509            let mut state = state_snapshot.write().unwrap();
4510            state.active_buffer_id = BufferId(42);
4511        }
4512
4513        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4514        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4515
4516        backend
4517            .execute_js(
4518                r#"
4519            const editor = getEditor();
4520            globalThis._activeId = editor.getActiveBufferId();
4521        "#,
4522                "test.js",
4523            )
4524            .unwrap();
4525
4526        backend
4527            .plugin_contexts
4528            .borrow()
4529            .get("test")
4530            .unwrap()
4531            .clone()
4532            .with(|ctx| {
4533                let global = ctx.globals();
4534                let result: u32 = global.get("_activeId").unwrap();
4535                assert_eq!(result, 42);
4536            });
4537    }
4538
4539    #[test]
4540    fn test_api_get_active_split_id() {
4541        let (tx, _rx) = mpsc::channel();
4542        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4543
4544        {
4545            let mut state = state_snapshot.write().unwrap();
4546            state.active_split_id = 7;
4547        }
4548
4549        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4550        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4551
4552        backend
4553            .execute_js(
4554                r#"
4555            const editor = getEditor();
4556            globalThis._splitId = editor.getActiveSplitId();
4557        "#,
4558                "test.js",
4559            )
4560            .unwrap();
4561
4562        backend
4563            .plugin_contexts
4564            .borrow()
4565            .get("test")
4566            .unwrap()
4567            .clone()
4568            .with(|ctx| {
4569                let global = ctx.globals();
4570                let result: u32 = global.get("_splitId").unwrap();
4571                assert_eq!(result, 7);
4572            });
4573    }
4574
4575    // ==================== File System Tests ====================
4576
4577    #[test]
4578    fn test_api_file_exists() {
4579        let (mut backend, _rx) = create_test_backend();
4580
4581        backend
4582            .execute_js(
4583                r#"
4584            const editor = getEditor();
4585            // Test with a path that definitely exists
4586            globalThis._exists = editor.fileExists("/");
4587        "#,
4588                "test.js",
4589            )
4590            .unwrap();
4591
4592        backend
4593            .plugin_contexts
4594            .borrow()
4595            .get("test")
4596            .unwrap()
4597            .clone()
4598            .with(|ctx| {
4599                let global = ctx.globals();
4600                let result: bool = global.get("_exists").unwrap();
4601                assert!(result);
4602            });
4603    }
4604
4605    #[test]
4606    fn test_api_get_cwd() {
4607        let (mut backend, _rx) = create_test_backend();
4608
4609        backend
4610            .execute_js(
4611                r#"
4612            const editor = getEditor();
4613            globalThis._cwd = editor.getCwd();
4614        "#,
4615                "test.js",
4616            )
4617            .unwrap();
4618
4619        backend
4620            .plugin_contexts
4621            .borrow()
4622            .get("test")
4623            .unwrap()
4624            .clone()
4625            .with(|ctx| {
4626                let global = ctx.globals();
4627                let result: String = global.get("_cwd").unwrap();
4628                // Should return some path
4629                assert!(!result.is_empty());
4630            });
4631    }
4632
4633    #[test]
4634    fn test_api_get_env() {
4635        let (mut backend, _rx) = create_test_backend();
4636
4637        // Set a test environment variable
4638        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
4639
4640        backend
4641            .execute_js(
4642                r#"
4643            const editor = getEditor();
4644            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
4645        "#,
4646                "test.js",
4647            )
4648            .unwrap();
4649
4650        backend
4651            .plugin_contexts
4652            .borrow()
4653            .get("test")
4654            .unwrap()
4655            .clone()
4656            .with(|ctx| {
4657                let global = ctx.globals();
4658                let result: Option<String> = global.get("_envVal").unwrap();
4659                assert_eq!(result, Some("test_value".to_string()));
4660            });
4661
4662        std::env::remove_var("TEST_PLUGIN_VAR");
4663    }
4664
4665    #[test]
4666    fn test_api_get_config() {
4667        let (mut backend, _rx) = create_test_backend();
4668
4669        backend
4670            .execute_js(
4671                r#"
4672            const editor = getEditor();
4673            const config = editor.getConfig();
4674            globalThis._isObject = typeof config === 'object';
4675        "#,
4676                "test.js",
4677            )
4678            .unwrap();
4679
4680        backend
4681            .plugin_contexts
4682            .borrow()
4683            .get("test")
4684            .unwrap()
4685            .clone()
4686            .with(|ctx| {
4687                let global = ctx.globals();
4688                let is_object: bool = global.get("_isObject").unwrap();
4689                // getConfig should return an object, not a string
4690                assert!(is_object);
4691            });
4692    }
4693
4694    #[test]
4695    fn test_api_get_themes_dir() {
4696        let (mut backend, _rx) = create_test_backend();
4697
4698        backend
4699            .execute_js(
4700                r#"
4701            const editor = getEditor();
4702            globalThis._themesDir = editor.getThemesDir();
4703        "#,
4704                "test.js",
4705            )
4706            .unwrap();
4707
4708        backend
4709            .plugin_contexts
4710            .borrow()
4711            .get("test")
4712            .unwrap()
4713            .clone()
4714            .with(|ctx| {
4715                let global = ctx.globals();
4716                let result: String = global.get("_themesDir").unwrap();
4717                // Should return some path
4718                assert!(!result.is_empty());
4719            });
4720    }
4721
4722    // ==================== Read Dir Test ====================
4723
4724    #[test]
4725    fn test_api_read_dir() {
4726        let (mut backend, _rx) = create_test_backend();
4727
4728        backend
4729            .execute_js(
4730                r#"
4731            const editor = getEditor();
4732            const entries = editor.readDir("/tmp");
4733            globalThis._isArray = Array.isArray(entries);
4734            globalThis._length = entries.length;
4735        "#,
4736                "test.js",
4737            )
4738            .unwrap();
4739
4740        backend
4741            .plugin_contexts
4742            .borrow()
4743            .get("test")
4744            .unwrap()
4745            .clone()
4746            .with(|ctx| {
4747                let global = ctx.globals();
4748                let is_array: bool = global.get("_isArray").unwrap();
4749                let length: u32 = global.get("_length").unwrap();
4750                // /tmp should exist and readDir should return an array
4751                assert!(is_array);
4752                // Length is valid (u32 always >= 0)
4753                let _ = length;
4754            });
4755    }
4756
4757    // ==================== Execute Action Test ====================
4758
4759    #[test]
4760    fn test_api_execute_action() {
4761        let (mut backend, rx) = create_test_backend();
4762
4763        backend
4764            .execute_js(
4765                r#"
4766            const editor = getEditor();
4767            editor.executeAction("move_cursor_up");
4768        "#,
4769                "test.js",
4770            )
4771            .unwrap();
4772
4773        let cmd = rx.try_recv().unwrap();
4774        match cmd {
4775            PluginCommand::ExecuteAction { action_name } => {
4776                assert_eq!(action_name, "move_cursor_up");
4777            }
4778            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
4779        }
4780    }
4781
4782    // ==================== Debug Test ====================
4783
4784    #[test]
4785    fn test_api_debug() {
4786        let (mut backend, _rx) = create_test_backend();
4787
4788        // debug() should not panic and should work with any input
4789        backend
4790            .execute_js(
4791                r#"
4792            const editor = getEditor();
4793            editor.debug("Test debug message");
4794            editor.debug("Another message with special chars: <>&\"'");
4795        "#,
4796                "test.js",
4797            )
4798            .unwrap();
4799        // If we get here without panic, the test passes
4800    }
4801
4802    // ==================== TypeScript Definitions Test ====================
4803
4804    #[test]
4805    fn test_typescript_preamble_generated() {
4806        // Check that the TypeScript preamble constant exists and has content
4807        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
4808        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
4809        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
4810        println!(
4811            "Generated {} bytes of TypeScript preamble",
4812            JSEDITORAPI_TS_PREAMBLE.len()
4813        );
4814    }
4815
4816    #[test]
4817    fn test_typescript_editor_api_generated() {
4818        // Check that the EditorAPI interface is generated
4819        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
4820        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
4821        println!(
4822            "Generated {} bytes of EditorAPI interface",
4823            JSEDITORAPI_TS_EDITOR_API.len()
4824        );
4825    }
4826
4827    #[test]
4828    fn test_js_methods_list() {
4829        // Check that the JS methods list is generated
4830        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
4831        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
4832        // Print first 20 methods
4833        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
4834            if i < 20 {
4835                println!("  - {}", method);
4836            }
4837        }
4838        if JSEDITORAPI_JS_METHODS.len() > 20 {
4839            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
4840        }
4841    }
4842}