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