Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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