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