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    /// Accepts tokens in the simple format:
1504    ///   {kind: "text"|"newline"|"space"|"break", text: "...", sourceOffset: N, style?: {...}}
1505    ///
1506    /// Also accepts the TypeScript-defined format for backwards compatibility:
1507    ///   {kind: {Text: "..."} | "Newline" | "Space" | "Break", source_offset: N, style?: {...}}
1508    #[allow(clippy::too_many_arguments)]
1509    pub fn submit_view_transform<'js>(
1510        &self,
1511        _ctx: rquickjs::Ctx<'js>,
1512        buffer_id: u32,
1513        split_id: Option<u32>,
1514        start: u32,
1515        end: u32,
1516        tokens: Vec<rquickjs::Object<'js>>,
1517        layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1518    ) -> rquickjs::Result<bool> {
1519        use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1520
1521        let tokens: Vec<ViewTokenWire> = tokens
1522            .into_iter()
1523            .enumerate()
1524            .map(|(idx, obj)| {
1525                // Try to parse the token, with detailed error messages
1526                parse_view_token(&obj, idx)
1527            })
1528            .collect::<rquickjs::Result<Vec<_>>>()?;
1529
1530        // Parse layout hints if provided
1531        let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
1532            let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
1533            let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
1534            Some(LayoutHints {
1535                compose_width,
1536                column_guides,
1537            })
1538        } else {
1539            None
1540        };
1541
1542        let payload = ViewTransformPayload {
1543            range: (start as usize)..(end as usize),
1544            tokens,
1545            layout_hints: parsed_layout_hints,
1546        };
1547
1548        Ok(self
1549            .command_sender
1550            .send(PluginCommand::SubmitViewTransform {
1551                buffer_id: BufferId(buffer_id as usize),
1552                split_id: split_id.map(|id| SplitId(id as usize)),
1553                payload,
1554            })
1555            .is_ok())
1556    }
1557
1558    /// Clear view transform for a buffer/split
1559    pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1560        self.command_sender
1561            .send(PluginCommand::ClearViewTransform {
1562                buffer_id: BufferId(buffer_id as usize),
1563                split_id: split_id.map(|id| SplitId(id as usize)),
1564            })
1565            .is_ok()
1566    }
1567
1568    // === File Explorer ===
1569
1570    /// Set file explorer decorations for a namespace
1571    pub fn set_file_explorer_decorations<'js>(
1572        &self,
1573        _ctx: rquickjs::Ctx<'js>,
1574        namespace: String,
1575        decorations: Vec<rquickjs::Object<'js>>,
1576    ) -> rquickjs::Result<bool> {
1577        use fresh_core::file_explorer::FileExplorerDecoration;
1578
1579        let decorations: Vec<FileExplorerDecoration> = decorations
1580            .into_iter()
1581            .map(|obj| {
1582                let path: String = obj.get("path")?;
1583                let symbol: String = obj.get("symbol")?;
1584                let color: Vec<u8> = obj.get("color")?;
1585                let priority: i32 = obj.get("priority").unwrap_or(0);
1586
1587                if color.len() < 3 {
1588                    return Err(rquickjs::Error::FromJs {
1589                        from: "array",
1590                        to: "color",
1591                        message: Some(format!(
1592                            "color array must have at least 3 elements, got {}",
1593                            color.len()
1594                        )),
1595                    });
1596                }
1597
1598                Ok(FileExplorerDecoration {
1599                    path: std::path::PathBuf::from(path),
1600                    symbol,
1601                    color: [color[0], color[1], color[2]],
1602                    priority,
1603                })
1604            })
1605            .collect::<rquickjs::Result<Vec<_>>>()?;
1606
1607        Ok(self
1608            .command_sender
1609            .send(PluginCommand::SetFileExplorerDecorations {
1610                namespace,
1611                decorations,
1612            })
1613            .is_ok())
1614    }
1615
1616    /// Clear file explorer decorations for a namespace
1617    pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1618        self.command_sender
1619            .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1620            .is_ok()
1621    }
1622
1623    // === Virtual Text ===
1624
1625    /// Add virtual text (inline text that doesn't exist in the buffer)
1626    #[allow(clippy::too_many_arguments)]
1627    pub fn add_virtual_text(
1628        &self,
1629        buffer_id: u32,
1630        virtual_text_id: String,
1631        position: u32,
1632        text: String,
1633        r: u8,
1634        g: u8,
1635        b: u8,
1636        before: bool,
1637        use_bg: bool,
1638    ) -> bool {
1639        self.command_sender
1640            .send(PluginCommand::AddVirtualText {
1641                buffer_id: BufferId(buffer_id as usize),
1642                virtual_text_id,
1643                position: position as usize,
1644                text,
1645                color: (r, g, b),
1646                use_bg,
1647                before,
1648            })
1649            .is_ok()
1650    }
1651
1652    /// Remove a virtual text by ID
1653    pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1654        self.command_sender
1655            .send(PluginCommand::RemoveVirtualText {
1656                buffer_id: BufferId(buffer_id as usize),
1657                virtual_text_id,
1658            })
1659            .is_ok()
1660    }
1661
1662    /// Remove virtual texts whose ID starts with the given prefix
1663    pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1664        self.command_sender
1665            .send(PluginCommand::RemoveVirtualTextsByPrefix {
1666                buffer_id: BufferId(buffer_id as usize),
1667                prefix,
1668            })
1669            .is_ok()
1670    }
1671
1672    /// Clear all virtual texts from a buffer
1673    pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1674        self.command_sender
1675            .send(PluginCommand::ClearVirtualTexts {
1676                buffer_id: BufferId(buffer_id as usize),
1677            })
1678            .is_ok()
1679    }
1680
1681    /// Clear all virtual texts in a namespace
1682    pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1683        self.command_sender
1684            .send(PluginCommand::ClearVirtualTextNamespace {
1685                buffer_id: BufferId(buffer_id as usize),
1686                namespace,
1687            })
1688            .is_ok()
1689    }
1690
1691    /// Add a virtual line (full line above/below a position)
1692    #[allow(clippy::too_many_arguments)]
1693    pub fn add_virtual_line(
1694        &self,
1695        buffer_id: u32,
1696        position: u32,
1697        text: String,
1698        fg_r: u8,
1699        fg_g: u8,
1700        fg_b: u8,
1701        bg_r: u8,
1702        bg_g: u8,
1703        bg_b: u8,
1704        above: bool,
1705        namespace: String,
1706        priority: i32,
1707    ) -> bool {
1708        self.command_sender
1709            .send(PluginCommand::AddVirtualLine {
1710                buffer_id: BufferId(buffer_id as usize),
1711                position: position as usize,
1712                text,
1713                fg_color: (fg_r, fg_g, fg_b),
1714                bg_color: Some((bg_r, bg_g, bg_b)),
1715                above,
1716                namespace,
1717                priority,
1718            })
1719            .is_ok()
1720    }
1721
1722    // === Prompts ===
1723
1724    /// Show a prompt and wait for user input (async)
1725    /// Returns the user input or null if cancelled
1726    #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1727    #[qjs(rename = "_promptStart")]
1728    pub fn prompt_start(
1729        &self,
1730        _ctx: rquickjs::Ctx<'_>,
1731        label: String,
1732        initial_value: String,
1733    ) -> u64 {
1734        let id = {
1735            let mut id_ref = self.next_request_id.borrow_mut();
1736            let id = *id_ref;
1737            *id_ref += 1;
1738            // Record context for this callback
1739            self.callback_contexts
1740                .borrow_mut()
1741                .insert(id, self.plugin_name.clone());
1742            id
1743        };
1744
1745        let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1746            label,
1747            initial_value,
1748            callback_id: JsCallbackId::new(id),
1749        });
1750
1751        id
1752    }
1753
1754    /// Start an interactive prompt
1755    pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1756        self.command_sender
1757            .send(PluginCommand::StartPrompt { label, prompt_type })
1758            .is_ok()
1759    }
1760
1761    /// Start a prompt with initial value
1762    pub fn start_prompt_with_initial(
1763        &self,
1764        label: String,
1765        prompt_type: String,
1766        initial_value: String,
1767    ) -> bool {
1768        self.command_sender
1769            .send(PluginCommand::StartPromptWithInitial {
1770                label,
1771                prompt_type,
1772                initial_value,
1773            })
1774            .is_ok()
1775    }
1776
1777    /// Set suggestions for the current prompt
1778    ///
1779    /// Uses typed Vec<Suggestion> - serde validates field names at runtime
1780    pub fn set_prompt_suggestions(
1781        &self,
1782        suggestions: Vec<fresh_core::command::Suggestion>,
1783    ) -> bool {
1784        self.command_sender
1785            .send(PluginCommand::SetPromptSuggestions { suggestions })
1786            .is_ok()
1787    }
1788
1789    // === Modes ===
1790
1791    /// Define a buffer mode (takes bindings as array of [key, command] pairs)
1792    pub fn define_mode(
1793        &self,
1794        name: String,
1795        parent: Option<String>,
1796        bindings_arr: Vec<Vec<String>>,
1797        read_only: rquickjs::function::Opt<bool>,
1798    ) -> bool {
1799        let bindings: Vec<(String, String)> = bindings_arr
1800            .into_iter()
1801            .filter_map(|arr| {
1802                if arr.len() >= 2 {
1803                    Some((arr[0].clone(), arr[1].clone()))
1804                } else {
1805                    None
1806                }
1807            })
1808            .collect();
1809
1810        // Register commands associated with this mode so start_action can find them
1811        // and execute them in the correct plugin context
1812        {
1813            let mut registered = self.registered_actions.borrow_mut();
1814            for (_, cmd_name) in &bindings {
1815                registered.insert(
1816                    cmd_name.clone(),
1817                    PluginHandler {
1818                        plugin_name: self.plugin_name.clone(),
1819                        handler_name: cmd_name.clone(),
1820                    },
1821                );
1822            }
1823        }
1824
1825        self.command_sender
1826            .send(PluginCommand::DefineMode {
1827                name,
1828                parent,
1829                bindings,
1830                read_only: read_only.0.unwrap_or(false),
1831            })
1832            .is_ok()
1833    }
1834
1835    /// Set the global editor mode
1836    pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1837        self.command_sender
1838            .send(PluginCommand::SetEditorMode { mode })
1839            .is_ok()
1840    }
1841
1842    /// Get the current editor mode
1843    pub fn get_editor_mode(&self) -> Option<String> {
1844        self.state_snapshot
1845            .read()
1846            .ok()
1847            .and_then(|s| s.editor_mode.clone())
1848    }
1849
1850    // === Splits ===
1851
1852    /// Close a split
1853    pub fn close_split(&self, split_id: u32) -> bool {
1854        self.command_sender
1855            .send(PluginCommand::CloseSplit {
1856                split_id: SplitId(split_id as usize),
1857            })
1858            .is_ok()
1859    }
1860
1861    /// Set the buffer displayed in a split
1862    pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1863        self.command_sender
1864            .send(PluginCommand::SetSplitBuffer {
1865                split_id: SplitId(split_id as usize),
1866                buffer_id: BufferId(buffer_id as usize),
1867            })
1868            .is_ok()
1869    }
1870
1871    /// Focus a specific split
1872    pub fn focus_split(&self, split_id: u32) -> bool {
1873        self.command_sender
1874            .send(PluginCommand::FocusSplit {
1875                split_id: SplitId(split_id as usize),
1876            })
1877            .is_ok()
1878    }
1879
1880    /// Set scroll position of a split
1881    pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1882        self.command_sender
1883            .send(PluginCommand::SetSplitScroll {
1884                split_id: SplitId(split_id as usize),
1885                top_byte: top_byte as usize,
1886            })
1887            .is_ok()
1888    }
1889
1890    /// Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
1891    pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1892        self.command_sender
1893            .send(PluginCommand::SetSplitRatio {
1894                split_id: SplitId(split_id as usize),
1895                ratio,
1896            })
1897            .is_ok()
1898    }
1899
1900    /// Distribute all splits evenly
1901    pub fn distribute_splits_evenly(&self) -> bool {
1902        // Get all split IDs - for now send empty vec (app will handle)
1903        self.command_sender
1904            .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1905            .is_ok()
1906    }
1907
1908    /// Set cursor position in a buffer
1909    pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1910        self.command_sender
1911            .send(PluginCommand::SetBufferCursor {
1912                buffer_id: BufferId(buffer_id as usize),
1913                position: position as usize,
1914            })
1915            .is_ok()
1916    }
1917
1918    // === Line Indicators ===
1919
1920    /// Set a line indicator in the gutter
1921    #[allow(clippy::too_many_arguments)]
1922    pub fn set_line_indicator(
1923        &self,
1924        buffer_id: u32,
1925        line: u32,
1926        namespace: String,
1927        symbol: String,
1928        r: u8,
1929        g: u8,
1930        b: u8,
1931        priority: i32,
1932    ) -> bool {
1933        self.command_sender
1934            .send(PluginCommand::SetLineIndicator {
1935                buffer_id: BufferId(buffer_id as usize),
1936                line: line as usize,
1937                namespace,
1938                symbol,
1939                color: (r, g, b),
1940                priority,
1941            })
1942            .is_ok()
1943    }
1944
1945    /// Clear line indicators in a namespace
1946    pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
1947        self.command_sender
1948            .send(PluginCommand::ClearLineIndicators {
1949                buffer_id: BufferId(buffer_id as usize),
1950                namespace,
1951            })
1952            .is_ok()
1953    }
1954
1955    /// Enable or disable line numbers for a buffer
1956    pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
1957        self.command_sender
1958            .send(PluginCommand::SetLineNumbers {
1959                buffer_id: BufferId(buffer_id as usize),
1960                enabled,
1961            })
1962            .is_ok()
1963    }
1964
1965    /// Enable or disable line wrapping for a buffer/split
1966    pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
1967        self.command_sender
1968            .send(PluginCommand::SetLineWrap {
1969                buffer_id: BufferId(buffer_id as usize),
1970                split_id: split_id.map(|s| SplitId(s as usize)),
1971                enabled,
1972            })
1973            .is_ok()
1974    }
1975
1976    // === Scroll Sync ===
1977
1978    /// Create a scroll sync group for anchor-based synchronized scrolling
1979    pub fn create_scroll_sync_group(
1980        &self,
1981        group_id: u32,
1982        left_split: u32,
1983        right_split: u32,
1984    ) -> bool {
1985        self.command_sender
1986            .send(PluginCommand::CreateScrollSyncGroup {
1987                group_id,
1988                left_split: SplitId(left_split as usize),
1989                right_split: SplitId(right_split as usize),
1990            })
1991            .is_ok()
1992    }
1993
1994    /// Set sync anchors for a scroll sync group
1995    pub fn set_scroll_sync_anchors<'js>(
1996        &self,
1997        _ctx: rquickjs::Ctx<'js>,
1998        group_id: u32,
1999        anchors: Vec<Vec<u32>>,
2000    ) -> bool {
2001        let anchors: Vec<(usize, usize)> = anchors
2002            .into_iter()
2003            .filter_map(|pair| {
2004                if pair.len() >= 2 {
2005                    Some((pair[0] as usize, pair[1] as usize))
2006                } else {
2007                    None
2008                }
2009            })
2010            .collect();
2011        self.command_sender
2012            .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2013            .is_ok()
2014    }
2015
2016    /// Remove a scroll sync group
2017    pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2018        self.command_sender
2019            .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2020            .is_ok()
2021    }
2022
2023    // === Actions ===
2024
2025    /// Execute multiple actions in sequence
2026    ///
2027    /// Takes typed ActionSpec array - serde validates field names at runtime
2028    pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2029        self.command_sender
2030            .send(PluginCommand::ExecuteActions { actions })
2031            .is_ok()
2032    }
2033
2034    /// Show an action popup
2035    ///
2036    /// Takes a typed ActionPopupOptions struct - serde validates field names at runtime
2037    pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2038        self.command_sender
2039            .send(PluginCommand::ShowActionPopup {
2040                popup_id: opts.id,
2041                title: opts.title,
2042                message: opts.message,
2043                actions: opts.actions,
2044            })
2045            .is_ok()
2046    }
2047
2048    /// Disable LSP for a specific language
2049    pub fn disable_lsp_for_language(&self, language: String) -> bool {
2050        self.command_sender
2051            .send(PluginCommand::DisableLspForLanguage { language })
2052            .is_ok()
2053    }
2054
2055    /// Set the workspace root URI for a specific language's LSP server
2056    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
2057    pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2058        self.command_sender
2059            .send(PluginCommand::SetLspRootUri { language, uri })
2060            .is_ok()
2061    }
2062
2063    /// Get all diagnostics from LSP
2064    #[plugin_api(ts_return = "JsDiagnostic[]")]
2065    pub fn get_all_diagnostics<'js>(
2066        &self,
2067        ctx: rquickjs::Ctx<'js>,
2068    ) -> rquickjs::Result<Value<'js>> {
2069        use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2070
2071        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2072            // Convert to JsDiagnostic format for JS
2073            let mut result: Vec<JsDiagnostic> = Vec::new();
2074            for (uri, diags) in &s.diagnostics {
2075                for diag in diags {
2076                    result.push(JsDiagnostic {
2077                        uri: uri.clone(),
2078                        message: diag.message.clone(),
2079                        severity: diag.severity.map(|s| match s {
2080                            lsp_types::DiagnosticSeverity::ERROR => 1,
2081                            lsp_types::DiagnosticSeverity::WARNING => 2,
2082                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
2083                            lsp_types::DiagnosticSeverity::HINT => 4,
2084                            _ => 0,
2085                        }),
2086                        range: JsRange {
2087                            start: JsPosition {
2088                                line: diag.range.start.line,
2089                                character: diag.range.start.character,
2090                            },
2091                            end: JsPosition {
2092                                line: diag.range.end.line,
2093                                character: diag.range.end.character,
2094                            },
2095                        },
2096                        source: diag.source.clone(),
2097                    });
2098                }
2099            }
2100            result
2101        } else {
2102            Vec::new()
2103        };
2104        rquickjs_serde::to_value(ctx, &diagnostics)
2105            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2106    }
2107
2108    /// Get registered event handlers for an event
2109    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2110        self.event_handlers
2111            .borrow()
2112            .get(&event_name)
2113            .cloned()
2114            .unwrap_or_default()
2115            .into_iter()
2116            .map(|h| h.handler_name)
2117            .collect()
2118    }
2119
2120    // === Virtual Buffers ===
2121
2122    /// Create a virtual buffer in current split (async, returns buffer and split IDs)
2123    #[plugin_api(
2124        async_promise,
2125        js_name = "createVirtualBuffer",
2126        ts_return = "VirtualBufferResult"
2127    )]
2128    #[qjs(rename = "_createVirtualBufferStart")]
2129    pub fn create_virtual_buffer_start(
2130        &self,
2131        _ctx: rquickjs::Ctx<'_>,
2132        opts: fresh_core::api::CreateVirtualBufferOptions,
2133    ) -> rquickjs::Result<u64> {
2134        let id = {
2135            let mut id_ref = self.next_request_id.borrow_mut();
2136            let id = *id_ref;
2137            *id_ref += 1;
2138            // Record context for this callback
2139            self.callback_contexts
2140                .borrow_mut()
2141                .insert(id, self.plugin_name.clone());
2142            id
2143        };
2144
2145        // Convert JsTextPropertyEntry to TextPropertyEntry
2146        let entries: Vec<TextPropertyEntry> = opts
2147            .entries
2148            .unwrap_or_default()
2149            .into_iter()
2150            .map(|e| TextPropertyEntry {
2151                text: e.text,
2152                properties: e.properties.unwrap_or_default(),
2153            })
2154            .collect();
2155
2156        tracing::debug!(
2157            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2158            id
2159        );
2160        let _ = self
2161            .command_sender
2162            .send(PluginCommand::CreateVirtualBufferWithContent {
2163                name: opts.name,
2164                mode: opts.mode.unwrap_or_default(),
2165                read_only: opts.read_only.unwrap_or(false),
2166                entries,
2167                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2168                show_cursors: opts.show_cursors.unwrap_or(true),
2169                editing_disabled: opts.editing_disabled.unwrap_or(false),
2170                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2171                request_id: Some(id),
2172            });
2173        Ok(id)
2174    }
2175
2176    /// Create a virtual buffer in a new split (async, returns buffer and split IDs)
2177    #[plugin_api(
2178        async_promise,
2179        js_name = "createVirtualBufferInSplit",
2180        ts_return = "VirtualBufferResult"
2181    )]
2182    #[qjs(rename = "_createVirtualBufferInSplitStart")]
2183    pub fn create_virtual_buffer_in_split_start(
2184        &self,
2185        _ctx: rquickjs::Ctx<'_>,
2186        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2187    ) -> rquickjs::Result<u64> {
2188        let id = {
2189            let mut id_ref = self.next_request_id.borrow_mut();
2190            let id = *id_ref;
2191            *id_ref += 1;
2192            // Record context for this callback
2193            self.callback_contexts
2194                .borrow_mut()
2195                .insert(id, self.plugin_name.clone());
2196            id
2197        };
2198
2199        // Convert JsTextPropertyEntry to TextPropertyEntry
2200        let entries: Vec<TextPropertyEntry> = opts
2201            .entries
2202            .unwrap_or_default()
2203            .into_iter()
2204            .map(|e| TextPropertyEntry {
2205                text: e.text,
2206                properties: e.properties.unwrap_or_default(),
2207            })
2208            .collect();
2209
2210        let _ = self
2211            .command_sender
2212            .send(PluginCommand::CreateVirtualBufferInSplit {
2213                name: opts.name,
2214                mode: opts.mode.unwrap_or_default(),
2215                read_only: opts.read_only.unwrap_or(false),
2216                entries,
2217                ratio: opts.ratio.unwrap_or(0.5),
2218                direction: opts.direction,
2219                panel_id: opts.panel_id,
2220                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2221                show_cursors: opts.show_cursors.unwrap_or(true),
2222                editing_disabled: opts.editing_disabled.unwrap_or(false),
2223                line_wrap: opts.line_wrap,
2224                request_id: Some(id),
2225            });
2226        Ok(id)
2227    }
2228
2229    /// Create a virtual buffer in an existing split (async, returns buffer and split IDs)
2230    #[plugin_api(
2231        async_promise,
2232        js_name = "createVirtualBufferInExistingSplit",
2233        ts_return = "VirtualBufferResult"
2234    )]
2235    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2236    pub fn create_virtual_buffer_in_existing_split_start(
2237        &self,
2238        _ctx: rquickjs::Ctx<'_>,
2239        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2240    ) -> rquickjs::Result<u64> {
2241        let id = {
2242            let mut id_ref = self.next_request_id.borrow_mut();
2243            let id = *id_ref;
2244            *id_ref += 1;
2245            // Record context for this callback
2246            self.callback_contexts
2247                .borrow_mut()
2248                .insert(id, self.plugin_name.clone());
2249            id
2250        };
2251
2252        // Convert JsTextPropertyEntry to TextPropertyEntry
2253        let entries: Vec<TextPropertyEntry> = opts
2254            .entries
2255            .unwrap_or_default()
2256            .into_iter()
2257            .map(|e| TextPropertyEntry {
2258                text: e.text,
2259                properties: e.properties.unwrap_or_default(),
2260            })
2261            .collect();
2262
2263        let _ = self
2264            .command_sender
2265            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2266                name: opts.name,
2267                mode: opts.mode.unwrap_or_default(),
2268                read_only: opts.read_only.unwrap_or(false),
2269                entries,
2270                split_id: SplitId(opts.split_id),
2271                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2272                show_cursors: opts.show_cursors.unwrap_or(true),
2273                editing_disabled: opts.editing_disabled.unwrap_or(false),
2274                line_wrap: opts.line_wrap,
2275                request_id: Some(id),
2276            });
2277        Ok(id)
2278    }
2279
2280    /// Set virtual buffer content (takes array of entry objects)
2281    ///
2282    /// Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
2283    pub fn set_virtual_buffer_content<'js>(
2284        &self,
2285        ctx: rquickjs::Ctx<'js>,
2286        buffer_id: u32,
2287        entries_arr: Vec<rquickjs::Object<'js>>,
2288    ) -> rquickjs::Result<bool> {
2289        let entries: Vec<TextPropertyEntry> = entries_arr
2290            .iter()
2291            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2292            .collect();
2293        Ok(self
2294            .command_sender
2295            .send(PluginCommand::SetVirtualBufferContent {
2296                buffer_id: BufferId(buffer_id as usize),
2297                entries,
2298            })
2299            .is_ok())
2300    }
2301
2302    /// Get text properties at cursor position (returns JS array)
2303    pub fn get_text_properties_at_cursor(
2304        &self,
2305        buffer_id: u32,
2306    ) -> fresh_core::api::TextPropertiesAtCursor {
2307        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2308    }
2309
2310    // === Async Operations ===
2311
2312    /// Spawn a process (async, returns request_id)
2313    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2314    #[qjs(rename = "_spawnProcessStart")]
2315    pub fn spawn_process_start(
2316        &self,
2317        _ctx: rquickjs::Ctx<'_>,
2318        command: String,
2319        args: Vec<String>,
2320        cwd: rquickjs::function::Opt<String>,
2321    ) -> u64 {
2322        let id = {
2323            let mut id_ref = self.next_request_id.borrow_mut();
2324            let id = *id_ref;
2325            *id_ref += 1;
2326            // Record context for this callback
2327            self.callback_contexts
2328                .borrow_mut()
2329                .insert(id, self.plugin_name.clone());
2330            id
2331        };
2332        // Use provided cwd, or fall back to snapshot's working_dir
2333        let effective_cwd = cwd.0.or_else(|| {
2334            self.state_snapshot
2335                .read()
2336                .ok()
2337                .map(|s| s.working_dir.to_string_lossy().to_string())
2338        });
2339        tracing::info!(
2340            "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2341            command,
2342            args,
2343            effective_cwd,
2344            id
2345        );
2346        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2347            callback_id: JsCallbackId::new(id),
2348            command,
2349            args,
2350            cwd: effective_cwd,
2351        });
2352        id
2353    }
2354
2355    /// Wait for a process to complete and get its result (async)
2356    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2357    #[qjs(rename = "_spawnProcessWaitStart")]
2358    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2359        let id = {
2360            let mut id_ref = self.next_request_id.borrow_mut();
2361            let id = *id_ref;
2362            *id_ref += 1;
2363            // Record context for this callback
2364            self.callback_contexts
2365                .borrow_mut()
2366                .insert(id, self.plugin_name.clone());
2367            id
2368        };
2369        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2370            process_id,
2371            callback_id: JsCallbackId::new(id),
2372        });
2373        id
2374    }
2375
2376    /// Get buffer text range (async, returns request_id)
2377    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2378    #[qjs(rename = "_getBufferTextStart")]
2379    pub fn get_buffer_text_start(
2380        &self,
2381        _ctx: rquickjs::Ctx<'_>,
2382        buffer_id: u32,
2383        start: u32,
2384        end: u32,
2385    ) -> u64 {
2386        let id = {
2387            let mut id_ref = self.next_request_id.borrow_mut();
2388            let id = *id_ref;
2389            *id_ref += 1;
2390            // Record context for this callback
2391            self.callback_contexts
2392                .borrow_mut()
2393                .insert(id, self.plugin_name.clone());
2394            id
2395        };
2396        let _ = self.command_sender.send(PluginCommand::GetBufferText {
2397            buffer_id: BufferId(buffer_id as usize),
2398            start: start as usize,
2399            end: end as usize,
2400            request_id: id,
2401        });
2402        id
2403    }
2404
2405    /// Delay/sleep (async, returns request_id)
2406    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2407    #[qjs(rename = "_delayStart")]
2408    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2409        let id = {
2410            let mut id_ref = self.next_request_id.borrow_mut();
2411            let id = *id_ref;
2412            *id_ref += 1;
2413            // Record context for this callback
2414            self.callback_contexts
2415                .borrow_mut()
2416                .insert(id, self.plugin_name.clone());
2417            id
2418        };
2419        let _ = self.command_sender.send(PluginCommand::Delay {
2420            callback_id: JsCallbackId::new(id),
2421            duration_ms,
2422        });
2423        id
2424    }
2425
2426    /// Send LSP request (async, returns request_id)
2427    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2428    #[qjs(rename = "_sendLspRequestStart")]
2429    pub fn send_lsp_request_start<'js>(
2430        &self,
2431        ctx: rquickjs::Ctx<'js>,
2432        language: String,
2433        method: String,
2434        params: Option<rquickjs::Object<'js>>,
2435    ) -> rquickjs::Result<u64> {
2436        let id = {
2437            let mut id_ref = self.next_request_id.borrow_mut();
2438            let id = *id_ref;
2439            *id_ref += 1;
2440            // Record context for this callback
2441            self.callback_contexts
2442                .borrow_mut()
2443                .insert(id, self.plugin_name.clone());
2444            id
2445        };
2446        // Convert params object to serde_json::Value
2447        let params_json: Option<serde_json::Value> = params.map(|obj| {
2448            let val = obj.into_value();
2449            js_to_json(&ctx, val)
2450        });
2451        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2452            request_id: id,
2453            language,
2454            method,
2455            params: params_json,
2456        });
2457        Ok(id)
2458    }
2459
2460    /// Spawn a background process (async, returns request_id which is also process_id)
2461    #[plugin_api(
2462        async_thenable,
2463        js_name = "spawnBackgroundProcess",
2464        ts_return = "BackgroundProcessResult"
2465    )]
2466    #[qjs(rename = "_spawnBackgroundProcessStart")]
2467    pub fn spawn_background_process_start(
2468        &self,
2469        _ctx: rquickjs::Ctx<'_>,
2470        command: String,
2471        args: Vec<String>,
2472        cwd: rquickjs::function::Opt<String>,
2473    ) -> u64 {
2474        let id = {
2475            let mut id_ref = self.next_request_id.borrow_mut();
2476            let id = *id_ref;
2477            *id_ref += 1;
2478            // Record context for this callback
2479            self.callback_contexts
2480                .borrow_mut()
2481                .insert(id, self.plugin_name.clone());
2482            id
2483        };
2484        // Use id as process_id for simplicity
2485        let process_id = id;
2486        let _ = self
2487            .command_sender
2488            .send(PluginCommand::SpawnBackgroundProcess {
2489                process_id,
2490                command,
2491                args,
2492                cwd: cwd.0,
2493                callback_id: JsCallbackId::new(id),
2494            });
2495        id
2496    }
2497
2498    /// Kill a background process
2499    pub fn kill_background_process(&self, process_id: u64) -> bool {
2500        self.command_sender
2501            .send(PluginCommand::KillBackgroundProcess { process_id })
2502            .is_ok()
2503    }
2504
2505    // === Misc ===
2506
2507    /// Force refresh of line display
2508    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2509        self.command_sender
2510            .send(PluginCommand::RefreshLines {
2511                buffer_id: BufferId(buffer_id as usize),
2512            })
2513            .is_ok()
2514    }
2515
2516    /// Get the current locale
2517    pub fn get_current_locale(&self) -> String {
2518        self.services.current_locale()
2519    }
2520
2521    // === Plugin Management ===
2522
2523    /// Load a plugin from a file path (async)
2524    #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2525    #[qjs(rename = "_loadPluginStart")]
2526    pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2527        let id = {
2528            let mut id_ref = self.next_request_id.borrow_mut();
2529            let id = *id_ref;
2530            *id_ref += 1;
2531            self.callback_contexts
2532                .borrow_mut()
2533                .insert(id, self.plugin_name.clone());
2534            id
2535        };
2536        let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2537            path: std::path::PathBuf::from(path),
2538            callback_id: JsCallbackId::new(id),
2539        });
2540        id
2541    }
2542
2543    /// Unload a plugin by name (async)
2544    #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2545    #[qjs(rename = "_unloadPluginStart")]
2546    pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2547        let id = {
2548            let mut id_ref = self.next_request_id.borrow_mut();
2549            let id = *id_ref;
2550            *id_ref += 1;
2551            self.callback_contexts
2552                .borrow_mut()
2553                .insert(id, self.plugin_name.clone());
2554            id
2555        };
2556        let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2557            name,
2558            callback_id: JsCallbackId::new(id),
2559        });
2560        id
2561    }
2562
2563    /// Reload a plugin by name (async)
2564    #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
2565    #[qjs(rename = "_reloadPluginStart")]
2566    pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2567        let id = {
2568            let mut id_ref = self.next_request_id.borrow_mut();
2569            let id = *id_ref;
2570            *id_ref += 1;
2571            self.callback_contexts
2572                .borrow_mut()
2573                .insert(id, self.plugin_name.clone());
2574            id
2575        };
2576        let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
2577            name,
2578            callback_id: JsCallbackId::new(id),
2579        });
2580        id
2581    }
2582
2583    /// List all loaded plugins (async)
2584    /// Returns array of { name: string, path: string, enabled: boolean }
2585    #[plugin_api(
2586        async_promise,
2587        js_name = "listPlugins",
2588        ts_return = "Array<{name: string, path: string, enabled: boolean}>"
2589    )]
2590    #[qjs(rename = "_listPluginsStart")]
2591    pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2592        let id = {
2593            let mut id_ref = self.next_request_id.borrow_mut();
2594            let id = *id_ref;
2595            *id_ref += 1;
2596            self.callback_contexts
2597                .borrow_mut()
2598                .insert(id, self.plugin_name.clone());
2599            id
2600        };
2601        let _ = self.command_sender.send(PluginCommand::ListPlugins {
2602            callback_id: JsCallbackId::new(id),
2603        });
2604        id
2605    }
2606}
2607
2608// =============================================================================
2609// View Token Parsing Helpers
2610// =============================================================================
2611
2612/// Parse a single view token from JS object
2613/// Supports both simple format and TypeScript format
2614fn parse_view_token(
2615    obj: &rquickjs::Object<'_>,
2616    idx: usize,
2617) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
2618    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2619
2620    // Try to get the 'kind' field - could be string or object
2621    let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
2622        from: "object",
2623        to: "ViewTokenWire",
2624        message: Some(format!("token[{}]: missing required field 'kind'", idx)),
2625    })?;
2626
2627    // Parse source_offset - try both camelCase and snake_case
2628    let source_offset: Option<usize> = obj
2629        .get("sourceOffset")
2630        .ok()
2631        .or_else(|| obj.get("source_offset").ok());
2632
2633    // Parse the kind field - support both formats
2634    let kind = if kind_value.is_string() {
2635        // Simple format: kind is a string like "text", "newline", etc.
2636        // OR TypeScript format for non-text: "Newline", "Space", "Break"
2637        let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2638            from: "value",
2639            to: "string",
2640            message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
2641        })?;
2642
2643        match kind_str.to_lowercase().as_str() {
2644            "text" => {
2645                let text: String = obj.get("text").unwrap_or_default();
2646                ViewTokenWireKind::Text(text)
2647            }
2648            "newline" => ViewTokenWireKind::Newline,
2649            "space" => ViewTokenWireKind::Space,
2650            "break" => ViewTokenWireKind::Break,
2651            _ => {
2652                // Unknown kind string - log warning and return error
2653                tracing::warn!(
2654                    "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
2655                    idx, kind_str
2656                );
2657                return Err(rquickjs::Error::FromJs {
2658                    from: "string",
2659                    to: "ViewTokenWireKind",
2660                    message: Some(format!(
2661                        "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
2662                        idx, kind_str
2663                    )),
2664                });
2665            }
2666        }
2667    } else if kind_value.is_object() {
2668        // TypeScript format: kind is an object like {Text: "..."} or {BinaryByte: N}
2669        let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2670            from: "value",
2671            to: "object",
2672            message: Some(format!("token[{}]: 'kind' is not an object", idx)),
2673        })?;
2674
2675        if let Ok(text) = kind_obj.get::<_, String>("Text") {
2676            ViewTokenWireKind::Text(text)
2677        } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
2678            ViewTokenWireKind::BinaryByte(byte)
2679        } else {
2680            // Check what keys are present for a helpful error
2681            let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
2682            tracing::warn!(
2683                "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
2684                idx,
2685                keys
2686            );
2687            return Err(rquickjs::Error::FromJs {
2688                from: "object",
2689                to: "ViewTokenWireKind",
2690                message: Some(format!(
2691                    "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
2692                    idx, keys
2693                )),
2694            });
2695        }
2696    } else {
2697        tracing::warn!(
2698            "token[{}]: 'kind' field must be a string or object, got: {:?}",
2699            idx,
2700            kind_value.type_of()
2701        );
2702        return Err(rquickjs::Error::FromJs {
2703            from: "value",
2704            to: "ViewTokenWireKind",
2705            message: Some(format!(
2706                "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
2707                idx
2708            )),
2709        });
2710    };
2711
2712    // Parse style if present
2713    let style = parse_view_token_style(obj, idx)?;
2714
2715    Ok(ViewTokenWire {
2716        source_offset,
2717        kind,
2718        style,
2719    })
2720}
2721
2722/// Parse optional style from a token object
2723fn parse_view_token_style(
2724    obj: &rquickjs::Object<'_>,
2725    idx: usize,
2726) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
2727    use fresh_core::api::ViewTokenStyle;
2728
2729    let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
2730    let Some(s) = style_obj else {
2731        return Ok(None);
2732    };
2733
2734    let fg: Option<Vec<u8>> = s.get("fg").ok();
2735    let bg: Option<Vec<u8>> = s.get("bg").ok();
2736
2737    // Validate color arrays
2738    let fg_color = if let Some(ref c) = fg {
2739        if c.len() < 3 {
2740            tracing::warn!(
2741                "token[{}]: style.fg has {} elements, expected 3 (RGB)",
2742                idx,
2743                c.len()
2744            );
2745            None
2746        } else {
2747            Some((c[0], c[1], c[2]))
2748        }
2749    } else {
2750        None
2751    };
2752
2753    let bg_color = if let Some(ref c) = bg {
2754        if c.len() < 3 {
2755            tracing::warn!(
2756                "token[{}]: style.bg has {} elements, expected 3 (RGB)",
2757                idx,
2758                c.len()
2759            );
2760            None
2761        } else {
2762            Some((c[0], c[1], c[2]))
2763        }
2764    } else {
2765        None
2766    };
2767
2768    Ok(Some(ViewTokenStyle {
2769        fg: fg_color,
2770        bg: bg_color,
2771        bold: s.get("bold").unwrap_or(false),
2772        italic: s.get("italic").unwrap_or(false),
2773    }))
2774}
2775
2776/// QuickJS-based JavaScript runtime for plugins
2777pub struct QuickJsBackend {
2778    runtime: Runtime,
2779    /// Main context for shared/internal operations
2780    main_context: Context,
2781    /// Plugin-specific contexts: plugin_name -> Context
2782    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2783    /// Event handlers: event_name -> list of PluginHandler
2784    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2785    /// Registered actions: action_name -> PluginHandler
2786    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2787    /// Editor state snapshot (read-only access)
2788    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2789    /// Command sender for write operations
2790    command_sender: mpsc::Sender<PluginCommand>,
2791    /// Pending response senders for async operations (held to keep Arc alive)
2792    #[allow(dead_code)]
2793    pending_responses: PendingResponses,
2794    /// Next request ID for async operations
2795    next_request_id: Rc<RefCell<u64>>,
2796    /// Plugin name for each pending callback ID
2797    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2798    /// Bridge for editor services (i18n, theme, etc.)
2799    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2800}
2801
2802impl QuickJsBackend {
2803    /// Create a new QuickJS backend (standalone, for testing)
2804    pub fn new() -> Result<Self> {
2805        let (tx, _rx) = mpsc::channel();
2806        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2807        let services = Arc::new(fresh_core::services::NoopServiceBridge);
2808        Self::with_state(state_snapshot, tx, services)
2809    }
2810
2811    /// Create a new QuickJS backend with editor state
2812    pub fn with_state(
2813        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2814        command_sender: mpsc::Sender<PluginCommand>,
2815        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2816    ) -> Result<Self> {
2817        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2818        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2819    }
2820
2821    /// Create a new QuickJS backend with editor state and shared pending responses
2822    pub fn with_state_and_responses(
2823        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2824        command_sender: mpsc::Sender<PluginCommand>,
2825        pending_responses: PendingResponses,
2826        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2827    ) -> Result<Self> {
2828        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2829
2830        let runtime =
2831            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2832
2833        // Set up promise rejection tracker to catch unhandled rejections
2834        runtime.set_host_promise_rejection_tracker(Some(Box::new(
2835            |_ctx, _promise, reason, is_handled| {
2836                if !is_handled {
2837                    // Format the rejection reason
2838                    let error_msg = if let Some(exc) = reason.as_exception() {
2839                        format!(
2840                            "{}: {}",
2841                            exc.message().unwrap_or_default(),
2842                            exc.stack().unwrap_or_default()
2843                        )
2844                    } else {
2845                        format!("{:?}", reason)
2846                    };
2847
2848                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
2849
2850                    if should_panic_on_js_errors() {
2851                        // Don't panic here - we're inside an FFI callback and rquickjs catches panics.
2852                        // Instead, set a fatal error flag that the plugin thread loop will check.
2853                        let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2854                        set_fatal_js_error(full_msg);
2855                    }
2856                }
2857            },
2858        )));
2859
2860        let main_context = Context::full(&runtime)
2861            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2862
2863        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2864        let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2865        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2866        let next_request_id = Rc::new(RefCell::new(1u64));
2867        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2868
2869        let backend = Self {
2870            runtime,
2871            main_context,
2872            plugin_contexts,
2873            event_handlers,
2874            registered_actions,
2875            state_snapshot,
2876            command_sender,
2877            pending_responses,
2878            next_request_id,
2879            callback_contexts,
2880            services,
2881        };
2882
2883        // Initialize main context (for internal utilities if needed)
2884        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2885
2886        tracing::debug!("QuickJsBackend::new: runtime created successfully");
2887        Ok(backend)
2888    }
2889
2890    /// Set up the editor API in a specific JavaScript context
2891    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2892        let state_snapshot = Arc::clone(&self.state_snapshot);
2893        let command_sender = self.command_sender.clone();
2894        let event_handlers = Rc::clone(&self.event_handlers);
2895        let registered_actions = Rc::clone(&self.registered_actions);
2896        let next_request_id = Rc::clone(&self.next_request_id);
2897
2898        context.with(|ctx| {
2899            let globals = ctx.globals();
2900
2901            // Set the plugin name global
2902            globals.set("__pluginName__", plugin_name)?;
2903
2904            // Create the editor object using JsEditorApi class
2905            // This provides proper lifetime handling for methods returning JS values
2906            let js_api = JsEditorApi {
2907                state_snapshot: Arc::clone(&state_snapshot),
2908                command_sender: command_sender.clone(),
2909                registered_actions: Rc::clone(&registered_actions),
2910                event_handlers: Rc::clone(&event_handlers),
2911                next_request_id: Rc::clone(&next_request_id),
2912                callback_contexts: Rc::clone(&self.callback_contexts),
2913                services: self.services.clone(),
2914                plugin_name: plugin_name.to_string(),
2915            };
2916            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2917
2918            // All methods are now in JsEditorApi - export editor as global
2919            globals.set("editor", editor)?;
2920
2921            // Define getEditor() globally
2922            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2923
2924            // Provide console.log for debugging
2925            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
2926            let console = Object::new(ctx.clone())?;
2927            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2928                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2929                tracing::info!("console.log: {}", parts.join(" "));
2930            })?)?;
2931            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2932                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2933                tracing::warn!("console.warn: {}", parts.join(" "));
2934            })?)?;
2935            console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2936                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2937                tracing::error!("console.error: {}", parts.join(" "));
2938            })?)?;
2939            globals.set("console", console)?;
2940
2941            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
2942            ctx.eval::<(), _>(r#"
2943                // Pending promise callbacks: callbackId -> { resolve, reject }
2944                globalThis._pendingCallbacks = new Map();
2945
2946                // Resolve a pending callback (called from Rust)
2947                globalThis._resolveCallback = function(callbackId, result) {
2948                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
2949                    const cb = globalThis._pendingCallbacks.get(callbackId);
2950                    if (cb) {
2951                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
2952                        globalThis._pendingCallbacks.delete(callbackId);
2953                        cb.resolve(result);
2954                        console.log('[JS] _resolveCallback: resolve() called');
2955                    } else {
2956                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
2957                    }
2958                };
2959
2960                // Reject a pending callback (called from Rust)
2961                globalThis._rejectCallback = function(callbackId, error) {
2962                    const cb = globalThis._pendingCallbacks.get(callbackId);
2963                    if (cb) {
2964                        globalThis._pendingCallbacks.delete(callbackId);
2965                        cb.reject(new Error(error));
2966                    }
2967                };
2968
2969                // Generic async wrapper decorator
2970                // Wraps a function that returns a callbackId into a promise-returning function
2971                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
2972                // NOTE: We pass the method name as a string and call via bracket notation
2973                // to preserve rquickjs's automatic Ctx injection for methods
2974                globalThis._wrapAsync = function(methodName, fnName) {
2975                    const startFn = editor[methodName];
2976                    if (typeof startFn !== 'function') {
2977                        // Return a function that always throws - catches missing implementations
2978                        return function(...args) {
2979                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
2980                            editor.debug(`[ASYNC ERROR] ${error.message}`);
2981                            throw error;
2982                        };
2983                    }
2984                    return function(...args) {
2985                        // Call via bracket notation to preserve method binding and Ctx injection
2986                        const callbackId = editor[methodName](...args);
2987                        return new Promise((resolve, reject) => {
2988                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
2989                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
2990                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
2991                        });
2992                    };
2993                };
2994
2995                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
2996                // The returned object has .result promise and is itself thenable
2997                globalThis._wrapAsyncThenable = function(methodName, fnName) {
2998                    const startFn = editor[methodName];
2999                    if (typeof startFn !== 'function') {
3000                        // Return a function that always throws - catches missing implementations
3001                        return function(...args) {
3002                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3003                            editor.debug(`[ASYNC ERROR] ${error.message}`);
3004                            throw error;
3005                        };
3006                    }
3007                    return function(...args) {
3008                        // Call via bracket notation to preserve method binding and Ctx injection
3009                        const callbackId = editor[methodName](...args);
3010                        const resultPromise = new Promise((resolve, reject) => {
3011                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3012                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3013                        });
3014                        return {
3015                            get result() { return resultPromise; },
3016                            then(onFulfilled, onRejected) {
3017                                return resultPromise.then(onFulfilled, onRejected);
3018                            },
3019                            catch(onRejected) {
3020                                return resultPromise.catch(onRejected);
3021                            }
3022                        };
3023                    };
3024                };
3025
3026                // Apply wrappers to async functions on editor
3027                editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3028                editor.delay = _wrapAsync("_delayStart", "delay");
3029                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3030                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3031                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3032                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3033                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3034                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3035                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3036                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3037                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3038                editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3039                editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3040                editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3041                editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3042
3043                // Wrapper for deleteTheme - wraps sync function in Promise
3044                editor.deleteTheme = function(name) {
3045                    return new Promise(function(resolve, reject) {
3046                        const success = editor._deleteThemeSync(name);
3047                        if (success) {
3048                            resolve();
3049                        } else {
3050                            reject(new Error("Failed to delete theme: " + name));
3051                        }
3052                    });
3053                };
3054            "#.as_bytes())?;
3055
3056            Ok::<_, rquickjs::Error>(())
3057        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3058
3059        Ok(())
3060    }
3061
3062    /// Load and execute a TypeScript/JavaScript plugin from a file path
3063    pub async fn load_module_with_source(
3064        &mut self,
3065        path: &str,
3066        _plugin_source: &str,
3067    ) -> Result<()> {
3068        let path_buf = PathBuf::from(path);
3069        let source = std::fs::read_to_string(&path_buf)
3070            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3071
3072        let filename = path_buf
3073            .file_name()
3074            .and_then(|s| s.to_str())
3075            .unwrap_or("plugin.ts");
3076
3077        // Check for ES imports - these need bundling to resolve dependencies
3078        if has_es_imports(&source) {
3079            // Try to bundle (this also strips imports and exports)
3080            match bundle_module(&path_buf) {
3081                Ok(bundled) => {
3082                    self.execute_js(&bundled, path)?;
3083                }
3084                Err(e) => {
3085                    tracing::warn!(
3086                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3087                        path,
3088                        e
3089                    );
3090                    return Ok(()); // Skip plugins with unresolvable imports
3091                }
3092            }
3093        } else if has_es_module_syntax(&source) {
3094            // Has exports but no imports - strip exports and transpile
3095            let stripped = strip_imports_and_exports(&source);
3096            let js_code = if filename.ends_with(".ts") {
3097                transpile_typescript(&stripped, filename)?
3098            } else {
3099                stripped
3100            };
3101            self.execute_js(&js_code, path)?;
3102        } else {
3103            // Plain code - just transpile if TypeScript
3104            let js_code = if filename.ends_with(".ts") {
3105                transpile_typescript(&source, filename)?
3106            } else {
3107                source
3108            };
3109            self.execute_js(&js_code, path)?;
3110        }
3111
3112        Ok(())
3113    }
3114
3115    /// Execute JavaScript code in the context
3116    fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3117        // Extract plugin name from path (filename without extension)
3118        let plugin_name = Path::new(source_name)
3119            .file_stem()
3120            .and_then(|s| s.to_str())
3121            .unwrap_or("unknown");
3122
3123        tracing::debug!(
3124            "execute_js: starting for plugin '{}' from '{}'",
3125            plugin_name,
3126            source_name
3127        );
3128
3129        // Get or create context for this plugin
3130        let context = {
3131            let mut contexts = self.plugin_contexts.borrow_mut();
3132            if let Some(ctx) = contexts.get(plugin_name) {
3133                ctx.clone()
3134            } else {
3135                let ctx = Context::full(&self.runtime).map_err(|e| {
3136                    anyhow!(
3137                        "Failed to create QuickJS context for plugin {}: {}",
3138                        plugin_name,
3139                        e
3140                    )
3141                })?;
3142                self.setup_context_api(&ctx, plugin_name)?;
3143                contexts.insert(plugin_name.to_string(), ctx.clone());
3144                ctx
3145            }
3146        };
3147
3148        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
3149        // This is critical for plugins like vi_mode that declare `const editor = ...`
3150        // which shadows the global `editor` causing TDZ if not wrapped.
3151        let wrapped_code = format!("(function() {{ {} }})();", code);
3152        let wrapped = wrapped_code.as_str();
3153
3154        context.with(|ctx| {
3155            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3156
3157            // Execute the plugin code with filename for better stack traces
3158            let mut eval_options = rquickjs::context::EvalOptions::default();
3159            eval_options.global = true;
3160            eval_options.filename = Some(source_name.to_string());
3161            let result = ctx
3162                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3163                .map_err(|e| format_js_error(&ctx, e, source_name));
3164
3165            tracing::debug!(
3166                "execute_js: plugin code execution finished for '{}', result: {:?}",
3167                plugin_name,
3168                result.is_ok()
3169            );
3170
3171            result
3172        })
3173    }
3174
3175    /// Emit an event to all registered handlers
3176    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3177        let _event_data_str = event_data.to_string();
3178        tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3179
3180        // Track execution state for signal handler debugging
3181        self.services
3182            .set_js_execution_state(format!("hook '{}'", event_name));
3183
3184        let handlers = self.event_handlers.borrow().get(event_name).cloned();
3185
3186        if let Some(handler_pairs) = handlers {
3187            if handler_pairs.is_empty() {
3188                self.services.clear_js_execution_state();
3189                return Ok(true);
3190            }
3191
3192            let plugin_contexts = self.plugin_contexts.borrow();
3193            for handler in handler_pairs {
3194                let context_opt = plugin_contexts.get(&handler.plugin_name);
3195                if let Some(context) = context_opt {
3196                    let handler_name = &handler.handler_name;
3197                    // Call the handler and properly handle both sync and async errors
3198                    // Async handlers return Promises - we attach .catch() to surface rejections
3199                    // Double-encode the JSON to produce a valid JavaScript string literal:
3200                    // event_data = {"path": "/test"} -> first to_string = {"path": "/test"}
3201                    // -> second to_string = "{\"path\": \"/test\"}" (properly quoted for JS)
3202                    let json_string = serde_json::to_string(event_data)?;
3203                    let js_string_literal = serde_json::to_string(&json_string)?;
3204                    let code = format!(
3205                        r#"
3206                        (function() {{
3207                            try {{
3208                                const data = JSON.parse({});
3209                                if (typeof globalThis["{}"] === 'function') {{
3210                                    const result = globalThis["{}"](data);
3211                                    // If handler returns a Promise, catch rejections
3212                                    if (result && typeof result.then === 'function') {{
3213                                        result.catch(function(e) {{
3214                                            console.error('Handler {} async error:', e);
3215                                            // Re-throw to make it an unhandled rejection for the runtime to catch
3216                                            throw e;
3217                                        }});
3218                                    }}
3219                                }}
3220                            }} catch (e) {{
3221                                console.error('Handler {} sync error:', e);
3222                                throw e;
3223                            }}
3224                        }})();
3225                        "#,
3226                        js_string_literal, handler_name, handler_name, handler_name, handler_name
3227                    );
3228
3229                    context.with(|ctx| {
3230                        if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3231                            log_js_error(&ctx, e, &format!("handler {}", handler_name));
3232                        }
3233                        // Run pending jobs to process any Promise continuations and catch errors
3234                        run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3235                    });
3236                }
3237            }
3238        }
3239
3240        self.services.clear_js_execution_state();
3241        Ok(true)
3242    }
3243
3244    /// Check if any handlers are registered for an event
3245    pub fn has_handlers(&self, event_name: &str) -> bool {
3246        self.event_handlers
3247            .borrow()
3248            .get(event_name)
3249            .map(|v| !v.is_empty())
3250            .unwrap_or(false)
3251    }
3252
3253    /// Start an action without waiting for async operations to complete.
3254    /// This is useful when the calling thread needs to continue processing
3255    /// ResolveCallback requests that the action may be waiting for.
3256    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3257        let pair = self.registered_actions.borrow().get(action_name).cloned();
3258        let (plugin_name, function_name) = match pair {
3259            Some(handler) => (handler.plugin_name, handler.handler_name),
3260            None => ("main".to_string(), action_name.to_string()),
3261        };
3262
3263        let plugin_contexts = self.plugin_contexts.borrow();
3264        let context = plugin_contexts
3265            .get(&plugin_name)
3266            .unwrap_or(&self.main_context);
3267
3268        // Track execution state for signal handler debugging
3269        self.services
3270            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3271
3272        tracing::info!(
3273            "start_action: BEGIN '{}' -> function '{}'",
3274            action_name,
3275            function_name
3276        );
3277
3278        // Just call the function - don't try to await or drive Promises
3279        let code = format!(
3280            r#"
3281            (function() {{
3282                console.log('[JS] start_action: calling {fn}');
3283                try {{
3284                    if (typeof globalThis.{fn} === 'function') {{
3285                        console.log('[JS] start_action: {fn} is a function, invoking...');
3286                        globalThis.{fn}();
3287                        console.log('[JS] start_action: {fn} invoked (may be async)');
3288                    }} else {{
3289                        console.error('[JS] Action {action} is not defined as a global function');
3290                    }}
3291                }} catch (e) {{
3292                    console.error('[JS] Action {action} error:', e);
3293                }}
3294            }})();
3295            "#,
3296            fn = function_name,
3297            action = action_name
3298        );
3299
3300        tracing::info!("start_action: evaluating JS code");
3301        context.with(|ctx| {
3302            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3303                log_js_error(&ctx, e, &format!("action {}", action_name));
3304            }
3305            tracing::info!("start_action: running pending microtasks");
3306            // Run any immediate microtasks
3307            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3308            tracing::info!("start_action: executed {} pending jobs", count);
3309        });
3310
3311        tracing::info!("start_action: END '{}'", action_name);
3312
3313        // Clear execution state (action started, may still be running async)
3314        self.services.clear_js_execution_state();
3315
3316        Ok(())
3317    }
3318
3319    /// Execute a registered action by name
3320    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3321        // First check if there's a registered command mapping
3322        let pair = self.registered_actions.borrow().get(action_name).cloned();
3323        let (plugin_name, function_name) = match pair {
3324            Some(handler) => (handler.plugin_name, handler.handler_name),
3325            None => ("main".to_string(), action_name.to_string()),
3326        };
3327
3328        let plugin_contexts = self.plugin_contexts.borrow();
3329        let context = plugin_contexts
3330            .get(&plugin_name)
3331            .unwrap_or(&self.main_context);
3332
3333        tracing::debug!(
3334            "execute_action: '{}' -> function '{}'",
3335            action_name,
3336            function_name
3337        );
3338
3339        // Call the function and await if it returns a Promise
3340        // We use a global _executeActionResult to pass the result back
3341        let code = format!(
3342            r#"
3343            (async function() {{
3344                try {{
3345                    if (typeof globalThis.{fn} === 'function') {{
3346                        const result = globalThis.{fn}();
3347                        // If it's a Promise, await it
3348                        if (result && typeof result.then === 'function') {{
3349                            await result;
3350                        }}
3351                    }} else {{
3352                        console.error('Action {action} is not defined as a global function');
3353                    }}
3354                }} catch (e) {{
3355                    console.error('Action {action} error:', e);
3356                }}
3357            }})();
3358            "#,
3359            fn = function_name,
3360            action = action_name
3361        );
3362
3363        context.with(|ctx| {
3364            // Eval returns a Promise for the async IIFE, which we need to drive
3365            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3366                Ok(value) => {
3367                    // If it's a Promise, we need to drive the runtime to completion
3368                    if value.is_object() {
3369                        if let Some(obj) = value.as_object() {
3370                            // Check if it's a Promise by looking for 'then' method
3371                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
3372                                // Drive the runtime to process the promise
3373                                // QuickJS processes promises synchronously when we call execute_pending_job
3374                                run_pending_jobs_checked(
3375                                    &ctx,
3376                                    &format!("execute_action {} promise", action_name),
3377                                );
3378                            }
3379                        }
3380                    }
3381                }
3382                Err(e) => {
3383                    log_js_error(&ctx, e, &format!("action {}", action_name));
3384                }
3385            }
3386        });
3387
3388        Ok(())
3389    }
3390
3391    /// Poll the event loop once to run any pending microtasks
3392    pub fn poll_event_loop_once(&mut self) -> bool {
3393        let mut had_work = false;
3394
3395        // Poll main context
3396        self.main_context.with(|ctx| {
3397            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3398            if count > 0 {
3399                had_work = true;
3400            }
3401        });
3402
3403        // Poll all plugin contexts
3404        let contexts = self.plugin_contexts.borrow().clone();
3405        for (name, context) in contexts {
3406            context.with(|ctx| {
3407                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3408                if count > 0 {
3409                    had_work = true;
3410                }
3411            });
3412        }
3413        had_work
3414    }
3415
3416    /// Send a status message to the editor
3417    pub fn send_status(&self, message: String) {
3418        let _ = self
3419            .command_sender
3420            .send(PluginCommand::SetStatus { message });
3421    }
3422
3423    /// Resolve a pending async callback with a result (called from Rust when async op completes)
3424    ///
3425    /// Takes a JSON string which is parsed and converted to a proper JS value.
3426    /// This avoids string interpolation with eval for better type safety.
3427    pub fn resolve_callback(
3428        &mut self,
3429        callback_id: fresh_core::api::JsCallbackId,
3430        result_json: &str,
3431    ) {
3432        let id = callback_id.as_u64();
3433        tracing::debug!("resolve_callback: starting for callback_id={}", id);
3434
3435        // Find the plugin name and then context for this callback
3436        let plugin_name = {
3437            let mut contexts = self.callback_contexts.borrow_mut();
3438            contexts.remove(&id)
3439        };
3440
3441        let Some(name) = plugin_name else {
3442            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3443            return;
3444        };
3445
3446        let plugin_contexts = self.plugin_contexts.borrow();
3447        let Some(context) = plugin_contexts.get(&name) else {
3448            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3449            return;
3450        };
3451
3452        context.with(|ctx| {
3453            // Parse JSON string to serde_json::Value
3454            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3455                Ok(v) => v,
3456                Err(e) => {
3457                    tracing::error!(
3458                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
3459                        id,
3460                        e
3461                    );
3462                    return;
3463                }
3464            };
3465
3466            // Convert to JS value using rquickjs_serde
3467            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3468                Ok(v) => v,
3469                Err(e) => {
3470                    tracing::error!(
3471                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3472                        id,
3473                        e
3474                    );
3475                    return;
3476                }
3477            };
3478
3479            // Get _resolveCallback function from globalThis
3480            let globals = ctx.globals();
3481            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3482                Ok(f) => f,
3483                Err(e) => {
3484                    tracing::error!(
3485                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3486                        id,
3487                        e
3488                    );
3489                    return;
3490                }
3491            };
3492
3493            // Call the function with callback_id (as u64) and the JS value
3494            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3495                log_js_error(&ctx, e, &format!("resolving callback {}", id));
3496            }
3497
3498            // IMPORTANT: Run pending jobs to process Promise continuations
3499            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3500            tracing::info!(
3501                "resolve_callback: executed {} pending jobs for callback_id={}",
3502                job_count,
3503                id
3504            );
3505        });
3506    }
3507
3508    /// Reject a pending async callback with an error (called from Rust when async op fails)
3509    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3510        let id = callback_id.as_u64();
3511
3512        // Find the plugin name and then context for this callback
3513        let plugin_name = {
3514            let mut contexts = self.callback_contexts.borrow_mut();
3515            contexts.remove(&id)
3516        };
3517
3518        let Some(name) = plugin_name else {
3519            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3520            return;
3521        };
3522
3523        let plugin_contexts = self.plugin_contexts.borrow();
3524        let Some(context) = plugin_contexts.get(&name) else {
3525            tracing::warn!("reject_callback: Context lost for plugin {}", name);
3526            return;
3527        };
3528
3529        context.with(|ctx| {
3530            // Get _rejectCallback function from globalThis
3531            let globals = ctx.globals();
3532            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3533                Ok(f) => f,
3534                Err(e) => {
3535                    tracing::error!(
3536                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3537                        id,
3538                        e
3539                    );
3540                    return;
3541                }
3542            };
3543
3544            // Call the function with callback_id (as u64) and error string
3545            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3546                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3547            }
3548
3549            // IMPORTANT: Run pending jobs to process Promise continuations
3550            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3551        });
3552    }
3553}
3554
3555#[cfg(test)]
3556mod tests {
3557    use super::*;
3558    use fresh_core::api::{BufferInfo, CursorInfo};
3559    use std::sync::mpsc;
3560
3561    /// Helper to create a backend with a command receiver for testing
3562    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3563        let (tx, rx) = mpsc::channel();
3564        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3565        let services = Arc::new(TestServiceBridge::new());
3566        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3567        (backend, rx)
3568    }
3569
3570    struct TestServiceBridge {
3571        en_strings: std::sync::Mutex<HashMap<String, String>>,
3572    }
3573
3574    impl TestServiceBridge {
3575        fn new() -> Self {
3576            Self {
3577                en_strings: std::sync::Mutex::new(HashMap::new()),
3578            }
3579        }
3580    }
3581
3582    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3583        fn as_any(&self) -> &dyn std::any::Any {
3584            self
3585        }
3586        fn translate(
3587            &self,
3588            _plugin_name: &str,
3589            key: &str,
3590            _args: &HashMap<String, String>,
3591        ) -> String {
3592            self.en_strings
3593                .lock()
3594                .unwrap()
3595                .get(key)
3596                .cloned()
3597                .unwrap_or_else(|| key.to_string())
3598        }
3599        fn current_locale(&self) -> String {
3600            "en".to_string()
3601        }
3602        fn set_js_execution_state(&self, _state: String) {}
3603        fn clear_js_execution_state(&self) {}
3604        fn get_theme_schema(&self) -> serde_json::Value {
3605            serde_json::json!({})
3606        }
3607        fn get_builtin_themes(&self) -> serde_json::Value {
3608            serde_json::json!([])
3609        }
3610        fn register_command(&self, _command: fresh_core::command::Command) {}
3611        fn unregister_command(&self, _name: &str) {}
3612        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3613        fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
3614        fn plugins_dir(&self) -> std::path::PathBuf {
3615            std::path::PathBuf::from("/tmp/plugins")
3616        }
3617        fn config_dir(&self) -> std::path::PathBuf {
3618            std::path::PathBuf::from("/tmp/config")
3619        }
3620    }
3621
3622    #[test]
3623    fn test_quickjs_backend_creation() {
3624        let backend = QuickJsBackend::new();
3625        assert!(backend.is_ok());
3626    }
3627
3628    #[test]
3629    fn test_execute_simple_js() {
3630        let mut backend = QuickJsBackend::new().unwrap();
3631        let result = backend.execute_js("const x = 1 + 2;", "test.js");
3632        assert!(result.is_ok());
3633    }
3634
3635    #[test]
3636    fn test_event_handler_registration() {
3637        let backend = QuickJsBackend::new().unwrap();
3638
3639        // Initially no handlers
3640        assert!(!backend.has_handlers("test_event"));
3641
3642        // Register a handler
3643        backend
3644            .event_handlers
3645            .borrow_mut()
3646            .entry("test_event".to_string())
3647            .or_default()
3648            .push(PluginHandler {
3649                plugin_name: "test".to_string(),
3650                handler_name: "testHandler".to_string(),
3651            });
3652
3653        // Now has handlers
3654        assert!(backend.has_handlers("test_event"));
3655    }
3656
3657    // ==================== API Tests ====================
3658
3659    #[test]
3660    fn test_api_set_status() {
3661        let (mut backend, rx) = create_test_backend();
3662
3663        backend
3664            .execute_js(
3665                r#"
3666            const editor = getEditor();
3667            editor.setStatus("Hello from test");
3668        "#,
3669                "test.js",
3670            )
3671            .unwrap();
3672
3673        let cmd = rx.try_recv().unwrap();
3674        match cmd {
3675            PluginCommand::SetStatus { message } => {
3676                assert_eq!(message, "Hello from test");
3677            }
3678            _ => panic!("Expected SetStatus command, got {:?}", cmd),
3679        }
3680    }
3681
3682    #[test]
3683    fn test_api_register_command() {
3684        let (mut backend, rx) = create_test_backend();
3685
3686        backend
3687            .execute_js(
3688                r#"
3689            const editor = getEditor();
3690            globalThis.myTestHandler = function() { };
3691            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3692        "#,
3693                "test_plugin.js",
3694            )
3695            .unwrap();
3696
3697        let cmd = rx.try_recv().unwrap();
3698        match cmd {
3699            PluginCommand::RegisterCommand { command } => {
3700                assert_eq!(command.name, "Test Command");
3701                assert_eq!(command.description, "A test command");
3702                // Check that plugin_name contains the plugin name (derived from filename)
3703                assert_eq!(command.plugin_name, "test_plugin");
3704            }
3705            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3706        }
3707    }
3708
3709    #[test]
3710    fn test_api_define_mode() {
3711        let (mut backend, rx) = create_test_backend();
3712
3713        backend
3714            .execute_js(
3715                r#"
3716            const editor = getEditor();
3717            editor.defineMode("test-mode", null, [
3718                ["a", "action_a"],
3719                ["b", "action_b"]
3720            ]);
3721        "#,
3722                "test.js",
3723            )
3724            .unwrap();
3725
3726        let cmd = rx.try_recv().unwrap();
3727        match cmd {
3728            PluginCommand::DefineMode {
3729                name,
3730                parent,
3731                bindings,
3732                read_only,
3733            } => {
3734                assert_eq!(name, "test-mode");
3735                assert!(parent.is_none());
3736                assert_eq!(bindings.len(), 2);
3737                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3738                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3739                assert!(!read_only);
3740            }
3741            _ => panic!("Expected DefineMode, got {:?}", cmd),
3742        }
3743    }
3744
3745    #[test]
3746    fn test_api_set_editor_mode() {
3747        let (mut backend, rx) = create_test_backend();
3748
3749        backend
3750            .execute_js(
3751                r#"
3752            const editor = getEditor();
3753            editor.setEditorMode("vi-normal");
3754        "#,
3755                "test.js",
3756            )
3757            .unwrap();
3758
3759        let cmd = rx.try_recv().unwrap();
3760        match cmd {
3761            PluginCommand::SetEditorMode { mode } => {
3762                assert_eq!(mode, Some("vi-normal".to_string()));
3763            }
3764            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3765        }
3766    }
3767
3768    #[test]
3769    fn test_api_clear_editor_mode() {
3770        let (mut backend, rx) = create_test_backend();
3771
3772        backend
3773            .execute_js(
3774                r#"
3775            const editor = getEditor();
3776            editor.setEditorMode(null);
3777        "#,
3778                "test.js",
3779            )
3780            .unwrap();
3781
3782        let cmd = rx.try_recv().unwrap();
3783        match cmd {
3784            PluginCommand::SetEditorMode { mode } => {
3785                assert!(mode.is_none());
3786            }
3787            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3788        }
3789    }
3790
3791    #[test]
3792    fn test_api_insert_at_cursor() {
3793        let (mut backend, rx) = create_test_backend();
3794
3795        backend
3796            .execute_js(
3797                r#"
3798            const editor = getEditor();
3799            editor.insertAtCursor("Hello, World!");
3800        "#,
3801                "test.js",
3802            )
3803            .unwrap();
3804
3805        let cmd = rx.try_recv().unwrap();
3806        match cmd {
3807            PluginCommand::InsertAtCursor { text } => {
3808                assert_eq!(text, "Hello, World!");
3809            }
3810            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3811        }
3812    }
3813
3814    #[test]
3815    fn test_api_set_context() {
3816        let (mut backend, rx) = create_test_backend();
3817
3818        backend
3819            .execute_js(
3820                r#"
3821            const editor = getEditor();
3822            editor.setContext("myContext", true);
3823        "#,
3824                "test.js",
3825            )
3826            .unwrap();
3827
3828        let cmd = rx.try_recv().unwrap();
3829        match cmd {
3830            PluginCommand::SetContext { name, active } => {
3831                assert_eq!(name, "myContext");
3832                assert!(active);
3833            }
3834            _ => panic!("Expected SetContext, got {:?}", cmd),
3835        }
3836    }
3837
3838    #[tokio::test]
3839    async fn test_execute_action_sync_function() {
3840        let (mut backend, rx) = create_test_backend();
3841
3842        // Register the action explicitly so it knows to look in "test" plugin
3843        backend.registered_actions.borrow_mut().insert(
3844            "my_sync_action".to_string(),
3845            PluginHandler {
3846                plugin_name: "test".to_string(),
3847                handler_name: "my_sync_action".to_string(),
3848            },
3849        );
3850
3851        // Define a sync function and register it
3852        backend
3853            .execute_js(
3854                r#"
3855            const editor = getEditor();
3856            globalThis.my_sync_action = function() {
3857                editor.setStatus("sync action executed");
3858            };
3859        "#,
3860                "test.js",
3861            )
3862            .unwrap();
3863
3864        // Drain any setup commands
3865        while rx.try_recv().is_ok() {}
3866
3867        // Execute the action
3868        backend.execute_action("my_sync_action").await.unwrap();
3869
3870        // Check the command was sent
3871        let cmd = rx.try_recv().unwrap();
3872        match cmd {
3873            PluginCommand::SetStatus { message } => {
3874                assert_eq!(message, "sync action executed");
3875            }
3876            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3877        }
3878    }
3879
3880    #[tokio::test]
3881    async fn test_execute_action_async_function() {
3882        let (mut backend, rx) = create_test_backend();
3883
3884        // Register the action explicitly
3885        backend.registered_actions.borrow_mut().insert(
3886            "my_async_action".to_string(),
3887            PluginHandler {
3888                plugin_name: "test".to_string(),
3889                handler_name: "my_async_action".to_string(),
3890            },
3891        );
3892
3893        // Define an async function
3894        backend
3895            .execute_js(
3896                r#"
3897            const editor = getEditor();
3898            globalThis.my_async_action = async function() {
3899                await Promise.resolve();
3900                editor.setStatus("async action executed");
3901            };
3902        "#,
3903                "test.js",
3904            )
3905            .unwrap();
3906
3907        // Drain any setup commands
3908        while rx.try_recv().is_ok() {}
3909
3910        // Execute the action
3911        backend.execute_action("my_async_action").await.unwrap();
3912
3913        // Check the command was sent (async should complete)
3914        let cmd = rx.try_recv().unwrap();
3915        match cmd {
3916            PluginCommand::SetStatus { message } => {
3917                assert_eq!(message, "async action executed");
3918            }
3919            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3920        }
3921    }
3922
3923    #[tokio::test]
3924    async fn test_execute_action_with_registered_handler() {
3925        let (mut backend, rx) = create_test_backend();
3926
3927        // Register an action with a different handler name
3928        backend.registered_actions.borrow_mut().insert(
3929            "my_action".to_string(),
3930            PluginHandler {
3931                plugin_name: "test".to_string(),
3932                handler_name: "actual_handler_function".to_string(),
3933            },
3934        );
3935
3936        backend
3937            .execute_js(
3938                r#"
3939            const editor = getEditor();
3940            globalThis.actual_handler_function = function() {
3941                editor.setStatus("handler executed");
3942            };
3943        "#,
3944                "test.js",
3945            )
3946            .unwrap();
3947
3948        // Drain any setup commands
3949        while rx.try_recv().is_ok() {}
3950
3951        // Execute the action by name (should resolve to handler)
3952        backend.execute_action("my_action").await.unwrap();
3953
3954        let cmd = rx.try_recv().unwrap();
3955        match cmd {
3956            PluginCommand::SetStatus { message } => {
3957                assert_eq!(message, "handler executed");
3958            }
3959            _ => panic!("Expected SetStatus, got {:?}", cmd),
3960        }
3961    }
3962
3963    #[test]
3964    fn test_api_on_event_registration() {
3965        let (mut backend, _rx) = create_test_backend();
3966
3967        backend
3968            .execute_js(
3969                r#"
3970            const editor = getEditor();
3971            globalThis.myEventHandler = function() { };
3972            editor.on("bufferSave", "myEventHandler");
3973        "#,
3974                "test.js",
3975            )
3976            .unwrap();
3977
3978        assert!(backend.has_handlers("bufferSave"));
3979    }
3980
3981    #[test]
3982    fn test_api_off_event_unregistration() {
3983        let (mut backend, _rx) = create_test_backend();
3984
3985        backend
3986            .execute_js(
3987                r#"
3988            const editor = getEditor();
3989            globalThis.myEventHandler = function() { };
3990            editor.on("bufferSave", "myEventHandler");
3991            editor.off("bufferSave", "myEventHandler");
3992        "#,
3993                "test.js",
3994            )
3995            .unwrap();
3996
3997        // Handler should be removed
3998        assert!(!backend.has_handlers("bufferSave"));
3999    }
4000
4001    #[tokio::test]
4002    async fn test_emit_event() {
4003        let (mut backend, rx) = create_test_backend();
4004
4005        backend
4006            .execute_js(
4007                r#"
4008            const editor = getEditor();
4009            globalThis.onSaveHandler = function(data) {
4010                editor.setStatus("saved: " + JSON.stringify(data));
4011            };
4012            editor.on("bufferSave", "onSaveHandler");
4013        "#,
4014                "test.js",
4015            )
4016            .unwrap();
4017
4018        // Drain setup commands
4019        while rx.try_recv().is_ok() {}
4020
4021        // Emit the event
4022        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
4023        backend.emit("bufferSave", &event_data).await.unwrap();
4024
4025        let cmd = rx.try_recv().unwrap();
4026        match cmd {
4027            PluginCommand::SetStatus { message } => {
4028                assert!(message.contains("/test.txt"));
4029            }
4030            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
4031        }
4032    }
4033
4034    #[test]
4035    fn test_api_copy_to_clipboard() {
4036        let (mut backend, rx) = create_test_backend();
4037
4038        backend
4039            .execute_js(
4040                r#"
4041            const editor = getEditor();
4042            editor.copyToClipboard("clipboard text");
4043        "#,
4044                "test.js",
4045            )
4046            .unwrap();
4047
4048        let cmd = rx.try_recv().unwrap();
4049        match cmd {
4050            PluginCommand::SetClipboard { text } => {
4051                assert_eq!(text, "clipboard text");
4052            }
4053            _ => panic!("Expected SetClipboard, got {:?}", cmd),
4054        }
4055    }
4056
4057    #[test]
4058    fn test_api_open_file() {
4059        let (mut backend, rx) = create_test_backend();
4060
4061        // openFile takes (path, line?, column?)
4062        backend
4063            .execute_js(
4064                r#"
4065            const editor = getEditor();
4066            editor.openFile("/path/to/file.txt", null, null);
4067        "#,
4068                "test.js",
4069            )
4070            .unwrap();
4071
4072        let cmd = rx.try_recv().unwrap();
4073        match cmd {
4074            PluginCommand::OpenFileAtLocation { path, line, column } => {
4075                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
4076                assert!(line.is_none());
4077                assert!(column.is_none());
4078            }
4079            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
4080        }
4081    }
4082
4083    #[test]
4084    fn test_api_delete_range() {
4085        let (mut backend, rx) = create_test_backend();
4086
4087        // deleteRange takes (buffer_id, start, end)
4088        backend
4089            .execute_js(
4090                r#"
4091            const editor = getEditor();
4092            editor.deleteRange(0, 10, 20);
4093        "#,
4094                "test.js",
4095            )
4096            .unwrap();
4097
4098        let cmd = rx.try_recv().unwrap();
4099        match cmd {
4100            PluginCommand::DeleteRange { range, .. } => {
4101                assert_eq!(range.start, 10);
4102                assert_eq!(range.end, 20);
4103            }
4104            _ => panic!("Expected DeleteRange, got {:?}", cmd),
4105        }
4106    }
4107
4108    #[test]
4109    fn test_api_insert_text() {
4110        let (mut backend, rx) = create_test_backend();
4111
4112        // insertText takes (buffer_id, position, text)
4113        backend
4114            .execute_js(
4115                r#"
4116            const editor = getEditor();
4117            editor.insertText(0, 5, "inserted");
4118        "#,
4119                "test.js",
4120            )
4121            .unwrap();
4122
4123        let cmd = rx.try_recv().unwrap();
4124        match cmd {
4125            PluginCommand::InsertText { position, text, .. } => {
4126                assert_eq!(position, 5);
4127                assert_eq!(text, "inserted");
4128            }
4129            _ => panic!("Expected InsertText, got {:?}", cmd),
4130        }
4131    }
4132
4133    #[test]
4134    fn test_api_set_buffer_cursor() {
4135        let (mut backend, rx) = create_test_backend();
4136
4137        // setBufferCursor takes (buffer_id, position)
4138        backend
4139            .execute_js(
4140                r#"
4141            const editor = getEditor();
4142            editor.setBufferCursor(0, 100);
4143        "#,
4144                "test.js",
4145            )
4146            .unwrap();
4147
4148        let cmd = rx.try_recv().unwrap();
4149        match cmd {
4150            PluginCommand::SetBufferCursor { position, .. } => {
4151                assert_eq!(position, 100);
4152            }
4153            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
4154        }
4155    }
4156
4157    #[test]
4158    fn test_api_get_cursor_position_from_state() {
4159        let (tx, _rx) = mpsc::channel();
4160        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4161
4162        // Set up cursor position in state
4163        {
4164            let mut state = state_snapshot.write().unwrap();
4165            state.primary_cursor = Some(CursorInfo {
4166                position: 42,
4167                selection: None,
4168            });
4169        }
4170
4171        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4172        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4173
4174        // Execute JS that reads and stores cursor position
4175        backend
4176            .execute_js(
4177                r#"
4178            const editor = getEditor();
4179            const pos = editor.getCursorPosition();
4180            globalThis._testResult = pos;
4181        "#,
4182                "test.js",
4183            )
4184            .unwrap();
4185
4186        // Verify by reading back - getCursorPosition returns byte offset as u32
4187        backend
4188            .plugin_contexts
4189            .borrow()
4190            .get("test")
4191            .unwrap()
4192            .clone()
4193            .with(|ctx| {
4194                let global = ctx.globals();
4195                let result: u32 = global.get("_testResult").unwrap();
4196                assert_eq!(result, 42);
4197            });
4198    }
4199
4200    #[test]
4201    fn test_api_path_functions() {
4202        let (mut backend, _rx) = create_test_backend();
4203
4204        // Use platform-appropriate absolute path for isAbsolute test
4205        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
4206        #[cfg(windows)]
4207        let absolute_path = r#"C:\\foo\\bar"#;
4208        #[cfg(not(windows))]
4209        let absolute_path = "/foo/bar";
4210
4211        // pathJoin takes an array of path parts
4212        let js_code = format!(
4213            r#"
4214            const editor = getEditor();
4215            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4216            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4217            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4218            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4219            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4220            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4221        "#,
4222            absolute_path
4223        );
4224        backend.execute_js(&js_code, "test.js").unwrap();
4225
4226        backend
4227            .plugin_contexts
4228            .borrow()
4229            .get("test")
4230            .unwrap()
4231            .clone()
4232            .with(|ctx| {
4233                let global = ctx.globals();
4234                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4235                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4236                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4237                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4238                assert!(!global.get::<_, bool>("_isRelative").unwrap());
4239                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4240            });
4241    }
4242
4243    #[test]
4244    fn test_typescript_transpilation() {
4245        use fresh_parser_js::transpile_typescript;
4246
4247        let (mut backend, rx) = create_test_backend();
4248
4249        // TypeScript code with type annotations
4250        let ts_code = r#"
4251            const editor = getEditor();
4252            function greet(name: string): string {
4253                return "Hello, " + name;
4254            }
4255            editor.setStatus(greet("TypeScript"));
4256        "#;
4257
4258        // Transpile to JavaScript first
4259        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4260
4261        // Execute the transpiled JavaScript
4262        backend.execute_js(&js_code, "test.js").unwrap();
4263
4264        let cmd = rx.try_recv().unwrap();
4265        match cmd {
4266            PluginCommand::SetStatus { message } => {
4267                assert_eq!(message, "Hello, TypeScript");
4268            }
4269            _ => panic!("Expected SetStatus, got {:?}", cmd),
4270        }
4271    }
4272
4273    #[test]
4274    fn test_api_get_buffer_text_sends_command() {
4275        let (mut backend, rx) = create_test_backend();
4276
4277        // Call getBufferText - this returns a Promise and sends the command
4278        backend
4279            .execute_js(
4280                r#"
4281            const editor = getEditor();
4282            // Store the promise for later
4283            globalThis._textPromise = editor.getBufferText(0, 10, 20);
4284        "#,
4285                "test.js",
4286            )
4287            .unwrap();
4288
4289        // Verify the GetBufferText command was sent
4290        let cmd = rx.try_recv().unwrap();
4291        match cmd {
4292            PluginCommand::GetBufferText {
4293                buffer_id,
4294                start,
4295                end,
4296                request_id,
4297            } => {
4298                assert_eq!(buffer_id.0, 0);
4299                assert_eq!(start, 10);
4300                assert_eq!(end, 20);
4301                assert!(request_id > 0); // Should have a valid request ID
4302            }
4303            _ => panic!("Expected GetBufferText, got {:?}", cmd),
4304        }
4305    }
4306
4307    #[test]
4308    fn test_api_get_buffer_text_resolves_callback() {
4309        let (mut backend, rx) = create_test_backend();
4310
4311        // Call getBufferText and set up a handler for when it resolves
4312        backend
4313            .execute_js(
4314                r#"
4315            const editor = getEditor();
4316            globalThis._resolvedText = null;
4317            editor.getBufferText(0, 0, 100).then(text => {
4318                globalThis._resolvedText = text;
4319            });
4320        "#,
4321                "test.js",
4322            )
4323            .unwrap();
4324
4325        // Get the request_id from the command
4326        let request_id = match rx.try_recv().unwrap() {
4327            PluginCommand::GetBufferText { request_id, .. } => request_id,
4328            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4329        };
4330
4331        // Simulate the editor responding with the text
4332        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4333
4334        // Drive the Promise to completion
4335        backend
4336            .plugin_contexts
4337            .borrow()
4338            .get("test")
4339            .unwrap()
4340            .clone()
4341            .with(|ctx| {
4342                run_pending_jobs_checked(&ctx, "test async getText");
4343            });
4344
4345        // Verify the Promise resolved with the text
4346        backend
4347            .plugin_contexts
4348            .borrow()
4349            .get("test")
4350            .unwrap()
4351            .clone()
4352            .with(|ctx| {
4353                let global = ctx.globals();
4354                let result: String = global.get("_resolvedText").unwrap();
4355                assert_eq!(result, "hello world");
4356            });
4357    }
4358
4359    #[test]
4360    fn test_plugin_translation() {
4361        let (mut backend, _rx) = create_test_backend();
4362
4363        // The t() function should work (returns key if translation not found)
4364        backend
4365            .execute_js(
4366                r#"
4367            const editor = getEditor();
4368            globalThis._translated = editor.t("test.key");
4369        "#,
4370                "test.js",
4371            )
4372            .unwrap();
4373
4374        backend
4375            .plugin_contexts
4376            .borrow()
4377            .get("test")
4378            .unwrap()
4379            .clone()
4380            .with(|ctx| {
4381                let global = ctx.globals();
4382                // Without actual translations, it returns the key
4383                let result: String = global.get("_translated").unwrap();
4384                assert_eq!(result, "test.key");
4385            });
4386    }
4387
4388    #[test]
4389    fn test_plugin_translation_with_registered_strings() {
4390        let (mut backend, _rx) = create_test_backend();
4391
4392        // Register translations for the test plugin
4393        let mut en_strings = std::collections::HashMap::new();
4394        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4395        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4396
4397        let mut strings = std::collections::HashMap::new();
4398        strings.insert("en".to_string(), en_strings);
4399
4400        // Register for "test" plugin
4401        if let Some(bridge) = backend
4402            .services
4403            .as_any()
4404            .downcast_ref::<TestServiceBridge>()
4405        {
4406            let mut en = bridge.en_strings.lock().unwrap();
4407            en.insert("greeting".to_string(), "Hello, World!".to_string());
4408            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4409        }
4410
4411        // Test translation
4412        backend
4413            .execute_js(
4414                r#"
4415            const editor = getEditor();
4416            globalThis._greeting = editor.t("greeting");
4417            globalThis._prompt = editor.t("prompt.find_file");
4418            globalThis._missing = editor.t("nonexistent.key");
4419        "#,
4420                "test.js",
4421            )
4422            .unwrap();
4423
4424        backend
4425            .plugin_contexts
4426            .borrow()
4427            .get("test")
4428            .unwrap()
4429            .clone()
4430            .with(|ctx| {
4431                let global = ctx.globals();
4432                let greeting: String = global.get("_greeting").unwrap();
4433                assert_eq!(greeting, "Hello, World!");
4434
4435                let prompt: String = global.get("_prompt").unwrap();
4436                assert_eq!(prompt, "Find file: ");
4437
4438                // Missing key should return the key itself
4439                let missing: String = global.get("_missing").unwrap();
4440                assert_eq!(missing, "nonexistent.key");
4441            });
4442    }
4443
4444    // ==================== Line Indicator Tests ====================
4445
4446    #[test]
4447    fn test_api_set_line_indicator() {
4448        let (mut backend, rx) = create_test_backend();
4449
4450        backend
4451            .execute_js(
4452                r#"
4453            const editor = getEditor();
4454            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4455        "#,
4456                "test.js",
4457            )
4458            .unwrap();
4459
4460        let cmd = rx.try_recv().unwrap();
4461        match cmd {
4462            PluginCommand::SetLineIndicator {
4463                buffer_id,
4464                line,
4465                namespace,
4466                symbol,
4467                color,
4468                priority,
4469            } => {
4470                assert_eq!(buffer_id.0, 1);
4471                assert_eq!(line, 5);
4472                assert_eq!(namespace, "test-ns");
4473                assert_eq!(symbol, "●");
4474                assert_eq!(color, (255, 0, 0));
4475                assert_eq!(priority, 10);
4476            }
4477            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4478        }
4479    }
4480
4481    #[test]
4482    fn test_api_clear_line_indicators() {
4483        let (mut backend, rx) = create_test_backend();
4484
4485        backend
4486            .execute_js(
4487                r#"
4488            const editor = getEditor();
4489            editor.clearLineIndicators(1, "test-ns");
4490        "#,
4491                "test.js",
4492            )
4493            .unwrap();
4494
4495        let cmd = rx.try_recv().unwrap();
4496        match cmd {
4497            PluginCommand::ClearLineIndicators {
4498                buffer_id,
4499                namespace,
4500            } => {
4501                assert_eq!(buffer_id.0, 1);
4502                assert_eq!(namespace, "test-ns");
4503            }
4504            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4505        }
4506    }
4507
4508    // ==================== Virtual Buffer Tests ====================
4509
4510    #[test]
4511    fn test_api_create_virtual_buffer_sends_command() {
4512        let (mut backend, rx) = create_test_backend();
4513
4514        backend
4515            .execute_js(
4516                r#"
4517            const editor = getEditor();
4518            editor.createVirtualBuffer({
4519                name: "*Test Buffer*",
4520                mode: "test-mode",
4521                readOnly: true,
4522                entries: [
4523                    { text: "Line 1\n", properties: { type: "header" } },
4524                    { text: "Line 2\n", properties: { type: "content" } }
4525                ],
4526                showLineNumbers: false,
4527                showCursors: true,
4528                editingDisabled: true
4529            });
4530        "#,
4531                "test.js",
4532            )
4533            .unwrap();
4534
4535        let cmd = rx.try_recv().unwrap();
4536        match cmd {
4537            PluginCommand::CreateVirtualBufferWithContent {
4538                name,
4539                mode,
4540                read_only,
4541                entries,
4542                show_line_numbers,
4543                show_cursors,
4544                editing_disabled,
4545                ..
4546            } => {
4547                assert_eq!(name, "*Test Buffer*");
4548                assert_eq!(mode, "test-mode");
4549                assert!(read_only);
4550                assert_eq!(entries.len(), 2);
4551                assert_eq!(entries[0].text, "Line 1\n");
4552                assert!(!show_line_numbers);
4553                assert!(show_cursors);
4554                assert!(editing_disabled);
4555            }
4556            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4557        }
4558    }
4559
4560    #[test]
4561    fn test_api_set_virtual_buffer_content() {
4562        let (mut backend, rx) = create_test_backend();
4563
4564        backend
4565            .execute_js(
4566                r#"
4567            const editor = getEditor();
4568            editor.setVirtualBufferContent(5, [
4569                { text: "New content\n", properties: { type: "updated" } }
4570            ]);
4571        "#,
4572                "test.js",
4573            )
4574            .unwrap();
4575
4576        let cmd = rx.try_recv().unwrap();
4577        match cmd {
4578            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4579                assert_eq!(buffer_id.0, 5);
4580                assert_eq!(entries.len(), 1);
4581                assert_eq!(entries[0].text, "New content\n");
4582            }
4583            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4584        }
4585    }
4586
4587    // ==================== Overlay Tests ====================
4588
4589    #[test]
4590    fn test_api_add_overlay() {
4591        let (mut backend, rx) = create_test_backend();
4592
4593        backend
4594            .execute_js(
4595                r#"
4596            const editor = getEditor();
4597            editor.addOverlay(1, "highlight", 10, 20, {
4598                fg: [255, 128, 0],
4599                bg: [50, 50, 50],
4600                bold: true,
4601            });
4602        "#,
4603                "test.js",
4604            )
4605            .unwrap();
4606
4607        let cmd = rx.try_recv().unwrap();
4608        match cmd {
4609            PluginCommand::AddOverlay {
4610                buffer_id,
4611                namespace,
4612                range,
4613                options,
4614            } => {
4615                use fresh_core::api::OverlayColorSpec;
4616                assert_eq!(buffer_id.0, 1);
4617                assert!(namespace.is_some());
4618                assert_eq!(namespace.unwrap().as_str(), "highlight");
4619                assert_eq!(range, 10..20);
4620                assert!(matches!(
4621                    options.fg,
4622                    Some(OverlayColorSpec::Rgb(255, 128, 0))
4623                ));
4624                assert!(matches!(
4625                    options.bg,
4626                    Some(OverlayColorSpec::Rgb(50, 50, 50))
4627                ));
4628                assert!(!options.underline);
4629                assert!(options.bold);
4630                assert!(!options.italic);
4631                assert!(!options.extend_to_line_end);
4632            }
4633            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4634        }
4635    }
4636
4637    #[test]
4638    fn test_api_add_overlay_with_theme_keys() {
4639        let (mut backend, rx) = create_test_backend();
4640
4641        backend
4642            .execute_js(
4643                r#"
4644            const editor = getEditor();
4645            // Test with theme keys for colors
4646            editor.addOverlay(1, "themed", 0, 10, {
4647                fg: "ui.status_bar_fg",
4648                bg: "editor.selection_bg",
4649            });
4650        "#,
4651                "test.js",
4652            )
4653            .unwrap();
4654
4655        let cmd = rx.try_recv().unwrap();
4656        match cmd {
4657            PluginCommand::AddOverlay {
4658                buffer_id,
4659                namespace,
4660                range,
4661                options,
4662            } => {
4663                use fresh_core::api::OverlayColorSpec;
4664                assert_eq!(buffer_id.0, 1);
4665                assert!(namespace.is_some());
4666                assert_eq!(namespace.unwrap().as_str(), "themed");
4667                assert_eq!(range, 0..10);
4668                assert!(matches!(
4669                    &options.fg,
4670                    Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
4671                ));
4672                assert!(matches!(
4673                    &options.bg,
4674                    Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
4675                ));
4676                assert!(!options.underline);
4677                assert!(!options.bold);
4678                assert!(!options.italic);
4679                assert!(!options.extend_to_line_end);
4680            }
4681            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4682        }
4683    }
4684
4685    #[test]
4686    fn test_api_clear_namespace() {
4687        let (mut backend, rx) = create_test_backend();
4688
4689        backend
4690            .execute_js(
4691                r#"
4692            const editor = getEditor();
4693            editor.clearNamespace(1, "highlight");
4694        "#,
4695                "test.js",
4696            )
4697            .unwrap();
4698
4699        let cmd = rx.try_recv().unwrap();
4700        match cmd {
4701            PluginCommand::ClearNamespace {
4702                buffer_id,
4703                namespace,
4704            } => {
4705                assert_eq!(buffer_id.0, 1);
4706                assert_eq!(namespace.as_str(), "highlight");
4707            }
4708            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4709        }
4710    }
4711
4712    // ==================== Theme Tests ====================
4713
4714    #[test]
4715    fn test_api_get_theme_schema() {
4716        let (mut backend, _rx) = create_test_backend();
4717
4718        backend
4719            .execute_js(
4720                r#"
4721            const editor = getEditor();
4722            const schema = editor.getThemeSchema();
4723            globalThis._isObject = typeof schema === 'object' && schema !== null;
4724        "#,
4725                "test.js",
4726            )
4727            .unwrap();
4728
4729        backend
4730            .plugin_contexts
4731            .borrow()
4732            .get("test")
4733            .unwrap()
4734            .clone()
4735            .with(|ctx| {
4736                let global = ctx.globals();
4737                let is_object: bool = global.get("_isObject").unwrap();
4738                // getThemeSchema should return an object
4739                assert!(is_object);
4740            });
4741    }
4742
4743    #[test]
4744    fn test_api_get_builtin_themes() {
4745        let (mut backend, _rx) = create_test_backend();
4746
4747        backend
4748            .execute_js(
4749                r#"
4750            const editor = getEditor();
4751            const themes = editor.getBuiltinThemes();
4752            globalThis._isObject = typeof themes === 'object' && themes !== null;
4753        "#,
4754                "test.js",
4755            )
4756            .unwrap();
4757
4758        backend
4759            .plugin_contexts
4760            .borrow()
4761            .get("test")
4762            .unwrap()
4763            .clone()
4764            .with(|ctx| {
4765                let global = ctx.globals();
4766                let is_object: bool = global.get("_isObject").unwrap();
4767                // getBuiltinThemes should return an object
4768                assert!(is_object);
4769            });
4770    }
4771
4772    #[test]
4773    fn test_api_apply_theme() {
4774        let (mut backend, rx) = create_test_backend();
4775
4776        backend
4777            .execute_js(
4778                r#"
4779            const editor = getEditor();
4780            editor.applyTheme("dark");
4781        "#,
4782                "test.js",
4783            )
4784            .unwrap();
4785
4786        let cmd = rx.try_recv().unwrap();
4787        match cmd {
4788            PluginCommand::ApplyTheme { theme_name } => {
4789                assert_eq!(theme_name, "dark");
4790            }
4791            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4792        }
4793    }
4794
4795    // ==================== Buffer Operations Tests ====================
4796
4797    #[test]
4798    fn test_api_close_buffer() {
4799        let (mut backend, rx) = create_test_backend();
4800
4801        backend
4802            .execute_js(
4803                r#"
4804            const editor = getEditor();
4805            editor.closeBuffer(3);
4806        "#,
4807                "test.js",
4808            )
4809            .unwrap();
4810
4811        let cmd = rx.try_recv().unwrap();
4812        match cmd {
4813            PluginCommand::CloseBuffer { buffer_id } => {
4814                assert_eq!(buffer_id.0, 3);
4815            }
4816            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4817        }
4818    }
4819
4820    #[test]
4821    fn test_api_focus_split() {
4822        let (mut backend, rx) = create_test_backend();
4823
4824        backend
4825            .execute_js(
4826                r#"
4827            const editor = getEditor();
4828            editor.focusSplit(2);
4829        "#,
4830                "test.js",
4831            )
4832            .unwrap();
4833
4834        let cmd = rx.try_recv().unwrap();
4835        match cmd {
4836            PluginCommand::FocusSplit { split_id } => {
4837                assert_eq!(split_id.0, 2);
4838            }
4839            _ => panic!("Expected FocusSplit, got {:?}", cmd),
4840        }
4841    }
4842
4843    #[test]
4844    fn test_api_list_buffers() {
4845        let (tx, _rx) = mpsc::channel();
4846        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4847
4848        // Add some buffers to state
4849        {
4850            let mut state = state_snapshot.write().unwrap();
4851            state.buffers.insert(
4852                BufferId(0),
4853                BufferInfo {
4854                    id: BufferId(0),
4855                    path: Some(PathBuf::from("/test1.txt")),
4856                    modified: false,
4857                    length: 100,
4858                },
4859            );
4860            state.buffers.insert(
4861                BufferId(1),
4862                BufferInfo {
4863                    id: BufferId(1),
4864                    path: Some(PathBuf::from("/test2.txt")),
4865                    modified: true,
4866                    length: 200,
4867                },
4868            );
4869        }
4870
4871        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4872        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4873
4874        backend
4875            .execute_js(
4876                r#"
4877            const editor = getEditor();
4878            const buffers = editor.listBuffers();
4879            globalThis._isArray = Array.isArray(buffers);
4880            globalThis._length = buffers.length;
4881        "#,
4882                "test.js",
4883            )
4884            .unwrap();
4885
4886        backend
4887            .plugin_contexts
4888            .borrow()
4889            .get("test")
4890            .unwrap()
4891            .clone()
4892            .with(|ctx| {
4893                let global = ctx.globals();
4894                let is_array: bool = global.get("_isArray").unwrap();
4895                let length: u32 = global.get("_length").unwrap();
4896                assert!(is_array);
4897                assert_eq!(length, 2);
4898            });
4899    }
4900
4901    // ==================== Prompt Tests ====================
4902
4903    #[test]
4904    fn test_api_start_prompt() {
4905        let (mut backend, rx) = create_test_backend();
4906
4907        backend
4908            .execute_js(
4909                r#"
4910            const editor = getEditor();
4911            editor.startPrompt("Enter value:", "test-prompt");
4912        "#,
4913                "test.js",
4914            )
4915            .unwrap();
4916
4917        let cmd = rx.try_recv().unwrap();
4918        match cmd {
4919            PluginCommand::StartPrompt { label, prompt_type } => {
4920                assert_eq!(label, "Enter value:");
4921                assert_eq!(prompt_type, "test-prompt");
4922            }
4923            _ => panic!("Expected StartPrompt, got {:?}", cmd),
4924        }
4925    }
4926
4927    #[test]
4928    fn test_api_start_prompt_with_initial() {
4929        let (mut backend, rx) = create_test_backend();
4930
4931        backend
4932            .execute_js(
4933                r#"
4934            const editor = getEditor();
4935            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
4936        "#,
4937                "test.js",
4938            )
4939            .unwrap();
4940
4941        let cmd = rx.try_recv().unwrap();
4942        match cmd {
4943            PluginCommand::StartPromptWithInitial {
4944                label,
4945                prompt_type,
4946                initial_value,
4947            } => {
4948                assert_eq!(label, "Enter value:");
4949                assert_eq!(prompt_type, "test-prompt");
4950                assert_eq!(initial_value, "default");
4951            }
4952            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
4953        }
4954    }
4955
4956    #[test]
4957    fn test_api_set_prompt_suggestions() {
4958        let (mut backend, rx) = create_test_backend();
4959
4960        backend
4961            .execute_js(
4962                r#"
4963            const editor = getEditor();
4964            editor.setPromptSuggestions([
4965                { text: "Option 1", value: "opt1" },
4966                { text: "Option 2", value: "opt2" }
4967            ]);
4968        "#,
4969                "test.js",
4970            )
4971            .unwrap();
4972
4973        let cmd = rx.try_recv().unwrap();
4974        match cmd {
4975            PluginCommand::SetPromptSuggestions { suggestions } => {
4976                assert_eq!(suggestions.len(), 2);
4977                assert_eq!(suggestions[0].text, "Option 1");
4978                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
4979            }
4980            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
4981        }
4982    }
4983
4984    // ==================== State Query Tests ====================
4985
4986    #[test]
4987    fn test_api_get_active_buffer_id() {
4988        let (tx, _rx) = mpsc::channel();
4989        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4990
4991        {
4992            let mut state = state_snapshot.write().unwrap();
4993            state.active_buffer_id = BufferId(42);
4994        }
4995
4996        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4997        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4998
4999        backend
5000            .execute_js(
5001                r#"
5002            const editor = getEditor();
5003            globalThis._activeId = editor.getActiveBufferId();
5004        "#,
5005                "test.js",
5006            )
5007            .unwrap();
5008
5009        backend
5010            .plugin_contexts
5011            .borrow()
5012            .get("test")
5013            .unwrap()
5014            .clone()
5015            .with(|ctx| {
5016                let global = ctx.globals();
5017                let result: u32 = global.get("_activeId").unwrap();
5018                assert_eq!(result, 42);
5019            });
5020    }
5021
5022    #[test]
5023    fn test_api_get_active_split_id() {
5024        let (tx, _rx) = mpsc::channel();
5025        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5026
5027        {
5028            let mut state = state_snapshot.write().unwrap();
5029            state.active_split_id = 7;
5030        }
5031
5032        let services = Arc::new(fresh_core::services::NoopServiceBridge);
5033        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5034
5035        backend
5036            .execute_js(
5037                r#"
5038            const editor = getEditor();
5039            globalThis._splitId = editor.getActiveSplitId();
5040        "#,
5041                "test.js",
5042            )
5043            .unwrap();
5044
5045        backend
5046            .plugin_contexts
5047            .borrow()
5048            .get("test")
5049            .unwrap()
5050            .clone()
5051            .with(|ctx| {
5052                let global = ctx.globals();
5053                let result: u32 = global.get("_splitId").unwrap();
5054                assert_eq!(result, 7);
5055            });
5056    }
5057
5058    // ==================== File System Tests ====================
5059
5060    #[test]
5061    fn test_api_file_exists() {
5062        let (mut backend, _rx) = create_test_backend();
5063
5064        backend
5065            .execute_js(
5066                r#"
5067            const editor = getEditor();
5068            // Test with a path that definitely exists
5069            globalThis._exists = editor.fileExists("/");
5070        "#,
5071                "test.js",
5072            )
5073            .unwrap();
5074
5075        backend
5076            .plugin_contexts
5077            .borrow()
5078            .get("test")
5079            .unwrap()
5080            .clone()
5081            .with(|ctx| {
5082                let global = ctx.globals();
5083                let result: bool = global.get("_exists").unwrap();
5084                assert!(result);
5085            });
5086    }
5087
5088    #[test]
5089    fn test_api_get_cwd() {
5090        let (mut backend, _rx) = create_test_backend();
5091
5092        backend
5093            .execute_js(
5094                r#"
5095            const editor = getEditor();
5096            globalThis._cwd = editor.getCwd();
5097        "#,
5098                "test.js",
5099            )
5100            .unwrap();
5101
5102        backend
5103            .plugin_contexts
5104            .borrow()
5105            .get("test")
5106            .unwrap()
5107            .clone()
5108            .with(|ctx| {
5109                let global = ctx.globals();
5110                let result: String = global.get("_cwd").unwrap();
5111                // Should return some path
5112                assert!(!result.is_empty());
5113            });
5114    }
5115
5116    #[test]
5117    fn test_api_get_env() {
5118        let (mut backend, _rx) = create_test_backend();
5119
5120        // Set a test environment variable
5121        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
5122
5123        backend
5124            .execute_js(
5125                r#"
5126            const editor = getEditor();
5127            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
5128        "#,
5129                "test.js",
5130            )
5131            .unwrap();
5132
5133        backend
5134            .plugin_contexts
5135            .borrow()
5136            .get("test")
5137            .unwrap()
5138            .clone()
5139            .with(|ctx| {
5140                let global = ctx.globals();
5141                let result: Option<String> = global.get("_envVal").unwrap();
5142                assert_eq!(result, Some("test_value".to_string()));
5143            });
5144
5145        std::env::remove_var("TEST_PLUGIN_VAR");
5146    }
5147
5148    #[test]
5149    fn test_api_get_config() {
5150        let (mut backend, _rx) = create_test_backend();
5151
5152        backend
5153            .execute_js(
5154                r#"
5155            const editor = getEditor();
5156            const config = editor.getConfig();
5157            globalThis._isObject = typeof config === 'object';
5158        "#,
5159                "test.js",
5160            )
5161            .unwrap();
5162
5163        backend
5164            .plugin_contexts
5165            .borrow()
5166            .get("test")
5167            .unwrap()
5168            .clone()
5169            .with(|ctx| {
5170                let global = ctx.globals();
5171                let is_object: bool = global.get("_isObject").unwrap();
5172                // getConfig should return an object, not a string
5173                assert!(is_object);
5174            });
5175    }
5176
5177    #[test]
5178    fn test_api_get_themes_dir() {
5179        let (mut backend, _rx) = create_test_backend();
5180
5181        backend
5182            .execute_js(
5183                r#"
5184            const editor = getEditor();
5185            globalThis._themesDir = editor.getThemesDir();
5186        "#,
5187                "test.js",
5188            )
5189            .unwrap();
5190
5191        backend
5192            .plugin_contexts
5193            .borrow()
5194            .get("test")
5195            .unwrap()
5196            .clone()
5197            .with(|ctx| {
5198                let global = ctx.globals();
5199                let result: String = global.get("_themesDir").unwrap();
5200                // Should return some path
5201                assert!(!result.is_empty());
5202            });
5203    }
5204
5205    // ==================== Read Dir Test ====================
5206
5207    #[test]
5208    fn test_api_read_dir() {
5209        let (mut backend, _rx) = create_test_backend();
5210
5211        backend
5212            .execute_js(
5213                r#"
5214            const editor = getEditor();
5215            const entries = editor.readDir("/tmp");
5216            globalThis._isArray = Array.isArray(entries);
5217            globalThis._length = entries.length;
5218        "#,
5219                "test.js",
5220            )
5221            .unwrap();
5222
5223        backend
5224            .plugin_contexts
5225            .borrow()
5226            .get("test")
5227            .unwrap()
5228            .clone()
5229            .with(|ctx| {
5230                let global = ctx.globals();
5231                let is_array: bool = global.get("_isArray").unwrap();
5232                let length: u32 = global.get("_length").unwrap();
5233                // /tmp should exist and readDir should return an array
5234                assert!(is_array);
5235                // Length is valid (u32 always >= 0)
5236                let _ = length;
5237            });
5238    }
5239
5240    // ==================== Execute Action Test ====================
5241
5242    #[test]
5243    fn test_api_execute_action() {
5244        let (mut backend, rx) = create_test_backend();
5245
5246        backend
5247            .execute_js(
5248                r#"
5249            const editor = getEditor();
5250            editor.executeAction("move_cursor_up");
5251        "#,
5252                "test.js",
5253            )
5254            .unwrap();
5255
5256        let cmd = rx.try_recv().unwrap();
5257        match cmd {
5258            PluginCommand::ExecuteAction { action_name } => {
5259                assert_eq!(action_name, "move_cursor_up");
5260            }
5261            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5262        }
5263    }
5264
5265    // ==================== Debug Test ====================
5266
5267    #[test]
5268    fn test_api_debug() {
5269        let (mut backend, _rx) = create_test_backend();
5270
5271        // debug() should not panic and should work with any input
5272        backend
5273            .execute_js(
5274                r#"
5275            const editor = getEditor();
5276            editor.debug("Test debug message");
5277            editor.debug("Another message with special chars: <>&\"'");
5278        "#,
5279                "test.js",
5280            )
5281            .unwrap();
5282        // If we get here without panic, the test passes
5283    }
5284
5285    // ==================== TypeScript Definitions Test ====================
5286
5287    #[test]
5288    fn test_typescript_preamble_generated() {
5289        // Check that the TypeScript preamble constant exists and has content
5290        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5291        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5292        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5293        println!(
5294            "Generated {} bytes of TypeScript preamble",
5295            JSEDITORAPI_TS_PREAMBLE.len()
5296        );
5297    }
5298
5299    #[test]
5300    fn test_typescript_editor_api_generated() {
5301        // Check that the EditorAPI interface is generated
5302        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5303        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5304        println!(
5305            "Generated {} bytes of EditorAPI interface",
5306            JSEDITORAPI_TS_EDITOR_API.len()
5307        );
5308    }
5309
5310    #[test]
5311    fn test_js_methods_list() {
5312        // Check that the JS methods list is generated
5313        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5314        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5315        // Print first 20 methods
5316        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5317            if i < 20 {
5318                println!("  - {}", method);
5319            }
5320        }
5321        if JSEDITORAPI_JS_METHODS.len() > 20 {
5322            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5323        }
5324    }
5325
5326    // ==================== Plugin Management API Tests ====================
5327
5328    #[test]
5329    fn test_api_load_plugin_sends_command() {
5330        let (mut backend, rx) = create_test_backend();
5331
5332        // Call loadPlugin - this returns a Promise and sends the command
5333        backend
5334            .execute_js(
5335                r#"
5336            const editor = getEditor();
5337            globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5338        "#,
5339                "test.js",
5340            )
5341            .unwrap();
5342
5343        // Verify the LoadPlugin command was sent
5344        let cmd = rx.try_recv().unwrap();
5345        match cmd {
5346            PluginCommand::LoadPlugin { path, callback_id } => {
5347                assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5348                assert!(callback_id.0 > 0); // Should have a valid callback ID
5349            }
5350            _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5351        }
5352    }
5353
5354    #[test]
5355    fn test_api_unload_plugin_sends_command() {
5356        let (mut backend, rx) = create_test_backend();
5357
5358        // Call unloadPlugin - this returns a Promise and sends the command
5359        backend
5360            .execute_js(
5361                r#"
5362            const editor = getEditor();
5363            globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5364        "#,
5365                "test.js",
5366            )
5367            .unwrap();
5368
5369        // Verify the UnloadPlugin command was sent
5370        let cmd = rx.try_recv().unwrap();
5371        match cmd {
5372            PluginCommand::UnloadPlugin { name, callback_id } => {
5373                assert_eq!(name, "my-plugin");
5374                assert!(callback_id.0 > 0); // Should have a valid callback ID
5375            }
5376            _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5377        }
5378    }
5379
5380    #[test]
5381    fn test_api_reload_plugin_sends_command() {
5382        let (mut backend, rx) = create_test_backend();
5383
5384        // Call reloadPlugin - this returns a Promise and sends the command
5385        backend
5386            .execute_js(
5387                r#"
5388            const editor = getEditor();
5389            globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5390        "#,
5391                "test.js",
5392            )
5393            .unwrap();
5394
5395        // Verify the ReloadPlugin command was sent
5396        let cmd = rx.try_recv().unwrap();
5397        match cmd {
5398            PluginCommand::ReloadPlugin { name, callback_id } => {
5399                assert_eq!(name, "my-plugin");
5400                assert!(callback_id.0 > 0); // Should have a valid callback ID
5401            }
5402            _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5403        }
5404    }
5405
5406    #[test]
5407    fn test_api_load_plugin_resolves_callback() {
5408        let (mut backend, rx) = create_test_backend();
5409
5410        // Call loadPlugin and set up a handler for when it resolves
5411        backend
5412            .execute_js(
5413                r#"
5414            const editor = getEditor();
5415            globalThis._loadResult = null;
5416            editor.loadPlugin("/path/to/plugin.ts").then(result => {
5417                globalThis._loadResult = result;
5418            });
5419        "#,
5420                "test.js",
5421            )
5422            .unwrap();
5423
5424        // Get the callback_id from the command
5425        let callback_id = match rx.try_recv().unwrap() {
5426            PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5427            cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5428        };
5429
5430        // Simulate the editor responding with success
5431        backend.resolve_callback(callback_id, "true");
5432
5433        // Drive the Promise to completion
5434        backend
5435            .plugin_contexts
5436            .borrow()
5437            .get("test")
5438            .unwrap()
5439            .clone()
5440            .with(|ctx| {
5441                run_pending_jobs_checked(&ctx, "test async loadPlugin");
5442            });
5443
5444        // Verify the Promise resolved with true
5445        backend
5446            .plugin_contexts
5447            .borrow()
5448            .get("test")
5449            .unwrap()
5450            .clone()
5451            .with(|ctx| {
5452                let global = ctx.globals();
5453                let result: bool = global.get("_loadResult").unwrap();
5454                assert!(result);
5455            });
5456    }
5457
5458    #[test]
5459    fn test_api_unload_plugin_rejects_on_error() {
5460        let (mut backend, rx) = create_test_backend();
5461
5462        // Call unloadPlugin and set up handlers for resolve/reject
5463        backend
5464            .execute_js(
5465                r#"
5466            const editor = getEditor();
5467            globalThis._unloadError = null;
5468            editor.unloadPlugin("nonexistent-plugin").catch(err => {
5469                globalThis._unloadError = err.message || String(err);
5470            });
5471        "#,
5472                "test.js",
5473            )
5474            .unwrap();
5475
5476        // Get the callback_id from the command
5477        let callback_id = match rx.try_recv().unwrap() {
5478            PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5479            cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5480        };
5481
5482        // Simulate the editor responding with an error
5483        backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5484
5485        // Drive the Promise to completion
5486        backend
5487            .plugin_contexts
5488            .borrow()
5489            .get("test")
5490            .unwrap()
5491            .clone()
5492            .with(|ctx| {
5493                run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5494            });
5495
5496        // Verify the Promise rejected with the error
5497        backend
5498            .plugin_contexts
5499            .borrow()
5500            .get("test")
5501            .unwrap()
5502            .clone()
5503            .with(|ctx| {
5504                let global = ctx.globals();
5505                let error: String = global.get("_unloadError").unwrap();
5506                assert!(error.contains("nonexistent-plugin"));
5507            });
5508    }
5509}