Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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