Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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