Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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