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