fresh_plugin_runtime/backend/
quickjs_backend.rs

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