Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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