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