Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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