Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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