Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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