Skip to main content

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