Skip to main content

fresh_plugin_runtime/backend/
quickjs_backend.rs

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