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    GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
93    PluginCommand, 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/// Recursively copy a directory and all its contents.
112fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
113    std::fs::create_dir_all(dst)?;
114    for entry in std::fs::read_dir(src)? {
115        let entry = entry?;
116        let file_type = entry.file_type()?;
117        let src_path = entry.path();
118        let dst_path = dst.join(entry.file_name());
119        if file_type.is_dir() {
120            copy_dir_recursive(&src_path, &dst_path)?;
121        } else {
122            std::fs::copy(&src_path, &dst_path)?;
123        }
124    }
125    Ok(())
126}
127
128/// Convert a QuickJS Value to serde_json::Value
129#[allow(clippy::only_used_in_recursion)]
130fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
131    use rquickjs::Type;
132    match val.type_of() {
133        Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
134        Type::Bool => val
135            .as_bool()
136            .map(serde_json::Value::Bool)
137            .unwrap_or(serde_json::Value::Null),
138        Type::Int => val
139            .as_int()
140            .map(|n| serde_json::Value::Number(n.into()))
141            .unwrap_or(serde_json::Value::Null),
142        Type::Float => val
143            .as_float()
144            .map(|f| {
145                // Emit whole-number floats as integers so serde deserializes
146                // them into u8/i32/etc. (QuickJS promotes ints to float in some ops)
147                if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
148                    serde_json::Value::Number((f as i64).into())
149                } else {
150                    serde_json::Number::from_f64(f)
151                        .map(serde_json::Value::Number)
152                        .unwrap_or(serde_json::Value::Null)
153                }
154            })
155            .unwrap_or(serde_json::Value::Null),
156        Type::String => val
157            .as_string()
158            .and_then(|s| s.to_string().ok())
159            .map(serde_json::Value::String)
160            .unwrap_or(serde_json::Value::Null),
161        Type::Array => {
162            if let Some(arr) = val.as_array() {
163                let items: Vec<serde_json::Value> = arr
164                    .iter()
165                    .filter_map(|item| item.ok())
166                    .map(|item| js_to_json(ctx, item))
167                    .collect();
168                serde_json::Value::Array(items)
169            } else {
170                serde_json::Value::Null
171            }
172        }
173        Type::Object | Type::Constructor | Type::Function => {
174            if let Some(obj) = val.as_object() {
175                let mut map = serde_json::Map::new();
176                for key in obj.keys::<String>().flatten() {
177                    if let Ok(v) = obj.get::<_, Value>(&key) {
178                        map.insert(key, js_to_json(ctx, v));
179                    }
180                }
181                serde_json::Value::Object(map)
182            } else {
183                serde_json::Value::Null
184            }
185        }
186        _ => serde_json::Value::Null,
187    }
188}
189
190/// Convert a serde_json::Value to a QuickJS Value
191fn json_to_js_value<'js>(
192    ctx: &rquickjs::Ctx<'js>,
193    val: &serde_json::Value,
194) -> rquickjs::Result<Value<'js>> {
195    match val {
196        serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
197        serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
198        serde_json::Value::Number(n) => {
199            if let Some(i) = n.as_i64() {
200                Ok(Value::new_int(ctx.clone(), i as i32))
201            } else if let Some(f) = n.as_f64() {
202                Ok(Value::new_float(ctx.clone(), f))
203            } else {
204                Ok(Value::new_null(ctx.clone()))
205            }
206        }
207        serde_json::Value::String(s) => {
208            let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
209            Ok(js_str.into_value())
210        }
211        serde_json::Value::Array(arr) => {
212            let js_arr = rquickjs::Array::new(ctx.clone())?;
213            for (i, item) in arr.iter().enumerate() {
214                let js_val = json_to_js_value(ctx, item)?;
215                js_arr.set(i, js_val)?;
216            }
217            Ok(js_arr.into_value())
218        }
219        serde_json::Value::Object(map) => {
220            let obj = rquickjs::Object::new(ctx.clone())?;
221            for (key, val) in map {
222                let js_val = json_to_js_value(ctx, val)?;
223                obj.set(key.as_str(), js_val)?;
224            }
225            Ok(obj.into_value())
226        }
227    }
228}
229
230/// Call a JS handler function directly with structured data, bypassing JSON
231/// string serialization and JS-side `JSON.parse()` + source re-parsing.
232fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
233    let js_data = match json_to_js_value(ctx, event_data) {
234        Ok(v) => v,
235        Err(e) => {
236            log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
237            return;
238        }
239    };
240
241    let globals = ctx.globals();
242    let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
243        return;
244    };
245
246    match func.call::<_, rquickjs::Value>((js_data,)) {
247        Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
248        Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
249    }
250
251    run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
252}
253
254/// If `result` is a thenable (Promise), attach `.catch()` to surface async rejections.
255fn attach_promise_catch<'js>(
256    ctx: &rquickjs::Ctx<'js>,
257    globals: &rquickjs::Object<'js>,
258    handler_name: &str,
259    result: rquickjs::Value<'js>,
260) {
261    let Some(obj) = result.as_object() else {
262        return;
263    };
264    if obj.get::<_, rquickjs::Function>("then").is_err() {
265        return;
266    }
267    let _ = globals.set("__pendingPromise", result);
268    let catch_code = format!(
269        r#"globalThis.__pendingPromise.catch(function(e) {{
270            console.error('Handler {} async error:', e);
271            throw e;
272        }}); delete globalThis.__pendingPromise;"#,
273        handler_name
274    );
275    let _ = ctx.eval::<(), _>(catch_code.as_bytes());
276}
277
278/// Get text properties at cursor position
279fn get_text_properties_at_cursor_typed(
280    snapshot: &Arc<RwLock<EditorStateSnapshot>>,
281    buffer_id: u32,
282) -> fresh_core::api::TextPropertiesAtCursor {
283    use fresh_core::api::TextPropertiesAtCursor;
284
285    let snap = match snapshot.read() {
286        Ok(s) => s,
287        Err(_) => return TextPropertiesAtCursor(Vec::new()),
288    };
289    let buffer_id_typed = BufferId(buffer_id as usize);
290    let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
291    let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
292        snap.primary_cursor.as_ref().map(|c| c.position)
293    } else {
294        None
295    };
296    let cursor_pos = match snapshot_pos.or(fallback_pos) {
297        Some(pos) => pos,
298        None => {
299            tracing::debug!(
300                "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
301                buffer_id_typed,
302                snapshot_pos,
303                snap.active_buffer_id
304            );
305            return TextPropertiesAtCursor(Vec::new());
306        }
307    };
308
309    let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
310        Some(p) => p,
311        None => {
312            tracing::debug!(
313                "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
314                buffer_id_typed,
315                cursor_pos
316            );
317            return TextPropertiesAtCursor(Vec::new());
318        }
319    };
320
321    let result: Vec<_> = properties
322        .iter()
323        .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
324        .map(|prop| prop.properties.clone())
325        .collect();
326
327    tracing::debug!(
328        "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
329        buffer_id_typed,
330        cursor_pos,
331        snapshot_pos,
332        fallback_pos,
333        snap.active_buffer_id,
334        properties.len(),
335        result.len()
336    );
337
338    TextPropertiesAtCursor(result)
339}
340
341/// Convert a JavaScript value to a string representation for console output
342fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
343    use rquickjs::Type;
344    match val.type_of() {
345        Type::Null => "null".to_string(),
346        Type::Undefined => "undefined".to_string(),
347        Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
348        Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
349        Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
350        Type::String => val
351            .as_string()
352            .and_then(|s| s.to_string().ok())
353            .unwrap_or_default(),
354        Type::Object | Type::Exception => {
355            // Check if this is an Error object (has message/stack properties)
356            if let Some(obj) = val.as_object() {
357                // Try to get error properties
358                let name: Option<String> = obj.get("name").ok();
359                let message: Option<String> = obj.get("message").ok();
360                let stack: Option<String> = obj.get("stack").ok();
361
362                if message.is_some() || name.is_some() {
363                    // This looks like an Error object
364                    let name = name.unwrap_or_else(|| "Error".to_string());
365                    let message = message.unwrap_or_default();
366                    if let Some(stack) = stack {
367                        return format!("{}: {}\n{}", name, message, stack);
368                    } else {
369                        return format!("{}: {}", name, message);
370                    }
371                }
372
373                // Regular object - convert to JSON
374                let json = js_to_json(ctx, val.clone());
375                serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
376            } else {
377                "[object]".to_string()
378            }
379        }
380        Type::Array => {
381            let json = js_to_json(ctx, val.clone());
382            serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
383        }
384        Type::Function | Type::Constructor => "[function]".to_string(),
385        Type::Symbol => "[symbol]".to_string(),
386        Type::BigInt => val
387            .as_big_int()
388            .and_then(|b| b.clone().to_i64().ok())
389            .map(|n| n.to_string())
390            .unwrap_or_else(|| "[bigint]".to_string()),
391        _ => format!("[{}]", val.type_name()),
392    }
393}
394
395/// Format a JavaScript error with full details including stack trace
396fn format_js_error(
397    ctx: &rquickjs::Ctx<'_>,
398    err: rquickjs::Error,
399    source_name: &str,
400) -> anyhow::Error {
401    // Check if this is an exception that we can catch for more details
402    if err.is_exception() {
403        // Try to catch the exception to get the full error object
404        let exc = ctx.catch();
405        if !exc.is_undefined() && !exc.is_null() {
406            // Try to get error message and stack from the exception object
407            if let Some(exc_obj) = exc.as_object() {
408                let message: String = exc_obj
409                    .get::<_, String>("message")
410                    .unwrap_or_else(|_| "Unknown error".to_string());
411                let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
412                let name: String = exc_obj
413                    .get::<_, String>("name")
414                    .unwrap_or_else(|_| "Error".to_string());
415
416                if !stack.is_empty() {
417                    return anyhow::anyhow!(
418                        "JS error in {}: {}: {}\nStack trace:\n{}",
419                        source_name,
420                        name,
421                        message,
422                        stack
423                    );
424                } else {
425                    return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
426                }
427            } else {
428                // Exception is not an object, try to convert to string
429                let exc_str: String = exc
430                    .as_string()
431                    .and_then(|s: &rquickjs::String| s.to_string().ok())
432                    .unwrap_or_else(|| format!("{:?}", exc));
433                return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
434            }
435        }
436    }
437
438    // Fall back to the basic error message
439    anyhow::anyhow!("JS error in {}: {}", source_name, err)
440}
441
442/// Log a JavaScript error with full details
443/// If panic_on_js_errors is enabled, this will panic to surface JS errors immediately
444fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
445    let error = format_js_error(ctx, err, context);
446    tracing::error!("{}", error);
447
448    // When enabled, panic on JS errors to make them visible and fail fast
449    if should_panic_on_js_errors() {
450        panic!("JavaScript error in {}: {}", context, error);
451    }
452}
453
454/// Global flag to panic on JS errors (enabled during testing)
455static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
456    std::sync::atomic::AtomicBool::new(false);
457
458/// Enable panicking on JS errors (call this from test setup)
459pub fn set_panic_on_js_errors(enabled: bool) {
460    PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
461}
462
463/// Check if panic on JS errors is enabled
464fn should_panic_on_js_errors() -> bool {
465    PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
466}
467
468/// Global flag indicating a fatal JS error occurred that should terminate the plugin thread.
469/// This is used because panicking inside rquickjs callbacks (FFI boundary) gets caught by
470/// rquickjs's catch_unwind, so we need an alternative mechanism to signal errors.
471static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
472
473/// Storage for the fatal error message
474static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
475
476/// Set a fatal JS error - call this instead of panicking inside FFI callbacks
477fn set_fatal_js_error(msg: String) {
478    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
479        if guard.is_none() {
480            // Only store the first error
481            *guard = Some(msg);
482        }
483    }
484    FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
485}
486
487/// Check if a fatal JS error has occurred
488pub fn has_fatal_js_error() -> bool {
489    FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
490}
491
492/// Get and clear the fatal JS error message (returns None if no error)
493pub fn take_fatal_js_error() -> Option<String> {
494    if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
495        return None;
496    }
497    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
498        guard.take()
499    } else {
500        Some("Fatal JS error (message unavailable)".to_string())
501    }
502}
503
504/// Run all pending jobs and check for unhandled exceptions
505/// If panic_on_js_errors is enabled, this will panic on unhandled exceptions
506fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
507    let mut count = 0;
508    loop {
509        // Check for unhandled exception before running more jobs
510        let exc: rquickjs::Value = ctx.catch();
511        // Only treat it as an exception if it's actually an Error object
512        if exc.is_exception() {
513            let error_msg = if let Some(err) = exc.as_exception() {
514                format!(
515                    "{}: {}",
516                    err.message().unwrap_or_default(),
517                    err.stack().unwrap_or_default()
518                )
519            } else {
520                format!("{:?}", exc)
521            };
522            tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
523            if should_panic_on_js_errors() {
524                panic!("Unhandled JS exception during {}: {}", context, error_msg);
525            }
526        }
527
528        if !ctx.execute_pending_job() {
529            break;
530        }
531        count += 1;
532    }
533
534    // Final check for exceptions after all jobs completed
535    let exc: rquickjs::Value = ctx.catch();
536    if exc.is_exception() {
537        let error_msg = if let Some(err) = exc.as_exception() {
538            format!(
539                "{}: {}",
540                err.message().unwrap_or_default(),
541                err.stack().unwrap_or_default()
542            )
543        } else {
544            format!("{:?}", exc)
545        };
546        tracing::error!(
547            "Unhandled JS exception after running jobs in {}: {}",
548            context,
549            error_msg
550        );
551        if should_panic_on_js_errors() {
552            panic!(
553                "Unhandled JS exception after running jobs in {}: {}",
554                context, error_msg
555            );
556        }
557    }
558
559    count
560}
561
562/// Parse a TextPropertyEntry from a JS Object
563fn parse_text_property_entry(
564    ctx: &rquickjs::Ctx<'_>,
565    obj: &Object<'_>,
566) -> Option<TextPropertyEntry> {
567    let text: String = obj.get("text").ok()?;
568    let properties: HashMap<String, serde_json::Value> = obj
569        .get::<_, Object>("properties")
570        .ok()
571        .map(|props_obj| {
572            let mut map = HashMap::new();
573            for key in props_obj.keys::<String>().flatten() {
574                if let Ok(v) = props_obj.get::<_, Value>(&key) {
575                    map.insert(key, js_to_json(ctx, v));
576                }
577            }
578            map
579        })
580        .unwrap_or_default();
581
582    // Parse optional style field
583    let style: Option<fresh_core::api::OverlayOptions> =
584        obj.get::<_, Object>("style").ok().and_then(|style_obj| {
585            let json_val = js_to_json(ctx, Value::from_object(style_obj));
586            serde_json::from_value(json_val).ok()
587        });
588
589    // Parse optional inlineOverlays array
590    let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
591        .get::<_, rquickjs::Array>("inlineOverlays")
592        .ok()
593        .map(|arr| {
594            arr.iter::<Object>()
595                .flatten()
596                .filter_map(|item| {
597                    let json_val = js_to_json(ctx, Value::from_object(item));
598                    serde_json::from_value(json_val).ok()
599                })
600                .collect()
601        })
602        .unwrap_or_default();
603
604    Some(TextPropertyEntry {
605        text,
606        properties,
607        style,
608        inline_overlays,
609    })
610}
611
612/// Pending response senders type alias
613pub type PendingResponses =
614    Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
615
616/// Information about a loaded plugin
617#[derive(Debug, Clone)]
618pub struct TsPluginInfo {
619    pub name: String,
620    pub path: PathBuf,
621    pub enabled: bool,
622}
623
624/// Handler information for events and actions
625/// Tracks state created by a plugin for cleanup on unload.
626///
627/// Each field records identifiers (namespaces, IDs, names) so that we can send
628/// compensating `PluginCommand`s when the plugin is unloaded.
629#[derive(Debug, Clone, Default)]
630pub struct PluginTrackedState {
631    /// (buffer_id, namespace) pairs used for overlays, conceals, soft breaks
632    pub overlay_namespaces: Vec<(BufferId, String)>,
633    /// (buffer_id, namespace) pairs used for virtual lines
634    pub virtual_line_namespaces: Vec<(BufferId, String)>,
635    /// (buffer_id, namespace) pairs used for line indicators
636    pub line_indicator_namespaces: Vec<(BufferId, String)>,
637    /// (buffer_id, virtual_text_id) pairs
638    pub virtual_text_ids: Vec<(BufferId, String)>,
639    /// File explorer decoration namespaces
640    pub file_explorer_namespaces: Vec<String>,
641    /// Context names set by the plugin
642    pub contexts_set: Vec<String>,
643    // --- Phase 3: Resource cleanup ---
644    /// Background process IDs spawned by this plugin
645    pub background_process_ids: Vec<u64>,
646    /// Scroll sync group IDs created by this plugin
647    pub scroll_sync_group_ids: Vec<u32>,
648    /// Virtual buffer IDs created by this plugin
649    pub virtual_buffer_ids: Vec<BufferId>,
650    /// Composite buffer IDs created by this plugin
651    pub composite_buffer_ids: Vec<BufferId>,
652    /// Terminal IDs created by this plugin
653    pub terminal_ids: Vec<fresh_core::TerminalId>,
654}
655
656/// Type alias for the shared async resource owner map.
657/// Maps request_id → plugin_name for pending async resource creations
658/// (virtual buffers, composite buffers, terminals).
659/// Shared between QuickJsBackend (plugin thread) and PluginThreadHandle (main thread).
660pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
661
662#[derive(Debug, Clone)]
663pub struct PluginHandler {
664    pub plugin_name: String,
665    pub handler_name: String,
666}
667
668/// JavaScript-exposed Editor API using rquickjs class system
669/// This allows proper lifetime handling for methods returning JS values
670#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
671#[rquickjs::class]
672pub struct JsEditorApi {
673    #[qjs(skip_trace)]
674    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
675    #[qjs(skip_trace)]
676    command_sender: mpsc::Sender<PluginCommand>,
677    #[qjs(skip_trace)]
678    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
679    #[qjs(skip_trace)]
680    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
681    #[qjs(skip_trace)]
682    next_request_id: Rc<RefCell<u64>>,
683    #[qjs(skip_trace)]
684    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
685    #[qjs(skip_trace)]
686    services: Arc<dyn fresh_core::services::PluginServiceBridge>,
687    #[qjs(skip_trace)]
688    plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
689    #[qjs(skip_trace)]
690    async_resource_owners: AsyncResourceOwners,
691    /// Tracks command name → owning plugin name (first-writer-wins collision detection)
692    #[qjs(skip_trace)]
693    registered_command_names: Rc<RefCell<HashMap<String, String>>>,
694    /// Tracks grammar language → owning plugin name (first-writer-wins collision detection)
695    #[qjs(skip_trace)]
696    registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
697    /// Tracks language config language → owning plugin name (first-writer-wins collision detection)
698    #[qjs(skip_trace)]
699    registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
700    /// Tracks LSP server language → owning plugin name (first-writer-wins collision detection)
701    #[qjs(skip_trace)]
702    registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
703    pub plugin_name: String,
704}
705
706#[plugin_api_impl]
707#[rquickjs::methods(rename_all = "camelCase")]
708impl JsEditorApi {
709    // === Buffer Queries ===
710
711    /// Get the plugin API version. Plugins can check this to verify
712    /// the editor supports the features they need.
713    pub fn api_version(&self) -> u32 {
714        2
715    }
716
717    /// Get the active buffer ID (0 if none)
718    pub fn get_active_buffer_id(&self) -> u32 {
719        self.state_snapshot
720            .read()
721            .map(|s| s.active_buffer_id.0 as u32)
722            .unwrap_or(0)
723    }
724
725    /// Get the active split ID
726    pub fn get_active_split_id(&self) -> u32 {
727        self.state_snapshot
728            .read()
729            .map(|s| s.active_split_id as u32)
730            .unwrap_or(0)
731    }
732
733    /// List all open buffers - returns array of BufferInfo objects
734    #[plugin_api(ts_return = "BufferInfo[]")]
735    pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
736        let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
737            s.buffers.values().cloned().collect()
738        } else {
739            Vec::new()
740        };
741        rquickjs_serde::to_value(ctx, &buffers)
742            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
743    }
744
745    /// List all available grammars with source info - returns array of GrammarInfo objects
746    #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
747    pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
748        let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
749            s.available_grammars.clone()
750        } else {
751            Vec::new()
752        };
753        rquickjs_serde::to_value(ctx, &grammars)
754            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
755    }
756
757    // === Logging ===
758
759    pub fn debug(&self, msg: String) {
760        tracing::trace!("Plugin.debug: {}", msg);
761    }
762
763    pub fn info(&self, msg: String) {
764        tracing::info!("Plugin: {}", msg);
765    }
766
767    pub fn warn(&self, msg: String) {
768        tracing::warn!("Plugin: {}", msg);
769    }
770
771    pub fn error(&self, msg: String) {
772        tracing::error!("Plugin: {}", msg);
773    }
774
775    // === Status ===
776
777    pub fn set_status(&self, msg: String) {
778        let _ = self
779            .command_sender
780            .send(PluginCommand::SetStatus { message: msg });
781    }
782
783    // === Clipboard ===
784
785    pub fn copy_to_clipboard(&self, text: String) {
786        let _ = self
787            .command_sender
788            .send(PluginCommand::SetClipboard { text });
789    }
790
791    pub fn set_clipboard(&self, text: String) {
792        let _ = self
793            .command_sender
794            .send(PluginCommand::SetClipboard { text });
795    }
796
797    // === Keybinding Queries ===
798
799    /// Get the display label for a keybinding by action name and optional mode.
800    /// Returns null if no binding is found.
801    pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
802        if let Some(mode_name) = mode {
803            let key = format!("{}\0{}", action, mode_name);
804            if let Ok(snapshot) = self.state_snapshot.read() {
805                return snapshot.keybinding_labels.get(&key).cloned();
806            }
807        }
808        None
809    }
810
811    // === Command Registration ===
812
813    /// Register a command in the command palette (Ctrl+P).
814    ///
815    /// Usually you should omit `context` so the command is always visible.
816    /// If provided, the command is **hidden** unless your plugin has activated
817    /// that context with `editor.setContext(name, true)` or the focused buffer's
818    /// virtual mode (from `defineMode()`) matches. This is for plugin-defined
819    /// contexts only (e.g. `"tour-active"`, `"review-mode"`), not built-in
820    /// editor modes.
821    pub fn register_command<'js>(
822        &self,
823        ctx: rquickjs::Ctx<'js>,
824        name: String,
825        description: String,
826        handler_name: String,
827        #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
828            rquickjs::Value<'js>,
829        >,
830    ) -> rquickjs::Result<bool> {
831        // Use stored plugin name instead of global lookup
832        let plugin_name = self.plugin_name.clone();
833        // Extract context string - handle null, undefined, or missing
834        let context_str: Option<String> = context.0.and_then(|v| {
835            if v.is_null() || v.is_undefined() {
836                None
837            } else {
838                v.as_string().and_then(|s| s.to_string().ok())
839            }
840        });
841
842        tracing::debug!(
843            "registerCommand: plugin='{}', name='{}', handler='{}'",
844            plugin_name,
845            name,
846            handler_name
847        );
848
849        // First-writer-wins: check if another plugin already registered this command name
850        // Names starting with '%' are per-plugin i18n keys (e.g. "%cmd.reload") that resolve
851        // to different display strings per plugin, so they are scoped by plugin name.
852        let tracking_key = if name.starts_with('%') {
853            format!("{}:{}", plugin_name, name)
854        } else {
855            name.clone()
856        };
857        {
858            let names = self.registered_command_names.borrow();
859            if let Some(existing_plugin) = names.get(&tracking_key) {
860                if existing_plugin != &plugin_name {
861                    let msg = format!(
862                        "Command '{}' already registered by plugin '{}'",
863                        name, existing_plugin
864                    );
865                    tracing::warn!("registerCommand collision: {}", msg);
866                    return Err(
867                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
868                    );
869                }
870                // Same plugin re-registering its own command is allowed (hot-reload)
871            }
872        }
873
874        // Record ownership
875        self.registered_command_names
876            .borrow_mut()
877            .insert(tracking_key, plugin_name.clone());
878
879        // Store action handler mapping with its plugin name
880        self.registered_actions.borrow_mut().insert(
881            handler_name.clone(),
882            PluginHandler {
883                plugin_name: self.plugin_name.clone(),
884                handler_name: handler_name.clone(),
885            },
886        );
887
888        // Register with editor
889        let command = Command {
890            name: name.clone(),
891            description,
892            action_name: handler_name,
893            plugin_name,
894            custom_contexts: context_str.into_iter().collect(),
895        };
896
897        Ok(self
898            .command_sender
899            .send(PluginCommand::RegisterCommand { command })
900            .is_ok())
901    }
902
903    /// Unregister a command by name
904    pub fn unregister_command(&self, name: String) -> bool {
905        // Clear ownership tracking so another plugin can register this name
906        // Use same scoping logic as register_command for %-prefixed i18n keys
907        let tracking_key = if name.starts_with('%') {
908            format!("{}:{}", self.plugin_name, name)
909        } else {
910            name.clone()
911        };
912        self.registered_command_names
913            .borrow_mut()
914            .remove(&tracking_key);
915        self.command_sender
916            .send(PluginCommand::UnregisterCommand { name })
917            .is_ok()
918    }
919
920    /// Set a context (for keybinding conditions)
921    pub fn set_context(&self, name: String, active: bool) -> bool {
922        // Track context name for cleanup on unload
923        if active {
924            self.plugin_tracked_state
925                .borrow_mut()
926                .entry(self.plugin_name.clone())
927                .or_default()
928                .contexts_set
929                .push(name.clone());
930        }
931        self.command_sender
932            .send(PluginCommand::SetContext { name, active })
933            .is_ok()
934    }
935
936    /// Execute a built-in action
937    pub fn execute_action(&self, action_name: String) -> bool {
938        self.command_sender
939            .send(PluginCommand::ExecuteAction { action_name })
940            .is_ok()
941    }
942
943    // === Translation ===
944
945    /// Translate a string - reads plugin name from __pluginName__ global
946    /// Args is optional - can be omitted, undefined, null, or an object
947    pub fn t<'js>(
948        &self,
949        _ctx: rquickjs::Ctx<'js>,
950        key: String,
951        args: rquickjs::function::Rest<Value<'js>>,
952    ) -> String {
953        // Use stored plugin name instead of global lookup
954        let plugin_name = self.plugin_name.clone();
955        // Convert args to HashMap - args.0 is a Vec of the rest arguments
956        let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
957            if let Some(obj) = first_arg.as_object() {
958                let mut map = HashMap::new();
959                for k in obj.keys::<String>().flatten() {
960                    if let Ok(v) = obj.get::<_, String>(&k) {
961                        map.insert(k, v);
962                    }
963                }
964                map
965            } else {
966                HashMap::new()
967            }
968        } else {
969            HashMap::new()
970        };
971        let res = self.services.translate(&plugin_name, &key, &args_map);
972
973        tracing::info!(
974            "Translating: key={}, plugin={}, args={:?} => res='{}'",
975            key,
976            plugin_name,
977            args_map,
978            res
979        );
980        res
981    }
982
983    // === Buffer Queries (additional) ===
984
985    /// Get cursor position in active buffer
986    pub fn get_cursor_position(&self) -> u32 {
987        self.state_snapshot
988            .read()
989            .ok()
990            .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
991            .unwrap_or(0)
992    }
993
994    /// Get file path for a buffer
995    pub fn get_buffer_path(&self, buffer_id: u32) -> String {
996        if let Ok(s) = self.state_snapshot.read() {
997            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
998                if let Some(p) = &b.path {
999                    return p.to_string_lossy().to_string();
1000                }
1001            }
1002        }
1003        String::new()
1004    }
1005
1006    /// Get buffer length in bytes
1007    pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1008        if let Ok(s) = self.state_snapshot.read() {
1009            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1010                return b.length as u32;
1011            }
1012        }
1013        0
1014    }
1015
1016    /// Check if buffer has unsaved changes
1017    pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1018        if let Ok(s) = self.state_snapshot.read() {
1019            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1020                return b.modified;
1021            }
1022        }
1023        false
1024    }
1025
1026    /// Save a buffer to a specific file path
1027    /// Used by :w filename to save unnamed buffers or save-as
1028    pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1029        self.command_sender
1030            .send(PluginCommand::SaveBufferToPath {
1031                buffer_id: BufferId(buffer_id as usize),
1032                path: std::path::PathBuf::from(path),
1033            })
1034            .is_ok()
1035    }
1036
1037    /// Get buffer info by ID
1038    #[plugin_api(ts_return = "BufferInfo | null")]
1039    pub fn get_buffer_info<'js>(
1040        &self,
1041        ctx: rquickjs::Ctx<'js>,
1042        buffer_id: u32,
1043    ) -> rquickjs::Result<Value<'js>> {
1044        let info = if let Ok(s) = self.state_snapshot.read() {
1045            s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1046        } else {
1047            None
1048        };
1049        rquickjs_serde::to_value(ctx, &info)
1050            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1051    }
1052
1053    /// Get primary cursor info for active buffer
1054    #[plugin_api(ts_return = "CursorInfo | null")]
1055    pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1056        let cursor = if let Ok(s) = self.state_snapshot.read() {
1057            s.primary_cursor.clone()
1058        } else {
1059            None
1060        };
1061        rquickjs_serde::to_value(ctx, &cursor)
1062            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1063    }
1064
1065    /// Get all cursors for active buffer
1066    #[plugin_api(ts_return = "CursorInfo[]")]
1067    pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1068        let cursors = if let Ok(s) = self.state_snapshot.read() {
1069            s.all_cursors.clone()
1070        } else {
1071            Vec::new()
1072        };
1073        rquickjs_serde::to_value(ctx, &cursors)
1074            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1075    }
1076
1077    /// Get all cursor positions as byte offsets
1078    #[plugin_api(ts_return = "number[]")]
1079    pub fn get_all_cursor_positions<'js>(
1080        &self,
1081        ctx: rquickjs::Ctx<'js>,
1082    ) -> rquickjs::Result<Value<'js>> {
1083        let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1084            s.all_cursors.iter().map(|c| c.position as u32).collect()
1085        } else {
1086            Vec::new()
1087        };
1088        rquickjs_serde::to_value(ctx, &positions)
1089            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1090    }
1091
1092    /// Get viewport info for active buffer
1093    #[plugin_api(ts_return = "ViewportInfo | null")]
1094    pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1095        let viewport = if let Ok(s) = self.state_snapshot.read() {
1096            s.viewport.clone()
1097        } else {
1098            None
1099        };
1100        rquickjs_serde::to_value(ctx, &viewport)
1101            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1102    }
1103
1104    /// Get the line number (0-indexed) of the primary cursor
1105    pub fn get_cursor_line(&self) -> u32 {
1106        // This would require line counting from the buffer
1107        // For now, return 0 - proper implementation needs buffer access
1108        // TODO: Add line number tracking to EditorStateSnapshot
1109        0
1110    }
1111
1112    /// Get the byte offset of the start of a line (0-indexed line number)
1113    /// Returns null if the line number is out of range
1114    #[plugin_api(
1115        async_promise,
1116        js_name = "getLineStartPosition",
1117        ts_return = "number | null"
1118    )]
1119    #[qjs(rename = "_getLineStartPositionStart")]
1120    pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1121        let id = {
1122            let mut id_ref = self.next_request_id.borrow_mut();
1123            let id = *id_ref;
1124            *id_ref += 1;
1125            // Record context for this callback
1126            self.callback_contexts
1127                .borrow_mut()
1128                .insert(id, self.plugin_name.clone());
1129            id
1130        };
1131        // Use buffer_id 0 for active buffer
1132        let _ = self
1133            .command_sender
1134            .send(PluginCommand::GetLineStartPosition {
1135                buffer_id: BufferId(0),
1136                line,
1137                request_id: id,
1138            });
1139        id
1140    }
1141
1142    /// Get the byte offset of the end of a line (0-indexed line number)
1143    /// Returns the position after the last character of the line (before newline)
1144    /// Returns null if the line number is out of range
1145    #[plugin_api(
1146        async_promise,
1147        js_name = "getLineEndPosition",
1148        ts_return = "number | null"
1149    )]
1150    #[qjs(rename = "_getLineEndPositionStart")]
1151    pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1152        let id = {
1153            let mut id_ref = self.next_request_id.borrow_mut();
1154            let id = *id_ref;
1155            *id_ref += 1;
1156            self.callback_contexts
1157                .borrow_mut()
1158                .insert(id, self.plugin_name.clone());
1159            id
1160        };
1161        // Use buffer_id 0 for active buffer
1162        let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1163            buffer_id: BufferId(0),
1164            line,
1165            request_id: id,
1166        });
1167        id
1168    }
1169
1170    /// Get the total number of lines in the active buffer
1171    /// Returns null if buffer not found
1172    #[plugin_api(
1173        async_promise,
1174        js_name = "getBufferLineCount",
1175        ts_return = "number | null"
1176    )]
1177    #[qjs(rename = "_getBufferLineCountStart")]
1178    pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1179        let id = {
1180            let mut id_ref = self.next_request_id.borrow_mut();
1181            let id = *id_ref;
1182            *id_ref += 1;
1183            self.callback_contexts
1184                .borrow_mut()
1185                .insert(id, self.plugin_name.clone());
1186            id
1187        };
1188        // Use buffer_id 0 for active buffer
1189        let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1190            buffer_id: BufferId(0),
1191            request_id: id,
1192        });
1193        id
1194    }
1195
1196    /// Scroll a split to center a specific line in the viewport
1197    /// Line is 0-indexed (0 = first line)
1198    pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1199        self.command_sender
1200            .send(PluginCommand::ScrollToLineCenter {
1201                split_id: SplitId(split_id as usize),
1202                buffer_id: BufferId(buffer_id as usize),
1203                line: line as usize,
1204            })
1205            .is_ok()
1206    }
1207
1208    /// Scroll any split/panel showing `buffer_id` so `line` is visible.
1209    /// Unlike `scrollToLineCenter`, this does not require a split id — it
1210    /// updates every split's viewport whose active buffer is the given
1211    /// buffer, including inner leaves of a buffer group. Use this from
1212    /// a panel plugin to keep the user's "selected" row in view after
1213    /// arrow-key navigation (the plugin's own selection state isn't
1214    /// automatically reflected in the buffer cursor, so the core-driven
1215    /// viewport would otherwise stay put).
1216    pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1217        self.command_sender
1218            .send(PluginCommand::ScrollBufferToLine {
1219                buffer_id: BufferId(buffer_id as usize),
1220                line: line as usize,
1221            })
1222            .is_ok()
1223    }
1224
1225    /// Find buffer by file path, returns buffer ID or 0 if not found
1226    pub fn find_buffer_by_path(&self, path: String) -> u32 {
1227        let path_buf = std::path::PathBuf::from(&path);
1228        if let Ok(s) = self.state_snapshot.read() {
1229            for (id, info) in &s.buffers {
1230                if let Some(buf_path) = &info.path {
1231                    if buf_path == &path_buf {
1232                        return id.0 as u32;
1233                    }
1234                }
1235            }
1236        }
1237        0
1238    }
1239
1240    /// Get diff between buffer content and last saved version
1241    #[plugin_api(ts_return = "BufferSavedDiff | null")]
1242    pub fn get_buffer_saved_diff<'js>(
1243        &self,
1244        ctx: rquickjs::Ctx<'js>,
1245        buffer_id: u32,
1246    ) -> rquickjs::Result<Value<'js>> {
1247        let diff = if let Ok(s) = self.state_snapshot.read() {
1248            s.buffer_saved_diffs
1249                .get(&BufferId(buffer_id as usize))
1250                .cloned()
1251        } else {
1252            None
1253        };
1254        rquickjs_serde::to_value(ctx, &diff)
1255            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1256    }
1257
1258    // === Text Editing ===
1259
1260    /// Insert text at a position in a buffer
1261    pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1262        self.command_sender
1263            .send(PluginCommand::InsertText {
1264                buffer_id: BufferId(buffer_id as usize),
1265                position: position as usize,
1266                text,
1267            })
1268            .is_ok()
1269    }
1270
1271    /// Delete a range from a buffer
1272    pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1273        self.command_sender
1274            .send(PluginCommand::DeleteRange {
1275                buffer_id: BufferId(buffer_id as usize),
1276                range: (start as usize)..(end as usize),
1277            })
1278            .is_ok()
1279    }
1280
1281    /// Insert text at cursor position in active buffer
1282    pub fn insert_at_cursor(&self, text: String) -> bool {
1283        self.command_sender
1284            .send(PluginCommand::InsertAtCursor { text })
1285            .is_ok()
1286    }
1287
1288    // === File Operations ===
1289
1290    /// Open a file, optionally at a specific line/column
1291    pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1292        self.command_sender
1293            .send(PluginCommand::OpenFileAtLocation {
1294                path: PathBuf::from(path),
1295                line: line.map(|l| l as usize),
1296                column: column.map(|c| c as usize),
1297            })
1298            .is_ok()
1299    }
1300
1301    /// Open a file in a specific split
1302    pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1303        self.command_sender
1304            .send(PluginCommand::OpenFileInSplit {
1305                split_id: split_id as usize,
1306                path: PathBuf::from(path),
1307                line: Some(line as usize),
1308                column: Some(column as usize),
1309            })
1310            .is_ok()
1311    }
1312
1313    /// Show a buffer in the current split
1314    pub fn show_buffer(&self, buffer_id: u32) -> bool {
1315        self.command_sender
1316            .send(PluginCommand::ShowBuffer {
1317                buffer_id: BufferId(buffer_id as usize),
1318            })
1319            .is_ok()
1320    }
1321
1322    /// Close a buffer
1323    pub fn close_buffer(&self, buffer_id: u32) -> bool {
1324        self.command_sender
1325            .send(PluginCommand::CloseBuffer {
1326                buffer_id: BufferId(buffer_id as usize),
1327            })
1328            .is_ok()
1329    }
1330
1331    // === Event Handling ===
1332
1333    /// Subscribe to an editor event
1334    pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1335        // If registering for lines_changed, clear all seen_byte_ranges so lines
1336        // that were already marked "seen" (before this plugin initialized) get
1337        // re-sent via the hook.
1338        if event_name == "lines_changed" {
1339            let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1340        }
1341        self.event_handlers
1342            .borrow_mut()
1343            .entry(event_name)
1344            .or_default()
1345            .push(PluginHandler {
1346                plugin_name: self.plugin_name.clone(),
1347                handler_name,
1348            });
1349    }
1350
1351    /// Unsubscribe from an event
1352    pub fn off(&self, event_name: String, handler_name: String) {
1353        if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1354            list.retain(|h| h.handler_name != handler_name);
1355        }
1356    }
1357
1358    // === Environment ===
1359
1360    /// Get an environment variable
1361    pub fn get_env(&self, name: String) -> Option<String> {
1362        std::env::var(&name).ok()
1363    }
1364
1365    /// Get current working directory
1366    pub fn get_cwd(&self) -> String {
1367        self.state_snapshot
1368            .read()
1369            .map(|s| s.working_dir.to_string_lossy().to_string())
1370            .unwrap_or_else(|_| ".".to_string())
1371    }
1372
1373    // === Path Operations ===
1374
1375    /// Join path components (variadic - accepts multiple string arguments)
1376    /// Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
1377    pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1378        let mut result_parts: Vec<String> = Vec::new();
1379        let mut has_leading_slash = false;
1380
1381        for part in &parts.0 {
1382            // Normalize separators to forward slashes
1383            let normalized = part.replace('\\', "/");
1384
1385            // Check if this is an absolute path (starts with / or has drive letter like C:/)
1386            let is_absolute = normalized.starts_with('/')
1387                || (normalized.len() >= 2
1388                    && normalized
1389                        .chars()
1390                        .next()
1391                        .map(|c| c.is_ascii_alphabetic())
1392                        .unwrap_or(false)
1393                    && normalized.chars().nth(1) == Some(':'));
1394
1395            if is_absolute {
1396                // Reset for absolute paths
1397                result_parts.clear();
1398                has_leading_slash = normalized.starts_with('/');
1399            }
1400
1401            // Split and add non-empty parts
1402            for segment in normalized.split('/') {
1403                if !segment.is_empty() && segment != "." {
1404                    if segment == ".." {
1405                        result_parts.pop();
1406                    } else {
1407                        result_parts.push(segment.to_string());
1408                    }
1409                }
1410            }
1411        }
1412
1413        // Reconstruct with forward slashes
1414        let joined = result_parts.join("/");
1415
1416        // Preserve leading slash for Unix absolute paths
1417        if has_leading_slash && !joined.is_empty() {
1418            format!("/{}", joined)
1419        } else {
1420            joined
1421        }
1422    }
1423
1424    /// Get directory name from path
1425    pub fn path_dirname(&self, path: String) -> String {
1426        Path::new(&path)
1427            .parent()
1428            .map(|p| p.to_string_lossy().to_string())
1429            .unwrap_or_default()
1430    }
1431
1432    /// Get file name from path
1433    pub fn path_basename(&self, path: String) -> String {
1434        Path::new(&path)
1435            .file_name()
1436            .map(|s| s.to_string_lossy().to_string())
1437            .unwrap_or_default()
1438    }
1439
1440    /// Get file extension
1441    pub fn path_extname(&self, path: String) -> String {
1442        Path::new(&path)
1443            .extension()
1444            .map(|s| format!(".{}", s.to_string_lossy()))
1445            .unwrap_or_default()
1446    }
1447
1448    /// Check if path is absolute
1449    pub fn path_is_absolute(&self, path: String) -> bool {
1450        Path::new(&path).is_absolute()
1451    }
1452
1453    /// Convert a file:// URI to a local file path.
1454    /// Handles percent-decoding and Windows drive letters.
1455    /// Returns an empty string if the URI is not a valid file URI.
1456    pub fn file_uri_to_path(&self, uri: String) -> String {
1457        fresh_core::file_uri::file_uri_to_path(&uri)
1458            .map(|p| p.to_string_lossy().to_string())
1459            .unwrap_or_default()
1460    }
1461
1462    /// Convert a local file path to a file:// URI.
1463    /// Handles Windows drive letters and special characters.
1464    /// Returns an empty string if the path cannot be converted.
1465    pub fn path_to_file_uri(&self, path: String) -> String {
1466        fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1467    }
1468
1469    /// Get the UTF-8 byte length of a JavaScript string.
1470    ///
1471    /// JS strings are UTF-16 internally, so `str.length` returns the number of
1472    /// UTF-16 code units, not the number of bytes in a UTF-8 encoding.  The
1473    /// editor API uses byte offsets for all buffer positions (overlays, cursor,
1474    /// getBufferText ranges, etc.).  This helper lets plugins convert JS string
1475    /// lengths / regex match indices to the byte offsets the editor expects.
1476    pub fn utf8_byte_length(&self, text: String) -> u32 {
1477        text.len() as u32
1478    }
1479
1480    // === File System ===
1481
1482    /// Check if file exists
1483    pub fn file_exists(&self, path: String) -> bool {
1484        Path::new(&path).exists()
1485    }
1486
1487    /// Read file contents
1488    pub fn read_file(&self, path: String) -> Option<String> {
1489        std::fs::read_to_string(&path).ok()
1490    }
1491
1492    /// Write file contents
1493    pub fn write_file(&self, path: String, content: String) -> bool {
1494        let p = Path::new(&path);
1495        if let Some(parent) = p.parent() {
1496            if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1497                return false;
1498            }
1499        }
1500        std::fs::write(p, content).is_ok()
1501    }
1502
1503    /// Read directory contents (returns array of {name, is_file, is_dir})
1504    #[plugin_api(ts_return = "DirEntry[]")]
1505    pub fn read_dir<'js>(
1506        &self,
1507        ctx: rquickjs::Ctx<'js>,
1508        path: String,
1509    ) -> rquickjs::Result<Value<'js>> {
1510        use fresh_core::api::DirEntry;
1511
1512        let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1513            Ok(entries) => entries
1514                .filter_map(|e| e.ok())
1515                .map(|entry| {
1516                    let file_type = entry.file_type().ok();
1517                    DirEntry {
1518                        name: entry.file_name().to_string_lossy().to_string(),
1519                        is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1520                        is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1521                    }
1522                })
1523                .collect(),
1524            Err(e) => {
1525                tracing::warn!("readDir failed for '{}': {}", path, e);
1526                Vec::new()
1527            }
1528        };
1529
1530        rquickjs_serde::to_value(ctx, &entries)
1531            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1532    }
1533
1534    /// Create a directory (and all parent directories) recursively.
1535    /// Returns true if the directory was created or already exists.
1536    pub fn create_dir(&self, path: String) -> bool {
1537        let p = Path::new(&path);
1538        if p.is_dir() {
1539            return true;
1540        }
1541        std::fs::create_dir_all(p).is_ok()
1542    }
1543
1544    /// Remove a file or directory by moving it to the OS trash/recycle bin.
1545    /// For safety, the path must be under the OS temp directory or the Fresh
1546    /// config directory. Returns true on success.
1547    pub fn remove_path(&self, path: String) -> bool {
1548        let target = match Path::new(&path).canonicalize() {
1549            Ok(p) => p,
1550            Err(_) => return false, // path doesn't exist or can't be resolved
1551        };
1552
1553        // Canonicalize allowed roots too, so that path prefix comparisons are
1554        // consistent.  On Windows, `Path::canonicalize` returns extended-length
1555        // UNC paths (e.g. `\\?\C:\...`) while `std::env::temp_dir()` and the
1556        // config dir may use regular paths.  Without canonicalizing the roots
1557        // the `starts_with` check would always fail on Windows.
1558        let temp_dir = std::env::temp_dir()
1559            .canonicalize()
1560            .unwrap_or_else(|_| std::env::temp_dir());
1561        let config_dir = self
1562            .services
1563            .config_dir()
1564            .canonicalize()
1565            .unwrap_or_else(|_| self.services.config_dir());
1566
1567        // Verify the path is under an allowed root (temp or config dir)
1568        let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1569        if !allowed {
1570            tracing::warn!(
1571                "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1572                target,
1573                temp_dir,
1574                config_dir
1575            );
1576            return false;
1577        }
1578
1579        // Don't allow removing the root directories themselves
1580        if target == temp_dir || target == config_dir {
1581            tracing::warn!(
1582                "removePath refused: cannot remove root directory {:?}",
1583                target
1584            );
1585            return false;
1586        }
1587
1588        match trash::delete(&target) {
1589            Ok(()) => true,
1590            Err(e) => {
1591                tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1592                false
1593            }
1594        }
1595    }
1596
1597    /// Rename/move a file or directory. Returns true on success.
1598    /// Falls back to copy then trash for cross-filesystem moves.
1599    pub fn rename_path(&self, from: String, to: String) -> bool {
1600        // Try direct rename first (works for same-filesystem moves)
1601        if std::fs::rename(&from, &to).is_ok() {
1602            return true;
1603        }
1604        // Cross-filesystem fallback: copy then trash the original
1605        let from_path = Path::new(&from);
1606        let copied = if from_path.is_dir() {
1607            copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1608        } else {
1609            std::fs::copy(&from, &to).is_ok()
1610        };
1611        if copied {
1612            return trash::delete(from_path).is_ok();
1613        }
1614        false
1615    }
1616
1617    /// Copy a file or directory recursively to a new location.
1618    /// Returns true on success.
1619    pub fn copy_path(&self, from: String, to: String) -> bool {
1620        let from_path = Path::new(&from);
1621        let to_path = Path::new(&to);
1622        if from_path.is_dir() {
1623            copy_dir_recursive(from_path, to_path).is_ok()
1624        } else {
1625            // Ensure parent directory exists
1626            if let Some(parent) = to_path.parent() {
1627                if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1628                    return false;
1629                }
1630            }
1631            std::fs::copy(from_path, to_path).is_ok()
1632        }
1633    }
1634
1635    /// Get the OS temporary directory path.
1636    pub fn get_temp_dir(&self) -> String {
1637        std::env::temp_dir().to_string_lossy().to_string()
1638    }
1639
1640    // === Config ===
1641
1642    /// Get current config as JS object.
1643    ///
1644    /// The snapshot holds an `Arc<serde_json::Value>` that was serialized
1645    /// on the editor side the last time the underlying `Arc<Config>`
1646    /// changed. Cloning the Arc inside the read lock is a refcount bump;
1647    /// the actual walk into the JS runtime happens outside the lock.
1648    pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1649        let config = self
1650            .state_snapshot
1651            .read()
1652            .map(|s| std::sync::Arc::clone(&s.config))
1653            .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1654
1655        rquickjs_serde::to_value(ctx, &*config)
1656            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1657    }
1658
1659    /// Get user config as JS object. Same Arc-clone pattern as `get_config`.
1660    pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1661        let config = self
1662            .state_snapshot
1663            .read()
1664            .map(|s| std::sync::Arc::clone(&s.user_config))
1665            .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1666
1667        rquickjs_serde::to_value(ctx, &*config)
1668            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1669    }
1670
1671    /// Reload configuration from file
1672    pub fn reload_config(&self) {
1673        let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1674    }
1675
1676    /// Reload theme registry from disk
1677    /// Call this after installing theme packages or saving new themes
1678    pub fn reload_themes(&self) {
1679        let _ = self
1680            .command_sender
1681            .send(PluginCommand::ReloadThemes { apply_theme: None });
1682    }
1683
1684    /// Reload theme registry and apply a theme atomically
1685    pub fn reload_and_apply_theme(&self, theme_name: String) {
1686        let _ = self.command_sender.send(PluginCommand::ReloadThemes {
1687            apply_theme: Some(theme_name),
1688        });
1689    }
1690
1691    /// Register a TextMate grammar file for a language
1692    /// The grammar will be pending until reload_grammars() is called
1693    pub fn register_grammar<'js>(
1694        &self,
1695        ctx: rquickjs::Ctx<'js>,
1696        language: String,
1697        grammar_path: String,
1698        extensions: Vec<String>,
1699    ) -> rquickjs::Result<bool> {
1700        // First-writer-wins: check if another plugin already registered a grammar for this language
1701        {
1702            let langs = self.registered_grammar_languages.borrow();
1703            if let Some(existing_plugin) = langs.get(&language) {
1704                if existing_plugin != &self.plugin_name {
1705                    let msg = format!(
1706                        "Grammar for language '{}' already registered by plugin '{}'",
1707                        language, existing_plugin
1708                    );
1709                    tracing::warn!("registerGrammar collision: {}", msg);
1710                    return Err(
1711                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1712                    );
1713                }
1714            }
1715        }
1716        self.registered_grammar_languages
1717            .borrow_mut()
1718            .insert(language.clone(), self.plugin_name.clone());
1719
1720        Ok(self
1721            .command_sender
1722            .send(PluginCommand::RegisterGrammar {
1723                language,
1724                grammar_path,
1725                extensions,
1726            })
1727            .is_ok())
1728    }
1729
1730    /// Register language configuration (comment prefix, indentation, formatter)
1731    pub fn register_language_config<'js>(
1732        &self,
1733        ctx: rquickjs::Ctx<'js>,
1734        language: String,
1735        config: LanguagePackConfig,
1736    ) -> rquickjs::Result<bool> {
1737        // First-writer-wins
1738        {
1739            let langs = self.registered_language_configs.borrow();
1740            if let Some(existing_plugin) = langs.get(&language) {
1741                if existing_plugin != &self.plugin_name {
1742                    let msg = format!(
1743                        "Language config for '{}' already registered by plugin '{}'",
1744                        language, existing_plugin
1745                    );
1746                    tracing::warn!("registerLanguageConfig collision: {}", msg);
1747                    return Err(
1748                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1749                    );
1750                }
1751            }
1752        }
1753        self.registered_language_configs
1754            .borrow_mut()
1755            .insert(language.clone(), self.plugin_name.clone());
1756
1757        Ok(self
1758            .command_sender
1759            .send(PluginCommand::RegisterLanguageConfig { language, config })
1760            .is_ok())
1761    }
1762
1763    /// Register an LSP server for a language
1764    pub fn register_lsp_server<'js>(
1765        &self,
1766        ctx: rquickjs::Ctx<'js>,
1767        language: String,
1768        config: LspServerPackConfig,
1769    ) -> rquickjs::Result<bool> {
1770        // First-writer-wins
1771        {
1772            let langs = self.registered_lsp_servers.borrow();
1773            if let Some(existing_plugin) = langs.get(&language) {
1774                if existing_plugin != &self.plugin_name {
1775                    let msg = format!(
1776                        "LSP server for language '{}' already registered by plugin '{}'",
1777                        language, existing_plugin
1778                    );
1779                    tracing::warn!("registerLspServer collision: {}", msg);
1780                    return Err(
1781                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1782                    );
1783                }
1784            }
1785        }
1786        self.registered_lsp_servers
1787            .borrow_mut()
1788            .insert(language.clone(), self.plugin_name.clone());
1789
1790        Ok(self
1791            .command_sender
1792            .send(PluginCommand::RegisterLspServer { language, config })
1793            .is_ok())
1794    }
1795
1796    /// Reload the grammar registry to apply registered grammars (async)
1797    /// Call this after registering one or more grammars.
1798    /// Returns a Promise that resolves when the grammar rebuild completes.
1799    #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
1800    #[qjs(rename = "_reloadGrammarsStart")]
1801    pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1802        let id = {
1803            let mut id_ref = self.next_request_id.borrow_mut();
1804            let id = *id_ref;
1805            *id_ref += 1;
1806            self.callback_contexts
1807                .borrow_mut()
1808                .insert(id, self.plugin_name.clone());
1809            id
1810        };
1811        let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
1812            callback_id: fresh_core::api::JsCallbackId::new(id),
1813        });
1814        id
1815    }
1816
1817    /// Get the directory where this plugin's files are stored.
1818    /// For package plugins this is `<plugins_dir>/packages/<plugin_name>/`.
1819    pub fn get_plugin_dir(&self) -> String {
1820        self.services
1821            .plugins_dir()
1822            .join("packages")
1823            .join(&self.plugin_name)
1824            .to_string_lossy()
1825            .to_string()
1826    }
1827
1828    /// Get config directory path
1829    pub fn get_config_dir(&self) -> String {
1830        self.services.config_dir().to_string_lossy().to_string()
1831    }
1832
1833    /// Get the persistent data directory path (DirectoryContext::data_dir).
1834    /// Intended for plugin state that should outlive a single session — e.g.
1835    /// review-diff comments keyed off git state.
1836    pub fn get_data_dir(&self) -> String {
1837        self.services.data_dir().to_string_lossy().to_string()
1838    }
1839
1840    /// Get themes directory path
1841    pub fn get_themes_dir(&self) -> String {
1842        self.services
1843            .config_dir()
1844            .join("themes")
1845            .to_string_lossy()
1846            .to_string()
1847    }
1848
1849    /// Apply a theme by name
1850    pub fn apply_theme(&self, theme_name: String) -> bool {
1851        self.command_sender
1852            .send(PluginCommand::ApplyTheme { theme_name })
1853            .is_ok()
1854    }
1855
1856    /// Get theme schema as JS object
1857    pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1858        let schema = self.services.get_theme_schema();
1859        rquickjs_serde::to_value(ctx, &schema)
1860            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1861    }
1862
1863    /// Get list of builtin themes as JS object
1864    pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1865        let themes = self.services.get_builtin_themes();
1866        rquickjs_serde::to_value(ctx, &themes)
1867            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1868    }
1869
1870    /// Delete a custom theme file (sync)
1871    #[qjs(rename = "_deleteThemeSync")]
1872    pub fn delete_theme_sync(&self, name: String) -> bool {
1873        // Security: only allow deleting from the themes directory
1874        let themes_dir = self.services.config_dir().join("themes");
1875        let theme_path = themes_dir.join(format!("{}.json", name));
1876
1877        // Verify the file is actually in the themes directory (prevent path traversal)
1878        if let Ok(canonical) = theme_path.canonicalize() {
1879            if let Ok(themes_canonical) = themes_dir.canonicalize() {
1880                if canonical.starts_with(&themes_canonical) {
1881                    return std::fs::remove_file(&canonical).is_ok();
1882                }
1883            }
1884        }
1885        false
1886    }
1887
1888    /// Delete a custom theme (alias for deleteThemeSync)
1889    pub fn delete_theme(&self, name: String) -> bool {
1890        self.delete_theme_sync(name)
1891    }
1892
1893    /// Get theme data (JSON) by name from the in-memory cache
1894    pub fn get_theme_data<'js>(
1895        &self,
1896        ctx: rquickjs::Ctx<'js>,
1897        name: String,
1898    ) -> rquickjs::Result<Value<'js>> {
1899        match self.services.get_theme_data(&name) {
1900            Some(data) => rquickjs_serde::to_value(ctx, &data)
1901                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1902            None => Ok(Value::new_null(ctx)),
1903        }
1904    }
1905
1906    /// Save a theme file to the user themes directory, returns the saved path
1907    pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
1908        self.services
1909            .save_theme_file(&name, &content)
1910            .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
1911    }
1912
1913    /// Check if a user theme file exists
1914    pub fn theme_file_exists(&self, name: String) -> bool {
1915        self.services.theme_file_exists(&name)
1916    }
1917
1918    // === File Stats ===
1919
1920    /// Get file stat information
1921    pub fn file_stat<'js>(
1922        &self,
1923        ctx: rquickjs::Ctx<'js>,
1924        path: String,
1925    ) -> rquickjs::Result<Value<'js>> {
1926        let metadata = std::fs::metadata(&path).ok();
1927        let stat = metadata.map(|m| {
1928            serde_json::json!({
1929                "isFile": m.is_file(),
1930                "isDir": m.is_dir(),
1931                "size": m.len(),
1932                "readonly": m.permissions().readonly(),
1933            })
1934        });
1935        rquickjs_serde::to_value(ctx, &stat)
1936            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1937    }
1938
1939    // === Process Management ===
1940
1941    /// Check if a background process is still running
1942    pub fn is_process_running(&self, _process_id: u64) -> bool {
1943        // This would need to check against tracked processes
1944        // For now, return false - proper implementation needs process tracking
1945        false
1946    }
1947
1948    /// Kill a process by ID (alias for killBackgroundProcess)
1949    pub fn kill_process(&self, process_id: u64) -> bool {
1950        self.command_sender
1951            .send(PluginCommand::KillBackgroundProcess { process_id })
1952            .is_ok()
1953    }
1954
1955    // === Translation ===
1956
1957    /// Translate a key for a specific plugin
1958    pub fn plugin_translate<'js>(
1959        &self,
1960        _ctx: rquickjs::Ctx<'js>,
1961        plugin_name: String,
1962        key: String,
1963        args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1964    ) -> String {
1965        let args_map: HashMap<String, String> = args
1966            .0
1967            .map(|obj| {
1968                let mut map = HashMap::new();
1969                for (k, v) in obj.props::<String, String>().flatten() {
1970                    map.insert(k, v);
1971                }
1972                map
1973            })
1974            .unwrap_or_default();
1975
1976        self.services.translate(&plugin_name, &key, &args_map)
1977    }
1978
1979    // === Composite Buffers ===
1980
1981    /// Create a composite buffer (async)
1982    ///
1983    /// Uses typed CreateCompositeBufferOptions - serde validates field names at runtime
1984    /// via `deny_unknown_fields` attribute
1985    #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1986    #[qjs(rename = "_createCompositeBufferStart")]
1987    pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1988        let id = {
1989            let mut id_ref = self.next_request_id.borrow_mut();
1990            let id = *id_ref;
1991            *id_ref += 1;
1992            // Record context for this callback
1993            self.callback_contexts
1994                .borrow_mut()
1995                .insert(id, self.plugin_name.clone());
1996            id
1997        };
1998
1999        // Track request_id → plugin_name for async resource tracking
2000        if let Ok(mut owners) = self.async_resource_owners.lock() {
2001            owners.insert(id, self.plugin_name.clone());
2002        }
2003        let _ = self
2004            .command_sender
2005            .send(PluginCommand::CreateCompositeBuffer {
2006                name: opts.name,
2007                mode: opts.mode,
2008                layout: opts.layout,
2009                sources: opts.sources,
2010                hunks: opts.hunks,
2011                initial_focus_hunk: opts.initial_focus_hunk,
2012                request_id: Some(id),
2013            });
2014
2015        id
2016    }
2017
2018    /// Update alignment hunks for a composite buffer
2019    ///
2020    /// Uses typed Vec<CompositeHunk> - serde validates field names at runtime
2021    pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2022        self.command_sender
2023            .send(PluginCommand::UpdateCompositeAlignment {
2024                buffer_id: BufferId(buffer_id as usize),
2025                hunks,
2026            })
2027            .is_ok()
2028    }
2029
2030    /// Close a composite buffer
2031    pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2032        self.command_sender
2033            .send(PluginCommand::CloseCompositeBuffer {
2034                buffer_id: BufferId(buffer_id as usize),
2035            })
2036            .is_ok()
2037    }
2038
2039    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
2040    /// After calling this, commands that depend on view state created during
2041    /// rendering (e.g., `compositeNextHunk`) will work correctly.
2042    pub fn flush_layout(&self) -> bool {
2043        self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2044    }
2045
2046    /// Navigate to the next hunk in a composite buffer
2047    pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2048        self.command_sender
2049            .send(PluginCommand::CompositeNextHunk {
2050                buffer_id: BufferId(buffer_id as usize),
2051            })
2052            .is_ok()
2053    }
2054
2055    /// Navigate to the previous hunk in a composite buffer
2056    pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2057        self.command_sender
2058            .send(PluginCommand::CompositePrevHunk {
2059                buffer_id: BufferId(buffer_id as usize),
2060            })
2061            .is_ok()
2062    }
2063
2064    // === Highlights ===
2065
2066    /// Request syntax highlights for a buffer range (async)
2067    #[plugin_api(
2068        async_promise,
2069        js_name = "getHighlights",
2070        ts_return = "TsHighlightSpan[]"
2071    )]
2072    #[qjs(rename = "_getHighlightsStart")]
2073    pub fn get_highlights_start<'js>(
2074        &self,
2075        _ctx: rquickjs::Ctx<'js>,
2076        buffer_id: u32,
2077        start: u32,
2078        end: u32,
2079    ) -> rquickjs::Result<u64> {
2080        let id = {
2081            let mut id_ref = self.next_request_id.borrow_mut();
2082            let id = *id_ref;
2083            *id_ref += 1;
2084            // Record plugin name for this callback
2085            self.callback_contexts
2086                .borrow_mut()
2087                .insert(id, self.plugin_name.clone());
2088            id
2089        };
2090
2091        let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2092            buffer_id: BufferId(buffer_id as usize),
2093            range: (start as usize)..(end as usize),
2094            request_id: id,
2095        });
2096
2097        Ok(id)
2098    }
2099
2100    // === Overlays ===
2101
2102    /// Add an overlay with styling options
2103    ///
2104    /// Colors can be specified as RGB arrays `[r, g, b]` or theme key strings.
2105    /// Theme keys are resolved at render time, so overlays update with theme changes.
2106    ///
2107    /// Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword"
2108    ///
2109    /// Options: fg, bg (RGB array or theme key string), bold, italic, underline,
2110    /// strikethrough, extend_to_line_end (all booleans, default false).
2111    ///
2112    /// Example usage in TypeScript:
2113    /// ```typescript
2114    /// editor.addOverlay(bufferId, "my-namespace", 0, 10, {
2115    ///   fg: "syntax.keyword",           // theme key
2116    ///   bg: [40, 40, 50],               // RGB array
2117    ///   bold: true,
2118    ///   strikethrough: true,
2119    /// });
2120    /// ```
2121    pub fn add_overlay<'js>(
2122        &self,
2123        _ctx: rquickjs::Ctx<'js>,
2124        buffer_id: u32,
2125        namespace: String,
2126        start: u32,
2127        end: u32,
2128        options: rquickjs::Object<'js>,
2129    ) -> rquickjs::Result<bool> {
2130        use fresh_core::api::OverlayColorSpec;
2131
2132        // Parse color spec from JS value (can be [r,g,b] array or "theme.key" string)
2133        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2134            // Try as string first (theme key)
2135            if let Ok(theme_key) = obj.get::<_, String>(key) {
2136                if !theme_key.is_empty() {
2137                    return Some(OverlayColorSpec::ThemeKey(theme_key));
2138                }
2139            }
2140            // Try as array [r, g, b]
2141            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2142                if arr.len() >= 3 {
2143                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2144                }
2145            }
2146            None
2147        }
2148
2149        let fg = parse_color_spec("fg", &options);
2150        let bg = parse_color_spec("bg", &options);
2151        let underline: bool = options.get("underline").unwrap_or(false);
2152        let bold: bool = options.get("bold").unwrap_or(false);
2153        let italic: bool = options.get("italic").unwrap_or(false);
2154        let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2155        let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2156        let url: Option<String> = options.get("url").ok();
2157
2158        let options = OverlayOptions {
2159            fg,
2160            bg,
2161            underline,
2162            bold,
2163            italic,
2164            strikethrough,
2165            extend_to_line_end,
2166            url,
2167        };
2168
2169        // Track namespace for cleanup on unload
2170        self.plugin_tracked_state
2171            .borrow_mut()
2172            .entry(self.plugin_name.clone())
2173            .or_default()
2174            .overlay_namespaces
2175            .push((BufferId(buffer_id as usize), namespace.clone()));
2176
2177        let _ = self.command_sender.send(PluginCommand::AddOverlay {
2178            buffer_id: BufferId(buffer_id as usize),
2179            namespace: Some(OverlayNamespace::from_string(namespace)),
2180            range: (start as usize)..(end as usize),
2181            options,
2182        });
2183
2184        Ok(true)
2185    }
2186
2187    /// Clear all overlays in a namespace
2188    pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2189        self.command_sender
2190            .send(PluginCommand::ClearNamespace {
2191                buffer_id: BufferId(buffer_id as usize),
2192                namespace: OverlayNamespace::from_string(namespace),
2193            })
2194            .is_ok()
2195    }
2196
2197    /// Clear all overlays from a buffer
2198    pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2199        self.command_sender
2200            .send(PluginCommand::ClearAllOverlays {
2201                buffer_id: BufferId(buffer_id as usize),
2202            })
2203            .is_ok()
2204    }
2205
2206    /// Clear all overlays that overlap with a byte range
2207    pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2208        self.command_sender
2209            .send(PluginCommand::ClearOverlaysInRange {
2210                buffer_id: BufferId(buffer_id as usize),
2211                start: start as usize,
2212                end: end as usize,
2213            })
2214            .is_ok()
2215    }
2216
2217    /// Remove an overlay by its handle
2218    pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2219        use fresh_core::overlay::OverlayHandle;
2220        self.command_sender
2221            .send(PluginCommand::RemoveOverlay {
2222                buffer_id: BufferId(buffer_id as usize),
2223                handle: OverlayHandle(handle),
2224            })
2225            .is_ok()
2226    }
2227
2228    // === Conceal Ranges ===
2229
2230    /// Add a conceal range that hides or replaces a byte range during rendering
2231    pub fn add_conceal(
2232        &self,
2233        buffer_id: u32,
2234        namespace: String,
2235        start: u32,
2236        end: u32,
2237        replacement: Option<String>,
2238    ) -> bool {
2239        // Track namespace for cleanup on unload
2240        self.plugin_tracked_state
2241            .borrow_mut()
2242            .entry(self.plugin_name.clone())
2243            .or_default()
2244            .overlay_namespaces
2245            .push((BufferId(buffer_id as usize), namespace.clone()));
2246
2247        self.command_sender
2248            .send(PluginCommand::AddConceal {
2249                buffer_id: BufferId(buffer_id as usize),
2250                namespace: OverlayNamespace::from_string(namespace),
2251                start: start as usize,
2252                end: end as usize,
2253                replacement,
2254            })
2255            .is_ok()
2256    }
2257
2258    /// Clear all conceal ranges in a namespace
2259    pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2260        self.command_sender
2261            .send(PluginCommand::ClearConcealNamespace {
2262                buffer_id: BufferId(buffer_id as usize),
2263                namespace: OverlayNamespace::from_string(namespace),
2264            })
2265            .is_ok()
2266    }
2267
2268    /// Clear all conceal ranges that overlap with a byte range
2269    pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2270        self.command_sender
2271            .send(PluginCommand::ClearConcealsInRange {
2272                buffer_id: BufferId(buffer_id as usize),
2273                start: start as usize,
2274                end: end as usize,
2275            })
2276            .is_ok()
2277    }
2278
2279    // === Folds ===
2280
2281    /// Add a collapsed fold range. Hides bytes [start, end) from
2282    /// rendering — the line containing `start - 1` (the fold "header")
2283    /// stays visible, while subsequent lines covered by the range are
2284    /// skipped.
2285    pub fn add_fold(
2286        &self,
2287        buffer_id: u32,
2288        start: u32,
2289        end: u32,
2290        placeholder: rquickjs::function::Opt<String>,
2291    ) -> bool {
2292        self.command_sender
2293            .send(PluginCommand::AddFold {
2294                buffer_id: BufferId(buffer_id as usize),
2295                start: start as usize,
2296                end: end as usize,
2297                placeholder: placeholder.0,
2298            })
2299            .is_ok()
2300    }
2301
2302    /// Clear every collapsed fold range on the buffer.
2303    pub fn clear_folds(&self, buffer_id: u32) -> bool {
2304        self.command_sender
2305            .send(PluginCommand::ClearFolds {
2306                buffer_id: BufferId(buffer_id as usize),
2307            })
2308            .is_ok()
2309    }
2310
2311    // === Soft Breaks ===
2312
2313    /// Add a soft break point for marker-based line wrapping
2314    pub fn add_soft_break(
2315        &self,
2316        buffer_id: u32,
2317        namespace: String,
2318        position: u32,
2319        indent: u32,
2320    ) -> bool {
2321        // Track namespace for cleanup on unload
2322        self.plugin_tracked_state
2323            .borrow_mut()
2324            .entry(self.plugin_name.clone())
2325            .or_default()
2326            .overlay_namespaces
2327            .push((BufferId(buffer_id as usize), namespace.clone()));
2328
2329        self.command_sender
2330            .send(PluginCommand::AddSoftBreak {
2331                buffer_id: BufferId(buffer_id as usize),
2332                namespace: OverlayNamespace::from_string(namespace),
2333                position: position as usize,
2334                indent: indent as u16,
2335            })
2336            .is_ok()
2337    }
2338
2339    /// Clear all soft breaks in a namespace
2340    pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2341        self.command_sender
2342            .send(PluginCommand::ClearSoftBreakNamespace {
2343                buffer_id: BufferId(buffer_id as usize),
2344                namespace: OverlayNamespace::from_string(namespace),
2345            })
2346            .is_ok()
2347    }
2348
2349    /// Clear all soft breaks that fall within a byte range
2350    pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2351        self.command_sender
2352            .send(PluginCommand::ClearSoftBreaksInRange {
2353                buffer_id: BufferId(buffer_id as usize),
2354                start: start as usize,
2355                end: end as usize,
2356            })
2357            .is_ok()
2358    }
2359
2360    // === View Transform ===
2361
2362    /// Submit a view transform for a buffer/split
2363    ///
2364    /// Accepts tokens in the simple format:
2365    ///   {kind: "text"|"newline"|"space"|"break", text: "...", sourceOffset: N, style?: {...}}
2366    ///
2367    /// Also accepts the TypeScript-defined format for backwards compatibility:
2368    ///   {kind: {Text: "..."} | "Newline" | "Space" | "Break", source_offset: N, style?: {...}}
2369    #[allow(clippy::too_many_arguments)]
2370    pub fn submit_view_transform<'js>(
2371        &self,
2372        _ctx: rquickjs::Ctx<'js>,
2373        buffer_id: u32,
2374        split_id: Option<u32>,
2375        start: u32,
2376        end: u32,
2377        tokens: Vec<rquickjs::Object<'js>>,
2378        layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2379    ) -> rquickjs::Result<bool> {
2380        use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2381
2382        let tokens: Vec<ViewTokenWire> = tokens
2383            .into_iter()
2384            .enumerate()
2385            .map(|(idx, obj)| {
2386                // Try to parse the token, with detailed error messages
2387                parse_view_token(&obj, idx)
2388            })
2389            .collect::<rquickjs::Result<Vec<_>>>()?;
2390
2391        // Parse layout hints if provided
2392        let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2393            let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2394            let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2395            Some(LayoutHints {
2396                compose_width,
2397                column_guides,
2398            })
2399        } else {
2400            None
2401        };
2402
2403        let payload = ViewTransformPayload {
2404            range: (start as usize)..(end as usize),
2405            tokens,
2406            layout_hints: parsed_layout_hints,
2407        };
2408
2409        Ok(self
2410            .command_sender
2411            .send(PluginCommand::SubmitViewTransform {
2412                buffer_id: BufferId(buffer_id as usize),
2413                split_id: split_id.map(|id| SplitId(id as usize)),
2414                payload,
2415            })
2416            .is_ok())
2417    }
2418
2419    /// Clear view transform for a buffer/split
2420    pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2421        self.command_sender
2422            .send(PluginCommand::ClearViewTransform {
2423                buffer_id: BufferId(buffer_id as usize),
2424                split_id: split_id.map(|id| SplitId(id as usize)),
2425            })
2426            .is_ok()
2427    }
2428
2429    /// Set layout hints (compose width, column guides) for a buffer/split
2430    /// without going through the view_transform pipeline.
2431    pub fn set_layout_hints<'js>(
2432        &self,
2433        buffer_id: u32,
2434        split_id: Option<u32>,
2435        #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2436    ) -> rquickjs::Result<bool> {
2437        use fresh_core::api::LayoutHints;
2438
2439        let compose_width: Option<u16> = hints.get("composeWidth").ok();
2440        let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2441        let parsed_hints = LayoutHints {
2442            compose_width,
2443            column_guides,
2444        };
2445
2446        Ok(self
2447            .command_sender
2448            .send(PluginCommand::SetLayoutHints {
2449                buffer_id: BufferId(buffer_id as usize),
2450                split_id: split_id.map(|id| SplitId(id as usize)),
2451                range: 0..0,
2452                hints: parsed_hints,
2453            })
2454            .is_ok())
2455    }
2456
2457    // === File Explorer ===
2458
2459    /// Set file explorer decorations for a namespace
2460    pub fn set_file_explorer_decorations<'js>(
2461        &self,
2462        _ctx: rquickjs::Ctx<'js>,
2463        namespace: String,
2464        decorations: Vec<rquickjs::Object<'js>>,
2465    ) -> rquickjs::Result<bool> {
2466        use fresh_core::file_explorer::FileExplorerDecoration;
2467
2468        let decorations: Vec<FileExplorerDecoration> = decorations
2469            .into_iter()
2470            .map(|obj| {
2471                let path: String = obj.get("path")?;
2472                let symbol: String = obj.get("symbol")?;
2473                let priority: i32 = obj.get("priority").unwrap_or(0);
2474
2475                // Color can be an RGB array [r, g, b] or a theme key string
2476                let color_val: rquickjs::Value = obj.get("color")?;
2477                let color = if color_val.is_string() {
2478                    let key: String = color_val.get()?;
2479                    fresh_core::api::OverlayColorSpec::ThemeKey(key)
2480                } else if color_val.is_array() {
2481                    let arr: Vec<u8> = color_val.get()?;
2482                    if arr.len() < 3 {
2483                        return Err(rquickjs::Error::FromJs {
2484                            from: "array",
2485                            to: "color",
2486                            message: Some(format!(
2487                                "color array must have at least 3 elements, got {}",
2488                                arr.len()
2489                            )),
2490                        });
2491                    }
2492                    fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2493                } else {
2494                    return Err(rquickjs::Error::FromJs {
2495                        from: "value",
2496                        to: "color",
2497                        message: Some("color must be an RGB array or theme key string".to_string()),
2498                    });
2499                };
2500
2501                Ok(FileExplorerDecoration {
2502                    path: std::path::PathBuf::from(path),
2503                    symbol,
2504                    color,
2505                    priority,
2506                })
2507            })
2508            .collect::<rquickjs::Result<Vec<_>>>()?;
2509
2510        // Track namespace for cleanup on unload
2511        self.plugin_tracked_state
2512            .borrow_mut()
2513            .entry(self.plugin_name.clone())
2514            .or_default()
2515            .file_explorer_namespaces
2516            .push(namespace.clone());
2517
2518        Ok(self
2519            .command_sender
2520            .send(PluginCommand::SetFileExplorerDecorations {
2521                namespace,
2522                decorations,
2523            })
2524            .is_ok())
2525    }
2526
2527    /// Clear file explorer decorations for a namespace
2528    pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2529        self.command_sender
2530            .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2531            .is_ok()
2532    }
2533
2534    // === Virtual Text ===
2535
2536    /// Add virtual text (inline text that doesn't exist in the buffer)
2537    #[allow(clippy::too_many_arguments)]
2538    pub fn add_virtual_text(
2539        &self,
2540        buffer_id: u32,
2541        virtual_text_id: String,
2542        position: u32,
2543        text: String,
2544        r: u8,
2545        g: u8,
2546        b: u8,
2547        before: bool,
2548        use_bg: bool,
2549    ) -> bool {
2550        // Track virtual text ID for cleanup on unload
2551        self.plugin_tracked_state
2552            .borrow_mut()
2553            .entry(self.plugin_name.clone())
2554            .or_default()
2555            .virtual_text_ids
2556            .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2557
2558        self.command_sender
2559            .send(PluginCommand::AddVirtualText {
2560                buffer_id: BufferId(buffer_id as usize),
2561                virtual_text_id,
2562                position: position as usize,
2563                text,
2564                color: (r, g, b),
2565                use_bg,
2566                before,
2567            })
2568            .is_ok()
2569    }
2570
2571    /// Remove a virtual text by ID
2572    pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2573        self.command_sender
2574            .send(PluginCommand::RemoveVirtualText {
2575                buffer_id: BufferId(buffer_id as usize),
2576                virtual_text_id,
2577            })
2578            .is_ok()
2579    }
2580
2581    /// Remove virtual texts whose ID starts with the given prefix
2582    pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
2583        self.command_sender
2584            .send(PluginCommand::RemoveVirtualTextsByPrefix {
2585                buffer_id: BufferId(buffer_id as usize),
2586                prefix,
2587            })
2588            .is_ok()
2589    }
2590
2591    /// Clear all virtual texts from a buffer
2592    pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
2593        self.command_sender
2594            .send(PluginCommand::ClearVirtualTexts {
2595                buffer_id: BufferId(buffer_id as usize),
2596            })
2597            .is_ok()
2598    }
2599
2600    /// Clear all virtual texts in a namespace
2601    pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2602        self.command_sender
2603            .send(PluginCommand::ClearVirtualTextNamespace {
2604                buffer_id: BufferId(buffer_id as usize),
2605                namespace,
2606            })
2607            .is_ok()
2608    }
2609
2610    /// Add a virtual line (full line above/below a position)
2611    ///
2612    /// The `options` object accepts:
2613    ///   * `fg`, `bg` — either an `[r, g, b]` array (each `0..=255`) or a
2614    ///     theme-key string (e.g. `"editor.line_number_fg"`).  Theme keys
2615    ///     are resolved at render time so the line follows theme changes.
2616    ///     Both default to `null` (no foreground / transparent background).
2617    #[allow(clippy::too_many_arguments)]
2618    pub fn add_virtual_line<'js>(
2619        &self,
2620        _ctx: rquickjs::Ctx<'js>,
2621        buffer_id: u32,
2622        position: u32,
2623        text: String,
2624        options: rquickjs::Object<'js>,
2625        above: bool,
2626        namespace: String,
2627        priority: i32,
2628    ) -> rquickjs::Result<bool> {
2629        use fresh_core::api::OverlayColorSpec;
2630
2631        // Same flexible parser as add_overlay: accepts theme key string or
2632        // RGB array.  Returns None when the key is missing or unusable.
2633        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2634            if let Ok(theme_key) = obj.get::<_, String>(key) {
2635                if !theme_key.is_empty() {
2636                    return Some(OverlayColorSpec::ThemeKey(theme_key));
2637                }
2638            }
2639            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2640                if arr.len() >= 3 {
2641                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2642                }
2643            }
2644            None
2645        }
2646
2647        let fg_color = parse_color_spec("fg", &options);
2648        let bg_color = parse_color_spec("bg", &options);
2649
2650        // Track namespace for cleanup on unload
2651        self.plugin_tracked_state
2652            .borrow_mut()
2653            .entry(self.plugin_name.clone())
2654            .or_default()
2655            .virtual_line_namespaces
2656            .push((BufferId(buffer_id as usize), namespace.clone()));
2657
2658        Ok(self
2659            .command_sender
2660            .send(PluginCommand::AddVirtualLine {
2661                buffer_id: BufferId(buffer_id as usize),
2662                position: position as usize,
2663                text,
2664                fg_color,
2665                bg_color,
2666                above,
2667                namespace,
2668                priority,
2669            })
2670            .is_ok())
2671    }
2672
2673    // === Prompts ===
2674
2675    /// Show a prompt and wait for user input (async)
2676    /// Returns the user input or null if cancelled
2677    #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
2678    #[qjs(rename = "_promptStart")]
2679    pub fn prompt_start(
2680        &self,
2681        _ctx: rquickjs::Ctx<'_>,
2682        label: String,
2683        initial_value: String,
2684    ) -> u64 {
2685        let id = {
2686            let mut id_ref = self.next_request_id.borrow_mut();
2687            let id = *id_ref;
2688            *id_ref += 1;
2689            // Record context for this callback
2690            self.callback_contexts
2691                .borrow_mut()
2692                .insert(id, self.plugin_name.clone());
2693            id
2694        };
2695
2696        let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
2697            label,
2698            initial_value,
2699            callback_id: JsCallbackId::new(id),
2700        });
2701
2702        id
2703    }
2704
2705    /// Start an interactive prompt
2706    pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
2707        self.command_sender
2708            .send(PluginCommand::StartPrompt { label, prompt_type })
2709            .is_ok()
2710    }
2711
2712    /// Start a prompt with initial value
2713    pub fn start_prompt_with_initial(
2714        &self,
2715        label: String,
2716        prompt_type: String,
2717        initial_value: String,
2718    ) -> bool {
2719        self.command_sender
2720            .send(PluginCommand::StartPromptWithInitial {
2721                label,
2722                prompt_type,
2723                initial_value,
2724            })
2725            .is_ok()
2726    }
2727
2728    /// Set suggestions for the current prompt
2729    ///
2730    /// Uses typed Vec<Suggestion> - serde validates field names at runtime
2731    pub fn set_prompt_suggestions(
2732        &self,
2733        suggestions: Vec<fresh_core::command::Suggestion>,
2734    ) -> bool {
2735        self.command_sender
2736            .send(PluginCommand::SetPromptSuggestions { suggestions })
2737            .is_ok()
2738    }
2739
2740    pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
2741        self.command_sender
2742            .send(PluginCommand::SetPromptInputSync { sync })
2743            .is_ok()
2744    }
2745
2746    // === Modes ===
2747
2748    /// Define a buffer mode (takes bindings as array of [key, command] pairs)
2749    pub fn define_mode(
2750        &self,
2751        name: String,
2752        bindings_arr: Vec<Vec<String>>,
2753        read_only: rquickjs::function::Opt<bool>,
2754        allow_text_input: rquickjs::function::Opt<bool>,
2755        inherit_normal_bindings: rquickjs::function::Opt<bool>,
2756    ) -> bool {
2757        let bindings: Vec<(String, String)> = bindings_arr
2758            .into_iter()
2759            .filter_map(|arr| {
2760                if arr.len() >= 2 {
2761                    Some((arr[0].clone(), arr[1].clone()))
2762                } else {
2763                    None
2764                }
2765            })
2766            .collect();
2767
2768        // Register commands associated with this mode so start_action can find them
2769        // and execute them in the correct plugin context
2770        {
2771            let mut registered = self.registered_actions.borrow_mut();
2772            for (_, cmd_name) in &bindings {
2773                registered.insert(
2774                    cmd_name.clone(),
2775                    PluginHandler {
2776                        plugin_name: self.plugin_name.clone(),
2777                        handler_name: cmd_name.clone(),
2778                    },
2779                );
2780            }
2781        }
2782
2783        // If allow_text_input is set, register a wildcard handler for text input
2784        // so the plugin can receive arbitrary character input
2785        let allow_text = allow_text_input.0.unwrap_or(false);
2786        if allow_text {
2787            let mut registered = self.registered_actions.borrow_mut();
2788            registered.insert(
2789                "mode_text_input".to_string(),
2790                PluginHandler {
2791                    plugin_name: self.plugin_name.clone(),
2792                    handler_name: "mode_text_input".to_string(),
2793                },
2794            );
2795        }
2796
2797        self.command_sender
2798            .send(PluginCommand::DefineMode {
2799                name,
2800                bindings,
2801                read_only: read_only.0.unwrap_or(false),
2802                allow_text_input: allow_text,
2803                inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
2804                plugin_name: Some(self.plugin_name.clone()),
2805            })
2806            .is_ok()
2807    }
2808
2809    /// Set the global editor mode
2810    pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
2811        self.command_sender
2812            .send(PluginCommand::SetEditorMode { mode })
2813            .is_ok()
2814    }
2815
2816    /// Get the current editor mode
2817    pub fn get_editor_mode(&self) -> Option<String> {
2818        self.state_snapshot
2819            .read()
2820            .ok()
2821            .and_then(|s| s.editor_mode.clone())
2822    }
2823
2824    // === Splits ===
2825
2826    /// Close a split
2827    pub fn close_split(&self, split_id: u32) -> bool {
2828        self.command_sender
2829            .send(PluginCommand::CloseSplit {
2830                split_id: SplitId(split_id as usize),
2831            })
2832            .is_ok()
2833    }
2834
2835    /// Set the buffer displayed in a split
2836    pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
2837        self.command_sender
2838            .send(PluginCommand::SetSplitBuffer {
2839                split_id: SplitId(split_id as usize),
2840                buffer_id: BufferId(buffer_id as usize),
2841            })
2842            .is_ok()
2843    }
2844
2845    /// Focus a specific split
2846    pub fn focus_split(&self, split_id: u32) -> bool {
2847        self.command_sender
2848            .send(PluginCommand::FocusSplit {
2849                split_id: SplitId(split_id as usize),
2850            })
2851            .is_ok()
2852    }
2853
2854    /// Set scroll position of a split
2855    pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
2856        self.command_sender
2857            .send(PluginCommand::SetSplitScroll {
2858                split_id: SplitId(split_id as usize),
2859                top_byte: top_byte as usize,
2860            })
2861            .is_ok()
2862    }
2863
2864    /// Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
2865    pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
2866        self.command_sender
2867            .send(PluginCommand::SetSplitRatio {
2868                split_id: SplitId(split_id as usize),
2869                ratio,
2870            })
2871            .is_ok()
2872    }
2873
2874    /// Set a label on a split (e.g., "sidebar")
2875    pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
2876        self.command_sender
2877            .send(PluginCommand::SetSplitLabel {
2878                split_id: SplitId(split_id as usize),
2879                label,
2880            })
2881            .is_ok()
2882    }
2883
2884    /// Remove a label from a split
2885    pub fn clear_split_label(&self, split_id: u32) -> bool {
2886        self.command_sender
2887            .send(PluginCommand::ClearSplitLabel {
2888                split_id: SplitId(split_id as usize),
2889            })
2890            .is_ok()
2891    }
2892
2893    /// Find a split by label (async)
2894    #[plugin_api(
2895        async_promise,
2896        js_name = "getSplitByLabel",
2897        ts_return = "number | null"
2898    )]
2899    #[qjs(rename = "_getSplitByLabelStart")]
2900    pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
2901        let id = {
2902            let mut id_ref = self.next_request_id.borrow_mut();
2903            let id = *id_ref;
2904            *id_ref += 1;
2905            self.callback_contexts
2906                .borrow_mut()
2907                .insert(id, self.plugin_name.clone());
2908            id
2909        };
2910        let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
2911            label,
2912            request_id: id,
2913        });
2914        id
2915    }
2916
2917    /// Distribute all splits evenly
2918    pub fn distribute_splits_evenly(&self) -> bool {
2919        // Get all split IDs - for now send empty vec (app will handle)
2920        self.command_sender
2921            .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
2922            .is_ok()
2923    }
2924
2925    /// Set cursor position in a buffer
2926    pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
2927        self.command_sender
2928            .send(PluginCommand::SetBufferCursor {
2929                buffer_id: BufferId(buffer_id as usize),
2930                position: position as usize,
2931            })
2932            .is_ok()
2933    }
2934
2935    /// Toggle whether the editor draws a native caret in this buffer.
2936    ///
2937    /// Buffer-group panel buffers default to `show_cursors = false`, which
2938    /// also blocks all native movement actions in `action_to_events`. Plugins
2939    /// that want native cursor motion in a panel (e.g. magit-style row
2940    /// navigation) call this with `true` after `createBufferGroup` returns.
2941    #[qjs(rename = "setBufferShowCursors")]
2942    pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
2943        self.command_sender
2944            .send(PluginCommand::SetBufferShowCursors {
2945                buffer_id: BufferId(buffer_id as usize),
2946                show,
2947            })
2948            .is_ok()
2949    }
2950
2951    // === Line Indicators ===
2952
2953    /// Set a line indicator in the gutter
2954    #[allow(clippy::too_many_arguments)]
2955    pub fn set_line_indicator(
2956        &self,
2957        buffer_id: u32,
2958        line: u32,
2959        namespace: String,
2960        symbol: String,
2961        r: u8,
2962        g: u8,
2963        b: u8,
2964        priority: i32,
2965    ) -> bool {
2966        // Track namespace for cleanup on unload
2967        self.plugin_tracked_state
2968            .borrow_mut()
2969            .entry(self.plugin_name.clone())
2970            .or_default()
2971            .line_indicator_namespaces
2972            .push((BufferId(buffer_id as usize), namespace.clone()));
2973
2974        self.command_sender
2975            .send(PluginCommand::SetLineIndicator {
2976                buffer_id: BufferId(buffer_id as usize),
2977                line: line as usize,
2978                namespace,
2979                symbol,
2980                color: (r, g, b),
2981                priority,
2982            })
2983            .is_ok()
2984    }
2985
2986    /// Batch set line indicators in the gutter
2987    #[allow(clippy::too_many_arguments)]
2988    pub fn set_line_indicators(
2989        &self,
2990        buffer_id: u32,
2991        lines: Vec<u32>,
2992        namespace: String,
2993        symbol: String,
2994        r: u8,
2995        g: u8,
2996        b: u8,
2997        priority: i32,
2998    ) -> bool {
2999        // Track namespace for cleanup on unload
3000        self.plugin_tracked_state
3001            .borrow_mut()
3002            .entry(self.plugin_name.clone())
3003            .or_default()
3004            .line_indicator_namespaces
3005            .push((BufferId(buffer_id as usize), namespace.clone()));
3006
3007        self.command_sender
3008            .send(PluginCommand::SetLineIndicators {
3009                buffer_id: BufferId(buffer_id as usize),
3010                lines: lines.into_iter().map(|l| l as usize).collect(),
3011                namespace,
3012                symbol,
3013                color: (r, g, b),
3014                priority,
3015            })
3016            .is_ok()
3017    }
3018
3019    /// Clear line indicators in a namespace
3020    pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3021        self.command_sender
3022            .send(PluginCommand::ClearLineIndicators {
3023                buffer_id: BufferId(buffer_id as usize),
3024                namespace,
3025            })
3026            .is_ok()
3027    }
3028
3029    /// Enable or disable line numbers for a buffer
3030    pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3031        self.command_sender
3032            .send(PluginCommand::SetLineNumbers {
3033                buffer_id: BufferId(buffer_id as usize),
3034                enabled,
3035            })
3036            .is_ok()
3037    }
3038
3039    /// Set the view mode for a buffer ("source" or "compose")
3040    pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3041        self.command_sender
3042            .send(PluginCommand::SetViewMode {
3043                buffer_id: BufferId(buffer_id as usize),
3044                mode,
3045            })
3046            .is_ok()
3047    }
3048
3049    /// Enable or disable line wrapping for a buffer/split
3050    pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3051        self.command_sender
3052            .send(PluginCommand::SetLineWrap {
3053                buffer_id: BufferId(buffer_id as usize),
3054                split_id: split_id.map(|s| SplitId(s as usize)),
3055                enabled,
3056            })
3057            .is_ok()
3058    }
3059
3060    // === Plugin View State ===
3061
3062    /// Set plugin-managed per-buffer view state (write-through to snapshot + command for persistence)
3063    pub fn set_view_state<'js>(
3064        &self,
3065        ctx: rquickjs::Ctx<'js>,
3066        buffer_id: u32,
3067        key: String,
3068        value: Value<'js>,
3069    ) -> bool {
3070        let bid = BufferId(buffer_id as usize);
3071
3072        // Convert JS value to serde_json::Value
3073        let json_value = if value.is_undefined() || value.is_null() {
3074            None
3075        } else {
3076            Some(js_to_json(&ctx, value))
3077        };
3078
3079        // Write-through: update the snapshot immediately so getViewState sees it
3080        if let Ok(mut snapshot) = self.state_snapshot.write() {
3081            if let Some(ref json_val) = json_value {
3082                snapshot
3083                    .plugin_view_states
3084                    .entry(bid)
3085                    .or_default()
3086                    .insert(key.clone(), json_val.clone());
3087            } else {
3088                // null/undefined = delete the key
3089                if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3090                    map.remove(&key);
3091                    if map.is_empty() {
3092                        snapshot.plugin_view_states.remove(&bid);
3093                    }
3094                }
3095            }
3096        }
3097
3098        // Send command to persist in BufferViewState.plugin_state
3099        self.command_sender
3100            .send(PluginCommand::SetViewState {
3101                buffer_id: bid,
3102                key,
3103                value: json_value,
3104            })
3105            .is_ok()
3106    }
3107
3108    /// Get plugin-managed per-buffer view state (reads from snapshot)
3109    pub fn get_view_state<'js>(
3110        &self,
3111        ctx: rquickjs::Ctx<'js>,
3112        buffer_id: u32,
3113        key: String,
3114    ) -> rquickjs::Result<Value<'js>> {
3115        let bid = BufferId(buffer_id as usize);
3116        if let Ok(snapshot) = self.state_snapshot.read() {
3117            if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3118                if let Some(json_val) = map.get(&key) {
3119                    return json_to_js_value(&ctx, json_val);
3120                }
3121            }
3122        }
3123        Ok(Value::new_undefined(ctx.clone()))
3124    }
3125
3126    // === Plugin Global State ===
3127
3128    /// Set plugin-managed global state (write-through to snapshot + command for persistence).
3129    /// State is automatically isolated per plugin using the plugin's name.
3130    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
3131    pub fn set_global_state<'js>(
3132        &self,
3133        ctx: rquickjs::Ctx<'js>,
3134        key: String,
3135        value: Value<'js>,
3136    ) -> bool {
3137        // Convert JS value to serde_json::Value
3138        let json_value = if value.is_undefined() || value.is_null() {
3139            None
3140        } else {
3141            Some(js_to_json(&ctx, value))
3142        };
3143
3144        // Write-through: update the snapshot immediately so getGlobalState sees it
3145        if let Ok(mut snapshot) = self.state_snapshot.write() {
3146            if let Some(ref json_val) = json_value {
3147                snapshot
3148                    .plugin_global_states
3149                    .entry(self.plugin_name.clone())
3150                    .or_default()
3151                    .insert(key.clone(), json_val.clone());
3152            } else {
3153                // null/undefined = delete the key
3154                if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3155                    map.remove(&key);
3156                    if map.is_empty() {
3157                        snapshot.plugin_global_states.remove(&self.plugin_name);
3158                    }
3159                }
3160            }
3161        }
3162
3163        // Send command to persist in Editor.plugin_global_state
3164        self.command_sender
3165            .send(PluginCommand::SetGlobalState {
3166                plugin_name: self.plugin_name.clone(),
3167                key,
3168                value: json_value,
3169            })
3170            .is_ok()
3171    }
3172
3173    /// Get plugin-managed global state (reads from snapshot).
3174    /// State is automatically isolated per plugin using the plugin's name.
3175    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
3176    pub fn get_global_state<'js>(
3177        &self,
3178        ctx: rquickjs::Ctx<'js>,
3179        key: String,
3180    ) -> rquickjs::Result<Value<'js>> {
3181        if let Ok(snapshot) = self.state_snapshot.read() {
3182            if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3183                if let Some(json_val) = map.get(&key) {
3184                    return json_to_js_value(&ctx, json_val);
3185                }
3186            }
3187        }
3188        Ok(Value::new_undefined(ctx.clone()))
3189    }
3190
3191    // === Scroll Sync ===
3192
3193    /// Create a scroll sync group for anchor-based synchronized scrolling
3194    pub fn create_scroll_sync_group(
3195        &self,
3196        group_id: u32,
3197        left_split: u32,
3198        right_split: u32,
3199    ) -> bool {
3200        // Track group ID for cleanup on unload
3201        self.plugin_tracked_state
3202            .borrow_mut()
3203            .entry(self.plugin_name.clone())
3204            .or_default()
3205            .scroll_sync_group_ids
3206            .push(group_id);
3207        self.command_sender
3208            .send(PluginCommand::CreateScrollSyncGroup {
3209                group_id,
3210                left_split: SplitId(left_split as usize),
3211                right_split: SplitId(right_split as usize),
3212            })
3213            .is_ok()
3214    }
3215
3216    /// Set sync anchors for a scroll sync group
3217    pub fn set_scroll_sync_anchors<'js>(
3218        &self,
3219        _ctx: rquickjs::Ctx<'js>,
3220        group_id: u32,
3221        anchors: Vec<Vec<u32>>,
3222    ) -> bool {
3223        let anchors: Vec<(usize, usize)> = anchors
3224            .into_iter()
3225            .filter_map(|pair| {
3226                if pair.len() >= 2 {
3227                    Some((pair[0] as usize, pair[1] as usize))
3228                } else {
3229                    None
3230                }
3231            })
3232            .collect();
3233        self.command_sender
3234            .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3235            .is_ok()
3236    }
3237
3238    /// Remove a scroll sync group
3239    pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
3240        self.command_sender
3241            .send(PluginCommand::RemoveScrollSyncGroup { group_id })
3242            .is_ok()
3243    }
3244
3245    // === Actions ===
3246
3247    /// Execute multiple actions in sequence
3248    ///
3249    /// Takes typed ActionSpec array - serde validates field names at runtime
3250    pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
3251        self.command_sender
3252            .send(PluginCommand::ExecuteActions { actions })
3253            .is_ok()
3254    }
3255
3256    /// Show an action popup
3257    ///
3258    /// Takes a typed ActionPopupOptions struct - serde validates field names at runtime
3259    pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
3260        self.command_sender
3261            .send(PluginCommand::ShowActionPopup {
3262                popup_id: opts.id,
3263                title: opts.title,
3264                message: opts.message,
3265                actions: opts.actions,
3266            })
3267            .is_ok()
3268    }
3269
3270    /// Disable LSP for a specific language
3271    pub fn disable_lsp_for_language(&self, language: String) -> bool {
3272        self.command_sender
3273            .send(PluginCommand::DisableLspForLanguage { language })
3274            .is_ok()
3275    }
3276
3277    /// Restart LSP server for a specific language
3278    pub fn restart_lsp_for_language(&self, language: String) -> bool {
3279        self.command_sender
3280            .send(PluginCommand::RestartLspForLanguage { language })
3281            .is_ok()
3282    }
3283
3284    /// Set the workspace root URI for a specific language's LSP server
3285    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
3286    pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
3287        self.command_sender
3288            .send(PluginCommand::SetLspRootUri { language, uri })
3289            .is_ok()
3290    }
3291
3292    /// Get all diagnostics from LSP
3293    #[plugin_api(ts_return = "JsDiagnostic[]")]
3294    pub fn get_all_diagnostics<'js>(
3295        &self,
3296        ctx: rquickjs::Ctx<'js>,
3297    ) -> rquickjs::Result<Value<'js>> {
3298        use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
3299
3300        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
3301            // Convert to JsDiagnostic format for JS
3302            let mut result: Vec<JsDiagnostic> = Vec::new();
3303            for (uri, diags) in s.diagnostics.iter() {
3304                for diag in diags {
3305                    result.push(JsDiagnostic {
3306                        uri: uri.clone(),
3307                        message: diag.message.clone(),
3308                        severity: diag.severity.map(|s| match s {
3309                            lsp_types::DiagnosticSeverity::ERROR => 1,
3310                            lsp_types::DiagnosticSeverity::WARNING => 2,
3311                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
3312                            lsp_types::DiagnosticSeverity::HINT => 4,
3313                            _ => 0,
3314                        }),
3315                        range: JsRange {
3316                            start: JsPosition {
3317                                line: diag.range.start.line,
3318                                character: diag.range.start.character,
3319                            },
3320                            end: JsPosition {
3321                                line: diag.range.end.line,
3322                                character: diag.range.end.character,
3323                            },
3324                        },
3325                        source: diag.source.clone(),
3326                    });
3327                }
3328            }
3329            result
3330        } else {
3331            Vec::new()
3332        };
3333        rquickjs_serde::to_value(ctx, &diagnostics)
3334            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3335    }
3336
3337    /// Get registered event handlers for an event
3338    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
3339        self.event_handlers
3340            .borrow()
3341            .get(&event_name)
3342            .cloned()
3343            .unwrap_or_default()
3344            .into_iter()
3345            .map(|h| h.handler_name)
3346            .collect()
3347    }
3348
3349    // === Virtual Buffers ===
3350
3351    /// Create a virtual buffer in current split (async, returns buffer and split IDs)
3352    #[plugin_api(
3353        async_promise,
3354        js_name = "createVirtualBuffer",
3355        ts_return = "VirtualBufferResult"
3356    )]
3357    #[qjs(rename = "_createVirtualBufferStart")]
3358    pub fn create_virtual_buffer_start(
3359        &self,
3360        _ctx: rquickjs::Ctx<'_>,
3361        opts: fresh_core::api::CreateVirtualBufferOptions,
3362    ) -> rquickjs::Result<u64> {
3363        let id = {
3364            let mut id_ref = self.next_request_id.borrow_mut();
3365            let id = *id_ref;
3366            *id_ref += 1;
3367            // Record context for this callback
3368            self.callback_contexts
3369                .borrow_mut()
3370                .insert(id, self.plugin_name.clone());
3371            id
3372        };
3373
3374        // Convert JsTextPropertyEntry to TextPropertyEntry
3375        let entries: Vec<TextPropertyEntry> = opts
3376            .entries
3377            .unwrap_or_default()
3378            .into_iter()
3379            .map(|e| TextPropertyEntry {
3380                text: e.text,
3381                properties: e.properties.unwrap_or_default(),
3382                style: e.style,
3383                inline_overlays: e.inline_overlays.unwrap_or_default(),
3384            })
3385            .collect();
3386
3387        tracing::debug!(
3388            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
3389            id
3390        );
3391        // Track request_id → plugin_name for async resource tracking
3392        if let Ok(mut owners) = self.async_resource_owners.lock() {
3393            owners.insert(id, self.plugin_name.clone());
3394        }
3395        let _ = self
3396            .command_sender
3397            .send(PluginCommand::CreateVirtualBufferWithContent {
3398                name: opts.name,
3399                mode: opts.mode.unwrap_or_default(),
3400                read_only: opts.read_only.unwrap_or(false),
3401                entries,
3402                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
3403                show_cursors: opts.show_cursors.unwrap_or(true),
3404                editing_disabled: opts.editing_disabled.unwrap_or(false),
3405                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
3406                request_id: Some(id),
3407            });
3408        Ok(id)
3409    }
3410
3411    /// Create a virtual buffer in a new split (async, returns buffer and split IDs)
3412    #[plugin_api(
3413        async_promise,
3414        js_name = "createVirtualBufferInSplit",
3415        ts_return = "VirtualBufferResult"
3416    )]
3417    #[qjs(rename = "_createVirtualBufferInSplitStart")]
3418    pub fn create_virtual_buffer_in_split_start(
3419        &self,
3420        _ctx: rquickjs::Ctx<'_>,
3421        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
3422    ) -> rquickjs::Result<u64> {
3423        let id = {
3424            let mut id_ref = self.next_request_id.borrow_mut();
3425            let id = *id_ref;
3426            *id_ref += 1;
3427            // Record context for this callback
3428            self.callback_contexts
3429                .borrow_mut()
3430                .insert(id, self.plugin_name.clone());
3431            id
3432        };
3433
3434        // Convert JsTextPropertyEntry to TextPropertyEntry
3435        let entries: Vec<TextPropertyEntry> = opts
3436            .entries
3437            .unwrap_or_default()
3438            .into_iter()
3439            .map(|e| TextPropertyEntry {
3440                text: e.text,
3441                properties: e.properties.unwrap_or_default(),
3442                style: e.style,
3443                inline_overlays: e.inline_overlays.unwrap_or_default(),
3444            })
3445            .collect();
3446
3447        // Track request_id → plugin_name for async resource tracking
3448        if let Ok(mut owners) = self.async_resource_owners.lock() {
3449            owners.insert(id, self.plugin_name.clone());
3450        }
3451        let _ = self
3452            .command_sender
3453            .send(PluginCommand::CreateVirtualBufferInSplit {
3454                name: opts.name,
3455                mode: opts.mode.unwrap_or_default(),
3456                read_only: opts.read_only.unwrap_or(false),
3457                entries,
3458                ratio: opts.ratio.unwrap_or(0.5),
3459                direction: opts.direction,
3460                panel_id: opts.panel_id,
3461                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3462                show_cursors: opts.show_cursors.unwrap_or(true),
3463                editing_disabled: opts.editing_disabled.unwrap_or(false),
3464                line_wrap: opts.line_wrap,
3465                before: opts.before.unwrap_or(false),
3466                request_id: Some(id),
3467            });
3468        Ok(id)
3469    }
3470
3471    /// Create a virtual buffer in an existing split (async, returns buffer and split IDs)
3472    #[plugin_api(
3473        async_promise,
3474        js_name = "createVirtualBufferInExistingSplit",
3475        ts_return = "VirtualBufferResult"
3476    )]
3477    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
3478    pub fn create_virtual_buffer_in_existing_split_start(
3479        &self,
3480        _ctx: rquickjs::Ctx<'_>,
3481        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
3482    ) -> rquickjs::Result<u64> {
3483        let id = {
3484            let mut id_ref = self.next_request_id.borrow_mut();
3485            let id = *id_ref;
3486            *id_ref += 1;
3487            // Record context for this callback
3488            self.callback_contexts
3489                .borrow_mut()
3490                .insert(id, self.plugin_name.clone());
3491            id
3492        };
3493
3494        // Convert JsTextPropertyEntry to TextPropertyEntry
3495        let entries: Vec<TextPropertyEntry> = opts
3496            .entries
3497            .unwrap_or_default()
3498            .into_iter()
3499            .map(|e| TextPropertyEntry {
3500                text: e.text,
3501                properties: e.properties.unwrap_or_default(),
3502                style: e.style,
3503                inline_overlays: e.inline_overlays.unwrap_or_default(),
3504            })
3505            .collect();
3506
3507        // Track request_id → plugin_name for async resource tracking
3508        if let Ok(mut owners) = self.async_resource_owners.lock() {
3509            owners.insert(id, self.plugin_name.clone());
3510        }
3511        let _ = self
3512            .command_sender
3513            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
3514                name: opts.name,
3515                mode: opts.mode.unwrap_or_default(),
3516                read_only: opts.read_only.unwrap_or(false),
3517                entries,
3518                split_id: SplitId(opts.split_id),
3519                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
3520                show_cursors: opts.show_cursors.unwrap_or(true),
3521                editing_disabled: opts.editing_disabled.unwrap_or(false),
3522                line_wrap: opts.line_wrap,
3523                request_id: Some(id),
3524            });
3525        Ok(id)
3526    }
3527
3528    /// Create a buffer group: multiple panels appearing as one tab
3529    #[qjs(rename = "_createBufferGroupStart")]
3530    pub fn create_buffer_group_start(
3531        &self,
3532        _ctx: rquickjs::Ctx<'_>,
3533        name: String,
3534        mode: String,
3535        layout_json: String,
3536    ) -> rquickjs::Result<u64> {
3537        let id = {
3538            let mut id_ref = self.next_request_id.borrow_mut();
3539            let id = *id_ref;
3540            *id_ref += 1;
3541            self.callback_contexts
3542                .borrow_mut()
3543                .insert(id, self.plugin_name.clone());
3544            id
3545        };
3546        if let Ok(mut owners) = self.async_resource_owners.lock() {
3547            owners.insert(id, self.plugin_name.clone());
3548        }
3549        let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
3550            name,
3551            mode,
3552            layout_json,
3553            request_id: Some(id),
3554        });
3555        Ok(id)
3556    }
3557
3558    /// Set the content of a panel within a buffer group
3559    #[qjs(rename = "setPanelContent")]
3560    pub fn set_panel_content<'js>(
3561        &self,
3562        ctx: rquickjs::Ctx<'js>,
3563        group_id: u32,
3564        panel_name: String,
3565        entries_arr: Vec<rquickjs::Object<'js>>,
3566    ) -> rquickjs::Result<bool> {
3567        let entries: Vec<TextPropertyEntry> = entries_arr
3568            .iter()
3569            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3570            .collect();
3571        Ok(self
3572            .command_sender
3573            .send(PluginCommand::SetPanelContent {
3574                group_id: group_id as usize,
3575                panel_name,
3576                entries,
3577            })
3578            .is_ok())
3579    }
3580
3581    /// Close a buffer group
3582    #[qjs(rename = "closeBufferGroup")]
3583    pub fn close_buffer_group(&self, group_id: u32) -> bool {
3584        self.command_sender
3585            .send(PluginCommand::CloseBufferGroup {
3586                group_id: group_id as usize,
3587            })
3588            .is_ok()
3589    }
3590
3591    /// Focus a specific panel within a buffer group
3592    #[qjs(rename = "focusBufferGroupPanel")]
3593    pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
3594        self.command_sender
3595            .send(PluginCommand::FocusPanel {
3596                group_id: group_id as usize,
3597                panel_name,
3598            })
3599            .is_ok()
3600    }
3601
3602    /// Set virtual buffer content (takes array of entry objects)
3603    ///
3604    /// Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
3605    pub fn set_virtual_buffer_content<'js>(
3606        &self,
3607        ctx: rquickjs::Ctx<'js>,
3608        buffer_id: u32,
3609        entries_arr: Vec<rquickjs::Object<'js>>,
3610    ) -> rquickjs::Result<bool> {
3611        let entries: Vec<TextPropertyEntry> = entries_arr
3612            .iter()
3613            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
3614            .collect();
3615        Ok(self
3616            .command_sender
3617            .send(PluginCommand::SetVirtualBufferContent {
3618                buffer_id: BufferId(buffer_id as usize),
3619                entries,
3620            })
3621            .is_ok())
3622    }
3623
3624    /// Get text properties at cursor position (returns JS array)
3625    pub fn get_text_properties_at_cursor(
3626        &self,
3627        buffer_id: u32,
3628    ) -> fresh_core::api::TextPropertiesAtCursor {
3629        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
3630    }
3631
3632    // === Async Operations ===
3633
3634    /// Spawn a process (async, returns request_id)
3635    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
3636    #[qjs(rename = "_spawnProcessStart")]
3637    pub fn spawn_process_start(
3638        &self,
3639        _ctx: rquickjs::Ctx<'_>,
3640        command: String,
3641        args: Vec<String>,
3642        cwd: rquickjs::function::Opt<String>,
3643    ) -> u64 {
3644        let id = {
3645            let mut id_ref = self.next_request_id.borrow_mut();
3646            let id = *id_ref;
3647            *id_ref += 1;
3648            // Record context for this callback
3649            self.callback_contexts
3650                .borrow_mut()
3651                .insert(id, self.plugin_name.clone());
3652            id
3653        };
3654        // Use provided cwd, or fall back to snapshot's working_dir
3655        let effective_cwd = cwd.0.or_else(|| {
3656            self.state_snapshot
3657                .read()
3658                .ok()
3659                .map(|s| s.working_dir.to_string_lossy().to_string())
3660        });
3661        tracing::info!(
3662            "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
3663            self.plugin_name,
3664            command,
3665            args,
3666            effective_cwd,
3667            id
3668        );
3669        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
3670            callback_id: JsCallbackId::new(id),
3671            command,
3672            args,
3673            cwd: effective_cwd,
3674        });
3675        id
3676    }
3677
3678    /// Wait for a process to complete and get its result (async)
3679    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
3680    #[qjs(rename = "_spawnProcessWaitStart")]
3681    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
3682        let id = {
3683            let mut id_ref = self.next_request_id.borrow_mut();
3684            let id = *id_ref;
3685            *id_ref += 1;
3686            // Record context for this callback
3687            self.callback_contexts
3688                .borrow_mut()
3689                .insert(id, self.plugin_name.clone());
3690            id
3691        };
3692        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
3693            process_id,
3694            callback_id: JsCallbackId::new(id),
3695        });
3696        id
3697    }
3698
3699    /// Get buffer text range (async, returns request_id)
3700    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
3701    #[qjs(rename = "_getBufferTextStart")]
3702    pub fn get_buffer_text_start(
3703        &self,
3704        _ctx: rquickjs::Ctx<'_>,
3705        buffer_id: u32,
3706        start: u32,
3707        end: u32,
3708    ) -> u64 {
3709        let id = {
3710            let mut id_ref = self.next_request_id.borrow_mut();
3711            let id = *id_ref;
3712            *id_ref += 1;
3713            // Record context for this callback
3714            self.callback_contexts
3715                .borrow_mut()
3716                .insert(id, self.plugin_name.clone());
3717            id
3718        };
3719        let _ = self.command_sender.send(PluginCommand::GetBufferText {
3720            buffer_id: BufferId(buffer_id as usize),
3721            start: start as usize,
3722            end: end as usize,
3723            request_id: id,
3724        });
3725        id
3726    }
3727
3728    /// Delay/sleep (async, returns request_id)
3729    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
3730    #[qjs(rename = "_delayStart")]
3731    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
3732        let id = {
3733            let mut id_ref = self.next_request_id.borrow_mut();
3734            let id = *id_ref;
3735            *id_ref += 1;
3736            // Record context for this callback
3737            self.callback_contexts
3738                .borrow_mut()
3739                .insert(id, self.plugin_name.clone());
3740            id
3741        };
3742        let _ = self.command_sender.send(PluginCommand::Delay {
3743            callback_id: JsCallbackId::new(id),
3744            duration_ms,
3745        });
3746        id
3747    }
3748
3749    /// Project-wide grep search (async)
3750    /// Searches all files in the project, respecting .gitignore.
3751    /// Open buffers with dirty edits are searched in-memory.
3752    #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
3753    #[qjs(rename = "_grepProjectStart")]
3754    pub fn grep_project_start(
3755        &self,
3756        _ctx: rquickjs::Ctx<'_>,
3757        pattern: String,
3758        fixed_string: Option<bool>,
3759        case_sensitive: Option<bool>,
3760        max_results: Option<u32>,
3761        whole_words: Option<bool>,
3762    ) -> u64 {
3763        let id = {
3764            let mut id_ref = self.next_request_id.borrow_mut();
3765            let id = *id_ref;
3766            *id_ref += 1;
3767            self.callback_contexts
3768                .borrow_mut()
3769                .insert(id, self.plugin_name.clone());
3770            id
3771        };
3772        let _ = self.command_sender.send(PluginCommand::GrepProject {
3773            pattern,
3774            fixed_string: fixed_string.unwrap_or(true),
3775            case_sensitive: case_sensitive.unwrap_or(true),
3776            max_results: max_results.unwrap_or(200) as usize,
3777            whole_words: whole_words.unwrap_or(false),
3778            callback_id: JsCallbackId::new(id),
3779        });
3780        id
3781    }
3782
3783    /// Streaming project-wide grep search
3784    /// Returns a thenable with a searchId property. The progressCallback is called
3785    /// with batches of matches as they are found.
3786    #[plugin_api(
3787        js_name = "grepProjectStreaming",
3788        ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
3789    )]
3790    #[qjs(rename = "_grepProjectStreamingStart")]
3791    pub fn grep_project_streaming_start(
3792        &self,
3793        _ctx: rquickjs::Ctx<'_>,
3794        pattern: String,
3795        fixed_string: bool,
3796        case_sensitive: bool,
3797        max_results: u32,
3798        whole_words: bool,
3799    ) -> u64 {
3800        let id = {
3801            let mut id_ref = self.next_request_id.borrow_mut();
3802            let id = *id_ref;
3803            *id_ref += 1;
3804            self.callback_contexts
3805                .borrow_mut()
3806                .insert(id, self.plugin_name.clone());
3807            id
3808        };
3809        let _ = self
3810            .command_sender
3811            .send(PluginCommand::GrepProjectStreaming {
3812                pattern,
3813                fixed_string,
3814                case_sensitive,
3815                max_results: max_results as usize,
3816                whole_words,
3817                search_id: id,
3818                callback_id: JsCallbackId::new(id),
3819            });
3820        id
3821    }
3822
3823    /// Replace matches in a file's buffer (async)
3824    /// Opens the file if not already in a buffer, applies edits via the buffer model,
3825    /// and saves. All edits are grouped as a single undo action.
3826    #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
3827    #[qjs(rename = "_replaceInFileStart")]
3828    pub fn replace_in_file_start(
3829        &self,
3830        _ctx: rquickjs::Ctx<'_>,
3831        file_path: String,
3832        matches: Vec<Vec<u32>>,
3833        replacement: String,
3834    ) -> u64 {
3835        let id = {
3836            let mut id_ref = self.next_request_id.borrow_mut();
3837            let id = *id_ref;
3838            *id_ref += 1;
3839            self.callback_contexts
3840                .borrow_mut()
3841                .insert(id, self.plugin_name.clone());
3842            id
3843        };
3844        // Convert [[offset, length], ...] to Vec<(usize, usize)>
3845        let match_pairs: Vec<(usize, usize)> = matches
3846            .iter()
3847            .map(|m| (m[0] as usize, m[1] as usize))
3848            .collect();
3849        let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
3850            file_path: PathBuf::from(file_path),
3851            matches: match_pairs,
3852            replacement,
3853            callback_id: JsCallbackId::new(id),
3854        });
3855        id
3856    }
3857
3858    /// Send LSP request (async, returns request_id)
3859    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
3860    #[qjs(rename = "_sendLspRequestStart")]
3861    pub fn send_lsp_request_start<'js>(
3862        &self,
3863        ctx: rquickjs::Ctx<'js>,
3864        language: String,
3865        method: String,
3866        params: Option<rquickjs::Object<'js>>,
3867    ) -> rquickjs::Result<u64> {
3868        let id = {
3869            let mut id_ref = self.next_request_id.borrow_mut();
3870            let id = *id_ref;
3871            *id_ref += 1;
3872            // Record context for this callback
3873            self.callback_contexts
3874                .borrow_mut()
3875                .insert(id, self.plugin_name.clone());
3876            id
3877        };
3878        // Convert params object to serde_json::Value
3879        let params_json: Option<serde_json::Value> = params.map(|obj| {
3880            let val = obj.into_value();
3881            js_to_json(&ctx, val)
3882        });
3883        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
3884            request_id: id,
3885            language,
3886            method,
3887            params: params_json,
3888        });
3889        Ok(id)
3890    }
3891
3892    /// Spawn a background process (async, returns request_id which is also process_id)
3893    #[plugin_api(
3894        async_thenable,
3895        js_name = "spawnBackgroundProcess",
3896        ts_return = "BackgroundProcessResult"
3897    )]
3898    #[qjs(rename = "_spawnBackgroundProcessStart")]
3899    pub fn spawn_background_process_start(
3900        &self,
3901        _ctx: rquickjs::Ctx<'_>,
3902        command: String,
3903        args: Vec<String>,
3904        cwd: rquickjs::function::Opt<String>,
3905    ) -> u64 {
3906        let id = {
3907            let mut id_ref = self.next_request_id.borrow_mut();
3908            let id = *id_ref;
3909            *id_ref += 1;
3910            // Record context for this callback
3911            self.callback_contexts
3912                .borrow_mut()
3913                .insert(id, self.plugin_name.clone());
3914            id
3915        };
3916        // Use id as process_id for simplicity
3917        let process_id = id;
3918        // Track process ID for cleanup on unload
3919        self.plugin_tracked_state
3920            .borrow_mut()
3921            .entry(self.plugin_name.clone())
3922            .or_default()
3923            .background_process_ids
3924            .push(process_id);
3925        let _ = self
3926            .command_sender
3927            .send(PluginCommand::SpawnBackgroundProcess {
3928                process_id,
3929                command,
3930                args,
3931                cwd: cwd.0,
3932                callback_id: JsCallbackId::new(id),
3933            });
3934        id
3935    }
3936
3937    /// Kill a background process
3938    pub fn kill_background_process(&self, process_id: u64) -> bool {
3939        self.command_sender
3940            .send(PluginCommand::KillBackgroundProcess { process_id })
3941            .is_ok()
3942    }
3943
3944    // === Terminal ===
3945
3946    /// Create a new terminal in a split (async, returns TerminalResult)
3947    #[plugin_api(
3948        async_promise,
3949        js_name = "createTerminal",
3950        ts_return = "TerminalResult"
3951    )]
3952    #[qjs(rename = "_createTerminalStart")]
3953    pub fn create_terminal_start(
3954        &self,
3955        _ctx: rquickjs::Ctx<'_>,
3956        opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
3957    ) -> rquickjs::Result<u64> {
3958        let id = {
3959            let mut id_ref = self.next_request_id.borrow_mut();
3960            let id = *id_ref;
3961            *id_ref += 1;
3962            self.callback_contexts
3963                .borrow_mut()
3964                .insert(id, self.plugin_name.clone());
3965            id
3966        };
3967
3968        let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
3969            cwd: None,
3970            direction: None,
3971            ratio: None,
3972            focus: None,
3973        });
3974
3975        // Track request_id → plugin_name for async resource tracking
3976        if let Ok(mut owners) = self.async_resource_owners.lock() {
3977            owners.insert(id, self.plugin_name.clone());
3978        }
3979        let _ = self.command_sender.send(PluginCommand::CreateTerminal {
3980            cwd: opts.cwd,
3981            direction: opts.direction,
3982            ratio: opts.ratio,
3983            focus: opts.focus,
3984            request_id: id,
3985        });
3986        Ok(id)
3987    }
3988
3989    /// Send input data to a terminal
3990    pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
3991        self.command_sender
3992            .send(PluginCommand::SendTerminalInput {
3993                terminal_id: fresh_core::TerminalId(terminal_id as usize),
3994                data,
3995            })
3996            .is_ok()
3997    }
3998
3999    /// Close a terminal
4000    pub fn close_terminal(&self, terminal_id: u64) -> bool {
4001        self.command_sender
4002            .send(PluginCommand::CloseTerminal {
4003                terminal_id: fresh_core::TerminalId(terminal_id as usize),
4004            })
4005            .is_ok()
4006    }
4007
4008    // === Misc ===
4009
4010    /// Force refresh of line display
4011    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
4012        self.command_sender
4013            .send(PluginCommand::RefreshLines {
4014                buffer_id: BufferId(buffer_id as usize),
4015            })
4016            .is_ok()
4017    }
4018
4019    /// Get the current locale
4020    pub fn get_current_locale(&self) -> String {
4021        self.services.current_locale()
4022    }
4023
4024    // === Plugin Management ===
4025
4026    /// Load a plugin from a file path (async)
4027    #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
4028    #[qjs(rename = "_loadPluginStart")]
4029    pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
4030        let id = {
4031            let mut id_ref = self.next_request_id.borrow_mut();
4032            let id = *id_ref;
4033            *id_ref += 1;
4034            self.callback_contexts
4035                .borrow_mut()
4036                .insert(id, self.plugin_name.clone());
4037            id
4038        };
4039        let _ = self.command_sender.send(PluginCommand::LoadPlugin {
4040            path: std::path::PathBuf::from(path),
4041            callback_id: JsCallbackId::new(id),
4042        });
4043        id
4044    }
4045
4046    /// Unload a plugin by name (async)
4047    #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
4048    #[qjs(rename = "_unloadPluginStart")]
4049    pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4050        let id = {
4051            let mut id_ref = self.next_request_id.borrow_mut();
4052            let id = *id_ref;
4053            *id_ref += 1;
4054            self.callback_contexts
4055                .borrow_mut()
4056                .insert(id, self.plugin_name.clone());
4057            id
4058        };
4059        let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
4060            name,
4061            callback_id: JsCallbackId::new(id),
4062        });
4063        id
4064    }
4065
4066    /// Reload a plugin by name (async)
4067    #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
4068    #[qjs(rename = "_reloadPluginStart")]
4069    pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
4070        let id = {
4071            let mut id_ref = self.next_request_id.borrow_mut();
4072            let id = *id_ref;
4073            *id_ref += 1;
4074            self.callback_contexts
4075                .borrow_mut()
4076                .insert(id, self.plugin_name.clone());
4077            id
4078        };
4079        let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
4080            name,
4081            callback_id: JsCallbackId::new(id),
4082        });
4083        id
4084    }
4085
4086    /// List all loaded plugins (async)
4087    /// Returns array of { name: string, path: string, enabled: boolean }
4088    #[plugin_api(
4089        async_promise,
4090        js_name = "listPlugins",
4091        ts_return = "Array<{name: string, path: string, enabled: boolean}>"
4092    )]
4093    #[qjs(rename = "_listPluginsStart")]
4094    pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4095        let id = {
4096            let mut id_ref = self.next_request_id.borrow_mut();
4097            let id = *id_ref;
4098            *id_ref += 1;
4099            self.callback_contexts
4100                .borrow_mut()
4101                .insert(id, self.plugin_name.clone());
4102            id
4103        };
4104        let _ = self.command_sender.send(PluginCommand::ListPlugins {
4105            callback_id: JsCallbackId::new(id),
4106        });
4107        id
4108    }
4109}
4110
4111// =============================================================================
4112// View Token Parsing Helpers
4113// =============================================================================
4114
4115/// Parse a single view token from JS object
4116/// Supports both simple format and TypeScript format
4117fn parse_view_token(
4118    obj: &rquickjs::Object<'_>,
4119    idx: usize,
4120) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
4121    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
4122
4123    // Try to get the 'kind' field - could be string or object
4124    let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
4125        from: "object",
4126        to: "ViewTokenWire",
4127        message: Some(format!("token[{}]: missing required field 'kind'", idx)),
4128    })?;
4129
4130    // Parse source_offset - try both camelCase and snake_case
4131    let source_offset: Option<usize> = obj
4132        .get("sourceOffset")
4133        .ok()
4134        .or_else(|| obj.get("source_offset").ok());
4135
4136    // Parse the kind field - support both formats
4137    let kind = if kind_value.is_string() {
4138        // Simple format: kind is a string like "text", "newline", etc.
4139        // OR TypeScript format for non-text: "Newline", "Space", "Break"
4140        let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4141            from: "value",
4142            to: "string",
4143            message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
4144        })?;
4145
4146        match kind_str.to_lowercase().as_str() {
4147            "text" => {
4148                let text: String = obj.get("text").unwrap_or_default();
4149                ViewTokenWireKind::Text(text)
4150            }
4151            "newline" => ViewTokenWireKind::Newline,
4152            "space" => ViewTokenWireKind::Space,
4153            "break" => ViewTokenWireKind::Break,
4154            _ => {
4155                // Unknown kind string - log warning and return error
4156                tracing::warn!(
4157                    "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
4158                    idx, kind_str
4159                );
4160                return Err(rquickjs::Error::FromJs {
4161                    from: "string",
4162                    to: "ViewTokenWireKind",
4163                    message: Some(format!(
4164                        "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
4165                        idx, kind_str
4166                    )),
4167                });
4168            }
4169        }
4170    } else if kind_value.is_object() {
4171        // TypeScript format: kind is an object like {Text: "..."} or {BinaryByte: N}
4172        let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
4173            from: "value",
4174            to: "object",
4175            message: Some(format!("token[{}]: 'kind' is not an object", idx)),
4176        })?;
4177
4178        if let Ok(text) = kind_obj.get::<_, String>("Text") {
4179            ViewTokenWireKind::Text(text)
4180        } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
4181            ViewTokenWireKind::BinaryByte(byte)
4182        } else {
4183            // Check what keys are present for a helpful error
4184            let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
4185            tracing::warn!(
4186                "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
4187                idx,
4188                keys
4189            );
4190            return Err(rquickjs::Error::FromJs {
4191                from: "object",
4192                to: "ViewTokenWireKind",
4193                message: Some(format!(
4194                    "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
4195                    idx, keys
4196                )),
4197            });
4198        }
4199    } else {
4200        tracing::warn!(
4201            "token[{}]: 'kind' field must be a string or object, got: {:?}",
4202            idx,
4203            kind_value.type_of()
4204        );
4205        return Err(rquickjs::Error::FromJs {
4206            from: "value",
4207            to: "ViewTokenWireKind",
4208            message: Some(format!(
4209                "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
4210                idx
4211            )),
4212        });
4213    };
4214
4215    // Parse style if present
4216    let style = parse_view_token_style(obj, idx)?;
4217
4218    Ok(ViewTokenWire {
4219        source_offset,
4220        kind,
4221        style,
4222    })
4223}
4224
4225/// Parse optional style from a token object
4226fn parse_view_token_style(
4227    obj: &rquickjs::Object<'_>,
4228    idx: usize,
4229) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
4230    use fresh_core::api::ViewTokenStyle;
4231
4232    let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
4233    let Some(s) = style_obj else {
4234        return Ok(None);
4235    };
4236
4237    let fg: Option<Vec<u8>> = s.get("fg").ok();
4238    let bg: Option<Vec<u8>> = s.get("bg").ok();
4239
4240    // Validate color arrays
4241    let fg_color = if let Some(ref c) = fg {
4242        if c.len() < 3 {
4243            tracing::warn!(
4244                "token[{}]: style.fg has {} elements, expected 3 (RGB)",
4245                idx,
4246                c.len()
4247            );
4248            None
4249        } else {
4250            Some((c[0], c[1], c[2]))
4251        }
4252    } else {
4253        None
4254    };
4255
4256    let bg_color = if let Some(ref c) = bg {
4257        if c.len() < 3 {
4258            tracing::warn!(
4259                "token[{}]: style.bg has {} elements, expected 3 (RGB)",
4260                idx,
4261                c.len()
4262            );
4263            None
4264        } else {
4265            Some((c[0], c[1], c[2]))
4266        }
4267    } else {
4268        None
4269    };
4270
4271    Ok(Some(ViewTokenStyle {
4272        fg: fg_color,
4273        bg: bg_color,
4274        bold: s.get("bold").unwrap_or(false),
4275        italic: s.get("italic").unwrap_or(false),
4276    }))
4277}
4278
4279/// QuickJS-based JavaScript runtime for plugins
4280pub struct QuickJsBackend {
4281    runtime: Runtime,
4282    /// Main context for shared/internal operations
4283    main_context: Context,
4284    /// Plugin-specific contexts: plugin_name -> Context
4285    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
4286    /// Event handlers: event_name -> list of PluginHandler
4287    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
4288    /// Registered actions: action_name -> PluginHandler
4289    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
4290    /// Editor state snapshot (read-only access)
4291    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4292    /// Command sender for write operations
4293    command_sender: mpsc::Sender<PluginCommand>,
4294    /// Pending response senders for async operations (held to keep Arc alive)
4295    #[allow(dead_code)]
4296    pending_responses: PendingResponses,
4297    /// Next request ID for async operations
4298    next_request_id: Rc<RefCell<u64>>,
4299    /// Plugin name for each pending callback ID
4300    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
4301    /// Bridge for editor services (i18n, theme, etc.)
4302    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4303    /// Per-plugin tracking of created state (namespaces, IDs) for cleanup on unload
4304    pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
4305    /// Shared map of request_id → plugin_name for async resource creations.
4306    /// Used by PluginThreadHandle to track buffer/terminal IDs when responses arrive.
4307    async_resource_owners: AsyncResourceOwners,
4308    /// Tracks command name → owning plugin name (first-writer-wins collision detection)
4309    registered_command_names: Rc<RefCell<HashMap<String, String>>>,
4310    /// Tracks grammar language → owning plugin name (first-writer-wins)
4311    registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
4312    /// Tracks language config language → owning plugin name (first-writer-wins)
4313    registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
4314    /// Tracks LSP server language → owning plugin name (first-writer-wins)
4315    registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
4316}
4317
4318impl QuickJsBackend {
4319    /// Create a new QuickJS backend (standalone, for testing)
4320    pub fn new() -> Result<Self> {
4321        let (tx, _rx) = mpsc::channel();
4322        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4323        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4324        Self::with_state(state_snapshot, tx, services)
4325    }
4326
4327    /// Create a new QuickJS backend with editor state
4328    pub fn with_state(
4329        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4330        command_sender: mpsc::Sender<PluginCommand>,
4331        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4332    ) -> Result<Self> {
4333        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
4334        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
4335    }
4336
4337    /// Create a new QuickJS backend with editor state and shared pending responses
4338    pub fn with_state_and_responses(
4339        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4340        command_sender: mpsc::Sender<PluginCommand>,
4341        pending_responses: PendingResponses,
4342        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4343    ) -> Result<Self> {
4344        let async_resource_owners: AsyncResourceOwners =
4345            Arc::new(std::sync::Mutex::new(HashMap::new()));
4346        Self::with_state_responses_and_resources(
4347            state_snapshot,
4348            command_sender,
4349            pending_responses,
4350            services,
4351            async_resource_owners,
4352        )
4353    }
4354
4355    /// Create a new QuickJS backend with editor state, shared pending responses,
4356    /// and a shared async resource owner map
4357    pub fn with_state_responses_and_resources(
4358        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
4359        command_sender: mpsc::Sender<PluginCommand>,
4360        pending_responses: PendingResponses,
4361        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
4362        async_resource_owners: AsyncResourceOwners,
4363    ) -> Result<Self> {
4364        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
4365
4366        let runtime =
4367            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
4368
4369        // Set up promise rejection tracker to catch unhandled rejections
4370        runtime.set_host_promise_rejection_tracker(Some(Box::new(
4371            |_ctx, _promise, reason, is_handled| {
4372                if !is_handled {
4373                    // Format the rejection reason
4374                    let error_msg = if let Some(exc) = reason.as_exception() {
4375                        format!(
4376                            "{}: {}",
4377                            exc.message().unwrap_or_default(),
4378                            exc.stack().unwrap_or_default()
4379                        )
4380                    } else {
4381                        format!("{:?}", reason)
4382                    };
4383
4384                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
4385
4386                    if should_panic_on_js_errors() {
4387                        // Don't panic here - we're inside an FFI callback and rquickjs catches panics.
4388                        // Instead, set a fatal error flag that the plugin thread loop will check.
4389                        let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
4390                        set_fatal_js_error(full_msg);
4391                    }
4392                }
4393            },
4394        )));
4395
4396        let main_context = Context::full(&runtime)
4397            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
4398
4399        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
4400        let event_handlers = Rc::new(RefCell::new(HashMap::new()));
4401        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
4402        let next_request_id = Rc::new(RefCell::new(1u64));
4403        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
4404        let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
4405        let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
4406        let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
4407        let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
4408        let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
4409
4410        let backend = Self {
4411            runtime,
4412            main_context,
4413            plugin_contexts,
4414            event_handlers,
4415            registered_actions,
4416            state_snapshot,
4417            command_sender,
4418            pending_responses,
4419            next_request_id,
4420            callback_contexts,
4421            services,
4422            plugin_tracked_state,
4423            async_resource_owners,
4424            registered_command_names,
4425            registered_grammar_languages,
4426            registered_language_configs,
4427            registered_lsp_servers,
4428        };
4429
4430        // Initialize main context (for internal utilities if needed)
4431        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
4432
4433        tracing::debug!("QuickJsBackend::new: runtime created successfully");
4434        Ok(backend)
4435    }
4436
4437    /// Set up the editor API in a specific JavaScript context
4438    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
4439        let state_snapshot = Arc::clone(&self.state_snapshot);
4440        let command_sender = self.command_sender.clone();
4441        let event_handlers = Rc::clone(&self.event_handlers);
4442        let registered_actions = Rc::clone(&self.registered_actions);
4443        let next_request_id = Rc::clone(&self.next_request_id);
4444        let registered_command_names = Rc::clone(&self.registered_command_names);
4445        let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
4446        let registered_language_configs = Rc::clone(&self.registered_language_configs);
4447        let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
4448
4449        context.with(|ctx| {
4450            let globals = ctx.globals();
4451
4452            // Set the plugin name global
4453            globals.set("__pluginName__", plugin_name)?;
4454
4455            // Create the editor object using JsEditorApi class
4456            // This provides proper lifetime handling for methods returning JS values
4457            let js_api = JsEditorApi {
4458                state_snapshot: Arc::clone(&state_snapshot),
4459                command_sender: command_sender.clone(),
4460                registered_actions: Rc::clone(&registered_actions),
4461                event_handlers: Rc::clone(&event_handlers),
4462                next_request_id: Rc::clone(&next_request_id),
4463                callback_contexts: Rc::clone(&self.callback_contexts),
4464                services: self.services.clone(),
4465                plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
4466                async_resource_owners: Arc::clone(&self.async_resource_owners),
4467                registered_command_names: Rc::clone(&registered_command_names),
4468                registered_grammar_languages: Rc::clone(&registered_grammar_languages),
4469                registered_language_configs: Rc::clone(&registered_language_configs),
4470                registered_lsp_servers: Rc::clone(&registered_lsp_servers),
4471                plugin_name: plugin_name.to_string(),
4472            };
4473            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
4474
4475            // All methods are now in JsEditorApi - export editor as global
4476            globals.set("editor", editor)?;
4477
4478            // Define getEditor() globally
4479            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
4480
4481            // Define registerHandler() for strict-mode-compatible handler registration
4482            ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
4483
4484            // Provide console.log for debugging
4485            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
4486            let console = Object::new(ctx.clone())?;
4487            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4488                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4489                tracing::info!("console.log: {}", parts.join(" "));
4490            })?)?;
4491            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4492                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4493                tracing::warn!("console.warn: {}", parts.join(" "));
4494            })?)?;
4495            console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
4496                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
4497                tracing::error!("console.error: {}", parts.join(" "));
4498            })?)?;
4499            globals.set("console", console)?;
4500
4501            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
4502            ctx.eval::<(), _>(r#"
4503                // Pending promise callbacks: callbackId -> { resolve, reject }
4504                globalThis._pendingCallbacks = new Map();
4505
4506                // Resolve a pending callback (called from Rust)
4507                globalThis._resolveCallback = function(callbackId, result) {
4508                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
4509                    const cb = globalThis._pendingCallbacks.get(callbackId);
4510                    if (cb) {
4511                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
4512                        globalThis._pendingCallbacks.delete(callbackId);
4513                        cb.resolve(result);
4514                        console.log('[JS] _resolveCallback: resolve() called');
4515                    } else {
4516                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
4517                    }
4518                };
4519
4520                // Reject a pending callback (called from Rust)
4521                globalThis._rejectCallback = function(callbackId, error) {
4522                    const cb = globalThis._pendingCallbacks.get(callbackId);
4523                    if (cb) {
4524                        globalThis._pendingCallbacks.delete(callbackId);
4525                        cb.reject(new Error(error));
4526                    }
4527                };
4528
4529                // Streaming callbacks: called multiple times with partial results
4530                globalThis._streamingCallbacks = new Map();
4531
4532                // Called from Rust with partial data. When done=true, cleans up.
4533                globalThis._callStreamingCallback = function(callbackId, result, done) {
4534                    const cb = globalThis._streamingCallbacks.get(callbackId);
4535                    if (cb) {
4536                        cb(result, done);
4537                        if (done) {
4538                            globalThis._streamingCallbacks.delete(callbackId);
4539                        }
4540                    }
4541                };
4542
4543                // Generic async wrapper decorator
4544                // Wraps a function that returns a callbackId into a promise-returning function
4545                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
4546                // NOTE: We pass the method name as a string and call via bracket notation
4547                // to preserve rquickjs's automatic Ctx injection for methods
4548                globalThis._wrapAsync = function(methodName, fnName) {
4549                    const startFn = editor[methodName];
4550                    if (typeof startFn !== 'function') {
4551                        // Return a function that always throws - catches missing implementations
4552                        return function(...args) {
4553                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4554                            editor.debug(`[ASYNC ERROR] ${error.message}`);
4555                            throw error;
4556                        };
4557                    }
4558                    return function(...args) {
4559                        // Call via bracket notation to preserve method binding and Ctx injection
4560                        const callbackId = editor[methodName](...args);
4561                        return new Promise((resolve, reject) => {
4562                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4563                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
4564                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4565                        });
4566                    };
4567                };
4568
4569                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
4570                // The returned object has .result promise and is itself thenable
4571                globalThis._wrapAsyncThenable = function(methodName, fnName) {
4572                    const startFn = editor[methodName];
4573                    if (typeof startFn !== 'function') {
4574                        // Return a function that always throws - catches missing implementations
4575                        return function(...args) {
4576                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
4577                            editor.debug(`[ASYNC ERROR] ${error.message}`);
4578                            throw error;
4579                        };
4580                    }
4581                    return function(...args) {
4582                        // Call via bracket notation to preserve method binding and Ctx injection
4583                        const callbackId = editor[methodName](...args);
4584                        const resultPromise = new Promise((resolve, reject) => {
4585                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
4586                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
4587                        });
4588                        return {
4589                            get result() { return resultPromise; },
4590                            then(onFulfilled, onRejected) {
4591                                return resultPromise.then(onFulfilled, onRejected);
4592                            },
4593                            catch(onRejected) {
4594                                return resultPromise.catch(onRejected);
4595                            }
4596                        };
4597                    };
4598                };
4599
4600                // Apply wrappers to async functions on editor
4601                editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
4602                editor.delay = _wrapAsync("_delayStart", "delay");
4603                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
4604                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
4605                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
4606                editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
4607                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
4608                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
4609                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
4610                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
4611                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
4612                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
4613                editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
4614                editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
4615                editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
4616                editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
4617                editor.prompt = _wrapAsync("_promptStart", "prompt");
4618                editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
4619                editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
4620                editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
4621                editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
4622                editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
4623                editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
4624
4625                // Streaming grep: takes a progress callback, returns a thenable with searchId
4626                editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
4627                    opts = opts || {};
4628                    const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
4629                    const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
4630                    const maxResults = opts.maxResults || 10000;
4631                    const wholeWords = opts.wholeWords || false;
4632
4633                    const searchId = editor._grepProjectStreamingStart(
4634                        pattern, fixedString, caseSensitive, maxResults, wholeWords
4635                    );
4636
4637                    // Register streaming callback
4638                    if (progressCallback) {
4639                        globalThis._streamingCallbacks.set(searchId, progressCallback);
4640                    }
4641
4642                    // Create completion promise (resolved via _resolveCallback when search finishes)
4643                    const resultPromise = new Promise(function(resolve, reject) {
4644                        globalThis._pendingCallbacks.set(searchId, {
4645                            resolve: function(result) {
4646                                globalThis._streamingCallbacks.delete(searchId);
4647                                resolve(result);
4648                            },
4649                            reject: function(err) {
4650                                globalThis._streamingCallbacks.delete(searchId);
4651                                reject(err);
4652                            }
4653                        });
4654                    });
4655
4656                    return {
4657                        searchId: searchId,
4658                        get result() { return resultPromise; },
4659                        then: function(f, r) { return resultPromise.then(f, r); },
4660                        catch: function(r) { return resultPromise.catch(r); }
4661                    };
4662                };
4663
4664                // Wrapper for deleteTheme - wraps sync function in Promise
4665                editor.deleteTheme = function(name) {
4666                    return new Promise(function(resolve, reject) {
4667                        const success = editor._deleteThemeSync(name);
4668                        if (success) {
4669                            resolve();
4670                        } else {
4671                            reject(new Error("Failed to delete theme: " + name));
4672                        }
4673                    });
4674                };
4675            "#.as_bytes())?;
4676
4677            Ok::<_, rquickjs::Error>(())
4678        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
4679
4680        Ok(())
4681    }
4682
4683    /// Load and execute a TypeScript/JavaScript plugin from a file path
4684    pub async fn load_module_with_source(
4685        &mut self,
4686        path: &str,
4687        _plugin_source: &str,
4688    ) -> Result<()> {
4689        let path_buf = PathBuf::from(path);
4690        let source = std::fs::read_to_string(&path_buf)
4691            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
4692
4693        let filename = path_buf
4694            .file_name()
4695            .and_then(|s| s.to_str())
4696            .unwrap_or("plugin.ts");
4697
4698        // Check for ES imports - these need bundling to resolve dependencies
4699        if has_es_imports(&source) {
4700            // Try to bundle (this also strips imports and exports)
4701            match bundle_module(&path_buf) {
4702                Ok(bundled) => {
4703                    self.execute_js(&bundled, path)?;
4704                }
4705                Err(e) => {
4706                    tracing::warn!(
4707                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
4708                        path,
4709                        e
4710                    );
4711                    return Ok(()); // Skip plugins with unresolvable imports
4712                }
4713            }
4714        } else if has_es_module_syntax(&source) {
4715            // Has exports but no imports - strip exports and transpile
4716            let stripped = strip_imports_and_exports(&source);
4717            let js_code = if filename.ends_with(".ts") {
4718                transpile_typescript(&stripped, filename)?
4719            } else {
4720                stripped
4721            };
4722            self.execute_js(&js_code, path)?;
4723        } else {
4724            // Plain code - just transpile if TypeScript
4725            let js_code = if filename.ends_with(".ts") {
4726                transpile_typescript(&source, filename)?
4727            } else {
4728                source
4729            };
4730            self.execute_js(&js_code, path)?;
4731        }
4732
4733        Ok(())
4734    }
4735
4736    /// Execute JavaScript code in the context
4737    pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
4738        // Extract plugin name from path (filename without extension)
4739        let plugin_name = Path::new(source_name)
4740            .file_stem()
4741            .and_then(|s| s.to_str())
4742            .unwrap_or("unknown");
4743
4744        tracing::debug!(
4745            "execute_js: starting for plugin '{}' from '{}'",
4746            plugin_name,
4747            source_name
4748        );
4749
4750        // Get or create context for this plugin
4751        let context = {
4752            let mut contexts = self.plugin_contexts.borrow_mut();
4753            if let Some(ctx) = contexts.get(plugin_name) {
4754                ctx.clone()
4755            } else {
4756                let ctx = Context::full(&self.runtime).map_err(|e| {
4757                    anyhow!(
4758                        "Failed to create QuickJS context for plugin {}: {}",
4759                        plugin_name,
4760                        e
4761                    )
4762                })?;
4763                self.setup_context_api(&ctx, plugin_name)?;
4764                contexts.insert(plugin_name.to_string(), ctx.clone());
4765                ctx
4766            }
4767        };
4768
4769        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
4770        // This is critical for plugins like vi_mode that declare `const editor = ...`
4771        // which shadows the global `editor` causing TDZ if not wrapped.
4772        let wrapped_code = format!("(function() {{ {} }})();", code);
4773        let wrapped = wrapped_code.as_str();
4774
4775        context.with(|ctx| {
4776            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
4777
4778            // Execute the plugin code with filename for better stack traces
4779            let mut eval_options = rquickjs::context::EvalOptions::default();
4780            eval_options.global = true;
4781            eval_options.filename = Some(source_name.to_string());
4782            let result = ctx
4783                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
4784                .map_err(|e| format_js_error(&ctx, e, source_name));
4785
4786            tracing::debug!(
4787                "execute_js: plugin code execution finished for '{}', result: {:?}",
4788                plugin_name,
4789                result.is_ok()
4790            );
4791
4792            result
4793        })
4794    }
4795
4796    /// Execute JavaScript source code directly as a plugin (no file I/O).
4797    ///
4798    /// This is the entry point for "load plugin from buffer" — the source code
4799    /// goes through the same transpile/strip pipeline as file-based plugins, but
4800    /// without reading from disk or resolving imports.
4801    pub fn execute_source(
4802        &mut self,
4803        source: &str,
4804        plugin_name: &str,
4805        is_typescript: bool,
4806    ) -> Result<()> {
4807        use fresh_parser_js::{
4808            has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
4809        };
4810
4811        if has_es_imports(source) {
4812            tracing::warn!(
4813                "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
4814                plugin_name
4815            );
4816        }
4817
4818        let js_code = if has_es_module_syntax(source) {
4819            let stripped = strip_imports_and_exports(source);
4820            if is_typescript {
4821                transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
4822            } else {
4823                stripped
4824            }
4825        } else if is_typescript {
4826            transpile_typescript(source, &format!("{}.ts", plugin_name))?
4827        } else {
4828            source.to_string()
4829        };
4830
4831        // Use plugin_name as the source_name so execute_js extracts the right name
4832        let source_name = format!(
4833            "{}.{}",
4834            plugin_name,
4835            if is_typescript { "ts" } else { "js" }
4836        );
4837        self.execute_js(&js_code, &source_name)
4838    }
4839
4840    /// Clean up all runtime state owned by a plugin.
4841    ///
4842    /// This removes the plugin's JS context, event handlers, registered actions,
4843    /// callback contexts, and sends compensating commands to the editor to clear
4844    /// namespaced visual state (overlays, conceals, virtual text, etc.).
4845    pub fn cleanup_plugin(&self, plugin_name: &str) {
4846        // 1. Remove plugin's JS context (CRITICAL — without this, execute_js reuses old context)
4847        self.plugin_contexts.borrow_mut().remove(plugin_name);
4848
4849        // 2. Remove event handlers for this plugin
4850        for handlers in self.event_handlers.borrow_mut().values_mut() {
4851            handlers.retain(|h| h.plugin_name != plugin_name);
4852        }
4853
4854        // 3. Remove registered actions for this plugin
4855        self.registered_actions
4856            .borrow_mut()
4857            .retain(|_, h| h.plugin_name != plugin_name);
4858
4859        // 4. Remove callback contexts for this plugin
4860        self.callback_contexts
4861            .borrow_mut()
4862            .retain(|_, pname| pname != plugin_name);
4863
4864        // 5. Send compensating commands for editor-side state
4865        if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
4866            // Deduplicate (buffer_id, namespace) pairs before sending
4867            let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
4868                std::collections::HashSet::new();
4869            for (buf_id, ns) in &tracked.overlay_namespaces {
4870                if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
4871                    // ClearNamespace clears overlays for this namespace
4872                    let _ = self.command_sender.send(PluginCommand::ClearNamespace {
4873                        buffer_id: *buf_id,
4874                        namespace: OverlayNamespace::from_string(ns.clone()),
4875                    });
4876                    // Also clear conceals and soft breaks (same namespace system)
4877                    let _ = self
4878                        .command_sender
4879                        .send(PluginCommand::ClearConcealNamespace {
4880                            buffer_id: *buf_id,
4881                            namespace: OverlayNamespace::from_string(ns.clone()),
4882                        });
4883                    let _ = self
4884                        .command_sender
4885                        .send(PluginCommand::ClearSoftBreakNamespace {
4886                            buffer_id: *buf_id,
4887                            namespace: OverlayNamespace::from_string(ns.clone()),
4888                        });
4889                }
4890            }
4891
4892            // Note: Virtual lines have no namespace-based clear command in the API.
4893            // They will persist until the buffer is closed. This is acceptable for now
4894            // since most plugins re-create virtual lines on init anyway.
4895
4896            // Clear line indicator namespaces
4897            let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
4898                std::collections::HashSet::new();
4899            for (buf_id, ns) in &tracked.line_indicator_namespaces {
4900                if seen_li_ns.insert((buf_id.0, ns.clone())) {
4901                    let _ = self
4902                        .command_sender
4903                        .send(PluginCommand::ClearLineIndicators {
4904                            buffer_id: *buf_id,
4905                            namespace: ns.clone(),
4906                        });
4907                }
4908            }
4909
4910            // Remove virtual text items
4911            let mut seen_vt: std::collections::HashSet<(usize, String)> =
4912                std::collections::HashSet::new();
4913            for (buf_id, vt_id) in &tracked.virtual_text_ids {
4914                if seen_vt.insert((buf_id.0, vt_id.clone())) {
4915                    let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
4916                        buffer_id: *buf_id,
4917                        virtual_text_id: vt_id.clone(),
4918                    });
4919                }
4920            }
4921
4922            // Clear file explorer decoration namespaces
4923            let mut seen_fe_ns: std::collections::HashSet<String> =
4924                std::collections::HashSet::new();
4925            for ns in &tracked.file_explorer_namespaces {
4926                if seen_fe_ns.insert(ns.clone()) {
4927                    let _ = self
4928                        .command_sender
4929                        .send(PluginCommand::ClearFileExplorerDecorations {
4930                            namespace: ns.clone(),
4931                        });
4932                }
4933            }
4934
4935            // Deactivate contexts set by this plugin
4936            let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
4937            for ctx_name in &tracked.contexts_set {
4938                if seen_ctx.insert(ctx_name.clone()) {
4939                    let _ = self.command_sender.send(PluginCommand::SetContext {
4940                        name: ctx_name.clone(),
4941                        active: false,
4942                    });
4943                }
4944            }
4945
4946            // --- Phase 3: Resource cleanup ---
4947
4948            // Kill background processes spawned by this plugin
4949            for process_id in &tracked.background_process_ids {
4950                let _ = self
4951                    .command_sender
4952                    .send(PluginCommand::KillBackgroundProcess {
4953                        process_id: *process_id,
4954                    });
4955            }
4956
4957            // Remove scroll sync groups created by this plugin
4958            for group_id in &tracked.scroll_sync_group_ids {
4959                let _ = self
4960                    .command_sender
4961                    .send(PluginCommand::RemoveScrollSyncGroup {
4962                        group_id: *group_id,
4963                    });
4964            }
4965
4966            // Close virtual buffers created by this plugin
4967            for buffer_id in &tracked.virtual_buffer_ids {
4968                let _ = self.command_sender.send(PluginCommand::CloseBuffer {
4969                    buffer_id: *buffer_id,
4970                });
4971            }
4972
4973            // Close composite buffers created by this plugin
4974            for buffer_id in &tracked.composite_buffer_ids {
4975                let _ = self
4976                    .command_sender
4977                    .send(PluginCommand::CloseCompositeBuffer {
4978                        buffer_id: *buffer_id,
4979                    });
4980            }
4981
4982            // Close terminals created by this plugin
4983            for terminal_id in &tracked.terminal_ids {
4984                let _ = self.command_sender.send(PluginCommand::CloseTerminal {
4985                    terminal_id: *terminal_id,
4986                });
4987            }
4988        }
4989
4990        // Clean up any pending async resource owner entries for this plugin
4991        if let Ok(mut owners) = self.async_resource_owners.lock() {
4992            owners.retain(|_, name| name != plugin_name);
4993        }
4994
4995        // Clear collision tracking maps so another plugin can re-register these names
4996        self.registered_command_names
4997            .borrow_mut()
4998            .retain(|_, pname| pname != plugin_name);
4999        self.registered_grammar_languages
5000            .borrow_mut()
5001            .retain(|_, pname| pname != plugin_name);
5002        self.registered_language_configs
5003            .borrow_mut()
5004            .retain(|_, pname| pname != plugin_name);
5005        self.registered_lsp_servers
5006            .borrow_mut()
5007            .retain(|_, pname| pname != plugin_name);
5008
5009        tracing::debug!(
5010            "cleanup_plugin: cleaned up runtime state for plugin '{}'",
5011            plugin_name
5012        );
5013    }
5014
5015    /// Emit an event to all registered handlers
5016    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
5017        tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
5018
5019        self.services
5020            .set_js_execution_state(format!("hook '{}'", event_name));
5021
5022        let handlers = self.event_handlers.borrow().get(event_name).cloned();
5023        if let Some(handler_pairs) = handlers {
5024            let plugin_contexts = self.plugin_contexts.borrow();
5025            for handler in &handler_pairs {
5026                let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
5027                    continue;
5028                };
5029                context.with(|ctx| {
5030                    call_handler(&ctx, &handler.handler_name, event_data);
5031                });
5032            }
5033        }
5034
5035        self.services.clear_js_execution_state();
5036        Ok(true)
5037    }
5038
5039    /// Check if any handlers are registered for an event
5040    pub fn has_handlers(&self, event_name: &str) -> bool {
5041        self.event_handlers
5042            .borrow()
5043            .get(event_name)
5044            .map(|v| !v.is_empty())
5045            .unwrap_or(false)
5046    }
5047
5048    /// Start an action without waiting for async operations to complete.
5049    /// This is useful when the calling thread needs to continue processing
5050    /// ResolveCallback requests that the action may be waiting for.
5051    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
5052        // Handle mode_text_input:<char> — route to the plugin that registered
5053        // "mode_text_input" and pass the character as an argument.
5054        let (lookup_name, text_input_char) =
5055            if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
5056                ("mode_text_input", Some(ch.to_string()))
5057            } else {
5058                (action_name, None)
5059            };
5060
5061        let pair = self.registered_actions.borrow().get(lookup_name).cloned();
5062        let (plugin_name, function_name) = match pair {
5063            Some(handler) => (handler.plugin_name, handler.handler_name),
5064            None => ("main".to_string(), lookup_name.to_string()),
5065        };
5066
5067        let plugin_contexts = self.plugin_contexts.borrow();
5068        let context = plugin_contexts
5069            .get(&plugin_name)
5070            .unwrap_or(&self.main_context);
5071
5072        // Track execution state for signal handler debugging
5073        self.services
5074            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
5075
5076        tracing::info!(
5077            "start_action: BEGIN '{}' -> function '{}'",
5078            action_name,
5079            function_name
5080        );
5081
5082        // Just call the function - don't try to await or drive Promises
5083        // For mode_text_input, pass the character as a JSON-encoded argument
5084        let call_args = if let Some(ref ch) = text_input_char {
5085            let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
5086            format!("({{text:\"{}\"}})", escaped)
5087        } else {
5088            "()".to_string()
5089        };
5090
5091        let code = format!(
5092            r#"
5093            (function() {{
5094                console.log('[JS] start_action: calling {fn}');
5095                try {{
5096                    if (typeof globalThis.{fn} === 'function') {{
5097                        console.log('[JS] start_action: {fn} is a function, invoking...');
5098                        globalThis.{fn}{args};
5099                        console.log('[JS] start_action: {fn} invoked (may be async)');
5100                    }} else {{
5101                        console.error('[JS] Action {action} is not defined as a global function');
5102                    }}
5103                }} catch (e) {{
5104                    console.error('[JS] Action {action} error:', e);
5105                }}
5106            }})();
5107            "#,
5108            fn = function_name,
5109            action = action_name,
5110            args = call_args
5111        );
5112
5113        tracing::info!("start_action: evaluating JS code");
5114        context.with(|ctx| {
5115            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5116                log_js_error(&ctx, e, &format!("action {}", action_name));
5117            }
5118            tracing::info!("start_action: running pending microtasks");
5119            // Run any immediate microtasks
5120            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
5121            tracing::info!("start_action: executed {} pending jobs", count);
5122        });
5123
5124        tracing::info!("start_action: END '{}'", action_name);
5125
5126        // Clear execution state (action started, may still be running async)
5127        self.services.clear_js_execution_state();
5128
5129        Ok(())
5130    }
5131
5132    /// Execute a registered action by name
5133    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
5134        // First check if there's a registered command mapping
5135        let pair = self.registered_actions.borrow().get(action_name).cloned();
5136        let (plugin_name, function_name) = match pair {
5137            Some(handler) => (handler.plugin_name, handler.handler_name),
5138            None => ("main".to_string(), action_name.to_string()),
5139        };
5140
5141        let plugin_contexts = self.plugin_contexts.borrow();
5142        let context = plugin_contexts
5143            .get(&plugin_name)
5144            .unwrap_or(&self.main_context);
5145
5146        tracing::debug!(
5147            "execute_action: '{}' -> function '{}'",
5148            action_name,
5149            function_name
5150        );
5151
5152        // Call the function and await if it returns a Promise
5153        // We use a global _executeActionResult to pass the result back
5154        let code = format!(
5155            r#"
5156            (async function() {{
5157                try {{
5158                    if (typeof globalThis.{fn} === 'function') {{
5159                        const result = globalThis.{fn}();
5160                        // If it's a Promise, await it
5161                        if (result && typeof result.then === 'function') {{
5162                            await result;
5163                        }}
5164                    }} else {{
5165                        console.error('Action {action} is not defined as a global function');
5166                    }}
5167                }} catch (e) {{
5168                    console.error('Action {action} error:', e);
5169                }}
5170            }})();
5171            "#,
5172            fn = function_name,
5173            action = action_name
5174        );
5175
5176        context.with(|ctx| {
5177            // Eval returns a Promise for the async IIFE, which we need to drive
5178            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
5179                Ok(value) => {
5180                    // If it's a Promise, we need to drive the runtime to completion
5181                    if value.is_object() {
5182                        if let Some(obj) = value.as_object() {
5183                            // Check if it's a Promise by looking for 'then' method
5184                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
5185                                // Drive the runtime to process the promise
5186                                // QuickJS processes promises synchronously when we call execute_pending_job
5187                                run_pending_jobs_checked(
5188                                    &ctx,
5189                                    &format!("execute_action {} promise", action_name),
5190                                );
5191                            }
5192                        }
5193                    }
5194                }
5195                Err(e) => {
5196                    log_js_error(&ctx, e, &format!("action {}", action_name));
5197                }
5198            }
5199        });
5200
5201        Ok(())
5202    }
5203
5204    /// Poll the event loop once to run any pending microtasks
5205    pub fn poll_event_loop_once(&mut self) -> bool {
5206        let mut had_work = false;
5207
5208        // Poll main context
5209        self.main_context.with(|ctx| {
5210            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
5211            if count > 0 {
5212                had_work = true;
5213            }
5214        });
5215
5216        // Poll all plugin contexts
5217        let contexts = self.plugin_contexts.borrow().clone();
5218        for (name, context) in contexts {
5219            context.with(|ctx| {
5220                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
5221                if count > 0 {
5222                    had_work = true;
5223                }
5224            });
5225        }
5226        had_work
5227    }
5228
5229    /// Send a status message to the editor
5230    pub fn send_status(&self, message: String) {
5231        let _ = self
5232            .command_sender
5233            .send(PluginCommand::SetStatus { message });
5234    }
5235
5236    /// Send a hook-completed sentinel to the editor.
5237    /// This signals that all commands from the hook have been sent,
5238    /// allowing the render loop to wait deterministically.
5239    pub fn send_hook_completed(&self, hook_name: String) {
5240        let _ = self
5241            .command_sender
5242            .send(PluginCommand::HookCompleted { hook_name });
5243    }
5244
5245    /// Resolve a pending async callback with a result (called from Rust when async op completes)
5246    ///
5247    /// Takes a JSON string which is parsed and converted to a proper JS value.
5248    /// This avoids string interpolation with eval for better type safety.
5249    pub fn resolve_callback(
5250        &mut self,
5251        callback_id: fresh_core::api::JsCallbackId,
5252        result_json: &str,
5253    ) {
5254        let id = callback_id.as_u64();
5255        tracing::debug!("resolve_callback: starting for callback_id={}", id);
5256
5257        // Find the plugin name and then context for this callback
5258        let plugin_name = {
5259            let mut contexts = self.callback_contexts.borrow_mut();
5260            contexts.remove(&id)
5261        };
5262
5263        let Some(name) = plugin_name else {
5264            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
5265            return;
5266        };
5267
5268        let plugin_contexts = self.plugin_contexts.borrow();
5269        let Some(context) = plugin_contexts.get(&name) else {
5270            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
5271            return;
5272        };
5273
5274        context.with(|ctx| {
5275            // Parse JSON string to serde_json::Value
5276            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5277                Ok(v) => v,
5278                Err(e) => {
5279                    tracing::error!(
5280                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
5281                        id,
5282                        e
5283                    );
5284                    return;
5285                }
5286            };
5287
5288            // Convert to JS value using rquickjs_serde
5289            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5290                Ok(v) => v,
5291                Err(e) => {
5292                    tracing::error!(
5293                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
5294                        id,
5295                        e
5296                    );
5297                    return;
5298                }
5299            };
5300
5301            // Get _resolveCallback function from globalThis
5302            let globals = ctx.globals();
5303            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
5304                Ok(f) => f,
5305                Err(e) => {
5306                    tracing::error!(
5307                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
5308                        id,
5309                        e
5310                    );
5311                    return;
5312                }
5313            };
5314
5315            // Call the function with callback_id (as u64) and the JS value
5316            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
5317                log_js_error(&ctx, e, &format!("resolving callback {}", id));
5318            }
5319
5320            // IMPORTANT: Run pending jobs to process Promise continuations
5321            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
5322            tracing::info!(
5323                "resolve_callback: executed {} pending jobs for callback_id={}",
5324                job_count,
5325                id
5326            );
5327        });
5328    }
5329
5330    /// Reject a pending async callback with an error (called from Rust when async op fails)
5331    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
5332        let id = callback_id.as_u64();
5333
5334        // Find the plugin name and then context for this callback
5335        let plugin_name = {
5336            let mut contexts = self.callback_contexts.borrow_mut();
5337            contexts.remove(&id)
5338        };
5339
5340        let Some(name) = plugin_name else {
5341            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
5342            return;
5343        };
5344
5345        let plugin_contexts = self.plugin_contexts.borrow();
5346        let Some(context) = plugin_contexts.get(&name) else {
5347            tracing::warn!("reject_callback: Context lost for plugin {}", name);
5348            return;
5349        };
5350
5351        context.with(|ctx| {
5352            // Get _rejectCallback function from globalThis
5353            let globals = ctx.globals();
5354            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
5355                Ok(f) => f,
5356                Err(e) => {
5357                    tracing::error!(
5358                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
5359                        id,
5360                        e
5361                    );
5362                    return;
5363                }
5364            };
5365
5366            // Call the function with callback_id (as u64) and error string
5367            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
5368                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
5369            }
5370
5371            // IMPORTANT: Run pending jobs to process Promise continuations
5372            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
5373        });
5374    }
5375
5376    /// Call a streaming callback with partial data.
5377    /// Unlike resolve_callback, this does NOT remove the callback from the context map.
5378    /// When `done` is true, the JS side cleans up the streaming callback.
5379    pub fn call_streaming_callback(
5380        &mut self,
5381        callback_id: fresh_core::api::JsCallbackId,
5382        result_json: &str,
5383        done: bool,
5384    ) {
5385        let id = callback_id.as_u64();
5386
5387        // Find the plugin name WITHOUT removing it (unlike resolve_callback)
5388        let plugin_name = {
5389            let contexts = self.callback_contexts.borrow();
5390            contexts.get(&id).cloned()
5391        };
5392
5393        let Some(name) = plugin_name else {
5394            tracing::warn!(
5395                "call_streaming_callback: No plugin found for callback_id={}",
5396                id
5397            );
5398            return;
5399        };
5400
5401        // If done, remove the callback context entry
5402        if done {
5403            self.callback_contexts.borrow_mut().remove(&id);
5404        }
5405
5406        let plugin_contexts = self.plugin_contexts.borrow();
5407        let Some(context) = plugin_contexts.get(&name) else {
5408            tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
5409            return;
5410        };
5411
5412        context.with(|ctx| {
5413            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
5414                Ok(v) => v,
5415                Err(e) => {
5416                    tracing::error!(
5417                        "call_streaming_callback: failed to parse JSON for callback_id={}: {}",
5418                        id,
5419                        e
5420                    );
5421                    return;
5422                }
5423            };
5424
5425            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
5426                Ok(v) => v,
5427                Err(e) => {
5428                    tracing::error!(
5429                        "call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
5430                        id,
5431                        e
5432                    );
5433                    return;
5434                }
5435            };
5436
5437            let globals = ctx.globals();
5438            let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
5439                Ok(f) => f,
5440                Err(e) => {
5441                    tracing::error!(
5442                        "call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
5443                        id,
5444                        e
5445                    );
5446                    return;
5447                }
5448            };
5449
5450            if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
5451                log_js_error(
5452                    &ctx,
5453                    e,
5454                    &format!("calling streaming callback {}", id),
5455                );
5456            }
5457
5458            run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
5459        });
5460    }
5461}
5462
5463#[cfg(test)]
5464mod tests {
5465    use super::*;
5466    use fresh_core::api::{BufferInfo, CursorInfo};
5467    use std::sync::mpsc;
5468
5469    /// Helper to create a backend with a command receiver for testing
5470    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
5471        let (tx, rx) = mpsc::channel();
5472        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5473        let services = Arc::new(TestServiceBridge::new());
5474        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5475        (backend, rx)
5476    }
5477
5478    struct TestServiceBridge {
5479        en_strings: std::sync::Mutex<HashMap<String, String>>,
5480    }
5481
5482    impl TestServiceBridge {
5483        fn new() -> Self {
5484            Self {
5485                en_strings: std::sync::Mutex::new(HashMap::new()),
5486            }
5487        }
5488    }
5489
5490    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
5491        fn as_any(&self) -> &dyn std::any::Any {
5492            self
5493        }
5494        fn translate(
5495            &self,
5496            _plugin_name: &str,
5497            key: &str,
5498            _args: &HashMap<String, String>,
5499        ) -> String {
5500            self.en_strings
5501                .lock()
5502                .unwrap()
5503                .get(key)
5504                .cloned()
5505                .unwrap_or_else(|| key.to_string())
5506        }
5507        fn current_locale(&self) -> String {
5508            "en".to_string()
5509        }
5510        fn set_js_execution_state(&self, _state: String) {}
5511        fn clear_js_execution_state(&self) {}
5512        fn get_theme_schema(&self) -> serde_json::Value {
5513            serde_json::json!({})
5514        }
5515        fn get_builtin_themes(&self) -> serde_json::Value {
5516            serde_json::json!([])
5517        }
5518        fn register_command(&self, _command: fresh_core::command::Command) {}
5519        fn unregister_command(&self, _name: &str) {}
5520        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
5521        fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
5522        fn plugins_dir(&self) -> std::path::PathBuf {
5523            std::path::PathBuf::from("/tmp/plugins")
5524        }
5525        fn config_dir(&self) -> std::path::PathBuf {
5526            std::path::PathBuf::from("/tmp/config")
5527        }
5528        fn data_dir(&self) -> std::path::PathBuf {
5529            std::path::PathBuf::from("/tmp/data")
5530        }
5531        fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
5532            None
5533        }
5534        fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
5535            Err("not implemented in test".to_string())
5536        }
5537        fn theme_file_exists(&self, _name: &str) -> bool {
5538            false
5539        }
5540    }
5541
5542    #[test]
5543    fn test_quickjs_backend_creation() {
5544        let backend = QuickJsBackend::new();
5545        assert!(backend.is_ok());
5546    }
5547
5548    #[test]
5549    fn test_execute_simple_js() {
5550        let mut backend = QuickJsBackend::new().unwrap();
5551        let result = backend.execute_js("const x = 1 + 2;", "test.js");
5552        assert!(result.is_ok());
5553    }
5554
5555    #[test]
5556    fn test_event_handler_registration() {
5557        let backend = QuickJsBackend::new().unwrap();
5558
5559        // Initially no handlers
5560        assert!(!backend.has_handlers("test_event"));
5561
5562        // Register a handler
5563        backend
5564            .event_handlers
5565            .borrow_mut()
5566            .entry("test_event".to_string())
5567            .or_default()
5568            .push(PluginHandler {
5569                plugin_name: "test".to_string(),
5570                handler_name: "testHandler".to_string(),
5571            });
5572
5573        // Now has handlers
5574        assert!(backend.has_handlers("test_event"));
5575    }
5576
5577    // ==================== API Tests ====================
5578
5579    #[test]
5580    fn test_api_set_status() {
5581        let (mut backend, rx) = create_test_backend();
5582
5583        backend
5584            .execute_js(
5585                r#"
5586            const editor = getEditor();
5587            editor.setStatus("Hello from test");
5588        "#,
5589                "test.js",
5590            )
5591            .unwrap();
5592
5593        let cmd = rx.try_recv().unwrap();
5594        match cmd {
5595            PluginCommand::SetStatus { message } => {
5596                assert_eq!(message, "Hello from test");
5597            }
5598            _ => panic!("Expected SetStatus command, got {:?}", cmd),
5599        }
5600    }
5601
5602    #[test]
5603    fn test_api_register_command() {
5604        let (mut backend, rx) = create_test_backend();
5605
5606        backend
5607            .execute_js(
5608                r#"
5609            const editor = getEditor();
5610            globalThis.myTestHandler = function() { };
5611            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
5612        "#,
5613                "test_plugin.js",
5614            )
5615            .unwrap();
5616
5617        let cmd = rx.try_recv().unwrap();
5618        match cmd {
5619            PluginCommand::RegisterCommand { command } => {
5620                assert_eq!(command.name, "Test Command");
5621                assert_eq!(command.description, "A test command");
5622                // Check that plugin_name contains the plugin name (derived from filename)
5623                assert_eq!(command.plugin_name, "test_plugin");
5624            }
5625            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
5626        }
5627    }
5628
5629    #[test]
5630    fn test_api_define_mode() {
5631        let (mut backend, rx) = create_test_backend();
5632
5633        backend
5634            .execute_js(
5635                r#"
5636            const editor = getEditor();
5637            editor.defineMode("test-mode", [
5638                ["a", "action_a"],
5639                ["b", "action_b"]
5640            ]);
5641        "#,
5642                "test.js",
5643            )
5644            .unwrap();
5645
5646        let cmd = rx.try_recv().unwrap();
5647        match cmd {
5648            PluginCommand::DefineMode {
5649                name,
5650                bindings,
5651                read_only,
5652                allow_text_input,
5653                inherit_normal_bindings,
5654                plugin_name,
5655            } => {
5656                assert_eq!(name, "test-mode");
5657                assert_eq!(bindings.len(), 2);
5658                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
5659                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
5660                assert!(!read_only);
5661                assert!(!allow_text_input);
5662                assert!(!inherit_normal_bindings);
5663                assert!(plugin_name.is_some());
5664            }
5665            _ => panic!("Expected DefineMode, got {:?}", cmd),
5666        }
5667    }
5668
5669    #[test]
5670    fn test_api_set_editor_mode() {
5671        let (mut backend, rx) = create_test_backend();
5672
5673        backend
5674            .execute_js(
5675                r#"
5676            const editor = getEditor();
5677            editor.setEditorMode("vi-normal");
5678        "#,
5679                "test.js",
5680            )
5681            .unwrap();
5682
5683        let cmd = rx.try_recv().unwrap();
5684        match cmd {
5685            PluginCommand::SetEditorMode { mode } => {
5686                assert_eq!(mode, Some("vi-normal".to_string()));
5687            }
5688            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
5689        }
5690    }
5691
5692    #[test]
5693    fn test_api_clear_editor_mode() {
5694        let (mut backend, rx) = create_test_backend();
5695
5696        backend
5697            .execute_js(
5698                r#"
5699            const editor = getEditor();
5700            editor.setEditorMode(null);
5701        "#,
5702                "test.js",
5703            )
5704            .unwrap();
5705
5706        let cmd = rx.try_recv().unwrap();
5707        match cmd {
5708            PluginCommand::SetEditorMode { mode } => {
5709                assert!(mode.is_none());
5710            }
5711            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
5712        }
5713    }
5714
5715    #[test]
5716    fn test_api_insert_at_cursor() {
5717        let (mut backend, rx) = create_test_backend();
5718
5719        backend
5720            .execute_js(
5721                r#"
5722            const editor = getEditor();
5723            editor.insertAtCursor("Hello, World!");
5724        "#,
5725                "test.js",
5726            )
5727            .unwrap();
5728
5729        let cmd = rx.try_recv().unwrap();
5730        match cmd {
5731            PluginCommand::InsertAtCursor { text } => {
5732                assert_eq!(text, "Hello, World!");
5733            }
5734            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
5735        }
5736    }
5737
5738    #[test]
5739    fn test_api_set_context() {
5740        let (mut backend, rx) = create_test_backend();
5741
5742        backend
5743            .execute_js(
5744                r#"
5745            const editor = getEditor();
5746            editor.setContext("myContext", true);
5747        "#,
5748                "test.js",
5749            )
5750            .unwrap();
5751
5752        let cmd = rx.try_recv().unwrap();
5753        match cmd {
5754            PluginCommand::SetContext { name, active } => {
5755                assert_eq!(name, "myContext");
5756                assert!(active);
5757            }
5758            _ => panic!("Expected SetContext, got {:?}", cmd),
5759        }
5760    }
5761
5762    #[tokio::test]
5763    async fn test_execute_action_sync_function() {
5764        let (mut backend, rx) = create_test_backend();
5765
5766        // Register the action explicitly so it knows to look in "test" plugin
5767        backend.registered_actions.borrow_mut().insert(
5768            "my_sync_action".to_string(),
5769            PluginHandler {
5770                plugin_name: "test".to_string(),
5771                handler_name: "my_sync_action".to_string(),
5772            },
5773        );
5774
5775        // Define a sync function and register it
5776        backend
5777            .execute_js(
5778                r#"
5779            const editor = getEditor();
5780            globalThis.my_sync_action = function() {
5781                editor.setStatus("sync action executed");
5782            };
5783        "#,
5784                "test.js",
5785            )
5786            .unwrap();
5787
5788        // Drain any setup commands
5789        while rx.try_recv().is_ok() {}
5790
5791        // Execute the action
5792        backend.execute_action("my_sync_action").await.unwrap();
5793
5794        // Check the command was sent
5795        let cmd = rx.try_recv().unwrap();
5796        match cmd {
5797            PluginCommand::SetStatus { message } => {
5798                assert_eq!(message, "sync action executed");
5799            }
5800            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
5801        }
5802    }
5803
5804    #[tokio::test]
5805    async fn test_execute_action_async_function() {
5806        let (mut backend, rx) = create_test_backend();
5807
5808        // Register the action explicitly
5809        backend.registered_actions.borrow_mut().insert(
5810            "my_async_action".to_string(),
5811            PluginHandler {
5812                plugin_name: "test".to_string(),
5813                handler_name: "my_async_action".to_string(),
5814            },
5815        );
5816
5817        // Define an async function
5818        backend
5819            .execute_js(
5820                r#"
5821            const editor = getEditor();
5822            globalThis.my_async_action = async function() {
5823                await Promise.resolve();
5824                editor.setStatus("async action executed");
5825            };
5826        "#,
5827                "test.js",
5828            )
5829            .unwrap();
5830
5831        // Drain any setup commands
5832        while rx.try_recv().is_ok() {}
5833
5834        // Execute the action
5835        backend.execute_action("my_async_action").await.unwrap();
5836
5837        // Check the command was sent (async should complete)
5838        let cmd = rx.try_recv().unwrap();
5839        match cmd {
5840            PluginCommand::SetStatus { message } => {
5841                assert_eq!(message, "async action executed");
5842            }
5843            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
5844        }
5845    }
5846
5847    #[tokio::test]
5848    async fn test_execute_action_with_registered_handler() {
5849        let (mut backend, rx) = create_test_backend();
5850
5851        // Register an action with a different handler name
5852        backend.registered_actions.borrow_mut().insert(
5853            "my_action".to_string(),
5854            PluginHandler {
5855                plugin_name: "test".to_string(),
5856                handler_name: "actual_handler_function".to_string(),
5857            },
5858        );
5859
5860        backend
5861            .execute_js(
5862                r#"
5863            const editor = getEditor();
5864            globalThis.actual_handler_function = function() {
5865                editor.setStatus("handler executed");
5866            };
5867        "#,
5868                "test.js",
5869            )
5870            .unwrap();
5871
5872        // Drain any setup commands
5873        while rx.try_recv().is_ok() {}
5874
5875        // Execute the action by name (should resolve to handler)
5876        backend.execute_action("my_action").await.unwrap();
5877
5878        let cmd = rx.try_recv().unwrap();
5879        match cmd {
5880            PluginCommand::SetStatus { message } => {
5881                assert_eq!(message, "handler executed");
5882            }
5883            _ => panic!("Expected SetStatus, got {:?}", cmd),
5884        }
5885    }
5886
5887    #[test]
5888    fn test_api_on_event_registration() {
5889        let (mut backend, _rx) = create_test_backend();
5890
5891        backend
5892            .execute_js(
5893                r#"
5894            const editor = getEditor();
5895            globalThis.myEventHandler = function() { };
5896            editor.on("bufferSave", "myEventHandler");
5897        "#,
5898                "test.js",
5899            )
5900            .unwrap();
5901
5902        assert!(backend.has_handlers("bufferSave"));
5903    }
5904
5905    #[test]
5906    fn test_api_off_event_unregistration() {
5907        let (mut backend, _rx) = create_test_backend();
5908
5909        backend
5910            .execute_js(
5911                r#"
5912            const editor = getEditor();
5913            globalThis.myEventHandler = function() { };
5914            editor.on("bufferSave", "myEventHandler");
5915            editor.off("bufferSave", "myEventHandler");
5916        "#,
5917                "test.js",
5918            )
5919            .unwrap();
5920
5921        // Handler should be removed
5922        assert!(!backend.has_handlers("bufferSave"));
5923    }
5924
5925    #[tokio::test]
5926    async fn test_emit_event() {
5927        let (mut backend, rx) = create_test_backend();
5928
5929        backend
5930            .execute_js(
5931                r#"
5932            const editor = getEditor();
5933            globalThis.onSaveHandler = function(data) {
5934                editor.setStatus("saved: " + JSON.stringify(data));
5935            };
5936            editor.on("bufferSave", "onSaveHandler");
5937        "#,
5938                "test.js",
5939            )
5940            .unwrap();
5941
5942        // Drain setup commands
5943        while rx.try_recv().is_ok() {}
5944
5945        // Emit the event
5946        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
5947        backend.emit("bufferSave", &event_data).await.unwrap();
5948
5949        let cmd = rx.try_recv().unwrap();
5950        match cmd {
5951            PluginCommand::SetStatus { message } => {
5952                assert!(message.contains("/test.txt"));
5953            }
5954            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
5955        }
5956    }
5957
5958    #[test]
5959    fn test_api_copy_to_clipboard() {
5960        let (mut backend, rx) = create_test_backend();
5961
5962        backend
5963            .execute_js(
5964                r#"
5965            const editor = getEditor();
5966            editor.copyToClipboard("clipboard text");
5967        "#,
5968                "test.js",
5969            )
5970            .unwrap();
5971
5972        let cmd = rx.try_recv().unwrap();
5973        match cmd {
5974            PluginCommand::SetClipboard { text } => {
5975                assert_eq!(text, "clipboard text");
5976            }
5977            _ => panic!("Expected SetClipboard, got {:?}", cmd),
5978        }
5979    }
5980
5981    #[test]
5982    fn test_api_open_file() {
5983        let (mut backend, rx) = create_test_backend();
5984
5985        // openFile takes (path, line?, column?)
5986        backend
5987            .execute_js(
5988                r#"
5989            const editor = getEditor();
5990            editor.openFile("/path/to/file.txt", null, null);
5991        "#,
5992                "test.js",
5993            )
5994            .unwrap();
5995
5996        let cmd = rx.try_recv().unwrap();
5997        match cmd {
5998            PluginCommand::OpenFileAtLocation { path, line, column } => {
5999                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
6000                assert!(line.is_none());
6001                assert!(column.is_none());
6002            }
6003            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
6004        }
6005    }
6006
6007    #[test]
6008    fn test_api_delete_range() {
6009        let (mut backend, rx) = create_test_backend();
6010
6011        // deleteRange takes (buffer_id, start, end)
6012        backend
6013            .execute_js(
6014                r#"
6015            const editor = getEditor();
6016            editor.deleteRange(0, 10, 20);
6017        "#,
6018                "test.js",
6019            )
6020            .unwrap();
6021
6022        let cmd = rx.try_recv().unwrap();
6023        match cmd {
6024            PluginCommand::DeleteRange { range, .. } => {
6025                assert_eq!(range.start, 10);
6026                assert_eq!(range.end, 20);
6027            }
6028            _ => panic!("Expected DeleteRange, got {:?}", cmd),
6029        }
6030    }
6031
6032    #[test]
6033    fn test_api_insert_text() {
6034        let (mut backend, rx) = create_test_backend();
6035
6036        // insertText takes (buffer_id, position, text)
6037        backend
6038            .execute_js(
6039                r#"
6040            const editor = getEditor();
6041            editor.insertText(0, 5, "inserted");
6042        "#,
6043                "test.js",
6044            )
6045            .unwrap();
6046
6047        let cmd = rx.try_recv().unwrap();
6048        match cmd {
6049            PluginCommand::InsertText { position, text, .. } => {
6050                assert_eq!(position, 5);
6051                assert_eq!(text, "inserted");
6052            }
6053            _ => panic!("Expected InsertText, got {:?}", cmd),
6054        }
6055    }
6056
6057    #[test]
6058    fn test_api_set_buffer_cursor() {
6059        let (mut backend, rx) = create_test_backend();
6060
6061        // setBufferCursor takes (buffer_id, position)
6062        backend
6063            .execute_js(
6064                r#"
6065            const editor = getEditor();
6066            editor.setBufferCursor(0, 100);
6067        "#,
6068                "test.js",
6069            )
6070            .unwrap();
6071
6072        let cmd = rx.try_recv().unwrap();
6073        match cmd {
6074            PluginCommand::SetBufferCursor { position, .. } => {
6075                assert_eq!(position, 100);
6076            }
6077            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
6078        }
6079    }
6080
6081    #[test]
6082    fn test_api_get_cursor_position_from_state() {
6083        let (tx, _rx) = mpsc::channel();
6084        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6085
6086        // Set up cursor position in state
6087        {
6088            let mut state = state_snapshot.write().unwrap();
6089            state.primary_cursor = Some(CursorInfo {
6090                position: 42,
6091                selection: None,
6092            });
6093        }
6094
6095        let services = Arc::new(fresh_core::services::NoopServiceBridge);
6096        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6097
6098        // Execute JS that reads and stores cursor position
6099        backend
6100            .execute_js(
6101                r#"
6102            const editor = getEditor();
6103            const pos = editor.getCursorPosition();
6104            globalThis._testResult = pos;
6105        "#,
6106                "test.js",
6107            )
6108            .unwrap();
6109
6110        // Verify by reading back - getCursorPosition returns byte offset as u32
6111        backend
6112            .plugin_contexts
6113            .borrow()
6114            .get("test")
6115            .unwrap()
6116            .clone()
6117            .with(|ctx| {
6118                let global = ctx.globals();
6119                let result: u32 = global.get("_testResult").unwrap();
6120                assert_eq!(result, 42);
6121            });
6122    }
6123
6124    #[test]
6125    fn test_api_path_functions() {
6126        let (mut backend, _rx) = create_test_backend();
6127
6128        // Use platform-appropriate absolute path for isAbsolute test
6129        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
6130        #[cfg(windows)]
6131        let absolute_path = r#"C:\\foo\\bar"#;
6132        #[cfg(not(windows))]
6133        let absolute_path = "/foo/bar";
6134
6135        // pathJoin takes an array of path parts
6136        let js_code = format!(
6137            r#"
6138            const editor = getEditor();
6139            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
6140            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
6141            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
6142            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
6143            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
6144            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
6145        "#,
6146            absolute_path
6147        );
6148        backend.execute_js(&js_code, "test.js").unwrap();
6149
6150        backend
6151            .plugin_contexts
6152            .borrow()
6153            .get("test")
6154            .unwrap()
6155            .clone()
6156            .with(|ctx| {
6157                let global = ctx.globals();
6158                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
6159                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
6160                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
6161                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
6162                assert!(!global.get::<_, bool>("_isRelative").unwrap());
6163                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
6164            });
6165    }
6166
6167    #[test]
6168    fn test_file_uri_to_path_and_back() {
6169        let (mut backend, _rx) = create_test_backend();
6170
6171        // Test Unix-style paths
6172        #[cfg(not(windows))]
6173        let js_code = r#"
6174            const editor = getEditor();
6175            // Basic file URI to path
6176            globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
6177            // Percent-encoded characters
6178            globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
6179            // Invalid URI returns empty string
6180            globalThis._path3 = editor.fileUriToPath("not-a-uri");
6181            // Path to file URI
6182            globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
6183            // Round-trip
6184            globalThis._roundtrip = editor.fileUriToPath(
6185                editor.pathToFileUri("/home/user/file.txt")
6186            );
6187        "#;
6188
6189        #[cfg(windows)]
6190        let js_code = r#"
6191            const editor = getEditor();
6192            // Windows URI with encoded colon (the bug from issue #1071)
6193            globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
6194            // Windows URI with normal colon
6195            globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
6196            // Invalid URI returns empty string
6197            globalThis._path3 = editor.fileUriToPath("not-a-uri");
6198            // Path to file URI
6199            globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
6200            // Round-trip
6201            globalThis._roundtrip = editor.fileUriToPath(
6202                editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
6203            );
6204        "#;
6205
6206        backend.execute_js(js_code, "test.js").unwrap();
6207
6208        backend
6209            .plugin_contexts
6210            .borrow()
6211            .get("test")
6212            .unwrap()
6213            .clone()
6214            .with(|ctx| {
6215                let global = ctx.globals();
6216
6217                #[cfg(not(windows))]
6218                {
6219                    assert_eq!(
6220                        global.get::<_, String>("_path1").unwrap(),
6221                        "/home/user/file.txt"
6222                    );
6223                    assert_eq!(
6224                        global.get::<_, String>("_path2").unwrap(),
6225                        "/home/user/my file.txt"
6226                    );
6227                    assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6228                    assert_eq!(
6229                        global.get::<_, String>("_uri1").unwrap(),
6230                        "file:///home/user/file.txt"
6231                    );
6232                    assert_eq!(
6233                        global.get::<_, String>("_roundtrip").unwrap(),
6234                        "/home/user/file.txt"
6235                    );
6236                }
6237
6238                #[cfg(windows)]
6239                {
6240                    // Issue #1071: encoded colon must be decoded to proper Windows path
6241                    assert_eq!(
6242                        global.get::<_, String>("_path1").unwrap(),
6243                        "C:\\Users\\admin\\Repos\\file.cs"
6244                    );
6245                    assert_eq!(
6246                        global.get::<_, String>("_path2").unwrap(),
6247                        "C:\\Users\\admin\\Repos\\file.cs"
6248                    );
6249                    assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
6250                    assert_eq!(
6251                        global.get::<_, String>("_uri1").unwrap(),
6252                        "file:///C:/Users/admin/Repos/file.cs"
6253                    );
6254                    assert_eq!(
6255                        global.get::<_, String>("_roundtrip").unwrap(),
6256                        "C:\\Users\\admin\\Repos\\file.cs"
6257                    );
6258                }
6259            });
6260    }
6261
6262    #[test]
6263    fn test_typescript_transpilation() {
6264        use fresh_parser_js::transpile_typescript;
6265
6266        let (mut backend, rx) = create_test_backend();
6267
6268        // TypeScript code with type annotations
6269        let ts_code = r#"
6270            const editor = getEditor();
6271            function greet(name: string): string {
6272                return "Hello, " + name;
6273            }
6274            editor.setStatus(greet("TypeScript"));
6275        "#;
6276
6277        // Transpile to JavaScript first
6278        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
6279
6280        // Execute the transpiled JavaScript
6281        backend.execute_js(&js_code, "test.js").unwrap();
6282
6283        let cmd = rx.try_recv().unwrap();
6284        match cmd {
6285            PluginCommand::SetStatus { message } => {
6286                assert_eq!(message, "Hello, TypeScript");
6287            }
6288            _ => panic!("Expected SetStatus, got {:?}", cmd),
6289        }
6290    }
6291
6292    #[test]
6293    fn test_api_get_buffer_text_sends_command() {
6294        let (mut backend, rx) = create_test_backend();
6295
6296        // Call getBufferText - this returns a Promise and sends the command
6297        backend
6298            .execute_js(
6299                r#"
6300            const editor = getEditor();
6301            // Store the promise for later
6302            globalThis._textPromise = editor.getBufferText(0, 10, 20);
6303        "#,
6304                "test.js",
6305            )
6306            .unwrap();
6307
6308        // Verify the GetBufferText command was sent
6309        let cmd = rx.try_recv().unwrap();
6310        match cmd {
6311            PluginCommand::GetBufferText {
6312                buffer_id,
6313                start,
6314                end,
6315                request_id,
6316            } => {
6317                assert_eq!(buffer_id.0, 0);
6318                assert_eq!(start, 10);
6319                assert_eq!(end, 20);
6320                assert!(request_id > 0); // Should have a valid request ID
6321            }
6322            _ => panic!("Expected GetBufferText, got {:?}", cmd),
6323        }
6324    }
6325
6326    #[test]
6327    fn test_api_get_buffer_text_resolves_callback() {
6328        let (mut backend, rx) = create_test_backend();
6329
6330        // Call getBufferText and set up a handler for when it resolves
6331        backend
6332            .execute_js(
6333                r#"
6334            const editor = getEditor();
6335            globalThis._resolvedText = null;
6336            editor.getBufferText(0, 0, 100).then(text => {
6337                globalThis._resolvedText = text;
6338            });
6339        "#,
6340                "test.js",
6341            )
6342            .unwrap();
6343
6344        // Get the request_id from the command
6345        let request_id = match rx.try_recv().unwrap() {
6346            PluginCommand::GetBufferText { request_id, .. } => request_id,
6347            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
6348        };
6349
6350        // Simulate the editor responding with the text
6351        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
6352
6353        // Drive the Promise to completion
6354        backend
6355            .plugin_contexts
6356            .borrow()
6357            .get("test")
6358            .unwrap()
6359            .clone()
6360            .with(|ctx| {
6361                run_pending_jobs_checked(&ctx, "test async getText");
6362            });
6363
6364        // Verify the Promise resolved with the text
6365        backend
6366            .plugin_contexts
6367            .borrow()
6368            .get("test")
6369            .unwrap()
6370            .clone()
6371            .with(|ctx| {
6372                let global = ctx.globals();
6373                let result: String = global.get("_resolvedText").unwrap();
6374                assert_eq!(result, "hello world");
6375            });
6376    }
6377
6378    #[test]
6379    fn test_plugin_translation() {
6380        let (mut backend, _rx) = create_test_backend();
6381
6382        // The t() function should work (returns key if translation not found)
6383        backend
6384            .execute_js(
6385                r#"
6386            const editor = getEditor();
6387            globalThis._translated = editor.t("test.key");
6388        "#,
6389                "test.js",
6390            )
6391            .unwrap();
6392
6393        backend
6394            .plugin_contexts
6395            .borrow()
6396            .get("test")
6397            .unwrap()
6398            .clone()
6399            .with(|ctx| {
6400                let global = ctx.globals();
6401                // Without actual translations, it returns the key
6402                let result: String = global.get("_translated").unwrap();
6403                assert_eq!(result, "test.key");
6404            });
6405    }
6406
6407    #[test]
6408    fn test_plugin_translation_with_registered_strings() {
6409        let (mut backend, _rx) = create_test_backend();
6410
6411        // Register translations for the test plugin
6412        let mut en_strings = std::collections::HashMap::new();
6413        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
6414        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6415
6416        let mut strings = std::collections::HashMap::new();
6417        strings.insert("en".to_string(), en_strings);
6418
6419        // Register for "test" plugin
6420        if let Some(bridge) = backend
6421            .services
6422            .as_any()
6423            .downcast_ref::<TestServiceBridge>()
6424        {
6425            let mut en = bridge.en_strings.lock().unwrap();
6426            en.insert("greeting".to_string(), "Hello, World!".to_string());
6427            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
6428        }
6429
6430        // Test translation
6431        backend
6432            .execute_js(
6433                r#"
6434            const editor = getEditor();
6435            globalThis._greeting = editor.t("greeting");
6436            globalThis._prompt = editor.t("prompt.find_file");
6437            globalThis._missing = editor.t("nonexistent.key");
6438        "#,
6439                "test.js",
6440            )
6441            .unwrap();
6442
6443        backend
6444            .plugin_contexts
6445            .borrow()
6446            .get("test")
6447            .unwrap()
6448            .clone()
6449            .with(|ctx| {
6450                let global = ctx.globals();
6451                let greeting: String = global.get("_greeting").unwrap();
6452                assert_eq!(greeting, "Hello, World!");
6453
6454                let prompt: String = global.get("_prompt").unwrap();
6455                assert_eq!(prompt, "Find file: ");
6456
6457                // Missing key should return the key itself
6458                let missing: String = global.get("_missing").unwrap();
6459                assert_eq!(missing, "nonexistent.key");
6460            });
6461    }
6462
6463    // ==================== Line Indicator Tests ====================
6464
6465    #[test]
6466    fn test_api_set_line_indicator() {
6467        let (mut backend, rx) = create_test_backend();
6468
6469        backend
6470            .execute_js(
6471                r#"
6472            const editor = getEditor();
6473            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
6474        "#,
6475                "test.js",
6476            )
6477            .unwrap();
6478
6479        let cmd = rx.try_recv().unwrap();
6480        match cmd {
6481            PluginCommand::SetLineIndicator {
6482                buffer_id,
6483                line,
6484                namespace,
6485                symbol,
6486                color,
6487                priority,
6488            } => {
6489                assert_eq!(buffer_id.0, 1);
6490                assert_eq!(line, 5);
6491                assert_eq!(namespace, "test-ns");
6492                assert_eq!(symbol, "●");
6493                assert_eq!(color, (255, 0, 0));
6494                assert_eq!(priority, 10);
6495            }
6496            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
6497        }
6498    }
6499
6500    #[test]
6501    fn test_api_clear_line_indicators() {
6502        let (mut backend, rx) = create_test_backend();
6503
6504        backend
6505            .execute_js(
6506                r#"
6507            const editor = getEditor();
6508            editor.clearLineIndicators(1, "test-ns");
6509        "#,
6510                "test.js",
6511            )
6512            .unwrap();
6513
6514        let cmd = rx.try_recv().unwrap();
6515        match cmd {
6516            PluginCommand::ClearLineIndicators {
6517                buffer_id,
6518                namespace,
6519            } => {
6520                assert_eq!(buffer_id.0, 1);
6521                assert_eq!(namespace, "test-ns");
6522            }
6523            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
6524        }
6525    }
6526
6527    // ==================== Virtual Buffer Tests ====================
6528
6529    #[test]
6530    fn test_api_create_virtual_buffer_sends_command() {
6531        let (mut backend, rx) = create_test_backend();
6532
6533        backend
6534            .execute_js(
6535                r#"
6536            const editor = getEditor();
6537            editor.createVirtualBuffer({
6538                name: "*Test Buffer*",
6539                mode: "test-mode",
6540                readOnly: true,
6541                entries: [
6542                    { text: "Line 1\n", properties: { type: "header" } },
6543                    { text: "Line 2\n", properties: { type: "content" } }
6544                ],
6545                showLineNumbers: false,
6546                showCursors: true,
6547                editingDisabled: true
6548            });
6549        "#,
6550                "test.js",
6551            )
6552            .unwrap();
6553
6554        let cmd = rx.try_recv().unwrap();
6555        match cmd {
6556            PluginCommand::CreateVirtualBufferWithContent {
6557                name,
6558                mode,
6559                read_only,
6560                entries,
6561                show_line_numbers,
6562                show_cursors,
6563                editing_disabled,
6564                ..
6565            } => {
6566                assert_eq!(name, "*Test Buffer*");
6567                assert_eq!(mode, "test-mode");
6568                assert!(read_only);
6569                assert_eq!(entries.len(), 2);
6570                assert_eq!(entries[0].text, "Line 1\n");
6571                assert!(!show_line_numbers);
6572                assert!(show_cursors);
6573                assert!(editing_disabled);
6574            }
6575            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
6576        }
6577    }
6578
6579    #[test]
6580    fn test_api_set_virtual_buffer_content() {
6581        let (mut backend, rx) = create_test_backend();
6582
6583        backend
6584            .execute_js(
6585                r#"
6586            const editor = getEditor();
6587            editor.setVirtualBufferContent(5, [
6588                { text: "New content\n", properties: { type: "updated" } }
6589            ]);
6590        "#,
6591                "test.js",
6592            )
6593            .unwrap();
6594
6595        let cmd = rx.try_recv().unwrap();
6596        match cmd {
6597            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
6598                assert_eq!(buffer_id.0, 5);
6599                assert_eq!(entries.len(), 1);
6600                assert_eq!(entries[0].text, "New content\n");
6601            }
6602            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
6603        }
6604    }
6605
6606    // ==================== Overlay Tests ====================
6607
6608    #[test]
6609    fn test_api_add_overlay() {
6610        let (mut backend, rx) = create_test_backend();
6611
6612        backend
6613            .execute_js(
6614                r#"
6615            const editor = getEditor();
6616            editor.addOverlay(1, "highlight", 10, 20, {
6617                fg: [255, 128, 0],
6618                bg: [50, 50, 50],
6619                bold: true,
6620            });
6621        "#,
6622                "test.js",
6623            )
6624            .unwrap();
6625
6626        let cmd = rx.try_recv().unwrap();
6627        match cmd {
6628            PluginCommand::AddOverlay {
6629                buffer_id,
6630                namespace,
6631                range,
6632                options,
6633            } => {
6634                use fresh_core::api::OverlayColorSpec;
6635                assert_eq!(buffer_id.0, 1);
6636                assert!(namespace.is_some());
6637                assert_eq!(namespace.unwrap().as_str(), "highlight");
6638                assert_eq!(range, 10..20);
6639                assert!(matches!(
6640                    options.fg,
6641                    Some(OverlayColorSpec::Rgb(255, 128, 0))
6642                ));
6643                assert!(matches!(
6644                    options.bg,
6645                    Some(OverlayColorSpec::Rgb(50, 50, 50))
6646                ));
6647                assert!(!options.underline);
6648                assert!(options.bold);
6649                assert!(!options.italic);
6650                assert!(!options.extend_to_line_end);
6651            }
6652            _ => panic!("Expected AddOverlay, got {:?}", cmd),
6653        }
6654    }
6655
6656    #[test]
6657    fn test_api_add_overlay_with_theme_keys() {
6658        let (mut backend, rx) = create_test_backend();
6659
6660        backend
6661            .execute_js(
6662                r#"
6663            const editor = getEditor();
6664            // Test with theme keys for colors
6665            editor.addOverlay(1, "themed", 0, 10, {
6666                fg: "ui.status_bar_fg",
6667                bg: "editor.selection_bg",
6668            });
6669        "#,
6670                "test.js",
6671            )
6672            .unwrap();
6673
6674        let cmd = rx.try_recv().unwrap();
6675        match cmd {
6676            PluginCommand::AddOverlay {
6677                buffer_id,
6678                namespace,
6679                range,
6680                options,
6681            } => {
6682                use fresh_core::api::OverlayColorSpec;
6683                assert_eq!(buffer_id.0, 1);
6684                assert!(namespace.is_some());
6685                assert_eq!(namespace.unwrap().as_str(), "themed");
6686                assert_eq!(range, 0..10);
6687                assert!(matches!(
6688                    &options.fg,
6689                    Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
6690                ));
6691                assert!(matches!(
6692                    &options.bg,
6693                    Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
6694                ));
6695                assert!(!options.underline);
6696                assert!(!options.bold);
6697                assert!(!options.italic);
6698                assert!(!options.extend_to_line_end);
6699            }
6700            _ => panic!("Expected AddOverlay, got {:?}", cmd),
6701        }
6702    }
6703
6704    #[test]
6705    fn test_api_clear_namespace() {
6706        let (mut backend, rx) = create_test_backend();
6707
6708        backend
6709            .execute_js(
6710                r#"
6711            const editor = getEditor();
6712            editor.clearNamespace(1, "highlight");
6713        "#,
6714                "test.js",
6715            )
6716            .unwrap();
6717
6718        let cmd = rx.try_recv().unwrap();
6719        match cmd {
6720            PluginCommand::ClearNamespace {
6721                buffer_id,
6722                namespace,
6723            } => {
6724                assert_eq!(buffer_id.0, 1);
6725                assert_eq!(namespace.as_str(), "highlight");
6726            }
6727            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
6728        }
6729    }
6730
6731    // ==================== Theme Tests ====================
6732
6733    #[test]
6734    fn test_api_get_theme_schema() {
6735        let (mut backend, _rx) = create_test_backend();
6736
6737        backend
6738            .execute_js(
6739                r#"
6740            const editor = getEditor();
6741            const schema = editor.getThemeSchema();
6742            globalThis._isObject = typeof schema === 'object' && schema !== null;
6743        "#,
6744                "test.js",
6745            )
6746            .unwrap();
6747
6748        backend
6749            .plugin_contexts
6750            .borrow()
6751            .get("test")
6752            .unwrap()
6753            .clone()
6754            .with(|ctx| {
6755                let global = ctx.globals();
6756                let is_object: bool = global.get("_isObject").unwrap();
6757                // getThemeSchema should return an object
6758                assert!(is_object);
6759            });
6760    }
6761
6762    #[test]
6763    fn test_api_get_builtin_themes() {
6764        let (mut backend, _rx) = create_test_backend();
6765
6766        backend
6767            .execute_js(
6768                r#"
6769            const editor = getEditor();
6770            const themes = editor.getBuiltinThemes();
6771            globalThis._isObject = typeof themes === 'object' && themes !== null;
6772        "#,
6773                "test.js",
6774            )
6775            .unwrap();
6776
6777        backend
6778            .plugin_contexts
6779            .borrow()
6780            .get("test")
6781            .unwrap()
6782            .clone()
6783            .with(|ctx| {
6784                let global = ctx.globals();
6785                let is_object: bool = global.get("_isObject").unwrap();
6786                // getBuiltinThemes should return an object
6787                assert!(is_object);
6788            });
6789    }
6790
6791    #[test]
6792    fn test_api_apply_theme() {
6793        let (mut backend, rx) = create_test_backend();
6794
6795        backend
6796            .execute_js(
6797                r#"
6798            const editor = getEditor();
6799            editor.applyTheme("dark");
6800        "#,
6801                "test.js",
6802            )
6803            .unwrap();
6804
6805        let cmd = rx.try_recv().unwrap();
6806        match cmd {
6807            PluginCommand::ApplyTheme { theme_name } => {
6808                assert_eq!(theme_name, "dark");
6809            }
6810            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
6811        }
6812    }
6813
6814    #[test]
6815    fn test_api_get_theme_data_missing() {
6816        let (mut backend, _rx) = create_test_backend();
6817
6818        backend
6819            .execute_js(
6820                r#"
6821            const editor = getEditor();
6822            const data = editor.getThemeData("nonexistent");
6823            globalThis._isNull = data === null;
6824        "#,
6825                "test.js",
6826            )
6827            .unwrap();
6828
6829        backend
6830            .plugin_contexts
6831            .borrow()
6832            .get("test")
6833            .unwrap()
6834            .clone()
6835            .with(|ctx| {
6836                let global = ctx.globals();
6837                let is_null: bool = global.get("_isNull").unwrap();
6838                // getThemeData should return null for non-existent theme
6839                assert!(is_null);
6840            });
6841    }
6842
6843    #[test]
6844    fn test_api_get_theme_data_present() {
6845        // Use a custom service bridge that returns theme data
6846        let (tx, _rx) = mpsc::channel();
6847        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6848        let services = Arc::new(ThemeCacheTestBridge {
6849            inner: TestServiceBridge::new(),
6850        });
6851        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6852
6853        backend
6854            .execute_js(
6855                r#"
6856            const editor = getEditor();
6857            const data = editor.getThemeData("test-theme");
6858            globalThis._hasData = data !== null && typeof data === 'object';
6859            globalThis._name = data ? data.name : null;
6860        "#,
6861                "test.js",
6862            )
6863            .unwrap();
6864
6865        backend
6866            .plugin_contexts
6867            .borrow()
6868            .get("test")
6869            .unwrap()
6870            .clone()
6871            .with(|ctx| {
6872                let global = ctx.globals();
6873                let has_data: bool = global.get("_hasData").unwrap();
6874                assert!(has_data, "getThemeData should return theme object");
6875                let name: String = global.get("_name").unwrap();
6876                assert_eq!(name, "test-theme");
6877            });
6878    }
6879
6880    #[test]
6881    fn test_api_theme_file_exists() {
6882        let (mut backend, _rx) = create_test_backend();
6883
6884        backend
6885            .execute_js(
6886                r#"
6887            const editor = getEditor();
6888            globalThis._exists = editor.themeFileExists("anything");
6889        "#,
6890                "test.js",
6891            )
6892            .unwrap();
6893
6894        backend
6895            .plugin_contexts
6896            .borrow()
6897            .get("test")
6898            .unwrap()
6899            .clone()
6900            .with(|ctx| {
6901                let global = ctx.globals();
6902                let exists: bool = global.get("_exists").unwrap();
6903                // TestServiceBridge returns false
6904                assert!(!exists);
6905            });
6906    }
6907
6908    #[test]
6909    fn test_api_save_theme_file_error() {
6910        let (mut backend, _rx) = create_test_backend();
6911
6912        backend
6913            .execute_js(
6914                r#"
6915            const editor = getEditor();
6916            let threw = false;
6917            try {
6918                editor.saveThemeFile("test", "{}");
6919            } catch (e) {
6920                threw = true;
6921            }
6922            globalThis._threw = threw;
6923        "#,
6924                "test.js",
6925            )
6926            .unwrap();
6927
6928        backend
6929            .plugin_contexts
6930            .borrow()
6931            .get("test")
6932            .unwrap()
6933            .clone()
6934            .with(|ctx| {
6935                let global = ctx.globals();
6936                let threw: bool = global.get("_threw").unwrap();
6937                // TestServiceBridge returns Err, so JS should throw
6938                assert!(threw);
6939            });
6940    }
6941
6942    /// Test helper: a service bridge that provides theme data in the cache.
6943    struct ThemeCacheTestBridge {
6944        inner: TestServiceBridge,
6945    }
6946
6947    impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
6948        fn as_any(&self) -> &dyn std::any::Any {
6949            self
6950        }
6951        fn translate(
6952            &self,
6953            plugin_name: &str,
6954            key: &str,
6955            args: &HashMap<String, String>,
6956        ) -> String {
6957            self.inner.translate(plugin_name, key, args)
6958        }
6959        fn current_locale(&self) -> String {
6960            self.inner.current_locale()
6961        }
6962        fn set_js_execution_state(&self, state: String) {
6963            self.inner.set_js_execution_state(state);
6964        }
6965        fn clear_js_execution_state(&self) {
6966            self.inner.clear_js_execution_state();
6967        }
6968        fn get_theme_schema(&self) -> serde_json::Value {
6969            self.inner.get_theme_schema()
6970        }
6971        fn get_builtin_themes(&self) -> serde_json::Value {
6972            self.inner.get_builtin_themes()
6973        }
6974        fn register_command(&self, command: fresh_core::command::Command) {
6975            self.inner.register_command(command);
6976        }
6977        fn unregister_command(&self, name: &str) {
6978            self.inner.unregister_command(name);
6979        }
6980        fn unregister_commands_by_prefix(&self, prefix: &str) {
6981            self.inner.unregister_commands_by_prefix(prefix);
6982        }
6983        fn unregister_commands_by_plugin(&self, plugin_name: &str) {
6984            self.inner.unregister_commands_by_plugin(plugin_name);
6985        }
6986        fn plugins_dir(&self) -> std::path::PathBuf {
6987            self.inner.plugins_dir()
6988        }
6989        fn config_dir(&self) -> std::path::PathBuf {
6990            self.inner.config_dir()
6991        }
6992        fn data_dir(&self) -> std::path::PathBuf {
6993            self.inner.data_dir()
6994        }
6995        fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
6996            if name == "test-theme" {
6997                Some(serde_json::json!({
6998                    "name": "test-theme",
6999                    "editor": {},
7000                    "ui": {},
7001                    "syntax": {}
7002                }))
7003            } else {
7004                None
7005            }
7006        }
7007        fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7008            Err("test bridge does not support save".to_string())
7009        }
7010        fn theme_file_exists(&self, name: &str) -> bool {
7011            name == "test-theme"
7012        }
7013    }
7014
7015    // ==================== Buffer Operations Tests ====================
7016
7017    #[test]
7018    fn test_api_close_buffer() {
7019        let (mut backend, rx) = create_test_backend();
7020
7021        backend
7022            .execute_js(
7023                r#"
7024            const editor = getEditor();
7025            editor.closeBuffer(3);
7026        "#,
7027                "test.js",
7028            )
7029            .unwrap();
7030
7031        let cmd = rx.try_recv().unwrap();
7032        match cmd {
7033            PluginCommand::CloseBuffer { buffer_id } => {
7034                assert_eq!(buffer_id.0, 3);
7035            }
7036            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
7037        }
7038    }
7039
7040    #[test]
7041    fn test_api_focus_split() {
7042        let (mut backend, rx) = create_test_backend();
7043
7044        backend
7045            .execute_js(
7046                r#"
7047            const editor = getEditor();
7048            editor.focusSplit(2);
7049        "#,
7050                "test.js",
7051            )
7052            .unwrap();
7053
7054        let cmd = rx.try_recv().unwrap();
7055        match cmd {
7056            PluginCommand::FocusSplit { split_id } => {
7057                assert_eq!(split_id.0, 2);
7058            }
7059            _ => panic!("Expected FocusSplit, got {:?}", cmd),
7060        }
7061    }
7062
7063    #[test]
7064    fn test_api_list_buffers() {
7065        let (tx, _rx) = mpsc::channel();
7066        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7067
7068        // Add some buffers to state
7069        {
7070            let mut state = state_snapshot.write().unwrap();
7071            state.buffers.insert(
7072                BufferId(0),
7073                BufferInfo {
7074                    id: BufferId(0),
7075                    path: Some(PathBuf::from("/test1.txt")),
7076                    modified: false,
7077                    length: 100,
7078                    is_virtual: false,
7079                    view_mode: "source".to_string(),
7080                    is_composing_in_any_split: false,
7081                    compose_width: None,
7082                    language: "text".to_string(),
7083                    is_preview: false,
7084                },
7085            );
7086            state.buffers.insert(
7087                BufferId(1),
7088                BufferInfo {
7089                    id: BufferId(1),
7090                    path: Some(PathBuf::from("/test2.txt")),
7091                    modified: true,
7092                    length: 200,
7093                    is_virtual: false,
7094                    view_mode: "source".to_string(),
7095                    is_composing_in_any_split: false,
7096                    compose_width: None,
7097                    language: "text".to_string(),
7098                    is_preview: false,
7099                },
7100            );
7101        }
7102
7103        let services = Arc::new(fresh_core::services::NoopServiceBridge);
7104        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7105
7106        backend
7107            .execute_js(
7108                r#"
7109            const editor = getEditor();
7110            const buffers = editor.listBuffers();
7111            globalThis._isArray = Array.isArray(buffers);
7112            globalThis._length = buffers.length;
7113        "#,
7114                "test.js",
7115            )
7116            .unwrap();
7117
7118        backend
7119            .plugin_contexts
7120            .borrow()
7121            .get("test")
7122            .unwrap()
7123            .clone()
7124            .with(|ctx| {
7125                let global = ctx.globals();
7126                let is_array: bool = global.get("_isArray").unwrap();
7127                let length: u32 = global.get("_length").unwrap();
7128                assert!(is_array);
7129                assert_eq!(length, 2);
7130            });
7131    }
7132
7133    // ==================== Prompt Tests ====================
7134
7135    #[test]
7136    fn test_api_start_prompt() {
7137        let (mut backend, rx) = create_test_backend();
7138
7139        backend
7140            .execute_js(
7141                r#"
7142            const editor = getEditor();
7143            editor.startPrompt("Enter value:", "test-prompt");
7144        "#,
7145                "test.js",
7146            )
7147            .unwrap();
7148
7149        let cmd = rx.try_recv().unwrap();
7150        match cmd {
7151            PluginCommand::StartPrompt { label, prompt_type } => {
7152                assert_eq!(label, "Enter value:");
7153                assert_eq!(prompt_type, "test-prompt");
7154            }
7155            _ => panic!("Expected StartPrompt, got {:?}", cmd),
7156        }
7157    }
7158
7159    #[test]
7160    fn test_api_start_prompt_with_initial() {
7161        let (mut backend, rx) = create_test_backend();
7162
7163        backend
7164            .execute_js(
7165                r#"
7166            const editor = getEditor();
7167            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
7168        "#,
7169                "test.js",
7170            )
7171            .unwrap();
7172
7173        let cmd = rx.try_recv().unwrap();
7174        match cmd {
7175            PluginCommand::StartPromptWithInitial {
7176                label,
7177                prompt_type,
7178                initial_value,
7179            } => {
7180                assert_eq!(label, "Enter value:");
7181                assert_eq!(prompt_type, "test-prompt");
7182                assert_eq!(initial_value, "default");
7183            }
7184            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
7185        }
7186    }
7187
7188    #[test]
7189    fn test_api_set_prompt_suggestions() {
7190        let (mut backend, rx) = create_test_backend();
7191
7192        backend
7193            .execute_js(
7194                r#"
7195            const editor = getEditor();
7196            editor.setPromptSuggestions([
7197                { text: "Option 1", value: "opt1" },
7198                { text: "Option 2", value: "opt2" }
7199            ]);
7200        "#,
7201                "test.js",
7202            )
7203            .unwrap();
7204
7205        let cmd = rx.try_recv().unwrap();
7206        match cmd {
7207            PluginCommand::SetPromptSuggestions { suggestions } => {
7208                assert_eq!(suggestions.len(), 2);
7209                assert_eq!(suggestions[0].text, "Option 1");
7210                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
7211            }
7212            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
7213        }
7214    }
7215
7216    // ==================== State Query Tests ====================
7217
7218    #[test]
7219    fn test_api_get_active_buffer_id() {
7220        let (tx, _rx) = mpsc::channel();
7221        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7222
7223        {
7224            let mut state = state_snapshot.write().unwrap();
7225            state.active_buffer_id = BufferId(42);
7226        }
7227
7228        let services = Arc::new(fresh_core::services::NoopServiceBridge);
7229        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7230
7231        backend
7232            .execute_js(
7233                r#"
7234            const editor = getEditor();
7235            globalThis._activeId = editor.getActiveBufferId();
7236        "#,
7237                "test.js",
7238            )
7239            .unwrap();
7240
7241        backend
7242            .plugin_contexts
7243            .borrow()
7244            .get("test")
7245            .unwrap()
7246            .clone()
7247            .with(|ctx| {
7248                let global = ctx.globals();
7249                let result: u32 = global.get("_activeId").unwrap();
7250                assert_eq!(result, 42);
7251            });
7252    }
7253
7254    #[test]
7255    fn test_api_get_active_split_id() {
7256        let (tx, _rx) = mpsc::channel();
7257        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7258
7259        {
7260            let mut state = state_snapshot.write().unwrap();
7261            state.active_split_id = 7;
7262        }
7263
7264        let services = Arc::new(fresh_core::services::NoopServiceBridge);
7265        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7266
7267        backend
7268            .execute_js(
7269                r#"
7270            const editor = getEditor();
7271            globalThis._splitId = editor.getActiveSplitId();
7272        "#,
7273                "test.js",
7274            )
7275            .unwrap();
7276
7277        backend
7278            .plugin_contexts
7279            .borrow()
7280            .get("test")
7281            .unwrap()
7282            .clone()
7283            .with(|ctx| {
7284                let global = ctx.globals();
7285                let result: u32 = global.get("_splitId").unwrap();
7286                assert_eq!(result, 7);
7287            });
7288    }
7289
7290    // ==================== File System Tests ====================
7291
7292    #[test]
7293    fn test_api_file_exists() {
7294        let (mut backend, _rx) = create_test_backend();
7295
7296        backend
7297            .execute_js(
7298                r#"
7299            const editor = getEditor();
7300            // Test with a path that definitely exists
7301            globalThis._exists = editor.fileExists("/");
7302        "#,
7303                "test.js",
7304            )
7305            .unwrap();
7306
7307        backend
7308            .plugin_contexts
7309            .borrow()
7310            .get("test")
7311            .unwrap()
7312            .clone()
7313            .with(|ctx| {
7314                let global = ctx.globals();
7315                let result: bool = global.get("_exists").unwrap();
7316                assert!(result);
7317            });
7318    }
7319
7320    #[test]
7321    fn test_api_get_cwd() {
7322        let (mut backend, _rx) = create_test_backend();
7323
7324        backend
7325            .execute_js(
7326                r#"
7327            const editor = getEditor();
7328            globalThis._cwd = editor.getCwd();
7329        "#,
7330                "test.js",
7331            )
7332            .unwrap();
7333
7334        backend
7335            .plugin_contexts
7336            .borrow()
7337            .get("test")
7338            .unwrap()
7339            .clone()
7340            .with(|ctx| {
7341                let global = ctx.globals();
7342                let result: String = global.get("_cwd").unwrap();
7343                // Should return some path
7344                assert!(!result.is_empty());
7345            });
7346    }
7347
7348    #[test]
7349    fn test_api_get_env() {
7350        let (mut backend, _rx) = create_test_backend();
7351
7352        // Set a test environment variable
7353        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
7354
7355        backend
7356            .execute_js(
7357                r#"
7358            const editor = getEditor();
7359            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
7360        "#,
7361                "test.js",
7362            )
7363            .unwrap();
7364
7365        backend
7366            .plugin_contexts
7367            .borrow()
7368            .get("test")
7369            .unwrap()
7370            .clone()
7371            .with(|ctx| {
7372                let global = ctx.globals();
7373                let result: Option<String> = global.get("_envVal").unwrap();
7374                assert_eq!(result, Some("test_value".to_string()));
7375            });
7376
7377        std::env::remove_var("TEST_PLUGIN_VAR");
7378    }
7379
7380    #[test]
7381    fn test_api_get_config() {
7382        let (mut backend, _rx) = create_test_backend();
7383
7384        backend
7385            .execute_js(
7386                r#"
7387            const editor = getEditor();
7388            const config = editor.getConfig();
7389            globalThis._isObject = typeof config === 'object';
7390        "#,
7391                "test.js",
7392            )
7393            .unwrap();
7394
7395        backend
7396            .plugin_contexts
7397            .borrow()
7398            .get("test")
7399            .unwrap()
7400            .clone()
7401            .with(|ctx| {
7402                let global = ctx.globals();
7403                let is_object: bool = global.get("_isObject").unwrap();
7404                // getConfig should return an object, not a string
7405                assert!(is_object);
7406            });
7407    }
7408
7409    #[test]
7410    fn test_api_get_themes_dir() {
7411        let (mut backend, _rx) = create_test_backend();
7412
7413        backend
7414            .execute_js(
7415                r#"
7416            const editor = getEditor();
7417            globalThis._themesDir = editor.getThemesDir();
7418        "#,
7419                "test.js",
7420            )
7421            .unwrap();
7422
7423        backend
7424            .plugin_contexts
7425            .borrow()
7426            .get("test")
7427            .unwrap()
7428            .clone()
7429            .with(|ctx| {
7430                let global = ctx.globals();
7431                let result: String = global.get("_themesDir").unwrap();
7432                // Should return some path
7433                assert!(!result.is_empty());
7434            });
7435    }
7436
7437    // ==================== Read Dir Test ====================
7438
7439    #[test]
7440    fn test_api_read_dir() {
7441        let (mut backend, _rx) = create_test_backend();
7442
7443        backend
7444            .execute_js(
7445                r#"
7446            const editor = getEditor();
7447            const entries = editor.readDir("/tmp");
7448            globalThis._isArray = Array.isArray(entries);
7449            globalThis._length = entries.length;
7450        "#,
7451                "test.js",
7452            )
7453            .unwrap();
7454
7455        backend
7456            .plugin_contexts
7457            .borrow()
7458            .get("test")
7459            .unwrap()
7460            .clone()
7461            .with(|ctx| {
7462                let global = ctx.globals();
7463                let is_array: bool = global.get("_isArray").unwrap();
7464                let length: u32 = global.get("_length").unwrap();
7465                // /tmp should exist and readDir should return an array
7466                assert!(is_array);
7467                // Length is valid (u32 always >= 0)
7468                let _ = length;
7469            });
7470    }
7471
7472    // ==================== Execute Action Test ====================
7473
7474    #[test]
7475    fn test_api_execute_action() {
7476        let (mut backend, rx) = create_test_backend();
7477
7478        backend
7479            .execute_js(
7480                r#"
7481            const editor = getEditor();
7482            editor.executeAction("move_cursor_up");
7483        "#,
7484                "test.js",
7485            )
7486            .unwrap();
7487
7488        let cmd = rx.try_recv().unwrap();
7489        match cmd {
7490            PluginCommand::ExecuteAction { action_name } => {
7491                assert_eq!(action_name, "move_cursor_up");
7492            }
7493            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
7494        }
7495    }
7496
7497    // ==================== Debug Test ====================
7498
7499    #[test]
7500    fn test_api_debug() {
7501        let (mut backend, _rx) = create_test_backend();
7502
7503        // debug() should not panic and should work with any input
7504        backend
7505            .execute_js(
7506                r#"
7507            const editor = getEditor();
7508            editor.debug("Test debug message");
7509            editor.debug("Another message with special chars: <>&\"'");
7510        "#,
7511                "test.js",
7512            )
7513            .unwrap();
7514        // If we get here without panic, the test passes
7515    }
7516
7517    // ==================== TypeScript Definitions Test ====================
7518
7519    #[test]
7520    fn test_typescript_preamble_generated() {
7521        // Check that the TypeScript preamble constant exists and has content
7522        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
7523        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
7524        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
7525        println!(
7526            "Generated {} bytes of TypeScript preamble",
7527            JSEDITORAPI_TS_PREAMBLE.len()
7528        );
7529    }
7530
7531    #[test]
7532    fn test_typescript_editor_api_generated() {
7533        // Check that the EditorAPI interface is generated
7534        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
7535        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
7536        println!(
7537            "Generated {} bytes of EditorAPI interface",
7538            JSEDITORAPI_TS_EDITOR_API.len()
7539        );
7540    }
7541
7542    #[test]
7543    fn test_js_methods_list() {
7544        // Check that the JS methods list is generated
7545        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
7546        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
7547        // Print first 20 methods
7548        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
7549            if i < 20 {
7550                println!("  - {}", method);
7551            }
7552        }
7553        if JSEDITORAPI_JS_METHODS.len() > 20 {
7554            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
7555        }
7556    }
7557
7558    // ==================== Plugin Management API Tests ====================
7559
7560    #[test]
7561    fn test_api_load_plugin_sends_command() {
7562        let (mut backend, rx) = create_test_backend();
7563
7564        // Call loadPlugin - this returns a Promise and sends the command
7565        backend
7566            .execute_js(
7567                r#"
7568            const editor = getEditor();
7569            globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
7570        "#,
7571                "test.js",
7572            )
7573            .unwrap();
7574
7575        // Verify the LoadPlugin command was sent
7576        let cmd = rx.try_recv().unwrap();
7577        match cmd {
7578            PluginCommand::LoadPlugin { path, callback_id } => {
7579                assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
7580                assert!(callback_id.0 > 0); // Should have a valid callback ID
7581            }
7582            _ => panic!("Expected LoadPlugin, got {:?}", cmd),
7583        }
7584    }
7585
7586    #[test]
7587    fn test_api_unload_plugin_sends_command() {
7588        let (mut backend, rx) = create_test_backend();
7589
7590        // Call unloadPlugin - this returns a Promise and sends the command
7591        backend
7592            .execute_js(
7593                r#"
7594            const editor = getEditor();
7595            globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
7596        "#,
7597                "test.js",
7598            )
7599            .unwrap();
7600
7601        // Verify the UnloadPlugin command was sent
7602        let cmd = rx.try_recv().unwrap();
7603        match cmd {
7604            PluginCommand::UnloadPlugin { name, callback_id } => {
7605                assert_eq!(name, "my-plugin");
7606                assert!(callback_id.0 > 0); // Should have a valid callback ID
7607            }
7608            _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
7609        }
7610    }
7611
7612    #[test]
7613    fn test_api_reload_plugin_sends_command() {
7614        let (mut backend, rx) = create_test_backend();
7615
7616        // Call reloadPlugin - this returns a Promise and sends the command
7617        backend
7618            .execute_js(
7619                r#"
7620            const editor = getEditor();
7621            globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
7622        "#,
7623                "test.js",
7624            )
7625            .unwrap();
7626
7627        // Verify the ReloadPlugin command was sent
7628        let cmd = rx.try_recv().unwrap();
7629        match cmd {
7630            PluginCommand::ReloadPlugin { name, callback_id } => {
7631                assert_eq!(name, "my-plugin");
7632                assert!(callback_id.0 > 0); // Should have a valid callback ID
7633            }
7634            _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
7635        }
7636    }
7637
7638    #[test]
7639    fn test_api_load_plugin_resolves_callback() {
7640        let (mut backend, rx) = create_test_backend();
7641
7642        // Call loadPlugin and set up a handler for when it resolves
7643        backend
7644            .execute_js(
7645                r#"
7646            const editor = getEditor();
7647            globalThis._loadResult = null;
7648            editor.loadPlugin("/path/to/plugin.ts").then(result => {
7649                globalThis._loadResult = result;
7650            });
7651        "#,
7652                "test.js",
7653            )
7654            .unwrap();
7655
7656        // Get the callback_id from the command
7657        let callback_id = match rx.try_recv().unwrap() {
7658            PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
7659            cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
7660        };
7661
7662        // Simulate the editor responding with success
7663        backend.resolve_callback(callback_id, "true");
7664
7665        // Drive the Promise to completion
7666        backend
7667            .plugin_contexts
7668            .borrow()
7669            .get("test")
7670            .unwrap()
7671            .clone()
7672            .with(|ctx| {
7673                run_pending_jobs_checked(&ctx, "test async loadPlugin");
7674            });
7675
7676        // Verify the Promise resolved with true
7677        backend
7678            .plugin_contexts
7679            .borrow()
7680            .get("test")
7681            .unwrap()
7682            .clone()
7683            .with(|ctx| {
7684                let global = ctx.globals();
7685                let result: bool = global.get("_loadResult").unwrap();
7686                assert!(result);
7687            });
7688    }
7689
7690    #[test]
7691    fn test_api_version() {
7692        let (mut backend, _rx) = create_test_backend();
7693
7694        backend
7695            .execute_js(
7696                r#"
7697            const editor = getEditor();
7698            globalThis._apiVersion = editor.apiVersion();
7699        "#,
7700                "test.js",
7701            )
7702            .unwrap();
7703
7704        backend
7705            .plugin_contexts
7706            .borrow()
7707            .get("test")
7708            .unwrap()
7709            .clone()
7710            .with(|ctx| {
7711                let version: u32 = ctx.globals().get("_apiVersion").unwrap();
7712                assert_eq!(version, 2);
7713            });
7714    }
7715
7716    #[test]
7717    fn test_api_unload_plugin_rejects_on_error() {
7718        let (mut backend, rx) = create_test_backend();
7719
7720        // Call unloadPlugin and set up handlers for resolve/reject
7721        backend
7722            .execute_js(
7723                r#"
7724            const editor = getEditor();
7725            globalThis._unloadError = null;
7726            editor.unloadPlugin("nonexistent-plugin").catch(err => {
7727                globalThis._unloadError = err.message || String(err);
7728            });
7729        "#,
7730                "test.js",
7731            )
7732            .unwrap();
7733
7734        // Get the callback_id from the command
7735        let callback_id = match rx.try_recv().unwrap() {
7736            PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
7737            cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
7738        };
7739
7740        // Simulate the editor responding with an error
7741        backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
7742
7743        // Drive the Promise to completion
7744        backend
7745            .plugin_contexts
7746            .borrow()
7747            .get("test")
7748            .unwrap()
7749            .clone()
7750            .with(|ctx| {
7751                run_pending_jobs_checked(&ctx, "test async unloadPlugin");
7752            });
7753
7754        // Verify the Promise rejected with the error
7755        backend
7756            .plugin_contexts
7757            .borrow()
7758            .get("test")
7759            .unwrap()
7760            .clone()
7761            .with(|ctx| {
7762                let global = ctx.globals();
7763                let error: String = global.get("_unloadError").unwrap();
7764                assert!(error.contains("nonexistent-plugin"));
7765            });
7766    }
7767
7768    #[test]
7769    fn test_api_set_global_state() {
7770        let (mut backend, rx) = create_test_backend();
7771
7772        backend
7773            .execute_js(
7774                r#"
7775            const editor = getEditor();
7776            editor.setGlobalState("myKey", { enabled: true, count: 42 });
7777        "#,
7778                "test_plugin.js",
7779            )
7780            .unwrap();
7781
7782        let cmd = rx.try_recv().unwrap();
7783        match cmd {
7784            PluginCommand::SetGlobalState {
7785                plugin_name,
7786                key,
7787                value,
7788            } => {
7789                assert_eq!(plugin_name, "test_plugin");
7790                assert_eq!(key, "myKey");
7791                let v = value.unwrap();
7792                assert_eq!(v["enabled"], serde_json::json!(true));
7793                assert_eq!(v["count"], serde_json::json!(42));
7794            }
7795            _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7796        }
7797    }
7798
7799    #[test]
7800    fn test_api_set_global_state_delete() {
7801        let (mut backend, rx) = create_test_backend();
7802
7803        backend
7804            .execute_js(
7805                r#"
7806            const editor = getEditor();
7807            editor.setGlobalState("myKey", null);
7808        "#,
7809                "test_plugin.js",
7810            )
7811            .unwrap();
7812
7813        let cmd = rx.try_recv().unwrap();
7814        match cmd {
7815            PluginCommand::SetGlobalState {
7816                plugin_name,
7817                key,
7818                value,
7819            } => {
7820                assert_eq!(plugin_name, "test_plugin");
7821                assert_eq!(key, "myKey");
7822                assert!(value.is_none(), "null should delete the key");
7823            }
7824            _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
7825        }
7826    }
7827
7828    #[test]
7829    fn test_api_get_global_state_roundtrip() {
7830        let (mut backend, _rx) = create_test_backend();
7831
7832        // Set a value, then immediately read it back (write-through)
7833        backend
7834            .execute_js(
7835                r#"
7836            const editor = getEditor();
7837            editor.setGlobalState("flag", true);
7838            globalThis._result = editor.getGlobalState("flag");
7839        "#,
7840                "test_plugin.js",
7841            )
7842            .unwrap();
7843
7844        backend
7845            .plugin_contexts
7846            .borrow()
7847            .get("test_plugin")
7848            .unwrap()
7849            .clone()
7850            .with(|ctx| {
7851                let global = ctx.globals();
7852                let result: bool = global.get("_result").unwrap();
7853                assert!(
7854                    result,
7855                    "getGlobalState should return the value set by setGlobalState"
7856                );
7857            });
7858    }
7859
7860    #[test]
7861    fn test_api_get_global_state_missing_key() {
7862        let (mut backend, _rx) = create_test_backend();
7863
7864        backend
7865            .execute_js(
7866                r#"
7867            const editor = getEditor();
7868            globalThis._result = editor.getGlobalState("nonexistent");
7869            globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
7870        "#,
7871                "test_plugin.js",
7872            )
7873            .unwrap();
7874
7875        backend
7876            .plugin_contexts
7877            .borrow()
7878            .get("test_plugin")
7879            .unwrap()
7880            .clone()
7881            .with(|ctx| {
7882                let global = ctx.globals();
7883                let is_undefined: bool = global.get("_isUndefined").unwrap();
7884                assert!(
7885                    is_undefined,
7886                    "getGlobalState for missing key should return undefined"
7887                );
7888            });
7889    }
7890
7891    #[test]
7892    fn test_api_global_state_isolation_between_plugins() {
7893        // Two plugins using the same key name should not see each other's state
7894        let (tx, _rx) = mpsc::channel();
7895        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7896        let services = Arc::new(TestServiceBridge::new());
7897
7898        // Plugin A sets "flag" = true
7899        let mut backend_a =
7900            QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7901                .unwrap();
7902        backend_a
7903            .execute_js(
7904                r#"
7905            const editor = getEditor();
7906            editor.setGlobalState("flag", "from_plugin_a");
7907        "#,
7908                "plugin_a.js",
7909            )
7910            .unwrap();
7911
7912        // Plugin B sets "flag" = "from_plugin_b"
7913        let mut backend_b =
7914            QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
7915                .unwrap();
7916        backend_b
7917            .execute_js(
7918                r#"
7919            const editor = getEditor();
7920            editor.setGlobalState("flag", "from_plugin_b");
7921        "#,
7922                "plugin_b.js",
7923            )
7924            .unwrap();
7925
7926        // Plugin A should still see its own value
7927        backend_a
7928            .execute_js(
7929                r#"
7930            const editor = getEditor();
7931            globalThis._aValue = editor.getGlobalState("flag");
7932        "#,
7933                "plugin_a.js",
7934            )
7935            .unwrap();
7936
7937        backend_a
7938            .plugin_contexts
7939            .borrow()
7940            .get("plugin_a")
7941            .unwrap()
7942            .clone()
7943            .with(|ctx| {
7944                let global = ctx.globals();
7945                let a_value: String = global.get("_aValue").unwrap();
7946                assert_eq!(
7947                    a_value, "from_plugin_a",
7948                    "Plugin A should see its own value, not plugin B's"
7949                );
7950            });
7951
7952        // Plugin B should see its own value
7953        backend_b
7954            .execute_js(
7955                r#"
7956            const editor = getEditor();
7957            globalThis._bValue = editor.getGlobalState("flag");
7958        "#,
7959                "plugin_b.js",
7960            )
7961            .unwrap();
7962
7963        backend_b
7964            .plugin_contexts
7965            .borrow()
7966            .get("plugin_b")
7967            .unwrap()
7968            .clone()
7969            .with(|ctx| {
7970                let global = ctx.globals();
7971                let b_value: String = global.get("_bValue").unwrap();
7972                assert_eq!(
7973                    b_value, "from_plugin_b",
7974                    "Plugin B should see its own value, not plugin A's"
7975                );
7976            });
7977    }
7978
7979    #[test]
7980    fn test_register_command_collision_different_plugins() {
7981        let (mut backend, _rx) = create_test_backend();
7982
7983        // Plugin A registers a command
7984        backend
7985            .execute_js(
7986                r#"
7987            const editor = getEditor();
7988            globalThis.handlerA = function() { };
7989            editor.registerCommand("My Command", "From A", "handlerA", null);
7990        "#,
7991                "plugin_a.js",
7992            )
7993            .unwrap();
7994
7995        // Plugin B tries to register the same command name — should throw
7996        let result = backend.execute_js(
7997            r#"
7998            const editor = getEditor();
7999            globalThis.handlerB = function() { };
8000            editor.registerCommand("My Command", "From B", "handlerB", null);
8001        "#,
8002            "plugin_b.js",
8003        );
8004
8005        assert!(
8006            result.is_err(),
8007            "Second plugin registering the same command name should fail"
8008        );
8009        let err_msg = result.unwrap_err().to_string();
8010        assert!(
8011            err_msg.contains("already registered"),
8012            "Error should mention collision: {}",
8013            err_msg
8014        );
8015    }
8016
8017    #[test]
8018    fn test_register_command_same_plugin_allowed() {
8019        let (mut backend, _rx) = create_test_backend();
8020
8021        // Plugin A registers a command, then re-registers it (hot-reload)
8022        backend
8023            .execute_js(
8024                r#"
8025            const editor = getEditor();
8026            globalThis.handler1 = function() { };
8027            editor.registerCommand("My Command", "Version 1", "handler1", null);
8028            globalThis.handler2 = function() { };
8029            editor.registerCommand("My Command", "Version 2", "handler2", null);
8030        "#,
8031                "plugin_a.js",
8032            )
8033            .unwrap();
8034    }
8035
8036    #[test]
8037    fn test_register_command_after_unregister() {
8038        let (mut backend, _rx) = create_test_backend();
8039
8040        // Plugin A registers then unregisters
8041        backend
8042            .execute_js(
8043                r#"
8044            const editor = getEditor();
8045            globalThis.handlerA = function() { };
8046            editor.registerCommand("My Command", "From A", "handlerA", null);
8047            editor.unregisterCommand("My Command");
8048        "#,
8049                "plugin_a.js",
8050            )
8051            .unwrap();
8052
8053        // Plugin B can now register the same name
8054        backend
8055            .execute_js(
8056                r#"
8057            const editor = getEditor();
8058            globalThis.handlerB = function() { };
8059            editor.registerCommand("My Command", "From B", "handlerB", null);
8060        "#,
8061                "plugin_b.js",
8062            )
8063            .unwrap();
8064    }
8065
8066    #[test]
8067    fn test_register_command_collision_caught_in_try_catch() {
8068        let (mut backend, _rx) = create_test_backend();
8069
8070        // Plugin A registers a command
8071        backend
8072            .execute_js(
8073                r#"
8074            const editor = getEditor();
8075            globalThis.handlerA = function() { };
8076            editor.registerCommand("My Command", "From A", "handlerA", null);
8077        "#,
8078                "plugin_a.js",
8079            )
8080            .unwrap();
8081
8082        // Plugin B catches the collision error gracefully
8083        backend
8084            .execute_js(
8085                r#"
8086            const editor = getEditor();
8087            globalThis.handlerB = function() { };
8088            let caught = false;
8089            try {
8090                editor.registerCommand("My Command", "From B", "handlerB", null);
8091            } catch (e) {
8092                caught = true;
8093            }
8094            if (!caught) throw new Error("Expected collision error");
8095        "#,
8096                "plugin_b.js",
8097            )
8098            .unwrap();
8099    }
8100
8101    #[test]
8102    fn test_register_command_i18n_key_no_collision_across_plugins() {
8103        let (mut backend, _rx) = create_test_backend();
8104
8105        // Plugin A registers a %-prefixed i18n command
8106        backend
8107            .execute_js(
8108                r#"
8109            const editor = getEditor();
8110            globalThis.handlerA = function() { };
8111            editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
8112        "#,
8113                "plugin_a.js",
8114            )
8115            .unwrap();
8116
8117        // Plugin B registers the same %-prefixed i18n key — should NOT collide
8118        // because %-prefixed names are scoped per plugin
8119        backend
8120            .execute_js(
8121                r#"
8122            const editor = getEditor();
8123            globalThis.handlerB = function() { };
8124            editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
8125        "#,
8126                "plugin_b.js",
8127            )
8128            .unwrap();
8129    }
8130
8131    #[test]
8132    fn test_register_command_non_i18n_still_collides() {
8133        let (mut backend, _rx) = create_test_backend();
8134
8135        // Plugin A registers a plain (non-%) command
8136        backend
8137            .execute_js(
8138                r#"
8139            const editor = getEditor();
8140            globalThis.handlerA = function() { };
8141            editor.registerCommand("My Reload", "Reload A", "handlerA", null);
8142        "#,
8143                "plugin_a.js",
8144            )
8145            .unwrap();
8146
8147        // Plugin B tries the same plain name — should collide
8148        let result = backend.execute_js(
8149            r#"
8150            const editor = getEditor();
8151            globalThis.handlerB = function() { };
8152            editor.registerCommand("My Reload", "Reload B", "handlerB", null);
8153        "#,
8154            "plugin_b.js",
8155        );
8156
8157        assert!(
8158            result.is_err(),
8159            "Non-%-prefixed names should still collide across plugins"
8160        );
8161    }
8162}