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