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