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