Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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