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    JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions, PluginCommand,
93    PluginResponse,
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/// Convert a QuickJS Value to serde_json::Value
112fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
113    use rquickjs::Type;
114    match val.type_of() {
115        Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
116        Type::Bool => val
117            .as_bool()
118            .map(serde_json::Value::Bool)
119            .unwrap_or(serde_json::Value::Null),
120        Type::Int => val
121            .as_int()
122            .map(|n| serde_json::Value::Number(n.into()))
123            .unwrap_or(serde_json::Value::Null),
124        Type::Float => val
125            .as_float()
126            .and_then(serde_json::Number::from_f64)
127            .map(serde_json::Value::Number)
128            .unwrap_or(serde_json::Value::Null),
129        Type::String => val
130            .as_string()
131            .and_then(|s| s.to_string().ok())
132            .map(serde_json::Value::String)
133            .unwrap_or(serde_json::Value::Null),
134        Type::Array => {
135            if let Some(arr) = val.as_array() {
136                let items: Vec<serde_json::Value> = arr
137                    .iter()
138                    .filter_map(|item| item.ok())
139                    .map(|item| js_to_json(ctx, item))
140                    .collect();
141                serde_json::Value::Array(items)
142            } else {
143                serde_json::Value::Null
144            }
145        }
146        Type::Object | Type::Constructor | Type::Function => {
147            if let Some(obj) = val.as_object() {
148                let mut map = serde_json::Map::new();
149                for key in obj.keys::<String>().flatten() {
150                    if let Ok(v) = obj.get::<_, Value>(&key) {
151                        map.insert(key, js_to_json(ctx, v));
152                    }
153                }
154                serde_json::Value::Object(map)
155            } else {
156                serde_json::Value::Null
157            }
158        }
159        _ => serde_json::Value::Null,
160    }
161}
162
163/// Get text properties at cursor position
164fn get_text_properties_at_cursor_typed(
165    snapshot: &Arc<RwLock<EditorStateSnapshot>>,
166    buffer_id: u32,
167) -> fresh_core::api::TextPropertiesAtCursor {
168    use fresh_core::api::TextPropertiesAtCursor;
169
170    let snap = match snapshot.read() {
171        Ok(s) => s,
172        Err(_) => return TextPropertiesAtCursor(Vec::new()),
173    };
174    let buffer_id_typed = BufferId(buffer_id as usize);
175    let cursor_pos = match snap
176        .buffer_cursor_positions
177        .get(&buffer_id_typed)
178        .copied()
179        .or_else(|| {
180            if snap.active_buffer_id == buffer_id_typed {
181                snap.primary_cursor.as_ref().map(|c| c.position)
182            } else {
183                None
184            }
185        }) {
186        Some(pos) => pos,
187        None => return TextPropertiesAtCursor(Vec::new()),
188    };
189
190    let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
191        Some(p) => p,
192        None => return TextPropertiesAtCursor(Vec::new()),
193    };
194
195    // Find all properties at cursor position
196    let result: Vec<_> = properties
197        .iter()
198        .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
199        .map(|prop| prop.properties.clone())
200        .collect();
201
202    TextPropertiesAtCursor(result)
203}
204
205/// Convert a JavaScript value to a string representation for console output
206fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
207    use rquickjs::Type;
208    match val.type_of() {
209        Type::Null => "null".to_string(),
210        Type::Undefined => "undefined".to_string(),
211        Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
212        Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
213        Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
214        Type::String => val
215            .as_string()
216            .and_then(|s| s.to_string().ok())
217            .unwrap_or_default(),
218        Type::Object | Type::Exception => {
219            // Check if this is an Error object (has message/stack properties)
220            if let Some(obj) = val.as_object() {
221                // Try to get error properties
222                let name: Option<String> = obj.get("name").ok();
223                let message: Option<String> = obj.get("message").ok();
224                let stack: Option<String> = obj.get("stack").ok();
225
226                if message.is_some() || name.is_some() {
227                    // This looks like an Error object
228                    let name = name.unwrap_or_else(|| "Error".to_string());
229                    let message = message.unwrap_or_default();
230                    if let Some(stack) = stack {
231                        return format!("{}: {}\n{}", name, message, stack);
232                    } else {
233                        return format!("{}: {}", name, message);
234                    }
235                }
236
237                // Regular object - convert to JSON
238                let json = js_to_json(ctx, val.clone());
239                serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
240            } else {
241                "[object]".to_string()
242            }
243        }
244        Type::Array => {
245            let json = js_to_json(ctx, val.clone());
246            serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
247        }
248        Type::Function | Type::Constructor => "[function]".to_string(),
249        Type::Symbol => "[symbol]".to_string(),
250        Type::BigInt => val
251            .as_big_int()
252            .and_then(|b| b.clone().to_i64().ok())
253            .map(|n| n.to_string())
254            .unwrap_or_else(|| "[bigint]".to_string()),
255        _ => format!("[{}]", val.type_name()),
256    }
257}
258
259/// Format a JavaScript error with full details including stack trace
260fn format_js_error(
261    ctx: &rquickjs::Ctx<'_>,
262    err: rquickjs::Error,
263    source_name: &str,
264) -> anyhow::Error {
265    // Check if this is an exception that we can catch for more details
266    if err.is_exception() {
267        // Try to catch the exception to get the full error object
268        let exc = ctx.catch();
269        if !exc.is_undefined() && !exc.is_null() {
270            // Try to get error message and stack from the exception object
271            if let Some(exc_obj) = exc.as_object() {
272                let message: String = exc_obj
273                    .get::<_, String>("message")
274                    .unwrap_or_else(|_| "Unknown error".to_string());
275                let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
276                let name: String = exc_obj
277                    .get::<_, String>("name")
278                    .unwrap_or_else(|_| "Error".to_string());
279
280                if !stack.is_empty() {
281                    return anyhow::anyhow!(
282                        "JS error in {}: {}: {}\nStack trace:\n{}",
283                        source_name,
284                        name,
285                        message,
286                        stack
287                    );
288                } else {
289                    return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
290                }
291            } else {
292                // Exception is not an object, try to convert to string
293                let exc_str: String = exc
294                    .as_string()
295                    .and_then(|s: &rquickjs::String| s.to_string().ok())
296                    .unwrap_or_else(|| format!("{:?}", exc));
297                return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
298            }
299        }
300    }
301
302    // Fall back to the basic error message
303    anyhow::anyhow!("JS error in {}: {}", source_name, err)
304}
305
306/// Log a JavaScript error with full details
307/// If panic_on_js_errors is enabled, this will panic to surface JS errors immediately
308fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
309    let error = format_js_error(ctx, err, context);
310    tracing::error!("{}", error);
311
312    // When enabled, panic on JS errors to make them visible and fail fast
313    if should_panic_on_js_errors() {
314        panic!("JavaScript error in {}: {}", context, error);
315    }
316}
317
318/// Global flag to panic on JS errors (enabled during testing)
319static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
320    std::sync::atomic::AtomicBool::new(false);
321
322/// Enable panicking on JS errors (call this from test setup)
323pub fn set_panic_on_js_errors(enabled: bool) {
324    PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
325}
326
327/// Check if panic on JS errors is enabled
328fn should_panic_on_js_errors() -> bool {
329    PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
330}
331
332/// Global flag indicating a fatal JS error occurred that should terminate the plugin thread.
333/// This is used because panicking inside rquickjs callbacks (FFI boundary) gets caught by
334/// rquickjs's catch_unwind, so we need an alternative mechanism to signal errors.
335static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
336
337/// Storage for the fatal error message
338static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
339
340/// Set a fatal JS error - call this instead of panicking inside FFI callbacks
341fn set_fatal_js_error(msg: String) {
342    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
343        if guard.is_none() {
344            // Only store the first error
345            *guard = Some(msg);
346        }
347    }
348    FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
349}
350
351/// Check if a fatal JS error has occurred
352pub fn has_fatal_js_error() -> bool {
353    FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
354}
355
356/// Get and clear the fatal JS error message (returns None if no error)
357pub fn take_fatal_js_error() -> Option<String> {
358    if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
359        return None;
360    }
361    if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
362        guard.take()
363    } else {
364        Some("Fatal JS error (message unavailable)".to_string())
365    }
366}
367
368/// Run all pending jobs and check for unhandled exceptions
369/// If panic_on_js_errors is enabled, this will panic on unhandled exceptions
370fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
371    let mut count = 0;
372    loop {
373        // Check for unhandled exception before running more jobs
374        let exc: rquickjs::Value = ctx.catch();
375        // Only treat it as an exception if it's actually an Error object
376        if exc.is_exception() {
377            let error_msg = if let Some(err) = exc.as_exception() {
378                format!(
379                    "{}: {}",
380                    err.message().unwrap_or_default(),
381                    err.stack().unwrap_or_default()
382                )
383            } else {
384                format!("{:?}", exc)
385            };
386            tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
387            if should_panic_on_js_errors() {
388                panic!("Unhandled JS exception during {}: {}", context, error_msg);
389            }
390        }
391
392        if !ctx.execute_pending_job() {
393            break;
394        }
395        count += 1;
396    }
397
398    // Final check for exceptions after all jobs completed
399    let exc: rquickjs::Value = ctx.catch();
400    if exc.is_exception() {
401        let error_msg = if let Some(err) = exc.as_exception() {
402            format!(
403                "{}: {}",
404                err.message().unwrap_or_default(),
405                err.stack().unwrap_or_default()
406            )
407        } else {
408            format!("{:?}", exc)
409        };
410        tracing::error!(
411            "Unhandled JS exception after running jobs in {}: {}",
412            context,
413            error_msg
414        );
415        if should_panic_on_js_errors() {
416            panic!(
417                "Unhandled JS exception after running jobs in {}: {}",
418                context, error_msg
419            );
420        }
421    }
422
423    count
424}
425
426/// Parse a TextPropertyEntry from a JS Object
427fn parse_text_property_entry(
428    ctx: &rquickjs::Ctx<'_>,
429    obj: &Object<'_>,
430) -> Option<TextPropertyEntry> {
431    let text: String = obj.get("text").ok()?;
432    let properties: HashMap<String, serde_json::Value> = obj
433        .get::<_, Object>("properties")
434        .ok()
435        .map(|props_obj| {
436            let mut map = HashMap::new();
437            for key in props_obj.keys::<String>().flatten() {
438                if let Ok(v) = props_obj.get::<_, Value>(&key) {
439                    map.insert(key, js_to_json(ctx, v));
440                }
441            }
442            map
443        })
444        .unwrap_or_default();
445    Some(TextPropertyEntry { text, properties })
446}
447
448/// Pending response senders type alias
449pub type PendingResponses =
450    Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
451
452/// Information about a loaded plugin
453#[derive(Debug, Clone)]
454pub struct TsPluginInfo {
455    pub name: String,
456    pub path: PathBuf,
457    pub enabled: bool,
458}
459
460/// Handler information for events and actions
461#[derive(Debug, Clone)]
462pub struct PluginHandler {
463    pub plugin_name: String,
464    pub handler_name: String,
465}
466
467/// JavaScript-exposed Editor API using rquickjs class system
468/// This allows proper lifetime handling for methods returning JS values
469#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
470#[rquickjs::class]
471pub struct JsEditorApi {
472    #[qjs(skip_trace)]
473    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
474    #[qjs(skip_trace)]
475    command_sender: mpsc::Sender<PluginCommand>,
476    #[qjs(skip_trace)]
477    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
478    #[qjs(skip_trace)]
479    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
480    #[qjs(skip_trace)]
481    next_request_id: Rc<RefCell<u64>>,
482    #[qjs(skip_trace)]
483    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
484    #[qjs(skip_trace)]
485    services: Arc<dyn fresh_core::services::PluginServiceBridge>,
486    pub plugin_name: String,
487}
488
489#[plugin_api_impl]
490#[rquickjs::methods(rename_all = "camelCase")]
491impl JsEditorApi {
492    // === Buffer Queries ===
493
494    /// Get the active buffer ID (0 if none)
495    pub fn get_active_buffer_id(&self) -> u32 {
496        self.state_snapshot
497            .read()
498            .map(|s| s.active_buffer_id.0 as u32)
499            .unwrap_or(0)
500    }
501
502    /// Get the active split ID
503    pub fn get_active_split_id(&self) -> u32 {
504        self.state_snapshot
505            .read()
506            .map(|s| s.active_split_id as u32)
507            .unwrap_or(0)
508    }
509
510    /// List all open buffers - returns array of BufferInfo objects
511    #[plugin_api(ts_return = "BufferInfo[]")]
512    pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
513        let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
514            s.buffers.values().cloned().collect()
515        } else {
516            Vec::new()
517        };
518        rquickjs_serde::to_value(ctx, &buffers)
519            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
520    }
521
522    // === Logging ===
523
524    pub fn debug(&self, msg: String) {
525        tracing::info!("Plugin.debug: {}", msg);
526    }
527
528    pub fn info(&self, msg: String) {
529        tracing::info!("Plugin: {}", msg);
530    }
531
532    pub fn warn(&self, msg: String) {
533        tracing::warn!("Plugin: {}", msg);
534    }
535
536    pub fn error(&self, msg: String) {
537        tracing::error!("Plugin: {}", msg);
538    }
539
540    // === Status ===
541
542    pub fn set_status(&self, msg: String) {
543        let _ = self
544            .command_sender
545            .send(PluginCommand::SetStatus { message: msg });
546    }
547
548    // === Clipboard ===
549
550    pub fn copy_to_clipboard(&self, text: String) {
551        let _ = self
552            .command_sender
553            .send(PluginCommand::SetClipboard { text });
554    }
555
556    pub fn set_clipboard(&self, text: String) {
557        let _ = self
558            .command_sender
559            .send(PluginCommand::SetClipboard { text });
560    }
561
562    // === Command Registration ===
563
564    /// Register a command - reads plugin name from __pluginName__ global
565    /// context is optional - can be omitted, null, undefined, or a string
566    pub fn register_command<'js>(
567        &self,
568        _ctx: rquickjs::Ctx<'js>,
569        name: String,
570        description: String,
571        handler_name: String,
572        context: rquickjs::function::Opt<rquickjs::Value<'js>>,
573    ) -> rquickjs::Result<bool> {
574        // Use stored plugin name instead of global lookup
575        let plugin_name = self.plugin_name.clone();
576        // Extract context string - handle null, undefined, or missing
577        let context_str: Option<String> = context.0.and_then(|v| {
578            if v.is_null() || v.is_undefined() {
579                None
580            } else {
581                v.as_string().and_then(|s| s.to_string().ok())
582            }
583        });
584
585        tracing::debug!(
586            "registerCommand: plugin='{}', name='{}', handler='{}'",
587            plugin_name,
588            name,
589            handler_name
590        );
591
592        // Store action handler mapping with its plugin name
593        self.registered_actions.borrow_mut().insert(
594            handler_name.clone(),
595            PluginHandler {
596                plugin_name: self.plugin_name.clone(),
597                handler_name: handler_name.clone(),
598            },
599        );
600
601        // Register with editor
602        let command = Command {
603            name: name.clone(),
604            description,
605            action_name: handler_name,
606            plugin_name,
607            custom_contexts: context_str.into_iter().collect(),
608        };
609
610        Ok(self
611            .command_sender
612            .send(PluginCommand::RegisterCommand { command })
613            .is_ok())
614    }
615
616    /// Unregister a command by name
617    pub fn unregister_command(&self, name: String) -> bool {
618        self.command_sender
619            .send(PluginCommand::UnregisterCommand { name })
620            .is_ok()
621    }
622
623    /// Set a context (for keybinding conditions)
624    pub fn set_context(&self, name: String, active: bool) -> bool {
625        self.command_sender
626            .send(PluginCommand::SetContext { name, active })
627            .is_ok()
628    }
629
630    /// Execute a built-in action
631    pub fn execute_action(&self, action_name: String) -> bool {
632        self.command_sender
633            .send(PluginCommand::ExecuteAction { action_name })
634            .is_ok()
635    }
636
637    // === Translation ===
638
639    /// Translate a string - reads plugin name from __pluginName__ global
640    /// Args is optional - can be omitted, undefined, null, or an object
641    pub fn t<'js>(
642        &self,
643        _ctx: rquickjs::Ctx<'js>,
644        key: String,
645        args: rquickjs::function::Rest<Value<'js>>,
646    ) -> String {
647        // Use stored plugin name instead of global lookup
648        let plugin_name = self.plugin_name.clone();
649        // Convert args to HashMap - args.0 is a Vec of the rest arguments
650        let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
651            if let Some(obj) = first_arg.as_object() {
652                let mut map = HashMap::new();
653                for k in obj.keys::<String>().flatten() {
654                    if let Ok(v) = obj.get::<_, String>(&k) {
655                        map.insert(k, v);
656                    }
657                }
658                map
659            } else {
660                HashMap::new()
661            }
662        } else {
663            HashMap::new()
664        };
665        let res = self.services.translate(&plugin_name, &key, &args_map);
666
667        tracing::info!(
668            "Translating: key={}, plugin={}, args={:?} => res='{}'",
669            key,
670            plugin_name,
671            args_map,
672            res
673        );
674        res
675    }
676
677    // === Buffer Queries (additional) ===
678
679    /// Get cursor position in active buffer
680    pub fn get_cursor_position(&self) -> u32 {
681        self.state_snapshot
682            .read()
683            .ok()
684            .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
685            .unwrap_or(0)
686    }
687
688    /// Get file path for a buffer
689    pub fn get_buffer_path(&self, buffer_id: u32) -> String {
690        if let Ok(s) = self.state_snapshot.read() {
691            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
692                if let Some(p) = &b.path {
693                    return p.to_string_lossy().to_string();
694                }
695            }
696        }
697        String::new()
698    }
699
700    /// Get buffer length in bytes
701    pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
702        if let Ok(s) = self.state_snapshot.read() {
703            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
704                return b.length as u32;
705            }
706        }
707        0
708    }
709
710    /// Check if buffer has unsaved changes
711    pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
712        if let Ok(s) = self.state_snapshot.read() {
713            if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
714                return b.modified;
715            }
716        }
717        false
718    }
719
720    /// Save a buffer to a specific file path
721    /// Used by :w filename to save unnamed buffers or save-as
722    pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
723        self.command_sender
724            .send(PluginCommand::SaveBufferToPath {
725                buffer_id: BufferId(buffer_id as usize),
726                path: std::path::PathBuf::from(path),
727            })
728            .is_ok()
729    }
730
731    /// Get buffer info by ID
732    #[plugin_api(ts_return = "BufferInfo | null")]
733    pub fn get_buffer_info<'js>(
734        &self,
735        ctx: rquickjs::Ctx<'js>,
736        buffer_id: u32,
737    ) -> rquickjs::Result<Value<'js>> {
738        let info = if let Ok(s) = self.state_snapshot.read() {
739            s.buffers.get(&BufferId(buffer_id as usize)).cloned()
740        } else {
741            None
742        };
743        rquickjs_serde::to_value(ctx, &info)
744            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
745    }
746
747    /// Get primary cursor info for active buffer
748    pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
749        let cursor = if let Ok(s) = self.state_snapshot.read() {
750            s.primary_cursor.clone()
751        } else {
752            None
753        };
754        rquickjs_serde::to_value(ctx, &cursor)
755            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
756    }
757
758    /// Get all cursors for active buffer
759    pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
760        let cursors = if let Ok(s) = self.state_snapshot.read() {
761            s.all_cursors.clone()
762        } else {
763            Vec::new()
764        };
765        rquickjs_serde::to_value(ctx, &cursors)
766            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
767    }
768
769    /// Get all cursor positions as byte offsets
770    pub fn get_all_cursor_positions<'js>(
771        &self,
772        ctx: rquickjs::Ctx<'js>,
773    ) -> rquickjs::Result<Value<'js>> {
774        let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
775            s.all_cursors.iter().map(|c| c.position as u32).collect()
776        } else {
777            Vec::new()
778        };
779        rquickjs_serde::to_value(ctx, &positions)
780            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
781    }
782
783    /// Get viewport info for active buffer
784    #[plugin_api(ts_return = "ViewportInfo | null")]
785    pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
786        let viewport = if let Ok(s) = self.state_snapshot.read() {
787            s.viewport.clone()
788        } else {
789            None
790        };
791        rquickjs_serde::to_value(ctx, &viewport)
792            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
793    }
794
795    /// Get the line number (0-indexed) of the primary cursor
796    pub fn get_cursor_line(&self) -> u32 {
797        // This would require line counting from the buffer
798        // For now, return 0 - proper implementation needs buffer access
799        // TODO: Add line number tracking to EditorStateSnapshot
800        0
801    }
802
803    /// Get the byte offset of the start of a line (0-indexed line number)
804    /// Returns null if the line number is out of range
805    #[plugin_api(
806        async_promise,
807        js_name = "getLineStartPosition",
808        ts_return = "number | null"
809    )]
810    #[qjs(rename = "_getLineStartPositionStart")]
811    pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
812        let id = {
813            let mut id_ref = self.next_request_id.borrow_mut();
814            let id = *id_ref;
815            *id_ref += 1;
816            // Record context for this callback
817            self.callback_contexts
818                .borrow_mut()
819                .insert(id, self.plugin_name.clone());
820            id
821        };
822        // Use buffer_id 0 for active buffer
823        let _ = self
824            .command_sender
825            .send(PluginCommand::GetLineStartPosition {
826                buffer_id: BufferId(0),
827                line,
828                request_id: id,
829            });
830        id
831    }
832
833    /// Get the byte offset of the end of a line (0-indexed line number)
834    /// Returns the position after the last character of the line (before newline)
835    /// Returns null if the line number is out of range
836    #[plugin_api(
837        async_promise,
838        js_name = "getLineEndPosition",
839        ts_return = "number | null"
840    )]
841    #[qjs(rename = "_getLineEndPositionStart")]
842    pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
843        let id = {
844            let mut id_ref = self.next_request_id.borrow_mut();
845            let id = *id_ref;
846            *id_ref += 1;
847            self.callback_contexts
848                .borrow_mut()
849                .insert(id, self.plugin_name.clone());
850            id
851        };
852        // Use buffer_id 0 for active buffer
853        let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
854            buffer_id: BufferId(0),
855            line,
856            request_id: id,
857        });
858        id
859    }
860
861    /// Get the total number of lines in the active buffer
862    /// Returns null if buffer not found
863    #[plugin_api(
864        async_promise,
865        js_name = "getBufferLineCount",
866        ts_return = "number | null"
867    )]
868    #[qjs(rename = "_getBufferLineCountStart")]
869    pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
870        let id = {
871            let mut id_ref = self.next_request_id.borrow_mut();
872            let id = *id_ref;
873            *id_ref += 1;
874            self.callback_contexts
875                .borrow_mut()
876                .insert(id, self.plugin_name.clone());
877            id
878        };
879        // Use buffer_id 0 for active buffer
880        let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
881            buffer_id: BufferId(0),
882            request_id: id,
883        });
884        id
885    }
886
887    /// Scroll a split to center a specific line in the viewport
888    /// Line is 0-indexed (0 = first line)
889    pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
890        self.command_sender
891            .send(PluginCommand::ScrollToLineCenter {
892                split_id: SplitId(split_id as usize),
893                buffer_id: BufferId(buffer_id as usize),
894                line: line as usize,
895            })
896            .is_ok()
897    }
898
899    /// Find buffer by file path, returns buffer ID or 0 if not found
900    pub fn find_buffer_by_path(&self, path: String) -> u32 {
901        let path_buf = std::path::PathBuf::from(&path);
902        if let Ok(s) = self.state_snapshot.read() {
903            for (id, info) in &s.buffers {
904                if let Some(buf_path) = &info.path {
905                    if buf_path == &path_buf {
906                        return id.0 as u32;
907                    }
908                }
909            }
910        }
911        0
912    }
913
914    /// Get diff between buffer content and last saved version
915    #[plugin_api(ts_return = "BufferSavedDiff | null")]
916    pub fn get_buffer_saved_diff<'js>(
917        &self,
918        ctx: rquickjs::Ctx<'js>,
919        buffer_id: u32,
920    ) -> rquickjs::Result<Value<'js>> {
921        let diff = if let Ok(s) = self.state_snapshot.read() {
922            s.buffer_saved_diffs
923                .get(&BufferId(buffer_id as usize))
924                .cloned()
925        } else {
926            None
927        };
928        rquickjs_serde::to_value(ctx, &diff)
929            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
930    }
931
932    // === Text Editing ===
933
934    /// Insert text at a position in a buffer
935    pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
936        self.command_sender
937            .send(PluginCommand::InsertText {
938                buffer_id: BufferId(buffer_id as usize),
939                position: position as usize,
940                text,
941            })
942            .is_ok()
943    }
944
945    /// Delete a range from a buffer
946    pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
947        self.command_sender
948            .send(PluginCommand::DeleteRange {
949                buffer_id: BufferId(buffer_id as usize),
950                range: (start as usize)..(end as usize),
951            })
952            .is_ok()
953    }
954
955    /// Insert text at cursor position in active buffer
956    pub fn insert_at_cursor(&self, text: String) -> bool {
957        self.command_sender
958            .send(PluginCommand::InsertAtCursor { text })
959            .is_ok()
960    }
961
962    // === File Operations ===
963
964    /// Open a file, optionally at a specific line/column
965    pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
966        self.command_sender
967            .send(PluginCommand::OpenFileAtLocation {
968                path: PathBuf::from(path),
969                line: line.map(|l| l as usize),
970                column: column.map(|c| c as usize),
971            })
972            .is_ok()
973    }
974
975    /// Open a file in a specific split
976    pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
977        self.command_sender
978            .send(PluginCommand::OpenFileInSplit {
979                split_id: split_id as usize,
980                path: PathBuf::from(path),
981                line: Some(line as usize),
982                column: Some(column as usize),
983            })
984            .is_ok()
985    }
986
987    /// Show a buffer in the current split
988    pub fn show_buffer(&self, buffer_id: u32) -> bool {
989        self.command_sender
990            .send(PluginCommand::ShowBuffer {
991                buffer_id: BufferId(buffer_id as usize),
992            })
993            .is_ok()
994    }
995
996    /// Close a buffer
997    pub fn close_buffer(&self, buffer_id: u32) -> bool {
998        self.command_sender
999            .send(PluginCommand::CloseBuffer {
1000                buffer_id: BufferId(buffer_id as usize),
1001            })
1002            .is_ok()
1003    }
1004
1005    // === Event Handling ===
1006
1007    /// Subscribe to an editor event
1008    pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1009        self.event_handlers
1010            .borrow_mut()
1011            .entry(event_name)
1012            .or_default()
1013            .push(PluginHandler {
1014                plugin_name: self.plugin_name.clone(),
1015                handler_name,
1016            });
1017    }
1018
1019    /// Unsubscribe from an event
1020    pub fn off(&self, event_name: String, handler_name: String) {
1021        if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1022            list.retain(|h| h.handler_name != handler_name);
1023        }
1024    }
1025
1026    // === Environment ===
1027
1028    /// Get an environment variable
1029    pub fn get_env(&self, name: String) -> Option<String> {
1030        std::env::var(&name).ok()
1031    }
1032
1033    /// Get current working directory
1034    pub fn get_cwd(&self) -> String {
1035        self.state_snapshot
1036            .read()
1037            .map(|s| s.working_dir.to_string_lossy().to_string())
1038            .unwrap_or_else(|_| ".".to_string())
1039    }
1040
1041    // === Path Operations ===
1042
1043    /// Join path components (variadic - accepts multiple string arguments)
1044    /// Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
1045    pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1046        let mut result_parts: Vec<String> = Vec::new();
1047        let mut has_leading_slash = false;
1048
1049        for part in &parts.0 {
1050            // Normalize separators to forward slashes
1051            let normalized = part.replace('\\', "/");
1052
1053            // Check if this is an absolute path (starts with / or has drive letter like C:/)
1054            let is_absolute = normalized.starts_with('/')
1055                || (normalized.len() >= 2
1056                    && normalized
1057                        .chars()
1058                        .next()
1059                        .map(|c| c.is_ascii_alphabetic())
1060                        .unwrap_or(false)
1061                    && normalized.chars().nth(1) == Some(':'));
1062
1063            if is_absolute {
1064                // Reset for absolute paths
1065                result_parts.clear();
1066                has_leading_slash = normalized.starts_with('/');
1067            }
1068
1069            // Split and add non-empty parts
1070            for segment in normalized.split('/') {
1071                if !segment.is_empty() && segment != "." {
1072                    if segment == ".." {
1073                        result_parts.pop();
1074                    } else {
1075                        result_parts.push(segment.to_string());
1076                    }
1077                }
1078            }
1079        }
1080
1081        // Reconstruct with forward slashes
1082        let joined = result_parts.join("/");
1083
1084        // Preserve leading slash for Unix absolute paths
1085        if has_leading_slash && !joined.is_empty() {
1086            format!("/{}", joined)
1087        } else {
1088            joined
1089        }
1090    }
1091
1092    /// Get directory name from path
1093    pub fn path_dirname(&self, path: String) -> String {
1094        Path::new(&path)
1095            .parent()
1096            .map(|p| p.to_string_lossy().to_string())
1097            .unwrap_or_default()
1098    }
1099
1100    /// Get file name from path
1101    pub fn path_basename(&self, path: String) -> String {
1102        Path::new(&path)
1103            .file_name()
1104            .map(|s| s.to_string_lossy().to_string())
1105            .unwrap_or_default()
1106    }
1107
1108    /// Get file extension
1109    pub fn path_extname(&self, path: String) -> String {
1110        Path::new(&path)
1111            .extension()
1112            .map(|s| format!(".{}", s.to_string_lossy()))
1113            .unwrap_or_default()
1114    }
1115
1116    /// Check if path is absolute
1117    pub fn path_is_absolute(&self, path: String) -> bool {
1118        Path::new(&path).is_absolute()
1119    }
1120
1121    // === File System ===
1122
1123    /// Check if file exists
1124    pub fn file_exists(&self, path: String) -> bool {
1125        Path::new(&path).exists()
1126    }
1127
1128    /// Read file contents
1129    pub fn read_file(&self, path: String) -> Option<String> {
1130        std::fs::read_to_string(&path).ok()
1131    }
1132
1133    /// Write file contents
1134    pub fn write_file(&self, path: String, content: String) -> bool {
1135        std::fs::write(&path, content).is_ok()
1136    }
1137
1138    /// Read directory contents (returns array of {name, is_file, is_dir})
1139    #[plugin_api(ts_return = "DirEntry[]")]
1140    pub fn read_dir<'js>(
1141        &self,
1142        ctx: rquickjs::Ctx<'js>,
1143        path: String,
1144    ) -> rquickjs::Result<Value<'js>> {
1145        use fresh_core::api::DirEntry;
1146
1147        let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1148            Ok(entries) => entries
1149                .filter_map(|e| e.ok())
1150                .map(|entry| {
1151                    let file_type = entry.file_type().ok();
1152                    DirEntry {
1153                        name: entry.file_name().to_string_lossy().to_string(),
1154                        is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1155                        is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1156                    }
1157                })
1158                .collect(),
1159            Err(e) => {
1160                tracing::warn!("readDir failed for '{}': {}", path, e);
1161                Vec::new()
1162            }
1163        };
1164
1165        rquickjs_serde::to_value(ctx, &entries)
1166            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1167    }
1168
1169    // === Config ===
1170
1171    /// Get current config as JS object
1172    pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1173        let config: serde_json::Value = self
1174            .state_snapshot
1175            .read()
1176            .map(|s| s.config.clone())
1177            .unwrap_or_else(|_| serde_json::json!({}));
1178
1179        rquickjs_serde::to_value(ctx, &config)
1180            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1181    }
1182
1183    /// Get user config as JS object
1184    pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1185        let config: serde_json::Value = self
1186            .state_snapshot
1187            .read()
1188            .map(|s| s.user_config.clone())
1189            .unwrap_or_else(|_| serde_json::json!({}));
1190
1191        rquickjs_serde::to_value(ctx, &config)
1192            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1193    }
1194
1195    /// Reload configuration from file
1196    pub fn reload_config(&self) {
1197        let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1198    }
1199
1200    /// Reload theme registry from disk
1201    /// Call this after installing theme packages or saving new themes
1202    pub fn reload_themes(&self) {
1203        let _ = self.command_sender.send(PluginCommand::ReloadThemes);
1204    }
1205
1206    /// Register a TextMate grammar file for a language
1207    /// The grammar will be pending until reload_grammars() is called
1208    pub fn register_grammar(
1209        &self,
1210        language: String,
1211        grammar_path: String,
1212        extensions: Vec<String>,
1213    ) -> bool {
1214        self.command_sender
1215            .send(PluginCommand::RegisterGrammar {
1216                language,
1217                grammar_path,
1218                extensions,
1219            })
1220            .is_ok()
1221    }
1222
1223    /// Register language configuration (comment prefix, indentation, formatter)
1224    pub fn register_language_config(&self, language: String, config: LanguagePackConfig) -> bool {
1225        self.command_sender
1226            .send(PluginCommand::RegisterLanguageConfig { language, config })
1227            .is_ok()
1228    }
1229
1230    /// Register an LSP server for a language
1231    pub fn register_lsp_server(&self, language: String, config: LspServerPackConfig) -> bool {
1232        self.command_sender
1233            .send(PluginCommand::RegisterLspServer { language, config })
1234            .is_ok()
1235    }
1236
1237    /// Reload the grammar registry to apply registered grammars
1238    /// Call this after registering one or more grammars
1239    pub fn reload_grammars(&self) {
1240        let _ = self.command_sender.send(PluginCommand::ReloadGrammars);
1241    }
1242
1243    /// Get config directory path
1244    pub fn get_config_dir(&self) -> String {
1245        self.services.config_dir().to_string_lossy().to_string()
1246    }
1247
1248    /// Get themes directory path
1249    pub fn get_themes_dir(&self) -> String {
1250        self.services
1251            .config_dir()
1252            .join("themes")
1253            .to_string_lossy()
1254            .to_string()
1255    }
1256
1257    /// Apply a theme by name
1258    pub fn apply_theme(&self, theme_name: String) -> bool {
1259        self.command_sender
1260            .send(PluginCommand::ApplyTheme { theme_name })
1261            .is_ok()
1262    }
1263
1264    /// Get theme schema as JS object
1265    pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1266        let schema = self.services.get_theme_schema();
1267        rquickjs_serde::to_value(ctx, &schema)
1268            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1269    }
1270
1271    /// Get list of builtin themes as JS object
1272    pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1273        let themes = self.services.get_builtin_themes();
1274        rquickjs_serde::to_value(ctx, &themes)
1275            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1276    }
1277
1278    /// Delete a custom theme file (sync)
1279    #[qjs(rename = "_deleteThemeSync")]
1280    pub fn delete_theme_sync(&self, name: String) -> bool {
1281        // Security: only allow deleting from the themes directory
1282        let themes_dir = self.services.config_dir().join("themes");
1283        let theme_path = themes_dir.join(format!("{}.json", name));
1284
1285        // Verify the file is actually in the themes directory (prevent path traversal)
1286        if let Ok(canonical) = theme_path.canonicalize() {
1287            if let Ok(themes_canonical) = themes_dir.canonicalize() {
1288                if canonical.starts_with(&themes_canonical) {
1289                    return std::fs::remove_file(&canonical).is_ok();
1290                }
1291            }
1292        }
1293        false
1294    }
1295
1296    /// Delete a custom theme (alias for deleteThemeSync)
1297    pub fn delete_theme(&self, name: String) -> bool {
1298        self.delete_theme_sync(name)
1299    }
1300
1301    // === File Stats ===
1302
1303    /// Get file stat information
1304    pub fn file_stat<'js>(
1305        &self,
1306        ctx: rquickjs::Ctx<'js>,
1307        path: String,
1308    ) -> rquickjs::Result<Value<'js>> {
1309        let metadata = std::fs::metadata(&path).ok();
1310        let stat = metadata.map(|m| {
1311            serde_json::json!({
1312                "isFile": m.is_file(),
1313                "isDir": m.is_dir(),
1314                "size": m.len(),
1315                "readonly": m.permissions().readonly(),
1316            })
1317        });
1318        rquickjs_serde::to_value(ctx, &stat)
1319            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1320    }
1321
1322    // === Process Management ===
1323
1324    /// Check if a background process is still running
1325    pub fn is_process_running(&self, _process_id: u64) -> bool {
1326        // This would need to check against tracked processes
1327        // For now, return false - proper implementation needs process tracking
1328        false
1329    }
1330
1331    /// Kill a process by ID (alias for killBackgroundProcess)
1332    pub fn kill_process(&self, process_id: u64) -> bool {
1333        self.command_sender
1334            .send(PluginCommand::KillBackgroundProcess { process_id })
1335            .is_ok()
1336    }
1337
1338    // === Translation ===
1339
1340    /// Translate a key for a specific plugin
1341    pub fn plugin_translate<'js>(
1342        &self,
1343        _ctx: rquickjs::Ctx<'js>,
1344        plugin_name: String,
1345        key: String,
1346        args: rquickjs::function::Opt<rquickjs::Object<'js>>,
1347    ) -> String {
1348        let args_map: HashMap<String, String> = args
1349            .0
1350            .map(|obj| {
1351                let mut map = HashMap::new();
1352                for (k, v) in obj.props::<String, String>().flatten() {
1353                    map.insert(k, v);
1354                }
1355                map
1356            })
1357            .unwrap_or_default();
1358
1359        self.services.translate(&plugin_name, &key, &args_map)
1360    }
1361
1362    // === Composite Buffers ===
1363
1364    /// Create a composite buffer (async)
1365    ///
1366    /// Uses typed CreateCompositeBufferOptions - serde validates field names at runtime
1367    /// via `deny_unknown_fields` attribute
1368    #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
1369    #[qjs(rename = "_createCompositeBufferStart")]
1370    pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
1371        let id = {
1372            let mut id_ref = self.next_request_id.borrow_mut();
1373            let id = *id_ref;
1374            *id_ref += 1;
1375            // Record context for this callback
1376            self.callback_contexts
1377                .borrow_mut()
1378                .insert(id, self.plugin_name.clone());
1379            id
1380        };
1381
1382        let _ = self
1383            .command_sender
1384            .send(PluginCommand::CreateCompositeBuffer {
1385                name: opts.name,
1386                mode: opts.mode,
1387                layout: opts.layout,
1388                sources: opts.sources,
1389                hunks: opts.hunks,
1390                request_id: Some(id),
1391            });
1392
1393        id
1394    }
1395
1396    /// Update alignment hunks for a composite buffer
1397    ///
1398    /// Uses typed Vec<CompositeHunk> - serde validates field names at runtime
1399    pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
1400        self.command_sender
1401            .send(PluginCommand::UpdateCompositeAlignment {
1402                buffer_id: BufferId(buffer_id as usize),
1403                hunks,
1404            })
1405            .is_ok()
1406    }
1407
1408    /// Close a composite buffer
1409    pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
1410        self.command_sender
1411            .send(PluginCommand::CloseCompositeBuffer {
1412                buffer_id: BufferId(buffer_id as usize),
1413            })
1414            .is_ok()
1415    }
1416
1417    // === Highlights ===
1418
1419    /// Request syntax highlights for a buffer range (async)
1420    #[plugin_api(
1421        async_promise,
1422        js_name = "getHighlights",
1423        ts_return = "TsHighlightSpan[]"
1424    )]
1425    #[qjs(rename = "_getHighlightsStart")]
1426    pub fn get_highlights_start<'js>(
1427        &self,
1428        _ctx: rquickjs::Ctx<'js>,
1429        buffer_id: u32,
1430        start: u32,
1431        end: u32,
1432    ) -> rquickjs::Result<u64> {
1433        let id = {
1434            let mut id_ref = self.next_request_id.borrow_mut();
1435            let id = *id_ref;
1436            *id_ref += 1;
1437            // Record plugin name for this callback
1438            self.callback_contexts
1439                .borrow_mut()
1440                .insert(id, self.plugin_name.clone());
1441            id
1442        };
1443
1444        let _ = self.command_sender.send(PluginCommand::RequestHighlights {
1445            buffer_id: BufferId(buffer_id as usize),
1446            range: (start as usize)..(end as usize),
1447            request_id: id,
1448        });
1449
1450        Ok(id)
1451    }
1452
1453    // === Overlays ===
1454
1455    /// Add an overlay with styling options
1456    ///
1457    /// Colors can be specified as RGB arrays `[r, g, b]` or theme key strings.
1458    /// Theme keys are resolved at render time, so overlays update with theme changes.
1459    ///
1460    /// Theme key examples: "ui.status_bar_fg", "editor.selection_bg", "syntax.keyword"
1461    ///
1462    /// Example usage in TypeScript:
1463    /// ```typescript
1464    /// editor.addOverlay(bufferId, "my-namespace", 0, 10, {
1465    ///   fg: "syntax.keyword",           // theme key
1466    ///   bg: [40, 40, 50],               // RGB array
1467    ///   bold: true,
1468    /// });
1469    /// ```
1470    pub fn add_overlay<'js>(
1471        &self,
1472        _ctx: rquickjs::Ctx<'js>,
1473        buffer_id: u32,
1474        namespace: String,
1475        start: u32,
1476        end: u32,
1477        options: rquickjs::Object<'js>,
1478    ) -> rquickjs::Result<bool> {
1479        use fresh_core::api::OverlayColorSpec;
1480
1481        // Parse color spec from JS value (can be [r,g,b] array or "theme.key" string)
1482        fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
1483            // Try as string first (theme key)
1484            if let Ok(theme_key) = obj.get::<_, String>(key) {
1485                if !theme_key.is_empty() {
1486                    return Some(OverlayColorSpec::ThemeKey(theme_key));
1487                }
1488            }
1489            // Try as array [r, g, b]
1490            if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
1491                if arr.len() >= 3 {
1492                    return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
1493                }
1494            }
1495            None
1496        }
1497
1498        let fg = parse_color_spec("fg", &options);
1499        let bg = parse_color_spec("bg", &options);
1500        let underline: bool = options.get("underline").unwrap_or(false);
1501        let bold: bool = options.get("bold").unwrap_or(false);
1502        let italic: bool = options.get("italic").unwrap_or(false);
1503        let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
1504
1505        let options = OverlayOptions {
1506            fg,
1507            bg,
1508            underline,
1509            bold,
1510            italic,
1511            extend_to_line_end,
1512        };
1513
1514        let _ = self.command_sender.send(PluginCommand::AddOverlay {
1515            buffer_id: BufferId(buffer_id as usize),
1516            namespace: Some(OverlayNamespace::from_string(namespace)),
1517            range: (start as usize)..(end as usize),
1518            options,
1519        });
1520
1521        Ok(true)
1522    }
1523
1524    /// Clear all overlays in a namespace
1525    pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1526        self.command_sender
1527            .send(PluginCommand::ClearNamespace {
1528                buffer_id: BufferId(buffer_id as usize),
1529                namespace: OverlayNamespace::from_string(namespace),
1530            })
1531            .is_ok()
1532    }
1533
1534    /// Clear all overlays from a buffer
1535    pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
1536        self.command_sender
1537            .send(PluginCommand::ClearAllOverlays {
1538                buffer_id: BufferId(buffer_id as usize),
1539            })
1540            .is_ok()
1541    }
1542
1543    /// Clear all overlays that overlap with a byte range
1544    pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1545        self.command_sender
1546            .send(PluginCommand::ClearOverlaysInRange {
1547                buffer_id: BufferId(buffer_id as usize),
1548                start: start as usize,
1549                end: end as usize,
1550            })
1551            .is_ok()
1552    }
1553
1554    /// Remove an overlay by its handle
1555    pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
1556        use fresh_core::overlay::OverlayHandle;
1557        self.command_sender
1558            .send(PluginCommand::RemoveOverlay {
1559                buffer_id: BufferId(buffer_id as usize),
1560                handle: OverlayHandle(handle),
1561            })
1562            .is_ok()
1563    }
1564
1565    // === View Transform ===
1566
1567    /// Submit a view transform for a buffer/split
1568    ///
1569    /// Accepts tokens in the simple format:
1570    ///   {kind: "text"|"newline"|"space"|"break", text: "...", sourceOffset: N, style?: {...}}
1571    ///
1572    /// Also accepts the TypeScript-defined format for backwards compatibility:
1573    ///   {kind: {Text: "..."} | "Newline" | "Space" | "Break", source_offset: N, style?: {...}}
1574    #[allow(clippy::too_many_arguments)]
1575    pub fn submit_view_transform<'js>(
1576        &self,
1577        _ctx: rquickjs::Ctx<'js>,
1578        buffer_id: u32,
1579        split_id: Option<u32>,
1580        start: u32,
1581        end: u32,
1582        tokens: Vec<rquickjs::Object<'js>>,
1583        layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
1584    ) -> rquickjs::Result<bool> {
1585        use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
1586
1587        let tokens: Vec<ViewTokenWire> = tokens
1588            .into_iter()
1589            .enumerate()
1590            .map(|(idx, obj)| {
1591                // Try to parse the token, with detailed error messages
1592                parse_view_token(&obj, idx)
1593            })
1594            .collect::<rquickjs::Result<Vec<_>>>()?;
1595
1596        // Parse layout hints if provided
1597        let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
1598            let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
1599            let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
1600            Some(LayoutHints {
1601                compose_width,
1602                column_guides,
1603            })
1604        } else {
1605            None
1606        };
1607
1608        let payload = ViewTransformPayload {
1609            range: (start as usize)..(end as usize),
1610            tokens,
1611            layout_hints: parsed_layout_hints,
1612        };
1613
1614        Ok(self
1615            .command_sender
1616            .send(PluginCommand::SubmitViewTransform {
1617                buffer_id: BufferId(buffer_id as usize),
1618                split_id: split_id.map(|id| SplitId(id as usize)),
1619                payload,
1620            })
1621            .is_ok())
1622    }
1623
1624    /// Clear view transform for a buffer/split
1625    pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
1626        self.command_sender
1627            .send(PluginCommand::ClearViewTransform {
1628                buffer_id: BufferId(buffer_id as usize),
1629                split_id: split_id.map(|id| SplitId(id as usize)),
1630            })
1631            .is_ok()
1632    }
1633
1634    // === File Explorer ===
1635
1636    /// Set file explorer decorations for a namespace
1637    pub fn set_file_explorer_decorations<'js>(
1638        &self,
1639        _ctx: rquickjs::Ctx<'js>,
1640        namespace: String,
1641        decorations: Vec<rquickjs::Object<'js>>,
1642    ) -> rquickjs::Result<bool> {
1643        use fresh_core::file_explorer::FileExplorerDecoration;
1644
1645        let decorations: Vec<FileExplorerDecoration> = decorations
1646            .into_iter()
1647            .map(|obj| {
1648                let path: String = obj.get("path")?;
1649                let symbol: String = obj.get("symbol")?;
1650                let color: Vec<u8> = obj.get("color")?;
1651                let priority: i32 = obj.get("priority").unwrap_or(0);
1652
1653                if color.len() < 3 {
1654                    return Err(rquickjs::Error::FromJs {
1655                        from: "array",
1656                        to: "color",
1657                        message: Some(format!(
1658                            "color array must have at least 3 elements, got {}",
1659                            color.len()
1660                        )),
1661                    });
1662                }
1663
1664                Ok(FileExplorerDecoration {
1665                    path: std::path::PathBuf::from(path),
1666                    symbol,
1667                    color: [color[0], color[1], color[2]],
1668                    priority,
1669                })
1670            })
1671            .collect::<rquickjs::Result<Vec<_>>>()?;
1672
1673        Ok(self
1674            .command_sender
1675            .send(PluginCommand::SetFileExplorerDecorations {
1676                namespace,
1677                decorations,
1678            })
1679            .is_ok())
1680    }
1681
1682    /// Clear file explorer decorations for a namespace
1683    pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
1684        self.command_sender
1685            .send(PluginCommand::ClearFileExplorerDecorations { namespace })
1686            .is_ok()
1687    }
1688
1689    // === Virtual Text ===
1690
1691    /// Add virtual text (inline text that doesn't exist in the buffer)
1692    #[allow(clippy::too_many_arguments)]
1693    pub fn add_virtual_text(
1694        &self,
1695        buffer_id: u32,
1696        virtual_text_id: String,
1697        position: u32,
1698        text: String,
1699        r: u8,
1700        g: u8,
1701        b: u8,
1702        before: bool,
1703        use_bg: bool,
1704    ) -> bool {
1705        self.command_sender
1706            .send(PluginCommand::AddVirtualText {
1707                buffer_id: BufferId(buffer_id as usize),
1708                virtual_text_id,
1709                position: position as usize,
1710                text,
1711                color: (r, g, b),
1712                use_bg,
1713                before,
1714            })
1715            .is_ok()
1716    }
1717
1718    /// Remove a virtual text by ID
1719    pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
1720        self.command_sender
1721            .send(PluginCommand::RemoveVirtualText {
1722                buffer_id: BufferId(buffer_id as usize),
1723                virtual_text_id,
1724            })
1725            .is_ok()
1726    }
1727
1728    /// Remove virtual texts whose ID starts with the given prefix
1729    pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
1730        self.command_sender
1731            .send(PluginCommand::RemoveVirtualTextsByPrefix {
1732                buffer_id: BufferId(buffer_id as usize),
1733                prefix,
1734            })
1735            .is_ok()
1736    }
1737
1738    /// Clear all virtual texts from a buffer
1739    pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
1740        self.command_sender
1741            .send(PluginCommand::ClearVirtualTexts {
1742                buffer_id: BufferId(buffer_id as usize),
1743            })
1744            .is_ok()
1745    }
1746
1747    /// Clear all virtual texts in a namespace
1748    pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
1749        self.command_sender
1750            .send(PluginCommand::ClearVirtualTextNamespace {
1751                buffer_id: BufferId(buffer_id as usize),
1752                namespace,
1753            })
1754            .is_ok()
1755    }
1756
1757    /// Add a virtual line (full line above/below a position)
1758    #[allow(clippy::too_many_arguments)]
1759    pub fn add_virtual_line(
1760        &self,
1761        buffer_id: u32,
1762        position: u32,
1763        text: String,
1764        fg_r: u8,
1765        fg_g: u8,
1766        fg_b: u8,
1767        bg_r: u8,
1768        bg_g: u8,
1769        bg_b: u8,
1770        above: bool,
1771        namespace: String,
1772        priority: i32,
1773    ) -> bool {
1774        self.command_sender
1775            .send(PluginCommand::AddVirtualLine {
1776                buffer_id: BufferId(buffer_id as usize),
1777                position: position as usize,
1778                text,
1779                fg_color: (fg_r, fg_g, fg_b),
1780                bg_color: Some((bg_r, bg_g, bg_b)),
1781                above,
1782                namespace,
1783                priority,
1784            })
1785            .is_ok()
1786    }
1787
1788    // === Prompts ===
1789
1790    /// Show a prompt and wait for user input (async)
1791    /// Returns the user input or null if cancelled
1792    #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
1793    #[qjs(rename = "_promptStart")]
1794    pub fn prompt_start(
1795        &self,
1796        _ctx: rquickjs::Ctx<'_>,
1797        label: String,
1798        initial_value: String,
1799    ) -> u64 {
1800        let id = {
1801            let mut id_ref = self.next_request_id.borrow_mut();
1802            let id = *id_ref;
1803            *id_ref += 1;
1804            // Record context for this callback
1805            self.callback_contexts
1806                .borrow_mut()
1807                .insert(id, self.plugin_name.clone());
1808            id
1809        };
1810
1811        let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
1812            label,
1813            initial_value,
1814            callback_id: JsCallbackId::new(id),
1815        });
1816
1817        id
1818    }
1819
1820    /// Start an interactive prompt
1821    pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
1822        self.command_sender
1823            .send(PluginCommand::StartPrompt { label, prompt_type })
1824            .is_ok()
1825    }
1826
1827    /// Start a prompt with initial value
1828    pub fn start_prompt_with_initial(
1829        &self,
1830        label: String,
1831        prompt_type: String,
1832        initial_value: String,
1833    ) -> bool {
1834        self.command_sender
1835            .send(PluginCommand::StartPromptWithInitial {
1836                label,
1837                prompt_type,
1838                initial_value,
1839            })
1840            .is_ok()
1841    }
1842
1843    /// Set suggestions for the current prompt
1844    ///
1845    /// Uses typed Vec<Suggestion> - serde validates field names at runtime
1846    pub fn set_prompt_suggestions(
1847        &self,
1848        suggestions: Vec<fresh_core::command::Suggestion>,
1849    ) -> bool {
1850        self.command_sender
1851            .send(PluginCommand::SetPromptSuggestions { suggestions })
1852            .is_ok()
1853    }
1854
1855    // === Modes ===
1856
1857    /// Define a buffer mode (takes bindings as array of [key, command] pairs)
1858    pub fn define_mode(
1859        &self,
1860        name: String,
1861        parent: Option<String>,
1862        bindings_arr: Vec<Vec<String>>,
1863        read_only: rquickjs::function::Opt<bool>,
1864    ) -> bool {
1865        let bindings: Vec<(String, String)> = bindings_arr
1866            .into_iter()
1867            .filter_map(|arr| {
1868                if arr.len() >= 2 {
1869                    Some((arr[0].clone(), arr[1].clone()))
1870                } else {
1871                    None
1872                }
1873            })
1874            .collect();
1875
1876        // Register commands associated with this mode so start_action can find them
1877        // and execute them in the correct plugin context
1878        {
1879            let mut registered = self.registered_actions.borrow_mut();
1880            for (_, cmd_name) in &bindings {
1881                registered.insert(
1882                    cmd_name.clone(),
1883                    PluginHandler {
1884                        plugin_name: self.plugin_name.clone(),
1885                        handler_name: cmd_name.clone(),
1886                    },
1887                );
1888            }
1889        }
1890
1891        self.command_sender
1892            .send(PluginCommand::DefineMode {
1893                name,
1894                parent,
1895                bindings,
1896                read_only: read_only.0.unwrap_or(false),
1897            })
1898            .is_ok()
1899    }
1900
1901    /// Set the global editor mode
1902    pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
1903        self.command_sender
1904            .send(PluginCommand::SetEditorMode { mode })
1905            .is_ok()
1906    }
1907
1908    /// Get the current editor mode
1909    pub fn get_editor_mode(&self) -> Option<String> {
1910        self.state_snapshot
1911            .read()
1912            .ok()
1913            .and_then(|s| s.editor_mode.clone())
1914    }
1915
1916    // === Splits ===
1917
1918    /// Close a split
1919    pub fn close_split(&self, split_id: u32) -> bool {
1920        self.command_sender
1921            .send(PluginCommand::CloseSplit {
1922                split_id: SplitId(split_id as usize),
1923            })
1924            .is_ok()
1925    }
1926
1927    /// Set the buffer displayed in a split
1928    pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
1929        self.command_sender
1930            .send(PluginCommand::SetSplitBuffer {
1931                split_id: SplitId(split_id as usize),
1932                buffer_id: BufferId(buffer_id as usize),
1933            })
1934            .is_ok()
1935    }
1936
1937    /// Focus a specific split
1938    pub fn focus_split(&self, split_id: u32) -> bool {
1939        self.command_sender
1940            .send(PluginCommand::FocusSplit {
1941                split_id: SplitId(split_id as usize),
1942            })
1943            .is_ok()
1944    }
1945
1946    /// Set scroll position of a split
1947    pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
1948        self.command_sender
1949            .send(PluginCommand::SetSplitScroll {
1950                split_id: SplitId(split_id as usize),
1951                top_byte: top_byte as usize,
1952            })
1953            .is_ok()
1954    }
1955
1956    /// Set the ratio of a split (0.0 to 1.0, 0.5 = equal)
1957    pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
1958        self.command_sender
1959            .send(PluginCommand::SetSplitRatio {
1960                split_id: SplitId(split_id as usize),
1961                ratio,
1962            })
1963            .is_ok()
1964    }
1965
1966    /// Distribute all splits evenly
1967    pub fn distribute_splits_evenly(&self) -> bool {
1968        // Get all split IDs - for now send empty vec (app will handle)
1969        self.command_sender
1970            .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
1971            .is_ok()
1972    }
1973
1974    /// Set cursor position in a buffer
1975    pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
1976        self.command_sender
1977            .send(PluginCommand::SetBufferCursor {
1978                buffer_id: BufferId(buffer_id as usize),
1979                position: position as usize,
1980            })
1981            .is_ok()
1982    }
1983
1984    // === Line Indicators ===
1985
1986    /// Set a line indicator in the gutter
1987    #[allow(clippy::too_many_arguments)]
1988    pub fn set_line_indicator(
1989        &self,
1990        buffer_id: u32,
1991        line: u32,
1992        namespace: String,
1993        symbol: String,
1994        r: u8,
1995        g: u8,
1996        b: u8,
1997        priority: i32,
1998    ) -> bool {
1999        self.command_sender
2000            .send(PluginCommand::SetLineIndicator {
2001                buffer_id: BufferId(buffer_id as usize),
2002                line: line as usize,
2003                namespace,
2004                symbol,
2005                color: (r, g, b),
2006                priority,
2007            })
2008            .is_ok()
2009    }
2010
2011    /// Clear line indicators in a namespace
2012    pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
2013        self.command_sender
2014            .send(PluginCommand::ClearLineIndicators {
2015                buffer_id: BufferId(buffer_id as usize),
2016                namespace,
2017            })
2018            .is_ok()
2019    }
2020
2021    /// Enable or disable line numbers for a buffer
2022    pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
2023        self.command_sender
2024            .send(PluginCommand::SetLineNumbers {
2025                buffer_id: BufferId(buffer_id as usize),
2026                enabled,
2027            })
2028            .is_ok()
2029    }
2030
2031    /// Enable or disable line wrapping for a buffer/split
2032    pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
2033        self.command_sender
2034            .send(PluginCommand::SetLineWrap {
2035                buffer_id: BufferId(buffer_id as usize),
2036                split_id: split_id.map(|s| SplitId(s as usize)),
2037                enabled,
2038            })
2039            .is_ok()
2040    }
2041
2042    // === Scroll Sync ===
2043
2044    /// Create a scroll sync group for anchor-based synchronized scrolling
2045    pub fn create_scroll_sync_group(
2046        &self,
2047        group_id: u32,
2048        left_split: u32,
2049        right_split: u32,
2050    ) -> bool {
2051        self.command_sender
2052            .send(PluginCommand::CreateScrollSyncGroup {
2053                group_id,
2054                left_split: SplitId(left_split as usize),
2055                right_split: SplitId(right_split as usize),
2056            })
2057            .is_ok()
2058    }
2059
2060    /// Set sync anchors for a scroll sync group
2061    pub fn set_scroll_sync_anchors<'js>(
2062        &self,
2063        _ctx: rquickjs::Ctx<'js>,
2064        group_id: u32,
2065        anchors: Vec<Vec<u32>>,
2066    ) -> bool {
2067        let anchors: Vec<(usize, usize)> = anchors
2068            .into_iter()
2069            .filter_map(|pair| {
2070                if pair.len() >= 2 {
2071                    Some((pair[0] as usize, pair[1] as usize))
2072                } else {
2073                    None
2074                }
2075            })
2076            .collect();
2077        self.command_sender
2078            .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
2079            .is_ok()
2080    }
2081
2082    /// Remove a scroll sync group
2083    pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
2084        self.command_sender
2085            .send(PluginCommand::RemoveScrollSyncGroup { group_id })
2086            .is_ok()
2087    }
2088
2089    // === Actions ===
2090
2091    /// Execute multiple actions in sequence
2092    ///
2093    /// Takes typed ActionSpec array - serde validates field names at runtime
2094    pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
2095        self.command_sender
2096            .send(PluginCommand::ExecuteActions { actions })
2097            .is_ok()
2098    }
2099
2100    /// Show an action popup
2101    ///
2102    /// Takes a typed ActionPopupOptions struct - serde validates field names at runtime
2103    pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
2104        self.command_sender
2105            .send(PluginCommand::ShowActionPopup {
2106                popup_id: opts.id,
2107                title: opts.title,
2108                message: opts.message,
2109                actions: opts.actions,
2110            })
2111            .is_ok()
2112    }
2113
2114    /// Disable LSP for a specific language
2115    pub fn disable_lsp_for_language(&self, language: String) -> bool {
2116        self.command_sender
2117            .send(PluginCommand::DisableLspForLanguage { language })
2118            .is_ok()
2119    }
2120
2121    /// Set the workspace root URI for a specific language's LSP server
2122    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
2123    pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
2124        self.command_sender
2125            .send(PluginCommand::SetLspRootUri { language, uri })
2126            .is_ok()
2127    }
2128
2129    /// Get all diagnostics from LSP
2130    #[plugin_api(ts_return = "JsDiagnostic[]")]
2131    pub fn get_all_diagnostics<'js>(
2132        &self,
2133        ctx: rquickjs::Ctx<'js>,
2134    ) -> rquickjs::Result<Value<'js>> {
2135        use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
2136
2137        let diagnostics = if let Ok(s) = self.state_snapshot.read() {
2138            // Convert to JsDiagnostic format for JS
2139            let mut result: Vec<JsDiagnostic> = Vec::new();
2140            for (uri, diags) in &s.diagnostics {
2141                for diag in diags {
2142                    result.push(JsDiagnostic {
2143                        uri: uri.clone(),
2144                        message: diag.message.clone(),
2145                        severity: diag.severity.map(|s| match s {
2146                            lsp_types::DiagnosticSeverity::ERROR => 1,
2147                            lsp_types::DiagnosticSeverity::WARNING => 2,
2148                            lsp_types::DiagnosticSeverity::INFORMATION => 3,
2149                            lsp_types::DiagnosticSeverity::HINT => 4,
2150                            _ => 0,
2151                        }),
2152                        range: JsRange {
2153                            start: JsPosition {
2154                                line: diag.range.start.line,
2155                                character: diag.range.start.character,
2156                            },
2157                            end: JsPosition {
2158                                line: diag.range.end.line,
2159                                character: diag.range.end.character,
2160                            },
2161                        },
2162                        source: diag.source.clone(),
2163                    });
2164                }
2165            }
2166            result
2167        } else {
2168            Vec::new()
2169        };
2170        rquickjs_serde::to_value(ctx, &diagnostics)
2171            .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2172    }
2173
2174    /// Get registered event handlers for an event
2175    pub fn get_handlers(&self, event_name: String) -> Vec<String> {
2176        self.event_handlers
2177            .borrow()
2178            .get(&event_name)
2179            .cloned()
2180            .unwrap_or_default()
2181            .into_iter()
2182            .map(|h| h.handler_name)
2183            .collect()
2184    }
2185
2186    // === Virtual Buffers ===
2187
2188    /// Create a virtual buffer in current split (async, returns buffer and split IDs)
2189    #[plugin_api(
2190        async_promise,
2191        js_name = "createVirtualBuffer",
2192        ts_return = "VirtualBufferResult"
2193    )]
2194    #[qjs(rename = "_createVirtualBufferStart")]
2195    pub fn create_virtual_buffer_start(
2196        &self,
2197        _ctx: rquickjs::Ctx<'_>,
2198        opts: fresh_core::api::CreateVirtualBufferOptions,
2199    ) -> rquickjs::Result<u64> {
2200        let id = {
2201            let mut id_ref = self.next_request_id.borrow_mut();
2202            let id = *id_ref;
2203            *id_ref += 1;
2204            // Record context for this callback
2205            self.callback_contexts
2206                .borrow_mut()
2207                .insert(id, self.plugin_name.clone());
2208            id
2209        };
2210
2211        // Convert JsTextPropertyEntry to TextPropertyEntry
2212        let entries: Vec<TextPropertyEntry> = opts
2213            .entries
2214            .unwrap_or_default()
2215            .into_iter()
2216            .map(|e| TextPropertyEntry {
2217                text: e.text,
2218                properties: e.properties.unwrap_or_default(),
2219            })
2220            .collect();
2221
2222        tracing::debug!(
2223            "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
2224            id
2225        );
2226        let _ = self
2227            .command_sender
2228            .send(PluginCommand::CreateVirtualBufferWithContent {
2229                name: opts.name,
2230                mode: opts.mode.unwrap_or_default(),
2231                read_only: opts.read_only.unwrap_or(false),
2232                entries,
2233                show_line_numbers: opts.show_line_numbers.unwrap_or(false),
2234                show_cursors: opts.show_cursors.unwrap_or(true),
2235                editing_disabled: opts.editing_disabled.unwrap_or(false),
2236                hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
2237                request_id: Some(id),
2238            });
2239        Ok(id)
2240    }
2241
2242    /// Create a virtual buffer in a new split (async, returns buffer and split IDs)
2243    #[plugin_api(
2244        async_promise,
2245        js_name = "createVirtualBufferInSplit",
2246        ts_return = "VirtualBufferResult"
2247    )]
2248    #[qjs(rename = "_createVirtualBufferInSplitStart")]
2249    pub fn create_virtual_buffer_in_split_start(
2250        &self,
2251        _ctx: rquickjs::Ctx<'_>,
2252        opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
2253    ) -> rquickjs::Result<u64> {
2254        let id = {
2255            let mut id_ref = self.next_request_id.borrow_mut();
2256            let id = *id_ref;
2257            *id_ref += 1;
2258            // Record context for this callback
2259            self.callback_contexts
2260                .borrow_mut()
2261                .insert(id, self.plugin_name.clone());
2262            id
2263        };
2264
2265        // Convert JsTextPropertyEntry to TextPropertyEntry
2266        let entries: Vec<TextPropertyEntry> = opts
2267            .entries
2268            .unwrap_or_default()
2269            .into_iter()
2270            .map(|e| TextPropertyEntry {
2271                text: e.text,
2272                properties: e.properties.unwrap_or_default(),
2273            })
2274            .collect();
2275
2276        let _ = self
2277            .command_sender
2278            .send(PluginCommand::CreateVirtualBufferInSplit {
2279                name: opts.name,
2280                mode: opts.mode.unwrap_or_default(),
2281                read_only: opts.read_only.unwrap_or(false),
2282                entries,
2283                ratio: opts.ratio.unwrap_or(0.5),
2284                direction: opts.direction,
2285                panel_id: opts.panel_id,
2286                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2287                show_cursors: opts.show_cursors.unwrap_or(true),
2288                editing_disabled: opts.editing_disabled.unwrap_or(false),
2289                line_wrap: opts.line_wrap,
2290                request_id: Some(id),
2291            });
2292        Ok(id)
2293    }
2294
2295    /// Create a virtual buffer in an existing split (async, returns buffer and split IDs)
2296    #[plugin_api(
2297        async_promise,
2298        js_name = "createVirtualBufferInExistingSplit",
2299        ts_return = "VirtualBufferResult"
2300    )]
2301    #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
2302    pub fn create_virtual_buffer_in_existing_split_start(
2303        &self,
2304        _ctx: rquickjs::Ctx<'_>,
2305        opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
2306    ) -> rquickjs::Result<u64> {
2307        let id = {
2308            let mut id_ref = self.next_request_id.borrow_mut();
2309            let id = *id_ref;
2310            *id_ref += 1;
2311            // Record context for this callback
2312            self.callback_contexts
2313                .borrow_mut()
2314                .insert(id, self.plugin_name.clone());
2315            id
2316        };
2317
2318        // Convert JsTextPropertyEntry to TextPropertyEntry
2319        let entries: Vec<TextPropertyEntry> = opts
2320            .entries
2321            .unwrap_or_default()
2322            .into_iter()
2323            .map(|e| TextPropertyEntry {
2324                text: e.text,
2325                properties: e.properties.unwrap_or_default(),
2326            })
2327            .collect();
2328
2329        let _ = self
2330            .command_sender
2331            .send(PluginCommand::CreateVirtualBufferInExistingSplit {
2332                name: opts.name,
2333                mode: opts.mode.unwrap_or_default(),
2334                read_only: opts.read_only.unwrap_or(false),
2335                entries,
2336                split_id: SplitId(opts.split_id),
2337                show_line_numbers: opts.show_line_numbers.unwrap_or(true),
2338                show_cursors: opts.show_cursors.unwrap_or(true),
2339                editing_disabled: opts.editing_disabled.unwrap_or(false),
2340                line_wrap: opts.line_wrap,
2341                request_id: Some(id),
2342            });
2343        Ok(id)
2344    }
2345
2346    /// Set virtual buffer content (takes array of entry objects)
2347    ///
2348    /// Note: entries should be TextPropertyEntry[] - uses manual parsing for HashMap support
2349    pub fn set_virtual_buffer_content<'js>(
2350        &self,
2351        ctx: rquickjs::Ctx<'js>,
2352        buffer_id: u32,
2353        entries_arr: Vec<rquickjs::Object<'js>>,
2354    ) -> rquickjs::Result<bool> {
2355        let entries: Vec<TextPropertyEntry> = entries_arr
2356            .iter()
2357            .filter_map(|obj| parse_text_property_entry(&ctx, obj))
2358            .collect();
2359        Ok(self
2360            .command_sender
2361            .send(PluginCommand::SetVirtualBufferContent {
2362                buffer_id: BufferId(buffer_id as usize),
2363                entries,
2364            })
2365            .is_ok())
2366    }
2367
2368    /// Get text properties at cursor position (returns JS array)
2369    pub fn get_text_properties_at_cursor(
2370        &self,
2371        buffer_id: u32,
2372    ) -> fresh_core::api::TextPropertiesAtCursor {
2373        get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
2374    }
2375
2376    // === Async Operations ===
2377
2378    /// Spawn a process (async, returns request_id)
2379    #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
2380    #[qjs(rename = "_spawnProcessStart")]
2381    pub fn spawn_process_start(
2382        &self,
2383        _ctx: rquickjs::Ctx<'_>,
2384        command: String,
2385        args: Vec<String>,
2386        cwd: rquickjs::function::Opt<String>,
2387    ) -> u64 {
2388        let id = {
2389            let mut id_ref = self.next_request_id.borrow_mut();
2390            let id = *id_ref;
2391            *id_ref += 1;
2392            // Record context for this callback
2393            self.callback_contexts
2394                .borrow_mut()
2395                .insert(id, self.plugin_name.clone());
2396            id
2397        };
2398        // Use provided cwd, or fall back to snapshot's working_dir
2399        let effective_cwd = cwd.0.or_else(|| {
2400            self.state_snapshot
2401                .read()
2402                .ok()
2403                .map(|s| s.working_dir.to_string_lossy().to_string())
2404        });
2405        tracing::info!(
2406            "spawn_process_start: command='{}', args={:?}, cwd={:?}, callback_id={}",
2407            command,
2408            args,
2409            effective_cwd,
2410            id
2411        );
2412        let _ = self.command_sender.send(PluginCommand::SpawnProcess {
2413            callback_id: JsCallbackId::new(id),
2414            command,
2415            args,
2416            cwd: effective_cwd,
2417        });
2418        id
2419    }
2420
2421    /// Wait for a process to complete and get its result (async)
2422    #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
2423    #[qjs(rename = "_spawnProcessWaitStart")]
2424    pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
2425        let id = {
2426            let mut id_ref = self.next_request_id.borrow_mut();
2427            let id = *id_ref;
2428            *id_ref += 1;
2429            // Record context for this callback
2430            self.callback_contexts
2431                .borrow_mut()
2432                .insert(id, self.plugin_name.clone());
2433            id
2434        };
2435        let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
2436            process_id,
2437            callback_id: JsCallbackId::new(id),
2438        });
2439        id
2440    }
2441
2442    /// Get buffer text range (async, returns request_id)
2443    #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
2444    #[qjs(rename = "_getBufferTextStart")]
2445    pub fn get_buffer_text_start(
2446        &self,
2447        _ctx: rquickjs::Ctx<'_>,
2448        buffer_id: u32,
2449        start: u32,
2450        end: u32,
2451    ) -> u64 {
2452        let id = {
2453            let mut id_ref = self.next_request_id.borrow_mut();
2454            let id = *id_ref;
2455            *id_ref += 1;
2456            // Record context for this callback
2457            self.callback_contexts
2458                .borrow_mut()
2459                .insert(id, self.plugin_name.clone());
2460            id
2461        };
2462        let _ = self.command_sender.send(PluginCommand::GetBufferText {
2463            buffer_id: BufferId(buffer_id as usize),
2464            start: start as usize,
2465            end: end as usize,
2466            request_id: id,
2467        });
2468        id
2469    }
2470
2471    /// Delay/sleep (async, returns request_id)
2472    #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
2473    #[qjs(rename = "_delayStart")]
2474    pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
2475        let id = {
2476            let mut id_ref = self.next_request_id.borrow_mut();
2477            let id = *id_ref;
2478            *id_ref += 1;
2479            // Record context for this callback
2480            self.callback_contexts
2481                .borrow_mut()
2482                .insert(id, self.plugin_name.clone());
2483            id
2484        };
2485        let _ = self.command_sender.send(PluginCommand::Delay {
2486            callback_id: JsCallbackId::new(id),
2487            duration_ms,
2488        });
2489        id
2490    }
2491
2492    /// Send LSP request (async, returns request_id)
2493    #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
2494    #[qjs(rename = "_sendLspRequestStart")]
2495    pub fn send_lsp_request_start<'js>(
2496        &self,
2497        ctx: rquickjs::Ctx<'js>,
2498        language: String,
2499        method: String,
2500        params: Option<rquickjs::Object<'js>>,
2501    ) -> rquickjs::Result<u64> {
2502        let id = {
2503            let mut id_ref = self.next_request_id.borrow_mut();
2504            let id = *id_ref;
2505            *id_ref += 1;
2506            // Record context for this callback
2507            self.callback_contexts
2508                .borrow_mut()
2509                .insert(id, self.plugin_name.clone());
2510            id
2511        };
2512        // Convert params object to serde_json::Value
2513        let params_json: Option<serde_json::Value> = params.map(|obj| {
2514            let val = obj.into_value();
2515            js_to_json(&ctx, val)
2516        });
2517        let _ = self.command_sender.send(PluginCommand::SendLspRequest {
2518            request_id: id,
2519            language,
2520            method,
2521            params: params_json,
2522        });
2523        Ok(id)
2524    }
2525
2526    /// Spawn a background process (async, returns request_id which is also process_id)
2527    #[plugin_api(
2528        async_thenable,
2529        js_name = "spawnBackgroundProcess",
2530        ts_return = "BackgroundProcessResult"
2531    )]
2532    #[qjs(rename = "_spawnBackgroundProcessStart")]
2533    pub fn spawn_background_process_start(
2534        &self,
2535        _ctx: rquickjs::Ctx<'_>,
2536        command: String,
2537        args: Vec<String>,
2538        cwd: rquickjs::function::Opt<String>,
2539    ) -> u64 {
2540        let id = {
2541            let mut id_ref = self.next_request_id.borrow_mut();
2542            let id = *id_ref;
2543            *id_ref += 1;
2544            // Record context for this callback
2545            self.callback_contexts
2546                .borrow_mut()
2547                .insert(id, self.plugin_name.clone());
2548            id
2549        };
2550        // Use id as process_id for simplicity
2551        let process_id = id;
2552        let _ = self
2553            .command_sender
2554            .send(PluginCommand::SpawnBackgroundProcess {
2555                process_id,
2556                command,
2557                args,
2558                cwd: cwd.0,
2559                callback_id: JsCallbackId::new(id),
2560            });
2561        id
2562    }
2563
2564    /// Kill a background process
2565    pub fn kill_background_process(&self, process_id: u64) -> bool {
2566        self.command_sender
2567            .send(PluginCommand::KillBackgroundProcess { process_id })
2568            .is_ok()
2569    }
2570
2571    // === Misc ===
2572
2573    /// Force refresh of line display
2574    pub fn refresh_lines(&self, buffer_id: u32) -> bool {
2575        self.command_sender
2576            .send(PluginCommand::RefreshLines {
2577                buffer_id: BufferId(buffer_id as usize),
2578            })
2579            .is_ok()
2580    }
2581
2582    /// Get the current locale
2583    pub fn get_current_locale(&self) -> String {
2584        self.services.current_locale()
2585    }
2586
2587    // === Plugin Management ===
2588
2589    /// Load a plugin from a file path (async)
2590    #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
2591    #[qjs(rename = "_loadPluginStart")]
2592    pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
2593        let id = {
2594            let mut id_ref = self.next_request_id.borrow_mut();
2595            let id = *id_ref;
2596            *id_ref += 1;
2597            self.callback_contexts
2598                .borrow_mut()
2599                .insert(id, self.plugin_name.clone());
2600            id
2601        };
2602        let _ = self.command_sender.send(PluginCommand::LoadPlugin {
2603            path: std::path::PathBuf::from(path),
2604            callback_id: JsCallbackId::new(id),
2605        });
2606        id
2607    }
2608
2609    /// Unload a plugin by name (async)
2610    #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
2611    #[qjs(rename = "_unloadPluginStart")]
2612    pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2613        let id = {
2614            let mut id_ref = self.next_request_id.borrow_mut();
2615            let id = *id_ref;
2616            *id_ref += 1;
2617            self.callback_contexts
2618                .borrow_mut()
2619                .insert(id, self.plugin_name.clone());
2620            id
2621        };
2622        let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
2623            name,
2624            callback_id: JsCallbackId::new(id),
2625        });
2626        id
2627    }
2628
2629    /// Reload a plugin by name (async)
2630    #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
2631    #[qjs(rename = "_reloadPluginStart")]
2632    pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
2633        let id = {
2634            let mut id_ref = self.next_request_id.borrow_mut();
2635            let id = *id_ref;
2636            *id_ref += 1;
2637            self.callback_contexts
2638                .borrow_mut()
2639                .insert(id, self.plugin_name.clone());
2640            id
2641        };
2642        let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
2643            name,
2644            callback_id: JsCallbackId::new(id),
2645        });
2646        id
2647    }
2648
2649    /// List all loaded plugins (async)
2650    /// Returns array of { name: string, path: string, enabled: boolean }
2651    #[plugin_api(
2652        async_promise,
2653        js_name = "listPlugins",
2654        ts_return = "Array<{name: string, path: string, enabled: boolean}>"
2655    )]
2656    #[qjs(rename = "_listPluginsStart")]
2657    pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2658        let id = {
2659            let mut id_ref = self.next_request_id.borrow_mut();
2660            let id = *id_ref;
2661            *id_ref += 1;
2662            self.callback_contexts
2663                .borrow_mut()
2664                .insert(id, self.plugin_name.clone());
2665            id
2666        };
2667        let _ = self.command_sender.send(PluginCommand::ListPlugins {
2668            callback_id: JsCallbackId::new(id),
2669        });
2670        id
2671    }
2672}
2673
2674// =============================================================================
2675// View Token Parsing Helpers
2676// =============================================================================
2677
2678/// Parse a single view token from JS object
2679/// Supports both simple format and TypeScript format
2680fn parse_view_token(
2681    obj: &rquickjs::Object<'_>,
2682    idx: usize,
2683) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
2684    use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
2685
2686    // Try to get the 'kind' field - could be string or object
2687    let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
2688        from: "object",
2689        to: "ViewTokenWire",
2690        message: Some(format!("token[{}]: missing required field 'kind'", idx)),
2691    })?;
2692
2693    // Parse source_offset - try both camelCase and snake_case
2694    let source_offset: Option<usize> = obj
2695        .get("sourceOffset")
2696        .ok()
2697        .or_else(|| obj.get("source_offset").ok());
2698
2699    // Parse the kind field - support both formats
2700    let kind = if kind_value.is_string() {
2701        // Simple format: kind is a string like "text", "newline", etc.
2702        // OR TypeScript format for non-text: "Newline", "Space", "Break"
2703        let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2704            from: "value",
2705            to: "string",
2706            message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
2707        })?;
2708
2709        match kind_str.to_lowercase().as_str() {
2710            "text" => {
2711                let text: String = obj.get("text").unwrap_or_default();
2712                ViewTokenWireKind::Text(text)
2713            }
2714            "newline" => ViewTokenWireKind::Newline,
2715            "space" => ViewTokenWireKind::Space,
2716            "break" => ViewTokenWireKind::Break,
2717            _ => {
2718                // Unknown kind string - log warning and return error
2719                tracing::warn!(
2720                    "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
2721                    idx, kind_str
2722                );
2723                return Err(rquickjs::Error::FromJs {
2724                    from: "string",
2725                    to: "ViewTokenWireKind",
2726                    message: Some(format!(
2727                        "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
2728                        idx, kind_str
2729                    )),
2730                });
2731            }
2732        }
2733    } else if kind_value.is_object() {
2734        // TypeScript format: kind is an object like {Text: "..."} or {BinaryByte: N}
2735        let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
2736            from: "value",
2737            to: "object",
2738            message: Some(format!("token[{}]: 'kind' is not an object", idx)),
2739        })?;
2740
2741        if let Ok(text) = kind_obj.get::<_, String>("Text") {
2742            ViewTokenWireKind::Text(text)
2743        } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
2744            ViewTokenWireKind::BinaryByte(byte)
2745        } else {
2746            // Check what keys are present for a helpful error
2747            let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
2748            tracing::warn!(
2749                "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
2750                idx,
2751                keys
2752            );
2753            return Err(rquickjs::Error::FromJs {
2754                from: "object",
2755                to: "ViewTokenWireKind",
2756                message: Some(format!(
2757                    "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
2758                    idx, keys
2759                )),
2760            });
2761        }
2762    } else {
2763        tracing::warn!(
2764            "token[{}]: 'kind' field must be a string or object, got: {:?}",
2765            idx,
2766            kind_value.type_of()
2767        );
2768        return Err(rquickjs::Error::FromJs {
2769            from: "value",
2770            to: "ViewTokenWireKind",
2771            message: Some(format!(
2772                "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
2773                idx
2774            )),
2775        });
2776    };
2777
2778    // Parse style if present
2779    let style = parse_view_token_style(obj, idx)?;
2780
2781    Ok(ViewTokenWire {
2782        source_offset,
2783        kind,
2784        style,
2785    })
2786}
2787
2788/// Parse optional style from a token object
2789fn parse_view_token_style(
2790    obj: &rquickjs::Object<'_>,
2791    idx: usize,
2792) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
2793    use fresh_core::api::ViewTokenStyle;
2794
2795    let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
2796    let Some(s) = style_obj else {
2797        return Ok(None);
2798    };
2799
2800    let fg: Option<Vec<u8>> = s.get("fg").ok();
2801    let bg: Option<Vec<u8>> = s.get("bg").ok();
2802
2803    // Validate color arrays
2804    let fg_color = if let Some(ref c) = fg {
2805        if c.len() < 3 {
2806            tracing::warn!(
2807                "token[{}]: style.fg has {} elements, expected 3 (RGB)",
2808                idx,
2809                c.len()
2810            );
2811            None
2812        } else {
2813            Some((c[0], c[1], c[2]))
2814        }
2815    } else {
2816        None
2817    };
2818
2819    let bg_color = if let Some(ref c) = bg {
2820        if c.len() < 3 {
2821            tracing::warn!(
2822                "token[{}]: style.bg has {} elements, expected 3 (RGB)",
2823                idx,
2824                c.len()
2825            );
2826            None
2827        } else {
2828            Some((c[0], c[1], c[2]))
2829        }
2830    } else {
2831        None
2832    };
2833
2834    Ok(Some(ViewTokenStyle {
2835        fg: fg_color,
2836        bg: bg_color,
2837        bold: s.get("bold").unwrap_or(false),
2838        italic: s.get("italic").unwrap_or(false),
2839    }))
2840}
2841
2842/// QuickJS-based JavaScript runtime for plugins
2843pub struct QuickJsBackend {
2844    runtime: Runtime,
2845    /// Main context for shared/internal operations
2846    main_context: Context,
2847    /// Plugin-specific contexts: plugin_name -> Context
2848    plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
2849    /// Event handlers: event_name -> list of PluginHandler
2850    event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
2851    /// Registered actions: action_name -> PluginHandler
2852    registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
2853    /// Editor state snapshot (read-only access)
2854    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2855    /// Command sender for write operations
2856    command_sender: mpsc::Sender<PluginCommand>,
2857    /// Pending response senders for async operations (held to keep Arc alive)
2858    #[allow(dead_code)]
2859    pending_responses: PendingResponses,
2860    /// Next request ID for async operations
2861    next_request_id: Rc<RefCell<u64>>,
2862    /// Plugin name for each pending callback ID
2863    callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
2864    /// Bridge for editor services (i18n, theme, etc.)
2865    pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2866}
2867
2868impl QuickJsBackend {
2869    /// Create a new QuickJS backend (standalone, for testing)
2870    pub fn new() -> Result<Self> {
2871        let (tx, _rx) = mpsc::channel();
2872        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2873        let services = Arc::new(fresh_core::services::NoopServiceBridge);
2874        Self::with_state(state_snapshot, tx, services)
2875    }
2876
2877    /// Create a new QuickJS backend with editor state
2878    pub fn with_state(
2879        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2880        command_sender: mpsc::Sender<PluginCommand>,
2881        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2882    ) -> Result<Self> {
2883        let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
2884        Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
2885    }
2886
2887    /// Create a new QuickJS backend with editor state and shared pending responses
2888    pub fn with_state_and_responses(
2889        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2890        command_sender: mpsc::Sender<PluginCommand>,
2891        pending_responses: PendingResponses,
2892        services: Arc<dyn fresh_core::services::PluginServiceBridge>,
2893    ) -> Result<Self> {
2894        tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
2895
2896        let runtime =
2897            Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
2898
2899        // Set up promise rejection tracker to catch unhandled rejections
2900        runtime.set_host_promise_rejection_tracker(Some(Box::new(
2901            |_ctx, _promise, reason, is_handled| {
2902                if !is_handled {
2903                    // Format the rejection reason
2904                    let error_msg = if let Some(exc) = reason.as_exception() {
2905                        format!(
2906                            "{}: {}",
2907                            exc.message().unwrap_or_default(),
2908                            exc.stack().unwrap_or_default()
2909                        )
2910                    } else {
2911                        format!("{:?}", reason)
2912                    };
2913
2914                    tracing::error!("Unhandled Promise rejection: {}", error_msg);
2915
2916                    if should_panic_on_js_errors() {
2917                        // Don't panic here - we're inside an FFI callback and rquickjs catches panics.
2918                        // Instead, set a fatal error flag that the plugin thread loop will check.
2919                        let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
2920                        set_fatal_js_error(full_msg);
2921                    }
2922                }
2923            },
2924        )));
2925
2926        let main_context = Context::full(&runtime)
2927            .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
2928
2929        let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
2930        let event_handlers = Rc::new(RefCell::new(HashMap::new()));
2931        let registered_actions = Rc::new(RefCell::new(HashMap::new()));
2932        let next_request_id = Rc::new(RefCell::new(1u64));
2933        let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
2934
2935        let backend = Self {
2936            runtime,
2937            main_context,
2938            plugin_contexts,
2939            event_handlers,
2940            registered_actions,
2941            state_snapshot,
2942            command_sender,
2943            pending_responses,
2944            next_request_id,
2945            callback_contexts,
2946            services,
2947        };
2948
2949        // Initialize main context (for internal utilities if needed)
2950        backend.setup_context_api(&backend.main_context.clone(), "internal")?;
2951
2952        tracing::debug!("QuickJsBackend::new: runtime created successfully");
2953        Ok(backend)
2954    }
2955
2956    /// Set up the editor API in a specific JavaScript context
2957    fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
2958        let state_snapshot = Arc::clone(&self.state_snapshot);
2959        let command_sender = self.command_sender.clone();
2960        let event_handlers = Rc::clone(&self.event_handlers);
2961        let registered_actions = Rc::clone(&self.registered_actions);
2962        let next_request_id = Rc::clone(&self.next_request_id);
2963
2964        context.with(|ctx| {
2965            let globals = ctx.globals();
2966
2967            // Set the plugin name global
2968            globals.set("__pluginName__", plugin_name)?;
2969
2970            // Create the editor object using JsEditorApi class
2971            // This provides proper lifetime handling for methods returning JS values
2972            let js_api = JsEditorApi {
2973                state_snapshot: Arc::clone(&state_snapshot),
2974                command_sender: command_sender.clone(),
2975                registered_actions: Rc::clone(&registered_actions),
2976                event_handlers: Rc::clone(&event_handlers),
2977                next_request_id: Rc::clone(&next_request_id),
2978                callback_contexts: Rc::clone(&self.callback_contexts),
2979                services: self.services.clone(),
2980                plugin_name: plugin_name.to_string(),
2981            };
2982            let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
2983
2984            // All methods are now in JsEditorApi - export editor as global
2985            globals.set("editor", editor)?;
2986
2987            // Define getEditor() globally
2988            ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
2989
2990            // Provide console.log for debugging
2991            // Use Rest<T> to handle variadic arguments like console.log('a', 'b', obj)
2992            let console = Object::new(ctx.clone())?;
2993            console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2994                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2995                tracing::info!("console.log: {}", parts.join(" "));
2996            })?)?;
2997            console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
2998                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
2999                tracing::warn!("console.warn: {}", parts.join(" "));
3000            })?)?;
3001            console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
3002                let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
3003                tracing::error!("console.error: {}", parts.join(" "));
3004            })?)?;
3005            globals.set("console", console)?;
3006
3007            // Bootstrap: Promise infrastructure (getEditor is defined per-plugin in execute_js)
3008            ctx.eval::<(), _>(r#"
3009                // Pending promise callbacks: callbackId -> { resolve, reject }
3010                globalThis._pendingCallbacks = new Map();
3011
3012                // Resolve a pending callback (called from Rust)
3013                globalThis._resolveCallback = function(callbackId, result) {
3014                    console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
3015                    const cb = globalThis._pendingCallbacks.get(callbackId);
3016                    if (cb) {
3017                        console.log('[JS] _resolveCallback: found callback, calling resolve()');
3018                        globalThis._pendingCallbacks.delete(callbackId);
3019                        cb.resolve(result);
3020                        console.log('[JS] _resolveCallback: resolve() called');
3021                    } else {
3022                        console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
3023                    }
3024                };
3025
3026                // Reject a pending callback (called from Rust)
3027                globalThis._rejectCallback = function(callbackId, error) {
3028                    const cb = globalThis._pendingCallbacks.get(callbackId);
3029                    if (cb) {
3030                        globalThis._pendingCallbacks.delete(callbackId);
3031                        cb.reject(new Error(error));
3032                    }
3033                };
3034
3035                // Generic async wrapper decorator
3036                // Wraps a function that returns a callbackId into a promise-returning function
3037                // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
3038                // NOTE: We pass the method name as a string and call via bracket notation
3039                // to preserve rquickjs's automatic Ctx injection for methods
3040                globalThis._wrapAsync = function(methodName, fnName) {
3041                    const startFn = editor[methodName];
3042                    if (typeof startFn !== 'function') {
3043                        // Return a function that always throws - catches missing implementations
3044                        return function(...args) {
3045                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3046                            editor.debug(`[ASYNC ERROR] ${error.message}`);
3047                            throw error;
3048                        };
3049                    }
3050                    return function(...args) {
3051                        // Call via bracket notation to preserve method binding and Ctx injection
3052                        const callbackId = editor[methodName](...args);
3053                        return new Promise((resolve, reject) => {
3054                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3055                            // TODO: Implement setTimeout polyfill using editor.delay() or similar
3056                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3057                        });
3058                    };
3059                };
3060
3061                // Async wrapper that returns a thenable object (for APIs like spawnProcess)
3062                // The returned object has .result promise and is itself thenable
3063                globalThis._wrapAsyncThenable = function(methodName, fnName) {
3064                    const startFn = editor[methodName];
3065                    if (typeof startFn !== 'function') {
3066                        // Return a function that always throws - catches missing implementations
3067                        return function(...args) {
3068                            const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
3069                            editor.debug(`[ASYNC ERROR] ${error.message}`);
3070                            throw error;
3071                        };
3072                    }
3073                    return function(...args) {
3074                        // Call via bracket notation to preserve method binding and Ctx injection
3075                        const callbackId = editor[methodName](...args);
3076                        const resultPromise = new Promise((resolve, reject) => {
3077                            // NOTE: setTimeout not available in QuickJS - timeout disabled for now
3078                            globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
3079                        });
3080                        return {
3081                            get result() { return resultPromise; },
3082                            then(onFulfilled, onRejected) {
3083                                return resultPromise.then(onFulfilled, onRejected);
3084                            },
3085                            catch(onRejected) {
3086                                return resultPromise.catch(onRejected);
3087                            }
3088                        };
3089                    };
3090                };
3091
3092                // Apply wrappers to async functions on editor
3093                editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
3094                editor.delay = _wrapAsync("_delayStart", "delay");
3095                editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
3096                editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
3097                editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
3098                editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
3099                editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
3100                editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
3101                editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
3102                editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
3103                editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
3104                editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
3105                editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
3106                editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
3107                editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
3108                editor.prompt = _wrapAsync("_promptStart", "prompt");
3109                editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
3110                editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
3111
3112                // Wrapper for deleteTheme - wraps sync function in Promise
3113                editor.deleteTheme = function(name) {
3114                    return new Promise(function(resolve, reject) {
3115                        const success = editor._deleteThemeSync(name);
3116                        if (success) {
3117                            resolve();
3118                        } else {
3119                            reject(new Error("Failed to delete theme: " + name));
3120                        }
3121                    });
3122                };
3123            "#.as_bytes())?;
3124
3125            Ok::<_, rquickjs::Error>(())
3126        }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
3127
3128        Ok(())
3129    }
3130
3131    /// Load and execute a TypeScript/JavaScript plugin from a file path
3132    pub async fn load_module_with_source(
3133        &mut self,
3134        path: &str,
3135        _plugin_source: &str,
3136    ) -> Result<()> {
3137        let path_buf = PathBuf::from(path);
3138        let source = std::fs::read_to_string(&path_buf)
3139            .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
3140
3141        let filename = path_buf
3142            .file_name()
3143            .and_then(|s| s.to_str())
3144            .unwrap_or("plugin.ts");
3145
3146        // Check for ES imports - these need bundling to resolve dependencies
3147        if has_es_imports(&source) {
3148            // Try to bundle (this also strips imports and exports)
3149            match bundle_module(&path_buf) {
3150                Ok(bundled) => {
3151                    self.execute_js(&bundled, path)?;
3152                }
3153                Err(e) => {
3154                    tracing::warn!(
3155                        "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
3156                        path,
3157                        e
3158                    );
3159                    return Ok(()); // Skip plugins with unresolvable imports
3160                }
3161            }
3162        } else if has_es_module_syntax(&source) {
3163            // Has exports but no imports - strip exports and transpile
3164            let stripped = strip_imports_and_exports(&source);
3165            let js_code = if filename.ends_with(".ts") {
3166                transpile_typescript(&stripped, filename)?
3167            } else {
3168                stripped
3169            };
3170            self.execute_js(&js_code, path)?;
3171        } else {
3172            // Plain code - just transpile if TypeScript
3173            let js_code = if filename.ends_with(".ts") {
3174                transpile_typescript(&source, filename)?
3175            } else {
3176                source
3177            };
3178            self.execute_js(&js_code, path)?;
3179        }
3180
3181        Ok(())
3182    }
3183
3184    /// Execute JavaScript code in the context
3185    fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
3186        // Extract plugin name from path (filename without extension)
3187        let plugin_name = Path::new(source_name)
3188            .file_stem()
3189            .and_then(|s| s.to_str())
3190            .unwrap_or("unknown");
3191
3192        tracing::debug!(
3193            "execute_js: starting for plugin '{}' from '{}'",
3194            plugin_name,
3195            source_name
3196        );
3197
3198        // Get or create context for this plugin
3199        let context = {
3200            let mut contexts = self.plugin_contexts.borrow_mut();
3201            if let Some(ctx) = contexts.get(plugin_name) {
3202                ctx.clone()
3203            } else {
3204                let ctx = Context::full(&self.runtime).map_err(|e| {
3205                    anyhow!(
3206                        "Failed to create QuickJS context for plugin {}: {}",
3207                        plugin_name,
3208                        e
3209                    )
3210                })?;
3211                self.setup_context_api(&ctx, plugin_name)?;
3212                contexts.insert(plugin_name.to_string(), ctx.clone());
3213                ctx
3214            }
3215        };
3216
3217        // Wrap plugin code in IIFE to prevent TDZ errors and scope pollution
3218        // This is critical for plugins like vi_mode that declare `const editor = ...`
3219        // which shadows the global `editor` causing TDZ if not wrapped.
3220        let wrapped_code = format!("(function() {{ {} }})();", code);
3221        let wrapped = wrapped_code.as_str();
3222
3223        context.with(|ctx| {
3224            tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
3225
3226            // Execute the plugin code with filename for better stack traces
3227            let mut eval_options = rquickjs::context::EvalOptions::default();
3228            eval_options.global = true;
3229            eval_options.filename = Some(source_name.to_string());
3230            let result = ctx
3231                .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
3232                .map_err(|e| format_js_error(&ctx, e, source_name));
3233
3234            tracing::debug!(
3235                "execute_js: plugin code execution finished for '{}', result: {:?}",
3236                plugin_name,
3237                result.is_ok()
3238            );
3239
3240            result
3241        })
3242    }
3243
3244    /// Emit an event to all registered handlers
3245    pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
3246        let _event_data_str = event_data.to_string();
3247        tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
3248
3249        // Track execution state for signal handler debugging
3250        self.services
3251            .set_js_execution_state(format!("hook '{}'", event_name));
3252
3253        let handlers = self.event_handlers.borrow().get(event_name).cloned();
3254
3255        if let Some(handler_pairs) = handlers {
3256            if handler_pairs.is_empty() {
3257                self.services.clear_js_execution_state();
3258                return Ok(true);
3259            }
3260
3261            let plugin_contexts = self.plugin_contexts.borrow();
3262            for handler in handler_pairs {
3263                let context_opt = plugin_contexts.get(&handler.plugin_name);
3264                if let Some(context) = context_opt {
3265                    let handler_name = &handler.handler_name;
3266                    // Call the handler and properly handle both sync and async errors
3267                    // Async handlers return Promises - we attach .catch() to surface rejections
3268                    // Double-encode the JSON to produce a valid JavaScript string literal:
3269                    // event_data = {"path": "/test"} -> first to_string = {"path": "/test"}
3270                    // -> second to_string = "{\"path\": \"/test\"}" (properly quoted for JS)
3271                    let json_string = serde_json::to_string(event_data)?;
3272                    let js_string_literal = serde_json::to_string(&json_string)?;
3273                    let code = format!(
3274                        r#"
3275                        (function() {{
3276                            try {{
3277                                const data = JSON.parse({});
3278                                if (typeof globalThis["{}"] === 'function') {{
3279                                    const result = globalThis["{}"](data);
3280                                    // If handler returns a Promise, catch rejections
3281                                    if (result && typeof result.then === 'function') {{
3282                                        result.catch(function(e) {{
3283                                            console.error('Handler {} async error:', e);
3284                                            // Re-throw to make it an unhandled rejection for the runtime to catch
3285                                            throw e;
3286                                        }});
3287                                    }}
3288                                }}
3289                            }} catch (e) {{
3290                                console.error('Handler {} sync error:', e);
3291                                throw e;
3292                            }}
3293                        }})();
3294                        "#,
3295                        js_string_literal, handler_name, handler_name, handler_name, handler_name
3296                    );
3297
3298                    context.with(|ctx| {
3299                        if let Err(e) = ctx.eval::<(), _>(code.as_bytes()) {
3300                            log_js_error(&ctx, e, &format!("handler {}", handler_name));
3301                        }
3302                        // Run pending jobs to process any Promise continuations and catch errors
3303                        run_pending_jobs_checked(&ctx, &format!("emit handler {}", handler_name));
3304                    });
3305                }
3306            }
3307        }
3308
3309        self.services.clear_js_execution_state();
3310        Ok(true)
3311    }
3312
3313    /// Check if any handlers are registered for an event
3314    pub fn has_handlers(&self, event_name: &str) -> bool {
3315        self.event_handlers
3316            .borrow()
3317            .get(event_name)
3318            .map(|v| !v.is_empty())
3319            .unwrap_or(false)
3320    }
3321
3322    /// Start an action without waiting for async operations to complete.
3323    /// This is useful when the calling thread needs to continue processing
3324    /// ResolveCallback requests that the action may be waiting for.
3325    pub fn start_action(&mut self, action_name: &str) -> Result<()> {
3326        let pair = self.registered_actions.borrow().get(action_name).cloned();
3327        let (plugin_name, function_name) = match pair {
3328            Some(handler) => (handler.plugin_name, handler.handler_name),
3329            None => ("main".to_string(), action_name.to_string()),
3330        };
3331
3332        let plugin_contexts = self.plugin_contexts.borrow();
3333        let context = plugin_contexts
3334            .get(&plugin_name)
3335            .unwrap_or(&self.main_context);
3336
3337        // Track execution state for signal handler debugging
3338        self.services
3339            .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
3340
3341        tracing::info!(
3342            "start_action: BEGIN '{}' -> function '{}'",
3343            action_name,
3344            function_name
3345        );
3346
3347        // Just call the function - don't try to await or drive Promises
3348        let code = format!(
3349            r#"
3350            (function() {{
3351                console.log('[JS] start_action: calling {fn}');
3352                try {{
3353                    if (typeof globalThis.{fn} === 'function') {{
3354                        console.log('[JS] start_action: {fn} is a function, invoking...');
3355                        globalThis.{fn}();
3356                        console.log('[JS] start_action: {fn} invoked (may be async)');
3357                    }} else {{
3358                        console.error('[JS] Action {action} is not defined as a global function');
3359                    }}
3360                }} catch (e) {{
3361                    console.error('[JS] Action {action} error:', e);
3362                }}
3363            }})();
3364            "#,
3365            fn = function_name,
3366            action = action_name
3367        );
3368
3369        tracing::info!("start_action: evaluating JS code");
3370        context.with(|ctx| {
3371            if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3372                log_js_error(&ctx, e, &format!("action {}", action_name));
3373            }
3374            tracing::info!("start_action: running pending microtasks");
3375            // Run any immediate microtasks
3376            let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
3377            tracing::info!("start_action: executed {} pending jobs", count);
3378        });
3379
3380        tracing::info!("start_action: END '{}'", action_name);
3381
3382        // Clear execution state (action started, may still be running async)
3383        self.services.clear_js_execution_state();
3384
3385        Ok(())
3386    }
3387
3388    /// Execute a registered action by name
3389    pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
3390        // First check if there's a registered command mapping
3391        let pair = self.registered_actions.borrow().get(action_name).cloned();
3392        let (plugin_name, function_name) = match pair {
3393            Some(handler) => (handler.plugin_name, handler.handler_name),
3394            None => ("main".to_string(), action_name.to_string()),
3395        };
3396
3397        let plugin_contexts = self.plugin_contexts.borrow();
3398        let context = plugin_contexts
3399            .get(&plugin_name)
3400            .unwrap_or(&self.main_context);
3401
3402        tracing::debug!(
3403            "execute_action: '{}' -> function '{}'",
3404            action_name,
3405            function_name
3406        );
3407
3408        // Call the function and await if it returns a Promise
3409        // We use a global _executeActionResult to pass the result back
3410        let code = format!(
3411            r#"
3412            (async function() {{
3413                try {{
3414                    if (typeof globalThis.{fn} === 'function') {{
3415                        const result = globalThis.{fn}();
3416                        // If it's a Promise, await it
3417                        if (result && typeof result.then === 'function') {{
3418                            await result;
3419                        }}
3420                    }} else {{
3421                        console.error('Action {action} is not defined as a global function');
3422                    }}
3423                }} catch (e) {{
3424                    console.error('Action {action} error:', e);
3425                }}
3426            }})();
3427            "#,
3428            fn = function_name,
3429            action = action_name
3430        );
3431
3432        context.with(|ctx| {
3433            // Eval returns a Promise for the async IIFE, which we need to drive
3434            match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
3435                Ok(value) => {
3436                    // If it's a Promise, we need to drive the runtime to completion
3437                    if value.is_object() {
3438                        if let Some(obj) = value.as_object() {
3439                            // Check if it's a Promise by looking for 'then' method
3440                            if obj.get::<_, rquickjs::Function>("then").is_ok() {
3441                                // Drive the runtime to process the promise
3442                                // QuickJS processes promises synchronously when we call execute_pending_job
3443                                run_pending_jobs_checked(
3444                                    &ctx,
3445                                    &format!("execute_action {} promise", action_name),
3446                                );
3447                            }
3448                        }
3449                    }
3450                }
3451                Err(e) => {
3452                    log_js_error(&ctx, e, &format!("action {}", action_name));
3453                }
3454            }
3455        });
3456
3457        Ok(())
3458    }
3459
3460    /// Poll the event loop once to run any pending microtasks
3461    pub fn poll_event_loop_once(&mut self) -> bool {
3462        let mut had_work = false;
3463
3464        // Poll main context
3465        self.main_context.with(|ctx| {
3466            let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
3467            if count > 0 {
3468                had_work = true;
3469            }
3470        });
3471
3472        // Poll all plugin contexts
3473        let contexts = self.plugin_contexts.borrow().clone();
3474        for (name, context) in contexts {
3475            context.with(|ctx| {
3476                let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
3477                if count > 0 {
3478                    had_work = true;
3479                }
3480            });
3481        }
3482        had_work
3483    }
3484
3485    /// Send a status message to the editor
3486    pub fn send_status(&self, message: String) {
3487        let _ = self
3488            .command_sender
3489            .send(PluginCommand::SetStatus { message });
3490    }
3491
3492    /// Resolve a pending async callback with a result (called from Rust when async op completes)
3493    ///
3494    /// Takes a JSON string which is parsed and converted to a proper JS value.
3495    /// This avoids string interpolation with eval for better type safety.
3496    pub fn resolve_callback(
3497        &mut self,
3498        callback_id: fresh_core::api::JsCallbackId,
3499        result_json: &str,
3500    ) {
3501        let id = callback_id.as_u64();
3502        tracing::debug!("resolve_callback: starting for callback_id={}", id);
3503
3504        // Find the plugin name and then context for this callback
3505        let plugin_name = {
3506            let mut contexts = self.callback_contexts.borrow_mut();
3507            contexts.remove(&id)
3508        };
3509
3510        let Some(name) = plugin_name else {
3511            tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
3512            return;
3513        };
3514
3515        let plugin_contexts = self.plugin_contexts.borrow();
3516        let Some(context) = plugin_contexts.get(&name) else {
3517            tracing::warn!("resolve_callback: Context lost for plugin {}", name);
3518            return;
3519        };
3520
3521        context.with(|ctx| {
3522            // Parse JSON string to serde_json::Value
3523            let json_value: serde_json::Value = match serde_json::from_str(result_json) {
3524                Ok(v) => v,
3525                Err(e) => {
3526                    tracing::error!(
3527                        "resolve_callback: failed to parse JSON for callback_id={}: {}",
3528                        id,
3529                        e
3530                    );
3531                    return;
3532                }
3533            };
3534
3535            // Convert to JS value using rquickjs_serde
3536            let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
3537                Ok(v) => v,
3538                Err(e) => {
3539                    tracing::error!(
3540                        "resolve_callback: failed to convert to JS value for callback_id={}: {}",
3541                        id,
3542                        e
3543                    );
3544                    return;
3545                }
3546            };
3547
3548            // Get _resolveCallback function from globalThis
3549            let globals = ctx.globals();
3550            let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
3551                Ok(f) => f,
3552                Err(e) => {
3553                    tracing::error!(
3554                        "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
3555                        id,
3556                        e
3557                    );
3558                    return;
3559                }
3560            };
3561
3562            // Call the function with callback_id (as u64) and the JS value
3563            if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
3564                log_js_error(&ctx, e, &format!("resolving callback {}", id));
3565            }
3566
3567            // IMPORTANT: Run pending jobs to process Promise continuations
3568            let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
3569            tracing::info!(
3570                "resolve_callback: executed {} pending jobs for callback_id={}",
3571                job_count,
3572                id
3573            );
3574        });
3575    }
3576
3577    /// Reject a pending async callback with an error (called from Rust when async op fails)
3578    pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
3579        let id = callback_id.as_u64();
3580
3581        // Find the plugin name and then context for this callback
3582        let plugin_name = {
3583            let mut contexts = self.callback_contexts.borrow_mut();
3584            contexts.remove(&id)
3585        };
3586
3587        let Some(name) = plugin_name else {
3588            tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
3589            return;
3590        };
3591
3592        let plugin_contexts = self.plugin_contexts.borrow();
3593        let Some(context) = plugin_contexts.get(&name) else {
3594            tracing::warn!("reject_callback: Context lost for plugin {}", name);
3595            return;
3596        };
3597
3598        context.with(|ctx| {
3599            // Get _rejectCallback function from globalThis
3600            let globals = ctx.globals();
3601            let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
3602                Ok(f) => f,
3603                Err(e) => {
3604                    tracing::error!(
3605                        "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
3606                        id,
3607                        e
3608                    );
3609                    return;
3610                }
3611            };
3612
3613            // Call the function with callback_id (as u64) and error string
3614            if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
3615                log_js_error(&ctx, e, &format!("rejecting callback {}", id));
3616            }
3617
3618            // IMPORTANT: Run pending jobs to process Promise continuations
3619            run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
3620        });
3621    }
3622}
3623
3624#[cfg(test)]
3625mod tests {
3626    use super::*;
3627    use fresh_core::api::{BufferInfo, CursorInfo};
3628    use std::sync::mpsc;
3629
3630    /// Helper to create a backend with a command receiver for testing
3631    fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
3632        let (tx, rx) = mpsc::channel();
3633        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3634        let services = Arc::new(TestServiceBridge::new());
3635        let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
3636        (backend, rx)
3637    }
3638
3639    struct TestServiceBridge {
3640        en_strings: std::sync::Mutex<HashMap<String, String>>,
3641    }
3642
3643    impl TestServiceBridge {
3644        fn new() -> Self {
3645            Self {
3646                en_strings: std::sync::Mutex::new(HashMap::new()),
3647            }
3648        }
3649    }
3650
3651    impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
3652        fn as_any(&self) -> &dyn std::any::Any {
3653            self
3654        }
3655        fn translate(
3656            &self,
3657            _plugin_name: &str,
3658            key: &str,
3659            _args: &HashMap<String, String>,
3660        ) -> String {
3661            self.en_strings
3662                .lock()
3663                .unwrap()
3664                .get(key)
3665                .cloned()
3666                .unwrap_or_else(|| key.to_string())
3667        }
3668        fn current_locale(&self) -> String {
3669            "en".to_string()
3670        }
3671        fn set_js_execution_state(&self, _state: String) {}
3672        fn clear_js_execution_state(&self) {}
3673        fn get_theme_schema(&self) -> serde_json::Value {
3674            serde_json::json!({})
3675        }
3676        fn get_builtin_themes(&self) -> serde_json::Value {
3677            serde_json::json!([])
3678        }
3679        fn register_command(&self, _command: fresh_core::command::Command) {}
3680        fn unregister_command(&self, _name: &str) {}
3681        fn unregister_commands_by_prefix(&self, _prefix: &str) {}
3682        fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
3683        fn plugins_dir(&self) -> std::path::PathBuf {
3684            std::path::PathBuf::from("/tmp/plugins")
3685        }
3686        fn config_dir(&self) -> std::path::PathBuf {
3687            std::path::PathBuf::from("/tmp/config")
3688        }
3689    }
3690
3691    #[test]
3692    fn test_quickjs_backend_creation() {
3693        let backend = QuickJsBackend::new();
3694        assert!(backend.is_ok());
3695    }
3696
3697    #[test]
3698    fn test_execute_simple_js() {
3699        let mut backend = QuickJsBackend::new().unwrap();
3700        let result = backend.execute_js("const x = 1 + 2;", "test.js");
3701        assert!(result.is_ok());
3702    }
3703
3704    #[test]
3705    fn test_event_handler_registration() {
3706        let backend = QuickJsBackend::new().unwrap();
3707
3708        // Initially no handlers
3709        assert!(!backend.has_handlers("test_event"));
3710
3711        // Register a handler
3712        backend
3713            .event_handlers
3714            .borrow_mut()
3715            .entry("test_event".to_string())
3716            .or_default()
3717            .push(PluginHandler {
3718                plugin_name: "test".to_string(),
3719                handler_name: "testHandler".to_string(),
3720            });
3721
3722        // Now has handlers
3723        assert!(backend.has_handlers("test_event"));
3724    }
3725
3726    // ==================== API Tests ====================
3727
3728    #[test]
3729    fn test_api_set_status() {
3730        let (mut backend, rx) = create_test_backend();
3731
3732        backend
3733            .execute_js(
3734                r#"
3735            const editor = getEditor();
3736            editor.setStatus("Hello from test");
3737        "#,
3738                "test.js",
3739            )
3740            .unwrap();
3741
3742        let cmd = rx.try_recv().unwrap();
3743        match cmd {
3744            PluginCommand::SetStatus { message } => {
3745                assert_eq!(message, "Hello from test");
3746            }
3747            _ => panic!("Expected SetStatus command, got {:?}", cmd),
3748        }
3749    }
3750
3751    #[test]
3752    fn test_api_register_command() {
3753        let (mut backend, rx) = create_test_backend();
3754
3755        backend
3756            .execute_js(
3757                r#"
3758            const editor = getEditor();
3759            globalThis.myTestHandler = function() { };
3760            editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
3761        "#,
3762                "test_plugin.js",
3763            )
3764            .unwrap();
3765
3766        let cmd = rx.try_recv().unwrap();
3767        match cmd {
3768            PluginCommand::RegisterCommand { command } => {
3769                assert_eq!(command.name, "Test Command");
3770                assert_eq!(command.description, "A test command");
3771                // Check that plugin_name contains the plugin name (derived from filename)
3772                assert_eq!(command.plugin_name, "test_plugin");
3773            }
3774            _ => panic!("Expected RegisterCommand, got {:?}", cmd),
3775        }
3776    }
3777
3778    #[test]
3779    fn test_api_define_mode() {
3780        let (mut backend, rx) = create_test_backend();
3781
3782        backend
3783            .execute_js(
3784                r#"
3785            const editor = getEditor();
3786            editor.defineMode("test-mode", null, [
3787                ["a", "action_a"],
3788                ["b", "action_b"]
3789            ]);
3790        "#,
3791                "test.js",
3792            )
3793            .unwrap();
3794
3795        let cmd = rx.try_recv().unwrap();
3796        match cmd {
3797            PluginCommand::DefineMode {
3798                name,
3799                parent,
3800                bindings,
3801                read_only,
3802            } => {
3803                assert_eq!(name, "test-mode");
3804                assert!(parent.is_none());
3805                assert_eq!(bindings.len(), 2);
3806                assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
3807                assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
3808                assert!(!read_only);
3809            }
3810            _ => panic!("Expected DefineMode, got {:?}", cmd),
3811        }
3812    }
3813
3814    #[test]
3815    fn test_api_set_editor_mode() {
3816        let (mut backend, rx) = create_test_backend();
3817
3818        backend
3819            .execute_js(
3820                r#"
3821            const editor = getEditor();
3822            editor.setEditorMode("vi-normal");
3823        "#,
3824                "test.js",
3825            )
3826            .unwrap();
3827
3828        let cmd = rx.try_recv().unwrap();
3829        match cmd {
3830            PluginCommand::SetEditorMode { mode } => {
3831                assert_eq!(mode, Some("vi-normal".to_string()));
3832            }
3833            _ => panic!("Expected SetEditorMode, got {:?}", cmd),
3834        }
3835    }
3836
3837    #[test]
3838    fn test_api_clear_editor_mode() {
3839        let (mut backend, rx) = create_test_backend();
3840
3841        backend
3842            .execute_js(
3843                r#"
3844            const editor = getEditor();
3845            editor.setEditorMode(null);
3846        "#,
3847                "test.js",
3848            )
3849            .unwrap();
3850
3851        let cmd = rx.try_recv().unwrap();
3852        match cmd {
3853            PluginCommand::SetEditorMode { mode } => {
3854                assert!(mode.is_none());
3855            }
3856            _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
3857        }
3858    }
3859
3860    #[test]
3861    fn test_api_insert_at_cursor() {
3862        let (mut backend, rx) = create_test_backend();
3863
3864        backend
3865            .execute_js(
3866                r#"
3867            const editor = getEditor();
3868            editor.insertAtCursor("Hello, World!");
3869        "#,
3870                "test.js",
3871            )
3872            .unwrap();
3873
3874        let cmd = rx.try_recv().unwrap();
3875        match cmd {
3876            PluginCommand::InsertAtCursor { text } => {
3877                assert_eq!(text, "Hello, World!");
3878            }
3879            _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
3880        }
3881    }
3882
3883    #[test]
3884    fn test_api_set_context() {
3885        let (mut backend, rx) = create_test_backend();
3886
3887        backend
3888            .execute_js(
3889                r#"
3890            const editor = getEditor();
3891            editor.setContext("myContext", true);
3892        "#,
3893                "test.js",
3894            )
3895            .unwrap();
3896
3897        let cmd = rx.try_recv().unwrap();
3898        match cmd {
3899            PluginCommand::SetContext { name, active } => {
3900                assert_eq!(name, "myContext");
3901                assert!(active);
3902            }
3903            _ => panic!("Expected SetContext, got {:?}", cmd),
3904        }
3905    }
3906
3907    #[tokio::test]
3908    async fn test_execute_action_sync_function() {
3909        let (mut backend, rx) = create_test_backend();
3910
3911        // Register the action explicitly so it knows to look in "test" plugin
3912        backend.registered_actions.borrow_mut().insert(
3913            "my_sync_action".to_string(),
3914            PluginHandler {
3915                plugin_name: "test".to_string(),
3916                handler_name: "my_sync_action".to_string(),
3917            },
3918        );
3919
3920        // Define a sync function and register it
3921        backend
3922            .execute_js(
3923                r#"
3924            const editor = getEditor();
3925            globalThis.my_sync_action = function() {
3926                editor.setStatus("sync action executed");
3927            };
3928        "#,
3929                "test.js",
3930            )
3931            .unwrap();
3932
3933        // Drain any setup commands
3934        while rx.try_recv().is_ok() {}
3935
3936        // Execute the action
3937        backend.execute_action("my_sync_action").await.unwrap();
3938
3939        // Check the command was sent
3940        let cmd = rx.try_recv().unwrap();
3941        match cmd {
3942            PluginCommand::SetStatus { message } => {
3943                assert_eq!(message, "sync action executed");
3944            }
3945            _ => panic!("Expected SetStatus from action, got {:?}", cmd),
3946        }
3947    }
3948
3949    #[tokio::test]
3950    async fn test_execute_action_async_function() {
3951        let (mut backend, rx) = create_test_backend();
3952
3953        // Register the action explicitly
3954        backend.registered_actions.borrow_mut().insert(
3955            "my_async_action".to_string(),
3956            PluginHandler {
3957                plugin_name: "test".to_string(),
3958                handler_name: "my_async_action".to_string(),
3959            },
3960        );
3961
3962        // Define an async function
3963        backend
3964            .execute_js(
3965                r#"
3966            const editor = getEditor();
3967            globalThis.my_async_action = async function() {
3968                await Promise.resolve();
3969                editor.setStatus("async action executed");
3970            };
3971        "#,
3972                "test.js",
3973            )
3974            .unwrap();
3975
3976        // Drain any setup commands
3977        while rx.try_recv().is_ok() {}
3978
3979        // Execute the action
3980        backend.execute_action("my_async_action").await.unwrap();
3981
3982        // Check the command was sent (async should complete)
3983        let cmd = rx.try_recv().unwrap();
3984        match cmd {
3985            PluginCommand::SetStatus { message } => {
3986                assert_eq!(message, "async action executed");
3987            }
3988            _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
3989        }
3990    }
3991
3992    #[tokio::test]
3993    async fn test_execute_action_with_registered_handler() {
3994        let (mut backend, rx) = create_test_backend();
3995
3996        // Register an action with a different handler name
3997        backend.registered_actions.borrow_mut().insert(
3998            "my_action".to_string(),
3999            PluginHandler {
4000                plugin_name: "test".to_string(),
4001                handler_name: "actual_handler_function".to_string(),
4002            },
4003        );
4004
4005        backend
4006            .execute_js(
4007                r#"
4008            const editor = getEditor();
4009            globalThis.actual_handler_function = function() {
4010                editor.setStatus("handler executed");
4011            };
4012        "#,
4013                "test.js",
4014            )
4015            .unwrap();
4016
4017        // Drain any setup commands
4018        while rx.try_recv().is_ok() {}
4019
4020        // Execute the action by name (should resolve to handler)
4021        backend.execute_action("my_action").await.unwrap();
4022
4023        let cmd = rx.try_recv().unwrap();
4024        match cmd {
4025            PluginCommand::SetStatus { message } => {
4026                assert_eq!(message, "handler executed");
4027            }
4028            _ => panic!("Expected SetStatus, got {:?}", cmd),
4029        }
4030    }
4031
4032    #[test]
4033    fn test_api_on_event_registration() {
4034        let (mut backend, _rx) = create_test_backend();
4035
4036        backend
4037            .execute_js(
4038                r#"
4039            const editor = getEditor();
4040            globalThis.myEventHandler = function() { };
4041            editor.on("bufferSave", "myEventHandler");
4042        "#,
4043                "test.js",
4044            )
4045            .unwrap();
4046
4047        assert!(backend.has_handlers("bufferSave"));
4048    }
4049
4050    #[test]
4051    fn test_api_off_event_unregistration() {
4052        let (mut backend, _rx) = create_test_backend();
4053
4054        backend
4055            .execute_js(
4056                r#"
4057            const editor = getEditor();
4058            globalThis.myEventHandler = function() { };
4059            editor.on("bufferSave", "myEventHandler");
4060            editor.off("bufferSave", "myEventHandler");
4061        "#,
4062                "test.js",
4063            )
4064            .unwrap();
4065
4066        // Handler should be removed
4067        assert!(!backend.has_handlers("bufferSave"));
4068    }
4069
4070    #[tokio::test]
4071    async fn test_emit_event() {
4072        let (mut backend, rx) = create_test_backend();
4073
4074        backend
4075            .execute_js(
4076                r#"
4077            const editor = getEditor();
4078            globalThis.onSaveHandler = function(data) {
4079                editor.setStatus("saved: " + JSON.stringify(data));
4080            };
4081            editor.on("bufferSave", "onSaveHandler");
4082        "#,
4083                "test.js",
4084            )
4085            .unwrap();
4086
4087        // Drain setup commands
4088        while rx.try_recv().is_ok() {}
4089
4090        // Emit the event
4091        let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
4092        backend.emit("bufferSave", &event_data).await.unwrap();
4093
4094        let cmd = rx.try_recv().unwrap();
4095        match cmd {
4096            PluginCommand::SetStatus { message } => {
4097                assert!(message.contains("/test.txt"));
4098            }
4099            _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
4100        }
4101    }
4102
4103    #[test]
4104    fn test_api_copy_to_clipboard() {
4105        let (mut backend, rx) = create_test_backend();
4106
4107        backend
4108            .execute_js(
4109                r#"
4110            const editor = getEditor();
4111            editor.copyToClipboard("clipboard text");
4112        "#,
4113                "test.js",
4114            )
4115            .unwrap();
4116
4117        let cmd = rx.try_recv().unwrap();
4118        match cmd {
4119            PluginCommand::SetClipboard { text } => {
4120                assert_eq!(text, "clipboard text");
4121            }
4122            _ => panic!("Expected SetClipboard, got {:?}", cmd),
4123        }
4124    }
4125
4126    #[test]
4127    fn test_api_open_file() {
4128        let (mut backend, rx) = create_test_backend();
4129
4130        // openFile takes (path, line?, column?)
4131        backend
4132            .execute_js(
4133                r#"
4134            const editor = getEditor();
4135            editor.openFile("/path/to/file.txt", null, null);
4136        "#,
4137                "test.js",
4138            )
4139            .unwrap();
4140
4141        let cmd = rx.try_recv().unwrap();
4142        match cmd {
4143            PluginCommand::OpenFileAtLocation { path, line, column } => {
4144                assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
4145                assert!(line.is_none());
4146                assert!(column.is_none());
4147            }
4148            _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
4149        }
4150    }
4151
4152    #[test]
4153    fn test_api_delete_range() {
4154        let (mut backend, rx) = create_test_backend();
4155
4156        // deleteRange takes (buffer_id, start, end)
4157        backend
4158            .execute_js(
4159                r#"
4160            const editor = getEditor();
4161            editor.deleteRange(0, 10, 20);
4162        "#,
4163                "test.js",
4164            )
4165            .unwrap();
4166
4167        let cmd = rx.try_recv().unwrap();
4168        match cmd {
4169            PluginCommand::DeleteRange { range, .. } => {
4170                assert_eq!(range.start, 10);
4171                assert_eq!(range.end, 20);
4172            }
4173            _ => panic!("Expected DeleteRange, got {:?}", cmd),
4174        }
4175    }
4176
4177    #[test]
4178    fn test_api_insert_text() {
4179        let (mut backend, rx) = create_test_backend();
4180
4181        // insertText takes (buffer_id, position, text)
4182        backend
4183            .execute_js(
4184                r#"
4185            const editor = getEditor();
4186            editor.insertText(0, 5, "inserted");
4187        "#,
4188                "test.js",
4189            )
4190            .unwrap();
4191
4192        let cmd = rx.try_recv().unwrap();
4193        match cmd {
4194            PluginCommand::InsertText { position, text, .. } => {
4195                assert_eq!(position, 5);
4196                assert_eq!(text, "inserted");
4197            }
4198            _ => panic!("Expected InsertText, got {:?}", cmd),
4199        }
4200    }
4201
4202    #[test]
4203    fn test_api_set_buffer_cursor() {
4204        let (mut backend, rx) = create_test_backend();
4205
4206        // setBufferCursor takes (buffer_id, position)
4207        backend
4208            .execute_js(
4209                r#"
4210            const editor = getEditor();
4211            editor.setBufferCursor(0, 100);
4212        "#,
4213                "test.js",
4214            )
4215            .unwrap();
4216
4217        let cmd = rx.try_recv().unwrap();
4218        match cmd {
4219            PluginCommand::SetBufferCursor { position, .. } => {
4220                assert_eq!(position, 100);
4221            }
4222            _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
4223        }
4224    }
4225
4226    #[test]
4227    fn test_api_get_cursor_position_from_state() {
4228        let (tx, _rx) = mpsc::channel();
4229        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4230
4231        // Set up cursor position in state
4232        {
4233            let mut state = state_snapshot.write().unwrap();
4234            state.primary_cursor = Some(CursorInfo {
4235                position: 42,
4236                selection: None,
4237            });
4238        }
4239
4240        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4241        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4242
4243        // Execute JS that reads and stores cursor position
4244        backend
4245            .execute_js(
4246                r#"
4247            const editor = getEditor();
4248            const pos = editor.getCursorPosition();
4249            globalThis._testResult = pos;
4250        "#,
4251                "test.js",
4252            )
4253            .unwrap();
4254
4255        // Verify by reading back - getCursorPosition returns byte offset as u32
4256        backend
4257            .plugin_contexts
4258            .borrow()
4259            .get("test")
4260            .unwrap()
4261            .clone()
4262            .with(|ctx| {
4263                let global = ctx.globals();
4264                let result: u32 = global.get("_testResult").unwrap();
4265                assert_eq!(result, 42);
4266            });
4267    }
4268
4269    #[test]
4270    fn test_api_path_functions() {
4271        let (mut backend, _rx) = create_test_backend();
4272
4273        // Use platform-appropriate absolute path for isAbsolute test
4274        // Note: On Windows, backslashes need to be escaped for JavaScript string literals
4275        #[cfg(windows)]
4276        let absolute_path = r#"C:\\foo\\bar"#;
4277        #[cfg(not(windows))]
4278        let absolute_path = "/foo/bar";
4279
4280        // pathJoin takes an array of path parts
4281        let js_code = format!(
4282            r#"
4283            const editor = getEditor();
4284            globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
4285            globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
4286            globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
4287            globalThis._isAbsolute = editor.pathIsAbsolute("{}");
4288            globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
4289            globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
4290        "#,
4291            absolute_path
4292        );
4293        backend.execute_js(&js_code, "test.js").unwrap();
4294
4295        backend
4296            .plugin_contexts
4297            .borrow()
4298            .get("test")
4299            .unwrap()
4300            .clone()
4301            .with(|ctx| {
4302                let global = ctx.globals();
4303                assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
4304                assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
4305                assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
4306                assert!(global.get::<_, bool>("_isAbsolute").unwrap());
4307                assert!(!global.get::<_, bool>("_isRelative").unwrap());
4308                assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
4309            });
4310    }
4311
4312    #[test]
4313    fn test_typescript_transpilation() {
4314        use fresh_parser_js::transpile_typescript;
4315
4316        let (mut backend, rx) = create_test_backend();
4317
4318        // TypeScript code with type annotations
4319        let ts_code = r#"
4320            const editor = getEditor();
4321            function greet(name: string): string {
4322                return "Hello, " + name;
4323            }
4324            editor.setStatus(greet("TypeScript"));
4325        "#;
4326
4327        // Transpile to JavaScript first
4328        let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
4329
4330        // Execute the transpiled JavaScript
4331        backend.execute_js(&js_code, "test.js").unwrap();
4332
4333        let cmd = rx.try_recv().unwrap();
4334        match cmd {
4335            PluginCommand::SetStatus { message } => {
4336                assert_eq!(message, "Hello, TypeScript");
4337            }
4338            _ => panic!("Expected SetStatus, got {:?}", cmd),
4339        }
4340    }
4341
4342    #[test]
4343    fn test_api_get_buffer_text_sends_command() {
4344        let (mut backend, rx) = create_test_backend();
4345
4346        // Call getBufferText - this returns a Promise and sends the command
4347        backend
4348            .execute_js(
4349                r#"
4350            const editor = getEditor();
4351            // Store the promise for later
4352            globalThis._textPromise = editor.getBufferText(0, 10, 20);
4353        "#,
4354                "test.js",
4355            )
4356            .unwrap();
4357
4358        // Verify the GetBufferText command was sent
4359        let cmd = rx.try_recv().unwrap();
4360        match cmd {
4361            PluginCommand::GetBufferText {
4362                buffer_id,
4363                start,
4364                end,
4365                request_id,
4366            } => {
4367                assert_eq!(buffer_id.0, 0);
4368                assert_eq!(start, 10);
4369                assert_eq!(end, 20);
4370                assert!(request_id > 0); // Should have a valid request ID
4371            }
4372            _ => panic!("Expected GetBufferText, got {:?}", cmd),
4373        }
4374    }
4375
4376    #[test]
4377    fn test_api_get_buffer_text_resolves_callback() {
4378        let (mut backend, rx) = create_test_backend();
4379
4380        // Call getBufferText and set up a handler for when it resolves
4381        backend
4382            .execute_js(
4383                r#"
4384            const editor = getEditor();
4385            globalThis._resolvedText = null;
4386            editor.getBufferText(0, 0, 100).then(text => {
4387                globalThis._resolvedText = text;
4388            });
4389        "#,
4390                "test.js",
4391            )
4392            .unwrap();
4393
4394        // Get the request_id from the command
4395        let request_id = match rx.try_recv().unwrap() {
4396            PluginCommand::GetBufferText { request_id, .. } => request_id,
4397            cmd => panic!("Expected GetBufferText, got {:?}", cmd),
4398        };
4399
4400        // Simulate the editor responding with the text
4401        backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
4402
4403        // Drive the Promise to completion
4404        backend
4405            .plugin_contexts
4406            .borrow()
4407            .get("test")
4408            .unwrap()
4409            .clone()
4410            .with(|ctx| {
4411                run_pending_jobs_checked(&ctx, "test async getText");
4412            });
4413
4414        // Verify the Promise resolved with the text
4415        backend
4416            .plugin_contexts
4417            .borrow()
4418            .get("test")
4419            .unwrap()
4420            .clone()
4421            .with(|ctx| {
4422                let global = ctx.globals();
4423                let result: String = global.get("_resolvedText").unwrap();
4424                assert_eq!(result, "hello world");
4425            });
4426    }
4427
4428    #[test]
4429    fn test_plugin_translation() {
4430        let (mut backend, _rx) = create_test_backend();
4431
4432        // The t() function should work (returns key if translation not found)
4433        backend
4434            .execute_js(
4435                r#"
4436            const editor = getEditor();
4437            globalThis._translated = editor.t("test.key");
4438        "#,
4439                "test.js",
4440            )
4441            .unwrap();
4442
4443        backend
4444            .plugin_contexts
4445            .borrow()
4446            .get("test")
4447            .unwrap()
4448            .clone()
4449            .with(|ctx| {
4450                let global = ctx.globals();
4451                // Without actual translations, it returns the key
4452                let result: String = global.get("_translated").unwrap();
4453                assert_eq!(result, "test.key");
4454            });
4455    }
4456
4457    #[test]
4458    fn test_plugin_translation_with_registered_strings() {
4459        let (mut backend, _rx) = create_test_backend();
4460
4461        // Register translations for the test plugin
4462        let mut en_strings = std::collections::HashMap::new();
4463        en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
4464        en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4465
4466        let mut strings = std::collections::HashMap::new();
4467        strings.insert("en".to_string(), en_strings);
4468
4469        // Register for "test" plugin
4470        if let Some(bridge) = backend
4471            .services
4472            .as_any()
4473            .downcast_ref::<TestServiceBridge>()
4474        {
4475            let mut en = bridge.en_strings.lock().unwrap();
4476            en.insert("greeting".to_string(), "Hello, World!".to_string());
4477            en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
4478        }
4479
4480        // Test translation
4481        backend
4482            .execute_js(
4483                r#"
4484            const editor = getEditor();
4485            globalThis._greeting = editor.t("greeting");
4486            globalThis._prompt = editor.t("prompt.find_file");
4487            globalThis._missing = editor.t("nonexistent.key");
4488        "#,
4489                "test.js",
4490            )
4491            .unwrap();
4492
4493        backend
4494            .plugin_contexts
4495            .borrow()
4496            .get("test")
4497            .unwrap()
4498            .clone()
4499            .with(|ctx| {
4500                let global = ctx.globals();
4501                let greeting: String = global.get("_greeting").unwrap();
4502                assert_eq!(greeting, "Hello, World!");
4503
4504                let prompt: String = global.get("_prompt").unwrap();
4505                assert_eq!(prompt, "Find file: ");
4506
4507                // Missing key should return the key itself
4508                let missing: String = global.get("_missing").unwrap();
4509                assert_eq!(missing, "nonexistent.key");
4510            });
4511    }
4512
4513    // ==================== Line Indicator Tests ====================
4514
4515    #[test]
4516    fn test_api_set_line_indicator() {
4517        let (mut backend, rx) = create_test_backend();
4518
4519        backend
4520            .execute_js(
4521                r#"
4522            const editor = getEditor();
4523            editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
4524        "#,
4525                "test.js",
4526            )
4527            .unwrap();
4528
4529        let cmd = rx.try_recv().unwrap();
4530        match cmd {
4531            PluginCommand::SetLineIndicator {
4532                buffer_id,
4533                line,
4534                namespace,
4535                symbol,
4536                color,
4537                priority,
4538            } => {
4539                assert_eq!(buffer_id.0, 1);
4540                assert_eq!(line, 5);
4541                assert_eq!(namespace, "test-ns");
4542                assert_eq!(symbol, "●");
4543                assert_eq!(color, (255, 0, 0));
4544                assert_eq!(priority, 10);
4545            }
4546            _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
4547        }
4548    }
4549
4550    #[test]
4551    fn test_api_clear_line_indicators() {
4552        let (mut backend, rx) = create_test_backend();
4553
4554        backend
4555            .execute_js(
4556                r#"
4557            const editor = getEditor();
4558            editor.clearLineIndicators(1, "test-ns");
4559        "#,
4560                "test.js",
4561            )
4562            .unwrap();
4563
4564        let cmd = rx.try_recv().unwrap();
4565        match cmd {
4566            PluginCommand::ClearLineIndicators {
4567                buffer_id,
4568                namespace,
4569            } => {
4570                assert_eq!(buffer_id.0, 1);
4571                assert_eq!(namespace, "test-ns");
4572            }
4573            _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
4574        }
4575    }
4576
4577    // ==================== Virtual Buffer Tests ====================
4578
4579    #[test]
4580    fn test_api_create_virtual_buffer_sends_command() {
4581        let (mut backend, rx) = create_test_backend();
4582
4583        backend
4584            .execute_js(
4585                r#"
4586            const editor = getEditor();
4587            editor.createVirtualBuffer({
4588                name: "*Test Buffer*",
4589                mode: "test-mode",
4590                readOnly: true,
4591                entries: [
4592                    { text: "Line 1\n", properties: { type: "header" } },
4593                    { text: "Line 2\n", properties: { type: "content" } }
4594                ],
4595                showLineNumbers: false,
4596                showCursors: true,
4597                editingDisabled: true
4598            });
4599        "#,
4600                "test.js",
4601            )
4602            .unwrap();
4603
4604        let cmd = rx.try_recv().unwrap();
4605        match cmd {
4606            PluginCommand::CreateVirtualBufferWithContent {
4607                name,
4608                mode,
4609                read_only,
4610                entries,
4611                show_line_numbers,
4612                show_cursors,
4613                editing_disabled,
4614                ..
4615            } => {
4616                assert_eq!(name, "*Test Buffer*");
4617                assert_eq!(mode, "test-mode");
4618                assert!(read_only);
4619                assert_eq!(entries.len(), 2);
4620                assert_eq!(entries[0].text, "Line 1\n");
4621                assert!(!show_line_numbers);
4622                assert!(show_cursors);
4623                assert!(editing_disabled);
4624            }
4625            _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
4626        }
4627    }
4628
4629    #[test]
4630    fn test_api_set_virtual_buffer_content() {
4631        let (mut backend, rx) = create_test_backend();
4632
4633        backend
4634            .execute_js(
4635                r#"
4636            const editor = getEditor();
4637            editor.setVirtualBufferContent(5, [
4638                { text: "New content\n", properties: { type: "updated" } }
4639            ]);
4640        "#,
4641                "test.js",
4642            )
4643            .unwrap();
4644
4645        let cmd = rx.try_recv().unwrap();
4646        match cmd {
4647            PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
4648                assert_eq!(buffer_id.0, 5);
4649                assert_eq!(entries.len(), 1);
4650                assert_eq!(entries[0].text, "New content\n");
4651            }
4652            _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
4653        }
4654    }
4655
4656    // ==================== Overlay Tests ====================
4657
4658    #[test]
4659    fn test_api_add_overlay() {
4660        let (mut backend, rx) = create_test_backend();
4661
4662        backend
4663            .execute_js(
4664                r#"
4665            const editor = getEditor();
4666            editor.addOverlay(1, "highlight", 10, 20, {
4667                fg: [255, 128, 0],
4668                bg: [50, 50, 50],
4669                bold: true,
4670            });
4671        "#,
4672                "test.js",
4673            )
4674            .unwrap();
4675
4676        let cmd = rx.try_recv().unwrap();
4677        match cmd {
4678            PluginCommand::AddOverlay {
4679                buffer_id,
4680                namespace,
4681                range,
4682                options,
4683            } => {
4684                use fresh_core::api::OverlayColorSpec;
4685                assert_eq!(buffer_id.0, 1);
4686                assert!(namespace.is_some());
4687                assert_eq!(namespace.unwrap().as_str(), "highlight");
4688                assert_eq!(range, 10..20);
4689                assert!(matches!(
4690                    options.fg,
4691                    Some(OverlayColorSpec::Rgb(255, 128, 0))
4692                ));
4693                assert!(matches!(
4694                    options.bg,
4695                    Some(OverlayColorSpec::Rgb(50, 50, 50))
4696                ));
4697                assert!(!options.underline);
4698                assert!(options.bold);
4699                assert!(!options.italic);
4700                assert!(!options.extend_to_line_end);
4701            }
4702            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4703        }
4704    }
4705
4706    #[test]
4707    fn test_api_add_overlay_with_theme_keys() {
4708        let (mut backend, rx) = create_test_backend();
4709
4710        backend
4711            .execute_js(
4712                r#"
4713            const editor = getEditor();
4714            // Test with theme keys for colors
4715            editor.addOverlay(1, "themed", 0, 10, {
4716                fg: "ui.status_bar_fg",
4717                bg: "editor.selection_bg",
4718            });
4719        "#,
4720                "test.js",
4721            )
4722            .unwrap();
4723
4724        let cmd = rx.try_recv().unwrap();
4725        match cmd {
4726            PluginCommand::AddOverlay {
4727                buffer_id,
4728                namespace,
4729                range,
4730                options,
4731            } => {
4732                use fresh_core::api::OverlayColorSpec;
4733                assert_eq!(buffer_id.0, 1);
4734                assert!(namespace.is_some());
4735                assert_eq!(namespace.unwrap().as_str(), "themed");
4736                assert_eq!(range, 0..10);
4737                assert!(matches!(
4738                    &options.fg,
4739                    Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
4740                ));
4741                assert!(matches!(
4742                    &options.bg,
4743                    Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
4744                ));
4745                assert!(!options.underline);
4746                assert!(!options.bold);
4747                assert!(!options.italic);
4748                assert!(!options.extend_to_line_end);
4749            }
4750            _ => panic!("Expected AddOverlay, got {:?}", cmd),
4751        }
4752    }
4753
4754    #[test]
4755    fn test_api_clear_namespace() {
4756        let (mut backend, rx) = create_test_backend();
4757
4758        backend
4759            .execute_js(
4760                r#"
4761            const editor = getEditor();
4762            editor.clearNamespace(1, "highlight");
4763        "#,
4764                "test.js",
4765            )
4766            .unwrap();
4767
4768        let cmd = rx.try_recv().unwrap();
4769        match cmd {
4770            PluginCommand::ClearNamespace {
4771                buffer_id,
4772                namespace,
4773            } => {
4774                assert_eq!(buffer_id.0, 1);
4775                assert_eq!(namespace.as_str(), "highlight");
4776            }
4777            _ => panic!("Expected ClearNamespace, got {:?}", cmd),
4778        }
4779    }
4780
4781    // ==================== Theme Tests ====================
4782
4783    #[test]
4784    fn test_api_get_theme_schema() {
4785        let (mut backend, _rx) = create_test_backend();
4786
4787        backend
4788            .execute_js(
4789                r#"
4790            const editor = getEditor();
4791            const schema = editor.getThemeSchema();
4792            globalThis._isObject = typeof schema === 'object' && schema !== null;
4793        "#,
4794                "test.js",
4795            )
4796            .unwrap();
4797
4798        backend
4799            .plugin_contexts
4800            .borrow()
4801            .get("test")
4802            .unwrap()
4803            .clone()
4804            .with(|ctx| {
4805                let global = ctx.globals();
4806                let is_object: bool = global.get("_isObject").unwrap();
4807                // getThemeSchema should return an object
4808                assert!(is_object);
4809            });
4810    }
4811
4812    #[test]
4813    fn test_api_get_builtin_themes() {
4814        let (mut backend, _rx) = create_test_backend();
4815
4816        backend
4817            .execute_js(
4818                r#"
4819            const editor = getEditor();
4820            const themes = editor.getBuiltinThemes();
4821            globalThis._isObject = typeof themes === 'object' && themes !== null;
4822        "#,
4823                "test.js",
4824            )
4825            .unwrap();
4826
4827        backend
4828            .plugin_contexts
4829            .borrow()
4830            .get("test")
4831            .unwrap()
4832            .clone()
4833            .with(|ctx| {
4834                let global = ctx.globals();
4835                let is_object: bool = global.get("_isObject").unwrap();
4836                // getBuiltinThemes should return an object
4837                assert!(is_object);
4838            });
4839    }
4840
4841    #[test]
4842    fn test_api_apply_theme() {
4843        let (mut backend, rx) = create_test_backend();
4844
4845        backend
4846            .execute_js(
4847                r#"
4848            const editor = getEditor();
4849            editor.applyTheme("dark");
4850        "#,
4851                "test.js",
4852            )
4853            .unwrap();
4854
4855        let cmd = rx.try_recv().unwrap();
4856        match cmd {
4857            PluginCommand::ApplyTheme { theme_name } => {
4858                assert_eq!(theme_name, "dark");
4859            }
4860            _ => panic!("Expected ApplyTheme, got {:?}", cmd),
4861        }
4862    }
4863
4864    // ==================== Buffer Operations Tests ====================
4865
4866    #[test]
4867    fn test_api_close_buffer() {
4868        let (mut backend, rx) = create_test_backend();
4869
4870        backend
4871            .execute_js(
4872                r#"
4873            const editor = getEditor();
4874            editor.closeBuffer(3);
4875        "#,
4876                "test.js",
4877            )
4878            .unwrap();
4879
4880        let cmd = rx.try_recv().unwrap();
4881        match cmd {
4882            PluginCommand::CloseBuffer { buffer_id } => {
4883                assert_eq!(buffer_id.0, 3);
4884            }
4885            _ => panic!("Expected CloseBuffer, got {:?}", cmd),
4886        }
4887    }
4888
4889    #[test]
4890    fn test_api_focus_split() {
4891        let (mut backend, rx) = create_test_backend();
4892
4893        backend
4894            .execute_js(
4895                r#"
4896            const editor = getEditor();
4897            editor.focusSplit(2);
4898        "#,
4899                "test.js",
4900            )
4901            .unwrap();
4902
4903        let cmd = rx.try_recv().unwrap();
4904        match cmd {
4905            PluginCommand::FocusSplit { split_id } => {
4906                assert_eq!(split_id.0, 2);
4907            }
4908            _ => panic!("Expected FocusSplit, got {:?}", cmd),
4909        }
4910    }
4911
4912    #[test]
4913    fn test_api_list_buffers() {
4914        let (tx, _rx) = mpsc::channel();
4915        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4916
4917        // Add some buffers to state
4918        {
4919            let mut state = state_snapshot.write().unwrap();
4920            state.buffers.insert(
4921                BufferId(0),
4922                BufferInfo {
4923                    id: BufferId(0),
4924                    path: Some(PathBuf::from("/test1.txt")),
4925                    modified: false,
4926                    length: 100,
4927                },
4928            );
4929            state.buffers.insert(
4930                BufferId(1),
4931                BufferInfo {
4932                    id: BufferId(1),
4933                    path: Some(PathBuf::from("/test2.txt")),
4934                    modified: true,
4935                    length: 200,
4936                },
4937            );
4938        }
4939
4940        let services = Arc::new(fresh_core::services::NoopServiceBridge);
4941        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
4942
4943        backend
4944            .execute_js(
4945                r#"
4946            const editor = getEditor();
4947            const buffers = editor.listBuffers();
4948            globalThis._isArray = Array.isArray(buffers);
4949            globalThis._length = buffers.length;
4950        "#,
4951                "test.js",
4952            )
4953            .unwrap();
4954
4955        backend
4956            .plugin_contexts
4957            .borrow()
4958            .get("test")
4959            .unwrap()
4960            .clone()
4961            .with(|ctx| {
4962                let global = ctx.globals();
4963                let is_array: bool = global.get("_isArray").unwrap();
4964                let length: u32 = global.get("_length").unwrap();
4965                assert!(is_array);
4966                assert_eq!(length, 2);
4967            });
4968    }
4969
4970    // ==================== Prompt Tests ====================
4971
4972    #[test]
4973    fn test_api_start_prompt() {
4974        let (mut backend, rx) = create_test_backend();
4975
4976        backend
4977            .execute_js(
4978                r#"
4979            const editor = getEditor();
4980            editor.startPrompt("Enter value:", "test-prompt");
4981        "#,
4982                "test.js",
4983            )
4984            .unwrap();
4985
4986        let cmd = rx.try_recv().unwrap();
4987        match cmd {
4988            PluginCommand::StartPrompt { label, prompt_type } => {
4989                assert_eq!(label, "Enter value:");
4990                assert_eq!(prompt_type, "test-prompt");
4991            }
4992            _ => panic!("Expected StartPrompt, got {:?}", cmd),
4993        }
4994    }
4995
4996    #[test]
4997    fn test_api_start_prompt_with_initial() {
4998        let (mut backend, rx) = create_test_backend();
4999
5000        backend
5001            .execute_js(
5002                r#"
5003            const editor = getEditor();
5004            editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
5005        "#,
5006                "test.js",
5007            )
5008            .unwrap();
5009
5010        let cmd = rx.try_recv().unwrap();
5011        match cmd {
5012            PluginCommand::StartPromptWithInitial {
5013                label,
5014                prompt_type,
5015                initial_value,
5016            } => {
5017                assert_eq!(label, "Enter value:");
5018                assert_eq!(prompt_type, "test-prompt");
5019                assert_eq!(initial_value, "default");
5020            }
5021            _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
5022        }
5023    }
5024
5025    #[test]
5026    fn test_api_set_prompt_suggestions() {
5027        let (mut backend, rx) = create_test_backend();
5028
5029        backend
5030            .execute_js(
5031                r#"
5032            const editor = getEditor();
5033            editor.setPromptSuggestions([
5034                { text: "Option 1", value: "opt1" },
5035                { text: "Option 2", value: "opt2" }
5036            ]);
5037        "#,
5038                "test.js",
5039            )
5040            .unwrap();
5041
5042        let cmd = rx.try_recv().unwrap();
5043        match cmd {
5044            PluginCommand::SetPromptSuggestions { suggestions } => {
5045                assert_eq!(suggestions.len(), 2);
5046                assert_eq!(suggestions[0].text, "Option 1");
5047                assert_eq!(suggestions[0].value, Some("opt1".to_string()));
5048            }
5049            _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
5050        }
5051    }
5052
5053    // ==================== State Query Tests ====================
5054
5055    #[test]
5056    fn test_api_get_active_buffer_id() {
5057        let (tx, _rx) = mpsc::channel();
5058        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5059
5060        {
5061            let mut state = state_snapshot.write().unwrap();
5062            state.active_buffer_id = BufferId(42);
5063        }
5064
5065        let services = Arc::new(fresh_core::services::NoopServiceBridge);
5066        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5067
5068        backend
5069            .execute_js(
5070                r#"
5071            const editor = getEditor();
5072            globalThis._activeId = editor.getActiveBufferId();
5073        "#,
5074                "test.js",
5075            )
5076            .unwrap();
5077
5078        backend
5079            .plugin_contexts
5080            .borrow()
5081            .get("test")
5082            .unwrap()
5083            .clone()
5084            .with(|ctx| {
5085                let global = ctx.globals();
5086                let result: u32 = global.get("_activeId").unwrap();
5087                assert_eq!(result, 42);
5088            });
5089    }
5090
5091    #[test]
5092    fn test_api_get_active_split_id() {
5093        let (tx, _rx) = mpsc::channel();
5094        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5095
5096        {
5097            let mut state = state_snapshot.write().unwrap();
5098            state.active_split_id = 7;
5099        }
5100
5101        let services = Arc::new(fresh_core::services::NoopServiceBridge);
5102        let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
5103
5104        backend
5105            .execute_js(
5106                r#"
5107            const editor = getEditor();
5108            globalThis._splitId = editor.getActiveSplitId();
5109        "#,
5110                "test.js",
5111            )
5112            .unwrap();
5113
5114        backend
5115            .plugin_contexts
5116            .borrow()
5117            .get("test")
5118            .unwrap()
5119            .clone()
5120            .with(|ctx| {
5121                let global = ctx.globals();
5122                let result: u32 = global.get("_splitId").unwrap();
5123                assert_eq!(result, 7);
5124            });
5125    }
5126
5127    // ==================== File System Tests ====================
5128
5129    #[test]
5130    fn test_api_file_exists() {
5131        let (mut backend, _rx) = create_test_backend();
5132
5133        backend
5134            .execute_js(
5135                r#"
5136            const editor = getEditor();
5137            // Test with a path that definitely exists
5138            globalThis._exists = editor.fileExists("/");
5139        "#,
5140                "test.js",
5141            )
5142            .unwrap();
5143
5144        backend
5145            .plugin_contexts
5146            .borrow()
5147            .get("test")
5148            .unwrap()
5149            .clone()
5150            .with(|ctx| {
5151                let global = ctx.globals();
5152                let result: bool = global.get("_exists").unwrap();
5153                assert!(result);
5154            });
5155    }
5156
5157    #[test]
5158    fn test_api_get_cwd() {
5159        let (mut backend, _rx) = create_test_backend();
5160
5161        backend
5162            .execute_js(
5163                r#"
5164            const editor = getEditor();
5165            globalThis._cwd = editor.getCwd();
5166        "#,
5167                "test.js",
5168            )
5169            .unwrap();
5170
5171        backend
5172            .plugin_contexts
5173            .borrow()
5174            .get("test")
5175            .unwrap()
5176            .clone()
5177            .with(|ctx| {
5178                let global = ctx.globals();
5179                let result: String = global.get("_cwd").unwrap();
5180                // Should return some path
5181                assert!(!result.is_empty());
5182            });
5183    }
5184
5185    #[test]
5186    fn test_api_get_env() {
5187        let (mut backend, _rx) = create_test_backend();
5188
5189        // Set a test environment variable
5190        std::env::set_var("TEST_PLUGIN_VAR", "test_value");
5191
5192        backend
5193            .execute_js(
5194                r#"
5195            const editor = getEditor();
5196            globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
5197        "#,
5198                "test.js",
5199            )
5200            .unwrap();
5201
5202        backend
5203            .plugin_contexts
5204            .borrow()
5205            .get("test")
5206            .unwrap()
5207            .clone()
5208            .with(|ctx| {
5209                let global = ctx.globals();
5210                let result: Option<String> = global.get("_envVal").unwrap();
5211                assert_eq!(result, Some("test_value".to_string()));
5212            });
5213
5214        std::env::remove_var("TEST_PLUGIN_VAR");
5215    }
5216
5217    #[test]
5218    fn test_api_get_config() {
5219        let (mut backend, _rx) = create_test_backend();
5220
5221        backend
5222            .execute_js(
5223                r#"
5224            const editor = getEditor();
5225            const config = editor.getConfig();
5226            globalThis._isObject = typeof config === 'object';
5227        "#,
5228                "test.js",
5229            )
5230            .unwrap();
5231
5232        backend
5233            .plugin_contexts
5234            .borrow()
5235            .get("test")
5236            .unwrap()
5237            .clone()
5238            .with(|ctx| {
5239                let global = ctx.globals();
5240                let is_object: bool = global.get("_isObject").unwrap();
5241                // getConfig should return an object, not a string
5242                assert!(is_object);
5243            });
5244    }
5245
5246    #[test]
5247    fn test_api_get_themes_dir() {
5248        let (mut backend, _rx) = create_test_backend();
5249
5250        backend
5251            .execute_js(
5252                r#"
5253            const editor = getEditor();
5254            globalThis._themesDir = editor.getThemesDir();
5255        "#,
5256                "test.js",
5257            )
5258            .unwrap();
5259
5260        backend
5261            .plugin_contexts
5262            .borrow()
5263            .get("test")
5264            .unwrap()
5265            .clone()
5266            .with(|ctx| {
5267                let global = ctx.globals();
5268                let result: String = global.get("_themesDir").unwrap();
5269                // Should return some path
5270                assert!(!result.is_empty());
5271            });
5272    }
5273
5274    // ==================== Read Dir Test ====================
5275
5276    #[test]
5277    fn test_api_read_dir() {
5278        let (mut backend, _rx) = create_test_backend();
5279
5280        backend
5281            .execute_js(
5282                r#"
5283            const editor = getEditor();
5284            const entries = editor.readDir("/tmp");
5285            globalThis._isArray = Array.isArray(entries);
5286            globalThis._length = entries.length;
5287        "#,
5288                "test.js",
5289            )
5290            .unwrap();
5291
5292        backend
5293            .plugin_contexts
5294            .borrow()
5295            .get("test")
5296            .unwrap()
5297            .clone()
5298            .with(|ctx| {
5299                let global = ctx.globals();
5300                let is_array: bool = global.get("_isArray").unwrap();
5301                let length: u32 = global.get("_length").unwrap();
5302                // /tmp should exist and readDir should return an array
5303                assert!(is_array);
5304                // Length is valid (u32 always >= 0)
5305                let _ = length;
5306            });
5307    }
5308
5309    // ==================== Execute Action Test ====================
5310
5311    #[test]
5312    fn test_api_execute_action() {
5313        let (mut backend, rx) = create_test_backend();
5314
5315        backend
5316            .execute_js(
5317                r#"
5318            const editor = getEditor();
5319            editor.executeAction("move_cursor_up");
5320        "#,
5321                "test.js",
5322            )
5323            .unwrap();
5324
5325        let cmd = rx.try_recv().unwrap();
5326        match cmd {
5327            PluginCommand::ExecuteAction { action_name } => {
5328                assert_eq!(action_name, "move_cursor_up");
5329            }
5330            _ => panic!("Expected ExecuteAction, got {:?}", cmd),
5331        }
5332    }
5333
5334    // ==================== Debug Test ====================
5335
5336    #[test]
5337    fn test_api_debug() {
5338        let (mut backend, _rx) = create_test_backend();
5339
5340        // debug() should not panic and should work with any input
5341        backend
5342            .execute_js(
5343                r#"
5344            const editor = getEditor();
5345            editor.debug("Test debug message");
5346            editor.debug("Another message with special chars: <>&\"'");
5347        "#,
5348                "test.js",
5349            )
5350            .unwrap();
5351        // If we get here without panic, the test passes
5352    }
5353
5354    // ==================== TypeScript Definitions Test ====================
5355
5356    #[test]
5357    fn test_typescript_preamble_generated() {
5358        // Check that the TypeScript preamble constant exists and has content
5359        assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
5360        assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
5361        assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
5362        println!(
5363            "Generated {} bytes of TypeScript preamble",
5364            JSEDITORAPI_TS_PREAMBLE.len()
5365        );
5366    }
5367
5368    #[test]
5369    fn test_typescript_editor_api_generated() {
5370        // Check that the EditorAPI interface is generated
5371        assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
5372        assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
5373        println!(
5374            "Generated {} bytes of EditorAPI interface",
5375            JSEDITORAPI_TS_EDITOR_API.len()
5376        );
5377    }
5378
5379    #[test]
5380    fn test_js_methods_list() {
5381        // Check that the JS methods list is generated
5382        assert!(!JSEDITORAPI_JS_METHODS.is_empty());
5383        println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
5384        // Print first 20 methods
5385        for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
5386            if i < 20 {
5387                println!("  - {}", method);
5388            }
5389        }
5390        if JSEDITORAPI_JS_METHODS.len() > 20 {
5391            println!("  ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
5392        }
5393    }
5394
5395    // ==================== Plugin Management API Tests ====================
5396
5397    #[test]
5398    fn test_api_load_plugin_sends_command() {
5399        let (mut backend, rx) = create_test_backend();
5400
5401        // Call loadPlugin - this returns a Promise and sends the command
5402        backend
5403            .execute_js(
5404                r#"
5405            const editor = getEditor();
5406            globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
5407        "#,
5408                "test.js",
5409            )
5410            .unwrap();
5411
5412        // Verify the LoadPlugin command was sent
5413        let cmd = rx.try_recv().unwrap();
5414        match cmd {
5415            PluginCommand::LoadPlugin { path, callback_id } => {
5416                assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
5417                assert!(callback_id.0 > 0); // Should have a valid callback ID
5418            }
5419            _ => panic!("Expected LoadPlugin, got {:?}", cmd),
5420        }
5421    }
5422
5423    #[test]
5424    fn test_api_unload_plugin_sends_command() {
5425        let (mut backend, rx) = create_test_backend();
5426
5427        // Call unloadPlugin - this returns a Promise and sends the command
5428        backend
5429            .execute_js(
5430                r#"
5431            const editor = getEditor();
5432            globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
5433        "#,
5434                "test.js",
5435            )
5436            .unwrap();
5437
5438        // Verify the UnloadPlugin command was sent
5439        let cmd = rx.try_recv().unwrap();
5440        match cmd {
5441            PluginCommand::UnloadPlugin { name, callback_id } => {
5442                assert_eq!(name, "my-plugin");
5443                assert!(callback_id.0 > 0); // Should have a valid callback ID
5444            }
5445            _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
5446        }
5447    }
5448
5449    #[test]
5450    fn test_api_reload_plugin_sends_command() {
5451        let (mut backend, rx) = create_test_backend();
5452
5453        // Call reloadPlugin - this returns a Promise and sends the command
5454        backend
5455            .execute_js(
5456                r#"
5457            const editor = getEditor();
5458            globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
5459        "#,
5460                "test.js",
5461            )
5462            .unwrap();
5463
5464        // Verify the ReloadPlugin command was sent
5465        let cmd = rx.try_recv().unwrap();
5466        match cmd {
5467            PluginCommand::ReloadPlugin { name, callback_id } => {
5468                assert_eq!(name, "my-plugin");
5469                assert!(callback_id.0 > 0); // Should have a valid callback ID
5470            }
5471            _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
5472        }
5473    }
5474
5475    #[test]
5476    fn test_api_load_plugin_resolves_callback() {
5477        let (mut backend, rx) = create_test_backend();
5478
5479        // Call loadPlugin and set up a handler for when it resolves
5480        backend
5481            .execute_js(
5482                r#"
5483            const editor = getEditor();
5484            globalThis._loadResult = null;
5485            editor.loadPlugin("/path/to/plugin.ts").then(result => {
5486                globalThis._loadResult = result;
5487            });
5488        "#,
5489                "test.js",
5490            )
5491            .unwrap();
5492
5493        // Get the callback_id from the command
5494        let callback_id = match rx.try_recv().unwrap() {
5495            PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
5496            cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
5497        };
5498
5499        // Simulate the editor responding with success
5500        backend.resolve_callback(callback_id, "true");
5501
5502        // Drive the Promise to completion
5503        backend
5504            .plugin_contexts
5505            .borrow()
5506            .get("test")
5507            .unwrap()
5508            .clone()
5509            .with(|ctx| {
5510                run_pending_jobs_checked(&ctx, "test async loadPlugin");
5511            });
5512
5513        // Verify the Promise resolved with true
5514        backend
5515            .plugin_contexts
5516            .borrow()
5517            .get("test")
5518            .unwrap()
5519            .clone()
5520            .with(|ctx| {
5521                let global = ctx.globals();
5522                let result: bool = global.get("_loadResult").unwrap();
5523                assert!(result);
5524            });
5525    }
5526
5527    #[test]
5528    fn test_api_unload_plugin_rejects_on_error() {
5529        let (mut backend, rx) = create_test_backend();
5530
5531        // Call unloadPlugin and set up handlers for resolve/reject
5532        backend
5533            .execute_js(
5534                r#"
5535            const editor = getEditor();
5536            globalThis._unloadError = null;
5537            editor.unloadPlugin("nonexistent-plugin").catch(err => {
5538                globalThis._unloadError = err.message || String(err);
5539            });
5540        "#,
5541                "test.js",
5542            )
5543            .unwrap();
5544
5545        // Get the callback_id from the command
5546        let callback_id = match rx.try_recv().unwrap() {
5547            PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
5548            cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
5549        };
5550
5551        // Simulate the editor responding with an error
5552        backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
5553
5554        // Drive the Promise to completion
5555        backend
5556            .plugin_contexts
5557            .borrow()
5558            .get("test")
5559            .unwrap()
5560            .clone()
5561            .with(|ctx| {
5562                run_pending_jobs_checked(&ctx, "test async unloadPlugin");
5563            });
5564
5565        // Verify the Promise rejected with the error
5566        backend
5567            .plugin_contexts
5568            .borrow()
5569            .get("test")
5570            .unwrap()
5571            .clone()
5572            .with(|ctx| {
5573                let global = ctx.globals();
5574                let error: String = global.get("_unloadError").unwrap();
5575                assert!(error.contains("nonexistent-plugin"));
5576            });
5577    }
5578}