Skip to main content

fresh_core/
api.rs

1//! Plugin API: Safe interface for plugins to interact with the editor
2//!
3//! This module provides a safe, controlled API for plugins (Lua, WASM, etc.)
4//! to interact with the editor without direct access to internal state.
5//!
6//! # Type Safety Architecture
7//!
8//! Rust structs in this module serve as the **single source of truth** for the
9//! TypeScript plugin API. The type safety system works as follows:
10//!
11//! ```text
12//! Rust struct                  Generated TypeScript
13//! ───────────                  ────────────────────
14//! #[derive(TS, Deserialize)]   type ActionPopupOptions = {
15//! #[serde(deny_unknown_fields)]    id: string;
16//! struct ActionPopupOptions {      title: string;
17//!     id: String,                  message: string;
18//!     title: String,               actions: TsActionPopupAction[];
19//!     ...                      };
20//! }
21//! ```
22//!
23//! ## Key Patterns
24//!
25//! 1. **`#[derive(TS)]`** - Generates TypeScript type definitions via ts-rs
26//! 2. **`#[serde(deny_unknown_fields)]`** - Rejects typos/unknown fields at runtime
27//! 3. **`impl FromJs`** - Bridges rquickjs values to typed Rust structs
28//!
29//! ## Validation Layers
30//!
31//! | Layer                  | What it catches                          |
32//! |------------------------|------------------------------------------|
33//! | TypeScript compile     | Wrong field names, missing required fields |
34//! | Rust runtime (serde)   | Typos like `popup_id` instead of `id`    |
35//! | Rust compile           | Type mismatches in method signatures     |
36//!
37//! ## Limitations & Tradeoffs
38//!
39//! - **Manual parsing for complex types**: Some methods (e.g., `submitViewTransform`)
40//!   still use manual object parsing due to enum serialization complexity
41//! - **Two-step deserialization**: Complex nested structs may need
42//!   `rquickjs::Value → serde_json::Value → typed struct` due to rquickjs_serde limits
43//! - **Duplicate attributes**: Both `#[serde(...)]` and `#[ts(...)]` needed since
44//!   they control different things (runtime serialization vs compile-time codegen)
45
46use crate::command::{Command, Suggestion};
47use crate::file_explorer::FileExplorerDecoration;
48use crate::hooks::{HookCallback, HookRegistry};
49use crate::menu::{Menu, MenuItem};
50use crate::overlay::{OverlayHandle, OverlayNamespace};
51use crate::text_property::{TextProperty, TextPropertyEntry};
52use crate::BufferId;
53use crate::SplitId;
54use crate::TerminalId;
55use lsp_types;
56use serde::{Deserialize, Serialize};
57use serde_json::Value as JsonValue;
58use std::collections::HashMap;
59use std::ops::Range;
60use std::path::PathBuf;
61use std::sync::{Arc, RwLock};
62use ts_rs::TS;
63
64/// Minimal command registry for PluginApi.
65/// This is a stub that provides basic command storage for plugin use.
66/// The editor's full CommandRegistry lives in fresh-editor.
67pub struct CommandRegistry {
68    commands: std::sync::RwLock<Vec<Command>>,
69}
70
71impl CommandRegistry {
72    /// Create a new empty command registry
73    pub fn new() -> Self {
74        Self {
75            commands: std::sync::RwLock::new(Vec::new()),
76        }
77    }
78
79    /// Register a command
80    pub fn register(&self, command: Command) {
81        let mut commands = self.commands.write().unwrap();
82        commands.retain(|c| c.name != command.name);
83        commands.push(command);
84    }
85
86    /// Unregister a command by name  
87    pub fn unregister(&self, name: &str) {
88        let mut commands = self.commands.write().unwrap();
89        commands.retain(|c| c.name != name);
90    }
91}
92
93impl Default for CommandRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99/// A callback ID for JavaScript promises in the plugin runtime.
100///
101/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
102/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
103/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
105#[ts(export)]
106pub struct JsCallbackId(pub u64);
107
108impl JsCallbackId {
109    /// Create a new JS callback ID
110    pub fn new(id: u64) -> Self {
111        Self(id)
112    }
113
114    /// Get the underlying u64 value
115    pub fn as_u64(self) -> u64 {
116        self.0
117    }
118}
119
120impl From<u64> for JsCallbackId {
121    fn from(id: u64) -> Self {
122        Self(id)
123    }
124}
125
126impl From<JsCallbackId> for u64 {
127    fn from(id: JsCallbackId) -> u64 {
128        id.0
129    }
130}
131
132impl std::fmt::Display for JsCallbackId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138/// Result of creating a terminal
139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, rename_all = "camelCase")]
142pub struct TerminalResult {
143    /// The created buffer ID (for use with setSplitBuffer, etc.)
144    #[ts(type = "number")]
145    pub buffer_id: u64,
146    /// The terminal ID (for use with sendTerminalInput, closeTerminal)
147    #[ts(type = "number")]
148    pub terminal_id: u64,
149    /// The split ID (if created in a new split)
150    #[ts(type = "number | null")]
151    pub split_id: Option<u64>,
152}
153
154/// Result of creating a virtual buffer
155#[derive(Debug, Clone, Serialize, Deserialize, TS)]
156#[serde(rename_all = "camelCase")]
157#[ts(export, rename_all = "camelCase")]
158pub struct VirtualBufferResult {
159    /// The created buffer ID
160    #[ts(type = "number")]
161    pub buffer_id: u64,
162    /// The split ID (if created in a new split)
163    #[ts(type = "number | null")]
164    pub split_id: Option<u64>,
165}
166
167/// Result of creating a buffer group
168#[derive(Debug, Clone, Serialize, Deserialize, TS)]
169#[serde(rename_all = "camelCase")]
170#[ts(export, rename_all = "camelCase")]
171pub struct BufferGroupResult {
172    /// The group ID
173    #[ts(type = "number")]
174    pub group_id: u64,
175    /// Panel buffer IDs, keyed by panel name
176    #[ts(type = "Record<string, number>")]
177    pub panels: HashMap<String, u64>,
178}
179
180/// Response from the editor for async plugin operations
181#[derive(Debug, Clone, Serialize, Deserialize, TS)]
182#[ts(export)]
183pub enum PluginResponse {
184    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
185    VirtualBufferCreated {
186        request_id: u64,
187        buffer_id: BufferId,
188        split_id: Option<SplitId>,
189    },
190    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
191    TerminalCreated {
192        request_id: u64,
193        buffer_id: BufferId,
194        terminal_id: TerminalId,
195        split_id: Option<SplitId>,
196    },
197    /// Response to a plugin-initiated LSP request
198    LspRequest {
199        request_id: u64,
200        #[ts(type = "any")]
201        result: Result<JsonValue, String>,
202    },
203    /// Response to RequestHighlights
204    HighlightsComputed {
205        request_id: u64,
206        spans: Vec<TsHighlightSpan>,
207    },
208    /// Response to GetBufferText with the text content
209    BufferText {
210        request_id: u64,
211        text: Result<String, String>,
212    },
213    /// Response to GetLineStartPosition with the byte offset
214    LineStartPosition {
215        request_id: u64,
216        /// None if line is out of range, Some(offset) for valid line
217        position: Option<usize>,
218    },
219    /// Response to GetLineEndPosition with the byte offset
220    LineEndPosition {
221        request_id: u64,
222        /// None if line is out of range, Some(offset) for valid line
223        position: Option<usize>,
224    },
225    /// Response to GetBufferLineCount with the total number of lines
226    BufferLineCount {
227        request_id: u64,
228        /// None if buffer not found, Some(count) for valid buffer
229        count: Option<usize>,
230    },
231    /// Response to CreateCompositeBuffer with the created buffer ID
232    CompositeBufferCreated {
233        request_id: u64,
234        buffer_id: BufferId,
235    },
236    /// Response to GetSplitByLabel with the found split ID (if any)
237    SplitByLabel {
238        request_id: u64,
239        split_id: Option<SplitId>,
240    },
241}
242
243/// Messages sent from async plugin tasks to the synchronous main loop
244#[derive(Debug, Clone, Serialize, Deserialize, TS)]
245#[ts(export)]
246pub enum PluginAsyncMessage {
247    /// Plugin process completed with output
248    ProcessOutput {
249        /// Unique ID for this process
250        process_id: u64,
251        /// Standard output
252        stdout: String,
253        /// Standard error
254        stderr: String,
255        /// Exit code
256        exit_code: i32,
257    },
258    /// Plugin delay/timer completed
259    DelayComplete {
260        /// Callback ID to resolve
261        callback_id: u64,
262    },
263    /// Background process stdout data
264    ProcessStdout { process_id: u64, data: String },
265    /// Background process stderr data
266    ProcessStderr { process_id: u64, data: String },
267    /// Background process exited
268    ProcessExit {
269        process_id: u64,
270        callback_id: u64,
271        exit_code: i32,
272    },
273    /// Response for a plugin-initiated LSP request
274    LspResponse {
275        language: String,
276        request_id: u64,
277        #[ts(type = "any")]
278        result: Result<JsonValue, String>,
279    },
280    /// Generic plugin response (e.g., GetBufferText result)
281    PluginResponse(crate::api::PluginResponse),
282
283    /// Streaming grep: partial results for one file
284    GrepStreamingProgress {
285        /// Search ID to route to the correct progress callback
286        search_id: u64,
287        /// Matches from a single file
288        matches_json: String,
289    },
290
291    /// Streaming grep: search complete
292    GrepStreamingComplete {
293        /// Search ID
294        search_id: u64,
295        /// Callback ID for the completion promise
296        callback_id: u64,
297        /// Total number of matches found
298        total_matches: usize,
299        /// Whether the search was stopped early due to reaching max_results
300        truncated: bool,
301    },
302}
303
304/// Information about a cursor in the editor
305#[derive(Debug, Clone, Serialize, Deserialize, TS)]
306#[ts(export)]
307pub struct CursorInfo {
308    /// Byte position of the cursor
309    pub position: usize,
310    /// Selection range (if any)
311    #[cfg_attr(
312        feature = "plugins",
313        ts(type = "{ start: number; end: number } | null")
314    )]
315    pub selection: Option<Range<usize>>,
316}
317
318/// Specification for an action to execute, with optional repeat count
319#[derive(Debug, Clone, Serialize, Deserialize, TS)]
320#[serde(deny_unknown_fields)]
321#[ts(export)]
322pub struct ActionSpec {
323    /// Action name (e.g., "move_word_right", "delete_line")
324    pub action: String,
325    /// Number of times to repeat the action (default 1)
326    #[serde(default = "default_action_count")]
327    pub count: u32,
328}
329
330fn default_action_count() -> u32 {
331    1
332}
333
334/// Information about a buffer
335#[derive(Debug, Clone, Serialize, Deserialize, TS)]
336#[ts(export)]
337pub struct BufferInfo {
338    /// Buffer ID
339    #[ts(type = "number")]
340    pub id: BufferId,
341    /// File path (if any)
342    #[serde(serialize_with = "serialize_path")]
343    #[ts(type = "string")]
344    pub path: Option<PathBuf>,
345    /// Whether the buffer has been modified
346    pub modified: bool,
347    /// Length of buffer in bytes
348    pub length: usize,
349    /// Whether this is a virtual buffer (not backed by a file)
350    pub is_virtual: bool,
351    /// Current view mode of the active split: "source" or "compose"
352    pub view_mode: String,
353    /// True if any split showing this buffer has compose mode enabled.
354    /// Plugins should use this (not `view_mode`) to decide whether to maintain
355    /// decorations, since decorations live on the buffer and are filtered
356    /// per-split at render time.
357    pub is_composing_in_any_split: bool,
358    /// Compose width (if set), from the active split's view state
359    pub compose_width: Option<u16>,
360    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
361    pub language: String,
362    /// Whether this tab was opened in "preview" (ephemeral) mode — true when
363    /// opened via single-click in the file explorer and not yet committed
364    /// (no edit, no double-click, no tab-click, no layout change). Plugins
365    /// that react to buffer lifecycle events should generally treat preview
366    /// buffers as transient; e.g. a diagnostics panel may want to skip
367    /// refreshing itself for a preview tab.
368    #[serde(default)]
369    pub is_preview: bool,
370    /// Split ids that currently hold this buffer (empty when the buffer is
371    /// open but not visible in any split — e.g. background-opened tabs
372    /// that haven't been focused). Lets plugins implement "focus existing
373    /// buffer if visible, else open new" without having to track split
374    /// ids across editor restarts (which reassign them). The list is a
375    /// snapshot at the last `update_plugin_state_snapshot` tick.
376    #[serde(default)]
377    #[ts(type = "number[]")]
378    pub splits: Vec<SplitId>,
379}
380
381fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
382    s.serialize_str(
383        &path
384            .as_ref()
385            .map(|p| p.to_string_lossy().to_string())
386            .unwrap_or_default(),
387    )
388}
389
390/// Serialize ranges as [start, end] tuples for JS compatibility
391fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
392where
393    S: serde::Serializer,
394{
395    use serde::ser::SerializeSeq;
396    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
397    for range in ranges {
398        seq.serialize_element(&(range.start, range.end))?;
399    }
400    seq.end()
401}
402
403/// Diff between current buffer content and last saved snapshot
404#[derive(Debug, Clone, Serialize, Deserialize, TS)]
405#[ts(export)]
406pub struct BufferSavedDiff {
407    pub equal: bool,
408    #[serde(serialize_with = "serialize_ranges_as_tuples")]
409    #[ts(type = "Array<[number, number]>")]
410    pub byte_ranges: Vec<Range<usize>>,
411}
412
413/// Information about the viewport
414#[derive(Debug, Clone, Serialize, Deserialize, TS)]
415#[serde(rename_all = "camelCase")]
416#[ts(export, rename_all = "camelCase")]
417pub struct ViewportInfo {
418    /// Byte position of the first visible line
419    pub top_byte: usize,
420    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
421    pub top_line: Option<usize>,
422    /// Left column offset (horizontal scroll)
423    pub left_column: usize,
424    /// Viewport width
425    pub width: u16,
426    /// Viewport height
427    pub height: u16,
428}
429
430/// Layout hints supplied by plugins (e.g., Compose mode)
431#[derive(Debug, Clone, Serialize, Deserialize, TS)]
432#[serde(rename_all = "camelCase")]
433#[ts(export, rename_all = "camelCase")]
434pub struct LayoutHints {
435    /// Optional compose width for centering/wrapping
436    #[ts(optional)]
437    pub compose_width: Option<u16>,
438    /// Optional column guides for aligned tables
439    #[ts(optional)]
440    pub column_guides: Option<Vec<u16>>,
441}
442
443// ============================================================================
444// Overlay Types with Theme Support
445// ============================================================================
446
447/// Color specification that can be either RGB values or a theme key.
448///
449/// Theme keys reference colors from the current theme, e.g.:
450/// - "ui.status_bar_bg" - UI status bar background
451/// - "editor.selection_bg" - Editor selection background
452/// - "syntax.keyword" - Syntax highlighting for keywords
453/// - "diagnostic.error" - Error diagnostic color
454///
455/// When a theme key is used, the color is resolved at render time,
456/// so overlays automatically update when the theme changes.
457#[derive(Debug, Clone, Serialize, Deserialize, TS)]
458#[serde(untagged)]
459#[ts(export)]
460pub enum OverlayColorSpec {
461    /// RGB color as [r, g, b] array
462    #[ts(type = "[number, number, number]")]
463    Rgb(u8, u8, u8),
464    /// Theme key reference (e.g., "ui.status_bar_bg")
465    ThemeKey(String),
466}
467
468impl OverlayColorSpec {
469    /// Create an RGB color spec
470    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
471        Self::Rgb(r, g, b)
472    }
473
474    /// Create a theme key color spec
475    pub fn theme_key(key: impl Into<String>) -> Self {
476        Self::ThemeKey(key.into())
477    }
478
479    /// Convert to RGB if this is an RGB spec, None if it's a theme key
480    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
481        match self {
482            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
483            Self::ThemeKey(_) => None,
484        }
485    }
486
487    /// Get the theme key if this is a theme key spec
488    pub fn as_theme_key(&self) -> Option<&str> {
489        match self {
490            Self::ThemeKey(key) => Some(key),
491            Self::Rgb(_, _, _) => None,
492        }
493    }
494}
495
496/// Options for adding an overlay with theme support.
497///
498/// This struct provides a type-safe way to specify overlay styling
499/// with optional theme key references for colors.
500#[derive(Debug, Clone, Serialize, Deserialize, TS)]
501#[serde(deny_unknown_fields, rename_all = "camelCase")]
502#[ts(export, rename_all = "camelCase")]
503#[derive(Default)]
504pub struct OverlayOptions {
505    /// Foreground color - RGB array or theme key string
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub fg: Option<OverlayColorSpec>,
508
509    /// Background color - RGB array or theme key string
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub bg: Option<OverlayColorSpec>,
512
513    /// Whether to render with underline
514    #[serde(default)]
515    pub underline: bool,
516
517    /// Whether to render in bold
518    #[serde(default)]
519    pub bold: bool,
520
521    /// Whether to render in italic
522    #[serde(default)]
523    pub italic: bool,
524
525    /// Whether to render with strikethrough
526    #[serde(default)]
527    pub strikethrough: bool,
528
529    /// Whether to extend background color to end of line
530    #[serde(default)]
531    pub extend_to_line_end: bool,
532
533    /// Optional URL for OSC 8 terminal hyperlinks.
534    /// When set, the overlay text becomes a clickable hyperlink in terminals
535    /// that support OSC 8 escape sequences.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub url: Option<String>,
538}
539
540// ============================================================================
541// Composite Buffer Configuration (for multi-buffer single-tab views)
542// ============================================================================
543
544/// Layout configuration for composite buffers
545#[derive(Debug, Clone, Serialize, Deserialize, TS)]
546#[serde(deny_unknown_fields)]
547#[ts(export, rename = "TsCompositeLayoutConfig")]
548pub struct CompositeLayoutConfig {
549    /// Layout type: "side-by-side", "stacked", or "unified"
550    #[serde(rename = "type")]
551    #[ts(rename = "type")]
552    pub layout_type: String,
553    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
554    #[serde(default)]
555    #[ts(optional)]
556    pub ratios: Option<Vec<f32>>,
557    /// Show separator between panes
558    #[serde(default = "default_true", rename = "showSeparator")]
559    #[ts(rename = "showSeparator")]
560    pub show_separator: bool,
561    /// Spacing for stacked layout
562    #[serde(default)]
563    #[ts(optional)]
564    pub spacing: Option<u16>,
565}
566
567fn default_true() -> bool {
568    true
569}
570
571/// Source pane configuration for composite buffers
572#[derive(Debug, Clone, Serialize, Deserialize, TS)]
573#[serde(deny_unknown_fields)]
574#[ts(export, rename = "TsCompositeSourceConfig")]
575pub struct CompositeSourceConfig {
576    /// Buffer ID of the source buffer (required)
577    #[serde(rename = "bufferId")]
578    #[ts(rename = "bufferId")]
579    pub buffer_id: usize,
580    /// Label for this pane (e.g., "OLD", "NEW")
581    pub label: String,
582    /// Whether this pane is editable
583    #[serde(default)]
584    pub editable: bool,
585    /// Style configuration
586    #[serde(default)]
587    pub style: Option<CompositePaneStyle>,
588}
589
590/// Style configuration for a composite pane
591#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
592#[serde(deny_unknown_fields)]
593#[ts(export, rename = "TsCompositePaneStyle")]
594pub struct CompositePaneStyle {
595    /// Background color for added lines (RGB)
596    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
597    #[serde(default, rename = "addBg")]
598    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
599    pub add_bg: Option<[u8; 3]>,
600    /// Background color for removed lines (RGB)
601    #[serde(default, rename = "removeBg")]
602    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
603    pub remove_bg: Option<[u8; 3]>,
604    /// Background color for modified lines (RGB)
605    #[serde(default, rename = "modifyBg")]
606    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
607    pub modify_bg: Option<[u8; 3]>,
608    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
609    #[serde(default, rename = "gutterStyle")]
610    #[ts(optional, rename = "gutterStyle")]
611    pub gutter_style: Option<String>,
612}
613
614/// Diff hunk for composite buffer alignment
615#[derive(Debug, Clone, Serialize, Deserialize, TS)]
616#[serde(deny_unknown_fields)]
617#[ts(export, rename = "TsCompositeHunk")]
618pub struct CompositeHunk {
619    /// Starting line in old buffer (0-indexed)
620    #[serde(rename = "oldStart")]
621    #[ts(rename = "oldStart")]
622    pub old_start: usize,
623    /// Number of lines in old buffer
624    #[serde(rename = "oldCount")]
625    #[ts(rename = "oldCount")]
626    pub old_count: usize,
627    /// Starting line in new buffer (0-indexed)
628    #[serde(rename = "newStart")]
629    #[ts(rename = "newStart")]
630    pub new_start: usize,
631    /// Number of lines in new buffer
632    #[serde(rename = "newCount")]
633    #[ts(rename = "newCount")]
634    pub new_count: usize,
635}
636
637/// Options for creating a composite buffer (used by plugin API)
638#[derive(Debug, Clone, Serialize, Deserialize, TS)]
639#[serde(deny_unknown_fields)]
640#[ts(export, rename = "TsCreateCompositeBufferOptions")]
641pub struct CreateCompositeBufferOptions {
642    /// Buffer name (displayed in tabs/title)
643    #[serde(default)]
644    pub name: String,
645    /// Mode for keybindings
646    #[serde(default)]
647    pub mode: String,
648    /// Layout configuration
649    pub layout: CompositeLayoutConfig,
650    /// Source pane configurations
651    pub sources: Vec<CompositeSourceConfig>,
652    /// Diff hunks for alignment (optional)
653    #[serde(default)]
654    pub hunks: Option<Vec<CompositeHunk>>,
655    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
656    /// This avoids timing issues with imperative scroll commands that depend on
657    /// render-created state (viewport dimensions, view state).
658    #[serde(default, rename = "initialFocusHunk")]
659    #[ts(optional, rename = "initialFocusHunk")]
660    pub initial_focus_hunk: Option<usize>,
661}
662
663/// Wire-format view token kind (serialized for plugin transforms)
664#[derive(Debug, Clone, Serialize, Deserialize, TS)]
665#[ts(export)]
666pub enum ViewTokenWireKind {
667    Text(String),
668    Newline,
669    Space,
670    /// Visual line break inserted by wrapping (not from source)
671    /// Always has source_offset: None
672    Break,
673    /// A single binary byte that should be rendered as <XX>
674    /// Used in binary file mode to ensure cursor positioning works correctly
675    /// (all 4 display chars of <XX> map to the same source byte)
676    BinaryByte(u8),
677}
678
679/// Styling for view tokens (used for injected annotations)
680///
681/// This allows plugins to specify styling for tokens that don't have a source
682/// mapping (sourceOffset: None), such as annotation headers in git blame.
683/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
684#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
685#[serde(deny_unknown_fields)]
686#[ts(export)]
687pub struct ViewTokenStyle {
688    /// Foreground color as RGB tuple
689    #[serde(default)]
690    #[ts(type = "[number, number, number] | null")]
691    pub fg: Option<(u8, u8, u8)>,
692    /// Background color as RGB tuple
693    #[serde(default)]
694    #[ts(type = "[number, number, number] | null")]
695    pub bg: Option<(u8, u8, u8)>,
696    /// Whether to render in bold
697    #[serde(default)]
698    pub bold: bool,
699    /// Whether to render in italic
700    #[serde(default)]
701    pub italic: bool,
702}
703
704/// Wire-format view token with optional source mapping and styling
705#[derive(Debug, Clone, Serialize, Deserialize, TS)]
706#[serde(deny_unknown_fields)]
707#[ts(export)]
708pub struct ViewTokenWire {
709    /// Source byte offset in the buffer. None for injected content (annotations).
710    #[ts(type = "number | null")]
711    pub source_offset: Option<usize>,
712    /// The token content
713    pub kind: ViewTokenWireKind,
714    /// Optional styling for injected content (only used when source_offset is None)
715    #[serde(default)]
716    #[ts(optional)]
717    pub style: Option<ViewTokenStyle>,
718}
719
720/// Transformed view stream payload (plugin-provided)
721#[derive(Debug, Clone, Serialize, Deserialize, TS)]
722#[ts(export)]
723pub struct ViewTransformPayload {
724    /// Byte range this transform applies to (viewport)
725    pub range: Range<usize>,
726    /// Tokens in wire format
727    pub tokens: Vec<ViewTokenWire>,
728    /// Layout hints
729    pub layout_hints: Option<LayoutHints>,
730}
731
732/// Snapshot of editor state for plugin queries
733/// This is updated by the editor on each loop iteration
734#[derive(Debug, Clone, Serialize, Deserialize, TS)]
735#[ts(export)]
736pub struct EditorStateSnapshot {
737    /// Currently active buffer ID
738    pub active_buffer_id: BufferId,
739    /// Currently active split ID
740    pub active_split_id: usize,
741    /// Information about all open buffers
742    pub buffers: HashMap<BufferId, BufferInfo>,
743    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
744    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
745    /// Primary cursor position for the active buffer
746    pub primary_cursor: Option<CursorInfo>,
747    /// All cursor positions for the active buffer
748    pub all_cursors: Vec<CursorInfo>,
749    /// Viewport information for the active buffer
750    pub viewport: Option<ViewportInfo>,
751    /// Cursor positions per buffer (for buffers other than active)
752    pub buffer_cursor_positions: HashMap<BufferId, usize>,
753    /// Text properties per buffer (for virtual buffers with properties)
754    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
755    /// Selected text from the primary cursor (if any selection exists)
756    /// This is populated on each update to avoid needing full buffer access
757    pub selected_text: Option<String>,
758    /// Internal clipboard content (for plugins that need clipboard access)
759    pub clipboard: String,
760    /// Editor's working directory (for file operations and spawning processes)
761    pub working_dir: PathBuf,
762    /// Status-bar / explorer label for the active authority.
763    ///
764    /// Empty = the local (default) authority with nothing to render.
765    /// Non-empty means a non-local authority is installed (e.g.
766    /// `"Container:abc123def456"` for a devcontainer). Plugins can
767    /// read this via `editor.getAuthorityLabel()` to detect "already
768    /// attached" without having to track state across editor restarts.
769    #[serde(default)]
770    pub authority_label: String,
771    /// LSP diagnostics per file URI.
772    /// Maps file URI string to Vec of diagnostics for that file.
773    ///
774    /// Wrapped in `Arc` so snapshot refresh is a refcount bump rather than
775    /// a deep clone. The editor only mutates its own map through
776    /// `Arc::make_mut`, which CoW-clones while this snapshot still holds
777    /// a reference — a reader can never observe an in-place mutation.
778    ///
779    /// `#[serde(skip)]`: serde out-of-the-box can't serialize `Arc<T>`
780    /// (behind the `rc` cargo feature we don't enable). We never serialize
781    /// the snapshot as a whole — plugin readers pull out these Arcs and
782    /// serialize the *inner* value directly (e.g. `get_all_diagnostics`).
783    #[serde(skip)]
784    #[ts(type = "any")]
785    pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
786    /// LSP folding ranges per file URI.
787    /// Maps file URI string to Vec of folding ranges for that file.
788    /// Arc-wrapped for the same CoW invariant as `diagnostics`; see that
789    /// field for why this is `#[serde(skip)]`.
790    #[serde(skip)]
791    #[ts(type = "any")]
792    pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
793    /// Runtime config as serde_json::Value (merged user config + defaults).
794    /// This is the runtime config, not just the user's config file.
795    ///
796    /// Wrapped in `Arc` so the snapshot update is a refcount bump. The
797    /// editor reserializes its source `Config` only when the underlying
798    /// `Arc<Config>` pointer has moved (i.e., after a real mutation), and
799    /// swaps the whole `Arc<Value>` atomically — callers never see a
800    /// partially-updated blob. `#[serde(skip)]` for the same reason as
801    /// `diagnostics`.
802    #[serde(skip)]
803    #[ts(type = "any")]
804    pub config: Arc<serde_json::Value>,
805    /// User config as serde_json::Value (only what's in the user's config file).
806    /// Fields not present here are using default values.
807    /// Arc-wrapped; swapped as a whole when the user's file is reloaded.
808    /// `#[serde(skip)]` for the same reason as `diagnostics`.
809    #[serde(skip)]
810    #[ts(type = "any")]
811    pub user_config: Arc<serde_json::Value>,
812    /// Available grammars with provenance info, updated when grammar registry changes
813    #[ts(type = "GrammarInfo[]")]
814    pub available_grammars: Vec<GrammarInfoSnapshot>,
815    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
816    /// When set, this mode's keybindings take precedence over normal key handling
817    pub editor_mode: Option<String>,
818
819    /// Plugin-managed per-buffer view state for the active split.
820    /// Updated from BufferViewState.plugin_state during snapshot updates.
821    /// Also written directly by JS plugins via setViewState for immediate read-back.
822    #[ts(type = "any")]
823    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
824
825    /// Tracks which split was active when plugin_view_states was last populated.
826    /// When the active split changes, plugin_view_states is fully repopulated.
827    #[serde(skip)]
828    #[ts(skip)]
829    pub plugin_view_states_split: usize,
830
831    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
832    /// Updated when modes are registered via defineMode().
833    #[serde(skip)]
834    #[ts(skip)]
835    pub keybinding_labels: HashMap<String, String>,
836
837    /// Plugin-managed global state, isolated per plugin.
838    /// Outer key is plugin name, inner key is the state key set by the plugin.
839    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
840    /// Currently we isolate by plugin name, but we may want a more robust approach
841    /// (e.g. preventing plugins from reading each other's state, or providing
842    /// explicit cross-plugin state sharing APIs).
843    #[ts(type = "any")]
844    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
845}
846
847impl EditorStateSnapshot {
848    pub fn new() -> Self {
849        Self {
850            active_buffer_id: BufferId(0),
851            active_split_id: 0,
852            buffers: HashMap::new(),
853            buffer_saved_diffs: HashMap::new(),
854            primary_cursor: None,
855            all_cursors: Vec::new(),
856            viewport: None,
857            buffer_cursor_positions: HashMap::new(),
858            buffer_text_properties: HashMap::new(),
859            selected_text: None,
860            clipboard: String::new(),
861            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
862            authority_label: String::new(),
863            diagnostics: Arc::new(HashMap::new()),
864            folding_ranges: Arc::new(HashMap::new()),
865            config: Arc::new(serde_json::Value::Null),
866            user_config: Arc::new(serde_json::Value::Null),
867            available_grammars: Vec::new(),
868            editor_mode: None,
869            plugin_view_states: HashMap::new(),
870            plugin_view_states_split: 0,
871            keybinding_labels: HashMap::new(),
872            plugin_global_states: HashMap::new(),
873        }
874    }
875}
876
877impl Default for EditorStateSnapshot {
878    fn default() -> Self {
879        Self::new()
880    }
881}
882
883/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
884#[derive(Debug, Clone, Serialize, Deserialize, TS)]
885#[ts(export)]
886pub struct GrammarInfoSnapshot {
887    /// The grammar name as used in config files (case-insensitive matching)
888    pub name: String,
889    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
890    pub source: String,
891    /// File extensions associated with this grammar
892    pub file_extensions: Vec<String>,
893    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
894    pub short_name: Option<String>,
895}
896
897/// Position for inserting menu items or menus
898#[derive(Debug, Clone, Serialize, Deserialize, TS)]
899#[ts(export)]
900pub enum MenuPosition {
901    /// Add at the beginning
902    Top,
903    /// Add at the end
904    Bottom,
905    /// Add before a specific label
906    Before(String),
907    /// Add after a specific label
908    After(String),
909}
910
911/// Plugin command - allows plugins to send commands to the editor
912#[derive(Debug, Clone, Serialize, Deserialize, TS)]
913#[ts(export)]
914pub enum PluginCommand {
915    /// Insert text at a position in a buffer
916    InsertText {
917        buffer_id: BufferId,
918        position: usize,
919        text: String,
920    },
921
922    /// Delete a range of text from a buffer
923    DeleteRange {
924        buffer_id: BufferId,
925        range: Range<usize>,
926    },
927
928    /// Add an overlay to a buffer, returns handle via response channel
929    ///
930    /// Colors can be specified as RGB tuples or theme keys. When theme keys
931    /// are provided, they take precedence and are resolved at render time.
932    AddOverlay {
933        buffer_id: BufferId,
934        namespace: Option<OverlayNamespace>,
935        range: Range<usize>,
936        /// Overlay styling options (colors, modifiers, etc.)
937        options: OverlayOptions,
938    },
939
940    /// Remove an overlay by its opaque handle
941    RemoveOverlay {
942        buffer_id: BufferId,
943        handle: OverlayHandle,
944    },
945
946    /// Set status message
947    SetStatus { message: String },
948
949    /// Apply a theme by name
950    ApplyTheme { theme_name: String },
951
952    /// Override specific theme color keys in-memory for the running session.
953    /// Keys are the same `section.field` strings accepted by
954    /// `Theme::resolve_theme_key` (e.g. `"editor.bg"`, `"ui.status_bar_fg"`).
955    /// Values are `[r, g, b]` triplets. Unknown keys are silently dropped so
956    /// a typo in a fast animation loop doesn't blow up the caller; the
957    /// return channel isn't used — plugins can do a dry-run look-up via
958    /// `getThemeSchema` if they want compile-time safety. Overrides are
959    /// reset the next time the caller (or anyone else) invokes
960    /// `applyTheme`, because that replaces the whole `Theme` from the
961    /// registry.
962    OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
963
964    /// Reload configuration from file
965    /// After a plugin saves config changes, it should call this to reload the config
966    ReloadConfig,
967
968    /// Write a single setting to the runtime overlay for this session.
969    /// `path` is dot-separated (e.g. "editor.tab_size"). Last write wins.
970    SetSetting {
971        plugin_name: String,
972        path: String,
973        #[ts(type = "unknown")]
974        value: JsonValue,
975    },
976
977    /// Register a custom command
978    RegisterCommand { command: Command },
979
980    /// Unregister a command by name
981    UnregisterCommand { name: String },
982
983    /// Open a file in the editor (in background, without switching focus)
984    OpenFileInBackground { path: PathBuf },
985
986    /// Insert text at the current cursor position in the active buffer
987    InsertAtCursor { text: String },
988
989    /// Spawn an async process
990    SpawnProcess {
991        command: String,
992        args: Vec<String>,
993        cwd: Option<String>,
994        callback_id: JsCallbackId,
995    },
996
997    /// Delay/sleep for a duration (async, resolves callback when done)
998    Delay {
999        callback_id: JsCallbackId,
1000        duration_ms: u64,
1001    },
1002
1003    /// Spawn a long-running background process
1004    /// Unlike SpawnProcess, this returns immediately with a process handle
1005    /// and provides streaming output via hooks
1006    SpawnBackgroundProcess {
1007        /// Unique ID for this process (generated by plugin runtime)
1008        process_id: u64,
1009        /// Command to execute
1010        command: String,
1011        /// Arguments to pass
1012        args: Vec<String>,
1013        /// Working directory (optional)
1014        cwd: Option<String>,
1015        /// Callback ID to call when process exits
1016        callback_id: JsCallbackId,
1017    },
1018
1019    /// Kill a background process by ID
1020    KillBackgroundProcess { process_id: u64 },
1021
1022    /// Wait for a process to complete and get its result
1023    /// Used with processes started via SpawnProcess
1024    SpawnProcessWait {
1025        /// Process ID to wait for
1026        process_id: u64,
1027        /// Callback ID for async response
1028        callback_id: JsCallbackId,
1029    },
1030
1031    /// Set layout hints for a buffer/viewport
1032    SetLayoutHints {
1033        buffer_id: BufferId,
1034        split_id: Option<SplitId>,
1035        range: Range<usize>,
1036        hints: LayoutHints,
1037    },
1038
1039    /// Enable/disable line numbers for a buffer
1040    SetLineNumbers { buffer_id: BufferId, enabled: bool },
1041
1042    /// Set the view mode for a buffer ("source" or "compose")
1043    SetViewMode { buffer_id: BufferId, mode: String },
1044
1045    /// Enable/disable line wrapping for a buffer
1046    SetLineWrap {
1047        buffer_id: BufferId,
1048        split_id: Option<SplitId>,
1049        enabled: bool,
1050    },
1051
1052    /// Submit a transformed view stream for a viewport
1053    SubmitViewTransform {
1054        buffer_id: BufferId,
1055        split_id: Option<SplitId>,
1056        payload: ViewTransformPayload,
1057    },
1058
1059    /// Clear view transform for a buffer/split (returns to normal rendering)
1060    ClearViewTransform {
1061        buffer_id: BufferId,
1062        split_id: Option<SplitId>,
1063    },
1064
1065    /// Set plugin-managed view state for a buffer in the active split.
1066    /// Stored in BufferViewState.plugin_state and persisted across sessions.
1067    SetViewState {
1068        buffer_id: BufferId,
1069        key: String,
1070        #[ts(type = "any")]
1071        value: Option<serde_json::Value>,
1072    },
1073
1074    /// Set plugin-managed global state (not tied to any buffer or split).
1075    /// Isolated per plugin by plugin_name.
1076    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
1077    SetGlobalState {
1078        plugin_name: String,
1079        key: String,
1080        #[ts(type = "any")]
1081        value: Option<serde_json::Value>,
1082    },
1083
1084    /// Remove all overlays from a buffer
1085    ClearAllOverlays { buffer_id: BufferId },
1086
1087    /// Remove all overlays in a namespace
1088    ClearNamespace {
1089        buffer_id: BufferId,
1090        namespace: OverlayNamespace,
1091    },
1092
1093    /// Remove all overlays that overlap with a byte range
1094    /// Used for targeted invalidation when content in a range changes
1095    ClearOverlaysInRange {
1096        buffer_id: BufferId,
1097        start: usize,
1098        end: usize,
1099    },
1100
1101    /// Add virtual text (inline text that doesn't exist in the buffer)
1102    /// Used for color swatches, type hints, parameter hints, etc.
1103    AddVirtualText {
1104        buffer_id: BufferId,
1105        virtual_text_id: String,
1106        position: usize,
1107        text: String,
1108        color: (u8, u8, u8),
1109        use_bg: bool, // true = use color as background, false = use as foreground
1110        before: bool, // true = before char, false = after char
1111    },
1112
1113    /// Remove a virtual text by ID
1114    RemoveVirtualText {
1115        buffer_id: BufferId,
1116        virtual_text_id: String,
1117    },
1118
1119    /// Remove virtual texts whose ID starts with the given prefix
1120    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
1121
1122    /// Clear all virtual texts from a buffer
1123    ClearVirtualTexts { buffer_id: BufferId },
1124
1125    /// Add a virtual LINE (full line above/below a position)
1126    /// Used for git blame headers, code coverage, inline documentation, etc.
1127    /// These lines do NOT show line numbers in the gutter.
1128    AddVirtualLine {
1129        buffer_id: BufferId,
1130        /// Byte position to anchor the line to
1131        position: usize,
1132        /// Full line content to display
1133        text: String,
1134        /// Foreground color — RGB tuple or theme key string (e.g.
1135        /// `"editor.line_number_fg"`).  Resolved at render time so the line
1136        /// follows theme changes.
1137        fg_color: Option<OverlayColorSpec>,
1138        /// Background color — RGB tuple or theme key string.  None =
1139        /// transparent (inherits from underlying viewport background).
1140        bg_color: Option<OverlayColorSpec>,
1141        /// true = above the line containing position, false = below
1142        above: bool,
1143        /// Namespace for bulk removal (e.g., "git-blame")
1144        namespace: String,
1145        /// Priority for ordering multiple lines at same position (higher = later)
1146        priority: i32,
1147    },
1148
1149    /// Clear all virtual texts in a namespace
1150    /// This is the primary way to remove a plugin's virtual lines before updating them.
1151    ClearVirtualTextNamespace {
1152        buffer_id: BufferId,
1153        namespace: String,
1154    },
1155
1156    /// Add a conceal range that hides or replaces a byte range during rendering.
1157    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1158    AddConceal {
1159        buffer_id: BufferId,
1160        /// Namespace for bulk removal (shared with overlay namespace system)
1161        namespace: OverlayNamespace,
1162        /// Byte range to conceal
1163        start: usize,
1164        end: usize,
1165        /// Optional replacement text to show instead. None = hide completely.
1166        replacement: Option<String>,
1167    },
1168
1169    /// Clear all conceal ranges in a namespace
1170    ClearConcealNamespace {
1171        buffer_id: BufferId,
1172        namespace: OverlayNamespace,
1173    },
1174
1175    /// Remove all conceal ranges that overlap with a byte range
1176    /// Used for targeted invalidation when content in a range changes
1177    ClearConcealsInRange {
1178        buffer_id: BufferId,
1179        start: usize,
1180        end: usize,
1181    },
1182
1183    /// Add a collapsed fold range. Hides the byte range
1184    /// `[start, end)` from rendering — the line containing `start - 1`
1185    /// (the fold's "header") stays visible while the lines covered by
1186    /// the range are skipped. Used by plugins that want to expose
1187    /// outline-style collapse without rebuilding buffer content.
1188    AddFold {
1189        buffer_id: BufferId,
1190        start: usize,
1191        end: usize,
1192        /// Optional placeholder text to show on the header line
1193        /// (currently unused by the renderer; reserved for future use).
1194        placeholder: Option<String>,
1195    },
1196
1197    /// Clear every collapsed fold range on the buffer.
1198    ClearFolds { buffer_id: BufferId },
1199
1200    /// Add a soft break point for marker-based line wrapping.
1201    /// The break is stored as a marker that auto-adjusts on buffer edits,
1202    /// eliminating the flicker caused by async view_transform round-trips.
1203    AddSoftBreak {
1204        buffer_id: BufferId,
1205        /// Namespace for bulk removal (shared with overlay namespace system)
1206        namespace: OverlayNamespace,
1207        /// Byte offset where the break should be injected
1208        position: usize,
1209        /// Number of hanging indent spaces after the break
1210        indent: u16,
1211    },
1212
1213    /// Clear all soft breaks in a namespace
1214    ClearSoftBreakNamespace {
1215        buffer_id: BufferId,
1216        namespace: OverlayNamespace,
1217    },
1218
1219    /// Remove all soft breaks that fall within a byte range
1220    ClearSoftBreaksInRange {
1221        buffer_id: BufferId,
1222        start: usize,
1223        end: usize,
1224    },
1225
1226    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1227    RefreshLines { buffer_id: BufferId },
1228
1229    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1230    /// Sent when a plugin registers for the lines_changed hook to handle the race
1231    /// where render marks lines as "seen" before the plugin has registered.
1232    RefreshAllLines,
1233
1234    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1235    /// Used by the render loop to wait deterministically for plugin responses
1236    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1237    HookCompleted { hook_name: String },
1238
1239    /// Set a line indicator in the gutter's indicator column
1240    /// Used for git gutter, breakpoints, bookmarks, etc.
1241    SetLineIndicator {
1242        buffer_id: BufferId,
1243        /// Line number (0-indexed)
1244        line: usize,
1245        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1246        namespace: String,
1247        /// Symbol to display (e.g., "│", "●", "★")
1248        symbol: String,
1249        /// Color as RGB tuple
1250        color: (u8, u8, u8),
1251        /// Priority for display when multiple indicators exist (higher wins)
1252        priority: i32,
1253    },
1254
1255    /// Batch set line indicators in the gutter's indicator column
1256    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1257    SetLineIndicators {
1258        buffer_id: BufferId,
1259        /// Line numbers (0-indexed)
1260        lines: Vec<usize>,
1261        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1262        namespace: String,
1263        /// Symbol to display (e.g., "│", "●", "★")
1264        symbol: String,
1265        /// Color as RGB tuple
1266        color: (u8, u8, u8),
1267        /// Priority for display when multiple indicators exist (higher wins)
1268        priority: i32,
1269    },
1270
1271    /// Clear all line indicators for a specific namespace
1272    ClearLineIndicators {
1273        buffer_id: BufferId,
1274        /// Namespace to clear (e.g., "git-gutter")
1275        namespace: String,
1276    },
1277
1278    /// Set file explorer decorations for a namespace
1279    SetFileExplorerDecorations {
1280        /// Namespace for grouping (e.g., "git-status")
1281        namespace: String,
1282        /// Decorations to apply
1283        decorations: Vec<FileExplorerDecoration>,
1284    },
1285
1286    /// Clear file explorer decorations for a namespace
1287    ClearFileExplorerDecorations {
1288        /// Namespace to clear (e.g., "git-status")
1289        namespace: String,
1290    },
1291
1292    /// Open a file at a specific line and column
1293    /// Line and column are 1-indexed to match git grep output
1294    OpenFileAtLocation {
1295        path: PathBuf,
1296        line: Option<usize>,   // 1-indexed, None = go to start
1297        column: Option<usize>, // 1-indexed, None = go to line start
1298    },
1299
1300    /// Open a file in a specific split at a given line and column
1301    /// Line and column are 1-indexed to match git grep output
1302    OpenFileInSplit {
1303        split_id: usize,
1304        path: PathBuf,
1305        line: Option<usize>,   // 1-indexed, None = go to start
1306        column: Option<usize>, // 1-indexed, None = go to line start
1307    },
1308
1309    /// Start a prompt (minibuffer) with a custom type identifier
1310    /// This allows plugins to create interactive prompts
1311    StartPrompt {
1312        label: String,
1313        prompt_type: String, // e.g., "git-grep", "git-find-file"
1314    },
1315
1316    /// Start a prompt with pre-filled initial value
1317    StartPromptWithInitial {
1318        label: String,
1319        prompt_type: String,
1320        initial_value: String,
1321    },
1322
1323    /// Start an async prompt that returns result via callback
1324    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1325    StartPromptAsync {
1326        label: String,
1327        initial_value: String,
1328        callback_id: JsCallbackId,
1329    },
1330
1331    /// Update the suggestions list for the current prompt
1332    /// Uses the editor's Suggestion type
1333    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1334
1335    /// When enabled, navigating suggestions updates the prompt input text
1336    SetPromptInputSync { sync: bool },
1337
1338    /// Add a menu item to an existing menu
1339    /// Add a menu item to an existing menu
1340    AddMenuItem {
1341        menu_label: String,
1342        item: MenuItem,
1343        position: MenuPosition,
1344    },
1345
1346    /// Add a new top-level menu
1347    AddMenu { menu: Menu, position: MenuPosition },
1348
1349    /// Remove a menu item from a menu
1350    RemoveMenuItem {
1351        menu_label: String,
1352        item_label: String,
1353    },
1354
1355    /// Remove a top-level menu
1356    RemoveMenu { menu_label: String },
1357
1358    /// Create a new virtual buffer (not backed by a file)
1359    CreateVirtualBuffer {
1360        /// Display name (e.g., "*Diagnostics*")
1361        name: String,
1362        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1363        mode: String,
1364        /// Whether the buffer is read-only
1365        read_only: bool,
1366    },
1367
1368    /// Create a virtual buffer and set its content in one operation
1369    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1370    /// because it doesn't require tracking the buffer ID
1371    CreateVirtualBufferWithContent {
1372        /// Display name (e.g., "*Diagnostics*")
1373        name: String,
1374        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1375        mode: String,
1376        /// Whether the buffer is read-only
1377        read_only: bool,
1378        /// Entries with text and embedded properties
1379        entries: Vec<TextPropertyEntry>,
1380        /// Whether to show line numbers in the gutter
1381        show_line_numbers: bool,
1382        /// Whether to show cursors in the buffer
1383        show_cursors: bool,
1384        /// Whether editing is disabled (blocks editing commands)
1385        editing_disabled: bool,
1386        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1387        hidden_from_tabs: bool,
1388        /// Optional request ID for async response
1389        request_id: Option<u64>,
1390    },
1391
1392    /// Create a virtual buffer in a horizontal split
1393    /// Opens the buffer in a new pane below the current one
1394    CreateVirtualBufferInSplit {
1395        /// Display name (e.g., "*Diagnostics*")
1396        name: String,
1397        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1398        mode: String,
1399        /// Whether the buffer is read-only
1400        read_only: bool,
1401        /// Entries with text and embedded properties
1402        entries: Vec<TextPropertyEntry>,
1403        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1404        ratio: f32,
1405        /// Split direction ("horizontal" or "vertical"), default horizontal
1406        direction: Option<String>,
1407        /// Optional panel ID for idempotent operations (if panel exists, update content)
1408        panel_id: Option<String>,
1409        /// Whether to show line numbers in the buffer (default true)
1410        show_line_numbers: bool,
1411        /// Whether to show cursors in the buffer (default true)
1412        show_cursors: bool,
1413        /// Whether editing is disabled for this buffer (default false)
1414        editing_disabled: bool,
1415        /// Whether line wrapping is enabled for this split (None = use global setting)
1416        line_wrap: Option<bool>,
1417        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1418        before: bool,
1419        /// Optional request ID for async response (if set, editor will send back buffer ID)
1420        request_id: Option<u64>,
1421    },
1422
1423    /// Set the content of a virtual buffer with text properties
1424    SetVirtualBufferContent {
1425        buffer_id: BufferId,
1426        /// Entries with text and embedded properties
1427        entries: Vec<TextPropertyEntry>,
1428    },
1429
1430    /// Get text properties at the cursor position in a buffer
1431    GetTextPropertiesAtCursor { buffer_id: BufferId },
1432
1433    /// Create a buffer group: multiple panels appearing as one tab.
1434    /// Each panel is a real buffer with its own scrollbar and viewport.
1435    CreateBufferGroup {
1436        /// Display name (shown in tab bar)
1437        name: String,
1438        /// Mode for keybindings
1439        mode: String,
1440        /// Layout tree as JSON string (parsed by the handler)
1441        layout_json: String,
1442        /// Optional request ID for async response
1443        request_id: Option<u64>,
1444    },
1445
1446    /// Set the content of a panel within a buffer group.
1447    SetPanelContent {
1448        /// Group ID
1449        group_id: usize,
1450        /// Panel name (e.g., "tree", "picker")
1451        panel_name: String,
1452        /// Content entries
1453        entries: Vec<TextPropertyEntry>,
1454    },
1455
1456    /// Close a buffer group (closes all panels and splits)
1457    CloseBufferGroup { group_id: usize },
1458
1459    /// Focus a specific panel within a buffer group
1460    FocusPanel { group_id: usize, panel_name: String },
1461
1462    /// Define a buffer mode with keybindings
1463    DefineMode {
1464        name: String,
1465        bindings: Vec<(String, String)>, // (key_string, command_name)
1466        read_only: bool,
1467        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1468        allow_text_input: bool,
1469        /// When true, keys not bound by this mode fall through to the Normal
1470        /// context (motion, selection, copy) instead of being dropped.
1471        inherit_normal_bindings: bool,
1472        /// Name of the plugin that defined this mode (for attribution)
1473        plugin_name: Option<String>,
1474    },
1475
1476    /// Switch the current split to display a buffer
1477    ShowBuffer { buffer_id: BufferId },
1478
1479    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1480    CreateVirtualBufferInExistingSplit {
1481        /// Display name (e.g., "*Commit Details*")
1482        name: String,
1483        /// Mode name for buffer-local keybindings
1484        mode: String,
1485        /// Whether the buffer is read-only
1486        read_only: bool,
1487        /// Entries with text and embedded properties
1488        entries: Vec<TextPropertyEntry>,
1489        /// Target split ID where the buffer should be displayed
1490        split_id: SplitId,
1491        /// Whether to show line numbers in the buffer (default true)
1492        show_line_numbers: bool,
1493        /// Whether to show cursors in the buffer (default true)
1494        show_cursors: bool,
1495        /// Whether editing is disabled for this buffer (default false)
1496        editing_disabled: bool,
1497        /// Whether line wrapping is enabled for this split (None = use global setting)
1498        line_wrap: Option<bool>,
1499        /// Optional request ID for async response
1500        request_id: Option<u64>,
1501    },
1502
1503    /// Close a buffer and remove it from all splits
1504    CloseBuffer { buffer_id: BufferId },
1505
1506    /// Create a composite buffer that displays multiple source buffers
1507    /// Used for side-by-side diff, unified diff, and 3-way merge views
1508    CreateCompositeBuffer {
1509        /// Display name (shown in tab bar)
1510        name: String,
1511        /// Mode name for keybindings (e.g., "diff-view")
1512        mode: String,
1513        /// Layout configuration
1514        layout: CompositeLayoutConfig,
1515        /// Source pane configurations
1516        sources: Vec<CompositeSourceConfig>,
1517        /// Diff hunks for line alignment (optional)
1518        hunks: Option<Vec<CompositeHunk>>,
1519        /// When set, first render scrolls to center this hunk (0-indexed)
1520        initial_focus_hunk: Option<usize>,
1521        /// Request ID for async response
1522        request_id: Option<u64>,
1523    },
1524
1525    /// Update alignment for a composite buffer (e.g., after source edit)
1526    UpdateCompositeAlignment {
1527        buffer_id: BufferId,
1528        hunks: Vec<CompositeHunk>,
1529    },
1530
1531    /// Close a composite buffer
1532    CloseCompositeBuffer { buffer_id: BufferId },
1533
1534    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1535    ///
1536    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1537    /// have one, and syncs viewport dimensions from split layout. This ensures
1538    /// subsequent commands can read/modify view state that is normally created
1539    /// lazily during the render cycle.
1540    FlushLayout,
1541
1542    /// Navigate to the next hunk in a composite buffer
1543    CompositeNextHunk { buffer_id: BufferId },
1544
1545    /// Navigate to the previous hunk in a composite buffer
1546    CompositePrevHunk { buffer_id: BufferId },
1547
1548    /// Focus a specific split
1549    FocusSplit { split_id: SplitId },
1550
1551    /// Set the buffer displayed in a specific split
1552    SetSplitBuffer {
1553        split_id: SplitId,
1554        buffer_id: BufferId,
1555    },
1556
1557    /// Set the scroll position of a specific split
1558    SetSplitScroll { split_id: SplitId, top_byte: usize },
1559
1560    /// Request syntax highlights for a buffer range
1561    RequestHighlights {
1562        buffer_id: BufferId,
1563        range: Range<usize>,
1564        request_id: u64,
1565    },
1566
1567    /// Close a split (if not the last one)
1568    CloseSplit { split_id: SplitId },
1569
1570    /// Set the ratio of a split container
1571    SetSplitRatio {
1572        split_id: SplitId,
1573        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1574        ratio: f32,
1575    },
1576
1577    /// Set a label on a leaf split (e.g., "sidebar")
1578    SetSplitLabel { split_id: SplitId, label: String },
1579
1580    /// Remove a label from a split
1581    ClearSplitLabel { split_id: SplitId },
1582
1583    /// Find a split by its label (async)
1584    GetSplitByLabel { label: String, request_id: u64 },
1585
1586    /// Distribute splits evenly - make all given splits equal size
1587    DistributeSplitsEvenly {
1588        /// Split IDs to distribute evenly
1589        split_ids: Vec<SplitId>,
1590    },
1591
1592    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1593    SetBufferCursor {
1594        buffer_id: BufferId,
1595        /// Byte offset position for the cursor
1596        position: usize,
1597    },
1598
1599    /// Toggle whether the editor draws a native caret for this buffer.
1600    ///
1601    /// Buffer-group panel buffers default to `show_cursors = false`, which not
1602    /// only hides the caret but also blocks all movement actions in
1603    /// `action_to_events`. Plugins that want native cursor motion in a panel
1604    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
1605    /// `createBufferGroup` returns.
1606    SetBufferShowCursors { buffer_id: BufferId, show: bool },
1607
1608    /// Send an arbitrary LSP request and return the raw JSON response
1609    SendLspRequest {
1610        language: String,
1611        method: String,
1612        #[ts(type = "any")]
1613        params: Option<JsonValue>,
1614        request_id: u64,
1615    },
1616
1617    /// Set the internal clipboard content
1618    SetClipboard { text: String },
1619
1620    /// Delete the current selection in the active buffer
1621    /// This deletes all selected text across all cursors
1622    DeleteSelection,
1623
1624    /// Set or unset a custom context
1625    /// Custom contexts are plugin-defined states that can be used to control command visibility
1626    /// For example, "config-editor" context could make config editor commands available
1627    SetContext {
1628        /// Context name (e.g., "config-editor")
1629        name: String,
1630        /// Whether the context is active
1631        active: bool,
1632    },
1633
1634    /// Set the hunks for the Review Diff tool
1635    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1636
1637    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1638    /// Used by vi mode plugin to run motions and calculate cursor ranges
1639    ExecuteAction {
1640        /// Action name (e.g., "move_word_right", "move_line_end")
1641        action_name: String,
1642    },
1643
1644    /// Execute multiple actions in sequence, each with an optional repeat count
1645    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1646    /// All actions execute atomically with no plugin roundtrips between them
1647    ExecuteActions {
1648        /// List of actions to execute in sequence
1649        actions: Vec<ActionSpec>,
1650    },
1651
1652    /// Get text from a buffer range (for yank operations)
1653    GetBufferText {
1654        /// Buffer ID
1655        buffer_id: BufferId,
1656        /// Start byte offset
1657        start: usize,
1658        /// End byte offset
1659        end: usize,
1660        /// Request ID for async response
1661        request_id: u64,
1662    },
1663
1664    /// Get byte offset of the start of a line (async)
1665    /// Line is 0-indexed (0 = first line)
1666    GetLineStartPosition {
1667        /// Buffer ID (0 for active buffer)
1668        buffer_id: BufferId,
1669        /// Line number (0-indexed)
1670        line: u32,
1671        /// Request ID for async response
1672        request_id: u64,
1673    },
1674
1675    /// Get byte offset of the end of a line (async)
1676    /// Line is 0-indexed (0 = first line)
1677    /// Returns the byte offset after the last character of the line (before newline)
1678    GetLineEndPosition {
1679        /// Buffer ID (0 for active buffer)
1680        buffer_id: BufferId,
1681        /// Line number (0-indexed)
1682        line: u32,
1683        /// Request ID for async response
1684        request_id: u64,
1685    },
1686
1687    /// Get the total number of lines in a buffer (async)
1688    GetBufferLineCount {
1689        /// Buffer ID (0 for active buffer)
1690        buffer_id: BufferId,
1691        /// Request ID for async response
1692        request_id: u64,
1693    },
1694
1695    /// Scroll a split to center a specific line in the viewport
1696    /// Line is 0-indexed (0 = first line)
1697    ScrollToLineCenter {
1698        /// Split ID to scroll
1699        split_id: SplitId,
1700        /// Buffer ID containing the line
1701        buffer_id: BufferId,
1702        /// Line number to center (0-indexed)
1703        line: usize,
1704    },
1705
1706    /// Scroll any split/panel that displays `buffer_id` so the given
1707    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
1708    /// does not require a split id — it walks all splits (including
1709    /// inner panels of a buffer group) and updates every viewport that
1710    /// shows this buffer. Line is 0-indexed.
1711    ScrollBufferToLine {
1712        /// Buffer ID to scroll
1713        buffer_id: BufferId,
1714        /// Line number to bring into view (0-indexed)
1715        line: usize,
1716    },
1717
1718    /// Set the global editor mode (for modal editing like vi mode)
1719    /// When set, the mode's keybindings take precedence over normal editing
1720    SetEditorMode {
1721        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1722        mode: Option<String>,
1723    },
1724
1725    /// Show an action popup with buttons for user interaction
1726    /// When the user selects an action, the ActionPopupResult hook is fired
1727    ShowActionPopup {
1728        /// Unique identifier for the popup (used in ActionPopupResult)
1729        popup_id: String,
1730        /// Title text for the popup
1731        title: String,
1732        /// Body message (supports basic formatting)
1733        message: String,
1734        /// Action buttons to display
1735        actions: Vec<ActionPopupAction>,
1736    },
1737
1738    /// Disable LSP for a specific language and persist to config
1739    DisableLspForLanguage {
1740        /// The language to disable LSP for (e.g., "python", "rust")
1741        language: String,
1742    },
1743
1744    /// Restart LSP server for a specific language
1745    RestartLspForLanguage {
1746        /// The language to restart LSP for (e.g., "python", "rust")
1747        language: String,
1748    },
1749
1750    /// Set the workspace root URI for a specific language's LSP server
1751    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1752    /// If the LSP is already running, it will be restarted with the new root
1753    SetLspRootUri {
1754        /// The language to set root URI for (e.g., "csharp", "rust")
1755        language: String,
1756        /// The root URI (file:// URL format)
1757        uri: String,
1758    },
1759
1760    /// Create a scroll sync group for anchor-based synchronized scrolling
1761    /// Used for side-by-side diff views where two panes need to scroll together
1762    /// The plugin provides the group ID (must be unique per plugin)
1763    CreateScrollSyncGroup {
1764        /// Plugin-assigned group ID
1765        group_id: u32,
1766        /// The left (primary) split - scroll position is tracked in this split's line space
1767        left_split: SplitId,
1768        /// The right (secondary) split - position is derived from anchors
1769        right_split: SplitId,
1770    },
1771
1772    /// Set sync anchors for a scroll sync group
1773    /// Anchors map corresponding line numbers between left and right buffers
1774    SetScrollSyncAnchors {
1775        /// The group ID returned by CreateScrollSyncGroup
1776        group_id: u32,
1777        /// List of (left_line, right_line) pairs marking corresponding positions
1778        anchors: Vec<(usize, usize)>,
1779    },
1780
1781    /// Remove a scroll sync group
1782    RemoveScrollSyncGroup {
1783        /// The group ID returned by CreateScrollSyncGroup
1784        group_id: u32,
1785    },
1786
1787    /// Save a buffer to a specific file path
1788    /// Used by :w filename command to save unnamed buffers or save-as
1789    SaveBufferToPath {
1790        /// Buffer ID to save
1791        buffer_id: BufferId,
1792        /// Path to save to
1793        path: PathBuf,
1794    },
1795
1796    /// Load a plugin from a file path
1797    /// The plugin will be initialized and start receiving events
1798    LoadPlugin {
1799        /// Path to the plugin file (.ts or .js)
1800        path: PathBuf,
1801        /// Callback ID for async response (success/failure)
1802        callback_id: JsCallbackId,
1803    },
1804
1805    /// Unload a plugin by name
1806    /// The plugin will stop receiving events and be removed from memory
1807    UnloadPlugin {
1808        /// Plugin name (as registered)
1809        name: String,
1810        /// Callback ID for async response (success/failure)
1811        callback_id: JsCallbackId,
1812    },
1813
1814    /// Reload a plugin by name (unload + load)
1815    /// Useful for development when plugin code changes
1816    ReloadPlugin {
1817        /// Plugin name (as registered)
1818        name: String,
1819        /// Callback ID for async response (success/failure)
1820        callback_id: JsCallbackId,
1821    },
1822
1823    /// List all loaded plugins
1824    /// Returns plugin info (name, path, enabled) for all loaded plugins
1825    ListPlugins {
1826        /// Callback ID for async response (JSON array of plugin info)
1827        callback_id: JsCallbackId,
1828    },
1829
1830    /// Reload the theme registry from disk
1831    /// Call this after installing a theme package or saving a new theme.
1832    /// If `apply_theme` is set, apply that theme immediately after reloading.
1833    ReloadThemes { apply_theme: Option<String> },
1834
1835    /// Register a TextMate grammar file for a language
1836    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1837    RegisterGrammar {
1838        /// Language identifier (e.g., "elixir", "zig")
1839        language: String,
1840        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1841        grammar_path: String,
1842        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1843        extensions: Vec<String>,
1844    },
1845
1846    /// Register language configuration (comment prefix, indentation, formatter)
1847    /// This is applied immediately to the runtime config
1848    RegisterLanguageConfig {
1849        /// Language identifier (e.g., "elixir")
1850        language: String,
1851        /// Language configuration
1852        config: LanguagePackConfig,
1853    },
1854
1855    /// Register an LSP server for a language
1856    /// This is applied immediately to the LSP manager and runtime config
1857    RegisterLspServer {
1858        /// Language identifier (e.g., "elixir")
1859        language: String,
1860        /// LSP server configuration
1861        config: LspServerPackConfig,
1862    },
1863
1864    /// Reload the grammar registry to apply registered grammars (async)
1865    /// Call this after registering one or more grammars to rebuild the syntax set.
1866    /// The callback is resolved when the background grammar build completes.
1867    ReloadGrammars { callback_id: JsCallbackId },
1868
1869    // ==================== Terminal Commands ====================
1870    /// Create a new terminal in a split (async, returns TerminalResult)
1871    /// This spawns a PTY-backed terminal that plugins can write to and read from.
1872    CreateTerminal {
1873        /// Working directory for the terminal (defaults to editor cwd)
1874        cwd: Option<String>,
1875        /// Split direction ("horizontal" or "vertical"), default vertical
1876        direction: Option<String>,
1877        /// Split ratio (0.0 to 1.0), default 0.5
1878        ratio: Option<f32>,
1879        /// Whether to focus the new terminal split (default true)
1880        focus: Option<bool>,
1881        /// Whether this terminal survives editor restarts. When false, the
1882        /// terminal is excluded from workspace serialization and its backing
1883        /// file is kept unique-per-spawn so no scrollback from a prior run
1884        /// leaks in. Plugin-created terminals default to `false` since they
1885        /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
1886        persistent: bool,
1887        /// Callback ID for async response
1888        request_id: u64,
1889    },
1890
1891    /// Send input data to a terminal by its terminal ID
1892    SendTerminalInput {
1893        /// The terminal ID (from TerminalResult)
1894        terminal_id: TerminalId,
1895        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
1896        data: String,
1897    },
1898
1899    /// Close a terminal by its terminal ID
1900    CloseTerminal {
1901        /// The terminal ID to close
1902        terminal_id: TerminalId,
1903    },
1904
1905    /// Project-wide grep search (async)
1906    /// Searches all project files via FileSystem trait, respecting .gitignore.
1907    /// For open buffers with dirty edits, searches the buffer's piece tree.
1908    GrepProject {
1909        /// Search pattern (literal string)
1910        pattern: String,
1911        /// Whether the pattern is a fixed string (true) or regex (false)
1912        fixed_string: bool,
1913        /// Whether the search is case-sensitive
1914        case_sensitive: bool,
1915        /// Maximum number of results to return
1916        max_results: usize,
1917        /// Whether to match whole words only
1918        whole_words: bool,
1919        /// Callback ID for async response
1920        callback_id: JsCallbackId,
1921    },
1922
1923    /// Project-wide streaming grep search (async, parallel)
1924    /// Like GrepProject but streams results incrementally via progress callback.
1925    /// Searches files in parallel using tokio tasks, sending per-file results
1926    /// back to the plugin as they complete.
1927    GrepProjectStreaming {
1928        /// Search pattern
1929        pattern: String,
1930        /// Whether the pattern is a fixed string (true) or regex (false)
1931        fixed_string: bool,
1932        /// Whether the search is case-sensitive
1933        case_sensitive: bool,
1934        /// Maximum number of results to return
1935        max_results: usize,
1936        /// Whether to match whole words only
1937        whole_words: bool,
1938        /// Search ID — used to route progress callbacks and for cancellation
1939        search_id: u64,
1940        /// Callback ID for the completion promise
1941        callback_id: JsCallbackId,
1942    },
1943
1944    /// Replace matches in a buffer (async)
1945    /// Opens the file if not already open, applies edits through the buffer model,
1946    /// groups as a single undo action, and saves via FileSystem trait.
1947    ReplaceInBuffer {
1948        /// File path to edit (will open if not already in a buffer)
1949        file_path: PathBuf,
1950        /// Matches to replace, each is (byte_offset, length)
1951        matches: Vec<(usize, usize)>,
1952        /// Replacement text
1953        replacement: String,
1954        /// Callback ID for async response
1955        callback_id: JsCallbackId,
1956    },
1957
1958    /// Install a new authority.
1959    ///
1960    /// Authority is opaque to core. The payload is a tagged JSON object
1961    /// (filesystem kind + spawner kind + terminal wrapper + display
1962    /// label) that `fresh-editor` deserializes into its concrete
1963    /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
1964    /// fresh-core from growing backend-specific knowledge; see
1965    /// `crates/fresh-editor/src/services/authority/mod.rs` for the
1966    /// canonical schema.
1967    ///
1968    /// Fire-and-forget: the transition piggy-backs on the existing
1969    /// editor restart flow, so the plugin that sent this command will
1970    /// be re-loaded as part of the restart. Any follow-up work the
1971    /// plugin wants to do after the switch belongs in its post-restart
1972    /// init code, not in a callback here.
1973    SetAuthority {
1974        #[ts(type = "unknown")]
1975        payload: JsonValue,
1976    },
1977
1978    /// Restore the default local authority. Same semantics as
1979    /// `SetAuthority` with a local payload — triggers an editor
1980    /// restart.
1981    ClearAuthority,
1982
1983    /// Override the Remote Indicator's displayed state for the rest
1984    /// of the current editor session (until a restart, or until the
1985    /// plugin sends another override / `ClearRemoteIndicatorState`).
1986    ///
1987    /// The derived state — computed from the active authority's
1988    /// connection info — keeps running underneath and is what the
1989    /// indicator shows whenever an override is not in effect.
1990    /// Plugins use this to surface lifecycle states that have no
1991    /// authority-level truth yet (e.g. "Connecting" during
1992    /// `devcontainer up`, "FailedAttach" after a non-zero exit).
1993    ///
1994    /// `state` is a tagged enum keyed by `kind`:
1995    ///   - `{ "kind": "local" }`
1996    ///   - `{ "kind": "connecting", "label": "..." }`
1997    ///   - `{ "kind": "connected", "label": "..." }`
1998    ///   - `{ "kind": "failed_attach", "error": "..." }`
1999    ///   - `{ "kind": "disconnected", "label": "..." }`
2000    ///
2001    /// The exact schema lives in
2002    /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
2003    /// takes it opaquely so new variants can land without touching
2004    /// core plumbing.
2005    SetRemoteIndicatorState {
2006        #[ts(type = "unknown")]
2007        state: JsonValue,
2008    },
2009
2010    /// Drop any active Remote Indicator override and fall back to
2011    /// the authority-derived state. Safe to call without a prior
2012    /// `SetRemoteIndicatorState`.
2013    ClearRemoteIndicatorState,
2014
2015    /// Spawn a process on the host, regardless of the currently
2016    /// installed authority.
2017    ///
2018    /// Intended for plugin internals that must run host-side work
2019    /// (e.g. `devcontainer up`) before installing an authority that
2020    /// would otherwise route the spawn elsewhere. Behaves like
2021    /// `SpawnProcess` but always uses `LocalProcessSpawner`.
2022    ///
2023    /// The TS-side handle exposes `.kill()` on the returned
2024    /// `ProcessHandle`, serviced by `KillHostProcess` below — this
2025    /// lets callers abort a long-running host spawn (e.g.
2026    /// `devcontainer up`) via a user action like "Cancel Startup".
2027    SpawnHostProcess {
2028        command: String,
2029        args: Vec<String>,
2030        cwd: Option<String>,
2031        callback_id: JsCallbackId,
2032    },
2033
2034    /// Cancel a host-side process previously started via
2035    /// `SpawnHostProcess`. `process_id` is the callback id returned
2036    /// by `spawnHostProcess` (the TS handle stores it and forwards
2037    /// when the caller invokes `.kill()`).
2038    ///
2039    /// No-op when the id is unknown — the process may have already
2040    /// exited, or the caller may hold a stale handle. SIGKILL on
2041    /// Unix per `tokio::process::Child::start_kill`; children of the
2042    /// killed process may leak (see Q-C2 in
2043    /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
2044    KillHostProcess { process_id: u64 },
2045}
2046
2047impl PluginCommand {
2048    /// Extract the enum variant name from the Debug representation.
2049    pub fn debug_variant_name(&self) -> String {
2050        let dbg = format!("{:?}", self);
2051        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
2052    }
2053}
2054
2055// =============================================================================
2056// Language Pack Configuration Types
2057// =============================================================================
2058
2059/// Language configuration for language packs
2060///
2061/// This is a simplified version of the full LanguageConfig, containing only
2062/// the fields that can be set via the plugin API.
2063#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
2064#[serde(rename_all = "camelCase")]
2065#[ts(export)]
2066pub struct LanguagePackConfig {
2067    /// Comment prefix for line comments (e.g., "//" or "#")
2068    #[serde(default)]
2069    pub comment_prefix: Option<String>,
2070
2071    /// Block comment start marker (e.g., slash-star)
2072    #[serde(default)]
2073    pub block_comment_start: Option<String>,
2074
2075    /// Block comment end marker (e.g., star-slash)
2076    #[serde(default)]
2077    pub block_comment_end: Option<String>,
2078
2079    /// Whether to use tabs instead of spaces for indentation
2080    #[serde(default)]
2081    pub use_tabs: Option<bool>,
2082
2083    /// Tab size (number of spaces per tab level)
2084    #[serde(default)]
2085    pub tab_size: Option<usize>,
2086
2087    /// Whether auto-indent is enabled
2088    #[serde(default)]
2089    pub auto_indent: Option<bool>,
2090
2091    /// Whether to show whitespace tab indicators (→) for this language
2092    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
2093    #[serde(default)]
2094    pub show_whitespace_tabs: Option<bool>,
2095
2096    /// Formatter configuration
2097    #[serde(default)]
2098    pub formatter: Option<FormatterPackConfig>,
2099}
2100
2101/// Formatter configuration for language packs
2102#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2103#[serde(rename_all = "camelCase")]
2104#[ts(export)]
2105pub struct FormatterPackConfig {
2106    /// Command to run (e.g., "prettier", "rustfmt")
2107    pub command: String,
2108
2109    /// Arguments to pass to the formatter
2110    #[serde(default)]
2111    pub args: Vec<String>,
2112}
2113
2114/// Process resource limits for LSP servers
2115#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2116#[serde(rename_all = "camelCase")]
2117#[ts(export)]
2118pub struct ProcessLimitsPackConfig {
2119    /// Maximum memory usage as percentage of total system memory (null = no limit)
2120    #[serde(default)]
2121    pub max_memory_percent: Option<u32>,
2122
2123    /// Maximum CPU usage as percentage of total CPU (null = no limit)
2124    #[serde(default)]
2125    pub max_cpu_percent: Option<u32>,
2126
2127    /// Enable resource limiting
2128    #[serde(default)]
2129    pub enabled: Option<bool>,
2130}
2131
2132/// LSP server configuration for language packs
2133#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2134#[serde(rename_all = "camelCase")]
2135#[ts(export)]
2136pub struct LspServerPackConfig {
2137    /// Command to start the LSP server
2138    pub command: String,
2139
2140    /// Arguments to pass to the command
2141    #[serde(default)]
2142    pub args: Vec<String>,
2143
2144    /// Whether to auto-start the server when a matching file is opened
2145    #[serde(default)]
2146    pub auto_start: Option<bool>,
2147
2148    /// LSP initialization options
2149    #[serde(default)]
2150    #[ts(type = "Record<string, unknown> | null")]
2151    pub initialization_options: Option<JsonValue>,
2152
2153    /// Process resource limits (memory and CPU)
2154    #[serde(default)]
2155    pub process_limits: Option<ProcessLimitsPackConfig>,
2156}
2157
2158/// Hunk status for Review Diff
2159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
2160#[ts(export)]
2161pub enum HunkStatus {
2162    Pending,
2163    Staged,
2164    Discarded,
2165}
2166
2167/// A high-level hunk directive for the Review Diff tool
2168#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2169#[ts(export)]
2170pub struct ReviewHunk {
2171    pub id: String,
2172    pub file: String,
2173    pub context_header: String,
2174    pub status: HunkStatus,
2175    /// 0-indexed line range in the base (HEAD) version
2176    pub base_range: Option<(usize, usize)>,
2177    /// 0-indexed line range in the modified (Working) version
2178    pub modified_range: Option<(usize, usize)>,
2179}
2180
2181/// Action button for action popups
2182#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2183#[serde(deny_unknown_fields)]
2184#[ts(export, rename = "TsActionPopupAction")]
2185pub struct ActionPopupAction {
2186    /// Unique action identifier (returned in ActionPopupResult)
2187    pub id: String,
2188    /// Display text for the button (can include command hints)
2189    pub label: String,
2190}
2191
2192/// Options for showActionPopup
2193#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2194#[serde(deny_unknown_fields)]
2195#[ts(export)]
2196pub struct ActionPopupOptions {
2197    /// Unique identifier for the popup (used in ActionPopupResult)
2198    pub id: String,
2199    /// Title text for the popup
2200    pub title: String,
2201    /// Body message (supports basic formatting)
2202    pub message: String,
2203    /// Action buttons to display
2204    pub actions: Vec<ActionPopupAction>,
2205}
2206
2207/// Syntax highlight span for a buffer range
2208#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2209#[ts(export)]
2210pub struct TsHighlightSpan {
2211    pub start: u32,
2212    pub end: u32,
2213    #[ts(type = "[number, number, number]")]
2214    pub color: (u8, u8, u8),
2215    pub bold: bool,
2216    pub italic: bool,
2217}
2218
2219/// Result from spawning a process with spawnProcess
2220#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2221#[ts(export)]
2222pub struct SpawnResult {
2223    /// Complete stdout as string
2224    pub stdout: String,
2225    /// Complete stderr as string
2226    pub stderr: String,
2227    /// Process exit code (0 usually means success, -1 if killed)
2228    pub exit_code: i32,
2229}
2230
2231/// Result from spawning a background process
2232#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2233#[ts(export)]
2234pub struct BackgroundProcessResult {
2235    /// Unique process ID for later reference
2236    #[ts(type = "number")]
2237    pub process_id: u64,
2238    /// Process exit code (0 usually means success, -1 if killed)
2239    /// Only present when the process has exited
2240    pub exit_code: i32,
2241}
2242
2243/// A single match from project-wide grep
2244#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2245#[serde(rename_all = "camelCase")]
2246#[ts(export, rename_all = "camelCase")]
2247pub struct GrepMatch {
2248    /// Absolute file path
2249    pub file: String,
2250    /// Buffer ID if the file is open (0 if not)
2251    #[ts(type = "number")]
2252    pub buffer_id: usize,
2253    /// Byte offset of match start in the file/buffer content
2254    #[ts(type = "number")]
2255    pub byte_offset: usize,
2256    /// Match length in bytes
2257    #[ts(type = "number")]
2258    pub length: usize,
2259    /// 1-indexed line number
2260    #[ts(type = "number")]
2261    pub line: usize,
2262    /// 1-indexed column number
2263    #[ts(type = "number")]
2264    pub column: usize,
2265    /// The matched line content (for display)
2266    pub context: String,
2267}
2268
2269/// Result from replacing matches in a buffer
2270#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2271#[serde(rename_all = "camelCase")]
2272#[ts(export, rename_all = "camelCase")]
2273pub struct ReplaceResult {
2274    /// Number of replacements made
2275    #[ts(type = "number")]
2276    pub replacements: usize,
2277    /// Buffer ID of the edited buffer
2278    #[ts(type = "number")]
2279    pub buffer_id: usize,
2280}
2281
2282/// Entry for virtual buffer content with optional text properties (JS API version)
2283#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2284#[serde(deny_unknown_fields, rename_all = "camelCase")]
2285#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2286pub struct JsTextPropertyEntry {
2287    /// Text content for this entry
2288    pub text: String,
2289    /// Optional properties attached to this text (e.g., file path, line number)
2290    #[serde(default)]
2291    #[ts(optional, type = "Record<string, unknown>")]
2292    pub properties: Option<HashMap<String, JsonValue>>,
2293    /// Optional whole-entry styling
2294    #[serde(default)]
2295    #[ts(optional, type = "Partial<OverlayOptions>")]
2296    pub style: Option<OverlayOptions>,
2297    /// Optional sub-range styling within this entry
2298    #[serde(default)]
2299    #[ts(optional)]
2300    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2301}
2302
2303/// Directory entry returned by readDir
2304#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2305#[ts(export)]
2306pub struct DirEntry {
2307    /// File/directory name
2308    pub name: String,
2309    /// True if this is a file
2310    pub is_file: bool,
2311    /// True if this is a directory
2312    pub is_dir: bool,
2313}
2314
2315/// Position in a document (line and character)
2316#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2317#[ts(export)]
2318pub struct JsPosition {
2319    /// Zero-indexed line number
2320    pub line: u32,
2321    /// Zero-indexed character offset
2322    pub character: u32,
2323}
2324
2325/// Range in a document (start and end positions)
2326#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2327#[ts(export)]
2328pub struct JsRange {
2329    /// Start position
2330    pub start: JsPosition,
2331    /// End position
2332    pub end: JsPosition,
2333}
2334
2335/// Diagnostic from LSP
2336#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2337#[ts(export)]
2338pub struct JsDiagnostic {
2339    /// Document URI
2340    pub uri: String,
2341    /// Diagnostic message
2342    pub message: String,
2343    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2344    pub severity: Option<u8>,
2345    /// Range in the document
2346    pub range: JsRange,
2347    /// Source of the diagnostic (e.g., "typescript", "eslint")
2348    #[ts(optional)]
2349    pub source: Option<String>,
2350}
2351
2352/// Options for createVirtualBuffer
2353#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2354#[serde(deny_unknown_fields)]
2355#[ts(export)]
2356pub struct CreateVirtualBufferOptions {
2357    /// Buffer name (displayed in tabs/title)
2358    pub name: String,
2359    /// Mode for keybindings (e.g., "git-log", "search-results")
2360    #[serde(default)]
2361    #[ts(optional)]
2362    pub mode: Option<String>,
2363    /// Whether buffer is read-only (default: false)
2364    #[serde(default, rename = "readOnly")]
2365    #[ts(optional, rename = "readOnly")]
2366    pub read_only: Option<bool>,
2367    /// Show line numbers in gutter (default: false)
2368    #[serde(default, rename = "showLineNumbers")]
2369    #[ts(optional, rename = "showLineNumbers")]
2370    pub show_line_numbers: Option<bool>,
2371    /// Show cursor (default: true)
2372    #[serde(default, rename = "showCursors")]
2373    #[ts(optional, rename = "showCursors")]
2374    pub show_cursors: Option<bool>,
2375    /// Disable text editing (default: false)
2376    #[serde(default, rename = "editingDisabled")]
2377    #[ts(optional, rename = "editingDisabled")]
2378    pub editing_disabled: Option<bool>,
2379    /// Hide from tab bar (default: false)
2380    #[serde(default, rename = "hiddenFromTabs")]
2381    #[ts(optional, rename = "hiddenFromTabs")]
2382    pub hidden_from_tabs: Option<bool>,
2383    /// Initial content entries with optional properties
2384    #[serde(default)]
2385    #[ts(optional)]
2386    pub entries: Option<Vec<JsTextPropertyEntry>>,
2387}
2388
2389/// Options for createVirtualBufferInSplit
2390#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2391#[serde(deny_unknown_fields)]
2392#[ts(export)]
2393pub struct CreateVirtualBufferInSplitOptions {
2394    /// Buffer name (displayed in tabs/title)
2395    pub name: String,
2396    /// Mode for keybindings (e.g., "git-log", "search-results")
2397    #[serde(default)]
2398    #[ts(optional)]
2399    pub mode: Option<String>,
2400    /// Whether buffer is read-only (default: false)
2401    #[serde(default, rename = "readOnly")]
2402    #[ts(optional, rename = "readOnly")]
2403    pub read_only: Option<bool>,
2404    /// Split ratio 0.0-1.0 (default: 0.5)
2405    #[serde(default)]
2406    #[ts(optional)]
2407    pub ratio: Option<f32>,
2408    /// Split direction: "horizontal" or "vertical"
2409    #[serde(default)]
2410    #[ts(optional)]
2411    pub direction: Option<String>,
2412    /// Panel ID to split from
2413    #[serde(default, rename = "panelId")]
2414    #[ts(optional, rename = "panelId")]
2415    pub panel_id: Option<String>,
2416    /// Show line numbers in gutter (default: true)
2417    #[serde(default, rename = "showLineNumbers")]
2418    #[ts(optional, rename = "showLineNumbers")]
2419    pub show_line_numbers: Option<bool>,
2420    /// Show cursor (default: true)
2421    #[serde(default, rename = "showCursors")]
2422    #[ts(optional, rename = "showCursors")]
2423    pub show_cursors: Option<bool>,
2424    /// Disable text editing (default: false)
2425    #[serde(default, rename = "editingDisabled")]
2426    #[ts(optional, rename = "editingDisabled")]
2427    pub editing_disabled: Option<bool>,
2428    /// Enable line wrapping
2429    #[serde(default, rename = "lineWrap")]
2430    #[ts(optional, rename = "lineWrap")]
2431    pub line_wrap: Option<bool>,
2432    /// Place the new buffer before (left/top of) the existing content (default: false)
2433    #[serde(default)]
2434    #[ts(optional)]
2435    pub before: Option<bool>,
2436    /// Initial content entries with optional properties
2437    #[serde(default)]
2438    #[ts(optional)]
2439    pub entries: Option<Vec<JsTextPropertyEntry>>,
2440}
2441
2442/// Options for createVirtualBufferInExistingSplit
2443#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2444#[serde(deny_unknown_fields)]
2445#[ts(export)]
2446pub struct CreateVirtualBufferInExistingSplitOptions {
2447    /// Buffer name (displayed in tabs/title)
2448    pub name: String,
2449    /// Target split ID (required)
2450    #[serde(rename = "splitId")]
2451    #[ts(rename = "splitId")]
2452    pub split_id: usize,
2453    /// Mode for keybindings (e.g., "git-log", "search-results")
2454    #[serde(default)]
2455    #[ts(optional)]
2456    pub mode: Option<String>,
2457    /// Whether buffer is read-only (default: false)
2458    #[serde(default, rename = "readOnly")]
2459    #[ts(optional, rename = "readOnly")]
2460    pub read_only: Option<bool>,
2461    /// Show line numbers in gutter (default: true)
2462    #[serde(default, rename = "showLineNumbers")]
2463    #[ts(optional, rename = "showLineNumbers")]
2464    pub show_line_numbers: Option<bool>,
2465    /// Show cursor (default: true)
2466    #[serde(default, rename = "showCursors")]
2467    #[ts(optional, rename = "showCursors")]
2468    pub show_cursors: Option<bool>,
2469    /// Disable text editing (default: false)
2470    #[serde(default, rename = "editingDisabled")]
2471    #[ts(optional, rename = "editingDisabled")]
2472    pub editing_disabled: Option<bool>,
2473    /// Enable line wrapping
2474    #[serde(default, rename = "lineWrap")]
2475    #[ts(optional, rename = "lineWrap")]
2476    pub line_wrap: Option<bool>,
2477    /// Initial content entries with optional properties
2478    #[serde(default)]
2479    #[ts(optional)]
2480    pub entries: Option<Vec<JsTextPropertyEntry>>,
2481}
2482
2483/// Options for createTerminal
2484#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2485#[serde(deny_unknown_fields)]
2486#[ts(export)]
2487pub struct CreateTerminalOptions {
2488    /// Working directory for the terminal (defaults to editor cwd)
2489    #[serde(default)]
2490    #[ts(optional)]
2491    pub cwd: Option<String>,
2492    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2493    #[serde(default)]
2494    #[ts(optional)]
2495    pub direction: Option<String>,
2496    /// Split ratio 0.0-1.0 (default: 0.5)
2497    #[serde(default)]
2498    #[ts(optional)]
2499    pub ratio: Option<f32>,
2500    /// Whether to focus the new terminal split (default: true)
2501    #[serde(default)]
2502    #[ts(optional)]
2503    pub focus: Option<bool>,
2504    /// Whether this terminal is part of the user's persisted workspace.
2505    /// Defaults to `false` for plugin-created terminals — they are typically
2506    /// one-off tool UIs (rebuilds, exec shells, build output) and should
2507    /// start with empty scrollback on each invocation. Set to `true` only
2508    /// when the plugin owns a terminal that the user should see restored
2509    /// across editor restarts.
2510    #[serde(default)]
2511    #[ts(optional)]
2512    pub persistent: Option<bool>,
2513}
2514
2515/// Result of getTextPropertiesAtCursor - array of property objects
2516///
2517/// Each element contains the properties from a text property span that overlaps
2518/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2519#[derive(Debug, Clone, Serialize, TS)]
2520#[ts(export, type = "Array<Record<string, unknown>>")]
2521pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2522
2523// Implement FromJs for option types using rquickjs_serde
2524#[cfg(feature = "plugins")]
2525mod fromjs_impls {
2526    use super::*;
2527    use rquickjs::{Ctx, FromJs, Value};
2528
2529    impl<'js> FromJs<'js> for JsTextPropertyEntry {
2530        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2531            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2532                from: "object",
2533                to: "JsTextPropertyEntry",
2534                message: Some(e.to_string()),
2535            })
2536        }
2537    }
2538
2539    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
2540        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2541            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2542                from: "object",
2543                to: "CreateVirtualBufferOptions",
2544                message: Some(e.to_string()),
2545            })
2546        }
2547    }
2548
2549    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
2550        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2551            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2552                from: "object",
2553                to: "CreateVirtualBufferInSplitOptions",
2554                message: Some(e.to_string()),
2555            })
2556        }
2557    }
2558
2559    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
2560        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2561            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2562                from: "object",
2563                to: "CreateVirtualBufferInExistingSplitOptions",
2564                message: Some(e.to_string()),
2565            })
2566        }
2567    }
2568
2569    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2570        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2571            rquickjs_serde::to_value(ctx.clone(), &self.0)
2572                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2573        }
2574    }
2575
2576    // === Additional input types for type-safe plugin API ===
2577
2578    impl<'js> FromJs<'js> for ActionSpec {
2579        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2580            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2581                from: "object",
2582                to: "ActionSpec",
2583                message: Some(e.to_string()),
2584            })
2585        }
2586    }
2587
2588    impl<'js> FromJs<'js> for ActionPopupAction {
2589        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2590            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2591                from: "object",
2592                to: "ActionPopupAction",
2593                message: Some(e.to_string()),
2594            })
2595        }
2596    }
2597
2598    impl<'js> FromJs<'js> for ActionPopupOptions {
2599        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2600            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2601                from: "object",
2602                to: "ActionPopupOptions",
2603                message: Some(e.to_string()),
2604            })
2605        }
2606    }
2607
2608    impl<'js> FromJs<'js> for ViewTokenWire {
2609        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2610            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2611                from: "object",
2612                to: "ViewTokenWire",
2613                message: Some(e.to_string()),
2614            })
2615        }
2616    }
2617
2618    impl<'js> FromJs<'js> for ViewTokenStyle {
2619        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2620            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2621                from: "object",
2622                to: "ViewTokenStyle",
2623                message: Some(e.to_string()),
2624            })
2625        }
2626    }
2627
2628    impl<'js> FromJs<'js> for LayoutHints {
2629        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2630            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2631                from: "object",
2632                to: "LayoutHints",
2633                message: Some(e.to_string()),
2634            })
2635        }
2636    }
2637
2638    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2639        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2640            // Use two-step deserialization for complex nested structures
2641            let json: serde_json::Value =
2642                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2643                    from: "object",
2644                    to: "CreateCompositeBufferOptions (json)",
2645                    message: Some(e.to_string()),
2646                })?;
2647            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2648                from: "json",
2649                to: "CreateCompositeBufferOptions",
2650                message: Some(e.to_string()),
2651            })
2652        }
2653    }
2654
2655    impl<'js> FromJs<'js> for CompositeHunk {
2656        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2657            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2658                from: "object",
2659                to: "CompositeHunk",
2660                message: Some(e.to_string()),
2661            })
2662        }
2663    }
2664
2665    impl<'js> FromJs<'js> for LanguagePackConfig {
2666        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2667            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2668                from: "object",
2669                to: "LanguagePackConfig",
2670                message: Some(e.to_string()),
2671            })
2672        }
2673    }
2674
2675    impl<'js> FromJs<'js> for LspServerPackConfig {
2676        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2677            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2678                from: "object",
2679                to: "LspServerPackConfig",
2680                message: Some(e.to_string()),
2681            })
2682        }
2683    }
2684
2685    impl<'js> FromJs<'js> for ProcessLimitsPackConfig {
2686        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2687            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2688                from: "object",
2689                to: "ProcessLimitsPackConfig",
2690                message: Some(e.to_string()),
2691            })
2692        }
2693    }
2694
2695    impl<'js> FromJs<'js> for CreateTerminalOptions {
2696        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2697            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2698                from: "object",
2699                to: "CreateTerminalOptions",
2700                message: Some(e.to_string()),
2701            })
2702        }
2703    }
2704
2705    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
2706    //
2707    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
2708    // that replaces the body with `Ok(Default::default())` drops the
2709    // decoded payload on the floor. Every test below asserts a
2710    // non-defaultable field value, so the mutant cannot pass.
2711    //
2712    // Note: many of the target structs do not implement `Default`, making
2713    // those mutants unviable (they fail to compile) — cargo-mutants still
2714    // lists them as candidates. The tests below serve double-duty as
2715    // behavioural regression protection for the JS → Rust conversion layer.
2716    #[cfg(test)]
2717    mod tests {
2718        use super::*;
2719        use rquickjs::{Context, Runtime};
2720
2721        /// Run a closure within a fresh QuickJS context so that `FromJs`
2722        /// impls can be exercised end-to-end.
2723        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
2724            let rt = Runtime::new().expect("create rquickjs runtime");
2725            let ctx = Context::full(&rt).expect("create rquickjs context");
2726            ctx.with(f)
2727        }
2728
2729        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
2730        fn eval_as<T>(src: &str) -> T
2731        where
2732            for<'js> T: rquickjs::FromJs<'js>,
2733        {
2734            with_js(|ctx| {
2735                let value: Value = ctx
2736                    .eval::<Value, _>(src.as_bytes())
2737                    .expect("eval JS source");
2738                T::from_js(&ctx, value).expect("from_js decode")
2739            })
2740        }
2741
2742        #[test]
2743        fn js_text_property_entry_decodes_text_and_properties() {
2744            let got: JsTextPropertyEntry =
2745                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
2746            assert_eq!(got.text, "hello");
2747            let props = got.properties.expect("properties present");
2748            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
2749        }
2750
2751        #[test]
2752        fn create_virtual_buffer_options_decodes_name() {
2753            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
2754            assert_eq!(got.name, "logs");
2755            assert_eq!(got.read_only, Some(true));
2756        }
2757
2758        #[test]
2759        fn create_virtual_buffer_in_split_options_decodes_ratio() {
2760            let got: CreateVirtualBufferInSplitOptions =
2761                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
2762            assert_eq!(got.name, "diag");
2763            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
2764            assert_eq!(got.direction.as_deref(), Some("horizontal"));
2765        }
2766
2767        #[test]
2768        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
2769            let got: CreateVirtualBufferInExistingSplitOptions =
2770                eval_as("({name: 'n', splitId: 7})");
2771            assert_eq!(got.name, "n");
2772            assert_eq!(got.split_id, 7);
2773        }
2774
2775        #[test]
2776        fn create_terminal_options_decodes_cwd_and_focus() {
2777            let got: CreateTerminalOptions =
2778                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
2779            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
2780            assert_eq!(got.direction.as_deref(), Some("vertical"));
2781            assert_eq!(got.focus, Some(false));
2782        }
2783
2784        #[test]
2785        fn action_spec_decodes_action_and_count() {
2786            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
2787            assert_eq!(got.action, "move_word_right");
2788            assert_eq!(got.count, 5);
2789        }
2790
2791        #[test]
2792        fn action_popup_action_decodes_id_and_label() {
2793            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
2794            assert_eq!(got.id, "ok");
2795            assert_eq!(got.label, "OK");
2796        }
2797
2798        #[test]
2799        fn action_popup_options_decodes_actions_list() {
2800            let got: ActionPopupOptions = eval_as(
2801                "({id: 'p', title: 't', message: 'm', \
2802                   actions: [{id: 'ok', label: 'OK'}]})",
2803            );
2804            assert_eq!(got.id, "p");
2805            assert_eq!(got.title, "t");
2806            assert_eq!(got.message, "m");
2807            assert_eq!(got.actions.len(), 1);
2808            assert_eq!(got.actions[0].id, "ok");
2809        }
2810
2811        #[test]
2812        fn view_token_wire_decodes_offset_and_kind() {
2813            // Using `Newline` (a unit variant) avoids the tuple-variant
2814            // wire-format ambiguity in rquickjs_serde while still exercising
2815            // the `FromJs` impl end-to-end.
2816            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
2817            assert_eq!(got.source_offset, Some(42));
2818            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
2819        }
2820
2821        #[test]
2822        fn view_token_style_decodes_boolean_flags() {
2823            // `fg`/`bg` are `Option<(u8, u8, u8)>` which rquickjs_serde does
2824            // not decode from plain JS arrays, so we pin down the boolean
2825            // flags — enough to prove the body actually ran.
2826            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
2827            assert!(got.bold);
2828            assert!(got.italic);
2829            assert!(got.fg.is_none());
2830        }
2831
2832        #[test]
2833        fn layout_hints_decodes_compose_width() {
2834            let got: LayoutHints = eval_as("({composeWidth: 120})");
2835            assert_eq!(got.compose_width, Some(120));
2836            assert!(got.column_guides.is_none());
2837        }
2838
2839        #[test]
2840        fn create_composite_buffer_options_decodes_name_and_sources() {
2841            let got: CreateCompositeBufferOptions = eval_as(
2842                "({name: 'diff', mode: 'm', \
2843                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
2844                   sources: [{bufferId: 3, label: 'OLD'}]})",
2845            );
2846            assert_eq!(got.name, "diff");
2847            assert_eq!(got.layout.layout_type, "side-by-side");
2848            assert_eq!(got.sources.len(), 1);
2849            assert_eq!(got.sources[0].buffer_id, 3);
2850            assert_eq!(got.sources[0].label, "OLD");
2851        }
2852
2853        #[test]
2854        fn composite_hunk_decodes_all_fields() {
2855            let got: CompositeHunk =
2856                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
2857            assert_eq!(got.old_start, 1);
2858            assert_eq!(got.old_count, 2);
2859            assert_eq!(got.new_start, 3);
2860            assert_eq!(got.new_count, 4);
2861        }
2862
2863        #[test]
2864        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
2865            let got: LanguagePackConfig =
2866                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
2867            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
2868            assert_eq!(got.tab_size, Some(7));
2869            assert_eq!(got.use_tabs, Some(true));
2870        }
2871
2872        #[test]
2873        fn lsp_server_pack_config_decodes_command_and_args() {
2874            let got: LspServerPackConfig =
2875                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
2876            assert_eq!(got.command, "rust-analyzer");
2877            assert_eq!(got.args, vec!["--log".to_string()]);
2878            assert_eq!(got.auto_start, Some(true));
2879        }
2880
2881        #[test]
2882        fn process_limits_pack_config_decodes_percentages() {
2883            let got: ProcessLimitsPackConfig =
2884                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
2885            assert_eq!(got.max_memory_percent, Some(75));
2886            assert_eq!(got.max_cpu_percent, Some(50));
2887            assert_eq!(got.enabled, Some(true));
2888        }
2889
2890        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
2891        /// into a JS array whose length matches the payload. A mutant that
2892        /// returns a default (`undefined` / empty) value would fail either
2893        /// the array check or the length check.
2894        #[test]
2895        fn text_properties_at_cursor_into_js_preserves_length() {
2896            use rquickjs::IntoJs;
2897            with_js(|ctx| {
2898                let mut entry = std::collections::HashMap::new();
2899                entry.insert("k".to_string(), serde_json::json!("v"));
2900                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
2901
2902                let v = payload.into_js(&ctx).expect("into_js");
2903                let arr = v.as_array().expect("expected JS array");
2904                assert_eq!(arr.len(), 2);
2905            });
2906        }
2907    }
2908}
2909
2910/// Plugin API context - provides safe access to editor functionality
2911pub struct PluginApi {
2912    /// Hook registry (shared with editor)
2913    hooks: Arc<RwLock<HookRegistry>>,
2914
2915    /// Command registry (shared with editor)
2916    commands: Arc<RwLock<CommandRegistry>>,
2917
2918    /// Command queue for sending commands to editor
2919    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2920
2921    /// Snapshot of editor state (read-only for plugins)
2922    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2923}
2924
2925impl PluginApi {
2926    /// Create a new plugin API context
2927    pub fn new(
2928        hooks: Arc<RwLock<HookRegistry>>,
2929        commands: Arc<RwLock<CommandRegistry>>,
2930        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2931        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2932    ) -> Self {
2933        Self {
2934            hooks,
2935            commands,
2936            command_sender,
2937            state_snapshot,
2938        }
2939    }
2940
2941    /// Register a hook callback
2942    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2943        let mut hooks = self.hooks.write().unwrap();
2944        hooks.add_hook(hook_name, callback);
2945    }
2946
2947    /// Remove all hooks for a specific name
2948    pub fn unregister_hooks(&self, hook_name: &str) {
2949        let mut hooks = self.hooks.write().unwrap();
2950        hooks.remove_hooks(hook_name);
2951    }
2952
2953    /// Register a command
2954    pub fn register_command(&self, command: Command) {
2955        let commands = self.commands.read().unwrap();
2956        commands.register(command);
2957    }
2958
2959    /// Unregister a command by name
2960    pub fn unregister_command(&self, name: &str) {
2961        let commands = self.commands.read().unwrap();
2962        commands.unregister(name);
2963    }
2964
2965    /// Send a command to the editor (async/non-blocking)
2966    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2967        self.command_sender
2968            .send(command)
2969            .map_err(|e| format!("Failed to send command: {}", e))
2970    }
2971
2972    /// Insert text at a position in a buffer
2973    pub fn insert_text(
2974        &self,
2975        buffer_id: BufferId,
2976        position: usize,
2977        text: String,
2978    ) -> Result<(), String> {
2979        self.send_command(PluginCommand::InsertText {
2980            buffer_id,
2981            position,
2982            text,
2983        })
2984    }
2985
2986    /// Delete a range of text from a buffer
2987    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2988        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2989    }
2990
2991    /// Add an overlay (decoration) to a buffer
2992    /// Add an overlay to a buffer with styling options
2993    ///
2994    /// Returns an opaque handle that can be used to remove the overlay later.
2995    ///
2996    /// Colors can be specified as RGB arrays or theme key strings.
2997    /// Theme keys are resolved at render time, so overlays update with theme changes.
2998    pub fn add_overlay(
2999        &self,
3000        buffer_id: BufferId,
3001        namespace: Option<String>,
3002        range: Range<usize>,
3003        options: OverlayOptions,
3004    ) -> Result<(), String> {
3005        self.send_command(PluginCommand::AddOverlay {
3006            buffer_id,
3007            namespace: namespace.map(OverlayNamespace::from_string),
3008            range,
3009            options,
3010        })
3011    }
3012
3013    /// Remove an overlay from a buffer by its handle
3014    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
3015        self.send_command(PluginCommand::RemoveOverlay {
3016            buffer_id,
3017            handle: OverlayHandle::from_string(handle),
3018        })
3019    }
3020
3021    /// Clear all overlays in a namespace from a buffer
3022    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
3023        self.send_command(PluginCommand::ClearNamespace {
3024            buffer_id,
3025            namespace: OverlayNamespace::from_string(namespace),
3026        })
3027    }
3028
3029    /// Clear all overlays that overlap with a byte range
3030    /// Used for targeted invalidation when content changes
3031    pub fn clear_overlays_in_range(
3032        &self,
3033        buffer_id: BufferId,
3034        start: usize,
3035        end: usize,
3036    ) -> Result<(), String> {
3037        self.send_command(PluginCommand::ClearOverlaysInRange {
3038            buffer_id,
3039            start,
3040            end,
3041        })
3042    }
3043
3044    /// Set the status message
3045    pub fn set_status(&self, message: String) -> Result<(), String> {
3046        self.send_command(PluginCommand::SetStatus { message })
3047    }
3048
3049    /// Open a file at a specific line and column (1-indexed)
3050    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
3051    pub fn open_file_at_location(
3052        &self,
3053        path: PathBuf,
3054        line: Option<usize>,
3055        column: Option<usize>,
3056    ) -> Result<(), String> {
3057        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
3058    }
3059
3060    /// Open a file in a specific split at a line and column
3061    ///
3062    /// Similar to open_file_at_location but targets a specific split pane.
3063    /// The split_id is the ID of the split pane to open the file in.
3064    pub fn open_file_in_split(
3065        &self,
3066        split_id: usize,
3067        path: PathBuf,
3068        line: Option<usize>,
3069        column: Option<usize>,
3070    ) -> Result<(), String> {
3071        self.send_command(PluginCommand::OpenFileInSplit {
3072            split_id,
3073            path,
3074            line,
3075            column,
3076        })
3077    }
3078
3079    /// Start a prompt (minibuffer) with a custom type identifier
3080    /// The prompt_type is used to filter hooks in plugin code
3081    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
3082        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
3083    }
3084
3085    /// Set the suggestions for the current prompt
3086    /// This updates the prompt's autocomplete/selection list
3087    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
3088        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
3089    }
3090
3091    /// Enable/disable syncing prompt input text when navigating suggestions
3092    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
3093        self.send_command(PluginCommand::SetPromptInputSync { sync })
3094    }
3095
3096    /// Add a menu item to an existing menu
3097    pub fn add_menu_item(
3098        &self,
3099        menu_label: String,
3100        item: MenuItem,
3101        position: MenuPosition,
3102    ) -> Result<(), String> {
3103        self.send_command(PluginCommand::AddMenuItem {
3104            menu_label,
3105            item,
3106            position,
3107        })
3108    }
3109
3110    /// Add a new top-level menu
3111    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
3112        self.send_command(PluginCommand::AddMenu { menu, position })
3113    }
3114
3115    /// Remove a menu item from a menu
3116    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
3117        self.send_command(PluginCommand::RemoveMenuItem {
3118            menu_label,
3119            item_label,
3120        })
3121    }
3122
3123    /// Remove a top-level menu
3124    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
3125        self.send_command(PluginCommand::RemoveMenu { menu_label })
3126    }
3127
3128    // === Virtual Buffer Methods ===
3129
3130    /// Create a new virtual buffer (not backed by a file)
3131    ///
3132    /// Virtual buffers are used for special displays like diagnostic lists,
3133    /// search results, etc. They have their own mode for keybindings.
3134    pub fn create_virtual_buffer(
3135        &self,
3136        name: String,
3137        mode: String,
3138        read_only: bool,
3139    ) -> Result<(), String> {
3140        self.send_command(PluginCommand::CreateVirtualBuffer {
3141            name,
3142            mode,
3143            read_only,
3144        })
3145    }
3146
3147    /// Create a virtual buffer and set its content in one operation
3148    ///
3149    /// This is the preferred way to create virtual buffers since it doesn't
3150    /// require tracking the buffer ID. The buffer is created and populated
3151    /// atomically.
3152    pub fn create_virtual_buffer_with_content(
3153        &self,
3154        name: String,
3155        mode: String,
3156        read_only: bool,
3157        entries: Vec<TextPropertyEntry>,
3158    ) -> Result<(), String> {
3159        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
3160            name,
3161            mode,
3162            read_only,
3163            entries,
3164            show_line_numbers: true,
3165            show_cursors: true,
3166            editing_disabled: false,
3167            hidden_from_tabs: false,
3168            request_id: None,
3169        })
3170    }
3171
3172    /// Set the content of a virtual buffer with text properties
3173    ///
3174    /// Each entry contains text and metadata properties (e.g., source location).
3175    pub fn set_virtual_buffer_content(
3176        &self,
3177        buffer_id: BufferId,
3178        entries: Vec<TextPropertyEntry>,
3179    ) -> Result<(), String> {
3180        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
3181    }
3182
3183    /// Get text properties at cursor position in a buffer
3184    ///
3185    /// This triggers a command that will make properties available to plugins.
3186    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
3187        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
3188    }
3189
3190    /// Define a buffer mode with keybindings
3191    ///
3192    /// Bindings are specified as (key_string, command_name) pairs.
3193    pub fn define_mode(
3194        &self,
3195        name: String,
3196        bindings: Vec<(String, String)>,
3197        read_only: bool,
3198        allow_text_input: bool,
3199    ) -> Result<(), String> {
3200        self.send_command(PluginCommand::DefineMode {
3201            name,
3202            bindings,
3203            read_only,
3204            allow_text_input,
3205            inherit_normal_bindings: false,
3206            plugin_name: None,
3207        })
3208    }
3209
3210    /// Switch the current split to display a buffer
3211    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
3212        self.send_command(PluginCommand::ShowBuffer { buffer_id })
3213    }
3214
3215    /// Set the scroll position of a specific split
3216    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
3217        self.send_command(PluginCommand::SetSplitScroll {
3218            split_id: SplitId(split_id),
3219            top_byte,
3220        })
3221    }
3222
3223    /// Request syntax highlights for a buffer range
3224    pub fn get_highlights(
3225        &self,
3226        buffer_id: BufferId,
3227        range: Range<usize>,
3228        request_id: u64,
3229    ) -> Result<(), String> {
3230        self.send_command(PluginCommand::RequestHighlights {
3231            buffer_id,
3232            range,
3233            request_id,
3234        })
3235    }
3236
3237    // === Query Methods ===
3238
3239    /// Get the currently active buffer ID
3240    pub fn get_active_buffer_id(&self) -> BufferId {
3241        let snapshot = self.state_snapshot.read().unwrap();
3242        snapshot.active_buffer_id
3243    }
3244
3245    /// Get the currently active split ID
3246    pub fn get_active_split_id(&self) -> usize {
3247        let snapshot = self.state_snapshot.read().unwrap();
3248        snapshot.active_split_id
3249    }
3250
3251    /// Get information about a specific buffer
3252    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
3253        let snapshot = self.state_snapshot.read().unwrap();
3254        snapshot.buffers.get(&buffer_id).cloned()
3255    }
3256
3257    /// Get all buffer IDs
3258    pub fn list_buffers(&self) -> Vec<BufferInfo> {
3259        let snapshot = self.state_snapshot.read().unwrap();
3260        snapshot.buffers.values().cloned().collect()
3261    }
3262
3263    /// Get primary cursor information for the active buffer
3264    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
3265        let snapshot = self.state_snapshot.read().unwrap();
3266        snapshot.primary_cursor.clone()
3267    }
3268
3269    /// Get all cursor information for the active buffer
3270    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
3271        let snapshot = self.state_snapshot.read().unwrap();
3272        snapshot.all_cursors.clone()
3273    }
3274
3275    /// Get viewport information for the active buffer
3276    pub fn get_viewport(&self) -> Option<ViewportInfo> {
3277        let snapshot = self.state_snapshot.read().unwrap();
3278        snapshot.viewport.clone()
3279    }
3280
3281    /// Get access to the state snapshot Arc (for internal use)
3282    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
3283        Arc::clone(&self.state_snapshot)
3284    }
3285}
3286
3287impl Clone for PluginApi {
3288    fn clone(&self) -> Self {
3289        Self {
3290            hooks: Arc::clone(&self.hooks),
3291            commands: Arc::clone(&self.commands),
3292            command_sender: self.command_sender.clone(),
3293            state_snapshot: Arc::clone(&self.state_snapshot),
3294        }
3295    }
3296}
3297
3298// ============================================================================
3299// Pluggable Completion Service — TypeScript Plugin API Types
3300// ============================================================================
3301//
3302// These types are the bridge between the Rust `CompletionService` and
3303// TypeScript plugins that want to provide completion candidates.  They are
3304// serialised to/from JSON via serde and generate TypeScript definitions via
3305// ts-rs so that the plugin API stays in sync automatically.
3306
3307/// A completion candidate produced by a TypeScript plugin provider.
3308///
3309/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
3310/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
3311#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3312#[serde(rename_all = "camelCase", deny_unknown_fields)]
3313#[ts(export, rename_all = "camelCase")]
3314pub struct TsCompletionCandidate {
3315    /// Display text shown in the completion popup.
3316    pub label: String,
3317
3318    /// Text to insert when accepted. Falls back to `label` if omitted.
3319    #[serde(skip_serializing_if = "Option::is_none")]
3320    pub insert_text: Option<String>,
3321
3322    /// Short detail string shown next to the label.
3323    #[serde(skip_serializing_if = "Option::is_none")]
3324    pub detail: Option<String>,
3325
3326    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
3327    #[serde(skip_serializing_if = "Option::is_none")]
3328    pub icon: Option<String>,
3329
3330    /// Provider-assigned relevance score (higher = better).
3331    #[serde(default)]
3332    pub score: i64,
3333
3334    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
3335    #[serde(default)]
3336    pub is_snippet: bool,
3337
3338    /// Opaque data carried through to the `completionAccepted` hook.
3339    #[serde(skip_serializing_if = "Option::is_none")]
3340    pub provider_data: Option<String>,
3341}
3342
3343/// Context sent to a TypeScript plugin's `provideCompletions` handler.
3344///
3345/// Plugins receive this as a read-only snapshot so they never need direct
3346/// buffer access (which would be unsafe for huge files).
3347#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3348#[serde(rename_all = "camelCase")]
3349#[ts(export, rename_all = "camelCase")]
3350pub struct TsCompletionContext {
3351    /// The word prefix typed so far.
3352    pub prefix: String,
3353
3354    /// Byte offset of the cursor.
3355    pub cursor_byte: usize,
3356
3357    /// Byte offset of the word start (for replacement range).
3358    pub word_start_byte: usize,
3359
3360    /// Total buffer size in bytes.
3361    pub buffer_len: usize,
3362
3363    /// Whether the buffer is a lazily-loaded huge file.
3364    pub is_large_file: bool,
3365
3366    /// A text excerpt around the cursor (the contents of the safe scan window).
3367    /// Plugins should search only this string, not request the full buffer.
3368    pub text_around_cursor: String,
3369
3370    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
3371    pub cursor_offset_in_text: usize,
3372
3373    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
3374    #[serde(skip_serializing_if = "Option::is_none")]
3375    pub language_id: Option<String>,
3376}
3377
3378/// Registration payload sent by a plugin to register a completion provider.
3379#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3380#[serde(rename_all = "camelCase", deny_unknown_fields)]
3381#[ts(export, rename_all = "camelCase")]
3382pub struct TsCompletionProviderRegistration {
3383    /// Unique id for this provider (e.g., `"my-snippets"`).
3384    pub id: String,
3385
3386    /// Human-readable name shown in status/debug UI.
3387    pub display_name: String,
3388
3389    /// Priority tier (lower = higher priority). Convention:
3390    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
3391    #[serde(default = "default_plugin_provider_priority")]
3392    pub priority: u32,
3393
3394    /// Optional list of language ids this provider is active for.
3395    /// If empty/omitted, the provider is active for all languages.
3396    #[serde(default)]
3397    pub language_ids: Vec<String>,
3398}
3399
3400fn default_plugin_provider_priority() -> u32 {
3401    50
3402}
3403
3404#[cfg(test)]
3405mod tests {
3406    use super::*;
3407
3408    #[test]
3409    fn test_plugin_api_creation() {
3410        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3411        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3412        let (tx, _rx) = std::sync::mpsc::channel();
3413        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3414
3415        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3416
3417        // Should not panic
3418        let _clone = api.clone();
3419    }
3420
3421    #[test]
3422    fn test_register_hook() {
3423        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3424        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3425        let (tx, _rx) = std::sync::mpsc::channel();
3426        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3427
3428        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
3429
3430        api.register_hook("test-hook", Box::new(|_| true));
3431
3432        let hook_registry = hooks.read().unwrap();
3433        assert_eq!(hook_registry.hook_count("test-hook"), 1);
3434    }
3435
3436    #[test]
3437    fn test_send_command() {
3438        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3439        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3440        let (tx, rx) = std::sync::mpsc::channel();
3441        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3442
3443        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3444
3445        let result = api.insert_text(BufferId(1), 0, "test".to_string());
3446        assert!(result.is_ok());
3447
3448        // Verify command was sent
3449        let received = rx.try_recv();
3450        assert!(received.is_ok());
3451
3452        match received.unwrap() {
3453            PluginCommand::InsertText {
3454                buffer_id,
3455                position,
3456                text,
3457            } => {
3458                assert_eq!(buffer_id.0, 1);
3459                assert_eq!(position, 0);
3460                assert_eq!(text, "test");
3461            }
3462            _ => panic!("Wrong command type"),
3463        }
3464    }
3465
3466    #[test]
3467    fn test_add_overlay_command() {
3468        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3469        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3470        let (tx, rx) = std::sync::mpsc::channel();
3471        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3472
3473        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3474
3475        let result = api.add_overlay(
3476            BufferId(1),
3477            Some("test-overlay".to_string()),
3478            0..10,
3479            OverlayOptions {
3480                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3481                bg: None,
3482                underline: true,
3483                bold: false,
3484                italic: false,
3485                strikethrough: false,
3486                extend_to_line_end: false,
3487                url: None,
3488            },
3489        );
3490        assert!(result.is_ok());
3491
3492        let received = rx.try_recv().unwrap();
3493        match received {
3494            PluginCommand::AddOverlay {
3495                buffer_id,
3496                namespace,
3497                range,
3498                options,
3499            } => {
3500                assert_eq!(buffer_id.0, 1);
3501                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3502                assert_eq!(range, 0..10);
3503                assert!(matches!(
3504                    options.fg,
3505                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3506                ));
3507                assert!(options.bg.is_none());
3508                assert!(options.underline);
3509                assert!(!options.bold);
3510                assert!(!options.italic);
3511                assert!(!options.extend_to_line_end);
3512            }
3513            _ => panic!("Wrong command type"),
3514        }
3515    }
3516
3517    #[test]
3518    fn test_set_status_command() {
3519        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3520        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3521        let (tx, rx) = std::sync::mpsc::channel();
3522        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3523
3524        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3525
3526        let result = api.set_status("Test status".to_string());
3527        assert!(result.is_ok());
3528
3529        let received = rx.try_recv().unwrap();
3530        match received {
3531            PluginCommand::SetStatus { message } => {
3532                assert_eq!(message, "Test status");
3533            }
3534            _ => panic!("Wrong command type"),
3535        }
3536    }
3537
3538    #[test]
3539    fn test_get_active_buffer_id() {
3540        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3541        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3542        let (tx, _rx) = std::sync::mpsc::channel();
3543        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3544
3545        // Set active buffer to 5
3546        {
3547            let mut snapshot = state_snapshot.write().unwrap();
3548            snapshot.active_buffer_id = BufferId(5);
3549        }
3550
3551        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3552
3553        let active_id = api.get_active_buffer_id();
3554        assert_eq!(active_id.0, 5);
3555    }
3556
3557    #[test]
3558    fn test_get_buffer_info() {
3559        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3560        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3561        let (tx, _rx) = std::sync::mpsc::channel();
3562        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3563
3564        // Add buffer info
3565        {
3566            let mut snapshot = state_snapshot.write().unwrap();
3567            let buffer_info = BufferInfo {
3568                id: BufferId(1),
3569                path: Some(std::path::PathBuf::from("/test/file.txt")),
3570                modified: true,
3571                length: 100,
3572                is_virtual: false,
3573                view_mode: "source".to_string(),
3574                is_composing_in_any_split: false,
3575                compose_width: None,
3576                language: "text".to_string(),
3577                is_preview: false,
3578                splits: Vec::new(),
3579            };
3580            snapshot.buffers.insert(BufferId(1), buffer_info);
3581        }
3582
3583        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3584
3585        let info = api.get_buffer_info(BufferId(1));
3586        assert!(info.is_some());
3587        let info = info.unwrap();
3588        assert_eq!(info.id.0, 1);
3589        assert_eq!(
3590            info.path.as_ref().unwrap().to_str().unwrap(),
3591            "/test/file.txt"
3592        );
3593        assert!(info.modified);
3594        assert_eq!(info.length, 100);
3595
3596        // Non-existent buffer
3597        let no_info = api.get_buffer_info(BufferId(999));
3598        assert!(no_info.is_none());
3599    }
3600
3601    #[test]
3602    fn test_list_buffers() {
3603        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3604        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3605        let (tx, _rx) = std::sync::mpsc::channel();
3606        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3607
3608        // Add multiple buffers
3609        {
3610            let mut snapshot = state_snapshot.write().unwrap();
3611            snapshot.buffers.insert(
3612                BufferId(1),
3613                BufferInfo {
3614                    id: BufferId(1),
3615                    path: Some(std::path::PathBuf::from("/file1.txt")),
3616                    modified: false,
3617                    length: 50,
3618                    is_virtual: false,
3619                    view_mode: "source".to_string(),
3620                    is_composing_in_any_split: false,
3621                    compose_width: None,
3622                    language: "text".to_string(),
3623                    is_preview: false,
3624                    splits: Vec::new(),
3625                },
3626            );
3627            snapshot.buffers.insert(
3628                BufferId(2),
3629                BufferInfo {
3630                    id: BufferId(2),
3631                    path: Some(std::path::PathBuf::from("/file2.txt")),
3632                    modified: true,
3633                    length: 100,
3634                    is_virtual: false,
3635                    view_mode: "source".to_string(),
3636                    is_composing_in_any_split: false,
3637                    compose_width: None,
3638                    language: "text".to_string(),
3639                    is_preview: false,
3640                    splits: Vec::new(),
3641                },
3642            );
3643            snapshot.buffers.insert(
3644                BufferId(3),
3645                BufferInfo {
3646                    id: BufferId(3),
3647                    path: None,
3648                    modified: false,
3649                    length: 0,
3650                    is_virtual: true,
3651                    view_mode: "source".to_string(),
3652                    is_composing_in_any_split: false,
3653                    compose_width: None,
3654                    language: "text".to_string(),
3655                    is_preview: false,
3656                    splits: Vec::new(),
3657                },
3658            );
3659        }
3660
3661        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3662
3663        let buffers = api.list_buffers();
3664        assert_eq!(buffers.len(), 3);
3665
3666        // Verify all buffers are present
3667        assert!(buffers.iter().any(|b| b.id.0 == 1));
3668        assert!(buffers.iter().any(|b| b.id.0 == 2));
3669        assert!(buffers.iter().any(|b| b.id.0 == 3));
3670    }
3671
3672    #[test]
3673    fn test_get_primary_cursor() {
3674        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3675        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3676        let (tx, _rx) = std::sync::mpsc::channel();
3677        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3678
3679        // Add cursor info
3680        {
3681            let mut snapshot = state_snapshot.write().unwrap();
3682            snapshot.primary_cursor = Some(CursorInfo {
3683                position: 42,
3684                selection: Some(10..42),
3685            });
3686        }
3687
3688        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3689
3690        let cursor = api.get_primary_cursor();
3691        assert!(cursor.is_some());
3692        let cursor = cursor.unwrap();
3693        assert_eq!(cursor.position, 42);
3694        assert_eq!(cursor.selection, Some(10..42));
3695    }
3696
3697    #[test]
3698    fn test_get_all_cursors() {
3699        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3700        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3701        let (tx, _rx) = std::sync::mpsc::channel();
3702        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3703
3704        // Add multiple cursors
3705        {
3706            let mut snapshot = state_snapshot.write().unwrap();
3707            snapshot.all_cursors = vec![
3708                CursorInfo {
3709                    position: 10,
3710                    selection: None,
3711                },
3712                CursorInfo {
3713                    position: 20,
3714                    selection: Some(15..20),
3715                },
3716                CursorInfo {
3717                    position: 30,
3718                    selection: Some(25..30),
3719                },
3720            ];
3721        }
3722
3723        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3724
3725        let cursors = api.get_all_cursors();
3726        assert_eq!(cursors.len(), 3);
3727        assert_eq!(cursors[0].position, 10);
3728        assert_eq!(cursors[0].selection, None);
3729        assert_eq!(cursors[1].position, 20);
3730        assert_eq!(cursors[1].selection, Some(15..20));
3731        assert_eq!(cursors[2].position, 30);
3732        assert_eq!(cursors[2].selection, Some(25..30));
3733    }
3734
3735    #[test]
3736    fn test_get_viewport() {
3737        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3738        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3739        let (tx, _rx) = std::sync::mpsc::channel();
3740        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3741
3742        // Add viewport info
3743        {
3744            let mut snapshot = state_snapshot.write().unwrap();
3745            snapshot.viewport = Some(ViewportInfo {
3746                top_byte: 100,
3747                top_line: Some(5),
3748                left_column: 5,
3749                width: 80,
3750                height: 24,
3751            });
3752        }
3753
3754        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3755
3756        let viewport = api.get_viewport();
3757        assert!(viewport.is_some());
3758        let viewport = viewport.unwrap();
3759        assert_eq!(viewport.top_byte, 100);
3760        assert_eq!(viewport.left_column, 5);
3761        assert_eq!(viewport.width, 80);
3762        assert_eq!(viewport.height, 24);
3763    }
3764
3765    #[test]
3766    fn test_composite_buffer_options_rejects_unknown_fields() {
3767        // Valid JSON with correct field names
3768        let valid_json = r#"{
3769            "name": "test",
3770            "mode": "diff",
3771            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3772            "sources": [{"bufferId": 1, "label": "old"}]
3773        }"#;
3774        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3775        assert!(
3776            result.is_ok(),
3777            "Valid JSON should parse: {:?}",
3778            result.err()
3779        );
3780
3781        // Invalid JSON with unknown field (buffer_id instead of bufferId)
3782        let invalid_json = r#"{
3783            "name": "test",
3784            "mode": "diff",
3785            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3786            "sources": [{"buffer_id": 1, "label": "old"}]
3787        }"#;
3788        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3789        assert!(
3790            result.is_err(),
3791            "JSON with unknown field should fail to parse"
3792        );
3793        let err = result.unwrap_err().to_string();
3794        assert!(
3795            err.contains("unknown field") || err.contains("buffer_id"),
3796            "Error should mention unknown field: {}",
3797            err
3798        );
3799    }
3800
3801    #[test]
3802    fn test_composite_hunk_rejects_unknown_fields() {
3803        // Valid JSON with correct field names
3804        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3805        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3806        assert!(
3807            result.is_ok(),
3808            "Valid JSON should parse: {:?}",
3809            result.err()
3810        );
3811
3812        // Invalid JSON with unknown field (old_start instead of oldStart)
3813        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3814        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3815        assert!(
3816            result.is_err(),
3817            "JSON with unknown field should fail to parse"
3818        );
3819        let err = result.unwrap_err().to_string();
3820        assert!(
3821            err.contains("unknown field") || err.contains("old_start"),
3822            "Error should mention unknown field: {}",
3823            err
3824        );
3825    }
3826
3827    #[test]
3828    fn test_plugin_response_line_end_position() {
3829        let response = PluginResponse::LineEndPosition {
3830            request_id: 42,
3831            position: Some(100),
3832        };
3833        let json = serde_json::to_string(&response).unwrap();
3834        assert!(json.contains("LineEndPosition"));
3835        assert!(json.contains("42"));
3836        assert!(json.contains("100"));
3837
3838        // Test None case
3839        let response_none = PluginResponse::LineEndPosition {
3840            request_id: 1,
3841            position: None,
3842        };
3843        let json_none = serde_json::to_string(&response_none).unwrap();
3844        assert!(json_none.contains("null"));
3845    }
3846
3847    #[test]
3848    fn test_plugin_response_buffer_line_count() {
3849        let response = PluginResponse::BufferLineCount {
3850            request_id: 99,
3851            count: Some(500),
3852        };
3853        let json = serde_json::to_string(&response).unwrap();
3854        assert!(json.contains("BufferLineCount"));
3855        assert!(json.contains("99"));
3856        assert!(json.contains("500"));
3857    }
3858
3859    #[test]
3860    fn test_plugin_command_get_line_end_position() {
3861        let command = PluginCommand::GetLineEndPosition {
3862            buffer_id: BufferId(1),
3863            line: 10,
3864            request_id: 123,
3865        };
3866        let json = serde_json::to_string(&command).unwrap();
3867        assert!(json.contains("GetLineEndPosition"));
3868        assert!(json.contains("10"));
3869    }
3870
3871    #[test]
3872    fn test_plugin_command_get_buffer_line_count() {
3873        let command = PluginCommand::GetBufferLineCount {
3874            buffer_id: BufferId(0),
3875            request_id: 456,
3876        };
3877        let json = serde_json::to_string(&command).unwrap();
3878        assert!(json.contains("GetBufferLineCount"));
3879        assert!(json.contains("456"));
3880    }
3881
3882    #[test]
3883    fn test_plugin_command_scroll_to_line_center() {
3884        let command = PluginCommand::ScrollToLineCenter {
3885            split_id: SplitId(1),
3886            buffer_id: BufferId(2),
3887            line: 50,
3888        };
3889        let json = serde_json::to_string(&command).unwrap();
3890        assert!(json.contains("ScrollToLineCenter"));
3891        assert!(json.contains("50"));
3892    }
3893
3894    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
3895    /// and renders as its underlying integer via `Display`.
3896    #[test]
3897    fn js_callback_id_conversions_and_display() {
3898        for raw in [0u64, 1, 42, u64::MAX] {
3899            let id = JsCallbackId::new(raw);
3900            assert_eq!(id.as_u64(), raw);
3901            assert_eq!(u64::from(id), raw);
3902            assert_eq!(JsCallbackId::from(raw), id);
3903            assert_eq!(id.to_string(), raw.to_string());
3904        }
3905    }
3906
3907    /// Serde `default = ...` helpers fire when the field is omitted and are
3908    /// overridden by explicit values. One test per struct pins each helper
3909    /// to its documented default.
3910    #[test]
3911    fn serde_defaults_fire_when_fields_are_omitted() {
3912        // default_action_count → 1
3913        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
3914        assert_eq!(spec.count, 1);
3915        let spec: ActionSpec =
3916            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
3917        assert_eq!(spec.count, 5);
3918
3919        // default_true → showSeparator = true
3920        let layout: CompositeLayoutConfig =
3921            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
3922        assert!(layout.show_separator);
3923        let layout: CompositeLayoutConfig =
3924            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
3925        assert!(!layout.show_separator);
3926
3927        // default_plugin_provider_priority → 50
3928        let reg: TsCompletionProviderRegistration =
3929            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
3930        assert_eq!(reg.priority, 50);
3931        let reg: TsCompletionProviderRegistration =
3932            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
3933        assert_eq!(reg.priority, 3);
3934    }
3935
3936    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
3937    //
3938    // These tests pin down observable behaviour for tiny methods whose bodies
3939    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
3940    // default value) without any existing test noticing.
3941
3942    /// Helper: build a minimal `Command` with a given name.
3943    fn mk_cmd(name: &str) -> Command {
3944        Command {
3945            name: name.to_string(),
3946            description: String::new(),
3947            action_name: String::new(),
3948            plugin_name: String::new(),
3949            custom_contexts: Vec::new(),
3950        }
3951    }
3952
3953    /// `CommandRegistry::register` appends new commands and replaces any
3954    /// existing entry with the same name; `unregister` removes exactly the
3955    /// matching entry and is a no-op for unknown names.
3956    ///
3957    /// Kills: replace register with `()`; `!= → ==` in register;
3958    ///        replace unregister with `()`; `!= → ==` in unregister.
3959    #[test]
3960    fn command_registry_register_and_unregister_semantics() {
3961        let r = CommandRegistry::new();
3962
3963        r.register(mk_cmd("a"));
3964        r.register(mk_cmd("b"));
3965        assert_eq!(r.commands.read().unwrap().len(), 2);
3966
3967        // Re-registering "a" must keep "b" (retain filters by `!=`); the
3968        // `== → !=` mutant would drop "b" and leave two copies of "a".
3969        r.register(mk_cmd("a"));
3970        let names: Vec<String> = r
3971            .commands
3972            .read()
3973            .unwrap()
3974            .iter()
3975            .map(|c| c.name.clone())
3976            .collect();
3977        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
3978
3979        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
3980        // mutant would keep "a" and drop "b".
3981        r.unregister("a");
3982        let names: Vec<String> = r
3983            .commands
3984            .read()
3985            .unwrap()
3986            .iter()
3987            .map(|c| c.name.clone())
3988            .collect();
3989        assert_eq!(names, vec!["b".to_string()]);
3990
3991        // Unregistering an unknown name is a no-op.
3992        r.unregister("nope");
3993        assert_eq!(r.commands.read().unwrap().len(), 1);
3994    }
3995
3996    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
3997    /// variant and `None` for the theme-key variant; `as_theme_key` is the
3998    /// dual. Uses a triple with no zero or one components and a theme key
3999    /// that is neither empty nor `"xyzzy"` to kill every constant-return
4000    /// mutant reported by cargo-mutants at once.
4001    #[test]
4002    fn overlay_color_spec_accessors_are_variant_specific() {
4003        let rgb = OverlayColorSpec::rgb(12, 34, 56);
4004        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
4005        assert_eq!(rgb.as_theme_key(), None);
4006
4007        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
4008        assert_eq!(tk.as_rgb(), None);
4009        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
4010    }
4011
4012    /// `PluginCommand::debug_variant_name` returns the actual variant name
4013    /// derived from the `Debug` impl, not an empty or hard-coded string.
4014    #[test]
4015    fn plugin_command_debug_variant_name_returns_real_variant() {
4016        let c = PluginCommand::SetStatus {
4017            message: "hi".into(),
4018        };
4019        assert_eq!(c.debug_variant_name(), "SetStatus");
4020
4021        let c2 = PluginCommand::InsertText {
4022            buffer_id: BufferId(1),
4023            position: 0,
4024            text: String::new(),
4025        };
4026        assert_eq!(c2.debug_variant_name(), "InsertText");
4027    }
4028
4029    // ── PluginApi dispatch / mutation tests ────────────────────────────────
4030    //
4031    // Each `PluginApi` method is a one-liner that either pushes a
4032    // `PluginCommand` onto the channel or mutates a shared registry. The
4033    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
4034    // disappears. One assertion per method ties the side effect down.
4035
4036    fn mk_api() -> (
4037        PluginApi,
4038        std::sync::mpsc::Receiver<PluginCommand>,
4039        Arc<RwLock<HookRegistry>>,
4040        Arc<RwLock<CommandRegistry>>,
4041        Arc<RwLock<EditorStateSnapshot>>,
4042    ) {
4043        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
4044        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
4045        let (tx, rx) = std::sync::mpsc::channel();
4046        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4047        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
4048        (api, rx, hooks, commands, snap)
4049    }
4050
4051    /// `unregister_hooks` must actually clear hooks registered under the
4052    /// same name; replacing the body with `()` leaves the count at 1.
4053    #[test]
4054    fn plugin_api_unregister_hooks_clears_registry() {
4055        let (api, _rx, hooks, _cmds, _snap) = mk_api();
4056        api.register_hook("h", Box::new(|_| true));
4057        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
4058        api.unregister_hooks("h");
4059        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
4060    }
4061
4062    /// `register_command` / `unregister_command` must actually write through
4063    /// to the shared `CommandRegistry`.
4064    #[test]
4065    fn plugin_api_register_and_unregister_command_write_through() {
4066        let (api, _rx, _hooks, cmds, _snap) = mk_api();
4067
4068        api.register_command(mk_cmd("x"));
4069        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
4070
4071        api.unregister_command("x");
4072        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
4073    }
4074
4075    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
4076    /// exactly one `PluginCommand` matching `$pattern` with the additional
4077    /// invariants in `$guard`.
4078    macro_rules! assert_dispatches {
4079        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
4080            let (api, rx, _h, _c, _s) = mk_api();
4081            let _ = $call(&api);
4082            match rx.try_recv().expect("no command sent") {
4083                $pattern $(if $guard)? => {}
4084                other => panic!("unexpected command variant: {:?}", other),
4085            }
4086        }};
4087    }
4088
4089    /// Every simple `send_command`-based method on `PluginApi` translates
4090    /// its arguments into the documented `PluginCommand` variant with the
4091    /// expected fields.
4092    #[test]
4093    fn plugin_api_send_command_methods_dispatch_correctly() {
4094        // delete_range
4095        assert_dispatches!(
4096            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
4097            PluginCommand::DeleteRange { buffer_id, range }
4098                if buffer_id == BufferId(7) && range == (3..9)
4099        );
4100
4101        // remove_overlay
4102        assert_dispatches!(
4103            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
4104            PluginCommand::RemoveOverlay { buffer_id, handle }
4105                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
4106        );
4107
4108        // clear_namespace
4109        assert_dispatches!(
4110            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
4111            PluginCommand::ClearNamespace { buffer_id, namespace }
4112                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
4113        );
4114
4115        // clear_overlays_in_range
4116        assert_dispatches!(
4117            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
4118            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
4119                if buffer_id == BufferId(4) && start == 10 && end == 20
4120        );
4121
4122        // open_file_at_location
4123        assert_dispatches!(
4124            |a: &PluginApi| a.open_file_at_location(
4125                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
4126            ),
4127            PluginCommand::OpenFileAtLocation { path, line, column }
4128                if path == PathBuf::from("/tmp/x.rs")
4129                    && line == Some(4)
4130                    && column == Some(8)
4131        );
4132
4133        // open_file_in_split
4134        assert_dispatches!(
4135            |a: &PluginApi| a.open_file_in_split(
4136                2, PathBuf::from("/tmp/y.rs"), Some(5), None
4137            ),
4138            PluginCommand::OpenFileInSplit { split_id, path, line, column }
4139                if split_id == 2
4140                    && path == PathBuf::from("/tmp/y.rs")
4141                    && line == Some(5)
4142                    && column.is_none()
4143        );
4144
4145        // start_prompt
4146        assert_dispatches!(
4147            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
4148            PluginCommand::StartPrompt { label, prompt_type }
4149                if label == "label" && prompt_type == "cmd"
4150        );
4151
4152        // set_prompt_suggestions
4153        assert_dispatches!(
4154            |a: &PluginApi| a.set_prompt_suggestions(vec![
4155                Suggestion::new("one".into()),
4156                Suggestion::new("two".into()),
4157            ]),
4158            PluginCommand::SetPromptSuggestions { suggestions }
4159                if suggestions.len() == 2
4160                    && suggestions[0].text == "one"
4161                    && suggestions[1].text == "two"
4162        );
4163
4164        // set_prompt_input_sync
4165        assert_dispatches!(
4166            |a: &PluginApi| a.set_prompt_input_sync(true),
4167            PluginCommand::SetPromptInputSync { sync } if sync
4168        );
4169        assert_dispatches!(
4170            |a: &PluginApi| a.set_prompt_input_sync(false),
4171            PluginCommand::SetPromptInputSync { sync } if !sync
4172        );
4173
4174        // add_menu_item
4175        assert_dispatches!(
4176            |a: &PluginApi| a.add_menu_item(
4177                "File".into(),
4178                MenuItem::Label { info: "info".into() },
4179                MenuPosition::Bottom,
4180            ),
4181            PluginCommand::AddMenuItem { menu_label, item, position }
4182                if menu_label == "File"
4183                    && matches!(item, MenuItem::Label { ref info } if info == "info")
4184                    && matches!(position, MenuPosition::Bottom)
4185        );
4186
4187        // add_menu
4188        assert_dispatches!(
4189            |a: &PluginApi| a.add_menu(
4190                Menu {
4191                    id: None,
4192                    label: "Help".into(),
4193                    items: vec![],
4194                    when: None,
4195                },
4196                MenuPosition::After("Edit".into()),
4197            ),
4198            PluginCommand::AddMenu { menu, position }
4199                if menu.label == "Help"
4200                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
4201        );
4202
4203        // remove_menu_item
4204        assert_dispatches!(
4205            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
4206            PluginCommand::RemoveMenuItem { menu_label, item_label }
4207                if menu_label == "File" && item_label == "Open"
4208        );
4209
4210        // remove_menu
4211        assert_dispatches!(
4212            |a: &PluginApi| a.remove_menu("File".into()),
4213            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
4214        );
4215
4216        // create_virtual_buffer
4217        assert_dispatches!(
4218            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
4219            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
4220                if name == "buf" && mode == "mode" && read_only
4221        );
4222
4223        // create_virtual_buffer_with_content
4224        assert_dispatches!(
4225            |a: &PluginApi| a.create_virtual_buffer_with_content(
4226                "n".into(), "m".into(), false, vec![]
4227            ),
4228            PluginCommand::CreateVirtualBufferWithContent {
4229                name, mode, read_only, show_line_numbers, show_cursors,
4230                editing_disabled, hidden_from_tabs, request_id, ..
4231            }
4232                if name == "n" && mode == "m" && !read_only
4233                    && show_line_numbers && show_cursors
4234                    && !editing_disabled && !hidden_from_tabs
4235                    && request_id.is_none()
4236        );
4237
4238        // set_virtual_buffer_content
4239        assert_dispatches!(
4240            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
4241            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
4242                if buffer_id == BufferId(9) && entries.is_empty()
4243        );
4244
4245        // get_text_properties_at_cursor
4246        assert_dispatches!(
4247            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
4248            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
4249                if buffer_id == BufferId(11)
4250        );
4251
4252        // define_mode
4253        assert_dispatches!(
4254            |a: &PluginApi| a.define_mode(
4255                "m".into(),
4256                vec![("j".into(), "move_down".into())],
4257                true,
4258                false,
4259            ),
4260            PluginCommand::DefineMode {
4261                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
4262            }
4263                if name == "m"
4264                    && bindings.len() == 1
4265                    && bindings[0].0 == "j"
4266                    && bindings[0].1 == "move_down"
4267                    && read_only
4268                    && !allow_text_input
4269                    && !inherit_normal_bindings
4270                    && plugin_name.is_none()
4271        );
4272
4273        // show_buffer
4274        assert_dispatches!(
4275            |a: &PluginApi| a.show_buffer(BufferId(77)),
4276            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
4277        );
4278
4279        // set_split_scroll
4280        assert_dispatches!(
4281            |a: &PluginApi| a.set_split_scroll(5, 128),
4282            PluginCommand::SetSplitScroll { split_id, top_byte }
4283                if split_id == SplitId(5) && top_byte == 128
4284        );
4285
4286        // get_highlights
4287        assert_dispatches!(
4288            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
4289            PluginCommand::RequestHighlights { buffer_id, range, request_id }
4290                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
4291        );
4292    }
4293
4294    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
4295    /// sentinel value kills both the `0` and `1` constant-return mutants.
4296    #[test]
4297    fn plugin_api_get_active_split_id_reads_snapshot() {
4298        let (api, _rx, _h, _c, snap) = mk_api();
4299        snap.write().unwrap().active_split_id = 42;
4300        assert_eq!(api.get_active_split_id(), 42);
4301    }
4302
4303    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
4304    /// freshly-defaulted snapshot. A distinguishing field value on the
4305    /// original state proves that the handle sees it.
4306    #[test]
4307    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
4308        let (api, _rx, _h, _c, snap) = mk_api();
4309        snap.write().unwrap().active_buffer_id = BufferId(42);
4310
4311        let h = api.state_snapshot_handle();
4312        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
4313        assert!(Arc::ptr_eq(&h, &snap));
4314    }
4315
4316    /// `KillHostProcess` survives a round-trip through serde: the
4317    /// `process_id` field stays identified by name and the variant
4318    /// retains its tag shape. If a future contributor renames the
4319    /// field or splits it into a tuple, the plugin-runtime TS side
4320    /// (which hand-builds the command JSON for the dispatcher) would
4321    /// silently break — this test pins the wire format.
4322    #[test]
4323    fn plugin_command_kill_host_process_serde_round_trip() {
4324        let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
4325        let json = serde_json::to_value(&cmd).unwrap();
4326        assert_eq!(json["KillHostProcess"]["process_id"], 1234);
4327        let decoded: PluginCommand = serde_json::from_value(json).unwrap();
4328        match decoded {
4329            PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
4330            other => panic!("expected KillHostProcess, got {:?}", other),
4331        }
4332    }
4333}