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, SearchHandleRegistry, SearchHandleState, SearchTakeResult,
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/// Plugin-API exports map shared across every `JsEditorApi` /
112/// `QuickJsBackend` instance on a single runtime. Maps an export name to
113/// `(exporter plugin name, persistent JS object)`.
114type PluginApiExports =
115    Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>;
116
117/// Recursively copy a directory and all its contents.
118fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
119    std::fs::create_dir_all(dst)?;
120    for entry in std::fs::read_dir(src)? {
121        let entry = entry?;
122        let file_type = entry.file_type()?;
123        let src_path = entry.path();
124        let dst_path = dst.join(entry.file_name());
125        if file_type.is_dir() {
126            copy_dir_recursive(&src_path, &dst_path)?;
127        } else {
128            std::fs::copy(&src_path, &dst_path)?;
129        }
130    }
131    Ok(())
132}
133
134/// Convert a QuickJS Value to serde_json::Value
135#[allow(clippy::only_used_in_recursion)]
136fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
137    use rquickjs::Type;
138    match val.type_of() {
139        Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
140        Type::Bool => val
141            .as_bool()
142            .map(serde_json::Value::Bool)
143            .unwrap_or(serde_json::Value::Null),
144        Type::Int => val
145            .as_int()
146            .map(|n| serde_json::Value::Number(n.into()))
147            .unwrap_or(serde_json::Value::Null),
148        Type::Float => val
149            .as_float()
150            .map(|f| {
151                // Emit whole-number floats as integers so serde deserializes
152                // them into u8/i32/etc. (QuickJS promotes ints to float in some ops)
153                if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
154                    serde_json::Value::Number((f as i64).into())
155                } else {
156                    serde_json::Number::from_f64(f)
157                        .map(serde_json::Value::Number)
158                        .unwrap_or(serde_json::Value::Null)
159                }
160            })
161            .unwrap_or(serde_json::Value::Null),
162        Type::String => val
163            .as_string()
164            .and_then(|s| s.to_string().ok())
165            .map(serde_json::Value::String)
166            .unwrap_or(serde_json::Value::Null),
167        Type::Array => {
168            if let Some(arr) = val.as_array() {
169                let items: Vec<serde_json::Value> = arr
170                    .iter()
171                    .filter_map(|item| item.ok())
172                    .map(|item| js_to_json(ctx, item))
173                    .collect();
174                serde_json::Value::Array(items)
175            } else {
176                serde_json::Value::Null
177            }
178        }
179        Type::Object | Type::Constructor | Type::Function => {
180            if let Some(obj) = val.as_object() {
181                let mut map = serde_json::Map::new();
182                for key in obj.keys::<String>().flatten() {
183                    if let Ok(v) = obj.get::<_, Value>(&key) {
184                        map.insert(key, js_to_json(ctx, v));
185                    }
186                }
187                serde_json::Value::Object(map)
188            } else {
189                serde_json::Value::Null
190            }
191        }
192        _ => serde_json::Value::Null,
193    }
194}
195
196/// Convert a serde_json::Value to a QuickJS Value
197fn json_to_js_value<'js>(
198    ctx: &rquickjs::Ctx<'js>,
199    val: &serde_json::Value,
200) -> rquickjs::Result<Value<'js>> {
201    match val {
202        serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
203        serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
204        serde_json::Value::Number(n) => {
205            if let Some(i) = n.as_i64() {
206                Ok(Value::new_int(ctx.clone(), i as i32))
207            } else if let Some(f) = n.as_f64() {
208                Ok(Value::new_float(ctx.clone(), f))
209            } else {
210                Ok(Value::new_null(ctx.clone()))
211            }
212        }
213        serde_json::Value::String(s) => {
214            let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
215            Ok(js_str.into_value())
216        }
217        serde_json::Value::Array(arr) => {
218            let js_arr = rquickjs::Array::new(ctx.clone())?;
219            for (i, item) in arr.iter().enumerate() {
220                let js_val = json_to_js_value(ctx, item)?;
221                js_arr.set(i, js_val)?;
222            }
223            Ok(js_arr.into_value())
224        }
225        serde_json::Value::Object(map) => {
226            let obj = rquickjs::Object::new(ctx.clone())?;
227            for (key, val) in map {
228                let js_val = json_to_js_value(ctx, val)?;
229                obj.set(key.as_str(), js_val)?;
230            }
231            Ok(obj.into_value())
232        }
233    }
234}
235
236/// Call a JS handler function directly with structured data, bypassing JSON
237/// string serialization and JS-side `JSON.parse()` + source re-parsing.
238fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
239    let js_data = match json_to_js_value(ctx, event_data) {
240        Ok(v) => v,
241        Err(e) => {
242            log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
243            return;
244        }
245    };
246
247    let globals = ctx.globals();
248    let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
249        return;
250    };
251
252    match func.call::<_, rquickjs::Value>((js_data,)) {
253        Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
254        Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
255    }
256
257    run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
258}
259
260/// If `result` is a thenable (Promise), attach `.catch()` to surface async rejections.
261fn attach_promise_catch<'js>(
262    ctx: &rquickjs::Ctx<'js>,
263    globals: &rquickjs::Object<'js>,
264    handler_name: &str,
265    result: rquickjs::Value<'js>,
266) {
267    let Some(obj) = result.as_object() else {
268        return;
269    };
270    if obj.get::<_, rquickjs::Function>("then").is_err() {
271        return;
272    }
273    let _ = globals.set("__pendingPromise", result);
274    let catch_code = format!(
275        r#"globalThis.__pendingPromise.catch(function(e) {{
276            console.error('Handler {} async error:', e);
277            throw e;
278        }}); delete globalThis.__pendingPromise;"#,
279        handler_name
280    );
281    let _ = ctx.eval::<(), _>(catch_code.as_bytes());
282}
283
284/// Get text properties at cursor position
285fn get_text_properties_at_cursor_typed(
286    snapshot: &Arc<RwLock<EditorStateSnapshot>>,
287    buffer_id: u32,
288) -> fresh_core::api::TextPropertiesAtCursor {
289    use fresh_core::api::TextPropertiesAtCursor;
290
291    let snap = match snapshot.read() {
292        Ok(s) => s,
293        Err(_) => return TextPropertiesAtCursor(Vec::new()),
294    };
295    let buffer_id_typed = BufferId(buffer_id as usize);
296    let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
297    let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
298        snap.primary_cursor.as_ref().map(|c| c.position)
299    } else {
300        None
301    };
302    let cursor_pos = match snapshot_pos.or(fallback_pos) {
303        Some(pos) => pos,
304        None => {
305            tracing::debug!(
306                "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
307                buffer_id_typed,
308                snapshot_pos,
309                snap.active_buffer_id
310            );
311            return TextPropertiesAtCursor(Vec::new());
312        }
313    };
314
315    let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
316        Some(p) => p,
317        None => {
318            tracing::debug!(
319                "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
320                buffer_id_typed,
321                cursor_pos
322            );
323            return TextPropertiesAtCursor(Vec::new());
324        }
325    };
326
327    let result: Vec<_> = properties
328        .iter()
329        .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
330        .map(|prop| prop.properties.clone())
331        .collect();
332
333    tracing::debug!(
334        "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
335        buffer_id_typed,
336        cursor_pos,
337        snapshot_pos,
338        fallback_pos,
339        snap.active_buffer_id,
340        properties.len(),
341        result.len()
342    );
343
344    TextPropertiesAtCursor(result)
345}
346
347/// Convert a JavaScript value to a string representation for console output
348fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
349    use rquickjs::Type;
350    match val.type_of() {
351        Type::Null => "null".to_string(),
352        Type::Undefined => "undefined".to_string(),
353        Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
354        Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
355        Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
356        Type::String => val
357            .as_string()
358            .and_then(|s| s.to_string().ok())
359            .unwrap_or_default(),
360        Type::Object | Type::Exception => {
361            // Check if this is an Error object (has message/stack properties)
362            if let Some(obj) = val.as_object() {
363                // Try to get error properties
364                let name: Option<String> = obj.get("name").ok();
365                let message: Option<String> = obj.get("message").ok();
366                let stack: Option<String> = obj.get("stack").ok();
367
368                if message.is_some() || name.is_some() {
369                    // This looks like an Error object
370                    let name = name.unwrap_or_else(|| "Error".to_string());
371                    let message = message.unwrap_or_default();
372                    if let Some(stack) = stack {
373                        return format!("{}: {}\n{}", name, message, stack);
374                    } else {
375                        return format!("{}: {}", name, message);
376                    }
377                }
378
379                // Regular object - convert to JSON
380                let json = js_to_json(ctx, val.clone());
381                serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
382            } else {
383                "[object]".to_string()
384            }
385        }
386        Type::Array => {
387            let json = js_to_json(ctx, val.clone());
388            serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
389        }
390        Type::Function | Type::Constructor => "[function]".to_string(),
391        Type::Symbol => "[symbol]".to_string(),
392        Type::BigInt => val
393            .as_big_int()
394            .and_then(|b| b.clone().to_i64().ok())
395            .map(|n| n.to_string())
396            .unwrap_or_else(|| "[bigint]".to_string()),
397        _ => format!("[{}]", val.type_name()),
398    }
399}
400
401/// Format a JavaScript error with full details including stack trace
402fn format_js_error(
403    ctx: &rquickjs::Ctx<'_>,
404    err: rquickjs::Error,
405    source_name: &str,
406) -> anyhow::Error {
407    // Check if this is an exception that we can catch for more details
408    if err.is_exception() {
409        // Try to catch the exception to get the full error object
410        let exc = ctx.catch();
411        if !exc.is_undefined() && !exc.is_null() {
412            // Try to get error message and stack from the exception object
413            if let Some(exc_obj) = exc.as_object() {
414                let message: String = exc_obj
415                    .get::<_, String>("message")
416                    .unwrap_or_else(|_| "Unknown error".to_string());
417                let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
418                let name: String = exc_obj
419                    .get::<_, String>("name")
420                    .unwrap_or_else(|_| "Error".to_string());
421
422                if !stack.is_empty() {
423                    return anyhow::anyhow!(
424                        "JS error in {}: {}: {}\nStack trace:\n{}",
425                        source_name,
426                        name,
427                        message,
428                        stack
429                    );
430                } else {
431                    return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
432                }
433            } else {
434                // Exception is not an object, try to convert to string
435                let exc_str: String = exc
436                    .as_string()
437                    .and_then(|s: &rquickjs::String| s.to_string().ok())
438                    .unwrap_or_else(|| format!("{:?}", exc));
439                return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
440            }
441        }
442    }
443
444    // Fall back to the basic error message
445    anyhow::anyhow!("JS error in {}: {}", source_name, err)
446}
447
448/// Log a JavaScript error with full details
449/// If panic_on_js_errors is enabled, this will panic to surface JS errors immediately
450fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
451    let error = format_js_error(ctx, err, context);
452    tracing::error!("{}", error);
453
454    // When enabled, panic on JS errors to make them visible and fail fast
455    if should_panic_on_js_errors() {
456        panic!("JavaScript error in {}: {}", context, error);
457    }
458}
459
460/// Global flag to panic on JS errors (enabled during testing)
461static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
462    std::sync::atomic::AtomicBool::new(false);
463
464/// Enable panicking on JS errors (call this from test setup)
465pub fn set_panic_on_js_errors(enabled: bool) {
466    PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
467}
468
469/// Check if panic on JS errors is enabled
470fn should_panic_on_js_errors() -> bool {
471    PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
472}
473
474/// Global flag indicating a fatal JS error occurred that should terminate the plugin thread.
475/// This is used because panicking inside rquickjs callbacks (FFI boundary) gets caught by
476/// rquickjs's catch_unwind, so we need an alternative mechanism to signal errors.
477static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
478
479/// Storage for the fatal error message
480static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
481
482/// Set a fatal JS error - call this instead of panicking inside FFI callbacks
483fn set_fatal_js_error(msg: String) {
484    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
485        if guard.is_none() {
486            // Only store the first error
487            *guard = Some(msg);
488        }
489    }
490    FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
491}
492
493/// Check if a fatal JS error has occurred
494pub fn has_fatal_js_error() -> bool {
495    FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
496}
497
498/// Get and clear the fatal JS error message (returns None if no error)
499pub fn take_fatal_js_error() -> Option<String> {
500    if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
501        return None;
502    }
503    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
504        guard.take()
505    } else {
506        Some("Fatal JS error (message unavailable)".to_string())
507    }
508}
509
510/// Run all pending jobs and check for unhandled exceptions
511/// If panic_on_js_errors is enabled, this will panic on unhandled exceptions
512fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
513    let mut count = 0;
514    loop {
515        // Check for unhandled exception before running more jobs
516        let exc: rquickjs::Value = ctx.catch();
517        // Only treat it as an exception if it's actually an Error object
518        if exc.is_exception() {
519            let error_msg = if let Some(err) = exc.as_exception() {
520                format!(
521                    "{}: {}",
522                    err.message().unwrap_or_default(),
523                    err.stack().unwrap_or_default()
524                )
525            } else {
526                format!("{:?}", exc)
527            };
528            tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
529            if should_panic_on_js_errors() {
530                panic!("Unhandled JS exception during {}: {}", context, error_msg);
531            }
532        }
533
534        if !ctx.execute_pending_job() {
535            break;
536        }
537        count += 1;
538    }
539
540    // Final check for exceptions after all jobs completed
541    let exc: rquickjs::Value = ctx.catch();
542    if exc.is_exception() {
543        let error_msg = if let Some(err) = exc.as_exception() {
544            format!(
545                "{}: {}",
546                err.message().unwrap_or_default(),
547                err.stack().unwrap_or_default()
548            )
549        } else {
550            format!("{:?}", exc)
551        };
552        tracing::error!(
553            "Unhandled JS exception after running jobs in {}: {}",
554            context,
555            error_msg
556        );
557        if should_panic_on_js_errors() {
558            panic!(
559                "Unhandled JS exception after running jobs in {}: {}",
560                context, error_msg
561            );
562        }
563    }
564
565    count
566}
567
568/// Parse a TextPropertyEntry from a JS Object
569fn parse_text_property_entry(
570    ctx: &rquickjs::Ctx<'_>,
571    obj: &Object<'_>,
572) -> Option<TextPropertyEntry> {
573    let text: String = obj.get("text").ok()?;
574    let properties: HashMap<String, serde_json::Value> = obj
575        .get::<_, Object>("properties")
576        .ok()
577        .map(|props_obj| {
578            let mut map = HashMap::new();
579            for key in props_obj.keys::<String>().flatten() {
580                if let Ok(v) = props_obj.get::<_, Value>(&key) {
581                    map.insert(key, js_to_json(ctx, v));
582                }
583            }
584            map
585        })
586        .unwrap_or_default();
587
588    // Parse optional style field
589    let style: Option<fresh_core::api::OverlayOptions> =
590        obj.get::<_, Object>("style").ok().and_then(|style_obj| {
591            let json_val = js_to_json(ctx, Value::from_object(style_obj));
592            serde_json::from_value(json_val).ok()
593        });
594
595    // Parse optional inlineOverlays array
596    let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
597        .get::<_, rquickjs::Array>("inlineOverlays")
598        .ok()
599        .map(|arr| {
600            arr.iter::<Object>()
601                .flatten()
602                .filter_map(|item| {
603                    let json_val = js_to_json(ctx, Value::from_object(item));
604                    serde_json::from_value(json_val).ok()
605                })
606                .collect()
607        })
608        .unwrap_or_default();
609
610    let pad_to_chars: Option<u32> = obj
611        .get::<_, f64>("padToChars")
612        .ok()
613        .map(|v| v.max(0.0) as u32);
614    let truncate_to_chars: Option<u32> = obj
615        .get::<_, f64>("truncateToChars")
616        .ok()
617        .map(|v| v.max(0.0) as u32);
618
619    let segments: Vec<fresh_core::text_property::StyledSegment> = obj
620        .get::<_, rquickjs::Array>("segments")
621        .ok()
622        .map(|arr| {
623            arr.iter::<Object>()
624                .flatten()
625                .filter_map(|item| {
626                    let json_val = js_to_json(ctx, Value::from_object(item));
627                    serde_json::from_value(json_val).ok()
628                })
629                .collect()
630        })
631        .unwrap_or_default();
632
633    Some(TextPropertyEntry {
634        text,
635        properties,
636        style,
637        inline_overlays,
638        segments,
639        pad_to_chars,
640        truncate_to_chars,
641    })
642}
643
644/// Pending response senders type alias
645pub type PendingResponses =
646    Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
647
648/// Information about a loaded plugin
649#[derive(Debug, Clone)]
650pub struct TsPluginInfo {
651    pub name: String,
652    pub path: PathBuf,
653    pub enabled: bool,
654    /// `.d.ts` emit for this plugin's source, produced by oxc's
655    /// isolated-declarations transformer at load time. Used to build
656    /// a consolidated plugins.d.ts under `<config_dir>/types/` so
657    /// `getPluginApi("foo")` can be typed without manual casts in
658    /// init.ts / downstream plugins. `None` means isolated-
659    /// declarations emit failed (plugin still runs).
660    pub declarations: Option<String>,
661}
662
663/// Handler information for events and actions
664/// Tracks state created by a plugin for cleanup on unload.
665///
666/// Each field records identifiers (namespaces, IDs, names) so that we can send
667/// compensating `PluginCommand`s when the plugin is unloaded.
668#[derive(Debug, Clone, Default)]
669pub struct PluginTrackedState {
670    /// (buffer_id, namespace) pairs used for overlays, conceals, soft breaks
671    pub overlay_namespaces: Vec<(BufferId, String)>,
672    /// (buffer_id, namespace) pairs used for virtual lines
673    pub virtual_line_namespaces: Vec<(BufferId, String)>,
674    /// (buffer_id, namespace) pairs used for line indicators
675    pub line_indicator_namespaces: Vec<(BufferId, String)>,
676    /// (buffer_id, virtual_text_id) pairs
677    pub virtual_text_ids: Vec<(BufferId, String)>,
678    /// File explorer decoration namespaces
679    pub file_explorer_namespaces: Vec<String>,
680    /// Context names set by the plugin
681    pub contexts_set: Vec<String>,
682    // --- Phase 3: Resource cleanup ---
683    /// Background process IDs spawned by this plugin
684    pub background_process_ids: Vec<u64>,
685    /// Scroll sync group IDs created by this plugin
686    pub scroll_sync_group_ids: Vec<u32>,
687    /// Virtual buffer IDs created by this plugin
688    pub virtual_buffer_ids: Vec<BufferId>,
689    /// Composite buffer IDs created by this plugin
690    pub composite_buffer_ids: Vec<BufferId>,
691    /// Terminal IDs created by this plugin
692    pub terminal_ids: Vec<fresh_core::TerminalId>,
693    /// File-watcher handles created by this plugin via
694    /// `editor.watchPath`. Cleaned up by sending UnwatchPath on
695    /// plugin unload.
696    pub watch_handles: Vec<u64>,
697}
698
699/// Type alias for the shared async resource owner map.
700/// Maps request_id → plugin_name for pending async resource creations
701/// (virtual buffers, composite buffers, terminals).
702/// Shared between QuickJsBackend (plugin thread) and PluginThreadHandle (main thread).
703pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
704
705/// Plugin event handler registry shared between the plugin thread (which
706/// reads + mutates on `on` / `off` / `emit` / `cleanup_plugin`) and the
707/// editor thread (which only does cheap "does anyone listen?" lookups
708/// via `has_subscribers` to gate expensive per-render hook arg building).
709///
710/// Switched from `Rc<RefCell<...>>` to `Arc<RwLock<...>>` so the editor
711/// thread can read it lock-free in the uncontended case (read fast path).
712pub type EventHandlerRegistry = Arc<RwLock<HashMap<String, Vec<PluginHandler>>>>;
713
714#[derive(Debug, Clone)]
715pub struct PluginHandler {
716    pub plugin_name: String,
717    pub handler_name: String,
718}
719
720/// Parse an `AnimationRect` from a JS object. Missing fields are treated
721/// as 0, which renders as a zero-area rect the runner drops immediately.
722fn parse_animation_rect(
723    obj: &rquickjs::Object<'_>,
724) -> rquickjs::Result<fresh_core::api::AnimationRect> {
725    Ok(fresh_core::api::AnimationRect {
726        x: obj.get::<_, u16>("x").unwrap_or(0),
727        y: obj.get::<_, u16>("y").unwrap_or(0),
728        width: obj.get::<_, u16>("width").unwrap_or(0),
729        height: obj.get::<_, u16>("height").unwrap_or(0),
730    })
731}
732
733/// Parse a `PluginAnimationKind` from a JS object keyed by `kind`. Unknown
734/// kinds fall back to the default `slideIn` shape so the editor side can
735/// still construct something sensible rather than crash.
736fn parse_animation_kind(
737    obj: &rquickjs::Object<'_>,
738) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
739    use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
740    let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
741    match kind.as_str() {
742        "slideIn" | "" => {
743            let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
744            let from = match from_str.as_str() {
745                "top" => PluginAnimationEdge::Top,
746                "left" => PluginAnimationEdge::Left,
747                "right" => PluginAnimationEdge::Right,
748                _ => PluginAnimationEdge::Bottom,
749            };
750            let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
751            let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
752            Ok(PluginAnimationKind::SlideIn {
753                from,
754                duration_ms,
755                delay_ms,
756            })
757        }
758        other => Err(rquickjs::Error::new_from_js_message(
759            "string",
760            "PluginAnimationKind",
761            format!("unknown animation kind: {}", other),
762        )),
763    }
764}
765
766/// JavaScript-exposed Editor API using rquickjs class system
767/// This allows proper lifetime handling for methods returning JS values
768#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
769#[rquickjs::class]
770pub struct JsEditorApi {
771    #[qjs(skip_trace)]
772    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
773    #[qjs(skip_trace)]
774    command_sender: mpsc::Sender<PluginCommand>,
775    #[qjs(skip_trace)]
776    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
777    #[qjs(skip_trace)]
778    event_handlers: EventHandlerRegistry,
779    #[qjs(skip_trace)]
780    next_request_id: Rc<RefCell<u64>>,
781    #[qjs(skip_trace)]
782    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
783    #[qjs(skip_trace)]
784    services: Arc<dyn fresh_core::services::PluginServiceBridge>,
785    #[qjs(skip_trace)]
786    plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
787    #[qjs(skip_trace)]
788    async_resource_owners: AsyncResourceOwners,
789    /// Tracks command name → owning plugin name (first-writer-wins collision detection)
790    #[qjs(skip_trace)]
791    registered_command_names: Rc<RefCell<HashMap<String, String>>>,
792    /// Tracks grammar language → owning plugin name (first-writer-wins collision detection)
793    #[qjs(skip_trace)]
794    registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
795    /// Tracks language config language → owning plugin name (first-writer-wins collision detection)
796    #[qjs(skip_trace)]
797    registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
798    /// Tracks LSP server language → owning plugin name (first-writer-wins collision detection)
799    #[qjs(skip_trace)]
800    registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
801    /// Plugin-configuration plane (design M3): name → (exporter plugin_name,
802    /// persistent JS Object). Shared across every plugin context on the
803    /// same Runtime so init.ts can reach another plugin's typed API.
804    #[qjs(skip_trace)]
805    plugin_api_exports: PluginApiExports,
806    /// Streaming-search handle registry. Shared with the editor thread so
807    /// host searcher tasks write into the same `SearchHandleState` the JS
808    /// side drains via `_searchHandleTake`.
809    #[qjs(skip_trace)]
810    search_handles: SearchHandleRegistry,
811    pub plugin_name: String,
812}
813
814// ─── Helpers for the defineConfigX methods ────────────────────────────
815// Free functions kept out of the impl block (which is processed by the
816// `plugin_api_impl` macro and would otherwise try to export them).
817
818fn throw_js<'js>(ctx: &rquickjs::Ctx<'js>, msg: &str) -> rquickjs::Error {
819    match rquickjs::String::from_str(ctx.clone(), msg) {
820        Ok(s) => ctx.throw(s.into_value()),
821        Err(e) => e,
822    }
823}
824
825fn parse_options<'js>(
826    ctx: &rquickjs::Ctx<'js>,
827    method: &str,
828    field: &str,
829    options: rquickjs::Object<'js>,
830) -> rquickjs::Result<serde_json::Map<String, serde_json::Value>> {
831    let value: serde_json::Value = rquickjs_serde::from_value(options.into_value())
832        .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
833    match value {
834        serde_json::Value::Object(m) => Ok(m),
835        _ => Err(throw_js(
836            ctx,
837            &format!("{}(\"{}\"): options must be an object", method, field),
838        )),
839    }
840}
841
842fn validate_allowed_keys<'js>(
843    ctx: &rquickjs::Ctx<'js>,
844    method: &str,
845    field: &str,
846    opts: &serde_json::Map<String, serde_json::Value>,
847    allowed: &[&str],
848) -> rquickjs::Result<()> {
849    for k in opts.keys() {
850        if !allowed.contains(&k.as_str()) {
851            return Err(throw_js(
852                ctx,
853                &format!(
854                    "{}(\"{}\"): unknown option `{}` (allowed: {})",
855                    method,
856                    field,
857                    k,
858                    allowed.join(", "),
859                ),
860            ));
861        }
862    }
863    Ok(())
864}
865
866fn string_opt(opts: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
867    opts.get(key)
868        .and_then(|v| v.as_str())
869        .map(|s| s.to_string())
870}
871
872fn require_integer<'js>(
873    ctx: &rquickjs::Ctx<'js>,
874    method: &str,
875    field: &str,
876    opts: &serde_json::Map<String, serde_json::Value>,
877    key: &str,
878) -> rquickjs::Result<i64> {
879    match opts.get(key) {
880        Some(v) => v.as_i64().ok_or_else(|| {
881            throw_js(
882                ctx,
883                &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
884            )
885        }),
886        None => Err(throw_js(
887            ctx,
888            &format!("{}(\"{}\"): `{}` is required", method, field, key),
889        )),
890    }
891}
892
893fn optional_integer<'js>(
894    ctx: &rquickjs::Ctx<'js>,
895    method: &str,
896    field: &str,
897    opts: &serde_json::Map<String, serde_json::Value>,
898    key: &str,
899) -> rquickjs::Result<Option<i64>> {
900    match opts.get(key) {
901        None => Ok(None),
902        Some(v) => v.as_i64().map(Some).ok_or_else(|| {
903            throw_js(
904                ctx,
905                &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
906            )
907        }),
908    }
909}
910
911fn require_number<'js>(
912    ctx: &rquickjs::Ctx<'js>,
913    method: &str,
914    field: &str,
915    opts: &serde_json::Map<String, serde_json::Value>,
916    key: &str,
917) -> rquickjs::Result<f64> {
918    match opts.get(key) {
919        Some(v) => v.as_f64().ok_or_else(|| {
920            throw_js(
921                ctx,
922                &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
923            )
924        }),
925        None => Err(throw_js(
926            ctx,
927            &format!("{}(\"{}\"): `{}` is required", method, field, key),
928        )),
929    }
930}
931
932fn optional_number<'js>(
933    ctx: &rquickjs::Ctx<'js>,
934    method: &str,
935    field: &str,
936    opts: &serde_json::Map<String, serde_json::Value>,
937    key: &str,
938) -> rquickjs::Result<Option<f64>> {
939    match opts.get(key) {
940        None => Ok(None),
941        Some(v) => v.as_f64().map(Some).ok_or_else(|| {
942            throw_js(
943                ctx,
944                &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
945            )
946        }),
947    }
948}
949
950fn check_range<'js>(
951    ctx: &rquickjs::Ctx<'js>,
952    method: &str,
953    field: &str,
954    default: f64,
955    minimum: Option<f64>,
956    maximum: Option<f64>,
957) -> rquickjs::Result<()> {
958    if let Some(min) = minimum {
959        if default < min {
960            return Err(throw_js(
961                ctx,
962                &format!(
963                    "{}(\"{}\"): default ({}) is below minimum ({})",
964                    method, field, default, min
965                ),
966            ));
967        }
968    }
969    if let Some(max) = maximum {
970        if default > max {
971            return Err(throw_js(
972                ctx,
973                &format!(
974                    "{}(\"{}\"): default ({}) is above maximum ({})",
975                    method, field, default, max
976                ),
977            ));
978        }
979    }
980    if let (Some(min), Some(max)) = (minimum, maximum) {
981        if min > max {
982            return Err(throw_js(
983                ctx,
984                &format!(
985                    "{}(\"{}\"): minimum ({}) is greater than maximum ({})",
986                    method, field, min, max
987                ),
988            ));
989        }
990    }
991    Ok(())
992}
993
994// Internal helpers used by the macro-processed `impl JsEditorApi` below.
995// Kept in a plain impl block so they don't get exported as JS methods.
996impl JsEditorApi {
997    /// Send an AddPluginConfigField command to the host.
998    fn send_field_registration(&self, field_name: &str, field_schema: serde_json::Value) {
999        let _ = self
1000            .command_sender
1001            .send(PluginCommand::AddPluginConfigField {
1002                plugin_name: self.plugin_name.clone(),
1003                field_name: field_name.to_string(),
1004                field_schema,
1005            });
1006    }
1007
1008    /// Look up the current value of one of this plugin's settings
1009    /// fields from the snapshot. Returns `None` if not yet present.
1010    fn current_field_value(&self, field_name: &str) -> Option<serde_json::Value> {
1011        self.state_snapshot.read().ok().and_then(|s| {
1012            s.config
1013                .pointer(&format!(
1014                    "/plugins/{}/settings/{}",
1015                    self.plugin_name, field_name
1016                ))
1017                .cloned()
1018        })
1019    }
1020}
1021
1022#[plugin_api_impl]
1023#[rquickjs::methods(rename_all = "camelCase")]
1024impl JsEditorApi {
1025    // === Buffer Queries ===
1026
1027    /// Get the plugin API version. Plugins can check this to verify
1028    /// the editor supports the features they need.
1029    pub fn api_version(&self) -> u32 {
1030        2
1031    }
1032
1033    /// The name of the plugin this `editor` handle belongs to. Used by the
1034    /// M3 plugin-API plane (`exportPluginApi` tags the exporter). Plugin
1035    /// authors generally don't call this directly.
1036    pub fn plugin_name(&self) -> String {
1037        self.plugin_name.clone()
1038    }
1039
1040    /// Publish a typed API surface under `name`. Another plugin (typically
1041    /// `init.ts`) can reach it later via `getPluginApi(name)`. Calling
1042    /// again with the same `name` replaces the previous registration
1043    /// (idempotent — reload works). Exports are auto-dropped when the
1044    /// calling plugin is unloaded.
1045    ///
1046    /// Returns `true` on success. Rejects with a TypeError if `name` is
1047    /// empty or `api` is not an object (functions and primitives are not
1048    /// valid API surfaces — only objects).
1049    #[plugin_api(ts_return = "boolean")]
1050    pub fn export_plugin_api<'js>(
1051        &self,
1052        ctx: rquickjs::Ctx<'js>,
1053        name: String,
1054        api: rquickjs::Value<'js>,
1055    ) -> rquickjs::Result<bool> {
1056        if name.is_empty() {
1057            let msg =
1058                rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
1059            return Err(ctx.throw(msg.into_value()));
1060        }
1061        let obj = match api.as_object() {
1062            Some(o) => o.clone(),
1063            None => {
1064                let msg = rquickjs::String::from_str(
1065                    ctx.clone(),
1066                    "exportPluginApi: api must be an object",
1067                )?;
1068                return Err(ctx.throw(msg.into_value()));
1069            }
1070        };
1071        let persistent = rquickjs::Persistent::save(&ctx, obj);
1072        self.plugin_api_exports
1073            .borrow_mut()
1074            .insert(name, (self.plugin_name.clone(), persistent));
1075        Ok(true)
1076    }
1077
1078    /// Look up a plugin API previously published via `exportPluginApi`.
1079    /// Returns the api object (restored into the caller's context) or
1080    /// `null` if no plugin exports under that name.
1081    #[plugin_api(ts_return = "unknown | null")]
1082    pub fn get_plugin_api<'js>(
1083        &self,
1084        ctx: rquickjs::Ctx<'js>,
1085        name: String,
1086    ) -> rquickjs::Result<rquickjs::Value<'js>> {
1087        let persistent = self
1088            .plugin_api_exports
1089            .borrow()
1090            .get(&name)
1091            .map(|(_exporter, p)| p.clone());
1092        match persistent {
1093            Some(p) => {
1094                let restored = p.restore(&ctx)?;
1095                Ok(restored.into_value())
1096            }
1097            None => Ok(rquickjs::Value::new_null(ctx)),
1098        }
1099    }
1100
1101    /// Get the active buffer ID (0 if none)
1102    pub fn get_active_buffer_id(&self) -> u32 {
1103        self.state_snapshot
1104            .read()
1105            .map(|s| s.active_buffer_id.0 as u32)
1106            .unwrap_or(0)
1107    }
1108
1109    /// Get the active split ID
1110    pub fn get_active_split_id(&self) -> u32 {
1111        self.state_snapshot
1112            .read()
1113            .map(|s| s.active_split_id as u32)
1114            .unwrap_or(0)
1115    }
1116
1117    /// List all open buffers - returns array of BufferInfo objects
1118    #[plugin_api(ts_return = "BufferInfo[]")]
1119    pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1120        let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
1121            s.buffers.values().cloned().collect()
1122        } else {
1123            Vec::new()
1124        };
1125        rquickjs_serde::to_value(ctx, &buffers)
1126            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1127    }
1128
1129    /// List all available grammars with source info - returns array of GrammarInfo objects
1130    #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
1131    pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1132        let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
1133            s.available_grammars.clone()
1134        } else {
1135            Vec::new()
1136        };
1137        rquickjs_serde::to_value(ctx, &grammars)
1138            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1139    }
1140
1141    // === Logging ===
1142
1143    pub fn debug(&self, msg: String) {
1144        tracing::debug!("Plugin: {}", msg);
1145    }
1146
1147    pub fn info(&self, msg: String) {
1148        tracing::info!("Plugin: {}", msg);
1149    }
1150
1151    pub fn warn(&self, msg: String) {
1152        tracing::warn!("Plugin: {}", msg);
1153    }
1154
1155    pub fn error(&self, msg: String) {
1156        tracing::error!("Plugin: {}", msg);
1157    }
1158
1159    // === Status ===
1160
1161    pub fn set_status(&self, msg: String) {
1162        let _ = self
1163            .command_sender
1164            .send(PluginCommand::SetStatus { message: msg });
1165    }
1166
1167    // === Clipboard ===
1168
1169    pub fn copy_to_clipboard(&self, text: String) {
1170        let _ = self
1171            .command_sender
1172            .send(PluginCommand::SetClipboard { text });
1173    }
1174
1175    pub fn set_clipboard(&self, text: String) {
1176        let _ = self
1177            .command_sender
1178            .send(PluginCommand::SetClipboard { text });
1179    }
1180
1181    // === Keybinding Queries ===
1182
1183    /// Get the display label for a keybinding by action name and optional mode.
1184    /// Returns null if no binding is found.
1185    pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
1186        if let Some(mode_name) = mode {
1187            let key = format!("{}\0{}", action, mode_name);
1188            if let Ok(snapshot) = self.state_snapshot.read() {
1189                return snapshot.keybinding_labels.get(&key).cloned();
1190            }
1191        }
1192        None
1193    }
1194
1195    // === Command Registration ===
1196
1197    /// Register a command in the command palette (Ctrl+P).
1198    ///
1199    /// Usually you should omit `context` so the command is always visible.
1200    /// If provided, the command is **hidden** unless your plugin has activated
1201    /// that context with `editor.setContext(name, true)` or the focused buffer's
1202    /// virtual mode (from `defineMode()`) matches. This is for plugin-defined
1203    /// contexts only (e.g. `"tour-active"`, `"review-mode"`), not built-in
1204    /// editor modes.
1205    pub fn register_command<'js>(
1206        &self,
1207        ctx: rquickjs::Ctx<'js>,
1208        name: String,
1209        description: String,
1210        handler_name: String,
1211        #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
1212            rquickjs::Value<'js>,
1213        >,
1214        #[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
1215        options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1216    ) -> rquickjs::Result<bool> {
1217        // Use stored plugin name instead of global lookup
1218        let plugin_name = self.plugin_name.clone();
1219        // Extract context string - handle null, undefined, or missing
1220        let context_str: Option<String> = context.0.and_then(|v| {
1221            if v.is_null() || v.is_undefined() {
1222                None
1223            } else {
1224                v.as_string().and_then(|s| s.to_string().ok())
1225            }
1226        });
1227
1228        tracing::debug!(
1229            "registerCommand: plugin='{}', name='{}', handler='{}'",
1230            plugin_name,
1231            name,
1232            handler_name
1233        );
1234
1235        // First-writer-wins: check if another plugin already registered this command name
1236        // Names starting with '%' are per-plugin i18n keys (e.g. "%cmd.reload") that resolve
1237        // to different display strings per plugin, so they are scoped by plugin name.
1238        let tracking_key = if name.starts_with('%') {
1239            format!("{}:{}", plugin_name, name)
1240        } else {
1241            name.clone()
1242        };
1243        {
1244            let names = self.registered_command_names.borrow();
1245            if let Some(existing_plugin) = names.get(&tracking_key) {
1246                if existing_plugin != &plugin_name {
1247                    let msg = format!(
1248                        "Command '{}' already registered by plugin '{}'",
1249                        name, existing_plugin
1250                    );
1251                    tracing::warn!("registerCommand collision: {}", msg);
1252                    return Err(
1253                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1254                    );
1255                }
1256                // Same plugin re-registering its own command is allowed (hot-reload)
1257            }
1258        }
1259
1260        // Record ownership
1261        self.registered_command_names
1262            .borrow_mut()
1263            .insert(tracking_key, plugin_name.clone());
1264
1265        // Store action handler mapping with its plugin name
1266        self.registered_actions.borrow_mut().insert(
1267            handler_name.clone(),
1268            PluginHandler {
1269                plugin_name: self.plugin_name.clone(),
1270                handler_name: handler_name.clone(),
1271            },
1272        );
1273
1274        // Extract `options.terminalBypass`. JS shape: `{ terminalBypass: true }`
1275        // or omitted/null. Anything else is ignored — wrong shape stays at the
1276        // safe default of `false`.
1277        let terminal_bypass: bool = options
1278            .0
1279            .and_then(|v| {
1280                if v.is_null() || v.is_undefined() {
1281                    None
1282                } else {
1283                    v.into_object()
1284                        .and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
1285                }
1286            })
1287            .unwrap_or(false);
1288
1289        // Register with editor
1290        let command = Command {
1291            name: name.clone(),
1292            description,
1293            action_name: handler_name,
1294            plugin_name,
1295            custom_contexts: context_str.into_iter().collect(),
1296            terminal_bypass,
1297        };
1298
1299        Ok(self
1300            .command_sender
1301            .send(PluginCommand::RegisterCommand { command })
1302            .is_ok())
1303    }
1304
1305    /// Unregister a command by name
1306    pub fn unregister_command(&self, name: String) -> bool {
1307        // Clear ownership tracking so another plugin can register this name
1308        // Use same scoping logic as register_command for %-prefixed i18n keys
1309        let tracking_key = if name.starts_with('%') {
1310            format!("{}:{}", self.plugin_name, name)
1311        } else {
1312            name.clone()
1313        };
1314        self.registered_command_names
1315            .borrow_mut()
1316            .remove(&tracking_key);
1317        self.command_sender
1318            .send(PluginCommand::UnregisterCommand { name })
1319            .is_ok()
1320    }
1321
1322    /// Set a context (for keybinding conditions)
1323    pub fn set_context(&self, name: String, active: bool) -> bool {
1324        // Track context name for cleanup on unload
1325        if active {
1326            self.plugin_tracked_state
1327                .borrow_mut()
1328                .entry(self.plugin_name.clone())
1329                .or_default()
1330                .contexts_set
1331                .push(name.clone());
1332        }
1333        self.command_sender
1334            .send(PluginCommand::SetContext { name, active })
1335            .is_ok()
1336    }
1337
1338    /// Execute a built-in action
1339    pub fn execute_action(&self, action_name: String) -> bool {
1340        self.command_sender
1341            .send(PluginCommand::ExecuteAction { action_name })
1342            .is_ok()
1343    }
1344
1345    /// Register a custom statusbar token.
1346    /// Token will be named "plugin_name:token_name" where plugin_name is the current plugin.
1347    /// Returns true if registration succeeded, false if invalid or already registered.
1348    pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
1349        let plugin_name = self.plugin_name.clone();
1350        self.command_sender
1351            .send(PluginCommand::RegisterStatusBarElement {
1352                plugin_name,
1353                token_name,
1354                title,
1355            })
1356            .is_ok()
1357    }
1358
1359    /// Set the value of a status-bar token for a specific buffer.
1360    /// The full token key sent to the editor is "plugin_name:token_name".
1361    pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
1362        let key = format!("{}:{}", self.plugin_name, token_name);
1363        self.command_sender
1364            .send(PluginCommand::SetStatusBarValue {
1365                buffer_id,
1366                key,
1367                value,
1368            })
1369            .is_ok()
1370    }
1371
1372    // === Translation ===
1373
1374    /// Translate a string - reads plugin name from __pluginName__ global
1375    /// Args is optional - can be omitted, undefined, null, or an object
1376    pub fn t<'js>(
1377        &self,
1378        _ctx: rquickjs::Ctx<'js>,
1379        key: String,
1380        args: rquickjs::function::Rest<Value<'js>>,
1381    ) -> String {
1382        // Use stored plugin name instead of global lookup
1383        let plugin_name = self.plugin_name.clone();
1384        // Convert args to HashMap - args.0 is a Vec of the rest arguments
1385        let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1386            if let Some(obj) = first_arg.as_object() {
1387                let mut map = HashMap::new();
1388                for k in obj.keys::<String>().flatten() {
1389                    if let Ok(v) = obj.get::<_, String>(&k) {
1390                        map.insert(k, v);
1391                    }
1392                }
1393                map
1394            } else {
1395                HashMap::new()
1396            }
1397        } else {
1398            HashMap::new()
1399        };
1400        let res = self.services.translate(&plugin_name, &key, &args_map);
1401
1402        tracing::info!(
1403            "Translating: key={}, plugin={}, args={:?} => res='{}'",
1404            key,
1405            plugin_name,
1406            args_map,
1407            res
1408        );
1409        res
1410    }
1411
1412    // === Buffer Queries (additional) ===
1413
1414    /// Get cursor position in active buffer
1415    pub fn get_cursor_position(&self) -> u32 {
1416        self.state_snapshot
1417            .read()
1418            .ok()
1419            .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1420            .unwrap_or(0)
1421    }
1422
1423    /// Get file path for a buffer
1424    pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1425        if let Ok(s) = self.state_snapshot.read() {
1426            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1427                if let Some(p) = &b.path {
1428                    return p.to_string_lossy().to_string();
1429                }
1430            }
1431        }
1432        String::new()
1433    }
1434
1435    /// Get buffer length in bytes
1436    pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1437        if let Ok(s) = self.state_snapshot.read() {
1438            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1439                return b.length as u32;
1440            }
1441        }
1442        0
1443    }
1444
1445    /// Check if buffer has unsaved changes
1446    pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1447        if let Ok(s) = self.state_snapshot.read() {
1448            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1449                return b.modified;
1450            }
1451        }
1452        false
1453    }
1454
1455    /// Save a buffer to a specific file path
1456    /// Used by :w filename to save unnamed buffers or save-as
1457    pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1458        self.command_sender
1459            .send(PluginCommand::SaveBufferToPath {
1460                buffer_id: BufferId(buffer_id as usize),
1461                path: std::path::PathBuf::from(path),
1462            })
1463            .is_ok()
1464    }
1465
1466    /// Get buffer info by ID
1467    #[plugin_api(ts_return = "BufferInfo | null")]
1468    pub fn get_buffer_info<'js>(
1469        &self,
1470        ctx: rquickjs::Ctx<'js>,
1471        buffer_id: u32,
1472    ) -> rquickjs::Result<Value<'js>> {
1473        let info = if let Ok(s) = self.state_snapshot.read() {
1474            s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1475        } else {
1476            None
1477        };
1478        rquickjs_serde::to_value(ctx, &info)
1479            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1480    }
1481
1482    /// Get primary cursor info for active buffer
1483    #[plugin_api(ts_return = "CursorInfo | null")]
1484    pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1485        let cursor = if let Ok(s) = self.state_snapshot.read() {
1486            s.primary_cursor.clone()
1487        } else {
1488            None
1489        };
1490        rquickjs_serde::to_value(ctx, &cursor)
1491            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1492    }
1493
1494    /// Get all cursors for active buffer
1495    #[plugin_api(ts_return = "CursorInfo[]")]
1496    pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1497        let cursors = if let Ok(s) = self.state_snapshot.read() {
1498            s.all_cursors.clone()
1499        } else {
1500            Vec::new()
1501        };
1502        rquickjs_serde::to_value(ctx, &cursors)
1503            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1504    }
1505
1506    /// Get all cursor positions as byte offsets
1507    #[plugin_api(ts_return = "number[]")]
1508    pub fn get_all_cursor_positions<'js>(
1509        &self,
1510        ctx: rquickjs::Ctx<'js>,
1511    ) -> rquickjs::Result<Value<'js>> {
1512        let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1513            s.all_cursors.iter().map(|c| c.position as u32).collect()
1514        } else {
1515            Vec::new()
1516        };
1517        rquickjs_serde::to_value(ctx, &positions)
1518            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1519    }
1520
1521    /// Get viewport info for active buffer
1522    #[plugin_api(ts_return = "ViewportInfo | null")]
1523    pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1524        let viewport = if let Ok(s) = self.state_snapshot.read() {
1525            s.viewport.clone()
1526        } else {
1527            None
1528        };
1529        rquickjs_serde::to_value(ctx, &viewport)
1530            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1531    }
1532
1533    /// Total terminal dimensions in cells. Unlike `getViewport()`
1534    /// (which reports the active split, shrunk by any vertical
1535    /// split layout), this reflects the full terminal — what a
1536    /// floating overlay sized by `heightPct` actually gets.
1537    #[plugin_api(ts_return = "ScreenSize")]
1538    pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1539        let size = if let Ok(s) = self.state_snapshot.read() {
1540            fresh_core::api::ScreenSize {
1541                width: s.terminal_width,
1542                height: s.terminal_height,
1543            }
1544        } else {
1545            fresh_core::api::ScreenSize {
1546                width: 0,
1547                height: 0,
1548            }
1549        };
1550        rquickjs_serde::to_value(ctx, &size)
1551            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1552    }
1553
1554    /// List every split with its active buffer and viewport.
1555    ///
1556    /// Plugins that need to operate on every visible buffer
1557    /// simultaneously (multi-split flash labels, syncing decorations
1558    /// across panes, …) iterate this list rather than only seeing
1559    /// `getViewport()`'s active-split data.  Order is unspecified.
1560    #[plugin_api(ts_return = "SplitSnapshot[]")]
1561    pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1562        let splits = if let Ok(s) = self.state_snapshot.read() {
1563            s.splits.clone()
1564        } else {
1565            Vec::new()
1566        };
1567        rquickjs_serde::to_value(ctx, &splits)
1568            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1569    }
1570
1571    /// Get the line number (0-indexed) of the primary cursor.
1572    ///
1573    /// @deprecated Use `getPrimaryCursor()?.line` instead. This accessor cannot
1574    /// represent "line index unavailable" (huge files before their line scan) —
1575    /// it returns `0` in that case, indistinguishable from a real first line.
1576    /// `getPrimaryCursor().line` is `number | null` and also covers every cursor
1577    /// via `getAllCursors()`.
1578    pub fn get_cursor_line(&self) -> u32 {
1579        self.state_snapshot
1580            .read()
1581            .ok()
1582            .and_then(|s| s.primary_cursor.as_ref().and_then(|c| c.line))
1583            .unwrap_or(0) as u32
1584    }
1585
1586    /// Get the byte offset of the start of a line (0-indexed line number)
1587    /// Returns null if the line number is out of range
1588    #[plugin_api(
1589        async_promise,
1590        js_name = "getLineStartPosition",
1591        ts_return = "number | null"
1592    )]
1593    #[qjs(rename = "_getLineStartPositionStart")]
1594    pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1595        let id = self.alloc_request_id();
1596        // Use buffer_id 0 for active buffer
1597        let _ = self
1598            .command_sender
1599            .send(PluginCommand::GetLineStartPosition {
1600                buffer_id: BufferId(0),
1601                line,
1602                request_id: id,
1603            });
1604        id
1605    }
1606
1607    /// Get the byte offset of the end of a line (0-indexed line number)
1608    /// Returns the position after the last character of the line (before newline)
1609    /// Returns null if the line number is out of range
1610    #[plugin_api(
1611        async_promise,
1612        js_name = "getLineEndPosition",
1613        ts_return = "number | null"
1614    )]
1615    #[qjs(rename = "_getLineEndPositionStart")]
1616    pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1617        let id = self.alloc_request_id();
1618        // Use buffer_id 0 for active buffer
1619        let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1620            buffer_id: BufferId(0),
1621            line,
1622            request_id: id,
1623        });
1624        id
1625    }
1626
1627    /// Get the total number of lines in the active buffer
1628    /// Returns null if buffer not found
1629    #[plugin_api(
1630        async_promise,
1631        js_name = "getBufferLineCount",
1632        ts_return = "number | null"
1633    )]
1634    #[qjs(rename = "_getBufferLineCountStart")]
1635    pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1636        let id = self.alloc_request_id();
1637        // Use buffer_id 0 for active buffer
1638        let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1639            buffer_id: BufferId(0),
1640            request_id: id,
1641        });
1642        id
1643    }
1644
1645    /// Scroll a split to center a specific line in the viewport
1646    /// Line is 0-indexed (0 = first line)
1647    pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1648        self.command_sender
1649            .send(PluginCommand::ScrollToLineCenter {
1650                split_id: SplitId(split_id as usize),
1651                buffer_id: BufferId(buffer_id as usize),
1652                line: line as usize,
1653            })
1654            .is_ok()
1655    }
1656
1657    /// Scroll any split/panel showing `buffer_id` so `line` is visible.
1658    /// Unlike `scrollToLineCenter`, this does not require a split id — it
1659    /// updates every split's viewport whose active buffer is the given
1660    /// buffer, including inner leaves of a buffer group. Use this from
1661    /// a panel plugin to keep the user's "selected" row in view after
1662    /// arrow-key navigation (the plugin's own selection state isn't
1663    /// automatically reflected in the buffer cursor, so the core-driven
1664    /// viewport would otherwise stay put).
1665    pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1666        self.command_sender
1667            .send(PluginCommand::ScrollBufferToLine {
1668                buffer_id: BufferId(buffer_id as usize),
1669                line: line as usize,
1670            })
1671            .is_ok()
1672    }
1673
1674    /// Find buffer by file path, returns buffer ID or 0 if not found
1675    pub fn find_buffer_by_path(&self, path: String) -> u32 {
1676        let path_buf = std::path::PathBuf::from(&path);
1677        if let Ok(s) = self.state_snapshot.read() {
1678            for (id, info) in &s.buffers {
1679                if let Some(buf_path) = &info.path {
1680                    if buf_path == &path_buf {
1681                        return id.0 as u32;
1682                    }
1683                }
1684            }
1685        }
1686        0
1687    }
1688
1689    /// Get diff between buffer content and last saved version
1690    #[plugin_api(ts_return = "BufferSavedDiff | null")]
1691    pub fn get_buffer_saved_diff<'js>(
1692        &self,
1693        ctx: rquickjs::Ctx<'js>,
1694        buffer_id: u32,
1695    ) -> rquickjs::Result<Value<'js>> {
1696        let diff = if let Ok(s) = self.state_snapshot.read() {
1697            s.buffer_saved_diffs
1698                .get(&BufferId(buffer_id as usize))
1699                .cloned()
1700        } else {
1701            None
1702        };
1703        rquickjs_serde::to_value(ctx, &diff)
1704            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1705    }
1706
1707    // === Text Editing ===
1708
1709    /// Insert text at a position in a buffer
1710    pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1711        self.command_sender
1712            .send(PluginCommand::InsertText {
1713                buffer_id: BufferId(buffer_id as usize),
1714                position: position as usize,
1715                text,
1716            })
1717            .is_ok()
1718    }
1719
1720    /// Delete a range from a buffer
1721    pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1722        self.command_sender
1723            .send(PluginCommand::DeleteRange {
1724                buffer_id: BufferId(buffer_id as usize),
1725                range: (start as usize)..(end as usize),
1726            })
1727            .is_ok()
1728    }
1729
1730    /// Insert text at cursor position in active buffer
1731    pub fn insert_at_cursor(&self, text: String) -> bool {
1732        self.command_sender
1733            .send(PluginCommand::InsertAtCursor { text })
1734            .is_ok()
1735    }
1736
1737    // === File Operations ===
1738
1739    /// Open a file, optionally at a specific line/column
1740    pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1741        self.command_sender
1742            .send(PluginCommand::OpenFileAtLocation {
1743                path: PathBuf::from(path),
1744                line: line.map(|l| l as usize),
1745                column: column.map(|c| c as usize),
1746            })
1747            .is_ok()
1748    }
1749
1750    /// Open a file in the background — no focus change, no
1751    /// active-split mutation. `windowId` defaults to the active
1752    /// session. Setting it to an inactive session id loads the
1753    /// file's buffer and adds it as a tab in that session's
1754    /// stashed split tree, ready to be revealed on next dive.
1755    /// Orchestrator uses this to populate worktree sessions with
1756    /// preselected files.
1757    pub fn open_file_in_background(
1758        &self,
1759        path: String,
1760        window_id: rquickjs::function::Opt<u64>,
1761    ) -> bool {
1762        self.command_sender
1763            .send(PluginCommand::OpenFileInBackground {
1764                path: PathBuf::from(path),
1765                window_id: window_id.0.map(fresh_core::WindowId),
1766            })
1767            .is_ok()
1768    }
1769
1770    /// Open a file in a specific split
1771    pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1772        self.command_sender
1773            .send(PluginCommand::OpenFileInSplit {
1774                split_id: split_id as usize,
1775                path: PathBuf::from(path),
1776                line: Some(line as usize),
1777                column: Some(column as usize),
1778            })
1779            .is_ok()
1780    }
1781
1782    /// Open `path` as a regular buffer in forced large-file (file-backed)
1783    /// mode. The file is created (empty) if missing — designed for
1784    /// buffers that will be filled by a concurrent `spawnProcess` with
1785    /// `stdoutTo`. Resolves with the new buffer's id, or `null` on
1786    /// failure.
1787    ///
1788    /// Pair with `refreshBufferFromDisk` to grow the buffer as the
1789    /// streaming write advances.
1790    #[plugin_api(
1791        async_promise,
1792        js_name = "openFileStreaming",
1793        ts_return = "number | null"
1794    )]
1795    #[qjs(rename = "_openFileStreamingStart")]
1796    pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
1797        let id = self.alloc_request_id();
1798        let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
1799            path: PathBuf::from(path),
1800            request_id: id,
1801        });
1802        id
1803    }
1804
1805    /// Re-stat the file backing `bufferId` and extend the buffer if the
1806    /// file has grown. Resolves with the new total byte length, or
1807    /// `null` if the buffer has no file path or doesn't exist.
1808    ///
1809    /// Used to drive a streaming display: while a `spawnProcess` writes
1810    /// to a temp file, the plugin polls this on a timer so the buffer
1811    /// length tracks the file length.
1812    #[plugin_api(
1813        async_promise,
1814        js_name = "refreshBufferFromDisk",
1815        ts_return = "number | null"
1816    )]
1817    #[qjs(rename = "_refreshBufferFromDiskStart")]
1818    pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
1819        let id = self.alloc_request_id();
1820        let _ = self
1821            .command_sender
1822            .send(PluginCommand::RefreshBufferFromDisk {
1823                buffer_id: BufferId(buffer_id as usize),
1824                request_id: id,
1825            });
1826        id
1827    }
1828
1829    /// Show a buffer in the current split
1830    pub fn show_buffer(&self, buffer_id: u32) -> bool {
1831        self.command_sender
1832            .send(PluginCommand::ShowBuffer {
1833                buffer_id: BufferId(buffer_id as usize),
1834            })
1835            .is_ok()
1836    }
1837
1838    /// Close a buffer
1839    pub fn close_buffer(&self, buffer_id: u32) -> bool {
1840        self.command_sender
1841            .send(PluginCommand::CloseBuffer {
1842                buffer_id: BufferId(buffer_id as usize),
1843            })
1844            .is_ok()
1845    }
1846
1847    /// Close other buffers in split
1848    pub fn close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1849        self.command_sender
1850            .send(PluginCommand::CloseOtherBuffersInSplit {
1851                buffer_id: BufferId(buffer_id as usize),
1852                split_id: SplitId(split_id as usize),
1853            })
1854            .is_ok()
1855    }
1856
1857    /// Close all buffers in split
1858    pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
1859        self.command_sender
1860            .send(PluginCommand::CloseAllBuffersInSplit {
1861                split_id: SplitId(split_id as usize),
1862            })
1863            .is_ok()
1864    }
1865
1866    /// Close buffers to right in split
1867    pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1868        self.command_sender
1869            .send(PluginCommand::CloseBuffersToRightInSplit {
1870                buffer_id: BufferId(buffer_id as usize),
1871                split_id: SplitId(split_id as usize),
1872            })
1873            .is_ok()
1874    }
1875
1876    /// Close buffers to left in split
1877    pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1878        self.command_sender
1879            .send(PluginCommand::CloseBuffersToLeftInSplit {
1880                buffer_id: BufferId(buffer_id as usize),
1881                split_id: SplitId(split_id as usize),
1882            })
1883            .is_ok()
1884    }
1885
1886    /// Move the active tab to the left in the active split
1887    #[plugin_api(ts_return = "boolean")]
1888    pub fn move_tab_to_left(&self) -> bool {
1889        self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
1890    }
1891
1892    /// Move the active tab to the right in the active split
1893    #[plugin_api(ts_return = "boolean")]
1894    pub fn move_tab_to_right(&self) -> bool {
1895        self.command_sender
1896            .send(PluginCommand::MoveTabRight)
1897            .is_ok()
1898    }
1899
1900    // === Frame-buffer animations ===
1901
1902    /// Allocate a fresh request id and register this plugin as the callback owner.
1903    /// Every async API method that returns a `request_id` must call this instead
1904    /// of duplicating the borrow-mut dance inline.
1905    #[plugin_api(skip)]
1906    #[qjs(skip)]
1907    fn alloc_request_id(&self) -> u64 {
1908        let mut id_ref = self.next_request_id.borrow_mut();
1909        let id = *id_ref;
1910        *id_ref += 1;
1911        self.callback_contexts
1912            .borrow_mut()
1913            .insert(id, self.plugin_name.clone());
1914        id
1915    }
1916
1917    /// Allocate a fresh animation id from the shared request-id counter.
1918    /// Not exposed to JS — used internally by `animateArea` /
1919    /// `animateVirtualBuffer`.
1920    #[plugin_api(skip)]
1921    #[qjs(skip)]
1922    fn alloc_animation_id(&self) -> u64 {
1923        let mut id_ref = self.next_request_id.borrow_mut();
1924        let id = *id_ref;
1925        *id_ref += 1;
1926        id
1927    }
1928
1929    /// Start a frame-buffer animation over an arbitrary screen region.
1930    /// Returns an animation id usable with `cancelAnimation`.
1931    pub fn animate_area<'js>(
1932        &self,
1933        #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1934        #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1935    ) -> rquickjs::Result<u64> {
1936        let rect = parse_animation_rect(&rect)?;
1937        let kind = parse_animation_kind(&kind)?;
1938        let id = self.alloc_animation_id();
1939        let _ = self
1940            .command_sender
1941            .send(PluginCommand::StartAnimationArea { id, rect, kind });
1942        Ok(id)
1943    }
1944
1945    /// Start an animation over the on-screen Rect currently occupied by a
1946    /// virtual buffer. No-op if the buffer is not visible.
1947    pub fn animate_virtual_buffer<'js>(
1948        &self,
1949        buffer_id: u32,
1950        #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1951    ) -> rquickjs::Result<u64> {
1952        let kind = parse_animation_kind(&kind)?;
1953        let id = self.alloc_animation_id();
1954        let _ = self
1955            .command_sender
1956            .send(PluginCommand::StartAnimationVirtualBuffer {
1957                id,
1958                buffer_id: BufferId(buffer_id as usize),
1959                kind,
1960            });
1961        Ok(id)
1962    }
1963
1964    /// Cancel an animation previously started via `animateArea` or
1965    /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
1966    pub fn cancel_animation(&self, id: u64) -> bool {
1967        self.command_sender
1968            .send(PluginCommand::CancelAnimation { id })
1969            .is_ok()
1970    }
1971
1972    // === Event Handling ===
1973
1974    /// Subscribe to an editor event
1975    pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1976        // If registering for lines_changed, clear all seen_byte_ranges so lines
1977        // that were already marked "seen" (before this plugin initialized) get
1978        // re-sent via the hook.
1979        if event_name == "lines_changed" {
1980            let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1981        }
1982        self.event_handlers
1983            .write()
1984            .expect("event_handlers poisoned")
1985            .entry(event_name)
1986            .or_default()
1987            .push(PluginHandler {
1988                plugin_name: self.plugin_name.clone(),
1989                handler_name,
1990            });
1991    }
1992
1993    /// Unsubscribe from an event
1994    pub fn off(&self, event_name: String, handler_name: String) {
1995        if let Some(list) = self
1996            .event_handlers
1997            .write()
1998            .expect("event_handlers poisoned")
1999            .get_mut(&event_name)
2000        {
2001            list.retain(|h| h.handler_name != handler_name);
2002        }
2003    }
2004
2005    // === Environment ===
2006
2007    /// Get an environment variable
2008    pub fn get_env(&self, name: String) -> Option<String> {
2009        std::env::var(&name).ok()
2010    }
2011
2012    /// Get current working directory
2013    pub fn get_cwd(&self) -> String {
2014        self.state_snapshot
2015            .read()
2016            .map(|s| s.working_dir.to_string_lossy().to_string())
2017            .unwrap_or_else(|_| ".".to_string())
2018    }
2019
2020    /// Get the active authority's display label.
2021    ///
2022    /// Empty means the local (default) authority. A non-empty value
2023    /// means a plugin-installed or SSH authority is in effect (e.g.
2024    /// `"Container:abc123def456"` for a devcontainer). Intended as a
2025    /// simple "am I already attached?" check that survives editor
2026    /// restarts — the label lives on the `Editor` state snapshot so it
2027    /// is fresh after the authority-transition restart flow.
2028    pub fn get_authority_label(&self) -> String {
2029        self.state_snapshot
2030            .read()
2031            .map(|s| s.authority_label.clone())
2032            .unwrap_or_default()
2033    }
2034
2035    /// Current Workspace Trust level for the active project: `"restricted"`,
2036    /// `"trusted"`, or `"blocked"` (empty when unavailable). Exposed to JS as
2037    /// `editor.workspaceTrustLevel()`. Plugins that run repo-controlled work
2038    /// should treat anything other than `"trusted"` as "do not execute".
2039    pub fn workspace_trust_level(&self) -> String {
2040        self.state_snapshot
2041            .read()
2042            .map(|s| s.workspace_trust_level.clone())
2043            .unwrap_or_default()
2044    }
2045
2046    /// Whether an environment is currently active (set via `editor.setEnv`).
2047    /// Exposed to JS as `editor.envActive()`. Lets the env-manager plugin
2048    /// reflect activation and re-establish its file watch after the restart
2049    /// that `setEnv` triggers.
2050    pub fn env_active(&self) -> bool {
2051        self.state_snapshot
2052            .read()
2053            .map(|s| s.env_active)
2054            .unwrap_or(false)
2055    }
2056
2057    // === Path Operations ===
2058
2059    /// Join path components (variadic - accepts multiple string arguments)
2060    /// Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
2061    ///
2062    /// Preserves up to 2 leading slashes, which matters on Windows: Rust's
2063    /// `Path::canonicalize` returns `\\?\`-prefixed paths, and `editor.getCwd()`
2064    /// surfaces that to plugin code verbatim. After the backslash→slash
2065    /// normalization the prefix becomes `//?/C:/...`; collapsing the leading
2066    /// `//` to a single `/` yields `/?/C:/...`, which every filesystem API on
2067    /// Windows rejects, breaking `findConfig()`-style plugin logic.
2068    pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2069        let mut result_parts: Vec<String> = Vec::new();
2070        // 0 = no leading slash, 1 = POSIX absolute, 2 = Windows UNC (`\\?\` etc).
2071        let mut leading_slashes: u8 = 0;
2072
2073        for part in &parts.0 {
2074            // Normalize separators to forward slashes
2075            let normalized = part.replace('\\', "/");
2076
2077            // Check if this is an absolute path (starts with / or has drive letter like C:/)
2078            let is_absolute = normalized.starts_with('/')
2079                || (normalized.len() >= 2
2080                    && normalized
2081                        .chars()
2082                        .next()
2083                        .map(|c| c.is_ascii_alphabetic())
2084                        .unwrap_or(false)
2085                    && normalized.chars().nth(1) == Some(':'));
2086
2087            if is_absolute {
2088                // Reset for absolute paths
2089                result_parts.clear();
2090                // Cap at 2 — `\\?\` and `\\server\share` both start with two
2091                // backslashes; anything beyond that is meaningless and a sign
2092                // of caller confusion, not a deeper namespace.
2093                leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2094            }
2095
2096            // Split and add non-empty parts
2097            for segment in normalized.split('/') {
2098                if !segment.is_empty() && segment != "." {
2099                    if segment == ".." {
2100                        result_parts.pop();
2101                    } else {
2102                        result_parts.push(segment.to_string());
2103                    }
2104                }
2105            }
2106        }
2107
2108        // Reconstruct with forward slashes
2109        let joined = result_parts.join("/");
2110        let prefix = match leading_slashes {
2111            0 => "",
2112            1 => "/",
2113            _ => "//",
2114        };
2115
2116        if leading_slashes > 0 {
2117            format!("{}{}", prefix, joined)
2118        } else {
2119            joined
2120        }
2121    }
2122
2123    /// Get directory name from path
2124    pub fn path_dirname(&self, path: String) -> String {
2125        Path::new(&path)
2126            .parent()
2127            .map(|p| p.to_string_lossy().to_string())
2128            .unwrap_or_default()
2129    }
2130
2131    /// Get file name from path
2132    pub fn path_basename(&self, path: String) -> String {
2133        Path::new(&path)
2134            .file_name()
2135            .map(|s| s.to_string_lossy().to_string())
2136            .unwrap_or_default()
2137    }
2138
2139    /// Get file extension
2140    pub fn path_extname(&self, path: String) -> String {
2141        Path::new(&path)
2142            .extension()
2143            .map(|s| format!(".{}", s.to_string_lossy()))
2144            .unwrap_or_default()
2145    }
2146
2147    /// Check if path is absolute
2148    pub fn path_is_absolute(&self, path: String) -> bool {
2149        Path::new(&path).is_absolute()
2150    }
2151
2152    /// Convert a file:// URI to a local file path.
2153    /// Handles percent-decoding and Windows drive letters.
2154    /// Returns an empty string if the URI is not a valid file URI.
2155    pub fn file_uri_to_path(&self, uri: String) -> String {
2156        fresh_core::file_uri::file_uri_to_path(&uri)
2157            .map(|p| p.to_string_lossy().to_string())
2158            .unwrap_or_default()
2159    }
2160
2161    /// Convert a local file path to a file:// URI.
2162    /// Handles Windows drive letters and special characters.
2163    /// Returns an empty string if the path cannot be converted.
2164    pub fn path_to_file_uri(&self, path: String) -> String {
2165        fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2166    }
2167
2168    /// Get the UTF-8 byte length of a JavaScript string.
2169    ///
2170    /// JS strings are UTF-16 internally, so `str.length` returns the number of
2171    /// UTF-16 code units, not the number of bytes in a UTF-8 encoding.  The
2172    /// editor API uses byte offsets for all buffer positions (overlays, cursor,
2173    /// getBufferText ranges, etc.).  This helper lets plugins convert JS string
2174    /// lengths / regex match indices to the byte offsets the editor expects.
2175    pub fn utf8_byte_length(&self, text: String) -> u32 {
2176        text.len() as u32
2177    }
2178
2179    // === File System ===
2180
2181    /// Check if file exists
2182    pub fn file_exists(&self, path: String) -> bool {
2183        Path::new(&path).exists()
2184    }
2185
2186    /// Read file contents
2187    pub fn read_file(&self, path: String) -> Option<String> {
2188        std::fs::read_to_string(&path).ok()
2189    }
2190
2191    /// Write file contents
2192    pub fn write_file(&self, path: String, content: String) -> bool {
2193        let p = Path::new(&path);
2194        if let Some(parent) = p.parent() {
2195            if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2196                return false;
2197            }
2198        }
2199        std::fs::write(p, content).is_ok()
2200    }
2201
2202    /// Read directory contents (returns array of {name, is_file, is_dir})
2203    #[plugin_api(ts_return = "DirEntry[]")]
2204    pub fn read_dir<'js>(
2205        &self,
2206        ctx: rquickjs::Ctx<'js>,
2207        path: String,
2208    ) -> rquickjs::Result<Value<'js>> {
2209        use fresh_core::api::DirEntry;
2210
2211        let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2212            Ok(entries) => entries
2213                .filter_map(|e| e.ok())
2214                .map(|entry| {
2215                    let file_type = entry.file_type().ok();
2216                    DirEntry {
2217                        name: entry.file_name().to_string_lossy().to_string(),
2218                        is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2219                        is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2220                    }
2221                })
2222                .collect(),
2223            Err(e) => {
2224                tracing::warn!("readDir failed for '{}': {}", path, e);
2225                Vec::new()
2226            }
2227        };
2228
2229        rquickjs_serde::to_value(ctx, &entries)
2230            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2231    }
2232
2233    /// Create a directory (and all parent directories) recursively.
2234    /// Returns true if the directory was created or already exists.
2235    pub fn create_dir(&self, path: String) -> bool {
2236        let p = Path::new(&path);
2237        if p.is_dir() {
2238            return true;
2239        }
2240        std::fs::create_dir_all(p).is_ok()
2241    }
2242
2243    /// Remove a file or directory by moving it to the OS trash/recycle bin.
2244    /// For safety, the path must be under the OS temp directory or the Fresh
2245    /// config directory. Returns true on success.
2246    pub fn remove_path(&self, path: String) -> bool {
2247        let target = match Path::new(&path).canonicalize() {
2248            Ok(p) => p,
2249            Err(_) => return false, // path doesn't exist or can't be resolved
2250        };
2251
2252        // Canonicalize allowed roots too, so that path prefix comparisons are
2253        // consistent.  On Windows, `Path::canonicalize` returns extended-length
2254        // UNC paths (e.g. `\\?\C:\...`) while `std::env::temp_dir()` and the
2255        // config dir may use regular paths.  Without canonicalizing the roots
2256        // the `starts_with` check would always fail on Windows.
2257        let temp_dir = std::env::temp_dir()
2258            .canonicalize()
2259            .unwrap_or_else(|_| std::env::temp_dir());
2260        let config_dir = self
2261            .services
2262            .config_dir()
2263            .canonicalize()
2264            .unwrap_or_else(|_| self.services.config_dir());
2265
2266        // Verify the path is under an allowed root (temp or config dir)
2267        let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2268        if !allowed {
2269            tracing::warn!(
2270                "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2271                target,
2272                temp_dir,
2273                config_dir
2274            );
2275            return false;
2276        }
2277
2278        // Don't allow removing the root directories themselves
2279        if target == temp_dir || target == config_dir {
2280            tracing::warn!(
2281                "removePath refused: cannot remove root directory {:?}",
2282                target
2283            );
2284            return false;
2285        }
2286
2287        match trash::delete(&target) {
2288            Ok(()) => true,
2289            Err(e) => {
2290                tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2291                false
2292            }
2293        }
2294    }
2295
2296    /// Rename/move a file or directory. Returns true on success.
2297    /// Falls back to copy then trash for cross-filesystem moves.
2298    pub fn rename_path(&self, from: String, to: String) -> bool {
2299        // Try direct rename first (works for same-filesystem moves)
2300        if std::fs::rename(&from, &to).is_ok() {
2301            return true;
2302        }
2303        // Cross-filesystem fallback: copy then trash the original
2304        let from_path = Path::new(&from);
2305        let copied = if from_path.is_dir() {
2306            copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2307        } else {
2308            std::fs::copy(&from, &to).is_ok()
2309        };
2310        if copied {
2311            return trash::delete(from_path).is_ok();
2312        }
2313        false
2314    }
2315
2316    /// Copy a file or directory recursively to a new location.
2317    /// Returns true on success.
2318    pub fn copy_path(&self, from: String, to: String) -> bool {
2319        let from_path = Path::new(&from);
2320        let to_path = Path::new(&to);
2321        if from_path.is_dir() {
2322            copy_dir_recursive(from_path, to_path).is_ok()
2323        } else {
2324            // Ensure parent directory exists
2325            if let Some(parent) = to_path.parent() {
2326                if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2327                    return false;
2328                }
2329            }
2330            std::fs::copy(from_path, to_path).is_ok()
2331        }
2332    }
2333
2334    /// Get the OS temporary directory path.
2335    pub fn get_temp_dir(&self) -> String {
2336        std::env::temp_dir().to_string_lossy().to_string()
2337    }
2338
2339    // === JSONC Parsing ===
2340
2341    /// Parse a JSONC (JSON with comments) string into a JS value.
2342    ///
2343    /// Accepts the JSONC superset: line and block comments, trailing
2344    /// commas, single-quoted strings, and unquoted object keys — matching
2345    /// devcontainer.json / tsconfig.json / VS Code settings.json.
2346    ///
2347    /// Throws a JS error (catchable with try/catch) when the input is not
2348    /// valid JSONC, like `JSON.parse` does for invalid JSON.
2349    #[plugin_api(ts_return = "unknown")]
2350    pub fn parse_jsonc<'js>(
2351        &self,
2352        ctx: rquickjs::Ctx<'js>,
2353        text: String,
2354    ) -> rquickjs::Result<Value<'js>> {
2355        let value: serde_json::Value =
2356            jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2357                rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2358            })?;
2359        rquickjs_serde::to_value(ctx, &value)
2360            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2361    }
2362
2363    // === Config ===
2364
2365    /// Get current config as JS object.
2366    ///
2367    /// The snapshot holds an `Arc<serde_json::Value>` that was serialized
2368    /// on the editor side the last time the underlying `Arc<Config>`
2369    /// changed. Cloning the Arc inside the read lock is a refcount bump;
2370    /// the actual walk into the JS runtime happens outside the lock.
2371    pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2372        let config = self
2373            .state_snapshot
2374            .read()
2375            .map(|s| std::sync::Arc::clone(&s.config))
2376            .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2377
2378        rquickjs_serde::to_value(ctx, &*config)
2379            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2380    }
2381
2382    /// Get user config as JS object. Same Arc-clone pattern as `get_config`.
2383    pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2384        let config = self
2385            .state_snapshot
2386            .read()
2387            .map(|s| std::sync::Arc::clone(&s.user_config))
2388            .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2389
2390        rquickjs_serde::to_value(ctx, &*config)
2391            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2392    }
2393
2394    /// Declare a boolean config field for the calling plugin.
2395    ///
2396    /// Validates `options` synchronously: the JS call throws if any
2397    /// unknown key is present or if `default` isn't a boolean. The
2398    /// Settings UI grows a "Plugin Settings → <plugin>" sub-category
2399    /// containing a toggle for this field. Returns the current value
2400    /// (user-set if present, otherwise the declared `default`).
2401    #[plugin_api(ts_return = "boolean")]
2402    pub fn define_config_boolean<'js>(
2403        &self,
2404        ctx: rquickjs::Ctx<'js>,
2405        name: String,
2406        #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2407        options: rquickjs::Object<'js>,
2408    ) -> rquickjs::Result<bool> {
2409        let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2410        validate_allowed_keys(
2411            &ctx,
2412            "defineConfigBoolean",
2413            &name,
2414            &opts,
2415            &["default", "description"],
2416        )?;
2417        let default = match opts.get("default") {
2418            Some(serde_json::Value::Bool(b)) => *b,
2419            _ => {
2420                return Err(throw_js(
2421                    &ctx,
2422                    &format!(
2423                        "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2424                        name
2425                    ),
2426                ));
2427            }
2428        };
2429        let description = string_opt(&opts, "description");
2430        let mut field = serde_json::Map::new();
2431        field.insert("type".into(), serde_json::json!("boolean"));
2432        field.insert("default".into(), serde_json::json!(default));
2433        if let Some(d) = description {
2434            field.insert("description".into(), serde_json::json!(d));
2435        }
2436        self.send_field_registration(&name, serde_json::Value::Object(field));
2437        Ok(self
2438            .current_field_value(&name)
2439            .and_then(|v| v.as_bool())
2440            .unwrap_or(default))
2441    }
2442
2443    /// Declare an integer config field for the calling plugin. Throws on
2444    /// invalid options or if the default falls outside `minimum/maximum`.
2445    #[plugin_api(ts_return = "number")]
2446    pub fn define_config_integer<'js>(
2447        &self,
2448        ctx: rquickjs::Ctx<'js>,
2449        name: String,
2450        #[plugin_api(
2451            ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2452        )]
2453        options: rquickjs::Object<'js>,
2454    ) -> rquickjs::Result<i64> {
2455        let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2456        validate_allowed_keys(
2457            &ctx,
2458            "defineConfigInteger",
2459            &name,
2460            &opts,
2461            &["default", "description", "minimum", "maximum"],
2462        )?;
2463        let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2464        let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2465        let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2466        check_range(
2467            &ctx,
2468            "defineConfigInteger",
2469            &name,
2470            default as f64,
2471            minimum.map(|v| v as f64),
2472            maximum.map(|v| v as f64),
2473        )?;
2474        let description = string_opt(&opts, "description");
2475        let mut field = serde_json::Map::new();
2476        field.insert("type".into(), serde_json::json!("integer"));
2477        field.insert("default".into(), serde_json::json!(default));
2478        if let Some(d) = description {
2479            field.insert("description".into(), serde_json::json!(d));
2480        }
2481        if let Some(v) = minimum {
2482            field.insert("minimum".into(), serde_json::json!(v));
2483        }
2484        if let Some(v) = maximum {
2485            field.insert("maximum".into(), serde_json::json!(v));
2486        }
2487        self.send_field_registration(&name, serde_json::Value::Object(field));
2488        Ok(self
2489            .current_field_value(&name)
2490            .and_then(|v| v.as_i64())
2491            .unwrap_or(default))
2492    }
2493
2494    /// Declare a floating-point number config field. Throws on bad
2495    /// options or default outside `minimum/maximum`.
2496    #[plugin_api(ts_return = "number")]
2497    pub fn define_config_number<'js>(
2498        &self,
2499        ctx: rquickjs::Ctx<'js>,
2500        name: String,
2501        #[plugin_api(
2502            ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2503        )]
2504        options: rquickjs::Object<'js>,
2505    ) -> rquickjs::Result<f64> {
2506        let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2507        validate_allowed_keys(
2508            &ctx,
2509            "defineConfigNumber",
2510            &name,
2511            &opts,
2512            &["default", "description", "minimum", "maximum"],
2513        )?;
2514        let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2515        let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2516        let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2517        check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2518        let description = string_opt(&opts, "description");
2519        let mut field = serde_json::Map::new();
2520        field.insert("type".into(), serde_json::json!("number"));
2521        field.insert("default".into(), serde_json::json!(default));
2522        if let Some(d) = description {
2523            field.insert("description".into(), serde_json::json!(d));
2524        }
2525        if let Some(v) = minimum {
2526            field.insert("minimum".into(), serde_json::json!(v));
2527        }
2528        if let Some(v) = maximum {
2529            field.insert("maximum".into(), serde_json::json!(v));
2530        }
2531        self.send_field_registration(&name, serde_json::Value::Object(field));
2532        Ok(self
2533            .current_field_value(&name)
2534            .and_then(|v| v.as_f64())
2535            .unwrap_or(default))
2536    }
2537
2538    /// Declare a free-form string config field.
2539    #[plugin_api(ts_return = "string")]
2540    pub fn define_config_string<'js>(
2541        &self,
2542        ctx: rquickjs::Ctx<'js>,
2543        name: String,
2544        #[plugin_api(ts_type = "{ default: string; description?: string }")]
2545        options: rquickjs::Object<'js>,
2546    ) -> rquickjs::Result<String> {
2547        let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2548        validate_allowed_keys(
2549            &ctx,
2550            "defineConfigString",
2551            &name,
2552            &opts,
2553            &["default", "description"],
2554        )?;
2555        let default = match opts.get("default") {
2556            Some(serde_json::Value::String(s)) => s.clone(),
2557            _ => {
2558                return Err(throw_js(
2559                    &ctx,
2560                    &format!(
2561                        "defineConfigString(\"{}\"): `default` (string) is required",
2562                        name
2563                    ),
2564                ));
2565            }
2566        };
2567        let description = string_opt(&opts, "description");
2568        let mut field = serde_json::Map::new();
2569        field.insert("type".into(), serde_json::json!("string"));
2570        field.insert("default".into(), serde_json::json!(default));
2571        if let Some(d) = description {
2572            field.insert("description".into(), serde_json::json!(d));
2573        }
2574        self.send_field_registration(&name, serde_json::Value::Object(field));
2575        Ok(self
2576            .current_field_value(&name)
2577            .and_then(|v| v.as_str().map(|s| s.to_string()))
2578            .unwrap_or(default))
2579    }
2580
2581    /// Declare a string config field constrained to one of a fixed set
2582    /// of values. The Settings UI renders this as a dropdown.
2583    ///
2584    /// The TS signature for this method is hand-written in the d.ts
2585    /// trailer because the macro can't express the `<E extends string>`
2586    /// generic that propagates `values` into the return type.
2587    #[plugin_api(skip)]
2588    pub fn define_config_enum<'js>(
2589        &self,
2590        ctx: rquickjs::Ctx<'js>,
2591        name: String,
2592        options: rquickjs::Object<'js>,
2593    ) -> rquickjs::Result<String> {
2594        let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2595        validate_allowed_keys(
2596            &ctx,
2597            "defineConfigEnum",
2598            &name,
2599            &opts,
2600            &["default", "description", "values"],
2601        )?;
2602        let values: Vec<String> = match opts.get("values") {
2603            Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
2604                let mut out = Vec::with_capacity(arr.len());
2605                for v in arr {
2606                    match v {
2607                        serde_json::Value::String(s) => out.push(s.clone()),
2608                        _ => {
2609                            return Err(throw_js(
2610                                &ctx,
2611                                &format!(
2612                                    "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2613                                    name
2614                                ),
2615                            ));
2616                        }
2617                    }
2618                }
2619                out
2620            }
2621            _ => {
2622                return Err(throw_js(
2623                    &ctx,
2624                    &format!(
2625                        "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2626                        name
2627                    ),
2628                ));
2629            }
2630        };
2631        let default = match opts.get("default") {
2632            Some(serde_json::Value::String(s)) => s.clone(),
2633            _ => {
2634                return Err(throw_js(
2635                    &ctx,
2636                    &format!(
2637                        "defineConfigEnum(\"{}\"): `default` (string) is required",
2638                        name
2639                    ),
2640                ));
2641            }
2642        };
2643        if !values.contains(&default) {
2644            return Err(throw_js(
2645                &ctx,
2646                &format!(
2647                    "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2648                    name, values
2649                ),
2650            ));
2651        }
2652        let description = string_opt(&opts, "description");
2653        let mut field = serde_json::Map::new();
2654        field.insert("type".into(), serde_json::json!("string"));
2655        field.insert("enum".into(), serde_json::json!(values));
2656        field.insert("default".into(), serde_json::json!(default));
2657        if let Some(d) = description {
2658            field.insert("description".into(), serde_json::json!(d));
2659        }
2660        self.send_field_registration(&name, serde_json::Value::Object(field));
2661        let current = self
2662            .current_field_value(&name)
2663            .and_then(|v| v.as_str().map(|s| s.to_string()));
2664        // Only honour current if it's still one of the declared values
2665        // (the user could have hand-edited config.json to something
2666        // stale after the plugin's enum changed).
2667        Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2668    }
2669
2670    /// Declare an array-of-strings config field (e.g. a list of
2671    /// patterns). The Settings UI renders this as a list editor.
2672    #[plugin_api(ts_return = "string[]")]
2673    pub fn define_config_string_array<'js>(
2674        &self,
2675        ctx: rquickjs::Ctx<'js>,
2676        name: String,
2677        #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2678        options: rquickjs::Object<'js>,
2679    ) -> rquickjs::Result<Vec<String>> {
2680        let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2681        validate_allowed_keys(
2682            &ctx,
2683            "defineConfigStringArray",
2684            &name,
2685            &opts,
2686            &["default", "description"],
2687        )?;
2688        let default: Vec<String> = match opts.get("default") {
2689            Some(serde_json::Value::Array(arr)) => {
2690                let mut out = Vec::with_capacity(arr.len());
2691                for v in arr {
2692                    match v {
2693                        serde_json::Value::String(s) => out.push(s.clone()),
2694                        _ => {
2695                            return Err(throw_js(
2696                                &ctx,
2697                                &format!(
2698                                    "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2699                                    name
2700                                ),
2701                            ));
2702                        }
2703                    }
2704                }
2705                out
2706            }
2707            _ => {
2708                return Err(throw_js(
2709                    &ctx,
2710                    &format!(
2711                        "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2712                        name
2713                    ),
2714                ));
2715            }
2716        };
2717        let description = string_opt(&opts, "description");
2718        let mut field = serde_json::Map::new();
2719        field.insert("type".into(), serde_json::json!("array"));
2720        field.insert("items".into(), serde_json::json!({"type": "string"}));
2721        field.insert("default".into(), serde_json::json!(default));
2722        if let Some(d) = description {
2723            field.insert("description".into(), serde_json::json!(d));
2724        }
2725        self.send_field_registration(&name, serde_json::Value::Object(field));
2726        Ok(self
2727            .current_field_value(&name)
2728            .and_then(|v| {
2729                v.as_array().map(|arr| {
2730                    arr.iter()
2731                        .filter_map(|x| x.as_str().map(|s| s.to_string()))
2732                        .collect::<Vec<_>>()
2733                })
2734            })
2735            .unwrap_or(default))
2736    }
2737
2738    /// Get the calling plugin's settings as a JS object.
2739    ///
2740    /// Returns the merged value at `config.plugins.<plugin_name>.settings`.
2741    /// The shape comes from whatever the plugin declared via
2742    /// `editor.definePluginConfig(...)` (defaults pre-populated by the
2743    /// host, user overrides on top from the Settings UI). Returns `null`
2744    /// if the plugin hasn't declared a schema and has no user-set value.
2745    pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2746        let config = self
2747            .state_snapshot
2748            .read()
2749            .map(|s| std::sync::Arc::clone(&s.config))
2750            .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2751
2752        let settings = config
2753            .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2754            .cloned()
2755            .unwrap_or(serde_json::Value::Null);
2756
2757        rquickjs_serde::to_value(ctx, &settings)
2758            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2759    }
2760
2761    /// Reload configuration from file
2762    pub fn reload_config(&self) {
2763        let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2764    }
2765
2766    /// Set a single config setting in the runtime layer for this session.
2767    ///
2768    /// `path` is dot-separated (e.g. `"editor.tab_size"`). `value` is any JSON
2769    /// value in the shape the setting expects. The write lives in an
2770    /// in-memory layer scoped to the calling plugin — it does not modify
2771    /// `config.json`, and unloading the plugin (or reloading init.ts) drops
2772    /// it. Intended use is `init.ts` running a conditional:
2773    /// `if (editor.getEnv("SSH_TTY")) editor.setSetting("terminal.mouse", false);`
2774    ///
2775    /// Returns `true` if the write was queued. The actual update is
2776    /// asynchronous; a subsequent `getConfig()` will reflect it after the
2777    /// editor processes the command.
2778    pub fn set_setting<'js>(
2779        &self,
2780        _ctx: rquickjs::Ctx<'js>,
2781        path: String,
2782        value: Value<'js>,
2783    ) -> rquickjs::Result<bool> {
2784        let json: serde_json::Value = rquickjs_serde::from_value(value)
2785            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2786        Ok(self
2787            .command_sender
2788            .send(PluginCommand::SetSetting {
2789                plugin_name: self.plugin_name.clone(),
2790                path,
2791                value: json,
2792            })
2793            .is_ok())
2794    }
2795
2796    /// Reload theme registry from disk
2797    /// Call this after installing theme packages or saving new themes
2798    pub fn reload_themes(&self) {
2799        let _ = self
2800            .command_sender
2801            .send(PluginCommand::ReloadThemes { apply_theme: None });
2802    }
2803
2804    /// Reload theme registry and apply a theme atomically
2805    pub fn reload_and_apply_theme(&self, theme_name: String) {
2806        let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2807            apply_theme: Some(theme_name),
2808        });
2809    }
2810
2811    /// Register a TextMate grammar file for a language
2812    /// The grammar will be pending until reload_grammars() is called
2813    pub fn register_grammar<'js>(
2814        &self,
2815        ctx: rquickjs::Ctx<'js>,
2816        language: String,
2817        grammar_path: String,
2818        extensions: Vec<String>,
2819    ) -> rquickjs::Result<bool> {
2820        // First-writer-wins: check if another plugin already registered a grammar for this language
2821        {
2822            let langs = self.registered_grammar_languages.borrow();
2823            if let Some(existing_plugin) = langs.get(&language) {
2824                if existing_plugin != &self.plugin_name {
2825                    let msg = format!(
2826                        "Grammar for language '{}' already registered by plugin '{}'",
2827                        language, existing_plugin
2828                    );
2829                    tracing::warn!("registerGrammar collision: {}", msg);
2830                    return Err(
2831                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2832                    );
2833                }
2834            }
2835        }
2836        self.registered_grammar_languages
2837            .borrow_mut()
2838            .insert(language.clone(), self.plugin_name.clone());
2839
2840        Ok(self
2841            .command_sender
2842            .send(PluginCommand::RegisterGrammar {
2843                language,
2844                grammar_path,
2845                extensions,
2846            })
2847            .is_ok())
2848    }
2849
2850    /// Register language configuration (comment prefix, indentation, formatter)
2851    pub fn register_language_config<'js>(
2852        &self,
2853        ctx: rquickjs::Ctx<'js>,
2854        language: String,
2855        config: LanguagePackConfig,
2856    ) -> rquickjs::Result<bool> {
2857        // First-writer-wins
2858        {
2859            let langs = self.registered_language_configs.borrow();
2860            if let Some(existing_plugin) = langs.get(&language) {
2861                if existing_plugin != &self.plugin_name {
2862                    let msg = format!(
2863                        "Language config for '{}' already registered by plugin '{}'",
2864                        language, existing_plugin
2865                    );
2866                    tracing::warn!("registerLanguageConfig collision: {}", msg);
2867                    return Err(
2868                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2869                    );
2870                }
2871            }
2872        }
2873        self.registered_language_configs
2874            .borrow_mut()
2875            .insert(language.clone(), self.plugin_name.clone());
2876
2877        Ok(self
2878            .command_sender
2879            .send(PluginCommand::RegisterLanguageConfig { language, config })
2880            .is_ok())
2881    }
2882
2883    /// Register an LSP server for a language
2884    pub fn register_lsp_server<'js>(
2885        &self,
2886        ctx: rquickjs::Ctx<'js>,
2887        language: String,
2888        config: LspServerPackConfig,
2889    ) -> rquickjs::Result<bool> {
2890        // First-writer-wins
2891        {
2892            let langs = self.registered_lsp_servers.borrow();
2893            if let Some(existing_plugin) = langs.get(&language) {
2894                if existing_plugin != &self.plugin_name {
2895                    let msg = format!(
2896                        "LSP server for language '{}' already registered by plugin '{}'",
2897                        language, existing_plugin
2898                    );
2899                    tracing::warn!("registerLspServer collision: {}", msg);
2900                    return Err(
2901                        ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2902                    );
2903                }
2904            }
2905        }
2906        self.registered_lsp_servers
2907            .borrow_mut()
2908            .insert(language.clone(), self.plugin_name.clone());
2909
2910        Ok(self
2911            .command_sender
2912            .send(PluginCommand::RegisterLspServer { language, config })
2913            .is_ok())
2914    }
2915
2916    /// Reload the grammar registry to apply registered grammars (async)
2917    /// Call this after registering one or more grammars.
2918    /// Returns a Promise that resolves when the grammar rebuild completes.
2919    #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2920    #[qjs(rename = "_reloadGrammarsStart")]
2921    pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2922        let id = self.alloc_request_id();
2923        let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2924            callback_id: fresh_core::api::JsCallbackId::new(id),
2925        });
2926        id
2927    }
2928
2929    /// Get the directory where this plugin's files are stored.
2930    /// For package plugins this is `<plugins_dir>/packages/<plugin_name>/`.
2931    pub fn get_plugin_dir(&self) -> String {
2932        self.services
2933            .plugins_dir()
2934            .join("packages")
2935            .join(&self.plugin_name)
2936            .to_string_lossy()
2937            .to_string()
2938    }
2939
2940    /// Get config directory path
2941    pub fn get_config_dir(&self) -> String {
2942        self.services.config_dir().to_string_lossy().to_string()
2943    }
2944
2945    /// Get the persistent data directory path (DirectoryContext::data_dir).
2946    /// Intended for plugin state that should outlive a single session — e.g.
2947    /// review-diff comments keyed off git state.
2948    pub fn get_data_dir(&self) -> String {
2949        self.services.data_dir().to_string_lossy().to_string()
2950    }
2951
2952    /// Directory holding terminal scrollback backing files for the current
2953    /// working directory. Each project root / worktree has its own subdir, so
2954    /// Universal Search's terminal scope can stay scoped to the active
2955    /// project rather than spanning every project's terminals.
2956    pub fn get_terminal_dir(&self) -> String {
2957        let working_dir = self
2958            .state_snapshot
2959            .read()
2960            .map(|s| s.working_dir.clone())
2961            .unwrap_or_else(|_| std::path::PathBuf::from("."));
2962        self.services
2963            .terminal_dir(&working_dir)
2964            .to_string_lossy()
2965            .to_string()
2966    }
2967
2968    /// Per-working-directory data root for plugin state scoped to the current
2969    /// project root / worktree (`<data_dir>/workdirs/<encoded-cwd>/`). Use
2970    /// instead of `getDataDir()` for state that should not be shared across
2971    /// worktrees. The directory is not created here — callers create what
2972    /// they need under it.
2973    pub fn get_working_data_dir(&self) -> String {
2974        let working_dir = self
2975            .state_snapshot
2976            .read()
2977            .map(|s| s.working_dir.clone())
2978            .unwrap_or_else(|_| std::path::PathBuf::from("."));
2979        self.services
2980            .working_data_dir(&working_dir)
2981            .to_string_lossy()
2982            .to_string()
2983    }
2984
2985    /// Get themes directory path
2986    pub fn get_themes_dir(&self) -> String {
2987        self.services
2988            .config_dir()
2989            .join("themes")
2990            .to_string_lossy()
2991            .to_string()
2992    }
2993
2994    /// Apply a theme by name
2995    pub fn apply_theme(&self, theme_name: String) -> bool {
2996        self.command_sender
2997            .send(PluginCommand::ApplyTheme { theme_name })
2998            .is_ok()
2999    }
3000
3001    /// Override theme colors in-memory for the running session. `overrides`
3002    /// is a JS object mapping `"section.field"` keys (same namespace as
3003    /// `getThemeSchema`) to `[r, g, b]` triplets (0–255 each).
3004    ///
3005    /// Unknown keys are dropped silently; out-of-range values are clamped
3006    /// to `0..=255`. Overrides survive until the next `applyTheme` call
3007    /// (which replaces the whole `Theme`). Intended for fast animation
3008    /// loops from `init.ts` — no disk I/O, no theme-registry rescan.
3009    pub fn override_theme_colors<'js>(
3010        &self,
3011        _ctx: rquickjs::Ctx<'js>,
3012        overrides: Value<'js>,
3013    ) -> rquickjs::Result<bool> {
3014        // rquickjs_serde can't deserialize a fixed-size `[i32; 3]` from a
3015        // JS Array at the nested-map position (it asks for a "top level
3016        // sequence value" and fails). Round-trip through serde_json::Value
3017        // instead — same pattern as `set_setting` — and hand-roll the
3018        // triple validation.
3019        let json: serde_json::Value = rquickjs_serde::from_value(overrides)
3020            .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
3021        let Some(obj) = json.as_object() else {
3022            return Err(rquickjs::Error::new_from_js_message(
3023                "type",
3024                "",
3025                "overrideThemeColors expects an object of \"key\": [r, g, b]",
3026            ));
3027        };
3028        let to_u8 = |n: &serde_json::Value| -> Option<u8> {
3029            n.as_i64()
3030                .or_else(|| n.as_f64().map(|f| f as i64))
3031                .map(|v| v.clamp(0, 255) as u8)
3032        };
3033        let mut clamped: std::collections::HashMap<String, [u8; 3]> =
3034            std::collections::HashMap::with_capacity(obj.len());
3035        for (key, value) in obj {
3036            let Some(arr) = value.as_array() else {
3037                continue;
3038            };
3039            if arr.len() != 3 {
3040                continue;
3041            }
3042            let Some(r) = to_u8(&arr[0]) else { continue };
3043            let Some(g) = to_u8(&arr[1]) else { continue };
3044            let Some(b) = to_u8(&arr[2]) else { continue };
3045            clamped.insert(key.clone(), [r, g, b]);
3046        }
3047        Ok(self
3048            .command_sender
3049            .send(PluginCommand::OverrideThemeColors { overrides: clamped })
3050            .is_ok())
3051    }
3052
3053    /// Get theme schema as JS object
3054    pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3055        let schema = self.services.get_theme_schema();
3056        rquickjs_serde::to_value(ctx, &schema)
3057            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3058    }
3059
3060    /// Get list of builtin themes as JS object
3061    pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3062        let themes = self.services.get_builtin_themes();
3063        rquickjs_serde::to_value(ctx, &themes)
3064            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3065    }
3066
3067    /// Full theme registry (builtins + user themes + packages + bundles).
3068    /// Keyed by canonical registry key; each value carries `_key` / `_pack`.
3069    pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3070        let themes = self.services.get_all_themes();
3071        rquickjs_serde::to_value(ctx, &themes)
3072            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3073    }
3074
3075    /// Delete a custom theme file (sync)
3076    #[qjs(rename = "_deleteThemeSync")]
3077    pub fn delete_theme_sync(&self, name: String) -> bool {
3078        // Security: only allow deleting from the themes directory
3079        let themes_dir = self.services.config_dir().join("themes");
3080        let theme_path = themes_dir.join(format!("{}.json", name));
3081
3082        // Verify the file is actually in the themes directory (prevent path traversal)
3083        if let Ok(canonical) = theme_path.canonicalize() {
3084            if let Ok(themes_canonical) = themes_dir.canonicalize() {
3085                if canonical.starts_with(&themes_canonical) {
3086                    return std::fs::remove_file(&canonical).is_ok();
3087                }
3088            }
3089        }
3090        false
3091    }
3092
3093    /// Delete a custom theme (alias for deleteThemeSync)
3094    pub fn delete_theme(&self, name: String) -> bool {
3095        self.delete_theme_sync(name)
3096    }
3097
3098    /// Get theme data (JSON) by name from the in-memory cache
3099    pub fn get_theme_data<'js>(
3100        &self,
3101        ctx: rquickjs::Ctx<'js>,
3102        name: String,
3103    ) -> rquickjs::Result<Value<'js>> {
3104        match self.services.get_theme_data(&name) {
3105            Some(data) => rquickjs_serde::to_value(ctx, &data)
3106                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3107            None => Ok(Value::new_null(ctx)),
3108        }
3109    }
3110
3111    /// Save a theme file to the user themes directory, returns the saved path
3112    pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3113        self.services
3114            .save_theme_file(&name, &content)
3115            .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3116    }
3117
3118    /// Check if a user theme file exists
3119    pub fn theme_file_exists(&self, name: String) -> bool {
3120        self.services.theme_file_exists(&name)
3121    }
3122
3123    // === File Stats ===
3124
3125    /// Get file stat information
3126    pub fn file_stat<'js>(
3127        &self,
3128        ctx: rquickjs::Ctx<'js>,
3129        path: String,
3130    ) -> rquickjs::Result<Value<'js>> {
3131        let metadata = std::fs::metadata(&path).ok();
3132        let stat = metadata.map(|m| {
3133            serde_json::json!({
3134                "isFile": m.is_file(),
3135                "isDir": m.is_dir(),
3136                "size": m.len(),
3137                "readonly": m.permissions().readonly(),
3138            })
3139        });
3140        rquickjs_serde::to_value(ctx, &stat)
3141            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3142    }
3143
3144    // === Process Management ===
3145
3146    /// Check if a background process is still running
3147    pub fn is_process_running(&self, _process_id: u64) -> bool {
3148        // This would need to check against tracked processes
3149        // For now, return false - proper implementation needs process tracking
3150        false
3151    }
3152
3153    /// Kill a process by ID (alias for killBackgroundProcess)
3154    pub fn kill_process(&self, process_id: u64) -> bool {
3155        self.command_sender
3156            .send(PluginCommand::KillBackgroundProcess { process_id })
3157            .is_ok()
3158    }
3159
3160    // === Translation ===
3161
3162    /// Translate a key for a specific plugin
3163    pub fn plugin_translate<'js>(
3164        &self,
3165        _ctx: rquickjs::Ctx<'js>,
3166        plugin_name: String,
3167        key: String,
3168        args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3169    ) -> String {
3170        let args_map: HashMap<String, String> = args
3171            .0
3172            .map(|obj| {
3173                let mut map = HashMap::new();
3174                for (k, v) in obj.props::<String, String>().flatten() {
3175                    map.insert(k, v);
3176                }
3177                map
3178            })
3179            .unwrap_or_default();
3180
3181        self.services.translate(&plugin_name, &key, &args_map)
3182    }
3183
3184    // === Composite Buffers ===
3185
3186    /// Create a composite buffer (async)
3187    ///
3188    /// Uses typed CreateCompositeBufferOptions - serde validates field names at runtime
3189    /// via `deny_unknown_fields` attribute
3190    #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3191    #[qjs(rename = "_createCompositeBufferStart")]
3192    pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3193        let id = self.alloc_request_id();
3194
3195        // Track request_id → plugin_name for async resource tracking
3196        if let Ok(mut owners) = self.async_resource_owners.lock() {
3197            owners.insert(id, self.plugin_name.clone());
3198        }
3199        let _ = self
3200            .command_sender
3201            .send(PluginCommand::CreateCompositeBuffer {
3202                name: opts.name,
3203                mode: opts.mode,
3204                layout: opts.layout,
3205                sources: opts.sources,
3206                hunks: opts.hunks,
3207                initial_focus_hunk: opts.initial_focus_hunk,
3208                request_id: Some(id),
3209            });
3210
3211        id
3212    }
3213
3214    /// Update alignment hunks for a composite buffer
3215    ///
3216    /// Uses typed Vec<CompositeHunk> - serde validates field names at runtime
3217    pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3218        self.command_sender
3219            .send(PluginCommand::UpdateCompositeAlignment {
3220                buffer_id: BufferId(buffer_id as usize),
3221                hunks,
3222            })
3223            .is_ok()
3224    }
3225
3226    /// Close a composite buffer
3227    pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3228        self.command_sender
3229            .send(PluginCommand::CloseCompositeBuffer {
3230                buffer_id: BufferId(buffer_id as usize),
3231            })
3232            .is_ok()
3233    }
3234
3235    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
3236    /// After calling this, commands that depend on view state created during
3237    /// rendering (e.g., `compositeNextHunk`) will work correctly.
3238    pub fn flush_layout(&self) -> bool {
3239        self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3240    }
3241
3242    /// Navigate to the next hunk in a composite buffer
3243    pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3244        self.command_sender
3245            .send(PluginCommand::CompositeNextHunk {
3246                buffer_id: BufferId(buffer_id as usize),
3247            })
3248            .is_ok()
3249    }
3250
3251    /// Navigate to the previous hunk in a composite buffer
3252    pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3253        self.command_sender
3254            .send(PluginCommand::CompositePrevHunk {
3255                buffer_id: BufferId(buffer_id as usize),
3256            })
3257            .is_ok()
3258    }
3259
3260    // === Highlights ===
3261
3262    /// Request syntax highlights for a buffer range (async)
3263    #[plugin_api(
3264        async_promise,
3265        js_name = "getHighlights",
3266        ts_return = "TsHighlightSpan[]"
3267    )]
3268    #[qjs(rename = "_getHighlightsStart")]
3269    pub fn get_highlights_start<'js>(
3270        &self,
3271        _ctx: rquickjs::Ctx<'js>,
3272        buffer_id: u32,
3273        start: u32,
3274        end: u32,
3275    ) -> rquickjs::Result<u64> {
3276        let id = self.alloc_request_id();
3277
3278        let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3279            buffer_id: BufferId(buffer_id as usize),
3280            range: (start as usize)..(end as usize),
3281            request_id: id,
3282        });
3283
3284        Ok(id)
3285    }
3286
3287    // === Overlays ===
3288
3289    /// Add an overlay with styling options
3290    ///
3291    /// Colors can be specified as RGB arrays `[r, g, b]` or theme key strings.
3292    /// Theme keys are resolved at render time, so overlays update with theme changes.
3293    ///
3294    /// Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword"
3295    ///
3296    /// Options: fg, bg (RGB array or theme key string), bold, italic, underline,
3297    /// strikethrough, extend_to_line_end (all booleans, default false).
3298    ///
3299    /// Example usage in TypeScript:
3300    /// ```typescript
3301    /// editor.addOverlay(bufferId, "my-namespace", 0, 10, {
3302    ///   fg: "syntax.keyword",           // theme key
3303    ///   bg: [40, 40, 50],               // RGB array
3304    ///   bold: true,
3305    ///   strikethrough: true,
3306    /// });
3307    /// ```
3308    pub fn add_overlay<'js>(
3309        &self,
3310        _ctx: rquickjs::Ctx<'js>,
3311        buffer_id: u32,
3312        namespace: String,
3313        start: u32,
3314        end: u32,
3315        options: rquickjs::Object<'js>,
3316    ) -> rquickjs::Result<bool> {
3317        use fresh_core::api::OverlayColorSpec;
3318
3319        // Parse color spec from JS value (can be [r,g,b] array or "theme.key" string)
3320        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3321            // Try as string first (theme key)
3322            if let Ok(theme_key) = obj.get::<_, String>(key) {
3323                if !theme_key.is_empty() {
3324                    return Some(OverlayColorSpec::ThemeKey(theme_key));
3325                }
3326            }
3327            // Try as array [r, g, b]
3328            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3329                if arr.len() >= 3 {
3330                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3331                }
3332            }
3333            None
3334        }
3335
3336        let fg = parse_color_spec("fg", &options);
3337        let bg = parse_color_spec("bg", &options);
3338        let underline: bool = options.get("underline").unwrap_or(false);
3339        let bold: bool = options.get("bold").unwrap_or(false);
3340        let italic: bool = options.get("italic").unwrap_or(false);
3341        let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3342        let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3343        let fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
3344        let url: Option<String> = options.get("url").ok();
3345
3346        let options = OverlayOptions {
3347            fg,
3348            bg,
3349            underline,
3350            bold,
3351            italic,
3352            strikethrough,
3353            extend_to_line_end,
3354            fg_on_collision_only,
3355            url,
3356        };
3357
3358        // Track namespace for cleanup on unload
3359        self.plugin_tracked_state
3360            .borrow_mut()
3361            .entry(self.plugin_name.clone())
3362            .or_default()
3363            .overlay_namespaces
3364            .push((BufferId(buffer_id as usize), namespace.clone()));
3365
3366        let _ = self.command_sender.send(PluginCommand::AddOverlay {
3367            buffer_id: BufferId(buffer_id as usize),
3368            namespace: Some(OverlayNamespace::from_string(namespace)),
3369            range: (start as usize)..(end as usize),
3370            options,
3371        });
3372
3373        Ok(true)
3374    }
3375
3376    /// Clear all overlays in a namespace
3377    pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3378        self.command_sender
3379            .send(PluginCommand::ClearNamespace {
3380                buffer_id: BufferId(buffer_id as usize),
3381                namespace: OverlayNamespace::from_string(namespace),
3382            })
3383            .is_ok()
3384    }
3385
3386    /// Clear all overlays from a buffer
3387    pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3388        self.command_sender
3389            .send(PluginCommand::ClearAllOverlays {
3390                buffer_id: BufferId(buffer_id as usize),
3391            })
3392            .is_ok()
3393    }
3394
3395    /// Clear all overlays that overlap with a byte range
3396    pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3397        self.command_sender
3398            .send(PluginCommand::ClearOverlaysInRange {
3399                buffer_id: BufferId(buffer_id as usize),
3400                start: start as usize,
3401                end: end as usize,
3402            })
3403            .is_ok()
3404    }
3405
3406    /// Remove an overlay by its handle
3407    pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3408        use fresh_core::overlay::OverlayHandle;
3409        self.command_sender
3410            .send(PluginCommand::RemoveOverlay {
3411                buffer_id: BufferId(buffer_id as usize),
3412                handle: OverlayHandle(handle),
3413            })
3414            .is_ok()
3415    }
3416
3417    // === Conceal Ranges ===
3418
3419    /// Add a conceal range that hides or replaces a byte range during rendering
3420    pub fn add_conceal(
3421        &self,
3422        buffer_id: u32,
3423        namespace: String,
3424        start: u32,
3425        end: u32,
3426        replacement: Option<String>,
3427    ) -> bool {
3428        // Track namespace for cleanup on unload
3429        self.plugin_tracked_state
3430            .borrow_mut()
3431            .entry(self.plugin_name.clone())
3432            .or_default()
3433            .overlay_namespaces
3434            .push((BufferId(buffer_id as usize), namespace.clone()));
3435
3436        self.command_sender
3437            .send(PluginCommand::AddConceal {
3438                buffer_id: BufferId(buffer_id as usize),
3439                namespace: OverlayNamespace::from_string(namespace),
3440                start: start as usize,
3441                end: end as usize,
3442                replacement,
3443            })
3444            .is_ok()
3445    }
3446
3447    /// Clear all conceal ranges in a namespace
3448    pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3449        self.command_sender
3450            .send(PluginCommand::ClearConcealNamespace {
3451                buffer_id: BufferId(buffer_id as usize),
3452                namespace: OverlayNamespace::from_string(namespace),
3453            })
3454            .is_ok()
3455    }
3456
3457    /// Clear all conceal ranges that overlap with a byte range
3458    pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3459        self.command_sender
3460            .send(PluginCommand::ClearConcealsInRange {
3461                buffer_id: BufferId(buffer_id as usize),
3462                start: start as usize,
3463                end: end as usize,
3464            })
3465            .is_ok()
3466    }
3467
3468    // === Folds ===
3469
3470    /// Add a collapsed fold range. Hides bytes [start, end) from
3471    /// rendering — the line containing `start - 1` (the fold "header")
3472    /// stays visible, while subsequent lines covered by the range are
3473    /// skipped.
3474    pub fn add_fold(
3475        &self,
3476        buffer_id: u32,
3477        start: u32,
3478        end: u32,
3479        placeholder: rquickjs::function::Opt<String>,
3480    ) -> bool {
3481        self.command_sender
3482            .send(PluginCommand::AddFold {
3483                buffer_id: BufferId(buffer_id as usize),
3484                start: start as usize,
3485                end: end as usize,
3486                placeholder: placeholder.0,
3487            })
3488            .is_ok()
3489    }
3490
3491    /// Clear every collapsed fold range on the buffer.
3492    pub fn clear_folds(&self, buffer_id: u32) -> bool {
3493        self.command_sender
3494            .send(PluginCommand::ClearFolds {
3495                buffer_id: BufferId(buffer_id as usize),
3496            })
3497            .is_ok()
3498    }
3499
3500    /// Publish a set of toggleable fold ranges on the buffer. Same
3501    /// shape an LSP `foldingRange` response would take. Unlike
3502    /// `addFold`, this does *not* pre-collapse anything — the
3503    /// standard fold-toggle keybinding finds the range under the
3504    /// cursor and collapses or expands it on demand. Replacing call
3505    /// replaces the prior set.
3506    ///
3507    /// `ranges` is a JS array of objects shaped
3508    /// `{ startLine, endLine, kind? }` (lines are 0-indexed).
3509    /// `kind` is one of `"comment"`, `"imports"`, `"region"` per
3510    /// the LSP spec; omitted/unknown values are accepted as plain
3511    /// folds.
3512    pub fn set_folding_ranges<'js>(
3513        &self,
3514        _ctx: rquickjs::Ctx<'js>,
3515        buffer_id: u32,
3516        ranges_arr: Vec<rquickjs::Object<'js>>,
3517    ) -> rquickjs::Result<bool> {
3518        let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3519        for obj in ranges_arr {
3520            let start_line: u32 = obj.get("startLine").unwrap_or(0);
3521            let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3522            let kind = obj
3523                .get::<_, String>("kind")
3524                .ok()
3525                .and_then(|s| match s.as_str() {
3526                    "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3527                    "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3528                    "region" => Some(lsp_types::FoldingRangeKind::Region),
3529                    _ => None,
3530                });
3531            ranges.push(lsp_types::FoldingRange {
3532                start_line,
3533                end_line,
3534                start_character: None,
3535                end_character: None,
3536                kind,
3537                collapsed_text: None,
3538            });
3539        }
3540        Ok(self
3541            .command_sender
3542            .send(PluginCommand::SetFoldingRanges {
3543                buffer_id: BufferId(buffer_id as usize),
3544                ranges,
3545            })
3546            .is_ok())
3547    }
3548
3549    // === Soft Breaks ===
3550
3551    /// Add a soft break point for marker-based line wrapping
3552    pub fn add_soft_break(
3553        &self,
3554        buffer_id: u32,
3555        namespace: String,
3556        position: u32,
3557        indent: u32,
3558    ) -> bool {
3559        // Track namespace for cleanup on unload
3560        self.plugin_tracked_state
3561            .borrow_mut()
3562            .entry(self.plugin_name.clone())
3563            .or_default()
3564            .overlay_namespaces
3565            .push((BufferId(buffer_id as usize), namespace.clone()));
3566
3567        self.command_sender
3568            .send(PluginCommand::AddSoftBreak {
3569                buffer_id: BufferId(buffer_id as usize),
3570                namespace: OverlayNamespace::from_string(namespace),
3571                position: position as usize,
3572                indent: indent as u16,
3573            })
3574            .is_ok()
3575    }
3576
3577    /// Clear all soft breaks in a namespace
3578    pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3579        self.command_sender
3580            .send(PluginCommand::ClearSoftBreakNamespace {
3581                buffer_id: BufferId(buffer_id as usize),
3582                namespace: OverlayNamespace::from_string(namespace),
3583            })
3584            .is_ok()
3585    }
3586
3587    /// Clear all soft breaks that fall within a byte range
3588    pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3589        self.command_sender
3590            .send(PluginCommand::ClearSoftBreaksInRange {
3591                buffer_id: BufferId(buffer_id as usize),
3592                start: start as usize,
3593                end: end as usize,
3594            })
3595            .is_ok()
3596    }
3597
3598    // === View Transform ===
3599
3600    /// Submit a view transform for a buffer/split
3601    ///
3602    /// Accepts tokens in the simple format:
3603    ///   {kind: "text"|"newline"|"space"|"break", text: "...", sourceOffset: N, style?: {...}}
3604    ///
3605    /// Also accepts the TypeScript-defined format for backwards compatibility:
3606    ///   {kind: {Text: "..."} | "Newline" | "Space" | "Break", source_offset: N, style?: {...}}
3607    #[allow(clippy::too_many_arguments)]
3608    pub fn submit_view_transform<'js>(
3609        &self,
3610        _ctx: rquickjs::Ctx<'js>,
3611        buffer_id: u32,
3612        split_id: Option<u32>,
3613        start: u32,
3614        end: u32,
3615        tokens: Vec<rquickjs::Object<'js>>,
3616        layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3617    ) -> rquickjs::Result<bool> {
3618        use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3619
3620        let tokens: Vec<ViewTokenWire> = tokens
3621            .into_iter()
3622            .enumerate()
3623            .map(|(idx, obj)| {
3624                // Try to parse the token, with detailed error messages
3625                parse_view_token(&obj, idx)
3626            })
3627            .collect::<rquickjs::Result<Vec<_>>>()?;
3628
3629        // Parse layout hints if provided
3630        let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3631            let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3632            let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3633            Some(LayoutHints {
3634                compose_width,
3635                column_guides,
3636            })
3637        } else {
3638            None
3639        };
3640
3641        let payload = ViewTransformPayload {
3642            range: (start as usize)..(end as usize),
3643            tokens,
3644            layout_hints: parsed_layout_hints,
3645        };
3646
3647        Ok(self
3648            .command_sender
3649            .send(PluginCommand::SubmitViewTransform {
3650                buffer_id: BufferId(buffer_id as usize),
3651                split_id: split_id.map(|id| SplitId(id as usize)),
3652                payload,
3653            })
3654            .is_ok())
3655    }
3656
3657    /// Clear view transform for a buffer/split
3658    pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3659        self.command_sender
3660            .send(PluginCommand::ClearViewTransform {
3661                buffer_id: BufferId(buffer_id as usize),
3662                split_id: split_id.map(|id| SplitId(id as usize)),
3663            })
3664            .is_ok()
3665    }
3666
3667    /// Set layout hints (compose width, column guides) for a buffer/split
3668    /// without going through the view_transform pipeline.
3669    pub fn set_layout_hints<'js>(
3670        &self,
3671        buffer_id: u32,
3672        split_id: Option<u32>,
3673        #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3674    ) -> rquickjs::Result<bool> {
3675        use fresh_core::api::LayoutHints;
3676
3677        let compose_width: Option<u16> = hints.get("composeWidth").ok();
3678        let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3679        let parsed_hints = LayoutHints {
3680            compose_width,
3681            column_guides,
3682        };
3683
3684        Ok(self
3685            .command_sender
3686            .send(PluginCommand::SetLayoutHints {
3687                buffer_id: BufferId(buffer_id as usize),
3688                split_id: split_id.map(|id| SplitId(id as usize)),
3689                range: 0..0,
3690                hints: parsed_hints,
3691            })
3692            .is_ok())
3693    }
3694
3695    // === File Explorer ===
3696
3697    /// Set file explorer decorations for a namespace
3698    pub fn set_file_explorer_decorations<'js>(
3699        &self,
3700        _ctx: rquickjs::Ctx<'js>,
3701        namespace: String,
3702        decorations: Vec<rquickjs::Object<'js>>,
3703    ) -> rquickjs::Result<bool> {
3704        use fresh_core::file_explorer::FileExplorerDecoration;
3705
3706        let decorations: Vec<FileExplorerDecoration> = decorations
3707            .into_iter()
3708            .map(|obj| {
3709                let path: String = obj.get("path")?;
3710                let symbol: String = obj.get("symbol")?;
3711                let priority: i32 = obj.get("priority").unwrap_or(0);
3712
3713                // Color can be an RGB array [r, g, b] or a theme key string
3714                let color_val: rquickjs::Value = obj.get("color")?;
3715                let color = if color_val.is_string() {
3716                    let key: String = color_val.get()?;
3717                    fresh_core::api::OverlayColorSpec::ThemeKey(key)
3718                } else if color_val.is_array() {
3719                    let arr: Vec<u8> = color_val.get()?;
3720                    if arr.len() < 3 {
3721                        return Err(rquickjs::Error::FromJs {
3722                            from: "array",
3723                            to: "color",
3724                            message: Some(format!(
3725                                "color array must have at least 3 elements, got {}",
3726                                arr.len()
3727                            )),
3728                        });
3729                    }
3730                    fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3731                } else {
3732                    return Err(rquickjs::Error::FromJs {
3733                        from: "value",
3734                        to: "color",
3735                        message: Some("color must be an RGB array or theme key string".to_string()),
3736                    });
3737                };
3738
3739                Ok(FileExplorerDecoration {
3740                    path: std::path::PathBuf::from(path),
3741                    symbol,
3742                    color,
3743                    priority,
3744                })
3745            })
3746            .collect::<rquickjs::Result<Vec<_>>>()?;
3747
3748        // Track namespace for cleanup on unload
3749        self.plugin_tracked_state
3750            .borrow_mut()
3751            .entry(self.plugin_name.clone())
3752            .or_default()
3753            .file_explorer_namespaces
3754            .push(namespace.clone());
3755
3756        Ok(self
3757            .command_sender
3758            .send(PluginCommand::SetFileExplorerDecorations {
3759                namespace,
3760                decorations,
3761            })
3762            .is_ok())
3763    }
3764
3765    /// Clear file explorer decorations for a namespace
3766    pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3767        self.command_sender
3768            .send(PluginCommand::ClearFileExplorerDecorations { namespace })
3769            .is_ok()
3770    }
3771
3772    // === Virtual Text ===
3773
3774    /// Add virtual text (inline text that doesn't exist in the buffer)
3775    #[allow(clippy::too_many_arguments)]
3776    pub fn add_virtual_text(
3777        &self,
3778        buffer_id: u32,
3779        virtual_text_id: String,
3780        position: u32,
3781        text: String,
3782        r: u8,
3783        g: u8,
3784        b: u8,
3785        before: bool,
3786        use_bg: bool,
3787    ) -> bool {
3788        // Track virtual text ID for cleanup on unload
3789        self.plugin_tracked_state
3790            .borrow_mut()
3791            .entry(self.plugin_name.clone())
3792            .or_default()
3793            .virtual_text_ids
3794            .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3795
3796        self.command_sender
3797            .send(PluginCommand::AddVirtualText {
3798                buffer_id: BufferId(buffer_id as usize),
3799                virtual_text_id,
3800                position: position as usize,
3801                text,
3802                color: (r, g, b),
3803                use_bg,
3804                before,
3805            })
3806            .is_ok()
3807    }
3808
3809    /// Remove a virtual text by ID
3810    pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
3811        self.command_sender
3812            .send(PluginCommand::RemoveVirtualText {
3813                buffer_id: BufferId(buffer_id as usize),
3814                virtual_text_id,
3815            })
3816            .is_ok()
3817    }
3818
3819    /// Add styled virtual text — richer form of `addVirtualText` whose
3820    /// `options` accepts an `addOverlay`-style record: `fg`/`bg` may
3821    /// be RGB arrays or theme-key strings, plus `bold`/`italic`. Theme
3822    /// keys are resolved at render time so the label follows theme
3823    /// changes live.
3824    #[allow(clippy::too_many_arguments)]
3825    pub fn add_virtual_text_styled<'js>(
3826        &self,
3827        _ctx: rquickjs::Ctx<'js>,
3828        buffer_id: u32,
3829        virtual_text_id: String,
3830        position: u32,
3831        text: String,
3832        options: rquickjs::Object<'js>,
3833        before: bool,
3834    ) -> rquickjs::Result<bool> {
3835        use fresh_core::api::OverlayColorSpec;
3836
3837        // Same parser shape as addOverlay; accepts `[r, g, b]` arrays
3838        // or theme-key strings.
3839        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3840            if let Ok(theme_key) = obj.get::<_, String>(key) {
3841                if !theme_key.is_empty() {
3842                    return Some(OverlayColorSpec::ThemeKey(theme_key));
3843                }
3844            }
3845            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3846                if arr.len() >= 3 {
3847                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3848                }
3849            }
3850            None
3851        }
3852
3853        let fg = parse_color_spec("fg", &options);
3854        let bg = parse_color_spec("bg", &options);
3855        let bold: bool = options.get("bold").unwrap_or(false);
3856        let italic: bool = options.get("italic").unwrap_or(false);
3857
3858        // Track virtual text ID for cleanup on unload.
3859        self.plugin_tracked_state
3860            .borrow_mut()
3861            .entry(self.plugin_name.clone())
3862            .or_default()
3863            .virtual_text_ids
3864            .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3865
3866        let _ = self
3867            .command_sender
3868            .send(PluginCommand::AddVirtualTextStyled {
3869                buffer_id: BufferId(buffer_id as usize),
3870                virtual_text_id,
3871                position: position as usize,
3872                text,
3873                fg,
3874                bg,
3875                bold,
3876                italic,
3877                before,
3878            });
3879        Ok(true)
3880    }
3881
3882    /// Remove virtual texts whose ID starts with the given prefix
3883    pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
3884        self.command_sender
3885            .send(PluginCommand::RemoveVirtualTextsByPrefix {
3886                buffer_id: BufferId(buffer_id as usize),
3887                prefix,
3888            })
3889            .is_ok()
3890    }
3891
3892    /// Clear all virtual texts from a buffer
3893    pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
3894        self.command_sender
3895            .send(PluginCommand::ClearVirtualTexts {
3896                buffer_id: BufferId(buffer_id as usize),
3897            })
3898            .is_ok()
3899    }
3900
3901    /// Clear all virtual texts in a namespace
3902    pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3903        self.command_sender
3904            .send(PluginCommand::ClearVirtualTextNamespace {
3905                buffer_id: BufferId(buffer_id as usize),
3906                namespace,
3907            })
3908            .is_ok()
3909    }
3910
3911    /// Add a virtual line (full line above/below a position)
3912    ///
3913    /// The `options` object accepts:
3914    ///   * `fg`, `bg` — either an `[r, g, b]` array (each `0..=255`) or a
3915    ///     theme-key string (e.g. `"editor.line_number_fg"`).  Theme keys
3916    ///     are resolved at render time so the line follows theme changes.
3917    ///     Both default to `null` (no foreground / transparent background).
3918    ///   * `gutterGlyph` — optional single character (any short string)
3919    ///     rendered in the line-number column on this virtual line's
3920    ///     first visual row. Use to mark e.g. a deletion line with "-"
3921    ///     so the indicator sits next to the deleted content instead
3922    ///     of on the following source line.
3923    ///   * `gutterColor` — color for `gutterGlyph`, same shape as
3924    ///     `fg`/`bg`. Falls back to the theme's line-number fg.
3925    #[allow(clippy::too_many_arguments)]
3926    pub fn add_virtual_line<'js>(
3927        &self,
3928        _ctx: rquickjs::Ctx<'js>,
3929        buffer_id: u32,
3930        position: u32,
3931        text: String,
3932        options: rquickjs::Object<'js>,
3933        above: bool,
3934        namespace: String,
3935        priority: i32,
3936    ) -> rquickjs::Result<bool> {
3937        use fresh_core::api::OverlayColorSpec;
3938
3939        // Same flexible parser as add_overlay: accepts theme key string or
3940        // RGB array.  Returns None when the key is missing or unusable.
3941        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3942            if let Ok(theme_key) = obj.get::<_, String>(key) {
3943                if !theme_key.is_empty() {
3944                    return Some(OverlayColorSpec::ThemeKey(theme_key));
3945                }
3946            }
3947            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3948                if arr.len() >= 3 {
3949                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3950                }
3951            }
3952            None
3953        }
3954
3955        let fg_color = parse_color_spec("fg", &options);
3956        let bg_color = parse_color_spec("bg", &options);
3957        let gutter_glyph = options
3958            .get::<_, String>("gutterGlyph")
3959            .ok()
3960            .filter(|s| !s.is_empty());
3961        let gutter_color = parse_color_spec("gutterColor", &options);
3962
3963        // Deserialize the array via the same serde-over-rquickjs path the
3964        // rest of the runtime uses (cf. `set_setting`), so the plugin-facing
3965        // shape is whatever `VirtualLineTextOverlay`'s `Deserialize` derive
3966        // accepts (camelCase keys, default-initialised modifier flags).
3967        // Drops empty/inverted ranges defensively.
3968        let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
3969            .get::<_, rquickjs::Value<'js>>("textOverlays")
3970            .ok()
3971            .filter(|v| !v.is_undefined() && !v.is_null())
3972            .and_then(|v| rquickjs_serde::from_value(v).ok())
3973            .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
3974                v.into_iter().filter(|o| o.end > o.start).collect()
3975            })
3976            .unwrap_or_default();
3977
3978        // Track namespace for cleanup on unload
3979        self.plugin_tracked_state
3980            .borrow_mut()
3981            .entry(self.plugin_name.clone())
3982            .or_default()
3983            .virtual_line_namespaces
3984            .push((BufferId(buffer_id as usize), namespace.clone()));
3985
3986        Ok(self
3987            .command_sender
3988            .send(PluginCommand::AddVirtualLine {
3989                buffer_id: BufferId(buffer_id as usize),
3990                position: position as usize,
3991                text,
3992                fg_color,
3993                bg_color,
3994                above,
3995                namespace,
3996                priority,
3997                gutter_glyph,
3998                gutter_color,
3999                text_overlays,
4000            })
4001            .is_ok())
4002    }
4003
4004    // === Prompts ===
4005
4006    /// Show a prompt and wait for user input (async)
4007    /// Returns the user input or null if cancelled
4008    #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
4009    #[qjs(rename = "_promptStart")]
4010    pub fn prompt_start(
4011        &self,
4012        _ctx: rquickjs::Ctx<'_>,
4013        label: String,
4014        initial_value: String,
4015    ) -> u64 {
4016        let id = self.alloc_request_id();
4017
4018        let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
4019            label,
4020            initial_value,
4021            callback_id: JsCallbackId::new(id),
4022        });
4023
4024        id
4025    }
4026
4027    /// Start an interactive prompt.
4028    ///
4029    /// When `floatingOverlay` is true, the editor renders the prompt
4030    /// and its suggestions inside a centred floating frame instead of
4031    /// the bottom minibuffer row (issue #1796 — Live Grep). The flag
4032    /// is rendering-only; confirm/cancel/hooks behave identically to a
4033    /// non-overlay prompt of the same `promptType`.
4034    // `floating_overlay` uses `rquickjs::function::Opt` (not `Option<bool>`)
4035    // so JS callers can omit the argument; `Option<bool>` would require
4036    // the argument position to be present at the JS layer.
4037    pub fn start_prompt(
4038        &self,
4039        label: String,
4040        prompt_type: String,
4041        floating_overlay: rquickjs::function::Opt<bool>,
4042    ) -> bool {
4043        self.command_sender
4044            .send(PluginCommand::StartPrompt {
4045                label,
4046                prompt_type,
4047                floating_overlay: floating_overlay.0.unwrap_or(false),
4048            })
4049            .is_ok()
4050    }
4051
4052    /// Begin a key-capture window for the calling plugin.
4053    ///
4054    /// Pair with `endKeyCapture()` around any `getNextKey()` loop.
4055    /// While capture is active, keys arriving between two
4056    /// `getNextKey()` calls are buffered in-order rather than
4057    /// falling through to the buffer / mode bindings, so fast typing,
4058    /// pastes, or held-key auto-repeat are delivered losslessly.
4059    /// Without this, a plugin's input loop has a race where keys
4060    /// typed while the plugin is mid-redraw can leak into the editor.
4061    pub fn begin_key_capture(&self) -> bool {
4062        self.command_sender
4063            .send(PluginCommand::SetKeyCaptureActive { active: true })
4064            .is_ok()
4065    }
4066
4067    /// End the key-capture window and discard any unconsumed buffered
4068    /// keys.  Call from a `finally` block so capture is released even
4069    /// if the plugin's loop throws.
4070    pub fn end_key_capture(&self) -> bool {
4071        self.command_sender
4072            .send(PluginCommand::SetKeyCaptureActive { active: false })
4073            .is_ok()
4074    }
4075
4076    /// Wait for the next keypress and resolve with a `KeyEventPayload`.
4077    ///
4078    /// While the returned promise is pending the editor consumes the
4079    /// next key and resolves it; the key does not propagate to mode
4080    /// bindings or other dispatch. Multiple in-flight requests across
4081    /// plugins are FIFO. Designed for short input loops (flash labels,
4082    /// vi find-char, replace-char) that would otherwise need to bind
4083    /// every printable key in `defineMode`.
4084    ///
4085    /// For lossless capture against fast typing or paste, wrap the
4086    /// loop with `beginKeyCapture()` / `endKeyCapture()`.
4087    #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4088    #[qjs(rename = "_getNextKeyStart")]
4089    pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4090        let id = self.alloc_request_id();
4091        let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4092            callback_id: JsCallbackId::new(id),
4093        });
4094        id
4095    }
4096
4097    /// Start a prompt with initial value. See `startPrompt` for the
4098    /// meaning of `floatingOverlay`.
4099    pub fn start_prompt_with_initial(
4100        &self,
4101        label: String,
4102        prompt_type: String,
4103        initial_value: String,
4104        floating_overlay: rquickjs::function::Opt<bool>,
4105    ) -> bool {
4106        self.command_sender
4107            .send(PluginCommand::StartPromptWithInitial {
4108                label,
4109                prompt_type,
4110                initial_value,
4111                floating_overlay: floating_overlay.0.unwrap_or(false),
4112            })
4113            .is_ok()
4114    }
4115
4116    /// Set suggestions for the current prompt
4117    ///
4118    /// Uses typed Vec<Suggestion> - serde validates field names at runtime
4119    pub fn set_prompt_suggestions(
4120        &self,
4121        suggestions: Vec<fresh_core::command::Suggestion>,
4122    ) -> bool {
4123        self.command_sender
4124            .send(PluginCommand::SetPromptSuggestions { suggestions })
4125            .is_ok()
4126    }
4127
4128    pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4129        self.command_sender
4130            .send(PluginCommand::SetPromptInputSync { sync })
4131            .is_ok()
4132    }
4133
4134    /// Set the title shown in the floating-overlay prompt's frame
4135    /// header (issue #1796) as styled segments. Each segment
4136    /// carries optional `Partial<OverlayOptions>`, the same
4137    /// styling primitive used by virtual text — plugins mark
4138    /// keybinding hints with `{ fg: "ui.help_key_fg" }`,
4139    /// separators with `{ fg: "ui.popup_border_fg" }`, etc. Pass
4140    /// an empty array to clear the title and fall back to the
4141    /// prompt-type default. Has no visible effect on non-overlay
4142    /// prompts.
4143    pub fn set_prompt_title(
4144        &self,
4145        #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4146    ) -> bool {
4147        self.command_sender
4148            .send(PluginCommand::SetPromptTitle { title })
4149            .is_ok()
4150    }
4151
4152    /// Set the footer chrome row of the floating-overlay prompt's
4153    /// results pane. Plugins use this for hotkey-hint banners
4154    /// (Orchestrator's `[n] new   [d] dive   [Esc] close` row).
4155    /// Empty array clears the footer. Has no visible effect on
4156    /// non-overlay prompts.
4157    pub fn set_prompt_footer(
4158        &self,
4159        #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4160    ) -> bool {
4161        self.command_sender
4162            .send(PluginCommand::SetPromptFooter { footer })
4163            .is_ok()
4164    }
4165
4166    /// Set the floating-overlay prompt's input-row status text (right-aligned,
4167    /// left of the match count). Empty string clears it.
4168    pub fn set_prompt_status(&self, status: String) -> bool {
4169        self.command_sender
4170            .send(PluginCommand::SetPromptStatus { status })
4171            .is_ok()
4172    }
4173
4174    /// Set the floating-overlay prompt's toolbar as a `WidgetSpec` (real,
4175    /// clickable `Toggle`/`Button` widgets rendered in the header band, in
4176    /// place of the styled-text title). Pass `null`/`undefined` to clear it.
4177    #[qjs(rename = "setPromptToolbar")]
4178    pub fn set_prompt_toolbar<'js>(
4179        &self,
4180        ctx: rquickjs::Ctx<'js>,
4181        spec_obj: rquickjs::Value<'js>,
4182    ) -> rquickjs::Result<bool> {
4183        let spec = if spec_obj.is_null() || spec_obj.is_undefined() {
4184            None
4185        } else {
4186            let json = js_to_json(&ctx, spec_obj);
4187            match serde_json::from_value::<fresh_core::api::WidgetSpec>(json) {
4188                Ok(s) => Some(s),
4189                Err(e) => {
4190                    tracing::error!("setPromptToolbar: invalid spec: {}", e);
4191                    return Ok(false);
4192                }
4193            }
4194        };
4195        Ok(self
4196            .command_sender
4197            .send(PluginCommand::SetPromptToolbar { spec })
4198            .is_ok())
4199    }
4200
4201    /// Toggle a floating-overlay toolbar control by its widget `key`. The host
4202    /// owns the toggle's checked state, flips it, and emits a `widget_event`
4203    /// the plugin can listen for. Lets a plugin route its own Alt+… shortcut
4204    /// through the same host path as a click / Space on the toggle.
4205    #[qjs(rename = "toggleOverlayToolbarWidget")]
4206    pub fn toggle_overlay_toolbar_widget(&self, key: String) -> bool {
4207        self.command_sender
4208            .send(PluginCommand::ToggleOverlayToolbarWidget { key })
4209            .is_ok()
4210    }
4211
4212    /// Override the currently-highlighted suggestion row in the
4213    /// open prompt. The editor clamps `index` to the suggestion
4214    /// list's bounds and the renderer scrolls it into view on
4215    /// the next frame. No-op when no prompt is open or the
4216    /// suggestion list is empty. Typical use: re-opening a
4217    /// picker and pre-selecting the entry the user last acted on
4218    /// (Orchestrator highlights the active session).
4219    pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4220        self.command_sender
4221            .send(PluginCommand::SetPromptSelectedIndex { index })
4222            .is_ok()
4223    }
4224
4225    // === Modes ===
4226
4227    /// Define a buffer mode (takes bindings as array of [key, command] pairs)
4228    pub fn define_mode(
4229        &self,
4230        name: String,
4231        bindings_arr: Vec<Vec<String>>,
4232        read_only: rquickjs::function::Opt<bool>,
4233        allow_text_input: rquickjs::function::Opt<bool>,
4234        inherit_normal_bindings: rquickjs::function::Opt<bool>,
4235    ) -> bool {
4236        let bindings: Vec<(String, String)> = bindings_arr
4237            .into_iter()
4238            .filter_map(|arr| {
4239                if arr.len() >= 2 {
4240                    Some((arr[0].clone(), arr[1].clone()))
4241                } else {
4242                    None
4243                }
4244            })
4245            .collect();
4246
4247        // Register commands associated with this mode so start_action can find them
4248        // and execute them in the correct plugin context
4249        {
4250            let mut registered = self.registered_actions.borrow_mut();
4251            for (_, cmd_name) in &bindings {
4252                registered.insert(
4253                    cmd_name.clone(),
4254                    PluginHandler {
4255                        plugin_name: self.plugin_name.clone(),
4256                        handler_name: cmd_name.clone(),
4257                    },
4258                );
4259            }
4260        }
4261
4262        // If allow_text_input is set, register a wildcard handler for text input
4263        // so the plugin can receive arbitrary character input
4264        let allow_text = allow_text_input.0.unwrap_or(false);
4265        if allow_text {
4266            let mut registered = self.registered_actions.borrow_mut();
4267            registered.insert(
4268                "mode_text_input".to_string(),
4269                PluginHandler {
4270                    plugin_name: self.plugin_name.clone(),
4271                    handler_name: "mode_text_input".to_string(),
4272                },
4273            );
4274        }
4275
4276        self.command_sender
4277            .send(PluginCommand::DefineMode {
4278                name,
4279                bindings,
4280                read_only: read_only.0.unwrap_or(false),
4281                allow_text_input: allow_text,
4282                inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4283                plugin_name: Some(self.plugin_name.clone()),
4284            })
4285            .is_ok()
4286    }
4287
4288    /// Set the global editor mode
4289    pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4290        self.command_sender
4291            .send(PluginCommand::SetEditorMode { mode })
4292            .is_ok()
4293    }
4294
4295    /// Get the current editor mode
4296    pub fn get_editor_mode(&self) -> Option<String> {
4297        self.state_snapshot
4298            .read()
4299            .ok()
4300            .and_then(|s| s.editor_mode.clone())
4301    }
4302
4303    // === Splits ===
4304
4305    /// Close a split
4306    pub fn close_split(&self, split_id: u32) -> bool {
4307        self.command_sender
4308            .send(PluginCommand::CloseSplit {
4309                split_id: SplitId(split_id as usize),
4310            })
4311            .is_ok()
4312    }
4313
4314    /// Set the buffer displayed in a split
4315    pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4316        self.command_sender
4317            .send(PluginCommand::SetSplitBuffer {
4318                split_id: SplitId(split_id as usize),
4319                buffer_id: BufferId(buffer_id as usize),
4320            })
4321            .is_ok()
4322    }
4323
4324    /// Focus a specific split
4325    pub fn focus_split(&self, split_id: u32) -> bool {
4326        self.command_sender
4327            .send(PluginCommand::FocusSplit {
4328                split_id: SplitId(split_id as usize),
4329            })
4330            .is_ok()
4331    }
4332
4333    // === Sessions ===
4334    //
4335    // See docs/internal/orchestrator-sessions-design.md. The base
4336    // session is always id 1 and survives every editor session.
4337    // Plugins observe lifecycle through the `window_created`,
4338    // `window_closed`, and `active_window_changed` hooks; the
4339    // current snapshot of all sessions and the active id is
4340    // available synchronously from `listWindows` / `activeSession`.
4341
4342    /// Create a new editor session rooted at `root`. `root` must be
4343    /// an absolute path; relative paths are rejected by the editor
4344    /// (logged, no session created). The new session's id is
4345    /// reported via the `window_created` hook payload — plugins
4346    /// that need the id should listen for that event rather than
4347    /// polling `listWindows`.
4348    ///
4349    /// Returns `false` only when the IPC channel to the editor is
4350    /// closed (editor is shutting down).
4351    pub fn create_window(&self, root: String, label: String) -> bool {
4352        self.command_sender
4353            .send(PluginCommand::CreateWindow {
4354                root: std::path::PathBuf::from(root),
4355                label,
4356            })
4357            .is_ok()
4358    }
4359
4360    /// Make the session with id `id` the active one. No-op if
4361    /// already active. Errors (id not found) are logged on the
4362    /// editor side; the JS caller can verify by reading
4363    /// `activeWindow()` after.
4364    pub fn set_active_window(&self, id: u64) -> bool {
4365        self.command_sender
4366            .send(PluginCommand::SetActiveWindow {
4367                id: fresh_core::WindowId(id),
4368            })
4369            .is_ok()
4370    }
4371
4372    /// Close session `id`. Refuses to close the active session or
4373    /// the base session (id 1). Logs and no-ops on failure.
4374    pub fn close_window(&self, id: u64) -> bool {
4375        self.command_sender
4376            .send(PluginCommand::CloseWindow {
4377                id: fresh_core::WindowId(id),
4378            })
4379            .is_ok()
4380    }
4381
4382    /// Eagerly initialise an inactive session's per-session state
4383    /// (file tree walk, ignore matcher, etc.) without diving.
4384    /// No-op for the active session or unknown id.
4385    pub fn prewarm_window(&self, id: u64) -> bool {
4386        self.command_sender
4387            .send(PluginCommand::PrewarmWindow {
4388                id: fresh_core::WindowId(id),
4389            })
4390            .is_ok()
4391    }
4392
4393    // === File watching ===
4394
4395    /// Register a `notify`-backed watch on `path`. Returns a
4396    /// promise that resolves to a numeric `handle` (also passed
4397    /// in subsequent `path_changed` event payloads). The promise
4398    /// rejects on `notify` errors (path missing, kernel limit).
4399    ///
4400    /// `recursive` defaults to `false`. Non-recursive watches
4401    /// cover the path itself plus its direct children for
4402    /// directories — see `services/file_watcher.rs` for the
4403    /// rationale.
4404    #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4405    #[qjs(rename = "_watchPathStart")]
4406    pub fn watch_path_start(
4407        &self,
4408        _ctx: rquickjs::Ctx<'_>,
4409        path: String,
4410        recursive: rquickjs::function::Opt<bool>,
4411    ) -> rquickjs::Result<u64> {
4412        let id = self.alloc_request_id();
4413        if let Ok(mut owners) = self.async_resource_owners.lock() {
4414            owners.insert(id, self.plugin_name.clone());
4415        }
4416        let _ = self.command_sender.send(PluginCommand::WatchPath {
4417            path: std::path::PathBuf::from(path),
4418            recursive: recursive.0.unwrap_or(false),
4419            request_id: id,
4420        });
4421        Ok(id)
4422    }
4423
4424    /// Drop a watcher by its handle. Unknown handles are
4425    /// silently ignored.
4426    pub fn unwatch_path(&self, handle: u64) -> bool {
4427        self.command_sender
4428            .send(PluginCommand::UnwatchPath { handle })
4429            .is_ok()
4430    }
4431
4432    /// Tell the editor that the floating-overlay prompt's
4433    /// preview pane should render the entire split tree of
4434    /// session `id` natively. `0` (or any unknown id) clears the
4435    /// override and the preview falls back to the existing
4436    /// path-based phantom-leaf renderer.
4437    ///
4438    /// Orchestrator calls this on each prompt-selection-change so
4439    /// the right pane shows the highlighted session's full
4440    /// editor UI live — splits, terminals, syntax highlighting,
4441    /// decorations — at native rendering cost.
4442    pub fn preview_window_in_rect(&self, id: u64) -> bool {
4443        let sid = if id == 0 {
4444            None
4445        } else {
4446            Some(fresh_core::WindowId(id))
4447        };
4448        self.command_sender
4449            .send(PluginCommand::PreviewWindowInRect { id: sid })
4450            .is_ok()
4451    }
4452
4453    /// Clear the session-preview override. Equivalent to
4454    /// `previewWindowInRect(0)` but reads better at call sites.
4455    pub fn clear_window_preview(&self) -> bool {
4456        self.command_sender
4457            .send(PluginCommand::PreviewWindowInRect { id: None })
4458            .is_ok()
4459    }
4460
4461    /// All editor sessions, sorted by id (creation order). Always
4462    /// non-empty (the base session is always present).
4463    #[plugin_api(ts_return = "WindowInfo[]")]
4464    pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4465        let sessions: Vec<fresh_core::api::WindowInfo> = self
4466            .state_snapshot
4467            .read()
4468            .map(|s| s.windows.clone())
4469            .unwrap_or_default();
4470        rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4471            rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4472        })
4473    }
4474
4475    /// The currently active session id. Always present in
4476    /// `listWindows()`.
4477    pub fn active_window(&self) -> u64 {
4478        self.state_snapshot
4479            .read()
4480            .map(|s| s.active_window_id.0)
4481            .unwrap_or(1)
4482    }
4483
4484    /// Set scroll position of a split
4485    pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4486        self.command_sender
4487            .send(PluginCommand::SetSplitScroll {
4488                split_id: SplitId(split_id as usize),
4489                top_byte: top_byte as usize,
4490            })
4491            .is_ok()
4492    }
4493
4494    /// Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
4495    pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4496        self.command_sender
4497            .send(PluginCommand::SetSplitRatio {
4498                split_id: SplitId(split_id as usize),
4499                ratio,
4500            })
4501            .is_ok()
4502    }
4503
4504    /// Set a label on a split (e.g., "sidebar")
4505    pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4506        self.command_sender
4507            .send(PluginCommand::SetSplitLabel {
4508                split_id: SplitId(split_id as usize),
4509                label,
4510            })
4511            .is_ok()
4512    }
4513
4514    /// Remove a label from a split
4515    pub fn clear_split_label(&self, split_id: u32) -> bool {
4516        self.command_sender
4517            .send(PluginCommand::ClearSplitLabel {
4518                split_id: SplitId(split_id as usize),
4519            })
4520            .is_ok()
4521    }
4522
4523    /// Find a split by label (async)
4524    #[plugin_api(
4525        async_promise,
4526        js_name = "getSplitByLabel",
4527        ts_return = "number | null"
4528    )]
4529    #[qjs(rename = "_getSplitByLabelStart")]
4530    pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4531        let id = self.alloc_request_id();
4532        let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4533            label,
4534            request_id: id,
4535        });
4536        id
4537    }
4538
4539    /// Distribute all splits evenly
4540    pub fn distribute_splits_evenly(&self) -> bool {
4541        // Get all split IDs - for now send empty vec (app will handle)
4542        self.command_sender
4543            .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4544            .is_ok()
4545    }
4546
4547    /// Set cursor position in a buffer
4548    pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4549        self.command_sender
4550            .send(PluginCommand::SetBufferCursor {
4551                buffer_id: BufferId(buffer_id as usize),
4552                position: position as usize,
4553            })
4554            .is_ok()
4555    }
4556
4557    /// Toggle whether the editor draws a native caret in this buffer.
4558    ///
4559    /// Buffer-group panel buffers default to `show_cursors = false`, which
4560    /// also blocks all native movement actions in `action_to_events`. Plugins
4561    /// that want native cursor motion in a panel (e.g. magit-style row
4562    /// navigation) call this with `true` after `createBufferGroup` returns.
4563    #[qjs(rename = "setBufferShowCursors")]
4564    pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4565        self.command_sender
4566            .send(PluginCommand::SetBufferShowCursors {
4567                buffer_id: BufferId(buffer_id as usize),
4568                show,
4569            })
4570            .is_ok()
4571    }
4572
4573    // === Line Indicators ===
4574
4575    /// Set a line indicator in the gutter
4576    #[allow(clippy::too_many_arguments)]
4577    pub fn set_line_indicator(
4578        &self,
4579        buffer_id: u32,
4580        line: u32,
4581        namespace: String,
4582        symbol: String,
4583        r: u8,
4584        g: u8,
4585        b: u8,
4586        priority: i32,
4587    ) -> bool {
4588        // Track namespace for cleanup on unload
4589        self.plugin_tracked_state
4590            .borrow_mut()
4591            .entry(self.plugin_name.clone())
4592            .or_default()
4593            .line_indicator_namespaces
4594            .push((BufferId(buffer_id as usize), namespace.clone()));
4595
4596        self.command_sender
4597            .send(PluginCommand::SetLineIndicator {
4598                buffer_id: BufferId(buffer_id as usize),
4599                line: line as usize,
4600                namespace,
4601                symbol,
4602                color: (r, g, b),
4603                priority,
4604            })
4605            .is_ok()
4606    }
4607
4608    /// Batch set line indicators in the gutter
4609    #[allow(clippy::too_many_arguments)]
4610    pub fn set_line_indicators(
4611        &self,
4612        buffer_id: u32,
4613        lines: Vec<u32>,
4614        namespace: String,
4615        symbol: String,
4616        r: u8,
4617        g: u8,
4618        b: u8,
4619        priority: i32,
4620    ) -> bool {
4621        // Track namespace for cleanup on unload
4622        self.plugin_tracked_state
4623            .borrow_mut()
4624            .entry(self.plugin_name.clone())
4625            .or_default()
4626            .line_indicator_namespaces
4627            .push((BufferId(buffer_id as usize), namespace.clone()));
4628
4629        self.command_sender
4630            .send(PluginCommand::SetLineIndicators {
4631                buffer_id: BufferId(buffer_id as usize),
4632                lines: lines.into_iter().map(|l| l as usize).collect(),
4633                namespace,
4634                symbol,
4635                color: (r, g, b),
4636                priority,
4637            })
4638            .is_ok()
4639    }
4640
4641    /// Clear line indicators in a namespace
4642    pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4643        self.command_sender
4644            .send(PluginCommand::ClearLineIndicators {
4645                buffer_id: BufferId(buffer_id as usize),
4646                namespace,
4647            })
4648            .is_ok()
4649    }
4650
4651    /// Enable or disable line numbers for a buffer
4652    pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4653        self.command_sender
4654            .send(PluginCommand::SetLineNumbers {
4655                buffer_id: BufferId(buffer_id as usize),
4656                enabled,
4657            })
4658            .is_ok()
4659    }
4660
4661    /// Set the view mode for a buffer ("source" or "compose")
4662    pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4663        self.command_sender
4664            .send(PluginCommand::SetViewMode {
4665                buffer_id: BufferId(buffer_id as usize),
4666                mode,
4667            })
4668            .is_ok()
4669    }
4670
4671    /// Enable or disable line wrapping for a buffer/split
4672    pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4673        self.command_sender
4674            .send(PluginCommand::SetLineWrap {
4675                buffer_id: BufferId(buffer_id as usize),
4676                split_id: split_id.map(|s| SplitId(s as usize)),
4677                enabled,
4678            })
4679            .is_ok()
4680    }
4681
4682    // === Plugin View State ===
4683
4684    /// Set plugin-managed per-buffer view state (write-through to snapshot + command for persistence)
4685    pub fn set_view_state<'js>(
4686        &self,
4687        ctx: rquickjs::Ctx<'js>,
4688        buffer_id: u32,
4689        key: String,
4690        value: Value<'js>,
4691    ) -> bool {
4692        let bid = BufferId(buffer_id as usize);
4693
4694        // Convert JS value to serde_json::Value
4695        let json_value = if value.is_undefined() || value.is_null() {
4696            None
4697        } else {
4698            Some(js_to_json(&ctx, value))
4699        };
4700
4701        // Write-through: update the snapshot immediately so getViewState sees it
4702        if let Ok(mut snapshot) = self.state_snapshot.write() {
4703            if let Some(ref json_val) = json_value {
4704                snapshot
4705                    .plugin_view_states
4706                    .entry(bid)
4707                    .or_default()
4708                    .insert(key.clone(), json_val.clone());
4709            } else {
4710                // null/undefined = delete the key
4711                if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4712                    map.remove(&key);
4713                    if map.is_empty() {
4714                        snapshot.plugin_view_states.remove(&bid);
4715                    }
4716                }
4717            }
4718        }
4719
4720        // Send command to persist in BufferViewState.plugin_state
4721        self.command_sender
4722            .send(PluginCommand::SetViewState {
4723                buffer_id: bid,
4724                key,
4725                value: json_value,
4726            })
4727            .is_ok()
4728    }
4729
4730    /// Get plugin-managed per-buffer view state (reads from snapshot)
4731    pub fn get_view_state<'js>(
4732        &self,
4733        ctx: rquickjs::Ctx<'js>,
4734        buffer_id: u32,
4735        key: String,
4736    ) -> rquickjs::Result<Value<'js>> {
4737        let bid = BufferId(buffer_id as usize);
4738        if let Ok(snapshot) = self.state_snapshot.read() {
4739            if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4740                if let Some(json_val) = map.get(&key) {
4741                    return json_to_js_value(&ctx, json_val);
4742                }
4743            }
4744        }
4745        Ok(Value::new_undefined(ctx.clone()))
4746    }
4747
4748    // === Plugin Global State ===
4749
4750    /// Set plugin-managed global state (write-through to snapshot + command for persistence).
4751    /// State is automatically isolated per plugin using the plugin's name.
4752    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
4753    pub fn set_global_state<'js>(
4754        &self,
4755        ctx: rquickjs::Ctx<'js>,
4756        key: String,
4757        value: Value<'js>,
4758    ) -> bool {
4759        // Convert JS value to serde_json::Value
4760        let json_value = if value.is_undefined() || value.is_null() {
4761            None
4762        } else {
4763            Some(js_to_json(&ctx, value))
4764        };
4765
4766        // Write-through: update the snapshot immediately so getGlobalState sees it
4767        if let Ok(mut snapshot) = self.state_snapshot.write() {
4768            if let Some(ref json_val) = json_value {
4769                snapshot
4770                    .plugin_global_states
4771                    .entry(self.plugin_name.clone())
4772                    .or_default()
4773                    .insert(key.clone(), json_val.clone());
4774            } else {
4775                // null/undefined = delete the key
4776                if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
4777                    map.remove(&key);
4778                    if map.is_empty() {
4779                        snapshot.plugin_global_states.remove(&self.plugin_name);
4780                    }
4781                }
4782            }
4783        }
4784
4785        // Send command to persist in Editor.plugin_global_state
4786        self.command_sender
4787            .send(PluginCommand::SetGlobalState {
4788                plugin_name: self.plugin_name.clone(),
4789                key,
4790                value: json_value,
4791            })
4792            .is_ok()
4793    }
4794
4795    /// Get plugin-managed global state (reads from snapshot).
4796    /// State is automatically isolated per plugin using the plugin's name.
4797    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
4798    pub fn get_global_state<'js>(
4799        &self,
4800        ctx: rquickjs::Ctx<'js>,
4801        key: String,
4802    ) -> rquickjs::Result<Value<'js>> {
4803        if let Ok(snapshot) = self.state_snapshot.read() {
4804            if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
4805                if let Some(json_val) = map.get(&key) {
4806                    return json_to_js_value(&ctx, json_val);
4807                }
4808            }
4809        }
4810        Ok(Value::new_undefined(ctx.clone()))
4811    }
4812
4813    /// Set per-session state on the **active** session. Same
4814    /// shape as `setGlobalState` (write-through to snapshot +
4815    /// dispatched to editor; null/undefined deletes), but the
4816    /// underlying storage lives on `Session.plugin_state` and
4817    /// swaps with the rest of session state on `setActiveWindow`.
4818    /// Plugins that genuinely want per-project state use this;
4819    /// Orchestrator itself uses `setGlobalState` because its session
4820    /// list lives above session boundaries.
4821    pub fn set_window_state<'js>(
4822        &self,
4823        ctx: rquickjs::Ctx<'js>,
4824        key: String,
4825        value: Value<'js>,
4826    ) -> bool {
4827        let json_value = if value.is_undefined() || value.is_null() {
4828            None
4829        } else {
4830            Some(js_to_json(&ctx, value))
4831        };
4832        // Write-through to snapshot's active-session map so the
4833        // very next getWindowState observes our write without
4834        // waiting for a tick.
4835        if let Ok(mut snapshot) = self.state_snapshot.write() {
4836            match &json_value {
4837                Some(v) => {
4838                    snapshot
4839                        .active_session_plugin_states
4840                        .entry(self.plugin_name.clone())
4841                        .or_default()
4842                        .insert(key.clone(), v.clone());
4843                }
4844                None => {
4845                    if let Some(map) = snapshot
4846                        .active_session_plugin_states
4847                        .get_mut(&self.plugin_name)
4848                    {
4849                        map.remove(&key);
4850                        if map.is_empty() {
4851                            snapshot
4852                                .active_session_plugin_states
4853                                .remove(&self.plugin_name);
4854                        }
4855                    }
4856                }
4857            }
4858        }
4859        self.command_sender
4860            .send(PluginCommand::SetWindowState {
4861                plugin_name: self.plugin_name.clone(),
4862                key,
4863                value: json_value,
4864            })
4865            .is_ok()
4866    }
4867
4868    /// Get per-session state from the **active** session
4869    /// (snapshot read). `undefined` if missing.
4870    pub fn get_window_state<'js>(
4871        &self,
4872        ctx: rquickjs::Ctx<'js>,
4873        key: String,
4874    ) -> rquickjs::Result<Value<'js>> {
4875        if let Ok(snapshot) = self.state_snapshot.read() {
4876            if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
4877                if let Some(json_val) = map.get(&key) {
4878                    return json_to_js_value(&ctx, json_val);
4879                }
4880            }
4881        }
4882        Ok(Value::new_undefined(ctx.clone()))
4883    }
4884
4885    // === Scroll Sync ===
4886
4887    /// Create a scroll sync group for anchor-based synchronized scrolling
4888    pub fn create_scroll_sync_group(
4889        &self,
4890        group_id: u32,
4891        left_split: u32,
4892        right_split: u32,
4893    ) -> bool {
4894        // Track group ID for cleanup on unload
4895        self.plugin_tracked_state
4896            .borrow_mut()
4897            .entry(self.plugin_name.clone())
4898            .or_default()
4899            .scroll_sync_group_ids
4900            .push(group_id);
4901        self.command_sender
4902            .send(PluginCommand::CreateScrollSyncGroup {
4903                group_id,
4904                left_split: SplitId(left_split as usize),
4905                right_split: SplitId(right_split as usize),
4906            })
4907            .is_ok()
4908    }
4909
4910    /// Set sync anchors for a scroll sync group
4911    pub fn set_scroll_sync_anchors<'js>(
4912        &self,
4913        _ctx: rquickjs::Ctx<'js>,
4914        group_id: u32,
4915        anchors: Vec<Vec<u32>>,
4916    ) -> bool {
4917        let anchors: Vec<(usize, usize)> = anchors
4918            .into_iter()
4919            .filter_map(|pair| {
4920                if pair.len() >= 2 {
4921                    Some((pair[0] as usize, pair[1] as usize))
4922                } else {
4923                    None
4924                }
4925            })
4926            .collect();
4927        self.command_sender
4928            .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
4929            .is_ok()
4930    }
4931
4932    /// Remove a scroll sync group
4933    pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
4934        self.command_sender
4935            .send(PluginCommand::RemoveScrollSyncGroup { group_id })
4936            .is_ok()
4937    }
4938
4939    // === Actions ===
4940
4941    /// Execute multiple actions in sequence
4942    ///
4943    /// Takes typed ActionSpec array - serde validates field names at runtime
4944    pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
4945        self.command_sender
4946            .send(PluginCommand::ExecuteActions { actions })
4947            .is_ok()
4948    }
4949
4950    /// Show an action popup
4951    ///
4952    /// Takes a typed ActionPopupOptions struct - serde validates field names at runtime
4953    pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
4954        self.command_sender
4955            .send(PluginCommand::ShowActionPopup {
4956                popup_id: opts.id,
4957                title: opts.title,
4958                message: opts.message,
4959                actions: opts.actions,
4960            })
4961            .is_ok()
4962    }
4963
4964    /// Contribute (or replace, or clear) menu rows for the LSP-Servers
4965    /// popup. Pass an empty `items` to clear this plugin's slice for
4966    /// the given language. See `PluginCommand::SetLspMenuContributions`.
4967    pub fn set_lsp_menu_contributions(
4968        &self,
4969        plugin_id: String,
4970        language: String,
4971        items: Vec<fresh_core::api::LspMenuItem>,
4972    ) -> bool {
4973        self.command_sender
4974            .send(PluginCommand::SetLspMenuContributions {
4975                plugin_id,
4976                language,
4977                items,
4978            })
4979            .is_ok()
4980    }
4981
4982    /// Disable LSP for a specific language
4983    pub fn disable_lsp_for_language(&self, language: String) -> bool {
4984        self.command_sender
4985            .send(PluginCommand::DisableLspForLanguage { language })
4986            .is_ok()
4987    }
4988
4989    /// Restart LSP server for a specific language
4990    pub fn restart_lsp_for_language(&self, language: String) -> bool {
4991        self.command_sender
4992            .send(PluginCommand::RestartLspForLanguage { language })
4993            .is_ok()
4994    }
4995
4996    /// Set the workspace root URI for a specific language's LSP server
4997    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
4998    pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
4999        self.command_sender
5000            .send(PluginCommand::SetLspRootUri { language, uri })
5001            .is_ok()
5002    }
5003
5004    /// Get all diagnostics from LSP
5005    #[plugin_api(ts_return = "JsDiagnostic[]")]
5006    pub fn get_all_diagnostics<'js>(
5007        &self,
5008        ctx: rquickjs::Ctx<'js>,
5009    ) -> rquickjs::Result<Value<'js>> {
5010        use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
5011
5012        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
5013            // Convert to JsDiagnostic format for JS
5014            let mut result: Vec<JsDiagnostic> = Vec::new();
5015            for (uri, diags) in s.diagnostics.iter() {
5016                for diag in diags {
5017                    result.push(JsDiagnostic {
5018                        uri: uri.clone(),
5019                        message: diag.message.clone(),
5020                        severity: diag.severity.map(|s| match s {
5021                            lsp_types::DiagnosticSeverity::ERROR => 1,
5022                            lsp_types::DiagnosticSeverity::WARNING => 2,
5023                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
5024                            lsp_types::DiagnosticSeverity::HINT => 4,
5025                            _ => 0,
5026                        }),
5027                        range: JsRange {
5028                            start: JsPosition {
5029                                line: diag.range.start.line,
5030                                character: diag.range.start.character,
5031                            },
5032                            end: JsPosition {
5033                                line: diag.range.end.line,
5034                                character: diag.range.end.character,
5035                            },
5036                        },
5037                        source: diag.source.clone(),
5038                    });
5039                }
5040            }
5041            result
5042        } else {
5043            Vec::new()
5044        };
5045        rquickjs_serde::to_value(ctx, &diagnostics)
5046            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5047    }
5048
5049    /// Get registered event handlers for an event
5050    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
5051        self.event_handlers
5052            .read()
5053            .expect("event_handlers poisoned")
5054            .get(&event_name)
5055            .cloned()
5056            .unwrap_or_default()
5057            .into_iter()
5058            .map(|h| h.handler_name)
5059            .collect()
5060    }
5061
5062    // === Virtual Buffers ===
5063
5064    /// Create a virtual buffer in current split (async, returns buffer and split IDs)
5065    #[plugin_api(
5066        async_promise,
5067        js_name = "createVirtualBuffer",
5068        ts_return = "VirtualBufferResult"
5069    )]
5070    #[qjs(rename = "_createVirtualBufferStart")]
5071    pub fn create_virtual_buffer_start(
5072        &self,
5073        _ctx: rquickjs::Ctx<'_>,
5074        opts: fresh_core::api::CreateVirtualBufferOptions,
5075    ) -> rquickjs::Result<u64> {
5076        let id = self.alloc_request_id();
5077
5078        // Convert JsTextPropertyEntry to TextPropertyEntry
5079        let entries: Vec<TextPropertyEntry> = opts
5080            .entries
5081            .unwrap_or_default()
5082            .into_iter()
5083            .map(|e| TextPropertyEntry {
5084                text: e.text,
5085                properties: e.properties.unwrap_or_default(),
5086                style: e.style,
5087                inline_overlays: e.inline_overlays.unwrap_or_default(),
5088                segments: e.segments.unwrap_or_default(),
5089                pad_to_chars: e.pad_to_chars,
5090                truncate_to_chars: e.truncate_to_chars,
5091            })
5092            .collect();
5093
5094        tracing::debug!(
5095            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
5096            id
5097        );
5098        // Track request_id → plugin_name for async resource tracking
5099        if let Ok(mut owners) = self.async_resource_owners.lock() {
5100            owners.insert(id, self.plugin_name.clone());
5101        }
5102        let _ = self
5103            .command_sender
5104            .send(PluginCommand::CreateVirtualBufferWithContent {
5105                name: opts.name,
5106                mode: opts.mode.unwrap_or_default(),
5107                read_only: opts.read_only.unwrap_or(false),
5108                entries,
5109                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5110                show_cursors: opts.show_cursors.unwrap_or(true),
5111                editing_disabled: opts.editing_disabled.unwrap_or(false),
5112                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5113                request_id: Some(id),
5114            });
5115        Ok(id)
5116    }
5117
5118    /// Create a virtual buffer in a new split (async, returns buffer and split IDs)
5119    #[plugin_api(
5120        async_promise,
5121        js_name = "createVirtualBufferInSplit",
5122        ts_return = "VirtualBufferResult"
5123    )]
5124    #[qjs(rename = "_createVirtualBufferInSplitStart")]
5125    pub fn create_virtual_buffer_in_split_start(
5126        &self,
5127        _ctx: rquickjs::Ctx<'_>,
5128        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5129    ) -> rquickjs::Result<u64> {
5130        let id = self.alloc_request_id();
5131
5132        // Convert JsTextPropertyEntry to TextPropertyEntry
5133        let entries: Vec<TextPropertyEntry> = opts
5134            .entries
5135            .unwrap_or_default()
5136            .into_iter()
5137            .map(|e| TextPropertyEntry {
5138                text: e.text,
5139                properties: e.properties.unwrap_or_default(),
5140                style: e.style,
5141                inline_overlays: e.inline_overlays.unwrap_or_default(),
5142                segments: e.segments.unwrap_or_default(),
5143                pad_to_chars: e.pad_to_chars,
5144                truncate_to_chars: e.truncate_to_chars,
5145            })
5146            .collect();
5147
5148        // Track request_id → plugin_name for async resource tracking
5149        if let Ok(mut owners) = self.async_resource_owners.lock() {
5150            owners.insert(id, self.plugin_name.clone());
5151        }
5152        let _ = self
5153            .command_sender
5154            .send(PluginCommand::CreateVirtualBufferInSplit {
5155                name: opts.name,
5156                mode: opts.mode.unwrap_or_default(),
5157                read_only: opts.read_only.unwrap_or(false),
5158                entries,
5159                ratio: opts.ratio.unwrap_or(0.5),
5160                direction: opts.direction,
5161                panel_id: opts.panel_id,
5162                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5163                show_cursors: opts.show_cursors.unwrap_or(true),
5164                editing_disabled: opts.editing_disabled.unwrap_or(false),
5165                line_wrap: opts.line_wrap,
5166                before: opts.before.unwrap_or(false),
5167                role: opts.role,
5168                request_id: Some(id),
5169            });
5170        Ok(id)
5171    }
5172
5173    /// Create a virtual buffer in an existing split (async, returns buffer and split IDs)
5174    #[plugin_api(
5175        async_promise,
5176        js_name = "createVirtualBufferInExistingSplit",
5177        ts_return = "VirtualBufferResult"
5178    )]
5179    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5180    pub fn create_virtual_buffer_in_existing_split_start(
5181        &self,
5182        _ctx: rquickjs::Ctx<'_>,
5183        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5184    ) -> rquickjs::Result<u64> {
5185        let id = self.alloc_request_id();
5186
5187        // Convert JsTextPropertyEntry to TextPropertyEntry
5188        let entries: Vec<TextPropertyEntry> = opts
5189            .entries
5190            .unwrap_or_default()
5191            .into_iter()
5192            .map(|e| TextPropertyEntry {
5193                text: e.text,
5194                properties: e.properties.unwrap_or_default(),
5195                style: e.style,
5196                inline_overlays: e.inline_overlays.unwrap_or_default(),
5197                segments: e.segments.unwrap_or_default(),
5198                pad_to_chars: e.pad_to_chars,
5199                truncate_to_chars: e.truncate_to_chars,
5200            })
5201            .collect();
5202
5203        // Track request_id → plugin_name for async resource tracking
5204        if let Ok(mut owners) = self.async_resource_owners.lock() {
5205            owners.insert(id, self.plugin_name.clone());
5206        }
5207        let _ = self
5208            .command_sender
5209            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5210                name: opts.name,
5211                mode: opts.mode.unwrap_or_default(),
5212                read_only: opts.read_only.unwrap_or(false),
5213                entries,
5214                split_id: SplitId(opts.split_id),
5215                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5216                show_cursors: opts.show_cursors.unwrap_or(true),
5217                editing_disabled: opts.editing_disabled.unwrap_or(false),
5218                line_wrap: opts.line_wrap,
5219                request_id: Some(id),
5220            });
5221        Ok(id)
5222    }
5223
5224    /// Create a buffer group: multiple panels appearing as one tab
5225    #[qjs(rename = "_createBufferGroupStart")]
5226    pub fn create_buffer_group_start(
5227        &self,
5228        _ctx: rquickjs::Ctx<'_>,
5229        name: String,
5230        mode: String,
5231        layout_json: String,
5232    ) -> rquickjs::Result<u64> {
5233        let id = self.alloc_request_id();
5234        if let Ok(mut owners) = self.async_resource_owners.lock() {
5235            owners.insert(id, self.plugin_name.clone());
5236        }
5237        let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5238            name,
5239            mode,
5240            layout_json,
5241            request_id: Some(id),
5242        });
5243        Ok(id)
5244    }
5245
5246    /// Set the content of a panel within a buffer group
5247    #[qjs(rename = "setPanelContent")]
5248    pub fn set_panel_content<'js>(
5249        &self,
5250        ctx: rquickjs::Ctx<'js>,
5251        group_id: u32,
5252        panel_name: String,
5253        entries_arr: Vec<rquickjs::Object<'js>>,
5254    ) -> rquickjs::Result<bool> {
5255        let entries: Vec<TextPropertyEntry> = entries_arr
5256            .iter()
5257            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5258            .collect();
5259        Ok(self
5260            .command_sender
5261            .send(PluginCommand::SetPanelContent {
5262                group_id: group_id as usize,
5263                panel_name,
5264                entries,
5265            })
5266            .is_ok())
5267    }
5268
5269    /// Close a buffer group
5270    #[qjs(rename = "closeBufferGroup")]
5271    pub fn close_buffer_group(&self, group_id: u32) -> bool {
5272        self.command_sender
5273            .send(PluginCommand::CloseBufferGroup {
5274                group_id: group_id as usize,
5275            })
5276            .is_ok()
5277    }
5278
5279    /// Focus a specific panel within a buffer group
5280    #[qjs(rename = "focusBufferGroupPanel")]
5281    pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5282        self.command_sender
5283            .send(PluginCommand::FocusPanel {
5284                group_id: group_id as usize,
5285                panel_name,
5286            })
5287            .is_ok()
5288    }
5289
5290    /// Re-point a buffer-group's panel at a different buffer id.
5291    ///
5292    /// Streaming plugins (e.g. git-log) allocate one file-backed
5293    /// buffer per item and call this on navigation to swap which
5294    /// buffer the panel displays — instead of mutating a single
5295    /// shared buffer's contents. Resolves with `true` on success.
5296    #[plugin_api(
5297        async_promise,
5298        js_name = "setBufferGroupPanelBuffer",
5299        ts_return = "boolean"
5300    )]
5301    #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5302    pub fn set_buffer_group_panel_buffer_start(
5303        &self,
5304        _ctx: rquickjs::Ctx<'_>,
5305        group_id: u32,
5306        panel_name: String,
5307        buffer_id: u32,
5308    ) -> u64 {
5309        let id = self.alloc_request_id();
5310        let _ = self
5311            .command_sender
5312            .send(PluginCommand::SetBufferGroupPanelBuffer {
5313                group_id: group_id as usize,
5314                panel_name,
5315                buffer_id: BufferId(buffer_id as usize),
5316                request_id: id,
5317            });
5318        id
5319    }
5320
5321    /// Set virtual buffer content (takes array of entry objects)
5322    ///
5323    /// Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
5324    pub fn set_virtual_buffer_content<'js>(
5325        &self,
5326        ctx: rquickjs::Ctx<'js>,
5327        buffer_id: u32,
5328        entries_arr: Vec<rquickjs::Object<'js>>,
5329    ) -> rquickjs::Result<bool> {
5330        let entries: Vec<TextPropertyEntry> = entries_arr
5331            .iter()
5332            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5333            .collect();
5334        Ok(self
5335            .command_sender
5336            .send(PluginCommand::SetVirtualBufferContent {
5337                buffer_id: BufferId(buffer_id as usize),
5338                entries,
5339            })
5340            .is_ok())
5341    }
5342
5343    /// Get text properties at cursor position (returns JS array)
5344    pub fn get_text_properties_at_cursor(
5345        &self,
5346        buffer_id: u32,
5347    ) -> fresh_core::api::TextPropertiesAtCursor {
5348        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5349    }
5350
5351    /// Mount a declarative widget panel inside a virtual buffer.
5352    ///
5353    /// `spec` is a `WidgetSpec` JSON tree (see fresh.d.ts for the
5354    /// shape). The host renders the spec into the buffer; subsequent
5355    /// `updateWidgetPanel` calls re-render the panel against the
5356    /// previously-mounted spec.
5357    ///
5358    /// Returns true on successful queue, false if the IPC channel is
5359    /// closed.
5360    #[qjs(rename = "mountWidgetPanel")]
5361    pub fn mount_widget_panel<'js>(
5362        &self,
5363        ctx: rquickjs::Ctx<'js>,
5364        panel_id: f64,
5365        buffer_id: u32,
5366        spec_obj: rquickjs::Value<'js>,
5367    ) -> rquickjs::Result<bool> {
5368        let json = js_to_json(&ctx, spec_obj);
5369        let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5370            Ok(s) => s,
5371            Err(e) => {
5372                tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5373                return Ok(false);
5374            }
5375        };
5376        Ok(self
5377            .command_sender
5378            .send(PluginCommand::MountWidgetPanel {
5379                panel_id: panel_id as u64,
5380                buffer_id: BufferId(buffer_id as usize),
5381                spec,
5382            })
5383            .is_ok())
5384    }
5385
5386    /// Replace the spec of a previously-mounted widget panel.
5387    /// No-op if the panel id was never mounted.
5388    #[qjs(rename = "updateWidgetPanel")]
5389    pub fn update_widget_panel<'js>(
5390        &self,
5391        ctx: rquickjs::Ctx<'js>,
5392        panel_id: f64,
5393        spec_obj: rquickjs::Value<'js>,
5394    ) -> rquickjs::Result<bool> {
5395        let json = js_to_json(&ctx, spec_obj);
5396        let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5397            Ok(s) => s,
5398            Err(e) => {
5399                tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5400                return Ok(false);
5401            }
5402        };
5403        Ok(self
5404            .command_sender
5405            .send(PluginCommand::UpdateWidgetPanel {
5406                panel_id: panel_id as u64,
5407                spec,
5408            })
5409            .is_ok())
5410    }
5411
5412    /// Unmount a previously-mounted widget panel. The plugin retains
5413    /// ownership of the underlying virtual buffer.
5414    #[qjs(rename = "unmountWidgetPanel")]
5415    pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5416        self.command_sender
5417            .send(PluginCommand::UnmountWidgetPanel {
5418                panel_id: panel_id as u64,
5419            })
5420            .is_ok()
5421    }
5422
5423    /// Route a keystroke / nav action to the panel's focused widget.
5424    ///
5425    /// `action` is a `WidgetAction` JSON object — see fresh.d.ts for
5426    /// the shapes (`{kind: "focusAdvance", delta: 1}` etc.). Plugin's
5427    /// `defineMode` bindings dispatch into here for keys handled by
5428    /// the widget layer; the host runtime acts on the panel's
5429    /// currently focused widget and fires `widget_event` as
5430    /// appropriate.
5431    #[qjs(rename = "widgetCommand")]
5432    pub fn widget_command<'js>(
5433        &self,
5434        ctx: rquickjs::Ctx<'js>,
5435        panel_id: f64,
5436        action_obj: rquickjs::Value<'js>,
5437    ) -> rquickjs::Result<bool> {
5438        let json = js_to_json(&ctx, action_obj);
5439        let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5440            Ok(a) => a,
5441            Err(e) => {
5442                tracing::error!("widgetCommand: invalid action: {}", e);
5443                return Ok(false);
5444            }
5445        };
5446        Ok(self
5447            .command_sender
5448            .send(PluginCommand::WidgetCommand {
5449                panel_id: panel_id as u64,
5450                action,
5451            })
5452            .is_ok())
5453    }
5454
5455    /// Apply a targeted mutation to a mounted widget panel — the
5456    /// IPC fast path. Use instead of `updateWidgetPanel` when the
5457    /// model change touches a single widget; the host applies the
5458    /// mutation in place without re-transmitting the full spec.
5459    /// See `WidgetMutation` in fresh.d.ts for the shapes.
5460    #[qjs(rename = "widgetMutate")]
5461    pub fn widget_mutate<'js>(
5462        &self,
5463        ctx: rquickjs::Ctx<'js>,
5464        panel_id: f64,
5465        mutation_obj: rquickjs::Value<'js>,
5466    ) -> rquickjs::Result<bool> {
5467        let json = js_to_json(&ctx, mutation_obj);
5468        let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5469            Ok(m) => m,
5470            Err(e) => {
5471                tracing::error!("widgetMutate: invalid mutation: {}", e);
5472                return Ok(false);
5473            }
5474        };
5475        Ok(self
5476            .command_sender
5477            .send(PluginCommand::WidgetMutate {
5478                panel_id: panel_id as u64,
5479                mutation,
5480            })
5481            .is_ok())
5482    }
5483
5484    /// Mount a declarative widget panel as a centered floating
5485    /// overlay (not bound to any virtual buffer).
5486    #[qjs(rename = "mountFloatingWidget")]
5487    pub fn mount_floating_widget<'js>(
5488        &self,
5489        ctx: rquickjs::Ctx<'js>,
5490        panel_id: f64,
5491        spec_obj: rquickjs::Value<'js>,
5492        width_pct: f64,
5493        height_pct: f64,
5494    ) -> rquickjs::Result<bool> {
5495        let json = js_to_json(&ctx, spec_obj);
5496        let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5497            Ok(s) => s,
5498            Err(e) => {
5499                tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5500                return Ok(false);
5501            }
5502        };
5503        let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5504        let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5505        Ok(self
5506            .command_sender
5507            .send(PluginCommand::MountFloatingWidget {
5508                panel_id: panel_id as u64,
5509                spec,
5510                width_pct,
5511                height_pct,
5512            })
5513            .is_ok())
5514    }
5515
5516    /// Replace the spec of the currently-mounted floating widget panel.
5517    #[qjs(rename = "updateFloatingWidget")]
5518    pub fn update_floating_widget<'js>(
5519        &self,
5520        ctx: rquickjs::Ctx<'js>,
5521        panel_id: f64,
5522        spec_obj: rquickjs::Value<'js>,
5523    ) -> rquickjs::Result<bool> {
5524        let json = js_to_json(&ctx, spec_obj);
5525        let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5526            Ok(s) => s,
5527            Err(e) => {
5528                tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5529                return Ok(false);
5530            }
5531        };
5532        Ok(self
5533            .command_sender
5534            .send(PluginCommand::UpdateFloatingWidget {
5535                panel_id: panel_id as u64,
5536                spec,
5537            })
5538            .is_ok())
5539    }
5540
5541    /// Tear down the floating widget panel.
5542    #[qjs(rename = "unmountFloatingWidget")]
5543    pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5544        self.command_sender
5545            .send(PluginCommand::UnmountFloatingWidget {
5546                panel_id: panel_id as u64,
5547            })
5548            .is_ok()
5549    }
5550
5551    // === Async Operations ===
5552
5553    /// Spawn a process (async, returns request_id)
5554    ///
5555    /// Optional 4th argument `stdoutTo: string` pipes the child's stdout
5556    /// directly into the named file instead of buffering it. The
5557    /// resolved `SpawnResult.stdout` is empty in that case; the bytes
5558    /// land on disk for `openFile` to pick up as a file-backed buffer.
5559    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5560    #[qjs(rename = "_spawnProcessStart")]
5561    pub fn spawn_process_start(
5562        &self,
5563        _ctx: rquickjs::Ctx<'_>,
5564        command: String,
5565        args: Vec<String>,
5566        cwd: rquickjs::function::Opt<String>,
5567        stdout_to: rquickjs::function::Opt<String>,
5568    ) -> u64 {
5569        let id = self.alloc_request_id();
5570        // Use provided cwd, or fall back to snapshot's working_dir.
5571        // An explicit empty string is treated the same as omitting the
5572        // argument — the TS declaration says `cwd?: string`, so scripts
5573        // that don't know a cwd can pass "" without tripping the
5574        // QuickJS-side `undefined → String` coercion.
5575        let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5576            self.state_snapshot
5577                .read()
5578                .ok()
5579                .map(|s| s.working_dir.to_string_lossy().to_string())
5580        });
5581        let stdout_to_path = stdout_to
5582            .0
5583            .filter(|s| !s.is_empty())
5584            .map(std::path::PathBuf::from);
5585        tracing::info!(
5586            "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5587            self.plugin_name,
5588            command,
5589            args,
5590            effective_cwd,
5591            stdout_to_path,
5592            id
5593        );
5594        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5595            callback_id: JsCallbackId::new(id),
5596            command,
5597            args,
5598            cwd: effective_cwd,
5599            stdout_to: stdout_to_path,
5600        });
5601        id
5602    }
5603
5604    /// Spawn a process on the host regardless of the active authority.
5605    ///
5606    /// Intended for plugin internals that must run host-side work
5607    /// (e.g. `devcontainer up`) before installing an authority that
5608    /// would otherwise route the spawn elsewhere. Same calling shape
5609    /// as `spawnProcess`.
5610    #[plugin_api(
5611        async_thenable,
5612        js_name = "spawnHostProcess",
5613        ts_return = "SpawnResult"
5614    )]
5615    #[qjs(rename = "_spawnHostProcessStart")]
5616    pub fn spawn_host_process_start(
5617        &self,
5618        _ctx: rquickjs::Ctx<'_>,
5619        command: String,
5620        args: Vec<String>,
5621        cwd: rquickjs::function::Opt<String>,
5622    ) -> u64 {
5623        let id = self.alloc_request_id();
5624        let effective_cwd = cwd.0.or_else(|| {
5625            self.state_snapshot
5626                .read()
5627                .ok()
5628                .map(|s| s.working_dir.to_string_lossy().to_string())
5629        });
5630        let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5631            callback_id: JsCallbackId::new(id),
5632            command,
5633            args,
5634            cwd: effective_cwd,
5635        });
5636        id
5637    }
5638
5639    /// Cancel a host-side process started via `spawnHostProcess`.
5640    /// `process_id` is the callback id the JS wrapper stashed on the
5641    /// handle. Returns `false` only when the command channel is dead
5642    /// (editor tearing down). Unknown ids no-op on the editor side —
5643    /// see `PluginCommand::KillHostProcess` in fresh-core/api.rs.
5644    ///
5645    /// Exposed on the JS side as `editor._killHostProcess`; the
5646    /// public API is `handle.kill()` from the `spawnHostProcess`
5647    /// wrapper.
5648    #[plugin_api(js_name = "_killHostProcess")]
5649    pub fn kill_host_process(&self, process_id: u64) -> bool {
5650        self.command_sender
5651            .send(PluginCommand::KillHostProcess { process_id })
5652            .is_ok()
5653    }
5654
5655    /// Install a new authority via an opaque payload.
5656    ///
5657    /// The payload is a JS object describing filesystem + spawner +
5658    /// terminal wrapper + display label. The canonical schema lives in
5659    /// the `AuthorityPayload` type in `fresh-editor`; plugins should
5660    /// hand-build objects that match it. Fire-and-forget: the editor
5661    /// restarts as part of the transition, so the plugin is reloaded
5662    /// before any follow-up work can run on this call's return value.
5663    #[plugin_api(js_name = "setAuthority")]
5664    pub fn set_authority(
5665        &self,
5666        ctx: rquickjs::Ctx<'_>,
5667        #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5668    ) -> bool {
5669        let json = js_to_json(&ctx, payload);
5670        let _ = self
5671            .command_sender
5672            .send(PluginCommand::SetAuthority { payload: json });
5673        true
5674    }
5675
5676    /// Restore the default local authority. Same restart semantics as
5677    /// `setAuthority`.
5678    #[plugin_api(js_name = "clearAuthority")]
5679    pub fn clear_authority(&self) {
5680        let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5681    }
5682
5683    /// Activate an environment: set the live env recipe (`snippet` run in
5684    /// `dir`). Applied to every spawn, re-evaluated on demand — no restart.
5685    /// Honored only when the workspace is Trusted.
5686    #[plugin_api(js_name = "setEnv")]
5687    pub fn set_env(&self, snippet: String, dir: Option<String>) {
5688        let _ = self
5689            .command_sender
5690            .send(PluginCommand::SetEnv { snippet, dir });
5691    }
5692
5693    /// Deactivate the environment — spawns return to the inherited env.
5694    #[plugin_api(js_name = "clearEnv")]
5695    pub fn clear_env(&self) {
5696        let _ = self.command_sender.send(PluginCommand::ClearEnv);
5697    }
5698
5699    /// Override the Remote Indicator's displayed state. Plugins call
5700    /// this to surface lifecycle transitions that the authority layer
5701    /// doesn't know about yet — "Connecting" while `devcontainer up`
5702    /// runs, "FailedAttach" after a non-zero exit, etc.
5703    ///
5704    /// Accepts a tagged JS object:
5705    /// ```ts
5706    /// editor.setRemoteIndicatorState({ kind: "connecting", label: "Building" });
5707    /// editor.setRemoteIndicatorState({ kind: "failed_attach", error: "exit 1" });
5708    /// editor.setRemoteIndicatorState({ kind: "connected", label: "Container:abc" });
5709    /// editor.setRemoteIndicatorState({ kind: "local" });
5710    /// ```
5711    ///
5712    /// The override sticks until replaced or cleared via
5713    /// `clearRemoteIndicatorState`. Editor restart (e.g. on
5714    /// `setAuthority`) resets it — plugins must reassert after a
5715    /// post-restart init if they want the override to persist.
5716    #[plugin_api(js_name = "setRemoteIndicatorState")]
5717    pub fn set_remote_indicator_state(
5718        &self,
5719        ctx: rquickjs::Ctx<'_>,
5720        #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
5721    ) -> bool {
5722        let json = js_to_json(&ctx, state);
5723        let _ = self
5724            .command_sender
5725            .send(PluginCommand::SetRemoteIndicatorState { state: json });
5726        true
5727    }
5728
5729    /// Drop any active Remote Indicator override. Safe to call even
5730    /// without a prior `setRemoteIndicatorState`.
5731    #[plugin_api(js_name = "clearRemoteIndicatorState")]
5732    pub fn clear_remote_indicator_state(&self) {
5733        let _ = self
5734            .command_sender
5735            .send(PluginCommand::ClearRemoteIndicatorState);
5736    }
5737
5738    /// Fetch a URL over HTTP(S) and stream the response body into `target_path`.
5739    ///
5740    /// Resolves with a `SpawnResult`-shaped value: `exit_code` is `0` on a
5741    /// 2xx response (file written), the HTTP status code on non-2xx
5742    /// (target file untouched), and `-1` on transport errors. `stderr`
5743    /// carries an error message in the non-success cases; `stdout` is
5744    /// always empty.
5745    ///
5746    /// This uses the editor's built-in HTTP client (`ureq`), so plugins
5747    /// don't need `curl`/`wget` on PATH.
5748    #[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
5749    #[qjs(rename = "_httpFetchStart")]
5750    pub fn http_fetch_start(
5751        &self,
5752        _ctx: rquickjs::Ctx<'_>,
5753        url: String,
5754        target_path: String,
5755    ) -> u64 {
5756        let id = self.alloc_request_id();
5757        tracing::info!(
5758            "http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
5759            self.plugin_name,
5760            url,
5761            target_path,
5762            id
5763        );
5764        let _ = self.command_sender.send(PluginCommand::HttpFetch {
5765            url,
5766            target_path: std::path::PathBuf::from(target_path),
5767            callback_id: JsCallbackId::new(id),
5768        });
5769        id
5770    }
5771
5772    /// Wait for a process to complete and get its result (async)
5773    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
5774    #[qjs(rename = "_spawnProcessWaitStart")]
5775    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
5776        let id = self.alloc_request_id();
5777        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
5778            process_id,
5779            callback_id: JsCallbackId::new(id),
5780        });
5781        id
5782    }
5783
5784    /// Get buffer text range (async, returns request_id)
5785    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
5786    #[qjs(rename = "_getBufferTextStart")]
5787    pub fn get_buffer_text_start(
5788        &self,
5789        _ctx: rquickjs::Ctx<'_>,
5790        buffer_id: u32,
5791        start: u32,
5792        end: u32,
5793    ) -> u64 {
5794        let id = self.alloc_request_id();
5795        let _ = self.command_sender.send(PluginCommand::GetBufferText {
5796            buffer_id: BufferId(buffer_id as usize),
5797            start: start as usize,
5798            end: end as usize,
5799            request_id: id,
5800        });
5801        id
5802    }
5803
5804    /// Delay/sleep (async, returns request_id)
5805    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
5806    #[qjs(rename = "_delayStart")]
5807    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
5808        let id = self.alloc_request_id();
5809        let _ = self.command_sender.send(PluginCommand::Delay {
5810            callback_id: JsCallbackId::new(id),
5811            duration_ms,
5812        });
5813        id
5814    }
5815
5816    /// Project-wide grep search (async)
5817    /// Searches all files in the project, respecting .gitignore.
5818    /// Open buffers with dirty edits are searched in-memory.
5819    #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
5820    #[qjs(rename = "_grepProjectStart")]
5821    pub fn grep_project_start(
5822        &self,
5823        _ctx: rquickjs::Ctx<'_>,
5824        pattern: String,
5825        fixed_string: Option<bool>,
5826        case_sensitive: Option<bool>,
5827        max_results: Option<u32>,
5828        whole_words: Option<bool>,
5829    ) -> u64 {
5830        let id = self.alloc_request_id();
5831        let _ = self.command_sender.send(PluginCommand::GrepProject {
5832            pattern,
5833            fixed_string: fixed_string.unwrap_or(true),
5834            case_sensitive: case_sensitive.unwrap_or(true),
5835            max_results: max_results.unwrap_or(200) as usize,
5836            whole_words: whole_words.unwrap_or(false),
5837            callback_id: JsCallbackId::new(id),
5838        });
5839        id
5840    }
5841
5842    /// Begin a streaming project-wide search and return a `SearchHandle`.
5843    /// The producer (host) writes matches at full speed into shared state;
5844    /// the consumer drains via `handle.take()` at its own cadence. Call
5845    /// `handle.cancel()` to abort.
5846    #[plugin_api(
5847        js_name = "beginSearch",
5848        ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }): SearchHandle"
5849    )]
5850    #[qjs(rename = "_beginSearch")]
5851    pub fn begin_search(
5852        &self,
5853        _ctx: rquickjs::Ctx<'_>,
5854        pattern: String,
5855        fixed_string: bool,
5856        case_sensitive: bool,
5857        max_results: u32,
5858        whole_words: bool,
5859    ) -> u64 {
5860        let id = self.alloc_request_id();
5861        // Register the shared state before sending the command so the
5862        // editor's task always sees an entry on lookup.
5863        let entry = Arc::new(SearchHandleState::new());
5864        if let Ok(mut map) = self.search_handles.lock() {
5865            map.insert(id, entry);
5866        }
5867        let _ = self.command_sender.send(PluginCommand::BeginSearch {
5868            pattern,
5869            fixed_string,
5870            case_sensitive,
5871            max_results: max_results as usize,
5872            whole_words,
5873            handle_id: id,
5874        });
5875        id
5876    }
5877
5878    /// Drain pending matches for a search handle. Returns the matches that
5879    /// have arrived since the previous call plus terminal-state flags.
5880    /// After `done` is observed, the handle entry is removed from the
5881    /// registry.
5882    #[plugin_api(ts_return = "SearchTakeResult")]
5883    #[qjs(rename = "_searchHandleTake")]
5884    pub fn search_handle_take<'js>(
5885        &self,
5886        ctx: rquickjs::Ctx<'js>,
5887        handle_id: u64,
5888    ) -> rquickjs::Result<Value<'js>> {
5889        let entry = self
5890            .search_handles
5891            .lock()
5892            .ok()
5893            .and_then(|m| m.get(&handle_id).cloned());
5894        let result = match entry {
5895            Some(handle) => {
5896                // Drain under the lock — O(1) `mem::take` of the pending vec.
5897                let mut state = match handle.state.lock() {
5898                    Ok(s) => s,
5899                    Err(poisoned) => poisoned.into_inner(),
5900                };
5901                let matches = std::mem::take(&mut state.pending);
5902                let snapshot = SearchTakeResult {
5903                    matches,
5904                    done: state.done,
5905                    total_seen: state.total_seen,
5906                    truncated: state.truncated,
5907                    error: state.error.clone(),
5908                };
5909                let done = snapshot.done;
5910                drop(state);
5911                if done {
5912                    if let Ok(mut map) = self.search_handles.lock() {
5913                        map.remove(&handle_id);
5914                    }
5915                }
5916                snapshot
5917            }
5918            None => SearchTakeResult {
5919                matches: Vec::new(),
5920                done: true,
5921                total_seen: 0,
5922                truncated: false,
5923                error: None,
5924            },
5925        };
5926        rquickjs_serde::to_value(ctx, &result)
5927            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5928    }
5929
5930    /// Cancel a streaming search. Idempotent and safe to call after the
5931    /// handle has been removed from the registry.
5932    #[qjs(rename = "_searchHandleCancel")]
5933    pub fn search_handle_cancel(&self, handle_id: u64) {
5934        if let Ok(map) = self.search_handles.lock() {
5935            if let Some(entry) = map.get(&handle_id) {
5936                entry
5937                    .cancel
5938                    .store(true, std::sync::atomic::Ordering::Relaxed);
5939            }
5940        }
5941    }
5942
5943    /// Replace matches in a file's buffer (async)
5944    /// Opens the file if not already in a buffer, applies edits via the buffer model,
5945    /// and saves. All edits are grouped as a single undo action.
5946    #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
5947    #[qjs(rename = "_replaceInFileStart")]
5948    pub fn replace_in_file_start(
5949        &self,
5950        _ctx: rquickjs::Ctx<'_>,
5951        file_path: String,
5952        matches: Vec<Vec<u32>>,
5953        replacement: String,
5954    ) -> u64 {
5955        let id = self.alloc_request_id();
5956        // Convert [[offset, length], ...] to Vec<(usize, usize)>
5957        let match_pairs: Vec<(usize, usize)> = matches
5958            .iter()
5959            .map(|m| (m[0] as usize, m[1] as usize))
5960            .collect();
5961        let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
5962            file_path: PathBuf::from(file_path),
5963            matches: match_pairs,
5964            replacement,
5965            callback_id: JsCallbackId::new(id),
5966        });
5967        id
5968    }
5969
5970    /// Send LSP request (async, returns request_id)
5971    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
5972    #[qjs(rename = "_sendLspRequestStart")]
5973    pub fn send_lsp_request_start<'js>(
5974        &self,
5975        ctx: rquickjs::Ctx<'js>,
5976        language: String,
5977        method: String,
5978        params: Option<rquickjs::Object<'js>>,
5979    ) -> rquickjs::Result<u64> {
5980        let id = self.alloc_request_id();
5981        // Convert params object to serde_json::Value
5982        let params_json: Option<serde_json::Value> = params.map(|obj| {
5983            let val = obj.into_value();
5984            js_to_json(&ctx, val)
5985        });
5986        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
5987            request_id: id,
5988            language,
5989            method,
5990            params: params_json,
5991        });
5992        Ok(id)
5993    }
5994
5995    /// Spawn a background process (async, returns request_id which is also process_id)
5996    #[plugin_api(
5997        async_thenable,
5998        js_name = "spawnBackgroundProcess",
5999        ts_return = "BackgroundProcessResult"
6000    )]
6001    #[qjs(rename = "_spawnBackgroundProcessStart")]
6002    pub fn spawn_background_process_start(
6003        &self,
6004        _ctx: rquickjs::Ctx<'_>,
6005        command: String,
6006        args: Vec<String>,
6007        cwd: rquickjs::function::Opt<String>,
6008    ) -> u64 {
6009        let id = self.alloc_request_id();
6010        // Use id as process_id for simplicity
6011        let process_id = id;
6012        // Track process ID for cleanup on unload
6013        self.plugin_tracked_state
6014            .borrow_mut()
6015            .entry(self.plugin_name.clone())
6016            .or_default()
6017            .background_process_ids
6018            .push(process_id);
6019        // Match `spawn_process_start`: empty-string cwd == omitted.
6020        let _ = self
6021            .command_sender
6022            .send(PluginCommand::SpawnBackgroundProcess {
6023                process_id,
6024                command,
6025                args,
6026                cwd: cwd.0.filter(|s| !s.is_empty()),
6027                callback_id: JsCallbackId::new(id),
6028            });
6029        id
6030    }
6031
6032    /// Kill a background process
6033    pub fn kill_background_process(&self, process_id: u64) -> bool {
6034        self.command_sender
6035            .send(PluginCommand::KillBackgroundProcess { process_id })
6036            .is_ok()
6037    }
6038
6039    // === Terminal ===
6040
6041    /// Create a new terminal in a split (async, returns TerminalResult)
6042    #[plugin_api(
6043        async_promise,
6044        js_name = "createTerminal",
6045        ts_return = "TerminalResult"
6046    )]
6047    #[qjs(rename = "_createTerminalStart")]
6048    pub fn create_terminal_start(
6049        &self,
6050        _ctx: rquickjs::Ctx<'_>,
6051        opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
6052    ) -> rquickjs::Result<u64> {
6053        let id = self.alloc_request_id();
6054
6055        let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
6056            cwd: None,
6057            direction: None,
6058            ratio: None,
6059            focus: None,
6060            persistent: None,
6061            window_id: None,
6062            command: None,
6063            title: None,
6064        });
6065
6066        // Track request_id → plugin_name for async resource tracking
6067        if let Ok(mut owners) = self.async_resource_owners.lock() {
6068            owners.insert(id, self.plugin_name.clone());
6069        }
6070        let _ = self.command_sender.send(PluginCommand::CreateTerminal {
6071            cwd: opts.cwd,
6072            direction: opts.direction,
6073            ratio: opts.ratio,
6074            focus: opts.focus,
6075            window_id: opts.window_id,
6076            // Plugin-created terminals default to ephemeral. Opt in explicitly
6077            // by passing `persistent: true` in the options if the plugin wants
6078            // the terminal to survive workspace save/restore.
6079            persistent: opts.persistent.unwrap_or(false),
6080            command: opts.command,
6081            title: opts.title,
6082            request_id: id,
6083        });
6084        Ok(id)
6085    }
6086
6087    /// Create a new editor window seeded with an agent terminal as
6088    /// its only buffer. Atomic — replaces the legacy
6089    /// `createWindow` + `setActiveWindow` + `createTerminal`
6090    /// chain that left a transient `[No Name]` tab alongside the
6091    /// agent terminal.
6092    #[plugin_api(
6093        async_promise,
6094        js_name = "createWindowWithTerminal",
6095        ts_return = "SessionWithTerminalResult"
6096    )]
6097    #[qjs(rename = "_createWindowWithTerminalStart")]
6098    pub fn create_window_with_terminal_start(
6099        &self,
6100        _ctx: rquickjs::Ctx<'_>,
6101        opts: fresh_core::api::CreateWindowWithTerminalOptions,
6102    ) -> rquickjs::Result<u64> {
6103        let id = self.alloc_request_id();
6104        if let Ok(mut owners) = self.async_resource_owners.lock() {
6105            owners.insert(id, self.plugin_name.clone());
6106        }
6107        let _ = self
6108            .command_sender
6109            .send(PluginCommand::CreateWindowWithTerminal {
6110                root: std::path::PathBuf::from(opts.root),
6111                label: opts.label,
6112                cwd: opts.cwd,
6113                command: opts.command,
6114                title: opts.title,
6115                request_id: id,
6116            });
6117        Ok(id)
6118    }
6119
6120    /// Send input data to a terminal
6121    pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
6122        self.command_sender
6123            .send(PluginCommand::SendTerminalInput {
6124                terminal_id: fresh_core::TerminalId(terminal_id as usize),
6125                data,
6126            })
6127            .is_ok()
6128    }
6129
6130    /// Close a terminal
6131    pub fn close_terminal(&self, terminal_id: u64) -> bool {
6132        self.command_sender
6133            .send(PluginCommand::CloseTerminal {
6134                terminal_id: fresh_core::TerminalId(terminal_id as usize),
6135            })
6136            .is_ok()
6137    }
6138
6139    /// Send `signal` ("SIGTERM" / "SIGKILL" / "SIGINT" / "SIGHUP")
6140    /// to every process group the window `id` is tracking. The
6141    /// window's authority decides delivery; this is the
6142    /// canonical entry point for "stop everything this window
6143    /// owns" rather than reaching at the terminal level. Returns
6144    /// `false` only when the command channel is closed.
6145    pub fn signal_window(&self, id: f64, signal: String) -> bool {
6146        self.command_sender
6147            .send(PluginCommand::SignalWindow {
6148                id: fresh_core::WindowId(id as u64),
6149                signal,
6150            })
6151            .is_ok()
6152    }
6153
6154    // === Misc ===
6155
6156    /// Force refresh of line display
6157    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
6158        self.command_sender
6159            .send(PluginCommand::RefreshLines {
6160                buffer_id: BufferId(buffer_id as usize),
6161            })
6162            .is_ok()
6163    }
6164
6165    /// Get the current locale
6166    pub fn get_current_locale(&self) -> String {
6167        self.services.current_locale()
6168    }
6169
6170    // === Plugin Management ===
6171
6172    /// Load a plugin from a file path (async)
6173    #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
6174    #[qjs(rename = "_loadPluginStart")]
6175    pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
6176        let id = self.alloc_request_id();
6177        let _ = self.command_sender.send(PluginCommand::LoadPlugin {
6178            path: std::path::PathBuf::from(path),
6179            callback_id: JsCallbackId::new(id),
6180        });
6181        id
6182    }
6183
6184    /// Unload a plugin by name (async)
6185    #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
6186    #[qjs(rename = "_unloadPluginStart")]
6187    pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6188        let id = self.alloc_request_id();
6189        let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
6190            name,
6191            callback_id: JsCallbackId::new(id),
6192        });
6193        id
6194    }
6195
6196    /// Reload a plugin by name (async)
6197    #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6198    #[qjs(rename = "_reloadPluginStart")]
6199    pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6200        let id = self.alloc_request_id();
6201        let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6202            name,
6203            callback_id: JsCallbackId::new(id),
6204        });
6205        id
6206    }
6207
6208    /// List all loaded plugins (async)
6209    /// Returns array of { name: string, path: string, enabled: boolean }
6210    #[plugin_api(
6211        async_promise,
6212        js_name = "listPlugins",
6213        ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6214    )]
6215    #[qjs(rename = "_listPluginsStart")]
6216    pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6217        let id = self.alloc_request_id();
6218        let _ = self.command_sender.send(PluginCommand::ListPlugins {
6219            callback_id: JsCallbackId::new(id),
6220        });
6221        id
6222    }
6223}
6224
6225// =============================================================================
6226// View Token Parsing Helpers
6227// =============================================================================
6228
6229/// Parse a single view token from JS object
6230/// Supports both simple format and TypeScript format
6231fn parse_view_token(
6232    obj: &rquickjs::Object<'_>,
6233    idx: usize,
6234) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6235    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6236
6237    // Try to get the 'kind' field - could be string or object
6238    let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6239        from: "object",
6240        to: "ViewTokenWire",
6241        message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6242    })?;
6243
6244    // Parse source_offset - try both camelCase and snake_case
6245    let source_offset: Option<usize> = obj
6246        .get("sourceOffset")
6247        .ok()
6248        .or_else(|| obj.get("source_offset").ok());
6249
6250    // Parse the kind field - support both formats
6251    let kind = if kind_value.is_string() {
6252        // Simple format: kind is a string like "text", "newline", etc.
6253        // OR TypeScript format for non-text: "Newline", "Space", "Break"
6254        let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6255            from: "value",
6256            to: "string",
6257            message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6258        })?;
6259
6260        match kind_str.to_lowercase().as_str() {
6261            "text" => {
6262                let text: String = obj.get("text").unwrap_or_default();
6263                ViewTokenWireKind::Text(text)
6264            }
6265            "newline" => ViewTokenWireKind::Newline,
6266            "space" => ViewTokenWireKind::Space,
6267            "break" => ViewTokenWireKind::Break,
6268            _ => {
6269                // Unknown kind string - log warning and return error
6270                tracing::warn!(
6271                    "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6272                    idx, kind_str
6273                );
6274                return Err(rquickjs::Error::FromJs {
6275                    from: "string",
6276                    to: "ViewTokenWireKind",
6277                    message: Some(format!(
6278                        "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6279                        idx, kind_str
6280                    )),
6281                });
6282            }
6283        }
6284    } else if kind_value.is_object() {
6285        // TypeScript format: kind is an object like {Text: "..."} or {BinaryByte: N}
6286        let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6287            from: "value",
6288            to: "object",
6289            message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6290        })?;
6291
6292        if let Ok(text) = kind_obj.get::<_, String>("Text") {
6293            ViewTokenWireKind::Text(text)
6294        } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6295            ViewTokenWireKind::BinaryByte(byte)
6296        } else {
6297            // Check what keys are present for a helpful error
6298            let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6299            tracing::warn!(
6300                "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6301                idx,
6302                keys
6303            );
6304            return Err(rquickjs::Error::FromJs {
6305                from: "object",
6306                to: "ViewTokenWireKind",
6307                message: Some(format!(
6308                    "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6309                    idx, keys
6310                )),
6311            });
6312        }
6313    } else {
6314        tracing::warn!(
6315            "token[{}]: 'kind' field must be a string or object, got: {:?}",
6316            idx,
6317            kind_value.type_of()
6318        );
6319        return Err(rquickjs::Error::FromJs {
6320            from: "value",
6321            to: "ViewTokenWireKind",
6322            message: Some(format!(
6323                "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6324                idx
6325            )),
6326        });
6327    };
6328
6329    // Parse style if present
6330    let style = parse_view_token_style(obj, idx)?;
6331
6332    Ok(ViewTokenWire {
6333        source_offset,
6334        kind,
6335        style,
6336    })
6337}
6338
6339/// Parse optional style from a token object
6340fn parse_view_token_style(
6341    obj: &rquickjs::Object<'_>,
6342    idx: usize,
6343) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6344    use fresh_core::api::{TokenColor, ViewTokenStyle};
6345
6346    let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6347    let Some(s) = style_obj else {
6348        return Ok(None);
6349    };
6350
6351    // fg/bg accept either `[r, g, b]` (legacy) or a string — a named
6352    // ANSI color (`"Red"`, `"Default"`, …) or a theme key
6353    // (`"editor.diff_remove_bg"`). Try the array form first, then fall
6354    // back to a string.
6355    fn parse_color(
6356        s: &rquickjs::Object<'_>,
6357        field: &str,
6358        idx: usize,
6359    ) -> rquickjs::Result<Option<TokenColor>> {
6360        if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6361            if arr.len() < 3 {
6362                tracing::warn!(
6363                    "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6364                    idx,
6365                    field,
6366                    arr.len()
6367                );
6368                return Ok(None);
6369            }
6370            return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6371        }
6372        if let Ok(name) = s.get::<_, String>(field) {
6373            return Ok(Some(TokenColor::Named(name)));
6374        }
6375        Ok(None)
6376    }
6377
6378    Ok(Some(ViewTokenStyle {
6379        fg: parse_color(&s, "fg", idx)?,
6380        bg: parse_color(&s, "bg", idx)?,
6381        bold: s.get("bold").unwrap_or(false),
6382        italic: s.get("italic").unwrap_or(false),
6383        underline: s.get("underline").unwrap_or(false),
6384    }))
6385}
6386
6387/// QuickJS-based JavaScript runtime for plugins
6388pub struct QuickJsBackend {
6389    runtime: Runtime,
6390    /// Main context for shared/internal operations
6391    main_context: Context,
6392    /// Plugin-specific contexts: plugin_name -> Context
6393    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6394    /// Event handlers: event_name -> list of PluginHandler.
6395    /// Shared with the editor thread so it can cheaply check
6396    /// "does anyone listen to this hook?" — see `EventHandlerRegistry`.
6397    event_handlers: EventHandlerRegistry,
6398    /// Registered actions: action_name -> PluginHandler
6399    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6400    /// Editor state snapshot (read-only access)
6401    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6402    /// Command sender for write operations
6403    command_sender: mpsc::Sender<PluginCommand>,
6404    /// Pending response senders for async operations (held to keep Arc alive)
6405    #[allow(dead_code)]
6406    pending_responses: PendingResponses,
6407    /// Next request ID for async operations
6408    next_request_id: Rc<RefCell<u64>>,
6409    /// Plugin name for each pending callback ID
6410    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6411    /// Bridge for editor services (i18n, theme, etc.)
6412    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6413    /// Per-plugin tracking of created state (namespaces, IDs) for cleanup on unload
6414    pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6415    /// Shared map of request_id → plugin_name for async resource creations.
6416    /// Used by PluginThreadHandle to track buffer/terminal IDs when responses arrive.
6417    async_resource_owners: AsyncResourceOwners,
6418    /// Tracks command name → owning plugin name (first-writer-wins collision detection)
6419    registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6420    /// Tracks grammar language → owning plugin name (first-writer-wins)
6421    registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6422    /// Tracks language config language → owning plugin name (first-writer-wins)
6423    registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6424    /// Tracks LSP server language → owning plugin name (first-writer-wins)
6425    registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6426    /// Plugin-configuration plane (design M3): name → (exporter, persistent
6427    /// JS Object). Shared across every JsEditorApi instance on this
6428    /// Runtime.
6429    plugin_api_exports: PluginApiExports,
6430    /// Streaming-search handle registry shared with the editor thread.
6431    search_handles: SearchHandleRegistry,
6432}
6433
6434impl Drop for QuickJsBackend {
6435    fn drop(&mut self) {
6436        // Persistent<Object> holds references into the QuickJS heap; if any
6437        // are alive when `runtime` drops, QuickJS asserts non-empty
6438        // gc_obj_list. Clear the plugin-API export map (and any other
6439        // Persistent-holding map we add later) before the Runtime field
6440        // gets to run its own Drop.
6441        self.plugin_api_exports.borrow_mut().clear();
6442    }
6443}
6444
6445impl QuickJsBackend {
6446    /// Create a new QuickJS backend (standalone, for testing)
6447    pub fn new() -> Result<Self> {
6448        let (tx, _rx) = mpsc::channel();
6449        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6450        let services = Arc::new(fresh_core::services::NoopServiceBridge);
6451        Self::with_state(state_snapshot, tx, services)
6452    }
6453
6454    /// Create a new QuickJS backend with editor state
6455    pub fn with_state(
6456        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6457        command_sender: mpsc::Sender<PluginCommand>,
6458        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6459    ) -> Result<Self> {
6460        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
6461        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
6462    }
6463
6464    /// Create a new QuickJS backend with editor state and shared pending responses
6465    pub fn with_state_and_responses(
6466        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6467        command_sender: mpsc::Sender<PluginCommand>,
6468        pending_responses: PendingResponses,
6469        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6470    ) -> Result<Self> {
6471        let async_resource_owners: AsyncResourceOwners =
6472            Arc::new(std::sync::Mutex::new(HashMap::new()));
6473        let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
6474        let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
6475        Self::with_state_responses_and_resources(
6476            state_snapshot,
6477            command_sender,
6478            pending_responses,
6479            services,
6480            async_resource_owners,
6481            search_handles,
6482            event_handlers,
6483        )
6484    }
6485
6486    /// Create a new QuickJS backend with editor state, shared pending responses,
6487    /// and a shared async resource owner map
6488    pub fn with_state_responses_and_resources(
6489        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6490        command_sender: mpsc::Sender<PluginCommand>,
6491        pending_responses: PendingResponses,
6492        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6493        async_resource_owners: AsyncResourceOwners,
6494        search_handles: SearchHandleRegistry,
6495        event_handlers: EventHandlerRegistry,
6496    ) -> Result<Self> {
6497        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
6498
6499        let runtime =
6500            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
6501
6502        // Set up promise rejection tracker to catch unhandled rejections
6503        runtime.set_host_promise_rejection_tracker(Some(Box::new(
6504            |_ctx, _promise, reason, is_handled| {
6505                if !is_handled {
6506                    // Format the rejection reason
6507                    let error_msg = if let Some(exc) = reason.as_exception() {
6508                        format!(
6509                            "{}: {}",
6510                            exc.message().unwrap_or_default(),
6511                            exc.stack().unwrap_or_default()
6512                        )
6513                    } else {
6514                        format!("{:?}", reason)
6515                    };
6516
6517                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
6518
6519                    if should_panic_on_js_errors() {
6520                        // Don't panic here - we're inside an FFI callback and rquickjs catches panics.
6521                        // Instead, set a fatal error flag that the plugin thread loop will check.
6522                        let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
6523                        set_fatal_js_error(full_msg);
6524                    }
6525                }
6526            },
6527        )));
6528
6529        let main_context = Context::full(&runtime)
6530            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
6531
6532        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
6533        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
6534        let next_request_id = Rc::new(RefCell::new(1u64));
6535        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
6536        let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
6537        let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
6538        let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
6539        let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
6540        let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
6541        let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
6542
6543        let backend = Self {
6544            runtime,
6545            main_context,
6546            plugin_contexts,
6547            event_handlers,
6548            registered_actions,
6549            state_snapshot,
6550            command_sender,
6551            pending_responses,
6552            next_request_id,
6553            callback_contexts,
6554            services,
6555            plugin_tracked_state,
6556            async_resource_owners,
6557            registered_command_names,
6558            registered_grammar_languages,
6559            registered_language_configs,
6560            registered_lsp_servers,
6561            plugin_api_exports,
6562            search_handles,
6563        };
6564
6565        // Initialize main context (for internal utilities if needed)
6566        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
6567
6568        tracing::debug!("QuickJsBackend::new: runtime created successfully");
6569        Ok(backend)
6570    }
6571
6572    /// Set up the editor API in a specific JavaScript context
6573    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
6574        let state_snapshot = Arc::clone(&self.state_snapshot);
6575        let command_sender = self.command_sender.clone();
6576        let event_handlers = Arc::clone(&self.event_handlers);
6577        let registered_actions = Rc::clone(&self.registered_actions);
6578        let next_request_id = Rc::clone(&self.next_request_id);
6579        let registered_command_names = Rc::clone(&self.registered_command_names);
6580        let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
6581        let registered_language_configs = Rc::clone(&self.registered_language_configs);
6582        let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
6583        let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
6584
6585        context.with(|ctx| {
6586            let globals = ctx.globals();
6587
6588            // Set the plugin name global
6589            globals.set("__pluginName__", plugin_name)?;
6590
6591            // Create the editor object using JsEditorApi class
6592            // This provides proper lifetime handling for methods returning JS values
6593            let js_api = JsEditorApi {
6594                state_snapshot: Arc::clone(&state_snapshot),
6595                command_sender: command_sender.clone(),
6596                registered_actions: Rc::clone(&registered_actions),
6597                event_handlers: Arc::clone(&event_handlers),
6598                next_request_id: Rc::clone(&next_request_id),
6599                callback_contexts: Rc::clone(&self.callback_contexts),
6600                services: self.services.clone(),
6601                plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
6602                async_resource_owners: Arc::clone(&self.async_resource_owners),
6603                registered_command_names: Rc::clone(&registered_command_names),
6604                registered_grammar_languages: Rc::clone(&registered_grammar_languages),
6605                registered_language_configs: Rc::clone(&registered_language_configs),
6606                registered_lsp_servers: Rc::clone(&registered_lsp_servers),
6607                plugin_api_exports: Rc::clone(&plugin_api_exports),
6608                search_handles: Arc::clone(&self.search_handles),
6609                plugin_name: plugin_name.to_string(),
6610            };
6611            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
6612
6613            // All methods are now in JsEditorApi - export editor as global
6614            globals.set("editor", editor)?;
6615
6616            // Define getEditor() globally
6617            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
6618
6619            // Define registerHandler() for strict-mode-compatible handler registration
6620            ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
6621
6622// Closure-friendly overload for `editor.on(event, fn)` (design M2).
6623            // The existing method takes a string handler name registered on
6624            // globalThis. This shim wraps it so callers can pass a function
6625            // directly — we synthesize a unique name, stash the function on
6626            // globalThis (mirroring registerHandler), and subscribe via the
6627            // original path. Pass-through for the legacy string form.
6628            ctx.eval::<(), _>(
6629                r#"
6630                (function() {
6631                    const originalOn = editor.on.bind(editor);
6632                    const originalOff = editor.off.bind(editor);
6633                    let counter = 0;
6634                    const anonNames = new WeakMap();
6635                    editor.on = function(eventName, handlerOrName) {
6636                        if (typeof handlerOrName === 'function') {
6637                            const existing = anonNames.get(handlerOrName);
6638                            const name = existing || `__anon_on_${++counter}`;
6639                            if (!existing) {
6640                                anonNames.set(handlerOrName, name);
6641                            }
6642                            globalThis[name] = handlerOrName;
6643                            return originalOn(eventName, name);
6644                        }
6645                        return originalOn(eventName, handlerOrName);
6646                    };
6647                    editor.off = function(eventName, handlerOrName) {
6648                        if (typeof handlerOrName === 'function') {
6649                            const name = anonNames.get(handlerOrName);
6650                            if (name === undefined) return false;
6651                            return originalOff(eventName, name);
6652                        }
6653                        return originalOff(eventName, handlerOrName);
6654                    };
6655                })();
6656                "#,
6657            )?;
6658
6659            // Provide console.log for debugging
6660            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
6661            let console = Object::new(ctx.clone())?;
6662            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6663                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6664                tracing::info!("console.log: {}", parts.join(" "));
6665            })?)?;
6666            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6667                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6668                tracing::warn!("console.warn: {}", parts.join(" "));
6669            })?)?;
6670            console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6671                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6672                tracing::error!("console.error: {}", parts.join(" "));
6673            })?)?;
6674            globals.set("console", console)?;
6675
6676            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
6677            ctx.eval::<(), _>(r#"
6678                // Pending promise callbacks: callbackId -> { resolve, reject }
6679                globalThis._pendingCallbacks = new Map();
6680
6681                // Resolve a pending callback (called from Rust)
6682                globalThis._resolveCallback = function(callbackId, result) {
6683                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
6684                    const cb = globalThis._pendingCallbacks.get(callbackId);
6685                    if (cb) {
6686                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
6687                        globalThis._pendingCallbacks.delete(callbackId);
6688                        cb.resolve(result);
6689                        console.log('[JS] _resolveCallback: resolve() called');
6690                    } else {
6691                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
6692                    }
6693                };
6694
6695                // Reject a pending callback (called from Rust)
6696                globalThis._rejectCallback = function(callbackId, error) {
6697                    const cb = globalThis._pendingCallbacks.get(callbackId);
6698                    if (cb) {
6699                        globalThis._pendingCallbacks.delete(callbackId);
6700                        cb.reject(new Error(error));
6701                    }
6702                };
6703
6704                // Generic async wrapper decorator
6705                // Wraps a function that returns a callbackId into a promise-returning function
6706                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6707                // NOTE: We pass the method name as a string and call via bracket notation
6708                // to preserve rquickjs's automatic Ctx injection for methods
6709                globalThis._wrapAsync = function(methodName, fnName) {
6710                    const startFn = editor[methodName];
6711                    if (typeof startFn !== 'function') {
6712                        // Return a function that always throws - catches missing implementations
6713                        return function(...args) {
6714                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6715                            editor.debug(`[ASYNC ERROR] ${error.message}`);
6716                            throw error;
6717                        };
6718                    }
6719                    return function(...args) {
6720                        // Call via bracket notation to preserve method binding and Ctx injection
6721                        const callbackId = editor[methodName](...args);
6722                        return new Promise((resolve, reject) => {
6723                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6724                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
6725                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6726                        });
6727                    };
6728                };
6729
6730                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6731                // The returned object has .result promise and is itself thenable
6732                globalThis._wrapAsyncThenable = function(methodName, fnName) {
6733                    const startFn = editor[methodName];
6734                    if (typeof startFn !== 'function') {
6735                        // Return a function that always throws - catches missing implementations
6736                        return function(...args) {
6737                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6738                            editor.debug(`[ASYNC ERROR] ${error.message}`);
6739                            throw error;
6740                        };
6741                    }
6742                    return function(...args) {
6743                        // Call via bracket notation to preserve method binding and Ctx injection
6744                        const callbackId = editor[methodName](...args);
6745                        const resultPromise = new Promise((resolve, reject) => {
6746                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6747                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6748                        });
6749                        return {
6750                            get result() { return resultPromise; },
6751                            then(onFulfilled, onRejected) {
6752                                return resultPromise.then(onFulfilled, onRejected);
6753                            },
6754                            catch(onRejected) {
6755                                return resultPromise.catch(onRejected);
6756                            }
6757                        };
6758                    };
6759                };
6760
6761                // Apply wrappers to async functions on editor
6762                // spawnProcess accepts either form for the 4th arg:
6763                //   editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
6764                //   editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
6765                // The first matches the auto-generated TS signature
6766                // (flat positional from the Rust binding's `Opt<String>`
6767                // args); the second is the structured options form
6768                // plugin authors often prefer.
6769                editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
6770                    if (typeof editor._spawnProcessStart !== 'function') {
6771                        throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
6772                    }
6773                    // The 3rd arg is either cwd (string) or an options
6774                    // object when cwd is omitted; the 4th is either a
6775                    // stdoutTo string or an options object.
6776                    let cwd = "";
6777                    let stdoutTo = "";
6778                    if (typeof cwdOrOpts === "string") {
6779                        cwd = cwdOrOpts;
6780                    } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
6781                        if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
6782                    }
6783                    if (typeof fourth === "string") {
6784                        stdoutTo = fourth;
6785                    } else if (fourth && typeof fourth === "object") {
6786                        if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
6787                    }
6788                    const callbackId = editor._spawnProcessStart(
6789                        command,
6790                        argsArr || [],
6791                        cwd,
6792                        stdoutTo,
6793                    );
6794                    const resultPromise = new Promise((resolve, reject) => {
6795                        globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6796                    });
6797                    return {
6798                        get result() { return resultPromise; },
6799                        // `kill()` cancels a still-running spawn. The
6800                        // dispatcher stores a oneshot keyed by callbackId;
6801                        // _killHostProcess fires it and the spawner's
6802                        // tokio::select! kills the child. No-op if the
6803                        // child already exited (id removed from the map).
6804                        kill() {
6805                            if (typeof editor._killHostProcess === 'function') {
6806                                return editor._killHostProcess(callbackId);
6807                            }
6808                            return false;
6809                        },
6810                        then(onFulfilled, onRejected) {
6811                            return resultPromise.then(onFulfilled, onRejected);
6812                        },
6813                        catch(onRejected) {
6814                            return resultPromise.catch(onRejected);
6815                        }
6816                    };
6817                };
6818                // spawnHostProcess gets a bespoke wrapper (instead of
6819                // `_wrapAsyncThenable`) because its `ProcessHandle`
6820                // exposes a real `kill()` that forwards to
6821                // `_killHostProcess`. Generic wrap has no hook for
6822                // that.
6823                editor.spawnHostProcess = function(command, args, cwd) {
6824                    if (typeof editor._spawnHostProcessStart !== 'function') {
6825                        throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
6826                    }
6827                    // Pass real strings only. Earlier revisions forwarded
6828                    // `""` for a missing cwd, which landed verbatim as
6829                    // `Command::current_dir("")` in the dispatcher —
6830                    // every host-spawn then failed with ENOENT. Use two
6831                    // arity forms so the Rust `Opt<String>` stays `None`
6832                    // instead of `Some("")`.
6833                    let callbackId;
6834                    if (typeof cwd === "string" && cwd.length > 0) {
6835                        callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
6836                    } else {
6837                        callbackId = editor._spawnHostProcessStart(command, args || []);
6838                    }
6839                    const resultPromise = new Promise(function(resolve, reject) {
6840                        globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
6841                    });
6842                    return {
6843                        processId: callbackId,
6844                        get result() { return resultPromise; },
6845                        then: function(f, r) { return resultPromise.then(f, r); },
6846                        catch: function(r) { return resultPromise.catch(r); },
6847                        kill: function() {
6848                            // Returns true when the kill was enqueued
6849                            // (the process may have already exited; in
6850                            // that case the dispatcher silently
6851                            // drops it). Matches the
6852                            // `ProcessHandle.kill(): Promise<boolean>`
6853                            // type signature by wrapping the sync
6854                            // boolean in a Promise.
6855                            return Promise.resolve(editor._killHostProcess(callbackId));
6856                        }
6857                    };
6858                };
6859                editor.delay = _wrapAsync("_delayStart", "delay");
6860                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
6861                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
6862                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
6863                editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
6864                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
6865                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
6866                editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
6867                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
6868                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
6869                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
6870                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
6871                editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
6872                editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
6873                editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
6874                editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
6875                editor.prompt = _wrapAsync("_promptStart", "prompt");
6876                editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
6877                editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
6878                editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
6879                editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
6880                editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
6881                editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
6882                editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
6883                editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
6884                editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
6885                editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
6886                editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
6887
6888                // Pull-based streaming search. Producers (host searcher tasks)
6889                // write into shared state at full speed; the consumer drains
6890                // it via take() at its own cadence — no per-chunk JS dispatch.
6891                editor.beginSearch = function(pattern, opts) {
6892                    opts = opts || {};
6893                    const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
6894                    const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
6895                    const maxResults = opts.maxResults || 10000;
6896                    const wholeWords = opts.wholeWords || false;
6897                    const handleId = editor._beginSearch(
6898                        pattern, fixedString, caseSensitive, maxResults, wholeWords
6899                    );
6900                    return {
6901                        searchId: handleId,
6902                        take: function() { return editor._searchHandleTake(handleId); },
6903                        cancel: function() { editor._searchHandleCancel(handleId); }
6904                    };
6905                };
6906
6907                // Wrapper for deleteTheme - wraps sync function in Promise
6908                editor.deleteTheme = function(name) {
6909                    return new Promise(function(resolve, reject) {
6910                        const success = editor._deleteThemeSync(name);
6911                        if (success) {
6912                            resolve();
6913                        } else {
6914                            reject(new Error("Failed to delete theme: " + name));
6915                        }
6916                    });
6917                };
6918            "#.as_bytes())?;
6919
6920            Ok::<_, rquickjs::Error>(())
6921        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
6922
6923        Ok(())
6924    }
6925
6926    /// Load and execute a TypeScript/JavaScript plugin from a file path
6927    pub async fn load_module_with_source(
6928        &mut self,
6929        path: &str,
6930        _plugin_source: &str,
6931    ) -> Result<()> {
6932        let path_buf = PathBuf::from(path);
6933        let source = std::fs::read_to_string(&path_buf)
6934            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
6935
6936        let filename = path_buf
6937            .file_name()
6938            .and_then(|s| s.to_str())
6939            .unwrap_or("plugin.ts");
6940
6941        // Check for ES imports - these need bundling to resolve dependencies
6942        if has_es_imports(&source) {
6943            // Try to bundle (this also strips imports and exports)
6944            match bundle_module(&path_buf) {
6945                Ok(bundled) => {
6946                    self.execute_js(&bundled, path)?;
6947                }
6948                Err(e) => {
6949                    tracing::warn!(
6950                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
6951                        path,
6952                        e
6953                    );
6954                    return Ok(()); // Skip plugins with unresolvable imports
6955                }
6956            }
6957        } else if has_es_module_syntax(&source) {
6958            // Has exports but no imports - strip exports and transpile
6959            let stripped = strip_imports_and_exports(&source);
6960            let js_code = if filename.ends_with(".ts") {
6961                transpile_typescript(&stripped, filename)?
6962            } else {
6963                stripped
6964            };
6965            self.execute_js(&js_code, path)?;
6966        } else {
6967            // Plain code - just transpile if TypeScript
6968            let js_code = if filename.ends_with(".ts") {
6969                transpile_typescript(&source, filename)?
6970            } else {
6971                source
6972            };
6973            self.execute_js(&js_code, path)?;
6974        }
6975
6976        Ok(())
6977    }
6978
6979    /// Execute JavaScript code in the context
6980    pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
6981        // Extract plugin name from path (filename without extension)
6982        let plugin_name = Path::new(source_name)
6983            .file_stem()
6984            .and_then(|s| s.to_str())
6985            .unwrap_or("unknown");
6986
6987        tracing::debug!(
6988            "execute_js: starting for plugin '{}' from '{}'",
6989            plugin_name,
6990            source_name
6991        );
6992
6993        // Get or create context for this plugin
6994        let context = {
6995            let mut contexts = self.plugin_contexts.borrow_mut();
6996            if let Some(ctx) = contexts.get(plugin_name) {
6997                ctx.clone()
6998            } else {
6999                let ctx = Context::full(&self.runtime).map_err(|e| {
7000                    anyhow!(
7001                        "Failed to create QuickJS context for plugin {}: {}",
7002                        plugin_name,
7003                        e
7004                    )
7005                })?;
7006                self.setup_context_api(&ctx, plugin_name)?;
7007                contexts.insert(plugin_name.to_string(), ctx.clone());
7008                ctx
7009            }
7010        };
7011
7012        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
7013        // This is critical for plugins like vi_mode that declare `const editor = ...`
7014        // which shadows the global `editor` causing TDZ if not wrapped.
7015        let wrapped_code = format!("(function() {{ {} }})();", code);
7016        let wrapped = wrapped_code.as_str();
7017
7018        context.with(|ctx| {
7019            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
7020
7021            // Execute the plugin code with filename for better stack traces
7022            let mut eval_options = rquickjs::context::EvalOptions::default();
7023            eval_options.global = true;
7024            eval_options.filename = Some(source_name.to_string());
7025            let result = ctx
7026                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
7027                .map_err(|e| format_js_error(&ctx, e, source_name));
7028
7029            tracing::debug!(
7030                "execute_js: plugin code execution finished for '{}', result: {:?}",
7031                plugin_name,
7032                result.is_ok()
7033            );
7034
7035            result
7036        })
7037    }
7038
7039    /// Execute JavaScript source code directly as a plugin (no file I/O).
7040    ///
7041    /// This is the entry point for "load plugin from buffer" — the source code
7042    /// goes through the same transpile/strip pipeline as file-based plugins, but
7043    /// without reading from disk or resolving imports.
7044    pub fn execute_source(
7045        &mut self,
7046        source: &str,
7047        plugin_name: &str,
7048        is_typescript: bool,
7049    ) -> Result<()> {
7050        use fresh_parser_js::{
7051            has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
7052        };
7053
7054        if has_es_imports(source) {
7055            tracing::warn!(
7056                "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
7057                plugin_name
7058            );
7059        }
7060
7061        let js_code = if has_es_module_syntax(source) {
7062            let stripped = strip_imports_and_exports(source);
7063            if is_typescript {
7064                transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
7065            } else {
7066                stripped
7067            }
7068        } else if is_typescript {
7069            transpile_typescript(source, &format!("{}.ts", plugin_name))?
7070        } else {
7071            source.to_string()
7072        };
7073
7074        // Use plugin_name as the source_name so execute_js extracts the right name
7075        let source_name = format!(
7076            "{}.{}",
7077            plugin_name,
7078            if is_typescript { "ts" } else { "js" }
7079        );
7080        self.execute_js(&js_code, &source_name)
7081    }
7082
7083    /// Clean up all runtime state owned by a plugin.
7084    ///
7085    /// This removes the plugin's JS context, event handlers, registered actions,
7086    /// callback contexts, and sends compensating commands to the editor to clear
7087    /// namespaced visual state (overlays, conceals, virtual text, etc.).
7088    pub fn cleanup_plugin(&self, plugin_name: &str) {
7089        // 1. Remove plugin's JS context (CRITICAL — without this, execute_js reuses old context)
7090        self.plugin_contexts.borrow_mut().remove(plugin_name);
7091
7092        // 2. Remove event handlers for this plugin
7093        {
7094            let mut handlers_map = self
7095                .event_handlers
7096                .write()
7097                .expect("event_handlers poisoned");
7098            for handlers in handlers_map.values_mut() {
7099                handlers.retain(|h| h.plugin_name != plugin_name);
7100            }
7101            // Drop any keys whose lists are now empty so a future
7102            // `has_subscribers(name)` check returns the right answer
7103            // without scanning a stale empty Vec.
7104            handlers_map.retain(|_, list| !list.is_empty());
7105        }
7106
7107        // 3. Remove registered actions for this plugin
7108        self.registered_actions
7109            .borrow_mut()
7110            .retain(|_, h| h.plugin_name != plugin_name);
7111
7112        // 4. Remove callback contexts for this plugin
7113        self.callback_contexts
7114            .borrow_mut()
7115            .retain(|_, pname| pname != plugin_name);
7116
7117        // 5. Send compensating commands for editor-side state
7118        if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
7119            // Deduplicate (buffer_id, namespace) pairs before sending
7120            let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
7121                std::collections::HashSet::new();
7122            for (buf_id, ns) in &tracked.overlay_namespaces {
7123                if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
7124                    // ClearNamespace clears overlays for this namespace
7125                    let _ = self.command_sender.send(PluginCommand::ClearNamespace {
7126                        buffer_id: *buf_id,
7127                        namespace: OverlayNamespace::from_string(ns.clone()),
7128                    });
7129                    // Also clear conceals and soft breaks (same namespace system)
7130                    let _ = self
7131                        .command_sender
7132                        .send(PluginCommand::ClearConcealNamespace {
7133                            buffer_id: *buf_id,
7134                            namespace: OverlayNamespace::from_string(ns.clone()),
7135                        });
7136                    let _ = self
7137                        .command_sender
7138                        .send(PluginCommand::ClearSoftBreakNamespace {
7139                            buffer_id: *buf_id,
7140                            namespace: OverlayNamespace::from_string(ns.clone()),
7141                        });
7142                }
7143            }
7144
7145            // Note: Virtual lines have no namespace-based clear command in the API.
7146            // They will persist until the buffer is closed. This is acceptable for now
7147            // since most plugins re-create virtual lines on init anyway.
7148
7149            // Clear line indicator namespaces
7150            let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
7151                std::collections::HashSet::new();
7152            for (buf_id, ns) in &tracked.line_indicator_namespaces {
7153                if seen_li_ns.insert((buf_id.0, ns.clone())) {
7154                    let _ = self
7155                        .command_sender
7156                        .send(PluginCommand::ClearLineIndicators {
7157                            buffer_id: *buf_id,
7158                            namespace: ns.clone(),
7159                        });
7160                }
7161            }
7162
7163            // Remove virtual text items
7164            let mut seen_vt: std::collections::HashSet<(usize, String)> =
7165                std::collections::HashSet::new();
7166            for (buf_id, vt_id) in &tracked.virtual_text_ids {
7167                if seen_vt.insert((buf_id.0, vt_id.clone())) {
7168                    let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
7169                        buffer_id: *buf_id,
7170                        virtual_text_id: vt_id.clone(),
7171                    });
7172                }
7173            }
7174
7175            // Clear file explorer decoration namespaces
7176            let mut seen_fe_ns: std::collections::HashSet<String> =
7177                std::collections::HashSet::new();
7178            for ns in &tracked.file_explorer_namespaces {
7179                if seen_fe_ns.insert(ns.clone()) {
7180                    let _ = self
7181                        .command_sender
7182                        .send(PluginCommand::ClearFileExplorerDecorations {
7183                            namespace: ns.clone(),
7184                        });
7185                }
7186            }
7187
7188            // Deactivate contexts set by this plugin
7189            let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
7190            for ctx_name in &tracked.contexts_set {
7191                if seen_ctx.insert(ctx_name.clone()) {
7192                    let _ = self.command_sender.send(PluginCommand::SetContext {
7193                        name: ctx_name.clone(),
7194                        active: false,
7195                    });
7196                }
7197            }
7198
7199            // --- Phase 3: Resource cleanup ---
7200
7201            // Kill background processes spawned by this plugin
7202            for process_id in &tracked.background_process_ids {
7203                let _ = self
7204                    .command_sender
7205                    .send(PluginCommand::KillBackgroundProcess {
7206                        process_id: *process_id,
7207                    });
7208            }
7209
7210            // Remove scroll sync groups created by this plugin
7211            for group_id in &tracked.scroll_sync_group_ids {
7212                let _ = self
7213                    .command_sender
7214                    .send(PluginCommand::RemoveScrollSyncGroup {
7215                        group_id: *group_id,
7216                    });
7217            }
7218
7219            // Close virtual buffers created by this plugin
7220            for buffer_id in &tracked.virtual_buffer_ids {
7221                let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7222                    buffer_id: *buffer_id,
7223                });
7224            }
7225
7226            // Close composite buffers created by this plugin
7227            for buffer_id in &tracked.composite_buffer_ids {
7228                let _ = self
7229                    .command_sender
7230                    .send(PluginCommand::CloseCompositeBuffer {
7231                        buffer_id: *buffer_id,
7232                    });
7233            }
7234
7235            // Close terminals created by this plugin
7236            for terminal_id in &tracked.terminal_ids {
7237                let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7238                    terminal_id: *terminal_id,
7239                });
7240            }
7241
7242            // Drop any file watchers this plugin registered. The
7243            // editor side ignores unknown handles, so it's safe to
7244            // resend on partial failures.
7245            for handle in &tracked.watch_handles {
7246                let _ = self
7247                    .command_sender
7248                    .send(PluginCommand::UnwatchPath { handle: *handle });
7249            }
7250        }
7251
7252        // Clean up any pending async resource owner entries for this plugin
7253        if let Ok(mut owners) = self.async_resource_owners.lock() {
7254            owners.retain(|_, name| name != plugin_name);
7255        }
7256
7257        // Drop any plugin-API exports (design M3) this plugin published.
7258        self.plugin_api_exports
7259            .borrow_mut()
7260            .retain(|_, (exporter, _)| exporter != plugin_name);
7261
7262        // Clear collision tracking maps so another plugin can re-register these names
7263        self.registered_command_names
7264            .borrow_mut()
7265            .retain(|_, pname| pname != plugin_name);
7266        self.registered_grammar_languages
7267            .borrow_mut()
7268            .retain(|_, pname| pname != plugin_name);
7269        self.registered_language_configs
7270            .borrow_mut()
7271            .retain(|_, pname| pname != plugin_name);
7272        self.registered_lsp_servers
7273            .borrow_mut()
7274            .retain(|_, pname| pname != plugin_name);
7275
7276        tracing::debug!(
7277            "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7278            plugin_name
7279        );
7280    }
7281
7282    /// Emit an event to all registered handlers
7283    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7284        tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7285
7286        self.services
7287            .set_js_execution_state(format!("hook '{}'", event_name));
7288
7289        let handlers = self
7290            .event_handlers
7291            .read()
7292            .expect("event_handlers poisoned")
7293            .get(event_name)
7294            .cloned();
7295        if let Some(handler_pairs) = handlers {
7296            let plugin_contexts = self.plugin_contexts.borrow();
7297            for handler in &handler_pairs {
7298                let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7299                    continue;
7300                };
7301                context.with(|ctx| {
7302                    call_handler(&ctx, &handler.handler_name, event_data);
7303                });
7304            }
7305        }
7306
7307        self.services.clear_js_execution_state();
7308        Ok(true)
7309    }
7310
7311    /// Check if any handlers are registered for an event
7312    pub fn has_handlers(&self, event_name: &str) -> bool {
7313        self.event_handlers
7314            .read()
7315            .expect("event_handlers poisoned")
7316            .get(event_name)
7317            .map(|v| !v.is_empty())
7318            .unwrap_or(false)
7319    }
7320
7321    /// Start an action without waiting for async operations to complete.
7322    /// This is useful when the calling thread needs to continue processing
7323    /// ResolveCallback requests that the action may be waiting for.
7324    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7325        // Handle mode_text_input:<char> — route to the plugin that registered
7326        // "mode_text_input" and pass the character as an argument.
7327        let (lookup_name, text_input_char) =
7328            if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7329                ("mode_text_input", Some(ch.to_string()))
7330            } else {
7331                (action_name, None)
7332            };
7333
7334        let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7335        let (plugin_name, function_name) = match pair {
7336            Some(handler) => (handler.plugin_name, handler.handler_name),
7337            None => ("main".to_string(), lookup_name.to_string()),
7338        };
7339
7340        let plugin_contexts = self.plugin_contexts.borrow();
7341        let context = plugin_contexts
7342            .get(&plugin_name)
7343            .unwrap_or(&self.main_context);
7344
7345        // Track execution state for signal handler debugging
7346        self.services
7347            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7348
7349        tracing::info!(
7350            "start_action: BEGIN '{}' -> function '{}'",
7351            action_name,
7352            function_name
7353        );
7354
7355        // Just call the function - don't try to await or drive Promises
7356        // For mode_text_input, pass the character as a JSON-encoded argument
7357        let call_args = if let Some(ref ch) = text_input_char {
7358            let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7359            format!("({{text:\"{}\"}})", escaped)
7360        } else {
7361            "()".to_string()
7362        };
7363
7364        let code = format!(
7365            r#"
7366            (function() {{
7367                console.log('[JS] start_action: calling {fn}');
7368                try {{
7369                    if (typeof globalThis.{fn} === 'function') {{
7370                        console.log('[JS] start_action: {fn} is a function, invoking...');
7371                        globalThis.{fn}{args};
7372                        console.log('[JS] start_action: {fn} invoked (may be async)');
7373                    }} else {{
7374                        console.error('[JS] Action {action} is not defined as a global function');
7375                    }}
7376                }} catch (e) {{
7377                    console.error('[JS] Action {action} error:', e);
7378                }}
7379            }})();
7380            "#,
7381            fn = function_name,
7382            action = action_name,
7383            args = call_args
7384        );
7385
7386        tracing::info!("start_action: evaluating JS code");
7387        context.with(|ctx| {
7388            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7389                log_js_error(&ctx, e, &format!("action {}", action_name));
7390            }
7391            tracing::info!("start_action: running pending microtasks");
7392            // Run any immediate microtasks
7393            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7394            tracing::info!("start_action: executed {} pending jobs", count);
7395        });
7396
7397        tracing::info!("start_action: END '{}'", action_name);
7398
7399        // Clear execution state (action started, may still be running async)
7400        self.services.clear_js_execution_state();
7401
7402        Ok(())
7403    }
7404
7405    /// Execute a registered action by name
7406    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7407        // First check if there's a registered command mapping
7408        let pair = self.registered_actions.borrow().get(action_name).cloned();
7409        let (plugin_name, function_name) = match pair {
7410            Some(handler) => (handler.plugin_name, handler.handler_name),
7411            None => ("main".to_string(), action_name.to_string()),
7412        };
7413
7414        let plugin_contexts = self.plugin_contexts.borrow();
7415        let context = plugin_contexts
7416            .get(&plugin_name)
7417            .unwrap_or(&self.main_context);
7418
7419        tracing::debug!(
7420            "execute_action: '{}' -> function '{}'",
7421            action_name,
7422            function_name
7423        );
7424
7425        // Call the function and await if it returns a Promise
7426        // We use a global _executeActionResult to pass the result back
7427        let code = format!(
7428            r#"
7429            (async function() {{
7430                try {{
7431                    if (typeof globalThis.{fn} === 'function') {{
7432                        const result = globalThis.{fn}();
7433                        // If it's a Promise, await it
7434                        if (result && typeof result.then === 'function') {{
7435                            await result;
7436                        }}
7437                    }} else {{
7438                        console.error('Action {action} is not defined as a global function');
7439                    }}
7440                }} catch (e) {{
7441                    console.error('Action {action} error:', e);
7442                }}
7443            }})();
7444            "#,
7445            fn = function_name,
7446            action = action_name
7447        );
7448
7449        context.with(|ctx| {
7450            // Eval returns a Promise for the async IIFE, which we need to drive
7451            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7452                Ok(value) => {
7453                    // If it's a Promise, we need to drive the runtime to completion
7454                    if value.is_object() {
7455                        if let Some(obj) = value.as_object() {
7456                            // Check if it's a Promise by looking for 'then' method
7457                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
7458                                // Drive the runtime to process the promise
7459                                // QuickJS processes promises synchronously when we call execute_pending_job
7460                                run_pending_jobs_checked(
7461                                    &ctx,
7462                                    &format!("execute_action {} promise", action_name),
7463                                );
7464                            }
7465                        }
7466                    }
7467                }
7468                Err(e) => {
7469                    log_js_error(&ctx, e, &format!("action {}", action_name));
7470                }
7471            }
7472        });
7473
7474        Ok(())
7475    }
7476
7477    /// Poll the event loop once to run any pending microtasks
7478    pub fn poll_event_loop_once(&mut self) -> bool {
7479        let mut had_work = false;
7480
7481        // Poll main context
7482        self.main_context.with(|ctx| {
7483            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7484            if count > 0 {
7485                had_work = true;
7486            }
7487        });
7488
7489        // Poll all plugin contexts
7490        let contexts = self.plugin_contexts.borrow().clone();
7491        for (name, context) in contexts {
7492            context.with(|ctx| {
7493                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7494                if count > 0 {
7495                    had_work = true;
7496                }
7497            });
7498        }
7499        had_work
7500    }
7501
7502    /// Send a status message to the editor
7503    pub fn send_status(&self, message: String) {
7504        let _ = self
7505            .command_sender
7506            .send(PluginCommand::SetStatus { message });
7507    }
7508
7509    /// Send a hook-completed sentinel to the editor.
7510    /// This signals that all commands from the hook have been sent,
7511    /// allowing the render loop to wait deterministically.
7512    pub fn send_hook_completed(&self, hook_name: String) {
7513        let _ = self
7514            .command_sender
7515            .send(PluginCommand::HookCompleted { hook_name });
7516    }
7517
7518    /// Resolve a pending async callback with a result (called from Rust when async op completes)
7519    ///
7520    /// Takes a JSON string which is parsed and converted to a proper JS value.
7521    /// This avoids string interpolation with eval for better type safety.
7522    pub fn resolve_callback(
7523        &mut self,
7524        callback_id: fresh_core::api::JsCallbackId,
7525        result_json: &str,
7526    ) {
7527        let id = callback_id.as_u64();
7528        tracing::debug!("resolve_callback: starting for callback_id={}", id);
7529
7530        // Find the plugin name and then context for this callback
7531        let plugin_name = {
7532            let mut contexts = self.callback_contexts.borrow_mut();
7533            contexts.remove(&id)
7534        };
7535
7536        let Some(name) = plugin_name else {
7537            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7538            return;
7539        };
7540
7541        let plugin_contexts = self.plugin_contexts.borrow();
7542        let Some(context) = plugin_contexts.get(&name) else {
7543            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7544            return;
7545        };
7546
7547        context.with(|ctx| {
7548            // Parse JSON string to serde_json::Value
7549            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7550                Ok(v) => v,
7551                Err(e) => {
7552                    tracing::error!(
7553                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
7554                        id,
7555                        e
7556                    );
7557                    return;
7558                }
7559            };
7560
7561            // Convert to JS value using rquickjs_serde
7562            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7563                Ok(v) => v,
7564                Err(e) => {
7565                    tracing::error!(
7566                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7567                        id,
7568                        e
7569                    );
7570                    return;
7571                }
7572            };
7573
7574            // Get _resolveCallback function from globalThis
7575            let globals = ctx.globals();
7576            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7577                Ok(f) => f,
7578                Err(e) => {
7579                    tracing::error!(
7580                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7581                        id,
7582                        e
7583                    );
7584                    return;
7585                }
7586            };
7587
7588            // Call the function with callback_id (as u64) and the JS value
7589            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7590                log_js_error(&ctx, e, &format!("resolving callback {}", id));
7591            }
7592
7593            // IMPORTANT: Run pending jobs to process Promise continuations
7594            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7595            tracing::info!(
7596                "resolve_callback: executed {} pending jobs for callback_id={}",
7597                job_count,
7598                id
7599            );
7600        });
7601    }
7602
7603    /// Reject a pending async callback with an error (called from Rust when async op fails)
7604    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7605        let id = callback_id.as_u64();
7606
7607        // Find the plugin name and then context for this callback
7608        let plugin_name = {
7609            let mut contexts = self.callback_contexts.borrow_mut();
7610            contexts.remove(&id)
7611        };
7612
7613        let Some(name) = plugin_name else {
7614            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
7615            return;
7616        };
7617
7618        let plugin_contexts = self.plugin_contexts.borrow();
7619        let Some(context) = plugin_contexts.get(&name) else {
7620            tracing::warn!("reject_callback: Context lost for plugin {}", name);
7621            return;
7622        };
7623
7624        context.with(|ctx| {
7625            // Get _rejectCallback function from globalThis
7626            let globals = ctx.globals();
7627            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
7628                Ok(f) => f,
7629                Err(e) => {
7630                    tracing::error!(
7631                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
7632                        id,
7633                        e
7634                    );
7635                    return;
7636                }
7637            };
7638
7639            // Call the function with callback_id (as u64) and error string
7640            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
7641                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
7642            }
7643
7644            // IMPORTANT: Run pending jobs to process Promise continuations
7645            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
7646        });
7647    }
7648}
7649
7650#[cfg(test)]
7651mod tests {
7652    use super::*;
7653    use fresh_core::api::{BufferInfo, CursorInfo};
7654    use std::sync::mpsc;
7655
7656    /// Helper to create a backend with a command receiver for testing
7657    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
7658        let (tx, rx) = mpsc::channel();
7659        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7660        let services = Arc::new(TestServiceBridge::new());
7661        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7662        (backend, rx)
7663    }
7664
7665    struct TestServiceBridge {
7666        en_strings: std::sync::Mutex<HashMap<String, String>>,
7667    }
7668
7669    impl TestServiceBridge {
7670        fn new() -> Self {
7671            Self {
7672                en_strings: std::sync::Mutex::new(HashMap::new()),
7673            }
7674        }
7675    }
7676
7677    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
7678        fn as_any(&self) -> &dyn std::any::Any {
7679            self
7680        }
7681        fn translate(
7682            &self,
7683            _plugin_name: &str,
7684            key: &str,
7685            _args: &HashMap<String, String>,
7686        ) -> String {
7687            self.en_strings
7688                .lock()
7689                .unwrap()
7690                .get(key)
7691                .cloned()
7692                .unwrap_or_else(|| key.to_string())
7693        }
7694        fn current_locale(&self) -> String {
7695            "en".to_string()
7696        }
7697        fn set_js_execution_state(&self, _state: String) {}
7698        fn clear_js_execution_state(&self) {}
7699        fn get_theme_schema(&self) -> serde_json::Value {
7700            serde_json::json!({})
7701        }
7702        fn get_builtin_themes(&self) -> serde_json::Value {
7703            serde_json::json!([])
7704        }
7705        fn get_all_themes(&self) -> serde_json::Value {
7706            serde_json::json!({})
7707        }
7708        fn register_command(&self, _command: fresh_core::command::Command) {}
7709        fn unregister_command(&self, _name: &str) {}
7710        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
7711        fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
7712        fn plugins_dir(&self) -> std::path::PathBuf {
7713            std::path::PathBuf::from("/tmp/plugins")
7714        }
7715        fn config_dir(&self) -> std::path::PathBuf {
7716            std::path::PathBuf::from("/tmp/config")
7717        }
7718        fn data_dir(&self) -> std::path::PathBuf {
7719            std::path::PathBuf::from("/tmp/data")
7720        }
7721        fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
7722            None
7723        }
7724        fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7725            Err("not implemented in test".to_string())
7726        }
7727        fn theme_file_exists(&self, _name: &str) -> bool {
7728            false
7729        }
7730    }
7731
7732    #[test]
7733    fn test_quickjs_backend_creation() {
7734        let backend = QuickJsBackend::new();
7735        assert!(backend.is_ok());
7736    }
7737
7738    #[test]
7739    fn test_execute_simple_js() {
7740        let mut backend = QuickJsBackend::new().unwrap();
7741        let result = backend.execute_js("const x = 1 + 2;", "test.js");
7742        assert!(result.is_ok());
7743    }
7744
7745    #[test]
7746    fn test_event_handler_registration() {
7747        let backend = QuickJsBackend::new().unwrap();
7748
7749        // Initially no handlers
7750        assert!(!backend.has_handlers("test_event"));
7751
7752        // Register a handler
7753        backend
7754            .event_handlers
7755            .write()
7756            .unwrap()
7757            .entry("test_event".to_string())
7758            .or_default()
7759            .push(PluginHandler {
7760                plugin_name: "test".to_string(),
7761                handler_name: "testHandler".to_string(),
7762            });
7763
7764        // Now has handlers
7765        assert!(backend.has_handlers("test_event"));
7766    }
7767
7768    // ==================== API Tests ====================
7769
7770    #[test]
7771    fn test_api_set_status() {
7772        let (mut backend, rx) = create_test_backend();
7773
7774        backend
7775            .execute_js(
7776                r#"
7777            const editor = getEditor();
7778            editor.setStatus("Hello from test");
7779        "#,
7780                "test.js",
7781            )
7782            .unwrap();
7783
7784        let cmd = rx.try_recv().unwrap();
7785        match cmd {
7786            PluginCommand::SetStatus { message } => {
7787                assert_eq!(message, "Hello from test");
7788            }
7789            _ => panic!("Expected SetStatus command, got {:?}", cmd),
7790        }
7791    }
7792
7793    #[test]
7794    fn test_api_register_command() {
7795        let (mut backend, rx) = create_test_backend();
7796
7797        backend
7798            .execute_js(
7799                r#"
7800            const editor = getEditor();
7801            globalThis.myTestHandler = function() { };
7802            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
7803        "#,
7804                "test_plugin.js",
7805            )
7806            .unwrap();
7807
7808        let cmd = rx.try_recv().unwrap();
7809        match cmd {
7810            PluginCommand::RegisterCommand { command } => {
7811                assert_eq!(command.name, "Test Command");
7812                assert_eq!(command.description, "A test command");
7813                // Check that plugin_name contains the plugin name (derived from filename)
7814                assert_eq!(command.plugin_name, "test_plugin");
7815            }
7816            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
7817        }
7818    }
7819
7820    #[test]
7821    fn test_api_define_mode() {
7822        let (mut backend, rx) = create_test_backend();
7823
7824        backend
7825            .execute_js(
7826                r#"
7827            const editor = getEditor();
7828            editor.defineMode("test-mode", [
7829                ["a", "action_a"],
7830                ["b", "action_b"]
7831            ]);
7832        "#,
7833                "test.js",
7834            )
7835            .unwrap();
7836
7837        let cmd = rx.try_recv().unwrap();
7838        match cmd {
7839            PluginCommand::DefineMode {
7840                name,
7841                bindings,
7842                read_only,
7843                allow_text_input,
7844                inherit_normal_bindings,
7845                plugin_name,
7846            } => {
7847                assert_eq!(name, "test-mode");
7848                assert_eq!(bindings.len(), 2);
7849                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
7850                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
7851                assert!(!read_only);
7852                assert!(!allow_text_input);
7853                assert!(!inherit_normal_bindings);
7854                assert!(plugin_name.is_some());
7855            }
7856            _ => panic!("Expected DefineMode, got {:?}", cmd),
7857        }
7858    }
7859
7860    #[test]
7861    fn test_api_set_editor_mode() {
7862        let (mut backend, rx) = create_test_backend();
7863
7864        backend
7865            .execute_js(
7866                r#"
7867            const editor = getEditor();
7868            editor.setEditorMode("vi-normal");
7869        "#,
7870                "test.js",
7871            )
7872            .unwrap();
7873
7874        let cmd = rx.try_recv().unwrap();
7875        match cmd {
7876            PluginCommand::SetEditorMode { mode } => {
7877                assert_eq!(mode, Some("vi-normal".to_string()));
7878            }
7879            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
7880        }
7881    }
7882
7883    #[test]
7884    fn test_api_clear_editor_mode() {
7885        let (mut backend, rx) = create_test_backend();
7886
7887        backend
7888            .execute_js(
7889                r#"
7890            const editor = getEditor();
7891            editor.setEditorMode(null);
7892        "#,
7893                "test.js",
7894            )
7895            .unwrap();
7896
7897        let cmd = rx.try_recv().unwrap();
7898        match cmd {
7899            PluginCommand::SetEditorMode { mode } => {
7900                assert!(mode.is_none());
7901            }
7902            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
7903        }
7904    }
7905
7906    #[test]
7907    fn test_api_insert_at_cursor() {
7908        let (mut backend, rx) = create_test_backend();
7909
7910        backend
7911            .execute_js(
7912                r#"
7913            const editor = getEditor();
7914            editor.insertAtCursor("Hello, World!");
7915        "#,
7916                "test.js",
7917            )
7918            .unwrap();
7919
7920        let cmd = rx.try_recv().unwrap();
7921        match cmd {
7922            PluginCommand::InsertAtCursor { text } => {
7923                assert_eq!(text, "Hello, World!");
7924            }
7925            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
7926        }
7927    }
7928
7929    #[test]
7930    fn test_api_set_context() {
7931        let (mut backend, rx) = create_test_backend();
7932
7933        backend
7934            .execute_js(
7935                r#"
7936            const editor = getEditor();
7937            editor.setContext("myContext", true);
7938        "#,
7939                "test.js",
7940            )
7941            .unwrap();
7942
7943        let cmd = rx.try_recv().unwrap();
7944        match cmd {
7945            PluginCommand::SetContext { name, active } => {
7946                assert_eq!(name, "myContext");
7947                assert!(active);
7948            }
7949            _ => panic!("Expected SetContext, got {:?}", cmd),
7950        }
7951    }
7952
7953    #[tokio::test]
7954    async fn test_execute_action_sync_function() {
7955        let (mut backend, rx) = create_test_backend();
7956
7957        // Register the action explicitly so it knows to look in "test" plugin
7958        backend.registered_actions.borrow_mut().insert(
7959            "my_sync_action".to_string(),
7960            PluginHandler {
7961                plugin_name: "test".to_string(),
7962                handler_name: "my_sync_action".to_string(),
7963            },
7964        );
7965
7966        // Define a sync function and register it
7967        backend
7968            .execute_js(
7969                r#"
7970            const editor = getEditor();
7971            globalThis.my_sync_action = function() {
7972                editor.setStatus("sync action executed");
7973            };
7974        "#,
7975                "test.js",
7976            )
7977            .unwrap();
7978
7979        // Drain any setup commands
7980        while rx.try_recv().is_ok() {}
7981
7982        // Execute the action
7983        backend.execute_action("my_sync_action").await.unwrap();
7984
7985        // Check the command was sent
7986        let cmd = rx.try_recv().unwrap();
7987        match cmd {
7988            PluginCommand::SetStatus { message } => {
7989                assert_eq!(message, "sync action executed");
7990            }
7991            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
7992        }
7993    }
7994
7995    #[tokio::test]
7996    async fn test_execute_action_async_function() {
7997        let (mut backend, rx) = create_test_backend();
7998
7999        // Register the action explicitly
8000        backend.registered_actions.borrow_mut().insert(
8001            "my_async_action".to_string(),
8002            PluginHandler {
8003                plugin_name: "test".to_string(),
8004                handler_name: "my_async_action".to_string(),
8005            },
8006        );
8007
8008        // Define an async function
8009        backend
8010            .execute_js(
8011                r#"
8012            const editor = getEditor();
8013            globalThis.my_async_action = async function() {
8014                await Promise.resolve();
8015                editor.setStatus("async action executed");
8016            };
8017        "#,
8018                "test.js",
8019            )
8020            .unwrap();
8021
8022        // Drain any setup commands
8023        while rx.try_recv().is_ok() {}
8024
8025        // Execute the action
8026        backend.execute_action("my_async_action").await.unwrap();
8027
8028        // Check the command was sent (async should complete)
8029        let cmd = rx.try_recv().unwrap();
8030        match cmd {
8031            PluginCommand::SetStatus { message } => {
8032                assert_eq!(message, "async action executed");
8033            }
8034            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
8035        }
8036    }
8037
8038    #[tokio::test]
8039    async fn test_execute_action_with_registered_handler() {
8040        let (mut backend, rx) = create_test_backend();
8041
8042        // Register an action with a different handler name
8043        backend.registered_actions.borrow_mut().insert(
8044            "my_action".to_string(),
8045            PluginHandler {
8046                plugin_name: "test".to_string(),
8047                handler_name: "actual_handler_function".to_string(),
8048            },
8049        );
8050
8051        backend
8052            .execute_js(
8053                r#"
8054            const editor = getEditor();
8055            globalThis.actual_handler_function = function() {
8056                editor.setStatus("handler executed");
8057            };
8058        "#,
8059                "test.js",
8060            )
8061            .unwrap();
8062
8063        // Drain any setup commands
8064        while rx.try_recv().is_ok() {}
8065
8066        // Execute the action by name (should resolve to handler)
8067        backend.execute_action("my_action").await.unwrap();
8068
8069        let cmd = rx.try_recv().unwrap();
8070        match cmd {
8071            PluginCommand::SetStatus { message } => {
8072                assert_eq!(message, "handler executed");
8073            }
8074            _ => panic!("Expected SetStatus, got {:?}", cmd),
8075        }
8076    }
8077
8078    #[test]
8079    fn test_api_on_event_registration() {
8080        let (mut backend, _rx) = create_test_backend();
8081
8082        backend
8083            .execute_js(
8084                r#"
8085            const editor = getEditor();
8086            globalThis.myEventHandler = function() { };
8087            editor.on("bufferSave", "myEventHandler");
8088        "#,
8089                "test.js",
8090            )
8091            .unwrap();
8092
8093        assert!(backend.has_handlers("bufferSave"));
8094    }
8095
8096    #[test]
8097    fn test_api_off_event_unregistration() {
8098        let (mut backend, _rx) = create_test_backend();
8099
8100        backend
8101            .execute_js(
8102                r#"
8103            const editor = getEditor();
8104            globalThis.myEventHandler = function() { };
8105            editor.on("bufferSave", "myEventHandler");
8106            editor.off("bufferSave", "myEventHandler");
8107        "#,
8108                "test.js",
8109            )
8110            .unwrap();
8111
8112        // Handler should be removed
8113        assert!(!backend.has_handlers("bufferSave"));
8114    }
8115
8116    #[tokio::test]
8117    async fn test_emit_event() {
8118        let (mut backend, rx) = create_test_backend();
8119
8120        backend
8121            .execute_js(
8122                r#"
8123            const editor = getEditor();
8124            globalThis.onSaveHandler = function(data) {
8125                editor.setStatus("saved: " + JSON.stringify(data));
8126            };
8127            editor.on("bufferSave", "onSaveHandler");
8128        "#,
8129                "test.js",
8130            )
8131            .unwrap();
8132
8133        // Drain setup commands
8134        while rx.try_recv().is_ok() {}
8135
8136        // Emit the event
8137        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
8138        backend.emit("bufferSave", &event_data).await.unwrap();
8139
8140        let cmd = rx.try_recv().unwrap();
8141        match cmd {
8142            PluginCommand::SetStatus { message } => {
8143                assert!(message.contains("/test.txt"));
8144            }
8145            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8146        }
8147    }
8148
8149    #[test]
8150    fn test_api_copy_to_clipboard() {
8151        let (mut backend, rx) = create_test_backend();
8152
8153        backend
8154            .execute_js(
8155                r#"
8156            const editor = getEditor();
8157            editor.copyToClipboard("clipboard text");
8158        "#,
8159                "test.js",
8160            )
8161            .unwrap();
8162
8163        let cmd = rx.try_recv().unwrap();
8164        match cmd {
8165            PluginCommand::SetClipboard { text } => {
8166                assert_eq!(text, "clipboard text");
8167            }
8168            _ => panic!("Expected SetClipboard, got {:?}", cmd),
8169        }
8170    }
8171
8172    #[test]
8173    fn test_api_open_file() {
8174        let (mut backend, rx) = create_test_backend();
8175
8176        // openFile takes (path, line?, column?)
8177        backend
8178            .execute_js(
8179                r#"
8180            const editor = getEditor();
8181            editor.openFile("/path/to/file.txt", null, null);
8182        "#,
8183                "test.js",
8184            )
8185            .unwrap();
8186
8187        let cmd = rx.try_recv().unwrap();
8188        match cmd {
8189            PluginCommand::OpenFileAtLocation { path, line, column } => {
8190                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
8191                assert!(line.is_none());
8192                assert!(column.is_none());
8193            }
8194            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8195        }
8196    }
8197
8198    #[test]
8199    fn test_api_delete_range() {
8200        let (mut backend, rx) = create_test_backend();
8201
8202        // deleteRange takes (buffer_id, start, end)
8203        backend
8204            .execute_js(
8205                r#"
8206            const editor = getEditor();
8207            editor.deleteRange(0, 10, 20);
8208        "#,
8209                "test.js",
8210            )
8211            .unwrap();
8212
8213        let cmd = rx.try_recv().unwrap();
8214        match cmd {
8215            PluginCommand::DeleteRange { range, .. } => {
8216                assert_eq!(range.start, 10);
8217                assert_eq!(range.end, 20);
8218            }
8219            _ => panic!("Expected DeleteRange, got {:?}", cmd),
8220        }
8221    }
8222
8223    #[test]
8224    fn test_api_insert_text() {
8225        let (mut backend, rx) = create_test_backend();
8226
8227        // insertText takes (buffer_id, position, text)
8228        backend
8229            .execute_js(
8230                r#"
8231            const editor = getEditor();
8232            editor.insertText(0, 5, "inserted");
8233        "#,
8234                "test.js",
8235            )
8236            .unwrap();
8237
8238        let cmd = rx.try_recv().unwrap();
8239        match cmd {
8240            PluginCommand::InsertText { position, text, .. } => {
8241                assert_eq!(position, 5);
8242                assert_eq!(text, "inserted");
8243            }
8244            _ => panic!("Expected InsertText, got {:?}", cmd),
8245        }
8246    }
8247
8248    #[test]
8249    fn test_api_set_buffer_cursor() {
8250        let (mut backend, rx) = create_test_backend();
8251
8252        // setBufferCursor takes (buffer_id, position)
8253        backend
8254            .execute_js(
8255                r#"
8256            const editor = getEditor();
8257            editor.setBufferCursor(0, 100);
8258        "#,
8259                "test.js",
8260            )
8261            .unwrap();
8262
8263        let cmd = rx.try_recv().unwrap();
8264        match cmd {
8265            PluginCommand::SetBufferCursor { position, .. } => {
8266                assert_eq!(position, 100);
8267            }
8268            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8269        }
8270    }
8271
8272    #[test]
8273    fn test_api_get_cursor_position_from_state() {
8274        let (tx, _rx) = mpsc::channel();
8275        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8276
8277        // Set up cursor position in state
8278        {
8279            let mut state = state_snapshot.write().unwrap();
8280            state.primary_cursor = Some(CursorInfo {
8281                position: 42,
8282                selection: None,
8283                line: Some(0),
8284            });
8285        }
8286
8287        let services = Arc::new(fresh_core::services::NoopServiceBridge);
8288        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8289
8290        // Execute JS that reads and stores cursor position
8291        backend
8292            .execute_js(
8293                r#"
8294            const editor = getEditor();
8295            const pos = editor.getCursorPosition();
8296            globalThis._testResult = pos;
8297        "#,
8298                "test.js",
8299            )
8300            .unwrap();
8301
8302        // Verify by reading back - getCursorPosition returns byte offset as u32
8303        backend
8304            .plugin_contexts
8305            .borrow()
8306            .get("test")
8307            .unwrap()
8308            .clone()
8309            .with(|ctx| {
8310                let global = ctx.globals();
8311                let result: u32 = global.get("_testResult").unwrap();
8312                assert_eq!(result, 42);
8313            });
8314    }
8315
8316    /// Ad-hoc plugin probe for the cursor-line API (issue #2076).
8317    ///
8318    /// Exercises the real JS plugin surface (`getPrimaryCursor().line`,
8319    /// `getAllCursors()`, and the deprecated `getCursorLine()`) against a
8320    /// hand-built snapshot, covering both modes:
8321    ///   * "small file": the buffer has a line index, so `line` is a real
8322    ///     0-indexed number for every cursor.
8323    ///   * "large file": the line index is unavailable (`line == None`), so
8324    ///     `getPrimaryCursor().line` is `null` and the deprecated
8325    ///     `getCursorLine()` collapses to its `0` fallback.
8326    #[test]
8327    fn test_api_get_cursor_line_small_and_large_file() {
8328        // --- small-file mode: line index present -----------------------------
8329        let (tx, _rx) = mpsc::channel();
8330        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8331        {
8332            let mut state = state_snapshot.write().unwrap();
8333            state.primary_cursor = Some(CursorInfo {
8334                position: 120,
8335                selection: None,
8336                line: Some(7),
8337            });
8338            state.all_cursors = vec![
8339                CursorInfo {
8340                    position: 120,
8341                    selection: None,
8342                    line: Some(7),
8343                },
8344                CursorInfo {
8345                    position: 200,
8346                    selection: None,
8347                    line: Some(12),
8348                },
8349            ];
8350        }
8351
8352        let services = Arc::new(fresh_core::services::NoopServiceBridge);
8353        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8354
8355        backend
8356            .execute_js(
8357                r#"
8358            const editor = getEditor();
8359            const primary = editor.getPrimaryCursor();
8360            globalThis._primaryLine = primary.line;
8361            globalThis._cursorLine = editor.getCursorLine();
8362            globalThis._allLines = editor.getAllCursors().map(c => c.line);
8363        "#,
8364                "probe_small.js",
8365            )
8366            .unwrap();
8367
8368        backend
8369            .plugin_contexts
8370            .borrow()
8371            .get("probe_small")
8372            .unwrap()
8373            .clone()
8374            .with(|ctx| {
8375                let global = ctx.globals();
8376                // getPrimaryCursor().line is the real 0-indexed line.
8377                let primary_line: i32 = global.get("_primaryLine").unwrap();
8378                assert_eq!(primary_line, 7);
8379                // Deprecated getCursorLine() agrees in small-file mode.
8380                let cursor_line: u32 = global.get("_cursorLine").unwrap();
8381                assert_eq!(cursor_line, 7);
8382                // Every cursor carries its own line (multi-cursor support).
8383                let all_lines: Vec<i32> = global.get("_allLines").unwrap();
8384                assert_eq!(all_lines, vec![7, 12]);
8385            });
8386
8387        // --- large-file mode: line index unavailable --------------------------
8388        let (tx2, _rx2) = mpsc::channel();
8389        let state_snapshot2 = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8390        {
8391            let mut state = state_snapshot2.write().unwrap();
8392            state.primary_cursor = Some(CursorInfo {
8393                position: 5_000_000,
8394                selection: None,
8395                line: None,
8396            });
8397            state.all_cursors = vec![CursorInfo {
8398                position: 5_000_000,
8399                selection: None,
8400                line: None,
8401            }];
8402        }
8403
8404        let services2 = Arc::new(fresh_core::services::NoopServiceBridge);
8405        let mut backend2 = QuickJsBackend::with_state(state_snapshot2, tx2, services2).unwrap();
8406
8407        backend2
8408            .execute_js(
8409                r#"
8410            const editor = getEditor();
8411            const primary = editor.getPrimaryCursor();
8412            // null and undefined both serialize to JS null here; normalize to a
8413            // sentinel so the Rust side can assert "unknown" unambiguously.
8414            globalThis._primaryLineIsNull = (primary.line === null || primary.line === undefined);
8415            globalThis._cursorLineFallback = editor.getCursorLine();
8416            globalThis._allLineIsNull = (editor.getAllCursors()[0].line === null);
8417        "#,
8418                "probe_large.js",
8419            )
8420            .unwrap();
8421
8422        backend2
8423            .plugin_contexts
8424            .borrow()
8425            .get("probe_large")
8426            .unwrap()
8427            .clone()
8428            .with(|ctx| {
8429                let global = ctx.globals();
8430                // The honest API reports "unknown" as null in large-file mode.
8431                let primary_null: bool = global.get("_primaryLineIsNull").unwrap();
8432                assert!(
8433                    primary_null,
8434                    "primary.line should be null in large-file mode"
8435                );
8436                let all_null: bool = global.get("_allLineIsNull").unwrap();
8437                assert!(
8438                    all_null,
8439                    "getAllCursors()[0].line should be null in large-file mode"
8440                );
8441                // The deprecated accessor cannot express null; it collapses to 0.
8442                let fallback: u32 = global.get("_cursorLineFallback").unwrap();
8443                assert_eq!(fallback, 0);
8444            });
8445    }
8446
8447    #[test]
8448    fn test_api_path_functions() {
8449        let (mut backend, _rx) = create_test_backend();
8450
8451        // Use platform-appropriate absolute path for isAbsolute test
8452        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
8453        #[cfg(windows)]
8454        let absolute_path = r#"C:\\foo\\bar"#;
8455        #[cfg(not(windows))]
8456        let absolute_path = "/foo/bar";
8457
8458        // pathJoin takes an array of path parts
8459        let js_code = format!(
8460            r#"
8461            const editor = getEditor();
8462            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8463            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8464            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8465            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8466            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8467            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8468        "#,
8469            absolute_path
8470        );
8471        backend.execute_js(&js_code, "test.js").unwrap();
8472
8473        backend
8474            .plugin_contexts
8475            .borrow()
8476            .get("test")
8477            .unwrap()
8478            .clone()
8479            .with(|ctx| {
8480                let global = ctx.globals();
8481                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8482                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8483                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8484                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8485                assert!(!global.get::<_, bool>("_isRelative").unwrap());
8486                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8487            });
8488    }
8489
8490    /// Rust's `Path::canonicalize` returns `\\?\`-prefixed verbatim paths
8491    /// on Windows, which `editor.getCwd()` surfaces to plugins verbatim.
8492    /// `pathJoin` must preserve the leading `//` once slashes are
8493    /// normalized — otherwise `pathJoin(cwd, ".devcontainer", "devcontainer.json")`
8494    /// on Windows resolves to `/?/C:/.../devcontainer.json`, which every
8495    /// filesystem API rejects and every plugin-side `findConfig()` call
8496    /// silently fails.
8497    #[test]
8498    fn test_path_join_preserves_unc_prefix() {
8499        let (mut backend, _rx) = create_test_backend();
8500        backend
8501            .execute_js(
8502                r#"
8503                const editor = getEditor();
8504                globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8505                globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8506                globalThis._posix = editor.pathJoin("/foo", "bar");
8507                globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8508            "#,
8509                "test.js",
8510            )
8511            .unwrap();
8512
8513        backend
8514            .plugin_contexts
8515            .borrow()
8516            .get("test")
8517            .unwrap()
8518            .clone()
8519            .with(|ctx| {
8520                let global = ctx.globals();
8521                assert_eq!(
8522                    global.get::<_, String>("_unc").unwrap(),
8523                    "//?/C:/workspace/.devcontainer/devcontainer.json",
8524                    "UNC prefix `\\\\?\\` must survive pathJoin normalization",
8525                );
8526                assert_eq!(
8527                    global.get::<_, String>("_unc_fwd").unwrap(),
8528                    "//?/C:/workspace/.devcontainer/devcontainer.json",
8529                    "UNC prefix in forward-slash form stays as `//`",
8530                );
8531                assert_eq!(
8532                    global.get::<_, String>("_posix").unwrap(),
8533                    "/foo/bar",
8534                    "POSIX absolute paths keep their single leading slash",
8535                );
8536                assert_eq!(
8537                    global.get::<_, String>("_drive").unwrap(),
8538                    "C:/foo/bar",
8539                    "Windows drive-letter paths have no leading slash",
8540                );
8541            });
8542    }
8543
8544    #[test]
8545    fn test_file_uri_to_path_and_back() {
8546        let (mut backend, _rx) = create_test_backend();
8547
8548        // Test Unix-style paths
8549        #[cfg(not(windows))]
8550        let js_code = r#"
8551            const editor = getEditor();
8552            // Basic file URI to path
8553            globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
8554            // Percent-encoded characters
8555            globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
8556            // Invalid URI returns empty string
8557            globalThis._path3 = editor.fileUriToPath("not-a-uri");
8558            // Path to file URI
8559            globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
8560            // Round-trip
8561            globalThis._roundtrip = editor.fileUriToPath(
8562                editor.pathToFileUri("/home/user/file.txt")
8563            );
8564        "#;
8565
8566        #[cfg(windows)]
8567        let js_code = r#"
8568            const editor = getEditor();
8569            // Windows URI with encoded colon (the bug from issue #1071)
8570            globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
8571            // Windows URI with normal colon
8572            globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
8573            // Invalid URI returns empty string
8574            globalThis._path3 = editor.fileUriToPath("not-a-uri");
8575            // Path to file URI
8576            globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
8577            // Round-trip
8578            globalThis._roundtrip = editor.fileUriToPath(
8579                editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
8580            );
8581        "#;
8582
8583        backend.execute_js(js_code, "test.js").unwrap();
8584
8585        backend
8586            .plugin_contexts
8587            .borrow()
8588            .get("test")
8589            .unwrap()
8590            .clone()
8591            .with(|ctx| {
8592                let global = ctx.globals();
8593
8594                #[cfg(not(windows))]
8595                {
8596                    assert_eq!(
8597                        global.get::<_, String>("_path1").unwrap(),
8598                        "/home/user/file.txt"
8599                    );
8600                    assert_eq!(
8601                        global.get::<_, String>("_path2").unwrap(),
8602                        "/home/user/my file.txt"
8603                    );
8604                    assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8605                    assert_eq!(
8606                        global.get::<_, String>("_uri1").unwrap(),
8607                        "file:///home/user/file.txt"
8608                    );
8609                    assert_eq!(
8610                        global.get::<_, String>("_roundtrip").unwrap(),
8611                        "/home/user/file.txt"
8612                    );
8613                }
8614
8615                #[cfg(windows)]
8616                {
8617                    // Issue #1071: encoded colon must be decoded to proper Windows path
8618                    assert_eq!(
8619                        global.get::<_, String>("_path1").unwrap(),
8620                        "C:\\Users\\admin\\Repos\\file.cs"
8621                    );
8622                    assert_eq!(
8623                        global.get::<_, String>("_path2").unwrap(),
8624                        "C:\\Users\\admin\\Repos\\file.cs"
8625                    );
8626                    assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8627                    assert_eq!(
8628                        global.get::<_, String>("_uri1").unwrap(),
8629                        "file:///C:/Users/admin/Repos/file.cs"
8630                    );
8631                    assert_eq!(
8632                        global.get::<_, String>("_roundtrip").unwrap(),
8633                        "C:\\Users\\admin\\Repos\\file.cs"
8634                    );
8635                }
8636            });
8637    }
8638
8639    #[test]
8640    fn test_typescript_transpilation() {
8641        use fresh_parser_js::transpile_typescript;
8642
8643        let (mut backend, rx) = create_test_backend();
8644
8645        // TypeScript code with type annotations
8646        let ts_code = r#"
8647            const editor = getEditor();
8648            function greet(name: string): string {
8649                return "Hello, " + name;
8650            }
8651            editor.setStatus(greet("TypeScript"));
8652        "#;
8653
8654        // Transpile to JavaScript first
8655        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
8656
8657        // Execute the transpiled JavaScript
8658        backend.execute_js(&js_code, "test.js").unwrap();
8659
8660        let cmd = rx.try_recv().unwrap();
8661        match cmd {
8662            PluginCommand::SetStatus { message } => {
8663                assert_eq!(message, "Hello, TypeScript");
8664            }
8665            _ => panic!("Expected SetStatus, got {:?}", cmd),
8666        }
8667    }
8668
8669    #[test]
8670    fn test_api_get_buffer_text_sends_command() {
8671        let (mut backend, rx) = create_test_backend();
8672
8673        // Call getBufferText - this returns a Promise and sends the command
8674        backend
8675            .execute_js(
8676                r#"
8677            const editor = getEditor();
8678            // Store the promise for later
8679            globalThis._textPromise = editor.getBufferText(0, 10, 20);
8680        "#,
8681                "test.js",
8682            )
8683            .unwrap();
8684
8685        // Verify the GetBufferText command was sent
8686        let cmd = rx.try_recv().unwrap();
8687        match cmd {
8688            PluginCommand::GetBufferText {
8689                buffer_id,
8690                start,
8691                end,
8692                request_id,
8693            } => {
8694                assert_eq!(buffer_id.0, 0);
8695                assert_eq!(start, 10);
8696                assert_eq!(end, 20);
8697                assert!(request_id > 0); // Should have a valid request ID
8698            }
8699            _ => panic!("Expected GetBufferText, got {:?}", cmd),
8700        }
8701    }
8702
8703    #[test]
8704    fn test_api_get_buffer_text_resolves_callback() {
8705        let (mut backend, rx) = create_test_backend();
8706
8707        // Call getBufferText and set up a handler for when it resolves
8708        backend
8709            .execute_js(
8710                r#"
8711            const editor = getEditor();
8712            globalThis._resolvedText = null;
8713            editor.getBufferText(0, 0, 100).then(text => {
8714                globalThis._resolvedText = text;
8715            });
8716        "#,
8717                "test.js",
8718            )
8719            .unwrap();
8720
8721        // Get the request_id from the command
8722        let request_id = match rx.try_recv().unwrap() {
8723            PluginCommand::GetBufferText { request_id, .. } => request_id,
8724            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
8725        };
8726
8727        // Simulate the editor responding with the text
8728        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
8729
8730        // Drive the Promise to completion
8731        backend
8732            .plugin_contexts
8733            .borrow()
8734            .get("test")
8735            .unwrap()
8736            .clone()
8737            .with(|ctx| {
8738                run_pending_jobs_checked(&ctx, "test async getText");
8739            });
8740
8741        // Verify the Promise resolved with the text
8742        backend
8743            .plugin_contexts
8744            .borrow()
8745            .get("test")
8746            .unwrap()
8747            .clone()
8748            .with(|ctx| {
8749                let global = ctx.globals();
8750                let result: String = global.get("_resolvedText").unwrap();
8751                assert_eq!(result, "hello world");
8752            });
8753    }
8754
8755    #[test]
8756    fn test_plugin_translation() {
8757        let (mut backend, _rx) = create_test_backend();
8758
8759        // The t() function should work (returns key if translation not found)
8760        backend
8761            .execute_js(
8762                r#"
8763            const editor = getEditor();
8764            globalThis._translated = editor.t("test.key");
8765        "#,
8766                "test.js",
8767            )
8768            .unwrap();
8769
8770        backend
8771            .plugin_contexts
8772            .borrow()
8773            .get("test")
8774            .unwrap()
8775            .clone()
8776            .with(|ctx| {
8777                let global = ctx.globals();
8778                // Without actual translations, it returns the key
8779                let result: String = global.get("_translated").unwrap();
8780                assert_eq!(result, "test.key");
8781            });
8782    }
8783
8784    #[test]
8785    fn test_plugin_translation_with_registered_strings() {
8786        let (mut backend, _rx) = create_test_backend();
8787
8788        // Register translations for the test plugin
8789        let mut en_strings = std::collections::HashMap::new();
8790        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
8791        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8792
8793        let mut strings = std::collections::HashMap::new();
8794        strings.insert("en".to_string(), en_strings);
8795
8796        // Register for "test" plugin
8797        if let Some(bridge) = backend
8798            .services
8799            .as_any()
8800            .downcast_ref::<TestServiceBridge>()
8801        {
8802            let mut en = bridge.en_strings.lock().unwrap();
8803            en.insert("greeting".to_string(), "Hello, World!".to_string());
8804            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8805        }
8806
8807        // Test translation
8808        backend
8809            .execute_js(
8810                r#"
8811            const editor = getEditor();
8812            globalThis._greeting = editor.t("greeting");
8813            globalThis._prompt = editor.t("prompt.find_file");
8814            globalThis._missing = editor.t("nonexistent.key");
8815        "#,
8816                "test.js",
8817            )
8818            .unwrap();
8819
8820        backend
8821            .plugin_contexts
8822            .borrow()
8823            .get("test")
8824            .unwrap()
8825            .clone()
8826            .with(|ctx| {
8827                let global = ctx.globals();
8828                let greeting: String = global.get("_greeting").unwrap();
8829                assert_eq!(greeting, "Hello, World!");
8830
8831                let prompt: String = global.get("_prompt").unwrap();
8832                assert_eq!(prompt, "Find file: ");
8833
8834                // Missing key should return the key itself
8835                let missing: String = global.get("_missing").unwrap();
8836                assert_eq!(missing, "nonexistent.key");
8837            });
8838    }
8839
8840    // ==================== Line Indicator Tests ====================
8841
8842    #[test]
8843    fn test_api_set_line_indicator() {
8844        let (mut backend, rx) = create_test_backend();
8845
8846        backend
8847            .execute_js(
8848                r#"
8849            const editor = getEditor();
8850            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
8851        "#,
8852                "test.js",
8853            )
8854            .unwrap();
8855
8856        let cmd = rx.try_recv().unwrap();
8857        match cmd {
8858            PluginCommand::SetLineIndicator {
8859                buffer_id,
8860                line,
8861                namespace,
8862                symbol,
8863                color,
8864                priority,
8865            } => {
8866                assert_eq!(buffer_id.0, 1);
8867                assert_eq!(line, 5);
8868                assert_eq!(namespace, "test-ns");
8869                assert_eq!(symbol, "●");
8870                assert_eq!(color, (255, 0, 0));
8871                assert_eq!(priority, 10);
8872            }
8873            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
8874        }
8875    }
8876
8877    #[test]
8878    fn test_api_clear_line_indicators() {
8879        let (mut backend, rx) = create_test_backend();
8880
8881        backend
8882            .execute_js(
8883                r#"
8884            const editor = getEditor();
8885            editor.clearLineIndicators(1, "test-ns");
8886        "#,
8887                "test.js",
8888            )
8889            .unwrap();
8890
8891        let cmd = rx.try_recv().unwrap();
8892        match cmd {
8893            PluginCommand::ClearLineIndicators {
8894                buffer_id,
8895                namespace,
8896            } => {
8897                assert_eq!(buffer_id.0, 1);
8898                assert_eq!(namespace, "test-ns");
8899            }
8900            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
8901        }
8902    }
8903
8904    // ==================== Virtual Buffer Tests ====================
8905
8906    #[test]
8907    fn test_api_create_virtual_buffer_sends_command() {
8908        let (mut backend, rx) = create_test_backend();
8909
8910        backend
8911            .execute_js(
8912                r#"
8913            const editor = getEditor();
8914            editor.createVirtualBuffer({
8915                name: "*Test Buffer*",
8916                mode: "test-mode",
8917                readOnly: true,
8918                entries: [
8919                    { text: "Line 1\n", properties: { type: "header" } },
8920                    { text: "Line 2\n", properties: { type: "content" } }
8921                ],
8922                showLineNumbers: false,
8923                showCursors: true,
8924                editingDisabled: true
8925            });
8926        "#,
8927                "test.js",
8928            )
8929            .unwrap();
8930
8931        let cmd = rx.try_recv().unwrap();
8932        match cmd {
8933            PluginCommand::CreateVirtualBufferWithContent {
8934                name,
8935                mode,
8936                read_only,
8937                entries,
8938                show_line_numbers,
8939                show_cursors,
8940                editing_disabled,
8941                ..
8942            } => {
8943                assert_eq!(name, "*Test Buffer*");
8944                assert_eq!(mode, "test-mode");
8945                assert!(read_only);
8946                assert_eq!(entries.len(), 2);
8947                assert_eq!(entries[0].text, "Line 1\n");
8948                assert!(!show_line_numbers);
8949                assert!(show_cursors);
8950                assert!(editing_disabled);
8951            }
8952            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
8953        }
8954    }
8955
8956    #[test]
8957    fn test_api_set_virtual_buffer_content() {
8958        let (mut backend, rx) = create_test_backend();
8959
8960        backend
8961            .execute_js(
8962                r#"
8963            const editor = getEditor();
8964            editor.setVirtualBufferContent(5, [
8965                { text: "New content\n", properties: { type: "updated" } }
8966            ]);
8967        "#,
8968                "test.js",
8969            )
8970            .unwrap();
8971
8972        let cmd = rx.try_recv().unwrap();
8973        match cmd {
8974            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
8975                assert_eq!(buffer_id.0, 5);
8976                assert_eq!(entries.len(), 1);
8977                assert_eq!(entries[0].text, "New content\n");
8978            }
8979            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
8980        }
8981    }
8982
8983    // ==================== Overlay Tests ====================
8984
8985    #[test]
8986    fn test_api_add_overlay() {
8987        let (mut backend, rx) = create_test_backend();
8988
8989        backend
8990            .execute_js(
8991                r#"
8992            const editor = getEditor();
8993            editor.addOverlay(1, "highlight", 10, 20, {
8994                fg: [255, 128, 0],
8995                bg: [50, 50, 50],
8996                bold: true,
8997            });
8998        "#,
8999                "test.js",
9000            )
9001            .unwrap();
9002
9003        let cmd = rx.try_recv().unwrap();
9004        match cmd {
9005            PluginCommand::AddOverlay {
9006                buffer_id,
9007                namespace,
9008                range,
9009                options,
9010            } => {
9011                use fresh_core::api::OverlayColorSpec;
9012                assert_eq!(buffer_id.0, 1);
9013                assert!(namespace.is_some());
9014                assert_eq!(namespace.unwrap().as_str(), "highlight");
9015                assert_eq!(range, 10..20);
9016                assert!(matches!(
9017                    options.fg,
9018                    Some(OverlayColorSpec::Rgb(255, 128, 0))
9019                ));
9020                assert!(matches!(
9021                    options.bg,
9022                    Some(OverlayColorSpec::Rgb(50, 50, 50))
9023                ));
9024                assert!(!options.underline);
9025                assert!(options.bold);
9026                assert!(!options.italic);
9027                assert!(!options.extend_to_line_end);
9028            }
9029            _ => panic!("Expected AddOverlay, got {:?}", cmd),
9030        }
9031    }
9032
9033    #[test]
9034    fn test_api_add_overlay_with_theme_keys() {
9035        let (mut backend, rx) = create_test_backend();
9036
9037        backend
9038            .execute_js(
9039                r#"
9040            const editor = getEditor();
9041            // Test with theme keys for colors
9042            editor.addOverlay(1, "themed", 0, 10, {
9043                fg: "ui.status_bar_fg",
9044                bg: "editor.selection_bg",
9045            });
9046        "#,
9047                "test.js",
9048            )
9049            .unwrap();
9050
9051        let cmd = rx.try_recv().unwrap();
9052        match cmd {
9053            PluginCommand::AddOverlay {
9054                buffer_id,
9055                namespace,
9056                range,
9057                options,
9058            } => {
9059                use fresh_core::api::OverlayColorSpec;
9060                assert_eq!(buffer_id.0, 1);
9061                assert!(namespace.is_some());
9062                assert_eq!(namespace.unwrap().as_str(), "themed");
9063                assert_eq!(range, 0..10);
9064                assert!(matches!(
9065                    &options.fg,
9066                    Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
9067                ));
9068                assert!(matches!(
9069                    &options.bg,
9070                    Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
9071                ));
9072                assert!(!options.underline);
9073                assert!(!options.bold);
9074                assert!(!options.italic);
9075                assert!(!options.extend_to_line_end);
9076            }
9077            _ => panic!("Expected AddOverlay, got {:?}", cmd),
9078        }
9079    }
9080
9081    #[test]
9082    fn test_api_clear_namespace() {
9083        let (mut backend, rx) = create_test_backend();
9084
9085        backend
9086            .execute_js(
9087                r#"
9088            const editor = getEditor();
9089            editor.clearNamespace(1, "highlight");
9090        "#,
9091                "test.js",
9092            )
9093            .unwrap();
9094
9095        let cmd = rx.try_recv().unwrap();
9096        match cmd {
9097            PluginCommand::ClearNamespace {
9098                buffer_id,
9099                namespace,
9100            } => {
9101                assert_eq!(buffer_id.0, 1);
9102                assert_eq!(namespace.as_str(), "highlight");
9103            }
9104            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
9105        }
9106    }
9107
9108    // ==================== Theme Tests ====================
9109
9110    #[test]
9111    fn test_api_get_theme_schema() {
9112        let (mut backend, _rx) = create_test_backend();
9113
9114        backend
9115            .execute_js(
9116                r#"
9117            const editor = getEditor();
9118            const schema = editor.getThemeSchema();
9119            globalThis._isObject = typeof schema === 'object' && schema !== null;
9120        "#,
9121                "test.js",
9122            )
9123            .unwrap();
9124
9125        backend
9126            .plugin_contexts
9127            .borrow()
9128            .get("test")
9129            .unwrap()
9130            .clone()
9131            .with(|ctx| {
9132                let global = ctx.globals();
9133                let is_object: bool = global.get("_isObject").unwrap();
9134                // getThemeSchema should return an object
9135                assert!(is_object);
9136            });
9137    }
9138
9139    #[test]
9140    fn test_api_get_builtin_themes() {
9141        let (mut backend, _rx) = create_test_backend();
9142
9143        backend
9144            .execute_js(
9145                r#"
9146            const editor = getEditor();
9147            const themes = editor.getBuiltinThemes();
9148            globalThis._isObject = typeof themes === 'object' && themes !== null;
9149        "#,
9150                "test.js",
9151            )
9152            .unwrap();
9153
9154        backend
9155            .plugin_contexts
9156            .borrow()
9157            .get("test")
9158            .unwrap()
9159            .clone()
9160            .with(|ctx| {
9161                let global = ctx.globals();
9162                let is_object: bool = global.get("_isObject").unwrap();
9163                // getBuiltinThemes should return an object
9164                assert!(is_object);
9165            });
9166    }
9167
9168    #[test]
9169    fn test_api_apply_theme() {
9170        let (mut backend, rx) = create_test_backend();
9171
9172        backend
9173            .execute_js(
9174                r#"
9175            const editor = getEditor();
9176            editor.applyTheme("dark");
9177        "#,
9178                "test.js",
9179            )
9180            .unwrap();
9181
9182        let cmd = rx.try_recv().unwrap();
9183        match cmd {
9184            PluginCommand::ApplyTheme { theme_name } => {
9185                assert_eq!(theme_name, "dark");
9186            }
9187            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
9188        }
9189    }
9190
9191    #[test]
9192    fn test_api_override_theme_colors_round_trip() {
9193        // Drives the JS → Rust deserialization path that regressed in
9194        // production: a plain object of "section.field" → [r,g,b] arrays.
9195        let (mut backend, rx) = create_test_backend();
9196
9197        backend
9198            .execute_js(
9199                r#"
9200            const editor = getEditor();
9201            editor.overrideThemeColors({
9202                "editor.bg": [10, 20, 30],
9203                "editor.fg": [220, 221, 222],
9204            });
9205        "#,
9206                "test.js",
9207            )
9208            .unwrap();
9209
9210        let cmd = rx.try_recv().unwrap();
9211        match cmd {
9212            PluginCommand::OverrideThemeColors { overrides } => {
9213                assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
9214                assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
9215                assert_eq!(overrides.len(), 2);
9216            }
9217            _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
9218        }
9219    }
9220
9221    #[test]
9222    fn test_api_override_theme_colors_clamps_out_of_range() {
9223        let (mut backend, rx) = create_test_backend();
9224
9225        backend
9226            .execute_js(
9227                r#"
9228            const editor = getEditor();
9229            editor.overrideThemeColors({
9230                "editor.bg": [-5, 300, 128],
9231            });
9232        "#,
9233                "test.js",
9234            )
9235            .unwrap();
9236
9237        match rx.try_recv().unwrap() {
9238            PluginCommand::OverrideThemeColors { overrides } => {
9239                assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
9240            }
9241            other => panic!("Expected OverrideThemeColors, got {other:?}"),
9242        }
9243    }
9244
9245    #[test]
9246    fn test_api_override_theme_colors_drops_malformed_entries() {
9247        // Wrong-shape values should be ignored without erroring so a fast
9248        // animation loop with a single typo keeps running.
9249        let (mut backend, rx) = create_test_backend();
9250
9251        backend
9252            .execute_js(
9253                r#"
9254            const editor = getEditor();
9255            editor.overrideThemeColors({
9256                "editor.bg": [1, 2, 3],
9257                "not_an_array": "oops",
9258                "wrong_length": [1, 2],
9259                "floats_are_fine": [10.7, 20.2, 30.9],
9260            });
9261        "#,
9262                "test.js",
9263            )
9264            .unwrap();
9265
9266        match rx.try_recv().unwrap() {
9267            PluginCommand::OverrideThemeColors { overrides } => {
9268                assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
9269                assert!(!overrides.contains_key("not_an_array"));
9270                assert!(!overrides.contains_key("wrong_length"));
9271                // serde_json::Number::as_i64 truncates floats toward zero.
9272                assert_eq!(
9273                    overrides.get("floats_are_fine").copied(),
9274                    Some([10, 20, 30])
9275                );
9276            }
9277            other => panic!("Expected OverrideThemeColors, got {other:?}"),
9278        }
9279    }
9280
9281    #[test]
9282    fn test_api_get_theme_data_missing() {
9283        let (mut backend, _rx) = create_test_backend();
9284
9285        backend
9286            .execute_js(
9287                r#"
9288            const editor = getEditor();
9289            const data = editor.getThemeData("nonexistent");
9290            globalThis._isNull = data === null;
9291        "#,
9292                "test.js",
9293            )
9294            .unwrap();
9295
9296        backend
9297            .plugin_contexts
9298            .borrow()
9299            .get("test")
9300            .unwrap()
9301            .clone()
9302            .with(|ctx| {
9303                let global = ctx.globals();
9304                let is_null: bool = global.get("_isNull").unwrap();
9305                // getThemeData should return null for non-existent theme
9306                assert!(is_null);
9307            });
9308    }
9309
9310    #[test]
9311    fn test_api_get_theme_data_present() {
9312        // Use a custom service bridge that returns theme data
9313        let (tx, _rx) = mpsc::channel();
9314        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9315        let services = Arc::new(ThemeCacheTestBridge {
9316            inner: TestServiceBridge::new(),
9317        });
9318        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9319
9320        backend
9321            .execute_js(
9322                r#"
9323            const editor = getEditor();
9324            const data = editor.getThemeData("test-theme");
9325            globalThis._hasData = data !== null && typeof data === 'object';
9326            globalThis._name = data ? data.name : null;
9327        "#,
9328                "test.js",
9329            )
9330            .unwrap();
9331
9332        backend
9333            .plugin_contexts
9334            .borrow()
9335            .get("test")
9336            .unwrap()
9337            .clone()
9338            .with(|ctx| {
9339                let global = ctx.globals();
9340                let has_data: bool = global.get("_hasData").unwrap();
9341                assert!(has_data, "getThemeData should return theme object");
9342                let name: String = global.get("_name").unwrap();
9343                assert_eq!(name, "test-theme");
9344            });
9345    }
9346
9347    #[test]
9348    fn test_api_theme_file_exists() {
9349        let (mut backend, _rx) = create_test_backend();
9350
9351        backend
9352            .execute_js(
9353                r#"
9354            const editor = getEditor();
9355            globalThis._exists = editor.themeFileExists("anything");
9356        "#,
9357                "test.js",
9358            )
9359            .unwrap();
9360
9361        backend
9362            .plugin_contexts
9363            .borrow()
9364            .get("test")
9365            .unwrap()
9366            .clone()
9367            .with(|ctx| {
9368                let global = ctx.globals();
9369                let exists: bool = global.get("_exists").unwrap();
9370                // TestServiceBridge returns false
9371                assert!(!exists);
9372            });
9373    }
9374
9375    #[test]
9376    fn test_api_save_theme_file_error() {
9377        let (mut backend, _rx) = create_test_backend();
9378
9379        backend
9380            .execute_js(
9381                r#"
9382            const editor = getEditor();
9383            let threw = false;
9384            try {
9385                editor.saveThemeFile("test", "{}");
9386            } catch (e) {
9387                threw = true;
9388            }
9389            globalThis._threw = threw;
9390        "#,
9391                "test.js",
9392            )
9393            .unwrap();
9394
9395        backend
9396            .plugin_contexts
9397            .borrow()
9398            .get("test")
9399            .unwrap()
9400            .clone()
9401            .with(|ctx| {
9402                let global = ctx.globals();
9403                let threw: bool = global.get("_threw").unwrap();
9404                // TestServiceBridge returns Err, so JS should throw
9405                assert!(threw);
9406            });
9407    }
9408
9409    /// Test helper: a service bridge that provides theme data in the cache.
9410    struct ThemeCacheTestBridge {
9411        inner: TestServiceBridge,
9412    }
9413
9414    impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9415        fn as_any(&self) -> &dyn std::any::Any {
9416            self
9417        }
9418        fn translate(
9419            &self,
9420            plugin_name: &str,
9421            key: &str,
9422            args: &HashMap<String, String>,
9423        ) -> String {
9424            self.inner.translate(plugin_name, key, args)
9425        }
9426        fn current_locale(&self) -> String {
9427            self.inner.current_locale()
9428        }
9429        fn set_js_execution_state(&self, state: String) {
9430            self.inner.set_js_execution_state(state);
9431        }
9432        fn clear_js_execution_state(&self) {
9433            self.inner.clear_js_execution_state();
9434        }
9435        fn get_theme_schema(&self) -> serde_json::Value {
9436            self.inner.get_theme_schema()
9437        }
9438        fn get_builtin_themes(&self) -> serde_json::Value {
9439            self.inner.get_builtin_themes()
9440        }
9441        fn get_all_themes(&self) -> serde_json::Value {
9442            self.inner.get_all_themes()
9443        }
9444        fn register_command(&self, command: fresh_core::command::Command) {
9445            self.inner.register_command(command);
9446        }
9447        fn unregister_command(&self, name: &str) {
9448            self.inner.unregister_command(name);
9449        }
9450        fn unregister_commands_by_prefix(&self, prefix: &str) {
9451            self.inner.unregister_commands_by_prefix(prefix);
9452        }
9453        fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9454            self.inner.unregister_commands_by_plugin(plugin_name);
9455        }
9456        fn plugins_dir(&self) -> std::path::PathBuf {
9457            self.inner.plugins_dir()
9458        }
9459        fn config_dir(&self) -> std::path::PathBuf {
9460            self.inner.config_dir()
9461        }
9462        fn data_dir(&self) -> std::path::PathBuf {
9463            self.inner.data_dir()
9464        }
9465        fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9466            if name == "test-theme" {
9467                Some(serde_json::json!({
9468                    "name": "test-theme",
9469                    "editor": {},
9470                    "ui": {},
9471                    "syntax": {}
9472                }))
9473            } else {
9474                None
9475            }
9476        }
9477        fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9478            Err("test bridge does not support save".to_string())
9479        }
9480        fn theme_file_exists(&self, name: &str) -> bool {
9481            name == "test-theme"
9482        }
9483    }
9484
9485    // ==================== Buffer Operations Tests ====================
9486
9487    #[test]
9488    fn test_api_close_buffer() {
9489        let (mut backend, rx) = create_test_backend();
9490
9491        backend
9492            .execute_js(
9493                r#"
9494            const editor = getEditor();
9495            editor.closeBuffer(3);
9496        "#,
9497                "test.js",
9498            )
9499            .unwrap();
9500
9501        let cmd = rx.try_recv().unwrap();
9502        match cmd {
9503            PluginCommand::CloseBuffer { buffer_id } => {
9504                assert_eq!(buffer_id.0, 3);
9505            }
9506            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9507        }
9508    }
9509
9510    #[test]
9511    fn test_api_focus_split() {
9512        let (mut backend, rx) = create_test_backend();
9513
9514        backend
9515            .execute_js(
9516                r#"
9517            const editor = getEditor();
9518            editor.focusSplit(2);
9519        "#,
9520                "test.js",
9521            )
9522            .unwrap();
9523
9524        let cmd = rx.try_recv().unwrap();
9525        match cmd {
9526            PluginCommand::FocusSplit { split_id } => {
9527                assert_eq!(split_id.0, 2);
9528            }
9529            _ => panic!("Expected FocusSplit, got {:?}", cmd),
9530        }
9531    }
9532
9533    /// `editor.createWindow`, `setActiveWindow`, and `closeWindow`
9534    /// each dispatch the matching `PluginCommand`, with arguments
9535    /// preserved.
9536    #[test]
9537    fn test_api_session_lifecycle_dispatches_commands() {
9538        let (mut backend, rx) = create_test_backend();
9539
9540        backend
9541            .execute_js(
9542                r#"
9543            const editor = getEditor();
9544            editor.createWindow("/tmp/wt-feat", "feat");
9545            editor.setActiveWindow(7);
9546            editor.closeWindow(3);
9547        "#,
9548                "test.js",
9549            )
9550            .unwrap();
9551
9552        let create = rx.try_recv().unwrap();
9553        match create {
9554            fresh_core::api::PluginCommand::CreateWindow { root, label } => {
9555                assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
9556                assert_eq!(label, "feat");
9557            }
9558            other => panic!("Expected CreateWindow, got {:?}", other),
9559        }
9560
9561        let activate = rx.try_recv().unwrap();
9562        match activate {
9563            fresh_core::api::PluginCommand::SetActiveWindow { id } => {
9564                assert_eq!(id, fresh_core::WindowId(7));
9565            }
9566            other => panic!("Expected SetActiveWindow, got {:?}", other),
9567        }
9568
9569        let close = rx.try_recv().unwrap();
9570        match close {
9571            fresh_core::api::PluginCommand::CloseWindow { id } => {
9572                assert_eq!(id, fresh_core::WindowId(3));
9573            }
9574            other => panic!("Expected CloseWindow, got {:?}", other),
9575        }
9576    }
9577
9578    /// `editor.listWindows()` reads from the state snapshot and
9579    /// returns `WindowInfo` objects shaped for plugin consumption.
9580    /// `editor.activeWindow()` returns the snapshot's active id.
9581    #[test]
9582    fn test_api_list_sessions_reads_snapshot() {
9583        let (tx, _rx) = mpsc::channel();
9584        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9585
9586        {
9587            let mut state = state_snapshot.write().unwrap();
9588            state.windows = vec![
9589                fresh_core::api::WindowInfo {
9590                    id: fresh_core::WindowId(1),
9591                    label: "main".into(),
9592                    root: std::path::PathBuf::from("/repo"),
9593                    project_path: None,
9594                    shared_worktree: false,
9595                },
9596                fresh_core::api::WindowInfo {
9597                    id: fresh_core::WindowId(2),
9598                    label: "feat-auth".into(),
9599                    root: std::path::PathBuf::from("/wt/feat-auth"),
9600                    project_path: None,
9601                    shared_worktree: false,
9602                },
9603            ];
9604            state.active_window_id = fresh_core::WindowId(2);
9605        }
9606
9607        let services = Arc::new(fresh_core::services::NoopServiceBridge);
9608        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9609
9610        backend
9611            .execute_js(
9612                r#"
9613            const editor = getEditor();
9614            const list = editor.listWindows();
9615            globalThis._sessionCount = list.length;
9616            globalThis._secondLabel = list[1].label;
9617            globalThis._secondRoot = list[1].root;
9618            globalThis._activeId = editor.activeWindow();
9619        "#,
9620                "test.js",
9621            )
9622            .unwrap();
9623
9624        backend
9625            .plugin_contexts
9626            .borrow()
9627            .get("test")
9628            .unwrap()
9629            .clone()
9630            .with(|ctx| {
9631                let global = ctx.globals();
9632                let count: u32 = global.get("_sessionCount").unwrap();
9633                let label: String = global.get("_secondLabel").unwrap();
9634                let root: String = global.get("_secondRoot").unwrap();
9635                let active: u32 = global.get("_activeId").unwrap();
9636                assert_eq!(count, 2);
9637                assert_eq!(label, "feat-auth");
9638                assert_eq!(root, "/wt/feat-auth");
9639                assert_eq!(active, 2);
9640            });
9641    }
9642
9643    #[test]
9644    fn test_api_list_buffers() {
9645        let (tx, _rx) = mpsc::channel();
9646        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9647
9648        // Add some buffers to state
9649        {
9650            let mut state = state_snapshot.write().unwrap();
9651            state.buffers.insert(
9652                BufferId(0),
9653                BufferInfo {
9654                    id: BufferId(0),
9655                    path: Some(PathBuf::from("/test1.txt")),
9656                    modified: false,
9657                    length: 100,
9658                    is_virtual: false,
9659                    view_mode: "source".to_string(),
9660                    is_composing_in_any_split: false,
9661                    compose_width: None,
9662                    language: "text".to_string(),
9663                    is_preview: false,
9664                    splits: Vec::new(),
9665                },
9666            );
9667            state.buffers.insert(
9668                BufferId(1),
9669                BufferInfo {
9670                    id: BufferId(1),
9671                    path: Some(PathBuf::from("/test2.txt")),
9672                    modified: true,
9673                    length: 200,
9674                    is_virtual: false,
9675                    view_mode: "source".to_string(),
9676                    is_composing_in_any_split: false,
9677                    compose_width: None,
9678                    language: "text".to_string(),
9679                    is_preview: false,
9680                    splits: Vec::new(),
9681                },
9682            );
9683        }
9684
9685        let services = Arc::new(fresh_core::services::NoopServiceBridge);
9686        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9687
9688        backend
9689            .execute_js(
9690                r#"
9691            const editor = getEditor();
9692            const buffers = editor.listBuffers();
9693            globalThis._isArray = Array.isArray(buffers);
9694            globalThis._length = buffers.length;
9695        "#,
9696                "test.js",
9697            )
9698            .unwrap();
9699
9700        backend
9701            .plugin_contexts
9702            .borrow()
9703            .get("test")
9704            .unwrap()
9705            .clone()
9706            .with(|ctx| {
9707                let global = ctx.globals();
9708                let is_array: bool = global.get("_isArray").unwrap();
9709                let length: u32 = global.get("_length").unwrap();
9710                assert!(is_array);
9711                assert_eq!(length, 2);
9712            });
9713    }
9714
9715    // ==================== Prompt Tests ====================
9716
9717    #[test]
9718    fn test_api_start_prompt() {
9719        let (mut backend, rx) = create_test_backend();
9720
9721        backend
9722            .execute_js(
9723                r#"
9724            const editor = getEditor();
9725            editor.startPrompt("Enter value:", "test-prompt");
9726        "#,
9727                "test.js",
9728            )
9729            .unwrap();
9730
9731        let cmd = rx.try_recv().unwrap();
9732        match cmd {
9733            PluginCommand::StartPrompt {
9734                label,
9735                prompt_type,
9736                floating_overlay,
9737            } => {
9738                assert_eq!(label, "Enter value:");
9739                assert_eq!(prompt_type, "test-prompt");
9740                assert!(!floating_overlay);
9741            }
9742            _ => panic!("Expected StartPrompt, got {:?}", cmd),
9743        }
9744    }
9745
9746    #[test]
9747    fn test_api_start_prompt_with_initial() {
9748        let (mut backend, rx) = create_test_backend();
9749
9750        backend
9751            .execute_js(
9752                r#"
9753            const editor = getEditor();
9754            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
9755        "#,
9756                "test.js",
9757            )
9758            .unwrap();
9759
9760        let cmd = rx.try_recv().unwrap();
9761        match cmd {
9762            PluginCommand::StartPromptWithInitial {
9763                label,
9764                prompt_type,
9765                initial_value,
9766                floating_overlay,
9767            } => {
9768                assert_eq!(label, "Enter value:");
9769                assert_eq!(prompt_type, "test-prompt");
9770                assert_eq!(initial_value, "default");
9771                assert!(!floating_overlay);
9772            }
9773            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
9774        }
9775    }
9776
9777    #[test]
9778    fn test_api_set_prompt_suggestions() {
9779        let (mut backend, rx) = create_test_backend();
9780
9781        backend
9782            .execute_js(
9783                r#"
9784            const editor = getEditor();
9785            editor.setPromptSuggestions([
9786                { text: "Option 1", value: "opt1" },
9787                { text: "Option 2", value: "opt2" }
9788            ]);
9789        "#,
9790                "test.js",
9791            )
9792            .unwrap();
9793
9794        let cmd = rx.try_recv().unwrap();
9795        match cmd {
9796            PluginCommand::SetPromptSuggestions { suggestions } => {
9797                assert_eq!(suggestions.len(), 2);
9798                assert_eq!(suggestions[0].text, "Option 1");
9799                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
9800            }
9801            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
9802        }
9803    }
9804
9805    // ==================== State Query Tests ====================
9806
9807    #[test]
9808    fn test_api_get_active_buffer_id() {
9809        let (tx, _rx) = mpsc::channel();
9810        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9811
9812        {
9813            let mut state = state_snapshot.write().unwrap();
9814            state.active_buffer_id = BufferId(42);
9815        }
9816
9817        let services = Arc::new(fresh_core::services::NoopServiceBridge);
9818        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9819
9820        backend
9821            .execute_js(
9822                r#"
9823            const editor = getEditor();
9824            globalThis._activeId = editor.getActiveBufferId();
9825        "#,
9826                "test.js",
9827            )
9828            .unwrap();
9829
9830        backend
9831            .plugin_contexts
9832            .borrow()
9833            .get("test")
9834            .unwrap()
9835            .clone()
9836            .with(|ctx| {
9837                let global = ctx.globals();
9838                let result: u32 = global.get("_activeId").unwrap();
9839                assert_eq!(result, 42);
9840            });
9841    }
9842
9843    #[test]
9844    fn test_api_get_active_split_id() {
9845        let (tx, _rx) = mpsc::channel();
9846        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9847
9848        {
9849            let mut state = state_snapshot.write().unwrap();
9850            state.active_split_id = 7;
9851        }
9852
9853        let services = Arc::new(fresh_core::services::NoopServiceBridge);
9854        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9855
9856        backend
9857            .execute_js(
9858                r#"
9859            const editor = getEditor();
9860            globalThis._splitId = editor.getActiveSplitId();
9861        "#,
9862                "test.js",
9863            )
9864            .unwrap();
9865
9866        backend
9867            .plugin_contexts
9868            .borrow()
9869            .get("test")
9870            .unwrap()
9871            .clone()
9872            .with(|ctx| {
9873                let global = ctx.globals();
9874                let result: u32 = global.get("_splitId").unwrap();
9875                assert_eq!(result, 7);
9876            });
9877    }
9878
9879    // ==================== File System Tests ====================
9880
9881    #[test]
9882    fn test_api_file_exists() {
9883        let (mut backend, _rx) = create_test_backend();
9884
9885        backend
9886            .execute_js(
9887                r#"
9888            const editor = getEditor();
9889            // Test with a path that definitely exists
9890            globalThis._exists = editor.fileExists("/");
9891        "#,
9892                "test.js",
9893            )
9894            .unwrap();
9895
9896        backend
9897            .plugin_contexts
9898            .borrow()
9899            .get("test")
9900            .unwrap()
9901            .clone()
9902            .with(|ctx| {
9903                let global = ctx.globals();
9904                let result: bool = global.get("_exists").unwrap();
9905                assert!(result);
9906            });
9907    }
9908
9909    #[test]
9910    fn test_api_parse_jsonc() {
9911        let (mut backend, _rx) = create_test_backend();
9912
9913        backend
9914            .execute_js(
9915                r#"
9916            const editor = getEditor();
9917            // Comments, trailing commas, and nested structures should all parse.
9918            const parsed = editor.parseJsonc(`{
9919                // name of the container
9920                "name": "test",
9921                "features": {
9922                    "docker-in-docker": {},
9923                },
9924                /* forwarded port list */
9925                "forwardPorts": [3000, 8080,],
9926            }`);
9927            globalThis._name = parsed.name;
9928            globalThis._featureCount = Object.keys(parsed.features).length;
9929            globalThis._portCount = parsed.forwardPorts.length;
9930
9931            // Invalid JSONC should throw.
9932            try {
9933                editor.parseJsonc("{ broken");
9934                globalThis._threw = false;
9935            } catch (_e) {
9936                globalThis._threw = true;
9937            }
9938        "#,
9939                "test.js",
9940            )
9941            .unwrap();
9942
9943        backend
9944            .plugin_contexts
9945            .borrow()
9946            .get("test")
9947            .unwrap()
9948            .clone()
9949            .with(|ctx| {
9950                let global = ctx.globals();
9951                let name: String = global.get("_name").unwrap();
9952                let feature_count: u32 = global.get("_featureCount").unwrap();
9953                let port_count: u32 = global.get("_portCount").unwrap();
9954                let threw: bool = global.get("_threw").unwrap();
9955                assert_eq!(name, "test");
9956                assert_eq!(feature_count, 1);
9957                assert_eq!(port_count, 2);
9958                assert!(threw, "Invalid JSONC should throw");
9959            });
9960    }
9961
9962    #[test]
9963    fn test_api_get_cwd() {
9964        let (mut backend, _rx) = create_test_backend();
9965
9966        backend
9967            .execute_js(
9968                r#"
9969            const editor = getEditor();
9970            globalThis._cwd = editor.getCwd();
9971        "#,
9972                "test.js",
9973            )
9974            .unwrap();
9975
9976        backend
9977            .plugin_contexts
9978            .borrow()
9979            .get("test")
9980            .unwrap()
9981            .clone()
9982            .with(|ctx| {
9983                let global = ctx.globals();
9984                let result: String = global.get("_cwd").unwrap();
9985                // Should return some path
9986                assert!(!result.is_empty());
9987            });
9988    }
9989
9990    #[test]
9991    fn test_api_get_env() {
9992        let (mut backend, _rx) = create_test_backend();
9993
9994        // Set a test environment variable
9995        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
9996
9997        backend
9998            .execute_js(
9999                r#"
10000            const editor = getEditor();
10001            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
10002        "#,
10003                "test.js",
10004            )
10005            .unwrap();
10006
10007        backend
10008            .plugin_contexts
10009            .borrow()
10010            .get("test")
10011            .unwrap()
10012            .clone()
10013            .with(|ctx| {
10014                let global = ctx.globals();
10015                let result: Option<String> = global.get("_envVal").unwrap();
10016                assert_eq!(result, Some("test_value".to_string()));
10017            });
10018
10019        std::env::remove_var("TEST_PLUGIN_VAR");
10020    }
10021
10022    #[test]
10023    fn test_api_get_config() {
10024        let (mut backend, _rx) = create_test_backend();
10025
10026        backend
10027            .execute_js(
10028                r#"
10029            const editor = getEditor();
10030            const config = editor.getConfig();
10031            globalThis._isObject = typeof config === 'object';
10032        "#,
10033                "test.js",
10034            )
10035            .unwrap();
10036
10037        backend
10038            .plugin_contexts
10039            .borrow()
10040            .get("test")
10041            .unwrap()
10042            .clone()
10043            .with(|ctx| {
10044                let global = ctx.globals();
10045                let is_object: bool = global.get("_isObject").unwrap();
10046                // getConfig should return an object, not a string
10047                assert!(is_object);
10048            });
10049    }
10050
10051    #[test]
10052    fn test_api_get_themes_dir() {
10053        let (mut backend, _rx) = create_test_backend();
10054
10055        backend
10056            .execute_js(
10057                r#"
10058            const editor = getEditor();
10059            globalThis._themesDir = editor.getThemesDir();
10060        "#,
10061                "test.js",
10062            )
10063            .unwrap();
10064
10065        backend
10066            .plugin_contexts
10067            .borrow()
10068            .get("test")
10069            .unwrap()
10070            .clone()
10071            .with(|ctx| {
10072                let global = ctx.globals();
10073                let result: String = global.get("_themesDir").unwrap();
10074                // Should return some path
10075                assert!(!result.is_empty());
10076            });
10077    }
10078
10079    // ==================== Read Dir Test ====================
10080
10081    #[test]
10082    fn test_api_read_dir() {
10083        let (mut backend, _rx) = create_test_backend();
10084
10085        backend
10086            .execute_js(
10087                r#"
10088            const editor = getEditor();
10089            const entries = editor.readDir("/tmp");
10090            globalThis._isArray = Array.isArray(entries);
10091            globalThis._length = entries.length;
10092        "#,
10093                "test.js",
10094            )
10095            .unwrap();
10096
10097        backend
10098            .plugin_contexts
10099            .borrow()
10100            .get("test")
10101            .unwrap()
10102            .clone()
10103            .with(|ctx| {
10104                let global = ctx.globals();
10105                let is_array: bool = global.get("_isArray").unwrap();
10106                let length: u32 = global.get("_length").unwrap();
10107                // /tmp should exist and readDir should return an array
10108                assert!(is_array);
10109                // Length is valid (u32 always >= 0)
10110                let _ = length;
10111            });
10112    }
10113
10114    // ==================== Execute Action Test ====================
10115
10116    #[test]
10117    fn test_api_execute_action() {
10118        let (mut backend, rx) = create_test_backend();
10119
10120        backend
10121            .execute_js(
10122                r#"
10123            const editor = getEditor();
10124            editor.executeAction("move_cursor_up");
10125        "#,
10126                "test.js",
10127            )
10128            .unwrap();
10129
10130        let cmd = rx.try_recv().unwrap();
10131        match cmd {
10132            PluginCommand::ExecuteAction { action_name } => {
10133                assert_eq!(action_name, "move_cursor_up");
10134            }
10135            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
10136        }
10137    }
10138
10139    // ==================== Debug Test ====================
10140
10141    #[test]
10142    fn test_api_debug() {
10143        let (mut backend, _rx) = create_test_backend();
10144
10145        // debug() should not panic and should work with any input
10146        backend
10147            .execute_js(
10148                r#"
10149            const editor = getEditor();
10150            editor.debug("Test debug message");
10151            editor.debug("Another message with special chars: <>&\"'");
10152        "#,
10153                "test.js",
10154            )
10155            .unwrap();
10156        // If we get here without panic, the test passes
10157    }
10158
10159    // ==================== TypeScript Definitions Test ====================
10160
10161    #[test]
10162    fn test_typescript_preamble_generated() {
10163        // Check that the TypeScript preamble constant exists and has content
10164        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
10165        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
10166        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
10167        println!(
10168            "Generated {} bytes of TypeScript preamble",
10169            JSEDITORAPI_TS_PREAMBLE.len()
10170        );
10171    }
10172
10173    #[test]
10174    fn test_typescript_editor_api_generated() {
10175        // Check that the EditorAPI interface is generated
10176        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
10177        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
10178        println!(
10179            "Generated {} bytes of EditorAPI interface",
10180            JSEDITORAPI_TS_EDITOR_API.len()
10181        );
10182    }
10183
10184    #[test]
10185    fn test_js_methods_list() {
10186        // Check that the JS methods list is generated
10187        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
10188        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
10189        // Print first 20 methods
10190        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
10191            if i < 20 {
10192                println!("  - {}", method);
10193            }
10194        }
10195        if JSEDITORAPI_JS_METHODS.len() > 20 {
10196            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
10197        }
10198    }
10199
10200    // ==================== Plugin Management API Tests ====================
10201
10202    #[test]
10203    fn test_api_load_plugin_sends_command() {
10204        let (mut backend, rx) = create_test_backend();
10205
10206        // Call loadPlugin - this returns a Promise and sends the command
10207        backend
10208            .execute_js(
10209                r#"
10210            const editor = getEditor();
10211            globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
10212        "#,
10213                "test.js",
10214            )
10215            .unwrap();
10216
10217        // Verify the LoadPlugin command was sent
10218        let cmd = rx.try_recv().unwrap();
10219        match cmd {
10220            PluginCommand::LoadPlugin { path, callback_id } => {
10221                assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
10222                assert!(callback_id.0 > 0); // Should have a valid callback ID
10223            }
10224            _ => panic!("Expected LoadPlugin, got {:?}", cmd),
10225        }
10226    }
10227
10228    #[test]
10229    fn test_api_unload_plugin_sends_command() {
10230        let (mut backend, rx) = create_test_backend();
10231
10232        // Call unloadPlugin - this returns a Promise and sends the command
10233        backend
10234            .execute_js(
10235                r#"
10236            const editor = getEditor();
10237            globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
10238        "#,
10239                "test.js",
10240            )
10241            .unwrap();
10242
10243        // Verify the UnloadPlugin command was sent
10244        let cmd = rx.try_recv().unwrap();
10245        match cmd {
10246            PluginCommand::UnloadPlugin { name, callback_id } => {
10247                assert_eq!(name, "my-plugin");
10248                assert!(callback_id.0 > 0); // Should have a valid callback ID
10249            }
10250            _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
10251        }
10252    }
10253
10254    #[test]
10255    fn test_api_reload_plugin_sends_command() {
10256        let (mut backend, rx) = create_test_backend();
10257
10258        // Call reloadPlugin - this returns a Promise and sends the command
10259        backend
10260            .execute_js(
10261                r#"
10262            const editor = getEditor();
10263            globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
10264        "#,
10265                "test.js",
10266            )
10267            .unwrap();
10268
10269        // Verify the ReloadPlugin command was sent
10270        let cmd = rx.try_recv().unwrap();
10271        match cmd {
10272            PluginCommand::ReloadPlugin { name, callback_id } => {
10273                assert_eq!(name, "my-plugin");
10274                assert!(callback_id.0 > 0); // Should have a valid callback ID
10275            }
10276            _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
10277        }
10278    }
10279
10280    #[test]
10281    fn test_api_load_plugin_resolves_callback() {
10282        let (mut backend, rx) = create_test_backend();
10283
10284        // Call loadPlugin and set up a handler for when it resolves
10285        backend
10286            .execute_js(
10287                r#"
10288            const editor = getEditor();
10289            globalThis._loadResult = null;
10290            editor.loadPlugin("/path/to/plugin.ts").then(result => {
10291                globalThis._loadResult = result;
10292            });
10293        "#,
10294                "test.js",
10295            )
10296            .unwrap();
10297
10298        // Get the callback_id from the command
10299        let callback_id = match rx.try_recv().unwrap() {
10300            PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
10301            cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
10302        };
10303
10304        // Simulate the editor responding with success
10305        backend.resolve_callback(callback_id, "true");
10306
10307        // Drive the Promise to completion
10308        backend
10309            .plugin_contexts
10310            .borrow()
10311            .get("test")
10312            .unwrap()
10313            .clone()
10314            .with(|ctx| {
10315                run_pending_jobs_checked(&ctx, "test async loadPlugin");
10316            });
10317
10318        // Verify the Promise resolved with true
10319        backend
10320            .plugin_contexts
10321            .borrow()
10322            .get("test")
10323            .unwrap()
10324            .clone()
10325            .with(|ctx| {
10326                let global = ctx.globals();
10327                let result: bool = global.get("_loadResult").unwrap();
10328                assert!(result);
10329            });
10330    }
10331
10332    #[test]
10333    fn test_api_version() {
10334        let (mut backend, _rx) = create_test_backend();
10335
10336        backend
10337            .execute_js(
10338                r#"
10339            const editor = getEditor();
10340            globalThis._apiVersion = editor.apiVersion();
10341        "#,
10342                "test.js",
10343            )
10344            .unwrap();
10345
10346        backend
10347            .plugin_contexts
10348            .borrow()
10349            .get("test")
10350            .unwrap()
10351            .clone()
10352            .with(|ctx| {
10353                let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10354                assert_eq!(version, 2);
10355            });
10356    }
10357
10358    #[test]
10359    fn test_api_unload_plugin_rejects_on_error() {
10360        let (mut backend, rx) = create_test_backend();
10361
10362        // Call unloadPlugin and set up handlers for resolve/reject
10363        backend
10364            .execute_js(
10365                r#"
10366            const editor = getEditor();
10367            globalThis._unloadError = null;
10368            editor.unloadPlugin("nonexistent-plugin").catch(err => {
10369                globalThis._unloadError = err.message || String(err);
10370            });
10371        "#,
10372                "test.js",
10373            )
10374            .unwrap();
10375
10376        // Get the callback_id from the command
10377        let callback_id = match rx.try_recv().unwrap() {
10378            PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10379            cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10380        };
10381
10382        // Simulate the editor responding with an error
10383        backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10384
10385        // Drive the Promise to completion
10386        backend
10387            .plugin_contexts
10388            .borrow()
10389            .get("test")
10390            .unwrap()
10391            .clone()
10392            .with(|ctx| {
10393                run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10394            });
10395
10396        // Verify the Promise rejected with the error
10397        backend
10398            .plugin_contexts
10399            .borrow()
10400            .get("test")
10401            .unwrap()
10402            .clone()
10403            .with(|ctx| {
10404                let global = ctx.globals();
10405                let error: String = global.get("_unloadError").unwrap();
10406                assert!(error.contains("nonexistent-plugin"));
10407            });
10408    }
10409
10410    #[test]
10411    fn test_api_set_global_state() {
10412        let (mut backend, rx) = create_test_backend();
10413
10414        backend
10415            .execute_js(
10416                r#"
10417            const editor = getEditor();
10418            editor.setGlobalState("myKey", { enabled: true, count: 42 });
10419        "#,
10420                "test_plugin.js",
10421            )
10422            .unwrap();
10423
10424        let cmd = rx.try_recv().unwrap();
10425        match cmd {
10426            PluginCommand::SetGlobalState {
10427                plugin_name,
10428                key,
10429                value,
10430            } => {
10431                assert_eq!(plugin_name, "test_plugin");
10432                assert_eq!(key, "myKey");
10433                let v = value.unwrap();
10434                assert_eq!(v["enabled"], serde_json::json!(true));
10435                assert_eq!(v["count"], serde_json::json!(42));
10436            }
10437            _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10438        }
10439    }
10440
10441    #[test]
10442    fn test_api_set_global_state_delete() {
10443        let (mut backend, rx) = create_test_backend();
10444
10445        backend
10446            .execute_js(
10447                r#"
10448            const editor = getEditor();
10449            editor.setGlobalState("myKey", null);
10450        "#,
10451                "test_plugin.js",
10452            )
10453            .unwrap();
10454
10455        let cmd = rx.try_recv().unwrap();
10456        match cmd {
10457            PluginCommand::SetGlobalState {
10458                plugin_name,
10459                key,
10460                value,
10461            } => {
10462                assert_eq!(plugin_name, "test_plugin");
10463                assert_eq!(key, "myKey");
10464                assert!(value.is_none(), "null should delete the key");
10465            }
10466            _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10467        }
10468    }
10469
10470    #[test]
10471    fn test_api_get_global_state_roundtrip() {
10472        let (mut backend, _rx) = create_test_backend();
10473
10474        // Set a value, then immediately read it back (write-through)
10475        backend
10476            .execute_js(
10477                r#"
10478            const editor = getEditor();
10479            editor.setGlobalState("flag", true);
10480            globalThis._result = editor.getGlobalState("flag");
10481        "#,
10482                "test_plugin.js",
10483            )
10484            .unwrap();
10485
10486        backend
10487            .plugin_contexts
10488            .borrow()
10489            .get("test_plugin")
10490            .unwrap()
10491            .clone()
10492            .with(|ctx| {
10493                let global = ctx.globals();
10494                let result: bool = global.get("_result").unwrap();
10495                assert!(
10496                    result,
10497                    "getGlobalState should return the value set by setGlobalState"
10498                );
10499            });
10500    }
10501
10502    /// `setWindowState` writes through to the snapshot's
10503    /// active-session map; `getWindowState` reads it back.
10504    /// Mirrors the global-state roundtrip test — the only
10505    /// behavioural difference is the storage namespace.
10506    #[test]
10507    fn test_api_set_session_state_roundtrip() {
10508        let (mut backend, _rx) = create_test_backend();
10509
10510        backend
10511            .execute_js(
10512                r#"
10513            const editor = getEditor();
10514            editor.setWindowState("draft", { count: 7 });
10515            globalThis._result = editor.getWindowState("draft");
10516            globalThis._missing = editor.getWindowState("absent");
10517        "#,
10518                "test_plugin.js",
10519            )
10520            .unwrap();
10521
10522        backend
10523            .plugin_contexts
10524            .borrow()
10525            .get("test_plugin")
10526            .unwrap()
10527            .clone()
10528            .with(|ctx| {
10529                let global = ctx.globals();
10530                let count: i64 = global
10531                    .get::<_, rquickjs::Object>("_result")
10532                    .unwrap()
10533                    .get("count")
10534                    .unwrap();
10535                assert_eq!(
10536                    count, 7,
10537                    "getWindowState should return the value set by setWindowState"
10538                );
10539                let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
10540                assert!(
10541                    missing.is_undefined(),
10542                    "getWindowState for an unset key must be undefined"
10543                );
10544            });
10545    }
10546
10547    #[test]
10548    fn test_api_get_global_state_missing_key() {
10549        let (mut backend, _rx) = create_test_backend();
10550
10551        backend
10552            .execute_js(
10553                r#"
10554            const editor = getEditor();
10555            globalThis._result = editor.getGlobalState("nonexistent");
10556            globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
10557        "#,
10558                "test_plugin.js",
10559            )
10560            .unwrap();
10561
10562        backend
10563            .plugin_contexts
10564            .borrow()
10565            .get("test_plugin")
10566            .unwrap()
10567            .clone()
10568            .with(|ctx| {
10569                let global = ctx.globals();
10570                let is_undefined: bool = global.get("_isUndefined").unwrap();
10571                assert!(
10572                    is_undefined,
10573                    "getGlobalState for missing key should return undefined"
10574                );
10575            });
10576    }
10577
10578    #[test]
10579    fn test_api_global_state_isolation_between_plugins() {
10580        // Two plugins using the same key name should not see each other's state
10581        let (tx, _rx) = mpsc::channel();
10582        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10583        let services = Arc::new(TestServiceBridge::new());
10584
10585        // Plugin A sets "flag" = true
10586        let mut backend_a =
10587            QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10588                .unwrap();
10589        backend_a
10590            .execute_js(
10591                r#"
10592            const editor = getEditor();
10593            editor.setGlobalState("flag", "from_plugin_a");
10594        "#,
10595                "plugin_a.js",
10596            )
10597            .unwrap();
10598
10599        // Plugin B sets "flag" = "from_plugin_b"
10600        let mut backend_b =
10601            QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10602                .unwrap();
10603        backend_b
10604            .execute_js(
10605                r#"
10606            const editor = getEditor();
10607            editor.setGlobalState("flag", "from_plugin_b");
10608        "#,
10609                "plugin_b.js",
10610            )
10611            .unwrap();
10612
10613        // Plugin A should still see its own value
10614        backend_a
10615            .execute_js(
10616                r#"
10617            const editor = getEditor();
10618            globalThis._aValue = editor.getGlobalState("flag");
10619        "#,
10620                "plugin_a.js",
10621            )
10622            .unwrap();
10623
10624        backend_a
10625            .plugin_contexts
10626            .borrow()
10627            .get("plugin_a")
10628            .unwrap()
10629            .clone()
10630            .with(|ctx| {
10631                let global = ctx.globals();
10632                let a_value: String = global.get("_aValue").unwrap();
10633                assert_eq!(
10634                    a_value, "from_plugin_a",
10635                    "Plugin A should see its own value, not plugin B's"
10636                );
10637            });
10638
10639        // Plugin B should see its own value
10640        backend_b
10641            .execute_js(
10642                r#"
10643            const editor = getEditor();
10644            globalThis._bValue = editor.getGlobalState("flag");
10645        "#,
10646                "plugin_b.js",
10647            )
10648            .unwrap();
10649
10650        backend_b
10651            .plugin_contexts
10652            .borrow()
10653            .get("plugin_b")
10654            .unwrap()
10655            .clone()
10656            .with(|ctx| {
10657                let global = ctx.globals();
10658                let b_value: String = global.get("_bValue").unwrap();
10659                assert_eq!(
10660                    b_value, "from_plugin_b",
10661                    "Plugin B should see its own value, not plugin A's"
10662                );
10663            });
10664    }
10665
10666    #[test]
10667    fn test_register_command_collision_different_plugins() {
10668        let (mut backend, _rx) = create_test_backend();
10669
10670        // Plugin A registers a command
10671        backend
10672            .execute_js(
10673                r#"
10674            const editor = getEditor();
10675            globalThis.handlerA = function() { };
10676            editor.registerCommand("My Command", "From A", "handlerA", null);
10677        "#,
10678                "plugin_a.js",
10679            )
10680            .unwrap();
10681
10682        // Plugin B tries to register the same command name — should throw
10683        let result = backend.execute_js(
10684            r#"
10685            const editor = getEditor();
10686            globalThis.handlerB = function() { };
10687            editor.registerCommand("My Command", "From B", "handlerB", null);
10688        "#,
10689            "plugin_b.js",
10690        );
10691
10692        assert!(
10693            result.is_err(),
10694            "Second plugin registering the same command name should fail"
10695        );
10696        let err_msg = result.unwrap_err().to_string();
10697        assert!(
10698            err_msg.contains("already registered"),
10699            "Error should mention collision: {}",
10700            err_msg
10701        );
10702    }
10703
10704    #[test]
10705    fn test_register_command_same_plugin_allowed() {
10706        let (mut backend, _rx) = create_test_backend();
10707
10708        // Plugin A registers a command, then re-registers it (hot-reload)
10709        backend
10710            .execute_js(
10711                r#"
10712            const editor = getEditor();
10713            globalThis.handler1 = function() { };
10714            editor.registerCommand("My Command", "Version 1", "handler1", null);
10715            globalThis.handler2 = function() { };
10716            editor.registerCommand("My Command", "Version 2", "handler2", null);
10717        "#,
10718                "plugin_a.js",
10719            )
10720            .unwrap();
10721    }
10722
10723    #[test]
10724    fn test_register_command_after_unregister() {
10725        let (mut backend, _rx) = create_test_backend();
10726
10727        // Plugin A registers then unregisters
10728        backend
10729            .execute_js(
10730                r#"
10731            const editor = getEditor();
10732            globalThis.handlerA = function() { };
10733            editor.registerCommand("My Command", "From A", "handlerA", null);
10734            editor.unregisterCommand("My Command");
10735        "#,
10736                "plugin_a.js",
10737            )
10738            .unwrap();
10739
10740        // Plugin B can now register the same name
10741        backend
10742            .execute_js(
10743                r#"
10744            const editor = getEditor();
10745            globalThis.handlerB = function() { };
10746            editor.registerCommand("My Command", "From B", "handlerB", null);
10747        "#,
10748                "plugin_b.js",
10749            )
10750            .unwrap();
10751    }
10752
10753    #[test]
10754    fn test_register_command_collision_caught_in_try_catch() {
10755        let (mut backend, _rx) = create_test_backend();
10756
10757        // Plugin A registers a command
10758        backend
10759            .execute_js(
10760                r#"
10761            const editor = getEditor();
10762            globalThis.handlerA = function() { };
10763            editor.registerCommand("My Command", "From A", "handlerA", null);
10764        "#,
10765                "plugin_a.js",
10766            )
10767            .unwrap();
10768
10769        // Plugin B catches the collision error gracefully
10770        backend
10771            .execute_js(
10772                r#"
10773            const editor = getEditor();
10774            globalThis.handlerB = function() { };
10775            let caught = false;
10776            try {
10777                editor.registerCommand("My Command", "From B", "handlerB", null);
10778            } catch (e) {
10779                caught = true;
10780            }
10781            if (!caught) throw new Error("Expected collision error");
10782        "#,
10783                "plugin_b.js",
10784            )
10785            .unwrap();
10786    }
10787
10788    #[test]
10789    fn test_register_command_i18n_key_no_collision_across_plugins() {
10790        let (mut backend, _rx) = create_test_backend();
10791
10792        // Plugin A registers a %-prefixed i18n command
10793        backend
10794            .execute_js(
10795                r#"
10796            const editor = getEditor();
10797            globalThis.handlerA = function() { };
10798            editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
10799        "#,
10800                "plugin_a.js",
10801            )
10802            .unwrap();
10803
10804        // Plugin B registers the same %-prefixed i18n key — should NOT collide
10805        // because %-prefixed names are scoped per plugin
10806        backend
10807            .execute_js(
10808                r#"
10809            const editor = getEditor();
10810            globalThis.handlerB = function() { };
10811            editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
10812        "#,
10813                "plugin_b.js",
10814            )
10815            .unwrap();
10816    }
10817
10818    #[test]
10819    fn test_register_command_non_i18n_still_collides() {
10820        let (mut backend, _rx) = create_test_backend();
10821
10822        // Plugin A registers a plain (non-%) command
10823        backend
10824            .execute_js(
10825                r#"
10826            const editor = getEditor();
10827            globalThis.handlerA = function() { };
10828            editor.registerCommand("My Reload", "Reload A", "handlerA", null);
10829        "#,
10830                "plugin_a.js",
10831            )
10832            .unwrap();
10833
10834        // Plugin B tries the same plain name — should collide
10835        let result = backend.execute_js(
10836            r#"
10837            const editor = getEditor();
10838            globalThis.handlerB = function() { };
10839            editor.registerCommand("My Reload", "Reload B", "handlerB", null);
10840        "#,
10841            "plugin_b.js",
10842        );
10843
10844        assert!(
10845            result.is_err(),
10846            "Non-%-prefixed names should still collide across plugins"
10847        );
10848    }
10849}