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