Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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