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    /// Get all diagnostics from LSP
1924    #[plugin_api(ts_return = "JsDiagnostic[]")]
1925    pub fn get_all_diagnostics<'js>(
1926        &self,
1927        ctx: rquickjs::Ctx<'js>,
1928    ) -> rquickjs::Result<Value<'js>> {
1929        use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
1930
1931        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
1932            // Convert to JsDiagnostic format for JS
1933            let mut result: Vec<JsDiagnostic> = Vec::new();
1934            for (uri, diags) in &s.diagnostics {
1935                for diag in diags {
1936                    result.push(JsDiagnostic {
1937                        uri: uri.clone(),
1938                        message: diag.message.clone(),
1939                        severity: diag.severity.map(|s| match s {
1940                            lsp_types::DiagnosticSeverity::ERROR => 1,
1941                            lsp_types::DiagnosticSeverity::WARNING => 2,
1942                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
1943                            lsp_types::DiagnosticSeverity::HINT => 4,
1944                            _ => 0,
1945                        }),
1946                        range: JsRange {
1947                            start: JsPosition {
1948                                line: diag.range.start.line,
1949                                character: diag.range.start.character,
1950                            },
1951                            end: JsPosition {
1952                                line: diag.range.end.line,
1953                                character: diag.range.end.character,
1954                            },
1955                        },
1956                        source: diag.source.clone(),
1957                    });
1958                }
1959            }
1960            result
1961        } else {
1962            Vec::new()
1963        };
1964        rquickjs_serde::to_value(ctx, &diagnostics)
1965            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1966    }
1967
1968    /// Get registered event handlers for an event
1969    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
1970        self.event_handlers
1971            .borrow()
1972            .get(&event_name)
1973            .cloned()
1974            .unwrap_or_default()
1975            .into_iter()
1976            .map(|h| h.handler_name)
1977            .collect()
1978    }
1979
1980    // === Virtual Buffers ===
1981
1982    /// Create a virtual buffer in current split (async, returns buffer and split IDs)
1983    #[plugin_api(
1984        async_promise,
1985        js_name = "createVirtualBuffer",
1986        ts_return = "VirtualBufferResult"
1987    )]
1988    #[qjs(rename = "_createVirtualBufferStart")]
1989    pub fn create_virtual_buffer_start(
1990        &self,
1991        _ctx: rquickjs::Ctx<'_>,
1992        opts: fresh_core::api::CreateVirtualBufferOptions,
1993    ) -> rquickjs::Result<u64> {
1994        let id = {
1995            let mut id_ref = self.next_request_id.borrow_mut();
1996            let id = *id_ref;
1997            *id_ref += 1;
1998            // Record context for this callback
1999            self.callback_contexts
2000                .borrow_mut()
2001                .insert(id, self.plugin_name.clone());
2002            id
2003        };
2004
2005        // Convert JsTextPropertyEntry to TextPropertyEntry
2006        let entries: Vec<TextPropertyEntry> = opts
2007            .entries
2008            .unwrap_or_default()
2009            .into_iter()
2010            .map(|e| TextPropertyEntry {
2011                text: e.text,
2012                properties: e.properties.unwrap_or_default(),
2013            })
2014            .collect();
2015
2016        tracing::debug!(
2017            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2018            id
2019        );
2020        let _ = self
2021            .command_sender
2022            .send(PluginCommand::CreateVirtualBufferWithContent {
2023                name: opts.name,
2024                mode: opts.mode.unwrap_or_default(),
2025                read_only: opts.read_only.unwrap_or(false),
2026                entries,
2027                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2028                show_cursors: opts.show_cursors.unwrap_or(true),
2029                editing_disabled: opts.editing_disabled.unwrap_or(false),
2030                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2031                request_id: Some(id),
2032            });
2033        Ok(id)
2034    }
2035
2036    /// Create a virtual buffer in a new split (async, returns buffer and split IDs)
2037    #[plugin_api(
2038        async_promise,
2039        js_name = "createVirtualBufferInSplit",
2040        ts_return = "VirtualBufferResult"
2041    )]
2042    #[qjs(rename = "_createVirtualBufferInSplitStart")]
2043    pub fn create_virtual_buffer_in_split_start(
2044        &self,
2045        _ctx: rquickjs::Ctx<'_>,
2046        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2047    ) -> rquickjs::Result<u64> {
2048        let id = {
2049            let mut id_ref = self.next_request_id.borrow_mut();
2050            let id = *id_ref;
2051            *id_ref += 1;
2052            // Record context for this callback
2053            self.callback_contexts
2054                .borrow_mut()
2055                .insert(id, self.plugin_name.clone());
2056            id
2057        };
2058
2059        // Convert JsTextPropertyEntry to TextPropertyEntry
2060        let entries: Vec<TextPropertyEntry> = opts
2061            .entries
2062            .unwrap_or_default()
2063            .into_iter()
2064            .map(|e| TextPropertyEntry {
2065                text: e.text,
2066                properties: e.properties.unwrap_or_default(),
2067            })
2068            .collect();
2069
2070        let _ = self
2071            .command_sender
2072            .send(PluginCommand::CreateVirtualBufferInSplit {
2073                name: opts.name,
2074                mode: opts.mode.unwrap_or_default(),
2075                read_only: opts.read_only.unwrap_or(false),
2076                entries,
2077                ratio: opts.ratio.unwrap_or(0.5),
2078                direction: opts.direction,
2079                panel_id: opts.panel_id,
2080                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2081                show_cursors: opts.show_cursors.unwrap_or(true),
2082                editing_disabled: opts.editing_disabled.unwrap_or(false),
2083                line_wrap: opts.line_wrap,
2084                request_id: Some(id),
2085            });
2086        Ok(id)
2087    }
2088
2089    /// Create a virtual buffer in an existing split (async, returns buffer and split IDs)
2090    #[plugin_api(
2091        async_promise,
2092        js_name = "createVirtualBufferInExistingSplit",
2093        ts_return = "VirtualBufferResult"
2094    )]
2095    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2096    pub fn create_virtual_buffer_in_existing_split_start(
2097        &self,
2098        _ctx: rquickjs::Ctx<'_>,
2099        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2100    ) -> rquickjs::Result<u64> {
2101        let id = {
2102            let mut id_ref = self.next_request_id.borrow_mut();
2103            let id = *id_ref;
2104            *id_ref += 1;
2105            // Record context for this callback
2106            self.callback_contexts
2107                .borrow_mut()
2108                .insert(id, self.plugin_name.clone());
2109            id
2110        };
2111
2112        // Convert JsTextPropertyEntry to TextPropertyEntry
2113        let entries: Vec<TextPropertyEntry> = opts
2114            .entries
2115            .unwrap_or_default()
2116            .into_iter()
2117            .map(|e| TextPropertyEntry {
2118                text: e.text,
2119                properties: e.properties.unwrap_or_default(),
2120            })
2121            .collect();
2122
2123        let _ = self
2124            .command_sender
2125            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2126                name: opts.name,
2127                mode: opts.mode.unwrap_or_default(),
2128                read_only: opts.read_only.unwrap_or(false),
2129                entries,
2130                split_id: SplitId(opts.split_id),
2131                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2132                show_cursors: opts.show_cursors.unwrap_or(true),
2133                editing_disabled: opts.editing_disabled.unwrap_or(false),
2134                line_wrap: opts.line_wrap,
2135                request_id: Some(id),
2136            });
2137        Ok(id)
2138    }
2139
2140    /// Set virtual buffer content (takes array of entry objects)
2141    ///
2142    /// Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
2143    pub fn set_virtual_buffer_content<'js>(
2144        &self,
2145        ctx: rquickjs::Ctx<'js>,
2146        buffer_id: u32,
2147        entries_arr: Vec<rquickjs::Object<'js>>,
2148    ) -> rquickjs::Result<bool> {
2149        let entries: Vec<TextPropertyEntry> = entries_arr
2150            .iter()
2151            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2152            .collect();
2153        Ok(self
2154            .command_sender
2155            .send(PluginCommand::SetVirtualBufferContent {
2156                buffer_id: BufferId(buffer_id as usize),
2157                entries,
2158            })
2159            .is_ok())
2160    }
2161
2162    /// Get text properties at cursor position (returns JS array)
2163    pub fn get_text_properties_at_cursor(
2164        &self,
2165        buffer_id: u32,
2166    ) -> fresh_core::api::TextPropertiesAtCursor {
2167        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2168    }
2169
2170    // === Async Operations ===
2171
2172    /// Spawn a process (async, returns request_id)
2173    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2174    #[qjs(rename = "_spawnProcessStart")]
2175    pub fn spawn_process_start(
2176        &self,
2177        _ctx: rquickjs::Ctx<'_>,
2178        command: String,
2179        args: Vec<String>,
2180        cwd: rquickjs::function::Opt<String>,
2181    ) -> u64 {
2182        let id = {
2183            let mut id_ref = self.next_request_id.borrow_mut();
2184            let id = *id_ref;
2185            *id_ref += 1;
2186            // Record context for this callback
2187            self.callback_contexts
2188                .borrow_mut()
2189                .insert(id, self.plugin_name.clone());
2190            id
2191        };
2192        // Use provided cwd, or fall back to snapshot's working_dir
2193        let effective_cwd = cwd.0.or_else(|| {
2194            self.state_snapshot
2195                .read()
2196                .ok()
2197                .map(|s| s.working_dir.to_string_lossy().to_string())
2198        });
2199        tracing::info!(
2200            "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2201            command,
2202            args,
2203            effective_cwd,
2204            id
2205        );
2206        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2207            callback_id: JsCallbackId::new(id),
2208            command,
2209            args,
2210            cwd: effective_cwd,
2211        });
2212        id
2213    }
2214
2215    /// Wait for a process to complete and get its result (async)
2216    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2217    #[qjs(rename = "_spawnProcessWaitStart")]
2218    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2219        let id = {
2220            let mut id_ref = self.next_request_id.borrow_mut();
2221            let id = *id_ref;
2222            *id_ref += 1;
2223            // Record context for this callback
2224            self.callback_contexts
2225                .borrow_mut()
2226                .insert(id, self.plugin_name.clone());
2227            id
2228        };
2229        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2230            process_id,
2231            callback_id: JsCallbackId::new(id),
2232        });
2233        id
2234    }
2235
2236    /// Get buffer text range (async, returns request_id)
2237    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2238    #[qjs(rename = "_getBufferTextStart")]
2239    pub fn get_buffer_text_start(
2240        &self,
2241        _ctx: rquickjs::Ctx<'_>,
2242        buffer_id: u32,
2243        start: u32,
2244        end: u32,
2245    ) -> u64 {
2246        let id = {
2247            let mut id_ref = self.next_request_id.borrow_mut();
2248            let id = *id_ref;
2249            *id_ref += 1;
2250            // Record context for this callback
2251            self.callback_contexts
2252                .borrow_mut()
2253                .insert(id, self.plugin_name.clone());
2254            id
2255        };
2256        let _ = self.command_sender.send(PluginCommand::GetBufferText {
2257            buffer_id: BufferId(buffer_id as usize),
2258            start: start as usize,
2259            end: end as usize,
2260            request_id: id,
2261        });
2262        id
2263    }
2264
2265    /// Delay/sleep (async, returns request_id)
2266    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2267    #[qjs(rename = "_delayStart")]
2268    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2269        let id = {
2270            let mut id_ref = self.next_request_id.borrow_mut();
2271            let id = *id_ref;
2272            *id_ref += 1;
2273            // Record context for this callback
2274            self.callback_contexts
2275                .borrow_mut()
2276                .insert(id, self.plugin_name.clone());
2277            id
2278        };
2279        let _ = self.command_sender.send(PluginCommand::Delay {
2280            callback_id: JsCallbackId::new(id),
2281            duration_ms,
2282        });
2283        id
2284    }
2285
2286    /// Send LSP request (async, returns request_id)
2287    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2288    #[qjs(rename = "_sendLspRequestStart")]
2289    pub fn send_lsp_request_start<'js>(
2290        &self,
2291        ctx: rquickjs::Ctx<'js>,
2292        language: String,
2293        method: String,
2294        params: Option<rquickjs::Object<'js>>,
2295    ) -> rquickjs::Result<u64> {
2296        let id = {
2297            let mut id_ref = self.next_request_id.borrow_mut();
2298            let id = *id_ref;
2299            *id_ref += 1;
2300            // Record context for this callback
2301            self.callback_contexts
2302                .borrow_mut()
2303                .insert(id, self.plugin_name.clone());
2304            id
2305        };
2306        // Convert params object to serde_json::Value
2307        let params_json: Option<serde_json::Value> = params.map(|obj| {
2308            let val = obj.into_value();
2309            js_to_json(&ctx, val)
2310        });
2311        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2312            request_id: id,
2313            language,
2314            method,
2315            params: params_json,
2316        });
2317        Ok(id)
2318    }
2319
2320    /// Spawn a background process (async, returns request_id which is also process_id)
2321    #[plugin_api(
2322        async_thenable,
2323        js_name = "spawnBackgroundProcess",
2324        ts_return = "BackgroundProcessResult"
2325    )]
2326    #[qjs(rename = "_spawnBackgroundProcessStart")]
2327    pub fn spawn_background_process_start(
2328        &self,
2329        _ctx: rquickjs::Ctx<'_>,
2330        command: String,
2331        args: Vec<String>,
2332        cwd: rquickjs::function::Opt<String>,
2333    ) -> u64 {
2334        let id = {
2335            let mut id_ref = self.next_request_id.borrow_mut();
2336            let id = *id_ref;
2337            *id_ref += 1;
2338            // Record context for this callback
2339            self.callback_contexts
2340                .borrow_mut()
2341                .insert(id, self.plugin_name.clone());
2342            id
2343        };
2344        // Use id as process_id for simplicity
2345        let process_id = id;
2346        let _ = self
2347            .command_sender
2348            .send(PluginCommand::SpawnBackgroundProcess {
2349                process_id,
2350                command,
2351                args,
2352                cwd: cwd.0,
2353                callback_id: JsCallbackId::new(id),
2354            });
2355        id
2356    }
2357
2358    /// Kill a background process
2359    pub fn kill_background_process(&self, process_id: u64) -> bool {
2360        self.command_sender
2361            .send(PluginCommand::KillBackgroundProcess { process_id })
2362            .is_ok()
2363    }
2364
2365    // === Misc ===
2366
2367    /// Force refresh of line display
2368    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2369        self.command_sender
2370            .send(PluginCommand::RefreshLines {
2371                buffer_id: BufferId(buffer_id as usize),
2372            })
2373            .is_ok()
2374    }
2375
2376    /// Get the current locale
2377    pub fn get_current_locale(&self) -> String {
2378        self.services.current_locale()
2379    }
2380}
2381
2382/// QuickJS-based JavaScript runtime for plugins
2383pub struct QuickJsBackend {
2384    runtime: Runtime,
2385    /// Main context for shared/internal operations
2386    main_context: Context,
2387    /// Plugin-specific contexts: plugin_name -> Context
2388    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2389    /// Event handlers: event_name -> list of PluginHandler
2390    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2391    /// Registered actions: action_name -> PluginHandler
2392    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2393    /// Editor state snapshot (read-only access)
2394    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2395    /// Command sender for write operations
2396    command_sender: mpsc::Sender<PluginCommand>,
2397    /// Pending response senders for async operations (held to keep Arc alive)
2398    #[allow(dead_code)]
2399    pending_responses: PendingResponses,
2400    /// Next request ID for async operations
2401    next_request_id: Rc<RefCell<u64>>,
2402    /// Plugin name for each pending callback ID
2403    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2404    /// Bridge for editor services (i18n, theme, etc.)
2405    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2406}
2407
2408impl QuickJsBackend {
2409    /// Create a new QuickJS backend (standalone, for testing)
2410    pub fn new() -> Result<Self> {
2411        let (tx, _rx) = mpsc::channel();
2412        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2413        let services = Arc::new(fresh_core::services::NoopServiceBridge);
2414        Self::with_state(state_snapshot, tx, services)
2415    }
2416
2417    /// Create a new QuickJS backend with editor state
2418    pub fn with_state(
2419        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2420        command_sender: mpsc::Sender<PluginCommand>,
2421        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2422    ) -> Result<Self> {
2423        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2424        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2425    }
2426
2427    /// Create a new QuickJS backend with editor state and shared pending responses
2428    pub fn with_state_and_responses(
2429        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2430        command_sender: mpsc::Sender<PluginCommand>,
2431        pending_responses: PendingResponses,
2432        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2433    ) -> Result<Self> {
2434        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2435
2436        let runtime =
2437            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2438
2439        // Set up promise rejection tracker to catch unhandled rejections
2440        runtime.set_host_promise_rejection_tracker(Some(Box::new(
2441            |_ctx, _promise, reason, is_handled| {
2442                if !is_handled {
2443                    // Format the rejection reason
2444                    let error_msg = if let Some(exc) = reason.as_exception() {
2445                        format!(
2446                            "{}: {}",
2447                            exc.message().unwrap_or_default(),
2448                            exc.stack().unwrap_or_default()
2449                        )
2450                    } else {
2451                        format!("{:?}", reason)
2452                    };
2453
2454                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
2455
2456                    if should_panic_on_js_errors() {
2457                        // Don't panic here - we're inside an FFI callback and rquickjs catches panics.
2458                        // Instead, set a fatal error flag that the plugin thread loop will check.
2459                        let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2460                        set_fatal_js_error(full_msg);
2461                    }
2462                }
2463            },
2464        )));
2465
2466        let main_context = Context::full(&runtime)
2467            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2468
2469        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2470        let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2471        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2472        let next_request_id = Rc::new(RefCell::new(1u64));
2473        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2474
2475        let backend = Self {
2476            runtime,
2477            main_context,
2478            plugin_contexts,
2479            event_handlers,
2480            registered_actions,
2481            state_snapshot,
2482            command_sender,
2483            pending_responses,
2484            next_request_id,
2485            callback_contexts,
2486            services,
2487        };
2488
2489        // Initialize main context (for internal utilities if needed)
2490        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2491
2492        tracing::debug!("QuickJsBackend::new: runtime created successfully");
2493        Ok(backend)
2494    }
2495
2496    /// Set up the editor API in a specific JavaScript context
2497    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2498        let state_snapshot = Arc::clone(&self.state_snapshot);
2499        let command_sender = self.command_sender.clone();
2500        let event_handlers = Rc::clone(&self.event_handlers);
2501        let registered_actions = Rc::clone(&self.registered_actions);
2502        let next_request_id = Rc::clone(&self.next_request_id);
2503
2504        context.with(|ctx| {
2505            let globals = ctx.globals();
2506
2507            // Set the plugin name global
2508            globals.set("__pluginName__", plugin_name)?;
2509
2510            // Create the editor object using JsEditorApi class
2511            // This provides proper lifetime handling for methods returning JS values
2512            let js_api = JsEditorApi {
2513                state_snapshot: Arc::clone(&state_snapshot),
2514                command_sender: command_sender.clone(),
2515                registered_actions: Rc::clone(&registered_actions),
2516                event_handlers: Rc::clone(&event_handlers),
2517                next_request_id: Rc::clone(&next_request_id),
2518                callback_contexts: Rc::clone(&self.callback_contexts),
2519                services: self.services.clone(),
2520                plugin_name: plugin_name.to_string(),
2521            };
2522            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2523
2524            // All methods are now in JsEditorApi - export editor as global
2525            globals.set("editor", editor)?;
2526
2527            // Define getEditor() globally
2528            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2529
2530            // Provide console.log for debugging
2531            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
2532            let console = Object::new(ctx.clone())?;
2533            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2534                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2535                tracing::info!("console.log: {}", parts.join(" "));
2536            })?)?;
2537            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2538                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2539                tracing::warn!("console.warn: {}", parts.join(" "));
2540            })?)?;
2541            console.set("error", 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::error!("console.error: {}", parts.join(" "));
2544            })?)?;
2545            globals.set("console", console)?;
2546
2547            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
2548            ctx.eval::<(), _>(r#"
2549                // Pending promise callbacks: callbackId -> { resolve, reject }
2550                globalThis._pendingCallbacks = new Map();
2551
2552                // Resolve a pending callback (called from Rust)
2553                globalThis._resolveCallback = function(callbackId, result) {
2554                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2555                    const cb = globalThis._pendingCallbacks.get(callbackId);
2556                    if (cb) {
2557                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
2558                        globalThis._pendingCallbacks.delete(callbackId);
2559                        cb.resolve(result);
2560                        console.log('[JS] _resolveCallback: resolve() called');
2561                    } else {
2562                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2563                    }
2564                };
2565
2566                // Reject a pending callback (called from Rust)
2567                globalThis._rejectCallback = function(callbackId, error) {
2568                    const cb = globalThis._pendingCallbacks.get(callbackId);
2569                    if (cb) {
2570                        globalThis._pendingCallbacks.delete(callbackId);
2571                        cb.reject(new Error(error));
2572                    }
2573                };
2574
2575                // Generic async wrapper decorator
2576                // Wraps a function that returns a callbackId into a promise-returning function
2577                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2578                // NOTE: We pass the method name as a string and call via bracket notation
2579                // to preserve rquickjs's automatic Ctx injection for methods
2580                globalThis._wrapAsync = function(methodName, fnName) {
2581                    const startFn = editor[methodName];
2582                    if (typeof startFn !== 'function') {
2583                        // Return a function that always throws - catches missing implementations
2584                        return function(...args) {
2585                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2586                            editor.debug(`[ASYNC ERROR] ${error.message}`);
2587                            throw error;
2588                        };
2589                    }
2590                    return function(...args) {
2591                        // Call via bracket notation to preserve method binding and Ctx injection
2592                        const callbackId = editor[methodName](...args);
2593                        return new Promise((resolve, reject) => {
2594                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2595                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
2596                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2597                        });
2598                    };
2599                };
2600
2601                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2602                // The returned object has .result promise and is itself thenable
2603                globalThis._wrapAsyncThenable = function(methodName, fnName) {
2604                    const startFn = editor[methodName];
2605                    if (typeof startFn !== 'function') {
2606                        // Return a function that always throws - catches missing implementations
2607                        return function(...args) {
2608                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2609                            editor.debug(`[ASYNC ERROR] ${error.message}`);
2610                            throw error;
2611                        };
2612                    }
2613                    return function(...args) {
2614                        // Call via bracket notation to preserve method binding and Ctx injection
2615                        const callbackId = editor[methodName](...args);
2616                        const resultPromise = new Promise((resolve, reject) => {
2617                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2618                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2619                        });
2620                        return {
2621                            get result() { return resultPromise; },
2622                            then(onFulfilled, onRejected) {
2623                                return resultPromise.then(onFulfilled, onRejected);
2624                            },
2625                            catch(onRejected) {
2626                                return resultPromise.catch(onRejected);
2627                            }
2628                        };
2629                    };
2630                };
2631
2632                // Apply wrappers to async functions on editor
2633                editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
2634                editor.delay = _wrapAsync("_delayStart", "delay");
2635                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
2636                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
2637                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
2638                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
2639                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
2640                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
2641                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
2642                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
2643                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
2644
2645                // Wrapper for deleteTheme - wraps sync function in Promise
2646                editor.deleteTheme = function(name) {
2647                    return new Promise(function(resolve, reject) {
2648                        const success = editor._deleteThemeSync(name);
2649                        if (success) {
2650                            resolve();
2651                        } else {
2652                            reject(new Error("Failed to delete theme: " + name));
2653                        }
2654                    });
2655                };
2656            "#.as_bytes())?;
2657
2658            Ok::<_, rquickjs::Error>(())
2659        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
2660
2661        Ok(())
2662    }
2663
2664    /// Load and execute a TypeScript/JavaScript plugin from a file path
2665    pub async fn load_module_with_source(
2666        &mut self,
2667        path: &str,
2668        _plugin_source: &str,
2669    ) -> Result<()> {
2670        let path_buf = PathBuf::from(path);
2671        let source = std::fs::read_to_string(&path_buf)
2672            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
2673
2674        let filename = path_buf
2675            .file_name()
2676            .and_then(|s| s.to_str())
2677            .unwrap_or("plugin.ts");
2678
2679        // Check for ES imports - these need bundling to resolve dependencies
2680        if has_es_imports(&source) {
2681            // Try to bundle (this also strips imports and exports)
2682            match bundle_module(&path_buf) {
2683                Ok(bundled) => {
2684                    self.execute_js(&bundled, path)?;
2685                }
2686                Err(e) => {
2687                    tracing::warn!(
2688                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
2689                        path,
2690                        e
2691                    );
2692                    return Ok(()); // Skip plugins with unresolvable imports
2693                }
2694            }
2695        } else if has_es_module_syntax(&source) {
2696            // Has exports but no imports - strip exports and transpile
2697            let stripped = strip_imports_and_exports(&source);
2698            let js_code = if filename.ends_with(".ts") {
2699                transpile_typescript(&stripped, filename)?
2700            } else {
2701                stripped
2702            };
2703            self.execute_js(&js_code, path)?;
2704        } else {
2705            // Plain code - just transpile if TypeScript
2706            let js_code = if filename.ends_with(".ts") {
2707                transpile_typescript(&source, filename)?
2708            } else {
2709                source
2710            };
2711            self.execute_js(&js_code, path)?;
2712        }
2713
2714        Ok(())
2715    }
2716
2717    /// Execute JavaScript code in the context
2718    fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
2719        // Extract plugin name from path (filename without extension)
2720        let plugin_name = Path::new(source_name)
2721            .file_stem()
2722            .and_then(|s| s.to_str())
2723            .unwrap_or("unknown");
2724
2725        tracing::debug!(
2726            "execute_js: starting for plugin '{}' from '{}'",
2727            plugin_name,
2728            source_name
2729        );
2730
2731        // Get or create context for this plugin
2732        let context = {
2733            let mut contexts = self.plugin_contexts.borrow_mut();
2734            if let Some(ctx) = contexts.get(plugin_name) {
2735                ctx.clone()
2736            } else {
2737                let ctx = Context::full(&self.runtime).map_err(|e| {
2738                    anyhow!(
2739                        "Failed to create QuickJS context for plugin {}: {}",
2740                        plugin_name,
2741                        e
2742                    )
2743                })?;
2744                self.setup_context_api(&ctx, plugin_name)?;
2745                contexts.insert(plugin_name.to_string(), ctx.clone());
2746                ctx
2747            }
2748        };
2749
2750        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
2751        // This is critical for plugins like vi_mode that declare `const editor = ...`
2752        // which shadows the global `editor` causing TDZ if not wrapped.
2753        let wrapped_code = format!("(function() {{ {} }})();", code);
2754        let wrapped = wrapped_code.as_str();
2755
2756        context.with(|ctx| {
2757            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
2758
2759            // Execute the plugin code with filename for better stack traces
2760            let mut eval_options = rquickjs::context::EvalOptions::default();
2761            eval_options.global = true;
2762            eval_options.filename = Some(source_name.to_string());
2763            let result = ctx
2764                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
2765                .map_err(|e| format_js_error(&ctx, e, source_name));
2766
2767            tracing::debug!(
2768                "execute_js: plugin code execution finished for '{}', result: {:?}",
2769                plugin_name,
2770                result.is_ok()
2771            );
2772
2773            result
2774        })
2775    }
2776
2777    /// Emit an event to all registered handlers
2778    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
2779        let _event_data_str = event_data.to_string();
2780        tracing::debug!("emit: event '{}' with data: {:?}", event_name, event_data);
2781
2782        // Track execution state for signal handler debugging
2783        self.services
2784            .set_js_execution_state(format!("hook '{}'", event_name));
2785
2786        let handlers = self.event_handlers.borrow().get(event_name).cloned();
2787
2788        if let Some(handler_pairs) = handlers {
2789            if handler_pairs.is_empty() {
2790                self.services.clear_js_execution_state();
2791                return Ok(true);
2792            }
2793
2794            let plugin_contexts = self.plugin_contexts.borrow();
2795            for handler in handler_pairs {
2796                let context_opt = plugin_contexts.get(&handler.plugin_name);
2797                if let Some(context) = context_opt {
2798                    let handler_name = &handler.handler_name;
2799                    // Call the handler and properly handle both sync and async errors
2800                    // Async handlers return Promises - we attach .catch() to surface rejections
2801                    // Double-encode the JSON to produce a valid JavaScript string literal:
2802                    // event_data = {"path": "/test"} -> first to_string = {"path": "/test"}
2803                    // -> second to_string = "{\"path\": \"/test\"}" (properly quoted for JS)
2804                    let json_string = serde_json::to_string(event_data)?;
2805                    let js_string_literal = serde_json::to_string(&json_string)?;
2806                    let code = format!(
2807                        r#"
2808                        (function() {{
2809                            try {{
2810                                const data = JSON.parse({});
2811                                if (typeof globalThis["{}"] === 'function') {{
2812                                    const result = globalThis["{}"](data);
2813                                    // If handler returns a Promise, catch rejections
2814                                    if (result && typeof result.then === 'function') {{
2815                                        result.catch(function(e) {{
2816                                            console.error('Handler {} async error:', e);
2817                                            // Re-throw to make it an unhandled rejection for the runtime to catch
2818                                            throw e;
2819                                        }});
2820                                    }}
2821                                }}
2822                            }} catch (e) {{
2823                                console.error('Handler {} sync error:', e);
2824                                throw e;
2825                            }}
2826                        }})();
2827                        "#,
2828                        js_string_literal, handler_name, handler_name, handler_name, handler_name
2829                    );
2830
2831                    context.with(|ctx| {
2832                        if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
2833                            log_js_error(&ctx, e, &format!("handler {}", handler_name));
2834                        }
2835                        // Run pending jobs to process any Promise continuations and catch errors
2836                        run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
2837                    });
2838                }
2839            }
2840        }
2841
2842        self.services.clear_js_execution_state();
2843        Ok(true)
2844    }
2845
2846    /// Check if any handlers are registered for an event
2847    pub fn has_handlers(&self, event_name: &str) -> bool {
2848        self.event_handlers
2849            .borrow()
2850            .get(event_name)
2851            .map(|v| !v.is_empty())
2852            .unwrap_or(false)
2853    }
2854
2855    /// Start an action without waiting for async operations to complete.
2856    /// This is useful when the calling thread needs to continue processing
2857    /// ResolveCallback requests that the action may be waiting for.
2858    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
2859        let pair = self.registered_actions.borrow().get(action_name).cloned();
2860        let (plugin_name, function_name) = match pair {
2861            Some(handler) => (handler.plugin_name, handler.handler_name),
2862            None => ("main".to_string(), action_name.to_string()),
2863        };
2864
2865        let plugin_contexts = self.plugin_contexts.borrow();
2866        let context = plugin_contexts
2867            .get(&plugin_name)
2868            .unwrap_or(&self.main_context);
2869
2870        // Track execution state for signal handler debugging
2871        self.services
2872            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
2873
2874        tracing::info!(
2875            "start_action: BEGIN '{}' -> function '{}'",
2876            action_name,
2877            function_name
2878        );
2879
2880        // Just call the function - don't try to await or drive Promises
2881        let code = format!(
2882            r#"
2883            (function() {{
2884                console.log('[JS] start_action: calling {fn}');
2885                try {{
2886                    if (typeof globalThis.{fn} === 'function') {{
2887                        console.log('[JS] start_action: {fn} is a function, invoking...');
2888                        globalThis.{fn}();
2889                        console.log('[JS] start_action: {fn} invoked (may be async)');
2890                    }} else {{
2891                        console.error('[JS] Action {action} is not defined as a global function');
2892                    }}
2893                }} catch (e) {{
2894                    console.error('[JS] Action {action} error:', e);
2895                }}
2896            }})();
2897            "#,
2898            fn = function_name,
2899            action = action_name
2900        );
2901
2902        tracing::info!("start_action: evaluating JS code");
2903        context.with(|ctx| {
2904            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2905                log_js_error(&ctx, e, &format!("action {}", action_name));
2906            }
2907            tracing::info!("start_action: running pending microtasks");
2908            // Run any immediate microtasks
2909            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
2910            tracing::info!("start_action: executed {} pending jobs", count);
2911        });
2912
2913        tracing::info!("start_action: END '{}'", action_name);
2914
2915        // Clear execution state (action started, may still be running async)
2916        self.services.clear_js_execution_state();
2917
2918        Ok(())
2919    }
2920
2921    /// Execute a registered action by name
2922    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
2923        // First check if there's a registered command mapping
2924        let pair = self.registered_actions.borrow().get(action_name).cloned();
2925        let (plugin_name, function_name) = match pair {
2926            Some(handler) => (handler.plugin_name, handler.handler_name),
2927            None => ("main".to_string(), action_name.to_string()),
2928        };
2929
2930        let plugin_contexts = self.plugin_contexts.borrow();
2931        let context = plugin_contexts
2932            .get(&plugin_name)
2933            .unwrap_or(&self.main_context);
2934
2935        tracing::debug!(
2936            "execute_action: '{}' -> function '{}'",
2937            action_name,
2938            function_name
2939        );
2940
2941        // Call the function and await if it returns a Promise
2942        // We use a global _executeActionResult to pass the result back
2943        let code = format!(
2944            r#"
2945            (async function() {{
2946                try {{
2947                    if (typeof globalThis.{fn} === 'function') {{
2948                        const result = globalThis.{fn}();
2949                        // If it's a Promise, await it
2950                        if (result && typeof result.then === 'function') {{
2951                            await result;
2952                        }}
2953                    }} else {{
2954                        console.error('Action {action} is not defined as a global function');
2955                    }}
2956                }} catch (e) {{
2957                    console.error('Action {action} error:', e);
2958                }}
2959            }})();
2960            "#,
2961            fn = function_name,
2962            action = action_name
2963        );
2964
2965        context.with(|ctx| {
2966            // Eval returns a Promise for the async IIFE, which we need to drive
2967            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
2968                Ok(value) => {
2969                    // If it's a Promise, we need to drive the runtime to completion
2970                    if value.is_object() {
2971                        if let Some(obj) = value.as_object() {
2972                            // Check if it's a Promise by looking for 'then' method
2973                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
2974                                // Drive the runtime to process the promise
2975                                // QuickJS processes promises synchronously when we call execute_pending_job
2976                                run_pending_jobs_checked(
2977                                    &ctx,
2978                                    &format!("execute_action {} promise", action_name),
2979                                );
2980                            }
2981                        }
2982                    }
2983                }
2984                Err(e) => {
2985                    log_js_error(&ctx, e, &format!("action {}", action_name));
2986                }
2987            }
2988        });
2989
2990        Ok(())
2991    }
2992
2993    /// Poll the event loop once to run any pending microtasks
2994    pub fn poll_event_loop_once(&mut self) -> bool {
2995        let mut had_work = false;
2996
2997        // Poll main context
2998        self.main_context.with(|ctx| {
2999            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3000            if count > 0 {
3001                had_work = true;
3002            }
3003        });
3004
3005        // Poll all plugin contexts
3006        let contexts = self.plugin_contexts.borrow().clone();
3007        for (name, context) in contexts {
3008            context.with(|ctx| {
3009                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3010                if count > 0 {
3011                    had_work = true;
3012                }
3013            });
3014        }
3015        had_work
3016    }
3017
3018    /// Send a status message to the editor
3019    pub fn send_status(&self, message: String) {
3020        let _ = self
3021            .command_sender
3022            .send(PluginCommand::SetStatus { message });
3023    }
3024
3025    /// Resolve a pending async callback with a result (called from Rust when async op completes)
3026    ///
3027    /// Takes a JSON string which is parsed and converted to a proper JS value.
3028    /// This avoids string interpolation with eval for better type safety.
3029    pub fn resolve_callback(
3030        &mut self,
3031        callback_id: fresh_core::api::JsCallbackId,
3032        result_json: &str,
3033    ) {
3034        let id = callback_id.as_u64();
3035        tracing::debug!("resolve_callback: starting for callback_id={}", id);
3036
3037        // Find the plugin name and then context for this callback
3038        let plugin_name = {
3039            let mut contexts = self.callback_contexts.borrow_mut();
3040            contexts.remove(&id)
3041        };
3042
3043        let Some(name) = plugin_name else {
3044            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3045            return;
3046        };
3047
3048        let plugin_contexts = self.plugin_contexts.borrow();
3049        let Some(context) = plugin_contexts.get(&name) else {
3050            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3051            return;
3052        };
3053
3054        context.with(|ctx| {
3055            // Parse JSON string to serde_json::Value
3056            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3057                Ok(v) => v,
3058                Err(e) => {
3059                    tracing::error!(
3060                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
3061                        id,
3062                        e
3063                    );
3064                    return;
3065                }
3066            };
3067
3068            // Convert to JS value using rquickjs_serde
3069            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3070                Ok(v) => v,
3071                Err(e) => {
3072                    tracing::error!(
3073                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3074                        id,
3075                        e
3076                    );
3077                    return;
3078                }
3079            };
3080
3081            // Get _resolveCallback function from globalThis
3082            let globals = ctx.globals();
3083            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3084                Ok(f) => f,
3085                Err(e) => {
3086                    tracing::error!(
3087                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3088                        id,
3089                        e
3090                    );
3091                    return;
3092                }
3093            };
3094
3095            // Call the function with callback_id (as u64) and the JS value
3096            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3097                log_js_error(&ctx, e, &format!("resolving callback {}", id));
3098            }
3099
3100            // IMPORTANT: Run pending jobs to process Promise continuations
3101            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3102            tracing::info!(
3103                "resolve_callback: executed {} pending jobs for callback_id={}",
3104                job_count,
3105                id
3106            );
3107        });
3108    }
3109
3110    /// Reject a pending async callback with an error (called from Rust when async op fails)
3111    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3112        let id = callback_id.as_u64();
3113
3114        // Find the plugin name and then context for this callback
3115        let plugin_name = {
3116            let mut contexts = self.callback_contexts.borrow_mut();
3117            contexts.remove(&id)
3118        };
3119
3120        let Some(name) = plugin_name else {
3121            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3122            return;
3123        };
3124
3125        let plugin_contexts = self.plugin_contexts.borrow();
3126        let Some(context) = plugin_contexts.get(&name) else {
3127            tracing::warn!("reject_callback: Context lost for plugin {}", name);
3128            return;
3129        };
3130
3131        context.with(|ctx| {
3132            // Get _rejectCallback function from globalThis
3133            let globals = ctx.globals();
3134            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3135                Ok(f) => f,
3136                Err(e) => {
3137                    tracing::error!(
3138                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3139                        id,
3140                        e
3141                    );
3142                    return;
3143                }
3144            };
3145
3146            // Call the function with callback_id (as u64) and error string
3147            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3148                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3149            }
3150
3151            // IMPORTANT: Run pending jobs to process Promise continuations
3152            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3153        });
3154    }
3155}
3156
3157#[cfg(test)]
3158mod tests {
3159    use super::*;
3160    use fresh_core::api::{BufferInfo, CursorInfo};
3161    use std::sync::mpsc;
3162
3163    /// Helper to create a backend with a command receiver for testing
3164    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3165        let (tx, rx) = mpsc::channel();
3166        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3167        let services = Arc::new(TestServiceBridge::new());
3168        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3169        (backend, rx)
3170    }
3171
3172    struct TestServiceBridge {
3173        en_strings: std::sync::Mutex<HashMap<String, String>>,
3174    }
3175
3176    impl TestServiceBridge {
3177        fn new() -> Self {
3178            Self {
3179                en_strings: std::sync::Mutex::new(HashMap::new()),
3180            }
3181        }
3182    }
3183
3184    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3185        fn as_any(&self) -> &dyn std::any::Any {
3186            self
3187        }
3188        fn translate(
3189            &self,
3190            _plugin_name: &str,
3191            key: &str,
3192            _args: &HashMap<String, String>,
3193        ) -> String {
3194            self.en_strings
3195                .lock()
3196                .unwrap()
3197                .get(key)
3198                .cloned()
3199                .unwrap_or_else(|| key.to_string())
3200        }
3201        fn current_locale(&self) -> String {
3202            "en".to_string()
3203        }
3204        fn set_js_execution_state(&self, _state: String) {}
3205        fn clear_js_execution_state(&self) {}
3206        fn get_theme_schema(&self) -> serde_json::Value {
3207            serde_json::json!({})
3208        }
3209        fn get_builtin_themes(&self) -> serde_json::Value {
3210            serde_json::json!([])
3211        }
3212        fn register_command(&self, _command: fresh_core::command::Command) {}
3213        fn unregister_command(&self, _name: &str) {}
3214        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3215        fn plugins_dir(&self) -> std::path::PathBuf {
3216            std::path::PathBuf::from("/tmp/plugins")
3217        }
3218        fn config_dir(&self) -> std::path::PathBuf {
3219            std::path::PathBuf::from("/tmp/config")
3220        }
3221    }
3222
3223    #[test]
3224    fn test_quickjs_backend_creation() {
3225        let backend = QuickJsBackend::new();
3226        assert!(backend.is_ok());
3227    }
3228
3229    #[test]
3230    fn test_execute_simple_js() {
3231        let mut backend = QuickJsBackend::new().unwrap();
3232        let result = backend.execute_js("const x = 1 + 2;", "test.js");
3233        assert!(result.is_ok());
3234    }
3235
3236    #[test]
3237    fn test_event_handler_registration() {
3238        let backend = QuickJsBackend::new().unwrap();
3239
3240        // Initially no handlers
3241        assert!(!backend.has_handlers("test_event"));
3242
3243        // Register a handler
3244        backend
3245            .event_handlers
3246            .borrow_mut()
3247            .entry("test_event".to_string())
3248            .or_default()
3249            .push(PluginHandler {
3250                plugin_name: "test".to_string(),
3251                handler_name: "testHandler".to_string(),
3252            });
3253
3254        // Now has handlers
3255        assert!(backend.has_handlers("test_event"));
3256    }
3257
3258    // ==================== API Tests ====================
3259
3260    #[test]
3261    fn test_api_set_status() {
3262        let (mut backend, rx) = create_test_backend();
3263
3264        backend
3265            .execute_js(
3266                r#"
3267            const editor = getEditor();
3268            editor.setStatus("Hello from test");
3269        "#,
3270                "test.js",
3271            )
3272            .unwrap();
3273
3274        let cmd = rx.try_recv().unwrap();
3275        match cmd {
3276            PluginCommand::SetStatus { message } => {
3277                assert_eq!(message, "Hello from test");
3278            }
3279            _ => panic!("Expected SetStatus command, got {:?}", cmd),
3280        }
3281    }
3282
3283    #[test]
3284    fn test_api_register_command() {
3285        let (mut backend, rx) = create_test_backend();
3286
3287        backend
3288            .execute_js(
3289                r#"
3290            const editor = getEditor();
3291            globalThis.myTestHandler = function() { };
3292            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3293        "#,
3294                "test_plugin.js",
3295            )
3296            .unwrap();
3297
3298        let cmd = rx.try_recv().unwrap();
3299        match cmd {
3300            PluginCommand::RegisterCommand { command } => {
3301                assert_eq!(command.name, "Test Command");
3302                assert_eq!(command.description, "A test command");
3303                // Check that plugin_name contains the plugin name (derived from filename)
3304                assert_eq!(command.plugin_name, "test_plugin");
3305            }
3306            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3307        }
3308    }
3309
3310    #[test]
3311    fn test_api_define_mode() {
3312        let (mut backend, rx) = create_test_backend();
3313
3314        backend
3315            .execute_js(
3316                r#"
3317            const editor = getEditor();
3318            editor.defineMode("test-mode", null, [
3319                ["a", "action_a"],
3320                ["b", "action_b"]
3321            ]);
3322        "#,
3323                "test.js",
3324            )
3325            .unwrap();
3326
3327        let cmd = rx.try_recv().unwrap();
3328        match cmd {
3329            PluginCommand::DefineMode {
3330                name,
3331                parent,
3332                bindings,
3333                read_only,
3334            } => {
3335                assert_eq!(name, "test-mode");
3336                assert!(parent.is_none());
3337                assert_eq!(bindings.len(), 2);
3338                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3339                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3340                assert!(!read_only);
3341            }
3342            _ => panic!("Expected DefineMode, got {:?}", cmd),
3343        }
3344    }
3345
3346    #[test]
3347    fn test_api_set_editor_mode() {
3348        let (mut backend, rx) = create_test_backend();
3349
3350        backend
3351            .execute_js(
3352                r#"
3353            const editor = getEditor();
3354            editor.setEditorMode("vi-normal");
3355        "#,
3356                "test.js",
3357            )
3358            .unwrap();
3359
3360        let cmd = rx.try_recv().unwrap();
3361        match cmd {
3362            PluginCommand::SetEditorMode { mode } => {
3363                assert_eq!(mode, Some("vi-normal".to_string()));
3364            }
3365            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3366        }
3367    }
3368
3369    #[test]
3370    fn test_api_clear_editor_mode() {
3371        let (mut backend, rx) = create_test_backend();
3372
3373        backend
3374            .execute_js(
3375                r#"
3376            const editor = getEditor();
3377            editor.setEditorMode(null);
3378        "#,
3379                "test.js",
3380            )
3381            .unwrap();
3382
3383        let cmd = rx.try_recv().unwrap();
3384        match cmd {
3385            PluginCommand::SetEditorMode { mode } => {
3386                assert!(mode.is_none());
3387            }
3388            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3389        }
3390    }
3391
3392    #[test]
3393    fn test_api_insert_at_cursor() {
3394        let (mut backend, rx) = create_test_backend();
3395
3396        backend
3397            .execute_js(
3398                r#"
3399            const editor = getEditor();
3400            editor.insertAtCursor("Hello, World!");
3401        "#,
3402                "test.js",
3403            )
3404            .unwrap();
3405
3406        let cmd = rx.try_recv().unwrap();
3407        match cmd {
3408            PluginCommand::InsertAtCursor { text } => {
3409                assert_eq!(text, "Hello, World!");
3410            }
3411            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3412        }
3413    }
3414
3415    #[test]
3416    fn test_api_set_context() {
3417        let (mut backend, rx) = create_test_backend();
3418
3419        backend
3420            .execute_js(
3421                r#"
3422            const editor = getEditor();
3423            editor.setContext("myContext", true);
3424        "#,
3425                "test.js",
3426            )
3427            .unwrap();
3428
3429        let cmd = rx.try_recv().unwrap();
3430        match cmd {
3431            PluginCommand::SetContext { name, active } => {
3432                assert_eq!(name, "myContext");
3433                assert!(active);
3434            }
3435            _ => panic!("Expected SetContext, got {:?}", cmd),
3436        }
3437    }
3438
3439    #[tokio::test]
3440    async fn test_execute_action_sync_function() {
3441        let (mut backend, rx) = create_test_backend();
3442
3443        // Register the action explicitly so it knows to look in "test" plugin
3444        backend.registered_actions.borrow_mut().insert(
3445            "my_sync_action".to_string(),
3446            PluginHandler {
3447                plugin_name: "test".to_string(),
3448                handler_name: "my_sync_action".to_string(),
3449            },
3450        );
3451
3452        // Define a sync function and register it
3453        backend
3454            .execute_js(
3455                r#"
3456            const editor = getEditor();
3457            globalThis.my_sync_action = function() {
3458                editor.setStatus("sync action executed");
3459            };
3460        "#,
3461                "test.js",
3462            )
3463            .unwrap();
3464
3465        // Drain any setup commands
3466        while rx.try_recv().is_ok() {}
3467
3468        // Execute the action
3469        backend.execute_action("my_sync_action").await.unwrap();
3470
3471        // Check the command was sent
3472        let cmd = rx.try_recv().unwrap();
3473        match cmd {
3474            PluginCommand::SetStatus { message } => {
3475                assert_eq!(message, "sync action executed");
3476            }
3477            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3478        }
3479    }
3480
3481    #[tokio::test]
3482    async fn test_execute_action_async_function() {
3483        let (mut backend, rx) = create_test_backend();
3484
3485        // Register the action explicitly
3486        backend.registered_actions.borrow_mut().insert(
3487            "my_async_action".to_string(),
3488            PluginHandler {
3489                plugin_name: "test".to_string(),
3490                handler_name: "my_async_action".to_string(),
3491            },
3492        );
3493
3494        // Define an async function
3495        backend
3496            .execute_js(
3497                r#"
3498            const editor = getEditor();
3499            globalThis.my_async_action = async function() {
3500                await Promise.resolve();
3501                editor.setStatus("async action executed");
3502            };
3503        "#,
3504                "test.js",
3505            )
3506            .unwrap();
3507
3508        // Drain any setup commands
3509        while rx.try_recv().is_ok() {}
3510
3511        // Execute the action
3512        backend.execute_action("my_async_action").await.unwrap();
3513
3514        // Check the command was sent (async should complete)
3515        let cmd = rx.try_recv().unwrap();
3516        match cmd {
3517            PluginCommand::SetStatus { message } => {
3518                assert_eq!(message, "async action executed");
3519            }
3520            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3521        }
3522    }
3523
3524    #[tokio::test]
3525    async fn test_execute_action_with_registered_handler() {
3526        let (mut backend, rx) = create_test_backend();
3527
3528        // Register an action with a different handler name
3529        backend.registered_actions.borrow_mut().insert(
3530            "my_action".to_string(),
3531            PluginHandler {
3532                plugin_name: "test".to_string(),
3533                handler_name: "actual_handler_function".to_string(),
3534            },
3535        );
3536
3537        backend
3538            .execute_js(
3539                r#"
3540            const editor = getEditor();
3541            globalThis.actual_handler_function = function() {
3542                editor.setStatus("handler executed");
3543            };
3544        "#,
3545                "test.js",
3546            )
3547            .unwrap();
3548
3549        // Drain any setup commands
3550        while rx.try_recv().is_ok() {}
3551
3552        // Execute the action by name (should resolve to handler)
3553        backend.execute_action("my_action").await.unwrap();
3554
3555        let cmd = rx.try_recv().unwrap();
3556        match cmd {
3557            PluginCommand::SetStatus { message } => {
3558                assert_eq!(message, "handler executed");
3559            }
3560            _ => panic!("Expected SetStatus, got {:?}", cmd),
3561        }
3562    }
3563
3564    #[test]
3565    fn test_api_on_event_registration() {
3566        let (mut backend, _rx) = create_test_backend();
3567
3568        backend
3569            .execute_js(
3570                r#"
3571            const editor = getEditor();
3572            globalThis.myEventHandler = function() { };
3573            editor.on("bufferSave", "myEventHandler");
3574        "#,
3575                "test.js",
3576            )
3577            .unwrap();
3578
3579        assert!(backend.has_handlers("bufferSave"));
3580    }
3581
3582    #[test]
3583    fn test_api_off_event_unregistration() {
3584        let (mut backend, _rx) = create_test_backend();
3585
3586        backend
3587            .execute_js(
3588                r#"
3589            const editor = getEditor();
3590            globalThis.myEventHandler = function() { };
3591            editor.on("bufferSave", "myEventHandler");
3592            editor.off("bufferSave", "myEventHandler");
3593        "#,
3594                "test.js",
3595            )
3596            .unwrap();
3597
3598        // Handler should be removed
3599        assert!(!backend.has_handlers("bufferSave"));
3600    }
3601
3602    #[tokio::test]
3603    async fn test_emit_event() {
3604        let (mut backend, rx) = create_test_backend();
3605
3606        backend
3607            .execute_js(
3608                r#"
3609            const editor = getEditor();
3610            globalThis.onSaveHandler = function(data) {
3611                editor.setStatus("saved: " + JSON.stringify(data));
3612            };
3613            editor.on("bufferSave", "onSaveHandler");
3614        "#,
3615                "test.js",
3616            )
3617            .unwrap();
3618
3619        // Drain setup commands
3620        while rx.try_recv().is_ok() {}
3621
3622        // Emit the event
3623        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
3624        backend.emit("bufferSave", &event_data).await.unwrap();
3625
3626        let cmd = rx.try_recv().unwrap();
3627        match cmd {
3628            PluginCommand::SetStatus { message } => {
3629                assert!(message.contains("/test.txt"));
3630            }
3631            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
3632        }
3633    }
3634
3635    #[test]
3636    fn test_api_copy_to_clipboard() {
3637        let (mut backend, rx) = create_test_backend();
3638
3639        backend
3640            .execute_js(
3641                r#"
3642            const editor = getEditor();
3643            editor.copyToClipboard("clipboard text");
3644        "#,
3645                "test.js",
3646            )
3647            .unwrap();
3648
3649        let cmd = rx.try_recv().unwrap();
3650        match cmd {
3651            PluginCommand::SetClipboard { text } => {
3652                assert_eq!(text, "clipboard text");
3653            }
3654            _ => panic!("Expected SetClipboard, got {:?}", cmd),
3655        }
3656    }
3657
3658    #[test]
3659    fn test_api_open_file() {
3660        let (mut backend, rx) = create_test_backend();
3661
3662        // openFile takes (path, line?, column?)
3663        backend
3664            .execute_js(
3665                r#"
3666            const editor = getEditor();
3667            editor.openFile("/path/to/file.txt", null, null);
3668        "#,
3669                "test.js",
3670            )
3671            .unwrap();
3672
3673        let cmd = rx.try_recv().unwrap();
3674        match cmd {
3675            PluginCommand::OpenFileAtLocation { path, line, column } => {
3676                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
3677                assert!(line.is_none());
3678                assert!(column.is_none());
3679            }
3680            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
3681        }
3682    }
3683
3684    #[test]
3685    fn test_api_delete_range() {
3686        let (mut backend, rx) = create_test_backend();
3687
3688        // deleteRange takes (buffer_id, start, end)
3689        backend
3690            .execute_js(
3691                r#"
3692            const editor = getEditor();
3693            editor.deleteRange(0, 10, 20);
3694        "#,
3695                "test.js",
3696            )
3697            .unwrap();
3698
3699        let cmd = rx.try_recv().unwrap();
3700        match cmd {
3701            PluginCommand::DeleteRange { range, .. } => {
3702                assert_eq!(range.start, 10);
3703                assert_eq!(range.end, 20);
3704            }
3705            _ => panic!("Expected DeleteRange, got {:?}", cmd),
3706        }
3707    }
3708
3709    #[test]
3710    fn test_api_insert_text() {
3711        let (mut backend, rx) = create_test_backend();
3712
3713        // insertText takes (buffer_id, position, text)
3714        backend
3715            .execute_js(
3716                r#"
3717            const editor = getEditor();
3718            editor.insertText(0, 5, "inserted");
3719        "#,
3720                "test.js",
3721            )
3722            .unwrap();
3723
3724        let cmd = rx.try_recv().unwrap();
3725        match cmd {
3726            PluginCommand::InsertText { position, text, .. } => {
3727                assert_eq!(position, 5);
3728                assert_eq!(text, "inserted");
3729            }
3730            _ => panic!("Expected InsertText, got {:?}", cmd),
3731        }
3732    }
3733
3734    #[test]
3735    fn test_api_set_buffer_cursor() {
3736        let (mut backend, rx) = create_test_backend();
3737
3738        // setBufferCursor takes (buffer_id, position)
3739        backend
3740            .execute_js(
3741                r#"
3742            const editor = getEditor();
3743            editor.setBufferCursor(0, 100);
3744        "#,
3745                "test.js",
3746            )
3747            .unwrap();
3748
3749        let cmd = rx.try_recv().unwrap();
3750        match cmd {
3751            PluginCommand::SetBufferCursor { position, .. } => {
3752                assert_eq!(position, 100);
3753            }
3754            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
3755        }
3756    }
3757
3758    #[test]
3759    fn test_api_get_cursor_position_from_state() {
3760        let (tx, _rx) = mpsc::channel();
3761        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3762
3763        // Set up cursor position in state
3764        {
3765            let mut state = state_snapshot.write().unwrap();
3766            state.primary_cursor = Some(CursorInfo {
3767                position: 42,
3768                selection: None,
3769            });
3770        }
3771
3772        let services = Arc::new(fresh_core::services::NoopServiceBridge);
3773        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3774
3775        // Execute JS that reads and stores cursor position
3776        backend
3777            .execute_js(
3778                r#"
3779            const editor = getEditor();
3780            const pos = editor.getCursorPosition();
3781            globalThis._testResult = pos;
3782        "#,
3783                "test.js",
3784            )
3785            .unwrap();
3786
3787        // Verify by reading back - getCursorPosition returns byte offset as u32
3788        backend
3789            .plugin_contexts
3790            .borrow()
3791            .get("test")
3792            .unwrap()
3793            .clone()
3794            .with(|ctx| {
3795                let global = ctx.globals();
3796                let result: u32 = global.get("_testResult").unwrap();
3797                assert_eq!(result, 42);
3798            });
3799    }
3800
3801    #[test]
3802    fn test_api_path_functions() {
3803        let (mut backend, _rx) = create_test_backend();
3804
3805        // Use platform-appropriate absolute path for isAbsolute test
3806        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
3807        #[cfg(windows)]
3808        let absolute_path = r#"C:\\foo\\bar"#;
3809        #[cfg(not(windows))]
3810        let absolute_path = "/foo/bar";
3811
3812        // pathJoin takes an array of path parts
3813        let js_code = format!(
3814            r#"
3815            const editor = getEditor();
3816            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
3817            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
3818            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
3819            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
3820            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
3821            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
3822        "#,
3823            absolute_path
3824        );
3825        backend.execute_js(&js_code, "test.js").unwrap();
3826
3827        backend
3828            .plugin_contexts
3829            .borrow()
3830            .get("test")
3831            .unwrap()
3832            .clone()
3833            .with(|ctx| {
3834                let global = ctx.globals();
3835                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
3836                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
3837                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
3838                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
3839                assert!(!global.get::<_, bool>("_isRelative").unwrap());
3840                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
3841            });
3842    }
3843
3844    #[test]
3845    fn test_typescript_transpilation() {
3846        use fresh_parser_js::transpile_typescript;
3847
3848        let (mut backend, rx) = create_test_backend();
3849
3850        // TypeScript code with type annotations
3851        let ts_code = r#"
3852            const editor = getEditor();
3853            function greet(name: string): string {
3854                return "Hello, " + name;
3855            }
3856            editor.setStatus(greet("TypeScript"));
3857        "#;
3858
3859        // Transpile to JavaScript first
3860        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
3861
3862        // Execute the transpiled JavaScript
3863        backend.execute_js(&js_code, "test.js").unwrap();
3864
3865        let cmd = rx.try_recv().unwrap();
3866        match cmd {
3867            PluginCommand::SetStatus { message } => {
3868                assert_eq!(message, "Hello, TypeScript");
3869            }
3870            _ => panic!("Expected SetStatus, got {:?}", cmd),
3871        }
3872    }
3873
3874    #[test]
3875    fn test_api_get_buffer_text_sends_command() {
3876        let (mut backend, rx) = create_test_backend();
3877
3878        // Call getBufferText - this returns a Promise and sends the command
3879        backend
3880            .execute_js(
3881                r#"
3882            const editor = getEditor();
3883            // Store the promise for later
3884            globalThis._textPromise = editor.getBufferText(0, 10, 20);
3885        "#,
3886                "test.js",
3887            )
3888            .unwrap();
3889
3890        // Verify the GetBufferText command was sent
3891        let cmd = rx.try_recv().unwrap();
3892        match cmd {
3893            PluginCommand::GetBufferText {
3894                buffer_id,
3895                start,
3896                end,
3897                request_id,
3898            } => {
3899                assert_eq!(buffer_id.0, 0);
3900                assert_eq!(start, 10);
3901                assert_eq!(end, 20);
3902                assert!(request_id > 0); // Should have a valid request ID
3903            }
3904            _ => panic!("Expected GetBufferText, got {:?}", cmd),
3905        }
3906    }
3907
3908    #[test]
3909    fn test_api_get_buffer_text_resolves_callback() {
3910        let (mut backend, rx) = create_test_backend();
3911
3912        // Call getBufferText and set up a handler for when it resolves
3913        backend
3914            .execute_js(
3915                r#"
3916            const editor = getEditor();
3917            globalThis._resolvedText = null;
3918            editor.getBufferText(0, 0, 100).then(text => {
3919                globalThis._resolvedText = text;
3920            });
3921        "#,
3922                "test.js",
3923            )
3924            .unwrap();
3925
3926        // Get the request_id from the command
3927        let request_id = match rx.try_recv().unwrap() {
3928            PluginCommand::GetBufferText { request_id, .. } => request_id,
3929            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
3930        };
3931
3932        // Simulate the editor responding with the text
3933        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
3934
3935        // Drive the Promise to completion
3936        backend
3937            .plugin_contexts
3938            .borrow()
3939            .get("test")
3940            .unwrap()
3941            .clone()
3942            .with(|ctx| {
3943                run_pending_jobs_checked(&ctx, "test async getText");
3944            });
3945
3946        // Verify the Promise resolved with the text
3947        backend
3948            .plugin_contexts
3949            .borrow()
3950            .get("test")
3951            .unwrap()
3952            .clone()
3953            .with(|ctx| {
3954                let global = ctx.globals();
3955                let result: String = global.get("_resolvedText").unwrap();
3956                assert_eq!(result, "hello world");
3957            });
3958    }
3959
3960    #[test]
3961    fn test_plugin_translation() {
3962        let (mut backend, _rx) = create_test_backend();
3963
3964        // The t() function should work (returns key if translation not found)
3965        backend
3966            .execute_js(
3967                r#"
3968            const editor = getEditor();
3969            globalThis._translated = editor.t("test.key");
3970        "#,
3971                "test.js",
3972            )
3973            .unwrap();
3974
3975        backend
3976            .plugin_contexts
3977            .borrow()
3978            .get("test")
3979            .unwrap()
3980            .clone()
3981            .with(|ctx| {
3982                let global = ctx.globals();
3983                // Without actual translations, it returns the key
3984                let result: String = global.get("_translated").unwrap();
3985                assert_eq!(result, "test.key");
3986            });
3987    }
3988
3989    #[test]
3990    fn test_plugin_translation_with_registered_strings() {
3991        let (mut backend, _rx) = create_test_backend();
3992
3993        // Register translations for the test plugin
3994        let mut en_strings = std::collections::HashMap::new();
3995        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
3996        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
3997
3998        let mut strings = std::collections::HashMap::new();
3999        strings.insert("en".to_string(), en_strings);
4000
4001        // Register for "test" plugin
4002        if let Some(bridge) = backend
4003            .services
4004            .as_any()
4005            .downcast_ref::<TestServiceBridge>()
4006        {
4007            let mut en = bridge.en_strings.lock().unwrap();
4008            en.insert("greeting".to_string(), "Hello, World!".to_string());
4009            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4010        }
4011
4012        // Test translation
4013        backend
4014            .execute_js(
4015                r#"
4016            const editor = getEditor();
4017            globalThis._greeting = editor.t("greeting");
4018            globalThis._prompt = editor.t("prompt.find_file");
4019            globalThis._missing = editor.t("nonexistent.key");
4020        "#,
4021                "test.js",
4022            )
4023            .unwrap();
4024
4025        backend
4026            .plugin_contexts
4027            .borrow()
4028            .get("test")
4029            .unwrap()
4030            .clone()
4031            .with(|ctx| {
4032                let global = ctx.globals();
4033                let greeting: String = global.get("_greeting").unwrap();
4034                assert_eq!(greeting, "Hello, World!");
4035
4036                let prompt: String = global.get("_prompt").unwrap();
4037                assert_eq!(prompt, "Find file: ");
4038
4039                // Missing key should return the key itself
4040                let missing: String = global.get("_missing").unwrap();
4041                assert_eq!(missing, "nonexistent.key");
4042            });
4043    }
4044
4045    // ==================== Line Indicator Tests ====================
4046
4047    #[test]
4048    fn test_api_set_line_indicator() {
4049        let (mut backend, rx) = create_test_backend();
4050
4051        backend
4052            .execute_js(
4053                r#"
4054            const editor = getEditor();
4055            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4056        "#,
4057                "test.js",
4058            )
4059            .unwrap();
4060
4061        let cmd = rx.try_recv().unwrap();
4062        match cmd {
4063            PluginCommand::SetLineIndicator {
4064                buffer_id,
4065                line,
4066                namespace,
4067                symbol,
4068                color,
4069                priority,
4070            } => {
4071                assert_eq!(buffer_id.0, 1);
4072                assert_eq!(line, 5);
4073                assert_eq!(namespace, "test-ns");
4074                assert_eq!(symbol, "●");
4075                assert_eq!(color, (255, 0, 0));
4076                assert_eq!(priority, 10);
4077            }
4078            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4079        }
4080    }
4081
4082    #[test]
4083    fn test_api_clear_line_indicators() {
4084        let (mut backend, rx) = create_test_backend();
4085
4086        backend
4087            .execute_js(
4088                r#"
4089            const editor = getEditor();
4090            editor.clearLineIndicators(1, "test-ns");
4091        "#,
4092                "test.js",
4093            )
4094            .unwrap();
4095
4096        let cmd = rx.try_recv().unwrap();
4097        match cmd {
4098            PluginCommand::ClearLineIndicators {
4099                buffer_id,
4100                namespace,
4101            } => {
4102                assert_eq!(buffer_id.0, 1);
4103                assert_eq!(namespace, "test-ns");
4104            }
4105            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4106        }
4107    }
4108
4109    // ==================== Virtual Buffer Tests ====================
4110
4111    #[test]
4112    fn test_api_create_virtual_buffer_sends_command() {
4113        let (mut backend, rx) = create_test_backend();
4114
4115        backend
4116            .execute_js(
4117                r#"
4118            const editor = getEditor();
4119            editor.createVirtualBuffer({
4120                name: "*Test Buffer*",
4121                mode: "test-mode",
4122                readOnly: true,
4123                entries: [
4124                    { text: "Line 1\n", properties: { type: "header" } },
4125                    { text: "Line 2\n", properties: { type: "content" } }
4126                ],
4127                showLineNumbers: false,
4128                showCursors: true,
4129                editingDisabled: true
4130            });
4131        "#,
4132                "test.js",
4133            )
4134            .unwrap();
4135
4136        let cmd = rx.try_recv().unwrap();
4137        match cmd {
4138            PluginCommand::CreateVirtualBufferWithContent {
4139                name,
4140                mode,
4141                read_only,
4142                entries,
4143                show_line_numbers,
4144                show_cursors,
4145                editing_disabled,
4146                ..
4147            } => {
4148                assert_eq!(name, "*Test Buffer*");
4149                assert_eq!(mode, "test-mode");
4150                assert!(read_only);
4151                assert_eq!(entries.len(), 2);
4152                assert_eq!(entries[0].text, "Line 1\n");
4153                assert!(!show_line_numbers);
4154                assert!(show_cursors);
4155                assert!(editing_disabled);
4156            }
4157            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4158        }
4159    }
4160
4161    #[test]
4162    fn test_api_set_virtual_buffer_content() {
4163        let (mut backend, rx) = create_test_backend();
4164
4165        backend
4166            .execute_js(
4167                r#"
4168            const editor = getEditor();
4169            editor.setVirtualBufferContent(5, [
4170                { text: "New content\n", properties: { type: "updated" } }
4171            ]);
4172        "#,
4173                "test.js",
4174            )
4175            .unwrap();
4176
4177        let cmd = rx.try_recv().unwrap();
4178        match cmd {
4179            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4180                assert_eq!(buffer_id.0, 5);
4181                assert_eq!(entries.len(), 1);
4182                assert_eq!(entries[0].text, "New content\n");
4183            }
4184            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4185        }
4186    }
4187
4188    // ==================== Overlay Tests ====================
4189
4190    #[test]
4191    fn test_api_add_overlay() {
4192        let (mut backend, rx) = create_test_backend();
4193
4194        backend.execute_js(r#"
4195            const editor = getEditor();
4196            editor.addOverlay(1, "highlight", 10, 20, 255, 128, 0, false, true, false, 50, 50, 50, false);
4197        "#, "test.js").unwrap();
4198
4199        let cmd = rx.try_recv().unwrap();
4200        match cmd {
4201            PluginCommand::AddOverlay {
4202                buffer_id,
4203                namespace,
4204                range,
4205                color,
4206                bg_color,
4207                underline,
4208                bold,
4209                italic,
4210                extend_to_line_end,
4211            } => {
4212                assert_eq!(buffer_id.0, 1);
4213                assert!(namespace.is_some());
4214                assert_eq!(namespace.unwrap().as_str(), "highlight");
4215                assert_eq!(range, 10..20);
4216                assert_eq!(color, (255, 128, 0));
4217                assert_eq!(bg_color, Some((50, 50, 50)));
4218                assert!(!underline);
4219                assert!(bold);
4220                assert!(!italic);
4221                assert!(!extend_to_line_end);
4222            }
4223            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4224        }
4225    }
4226
4227    #[test]
4228    fn test_api_clear_namespace() {
4229        let (mut backend, rx) = create_test_backend();
4230
4231        backend
4232            .execute_js(
4233                r#"
4234            const editor = getEditor();
4235            editor.clearNamespace(1, "highlight");
4236        "#,
4237                "test.js",
4238            )
4239            .unwrap();
4240
4241        let cmd = rx.try_recv().unwrap();
4242        match cmd {
4243            PluginCommand::ClearNamespace {
4244                buffer_id,
4245                namespace,
4246            } => {
4247                assert_eq!(buffer_id.0, 1);
4248                assert_eq!(namespace.as_str(), "highlight");
4249            }
4250            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4251        }
4252    }
4253
4254    // ==================== Theme Tests ====================
4255
4256    #[test]
4257    fn test_api_get_theme_schema() {
4258        let (mut backend, _rx) = create_test_backend();
4259
4260        backend
4261            .execute_js(
4262                r#"
4263            const editor = getEditor();
4264            const schema = editor.getThemeSchema();
4265            globalThis._isObject = typeof schema === 'object' && schema !== null;
4266        "#,
4267                "test.js",
4268            )
4269            .unwrap();
4270
4271        backend
4272            .plugin_contexts
4273            .borrow()
4274            .get("test")
4275            .unwrap()
4276            .clone()
4277            .with(|ctx| {
4278                let global = ctx.globals();
4279                let is_object: bool = global.get("_isObject").unwrap();
4280                // getThemeSchema should return an object
4281                assert!(is_object);
4282            });
4283    }
4284
4285    #[test]
4286    fn test_api_get_builtin_themes() {
4287        let (mut backend, _rx) = create_test_backend();
4288
4289        backend
4290            .execute_js(
4291                r#"
4292            const editor = getEditor();
4293            const themes = editor.getBuiltinThemes();
4294            globalThis._isObject = typeof themes === 'object' && themes !== null;
4295        "#,
4296                "test.js",
4297            )
4298            .unwrap();
4299
4300        backend
4301            .plugin_contexts
4302            .borrow()
4303            .get("test")
4304            .unwrap()
4305            .clone()
4306            .with(|ctx| {
4307                let global = ctx.globals();
4308                let is_object: bool = global.get("_isObject").unwrap();
4309                // getBuiltinThemes should return an object
4310                assert!(is_object);
4311            });
4312    }
4313
4314    #[test]
4315    fn test_api_apply_theme() {
4316        let (mut backend, rx) = create_test_backend();
4317
4318        backend
4319            .execute_js(
4320                r#"
4321            const editor = getEditor();
4322            editor.applyTheme("dark");
4323        "#,
4324                "test.js",
4325            )
4326            .unwrap();
4327
4328        let cmd = rx.try_recv().unwrap();
4329        match cmd {
4330            PluginCommand::ApplyTheme { theme_name } => {
4331                assert_eq!(theme_name, "dark");
4332            }
4333            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4334        }
4335    }
4336
4337    // ==================== Buffer Operations Tests ====================
4338
4339    #[test]
4340    fn test_api_close_buffer() {
4341        let (mut backend, rx) = create_test_backend();
4342
4343        backend
4344            .execute_js(
4345                r#"
4346            const editor = getEditor();
4347            editor.closeBuffer(3);
4348        "#,
4349                "test.js",
4350            )
4351            .unwrap();
4352
4353        let cmd = rx.try_recv().unwrap();
4354        match cmd {
4355            PluginCommand::CloseBuffer { buffer_id } => {
4356                assert_eq!(buffer_id.0, 3);
4357            }
4358            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4359        }
4360    }
4361
4362    #[test]
4363    fn test_api_focus_split() {
4364        let (mut backend, rx) = create_test_backend();
4365
4366        backend
4367            .execute_js(
4368                r#"
4369            const editor = getEditor();
4370            editor.focusSplit(2);
4371        "#,
4372                "test.js",
4373            )
4374            .unwrap();
4375
4376        let cmd = rx.try_recv().unwrap();
4377        match cmd {
4378            PluginCommand::FocusSplit { split_id } => {
4379                assert_eq!(split_id.0, 2);
4380            }
4381            _ => panic!("Expected FocusSplit, got {:?}", cmd),
4382        }
4383    }
4384
4385    #[test]
4386    fn test_api_list_buffers() {
4387        let (tx, _rx) = mpsc::channel();
4388        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4389
4390        // Add some buffers to state
4391        {
4392            let mut state = state_snapshot.write().unwrap();
4393            state.buffers.insert(
4394                BufferId(0),
4395                BufferInfo {
4396                    id: BufferId(0),
4397                    path: Some(PathBuf::from("/test1.txt")),
4398                    modified: false,
4399                    length: 100,
4400                },
4401            );
4402            state.buffers.insert(
4403                BufferId(1),
4404                BufferInfo {
4405                    id: BufferId(1),
4406                    path: Some(PathBuf::from("/test2.txt")),
4407                    modified: true,
4408                    length: 200,
4409                },
4410            );
4411        }
4412
4413        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4414        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4415
4416        backend
4417            .execute_js(
4418                r#"
4419            const editor = getEditor();
4420            const buffers = editor.listBuffers();
4421            globalThis._isArray = Array.isArray(buffers);
4422            globalThis._length = buffers.length;
4423        "#,
4424                "test.js",
4425            )
4426            .unwrap();
4427
4428        backend
4429            .plugin_contexts
4430            .borrow()
4431            .get("test")
4432            .unwrap()
4433            .clone()
4434            .with(|ctx| {
4435                let global = ctx.globals();
4436                let is_array: bool = global.get("_isArray").unwrap();
4437                let length: u32 = global.get("_length").unwrap();
4438                assert!(is_array);
4439                assert_eq!(length, 2);
4440            });
4441    }
4442
4443    // ==================== Prompt Tests ====================
4444
4445    #[test]
4446    fn test_api_start_prompt() {
4447        let (mut backend, rx) = create_test_backend();
4448
4449        backend
4450            .execute_js(
4451                r#"
4452            const editor = getEditor();
4453            editor.startPrompt("Enter value:", "test-prompt");
4454        "#,
4455                "test.js",
4456            )
4457            .unwrap();
4458
4459        let cmd = rx.try_recv().unwrap();
4460        match cmd {
4461            PluginCommand::StartPrompt { label, prompt_type } => {
4462                assert_eq!(label, "Enter value:");
4463                assert_eq!(prompt_type, "test-prompt");
4464            }
4465            _ => panic!("Expected StartPrompt, got {:?}", cmd),
4466        }
4467    }
4468
4469    #[test]
4470    fn test_api_start_prompt_with_initial() {
4471        let (mut backend, rx) = create_test_backend();
4472
4473        backend
4474            .execute_js(
4475                r#"
4476            const editor = getEditor();
4477            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4478        "#,
4479                "test.js",
4480            )
4481            .unwrap();
4482
4483        let cmd = rx.try_recv().unwrap();
4484        match cmd {
4485            PluginCommand::StartPromptWithInitial {
4486                label,
4487                prompt_type,
4488                initial_value,
4489            } => {
4490                assert_eq!(label, "Enter value:");
4491                assert_eq!(prompt_type, "test-prompt");
4492                assert_eq!(initial_value, "default");
4493            }
4494            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4495        }
4496    }
4497
4498    #[test]
4499    fn test_api_set_prompt_suggestions() {
4500        let (mut backend, rx) = create_test_backend();
4501
4502        backend
4503            .execute_js(
4504                r#"
4505            const editor = getEditor();
4506            editor.setPromptSuggestions([
4507                { text: "Option 1", value: "opt1" },
4508                { text: "Option 2", value: "opt2" }
4509            ]);
4510        "#,
4511                "test.js",
4512            )
4513            .unwrap();
4514
4515        let cmd = rx.try_recv().unwrap();
4516        match cmd {
4517            PluginCommand::SetPromptSuggestions { suggestions } => {
4518                assert_eq!(suggestions.len(), 2);
4519                assert_eq!(suggestions[0].text, "Option 1");
4520                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4521            }
4522            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4523        }
4524    }
4525
4526    // ==================== State Query Tests ====================
4527
4528    #[test]
4529    fn test_api_get_active_buffer_id() {
4530        let (tx, _rx) = mpsc::channel();
4531        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4532
4533        {
4534            let mut state = state_snapshot.write().unwrap();
4535            state.active_buffer_id = BufferId(42);
4536        }
4537
4538        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4539        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4540
4541        backend
4542            .execute_js(
4543                r#"
4544            const editor = getEditor();
4545            globalThis._activeId = editor.getActiveBufferId();
4546        "#,
4547                "test.js",
4548            )
4549            .unwrap();
4550
4551        backend
4552            .plugin_contexts
4553            .borrow()
4554            .get("test")
4555            .unwrap()
4556            .clone()
4557            .with(|ctx| {
4558                let global = ctx.globals();
4559                let result: u32 = global.get("_activeId").unwrap();
4560                assert_eq!(result, 42);
4561            });
4562    }
4563
4564    #[test]
4565    fn test_api_get_active_split_id() {
4566        let (tx, _rx) = mpsc::channel();
4567        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4568
4569        {
4570            let mut state = state_snapshot.write().unwrap();
4571            state.active_split_id = 7;
4572        }
4573
4574        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4575        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4576
4577        backend
4578            .execute_js(
4579                r#"
4580            const editor = getEditor();
4581            globalThis._splitId = editor.getActiveSplitId();
4582        "#,
4583                "test.js",
4584            )
4585            .unwrap();
4586
4587        backend
4588            .plugin_contexts
4589            .borrow()
4590            .get("test")
4591            .unwrap()
4592            .clone()
4593            .with(|ctx| {
4594                let global = ctx.globals();
4595                let result: u32 = global.get("_splitId").unwrap();
4596                assert_eq!(result, 7);
4597            });
4598    }
4599
4600    // ==================== File System Tests ====================
4601
4602    #[test]
4603    fn test_api_file_exists() {
4604        let (mut backend, _rx) = create_test_backend();
4605
4606        backend
4607            .execute_js(
4608                r#"
4609            const editor = getEditor();
4610            // Test with a path that definitely exists
4611            globalThis._exists = editor.fileExists("/");
4612        "#,
4613                "test.js",
4614            )
4615            .unwrap();
4616
4617        backend
4618            .plugin_contexts
4619            .borrow()
4620            .get("test")
4621            .unwrap()
4622            .clone()
4623            .with(|ctx| {
4624                let global = ctx.globals();
4625                let result: bool = global.get("_exists").unwrap();
4626                assert!(result);
4627            });
4628    }
4629
4630    #[test]
4631    fn test_api_get_cwd() {
4632        let (mut backend, _rx) = create_test_backend();
4633
4634        backend
4635            .execute_js(
4636                r#"
4637            const editor = getEditor();
4638            globalThis._cwd = editor.getCwd();
4639        "#,
4640                "test.js",
4641            )
4642            .unwrap();
4643
4644        backend
4645            .plugin_contexts
4646            .borrow()
4647            .get("test")
4648            .unwrap()
4649            .clone()
4650            .with(|ctx| {
4651                let global = ctx.globals();
4652                let result: String = global.get("_cwd").unwrap();
4653                // Should return some path
4654                assert!(!result.is_empty());
4655            });
4656    }
4657
4658    #[test]
4659    fn test_api_get_env() {
4660        let (mut backend, _rx) = create_test_backend();
4661
4662        // Set a test environment variable
4663        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
4664
4665        backend
4666            .execute_js(
4667                r#"
4668            const editor = getEditor();
4669            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
4670        "#,
4671                "test.js",
4672            )
4673            .unwrap();
4674
4675        backend
4676            .plugin_contexts
4677            .borrow()
4678            .get("test")
4679            .unwrap()
4680            .clone()
4681            .with(|ctx| {
4682                let global = ctx.globals();
4683                let result: Option<String> = global.get("_envVal").unwrap();
4684                assert_eq!(result, Some("test_value".to_string()));
4685            });
4686
4687        std::env::remove_var("TEST_PLUGIN_VAR");
4688    }
4689
4690    #[test]
4691    fn test_api_get_config() {
4692        let (mut backend, _rx) = create_test_backend();
4693
4694        backend
4695            .execute_js(
4696                r#"
4697            const editor = getEditor();
4698            const config = editor.getConfig();
4699            globalThis._isObject = typeof config === 'object';
4700        "#,
4701                "test.js",
4702            )
4703            .unwrap();
4704
4705        backend
4706            .plugin_contexts
4707            .borrow()
4708            .get("test")
4709            .unwrap()
4710            .clone()
4711            .with(|ctx| {
4712                let global = ctx.globals();
4713                let is_object: bool = global.get("_isObject").unwrap();
4714                // getConfig should return an object, not a string
4715                assert!(is_object);
4716            });
4717    }
4718
4719    #[test]
4720    fn test_api_get_themes_dir() {
4721        let (mut backend, _rx) = create_test_backend();
4722
4723        backend
4724            .execute_js(
4725                r#"
4726            const editor = getEditor();
4727            globalThis._themesDir = editor.getThemesDir();
4728        "#,
4729                "test.js",
4730            )
4731            .unwrap();
4732
4733        backend
4734            .plugin_contexts
4735            .borrow()
4736            .get("test")
4737            .unwrap()
4738            .clone()
4739            .with(|ctx| {
4740                let global = ctx.globals();
4741                let result: String = global.get("_themesDir").unwrap();
4742                // Should return some path
4743                assert!(!result.is_empty());
4744            });
4745    }
4746
4747    // ==================== Read Dir Test ====================
4748
4749    #[test]
4750    fn test_api_read_dir() {
4751        let (mut backend, _rx) = create_test_backend();
4752
4753        backend
4754            .execute_js(
4755                r#"
4756            const editor = getEditor();
4757            const entries = editor.readDir("/tmp");
4758            globalThis._isArray = Array.isArray(entries);
4759            globalThis._length = entries.length;
4760        "#,
4761                "test.js",
4762            )
4763            .unwrap();
4764
4765        backend
4766            .plugin_contexts
4767            .borrow()
4768            .get("test")
4769            .unwrap()
4770            .clone()
4771            .with(|ctx| {
4772                let global = ctx.globals();
4773                let is_array: bool = global.get("_isArray").unwrap();
4774                let length: u32 = global.get("_length").unwrap();
4775                // /tmp should exist and readDir should return an array
4776                assert!(is_array);
4777                // Length is valid (u32 always >= 0)
4778                let _ = length;
4779            });
4780    }
4781
4782    // ==================== Execute Action Test ====================
4783
4784    #[test]
4785    fn test_api_execute_action() {
4786        let (mut backend, rx) = create_test_backend();
4787
4788        backend
4789            .execute_js(
4790                r#"
4791            const editor = getEditor();
4792            editor.executeAction("move_cursor_up");
4793        "#,
4794                "test.js",
4795            )
4796            .unwrap();
4797
4798        let cmd = rx.try_recv().unwrap();
4799        match cmd {
4800            PluginCommand::ExecuteAction { action_name } => {
4801                assert_eq!(action_name, "move_cursor_up");
4802            }
4803            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
4804        }
4805    }
4806
4807    // ==================== Debug Test ====================
4808
4809    #[test]
4810    fn test_api_debug() {
4811        let (mut backend, _rx) = create_test_backend();
4812
4813        // debug() should not panic and should work with any input
4814        backend
4815            .execute_js(
4816                r#"
4817            const editor = getEditor();
4818            editor.debug("Test debug message");
4819            editor.debug("Another message with special chars: <>&\"'");
4820        "#,
4821                "test.js",
4822            )
4823            .unwrap();
4824        // If we get here without panic, the test passes
4825    }
4826
4827    // ==================== TypeScript Definitions Test ====================
4828
4829    #[test]
4830    fn test_typescript_preamble_generated() {
4831        // Check that the TypeScript preamble constant exists and has content
4832        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
4833        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
4834        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
4835        println!(
4836            "Generated {} bytes of TypeScript preamble",
4837            JSEDITORAPI_TS_PREAMBLE.len()
4838        );
4839    }
4840
4841    #[test]
4842    fn test_typescript_editor_api_generated() {
4843        // Check that the EditorAPI interface is generated
4844        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
4845        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
4846        println!(
4847            "Generated {} bytes of EditorAPI interface",
4848            JSEDITORAPI_TS_EDITOR_API.len()
4849        );
4850    }
4851
4852    #[test]
4853    fn test_js_methods_list() {
4854        // Check that the JS methods list is generated
4855        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
4856        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
4857        // Print first 20 methods
4858        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
4859            if i < 20 {
4860                println!("  - {}", method);
4861            }
4862        }
4863        if JSEDITORAPI_JS_METHODS.len() > 20 {
4864            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
4865        }
4866    }
4867}