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