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