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