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