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 lsp_types;
55use serde::{Deserialize, Serialize};
56use serde_json::Value as JsonValue;
57use std::collections::HashMap;
58use std::ops::Range;
59use std::path::PathBuf;
60use std::sync::{Arc, RwLock};
61use ts_rs::TS;
62
63/// Minimal command registry for PluginApi.
64/// This is a stub that provides basic command storage for plugin use.
65/// The editor's full CommandRegistry lives in fresh-editor.
66pub struct CommandRegistry {
67    commands: std::sync::RwLock<Vec<Command>>,
68}
69
70impl CommandRegistry {
71    /// Create a new empty command registry
72    pub fn new() -> Self {
73        Self {
74            commands: std::sync::RwLock::new(Vec::new()),
75        }
76    }
77
78    /// Register a command
79    pub fn register(&self, command: Command) {
80        let mut commands = self.commands.write().unwrap();
81        commands.retain(|c| c.name != command.name);
82        commands.push(command);
83    }
84
85    /// Unregister a command by name  
86    pub fn unregister(&self, name: &str) {
87        let mut commands = self.commands.write().unwrap();
88        commands.retain(|c| c.name != name);
89    }
90}
91
92impl Default for CommandRegistry {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// A callback ID for JavaScript promises in the plugin runtime.
99///
100/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
101/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
102/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
104#[ts(export)]
105pub struct JsCallbackId(pub u64);
106
107impl JsCallbackId {
108    /// Create a new JS callback ID
109    pub fn new(id: u64) -> Self {
110        Self(id)
111    }
112
113    /// Get the underlying u64 value
114    pub fn as_u64(self) -> u64 {
115        self.0
116    }
117}
118
119impl From<u64> for JsCallbackId {
120    fn from(id: u64) -> Self {
121        Self(id)
122    }
123}
124
125impl From<JsCallbackId> for u64 {
126    fn from(id: JsCallbackId) -> u64 {
127        id.0
128    }
129}
130
131impl std::fmt::Display for JsCallbackId {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}", self.0)
134    }
135}
136
137/// Result of creating a virtual buffer
138#[derive(Debug, Clone, Serialize, Deserialize, TS)]
139#[serde(rename_all = "camelCase")]
140#[ts(export, rename_all = "camelCase")]
141pub struct VirtualBufferResult {
142    /// The created buffer ID
143    #[ts(type = "number")]
144    pub buffer_id: u64,
145    /// The split ID (if created in a new split)
146    #[ts(type = "number | null")]
147    pub split_id: Option<u64>,
148}
149
150/// Response from the editor for async plugin operations
151#[derive(Debug, Clone, Serialize, Deserialize, TS)]
152#[ts(export)]
153pub enum PluginResponse {
154    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
155    VirtualBufferCreated {
156        request_id: u64,
157        buffer_id: BufferId,
158        split_id: Option<SplitId>,
159    },
160    /// Response to a plugin-initiated LSP request
161    LspRequest {
162        request_id: u64,
163        #[ts(type = "any")]
164        result: Result<JsonValue, String>,
165    },
166    /// Response to RequestHighlights
167    HighlightsComputed {
168        request_id: u64,
169        spans: Vec<TsHighlightSpan>,
170    },
171    /// Response to GetBufferText with the text content
172    BufferText {
173        request_id: u64,
174        text: Result<String, String>,
175    },
176    /// Response to GetLineStartPosition with the byte offset
177    LineStartPosition {
178        request_id: u64,
179        /// None if line is out of range, Some(offset) for valid line
180        position: Option<usize>,
181    },
182    /// Response to GetLineEndPosition with the byte offset
183    LineEndPosition {
184        request_id: u64,
185        /// None if line is out of range, Some(offset) for valid line
186        position: Option<usize>,
187    },
188    /// Response to GetBufferLineCount with the total number of lines
189    BufferLineCount {
190        request_id: u64,
191        /// None if buffer not found, Some(count) for valid buffer
192        count: Option<usize>,
193    },
194    /// Response to CreateCompositeBuffer with the created buffer ID
195    CompositeBufferCreated {
196        request_id: u64,
197        buffer_id: BufferId,
198    },
199}
200
201/// Messages sent from async plugin tasks to the synchronous main loop
202#[derive(Debug, Clone, Serialize, Deserialize, TS)]
203#[ts(export)]
204pub enum PluginAsyncMessage {
205    /// Plugin process completed with output
206    ProcessOutput {
207        /// Unique ID for this process
208        process_id: u64,
209        /// Standard output
210        stdout: String,
211        /// Standard error
212        stderr: String,
213        /// Exit code
214        exit_code: i32,
215    },
216    /// Plugin delay/timer completed
217    DelayComplete {
218        /// Callback ID to resolve
219        callback_id: u64,
220    },
221    /// Background process stdout data
222    ProcessStdout { process_id: u64, data: String },
223    /// Background process stderr data
224    ProcessStderr { process_id: u64, data: String },
225    /// Background process exited
226    ProcessExit {
227        process_id: u64,
228        callback_id: u64,
229        exit_code: i32,
230    },
231    /// Response for a plugin-initiated LSP request
232    LspResponse {
233        language: String,
234        request_id: u64,
235        #[ts(type = "any")]
236        result: Result<JsonValue, String>,
237    },
238    /// Generic plugin response (e.g., GetBufferText result)
239    PluginResponse(crate::api::PluginResponse),
240}
241
242/// Information about a cursor in the editor
243#[derive(Debug, Clone, Serialize, Deserialize, TS)]
244#[ts(export)]
245pub struct CursorInfo {
246    /// Byte position of the cursor
247    pub position: usize,
248    /// Selection range (if any)
249    #[cfg_attr(
250        feature = "plugins",
251        ts(type = "{ start: number; end: number } | null")
252    )]
253    pub selection: Option<Range<usize>>,
254}
255
256/// Specification for an action to execute, with optional repeat count
257#[derive(Debug, Clone, Serialize, Deserialize, TS)]
258#[serde(deny_unknown_fields)]
259#[ts(export)]
260pub struct ActionSpec {
261    /// Action name (e.g., "move_word_right", "delete_line")
262    pub action: String,
263    /// Number of times to repeat the action (default 1)
264    #[serde(default = "default_action_count")]
265    pub count: u32,
266}
267
268fn default_action_count() -> u32 {
269    1
270}
271
272/// Information about a buffer
273#[derive(Debug, Clone, Serialize, Deserialize, TS)]
274#[ts(export)]
275pub struct BufferInfo {
276    /// Buffer ID
277    #[ts(type = "number")]
278    pub id: BufferId,
279    /// File path (if any)
280    #[serde(serialize_with = "serialize_path")]
281    #[ts(type = "string")]
282    pub path: Option<PathBuf>,
283    /// Whether the buffer has been modified
284    pub modified: bool,
285    /// Length of buffer in bytes
286    pub length: usize,
287}
288
289fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
290    s.serialize_str(
291        &path
292            .as_ref()
293            .map(|p| p.to_string_lossy().to_string())
294            .unwrap_or_default(),
295    )
296}
297
298/// Serialize ranges as [start, end] tuples for JS compatibility
299fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
300where
301    S: serde::Serializer,
302{
303    use serde::ser::SerializeSeq;
304    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
305    for range in ranges {
306        seq.serialize_element(&(range.start, range.end))?;
307    }
308    seq.end()
309}
310
311/// Serialize optional ranges as [start, end] tuples for JS compatibility
312fn serialize_opt_ranges_as_tuples<S>(
313    ranges: &Option<Vec<Range<usize>>>,
314    serializer: S,
315) -> Result<S::Ok, S::Error>
316where
317    S: serde::Serializer,
318{
319    match ranges {
320        Some(ranges) => {
321            use serde::ser::SerializeSeq;
322            let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
323            for range in ranges {
324                seq.serialize_element(&(range.start, range.end))?;
325            }
326            seq.end()
327        }
328        None => serializer.serialize_none(),
329    }
330}
331
332/// Diff between current buffer content and last saved snapshot
333#[derive(Debug, Clone, Serialize, Deserialize, TS)]
334#[ts(export)]
335pub struct BufferSavedDiff {
336    pub equal: bool,
337    #[serde(serialize_with = "serialize_ranges_as_tuples")]
338    #[ts(type = "Array<[number, number]>")]
339    pub byte_ranges: Vec<Range<usize>>,
340    #[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
341    #[ts(type = "Array<[number, number]> | null")]
342    pub line_ranges: Option<Vec<Range<usize>>>,
343}
344
345/// Information about the viewport
346#[derive(Debug, Clone, Serialize, Deserialize, TS)]
347#[serde(rename_all = "camelCase")]
348#[ts(export, rename_all = "camelCase")]
349pub struct ViewportInfo {
350    /// Byte position of the first visible line
351    pub top_byte: usize,
352    /// Left column offset (horizontal scroll)
353    pub left_column: usize,
354    /// Viewport width
355    pub width: u16,
356    /// Viewport height
357    pub height: u16,
358}
359
360/// Layout hints supplied by plugins (e.g., Compose mode)
361#[derive(Debug, Clone, Serialize, Deserialize, TS)]
362#[serde(rename_all = "camelCase")]
363#[ts(export, rename_all = "camelCase")]
364pub struct LayoutHints {
365    /// Optional compose width for centering/wrapping
366    pub compose_width: Option<u16>,
367    /// Optional column guides for aligned tables
368    pub column_guides: Option<Vec<u16>>,
369}
370
371// ============================================================================
372// Overlay Types with Theme Support
373// ============================================================================
374
375/// Color specification that can be either RGB values or a theme key.
376///
377/// Theme keys reference colors from the current theme, e.g.:
378/// - "ui.status_bar_bg" - UI status bar background
379/// - "editor.selection_bg" - Editor selection background
380/// - "syntax.keyword" - Syntax highlighting for keywords
381/// - "diagnostic.error" - Error diagnostic color
382///
383/// When a theme key is used, the color is resolved at render time,
384/// so overlays automatically update when the theme changes.
385#[derive(Debug, Clone, Serialize, Deserialize, TS)]
386#[serde(untagged)]
387#[ts(export)]
388pub enum OverlayColorSpec {
389    /// RGB color as [r, g, b] array
390    #[ts(type = "[number, number, number]")]
391    Rgb(u8, u8, u8),
392    /// Theme key reference (e.g., "ui.status_bar_bg")
393    ThemeKey(String),
394}
395
396impl OverlayColorSpec {
397    /// Create an RGB color spec
398    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
399        Self::Rgb(r, g, b)
400    }
401
402    /// Create a theme key color spec
403    pub fn theme_key(key: impl Into<String>) -> Self {
404        Self::ThemeKey(key.into())
405    }
406
407    /// Convert to RGB if this is an RGB spec, None if it's a theme key
408    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
409        match self {
410            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
411            Self::ThemeKey(_) => None,
412        }
413    }
414
415    /// Get the theme key if this is a theme key spec
416    pub fn as_theme_key(&self) -> Option<&str> {
417        match self {
418            Self::ThemeKey(key) => Some(key),
419            Self::Rgb(_, _, _) => None,
420        }
421    }
422}
423
424/// Options for adding an overlay with theme support.
425///
426/// This struct provides a type-safe way to specify overlay styling
427/// with optional theme key references for colors.
428#[derive(Debug, Clone, Serialize, Deserialize, TS)]
429#[serde(deny_unknown_fields, rename_all = "camelCase")]
430#[ts(export, rename_all = "camelCase")]
431pub struct OverlayOptions {
432    /// Foreground color - RGB array or theme key string
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub fg: Option<OverlayColorSpec>,
435
436    /// Background color - RGB array or theme key string
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub bg: Option<OverlayColorSpec>,
439
440    /// Whether to render with underline
441    #[serde(default)]
442    pub underline: bool,
443
444    /// Whether to render in bold
445    #[serde(default)]
446    pub bold: bool,
447
448    /// Whether to render in italic
449    #[serde(default)]
450    pub italic: bool,
451
452    /// Whether to extend background color to end of line
453    #[serde(default)]
454    pub extend_to_line_end: bool,
455}
456
457impl Default for OverlayOptions {
458    fn default() -> Self {
459        Self {
460            fg: None,
461            bg: None,
462            underline: false,
463            bold: false,
464            italic: false,
465            extend_to_line_end: false,
466        }
467    }
468}
469
470// ============================================================================
471// Composite Buffer Configuration (for multi-buffer single-tab views)
472// ============================================================================
473
474/// Layout configuration for composite buffers
475#[derive(Debug, Clone, Serialize, Deserialize, TS)]
476#[serde(deny_unknown_fields)]
477#[ts(export, rename = "TsCompositeLayoutConfig")]
478pub struct CompositeLayoutConfig {
479    /// Layout type: "side-by-side", "stacked", or "unified"
480    #[serde(rename = "type")]
481    #[ts(rename = "type")]
482    pub layout_type: String,
483    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
484    #[serde(default)]
485    pub ratios: Option<Vec<f32>>,
486    /// Show separator between panes
487    #[serde(default = "default_true", rename = "showSeparator")]
488    #[ts(rename = "showSeparator")]
489    pub show_separator: bool,
490    /// Spacing for stacked layout
491    #[serde(default)]
492    pub spacing: Option<u16>,
493}
494
495fn default_true() -> bool {
496    true
497}
498
499/// Source pane configuration for composite buffers
500#[derive(Debug, Clone, Serialize, Deserialize, TS)]
501#[serde(deny_unknown_fields)]
502#[ts(export, rename = "TsCompositeSourceConfig")]
503pub struct CompositeSourceConfig {
504    /// Buffer ID of the source buffer (required)
505    #[serde(rename = "bufferId")]
506    #[ts(rename = "bufferId")]
507    pub buffer_id: usize,
508    /// Label for this pane (e.g., "OLD", "NEW")
509    pub label: String,
510    /// Whether this pane is editable
511    #[serde(default)]
512    pub editable: bool,
513    /// Style configuration
514    #[serde(default)]
515    pub style: Option<CompositePaneStyle>,
516}
517
518/// Style configuration for a composite pane
519#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
520#[serde(deny_unknown_fields)]
521#[ts(export, rename = "TsCompositePaneStyle")]
522pub struct CompositePaneStyle {
523    /// Background color for added lines (RGB)
524    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
525    #[serde(default, rename = "addBg")]
526    #[ts(rename = "addBg", type = "[number, number, number] | null")]
527    pub add_bg: Option<[u8; 3]>,
528    /// Background color for removed lines (RGB)
529    #[serde(default, rename = "removeBg")]
530    #[ts(rename = "removeBg", type = "[number, number, number] | null")]
531    pub remove_bg: Option<[u8; 3]>,
532    /// Background color for modified lines (RGB)
533    #[serde(default, rename = "modifyBg")]
534    #[ts(rename = "modifyBg", type = "[number, number, number] | null")]
535    pub modify_bg: Option<[u8; 3]>,
536    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
537    #[serde(default, rename = "gutterStyle")]
538    #[ts(rename = "gutterStyle")]
539    pub gutter_style: Option<String>,
540}
541
542/// Diff hunk for composite buffer alignment
543#[derive(Debug, Clone, Serialize, Deserialize, TS)]
544#[serde(deny_unknown_fields)]
545#[ts(export, rename = "TsCompositeHunk")]
546pub struct CompositeHunk {
547    /// Starting line in old buffer (0-indexed)
548    #[serde(rename = "oldStart")]
549    #[ts(rename = "oldStart")]
550    pub old_start: usize,
551    /// Number of lines in old buffer
552    #[serde(rename = "oldCount")]
553    #[ts(rename = "oldCount")]
554    pub old_count: usize,
555    /// Starting line in new buffer (0-indexed)
556    #[serde(rename = "newStart")]
557    #[ts(rename = "newStart")]
558    pub new_start: usize,
559    /// Number of lines in new buffer
560    #[serde(rename = "newCount")]
561    #[ts(rename = "newCount")]
562    pub new_count: usize,
563}
564
565/// Options for creating a composite buffer (used by plugin API)
566#[derive(Debug, Clone, Serialize, Deserialize, TS)]
567#[serde(deny_unknown_fields)]
568#[ts(export, rename = "TsCreateCompositeBufferOptions")]
569pub struct CreateCompositeBufferOptions {
570    /// Buffer name (displayed in tabs/title)
571    #[serde(default)]
572    pub name: String,
573    /// Mode for keybindings
574    #[serde(default)]
575    pub mode: String,
576    /// Layout configuration
577    pub layout: CompositeLayoutConfig,
578    /// Source pane configurations
579    pub sources: Vec<CompositeSourceConfig>,
580    /// Diff hunks for alignment (optional)
581    #[serde(default)]
582    pub hunks: Option<Vec<CompositeHunk>>,
583}
584
585/// Wire-format view token kind (serialized for plugin transforms)
586#[derive(Debug, Clone, Serialize, Deserialize, TS)]
587#[ts(export)]
588pub enum ViewTokenWireKind {
589    Text(String),
590    Newline,
591    Space,
592    /// Visual line break inserted by wrapping (not from source)
593    /// Always has source_offset: None
594    Break,
595    /// A single binary byte that should be rendered as <XX>
596    /// Used in binary file mode to ensure cursor positioning works correctly
597    /// (all 4 display chars of <XX> map to the same source byte)
598    BinaryByte(u8),
599}
600
601/// Styling for view tokens (used for injected annotations)
602///
603/// This allows plugins to specify styling for tokens that don't have a source
604/// mapping (sourceOffset: None), such as annotation headers in git blame.
605/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
606#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
607#[serde(deny_unknown_fields)]
608#[ts(export)]
609pub struct ViewTokenStyle {
610    /// Foreground color as RGB tuple
611    #[serde(default)]
612    #[ts(type = "[number, number, number] | null")]
613    pub fg: Option<(u8, u8, u8)>,
614    /// Background color as RGB tuple
615    #[serde(default)]
616    #[ts(type = "[number, number, number] | null")]
617    pub bg: Option<(u8, u8, u8)>,
618    /// Whether to render in bold
619    #[serde(default)]
620    pub bold: bool,
621    /// Whether to render in italic
622    #[serde(default)]
623    pub italic: bool,
624}
625
626/// Wire-format view token with optional source mapping and styling
627#[derive(Debug, Clone, Serialize, Deserialize, TS)]
628#[serde(deny_unknown_fields)]
629#[ts(export)]
630pub struct ViewTokenWire {
631    /// Source byte offset in the buffer. None for injected content (annotations).
632    #[ts(type = "number | null")]
633    pub source_offset: Option<usize>,
634    /// The token content
635    pub kind: ViewTokenWireKind,
636    /// Optional styling for injected content (only used when source_offset is None)
637    #[serde(default)]
638    #[ts(optional)]
639    pub style: Option<ViewTokenStyle>,
640}
641
642/// Transformed view stream payload (plugin-provided)
643#[derive(Debug, Clone, Serialize, Deserialize, TS)]
644#[ts(export)]
645pub struct ViewTransformPayload {
646    /// Byte range this transform applies to (viewport)
647    pub range: Range<usize>,
648    /// Tokens in wire format
649    pub tokens: Vec<ViewTokenWire>,
650    /// Layout hints
651    pub layout_hints: Option<LayoutHints>,
652}
653
654/// Snapshot of editor state for plugin queries
655/// This is updated by the editor on each loop iteration
656#[derive(Debug, Clone, Serialize, Deserialize, TS)]
657#[ts(export)]
658pub struct EditorStateSnapshot {
659    /// Currently active buffer ID
660    pub active_buffer_id: BufferId,
661    /// Currently active split ID
662    pub active_split_id: usize,
663    /// Information about all open buffers
664    pub buffers: HashMap<BufferId, BufferInfo>,
665    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
666    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
667    /// Primary cursor position for the active buffer
668    pub primary_cursor: Option<CursorInfo>,
669    /// All cursor positions for the active buffer
670    pub all_cursors: Vec<CursorInfo>,
671    /// Viewport information for the active buffer
672    pub viewport: Option<ViewportInfo>,
673    /// Cursor positions per buffer (for buffers other than active)
674    pub buffer_cursor_positions: HashMap<BufferId, usize>,
675    /// Text properties per buffer (for virtual buffers with properties)
676    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
677    /// Selected text from the primary cursor (if any selection exists)
678    /// This is populated on each update to avoid needing full buffer access
679    pub selected_text: Option<String>,
680    /// Internal clipboard content (for plugins that need clipboard access)
681    pub clipboard: String,
682    /// Editor's working directory (for file operations and spawning processes)
683    pub working_dir: PathBuf,
684    /// LSP diagnostics per file URI
685    /// Maps file URI string to Vec of diagnostics for that file
686    #[ts(type = "any")]
687    pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
688    /// Runtime config as serde_json::Value (merged user config + defaults)
689    /// This is the runtime config, not just the user's config file
690    #[ts(type = "any")]
691    pub config: serde_json::Value,
692    /// User config as serde_json::Value (only what's in the user's config file)
693    /// Fields not present here are using default values
694    #[ts(type = "any")]
695    pub user_config: serde_json::Value,
696    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
697    /// When set, this mode's keybindings take precedence over normal key handling
698    pub editor_mode: Option<String>,
699}
700
701impl EditorStateSnapshot {
702    pub fn new() -> Self {
703        Self {
704            active_buffer_id: BufferId(0),
705            active_split_id: 0,
706            buffers: HashMap::new(),
707            buffer_saved_diffs: HashMap::new(),
708            primary_cursor: None,
709            all_cursors: Vec::new(),
710            viewport: None,
711            buffer_cursor_positions: HashMap::new(),
712            buffer_text_properties: HashMap::new(),
713            selected_text: None,
714            clipboard: String::new(),
715            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
716            diagnostics: HashMap::new(),
717            config: serde_json::Value::Null,
718            user_config: serde_json::Value::Null,
719            editor_mode: None,
720        }
721    }
722}
723
724impl Default for EditorStateSnapshot {
725    fn default() -> Self {
726        Self::new()
727    }
728}
729
730/// Position for inserting menu items or menus
731#[derive(Debug, Clone, Serialize, Deserialize, TS)]
732#[ts(export)]
733pub enum MenuPosition {
734    /// Add at the beginning
735    Top,
736    /// Add at the end
737    Bottom,
738    /// Add before a specific label
739    Before(String),
740    /// Add after a specific label
741    After(String),
742}
743
744/// Plugin command - allows plugins to send commands to the editor
745#[derive(Debug, Clone, Serialize, Deserialize, TS)]
746#[ts(export)]
747pub enum PluginCommand {
748    /// Insert text at a position in a buffer
749    InsertText {
750        buffer_id: BufferId,
751        position: usize,
752        text: String,
753    },
754
755    /// Delete a range of text from a buffer
756    DeleteRange {
757        buffer_id: BufferId,
758        range: Range<usize>,
759    },
760
761    /// Add an overlay to a buffer, returns handle via response channel
762    ///
763    /// Colors can be specified as RGB tuples or theme keys. When theme keys
764    /// are provided, they take precedence and are resolved at render time.
765    AddOverlay {
766        buffer_id: BufferId,
767        namespace: Option<OverlayNamespace>,
768        range: Range<usize>,
769        /// Overlay styling options (colors, modifiers, etc.)
770        options: OverlayOptions,
771    },
772
773    /// Remove an overlay by its opaque handle
774    RemoveOverlay {
775        buffer_id: BufferId,
776        handle: OverlayHandle,
777    },
778
779    /// Set status message
780    SetStatus { message: String },
781
782    /// Apply a theme by name
783    ApplyTheme { theme_name: String },
784
785    /// Reload configuration from file
786    /// After a plugin saves config changes, it should call this to reload the config
787    ReloadConfig,
788
789    /// Register a custom command
790    RegisterCommand { command: Command },
791
792    /// Unregister a command by name
793    UnregisterCommand { name: String },
794
795    /// Open a file in the editor (in background, without switching focus)
796    OpenFileInBackground { path: PathBuf },
797
798    /// Insert text at the current cursor position in the active buffer
799    InsertAtCursor { text: String },
800
801    /// Spawn an async process
802    SpawnProcess {
803        command: String,
804        args: Vec<String>,
805        cwd: Option<String>,
806        callback_id: JsCallbackId,
807    },
808
809    /// Delay/sleep for a duration (async, resolves callback when done)
810    Delay {
811        callback_id: JsCallbackId,
812        duration_ms: u64,
813    },
814
815    /// Spawn a long-running background process
816    /// Unlike SpawnProcess, this returns immediately with a process handle
817    /// and provides streaming output via hooks
818    SpawnBackgroundProcess {
819        /// Unique ID for this process (generated by plugin runtime)
820        process_id: u64,
821        /// Command to execute
822        command: String,
823        /// Arguments to pass
824        args: Vec<String>,
825        /// Working directory (optional)
826        cwd: Option<String>,
827        /// Callback ID to call when process exits
828        callback_id: JsCallbackId,
829    },
830
831    /// Kill a background process by ID
832    KillBackgroundProcess { process_id: u64 },
833
834    /// Wait for a process to complete and get its result
835    /// Used with processes started via SpawnProcess
836    SpawnProcessWait {
837        /// Process ID to wait for
838        process_id: u64,
839        /// Callback ID for async response
840        callback_id: JsCallbackId,
841    },
842
843    /// Set layout hints for a buffer/viewport
844    SetLayoutHints {
845        buffer_id: BufferId,
846        split_id: Option<SplitId>,
847        range: Range<usize>,
848        hints: LayoutHints,
849    },
850
851    /// Enable/disable line numbers for a buffer
852    SetLineNumbers { buffer_id: BufferId, enabled: bool },
853
854    /// Enable/disable line wrapping for a buffer
855    SetLineWrap {
856        buffer_id: BufferId,
857        split_id: Option<SplitId>,
858        enabled: bool,
859    },
860
861    /// Submit a transformed view stream for a viewport
862    SubmitViewTransform {
863        buffer_id: BufferId,
864        split_id: Option<SplitId>,
865        payload: ViewTransformPayload,
866    },
867
868    /// Clear view transform for a buffer/split (returns to normal rendering)
869    ClearViewTransform {
870        buffer_id: BufferId,
871        split_id: Option<SplitId>,
872    },
873
874    /// Remove all overlays from a buffer
875    ClearAllOverlays { buffer_id: BufferId },
876
877    /// Remove all overlays in a namespace
878    ClearNamespace {
879        buffer_id: BufferId,
880        namespace: OverlayNamespace,
881    },
882
883    /// Remove all overlays that overlap with a byte range
884    /// Used for targeted invalidation when content in a range changes
885    ClearOverlaysInRange {
886        buffer_id: BufferId,
887        start: usize,
888        end: usize,
889    },
890
891    /// Add virtual text (inline text that doesn't exist in the buffer)
892    /// Used for color swatches, type hints, parameter hints, etc.
893    AddVirtualText {
894        buffer_id: BufferId,
895        virtual_text_id: String,
896        position: usize,
897        text: String,
898        color: (u8, u8, u8),
899        use_bg: bool, // true = use color as background, false = use as foreground
900        before: bool, // true = before char, false = after char
901    },
902
903    /// Remove a virtual text by ID
904    RemoveVirtualText {
905        buffer_id: BufferId,
906        virtual_text_id: String,
907    },
908
909    /// Remove virtual texts whose ID starts with the given prefix
910    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
911
912    /// Clear all virtual texts from a buffer
913    ClearVirtualTexts { buffer_id: BufferId },
914
915    /// Add a virtual LINE (full line above/below a position)
916    /// Used for git blame headers, code coverage, inline documentation, etc.
917    /// These lines do NOT show line numbers in the gutter.
918    AddVirtualLine {
919        buffer_id: BufferId,
920        /// Byte position to anchor the line to
921        position: usize,
922        /// Full line content to display
923        text: String,
924        /// Foreground color (RGB)
925        fg_color: (u8, u8, u8),
926        /// Background color (RGB), None = transparent
927        bg_color: Option<(u8, u8, u8)>,
928        /// true = above the line containing position, false = below
929        above: bool,
930        /// Namespace for bulk removal (e.g., "git-blame")
931        namespace: String,
932        /// Priority for ordering multiple lines at same position (higher = later)
933        priority: i32,
934    },
935
936    /// Clear all virtual texts in a namespace
937    /// This is the primary way to remove a plugin's virtual lines before updating them.
938    ClearVirtualTextNamespace {
939        buffer_id: BufferId,
940        namespace: String,
941    },
942
943    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
944    RefreshLines { buffer_id: BufferId },
945
946    /// Set a line indicator in the gutter's indicator column
947    /// Used for git gutter, breakpoints, bookmarks, etc.
948    SetLineIndicator {
949        buffer_id: BufferId,
950        /// Line number (0-indexed)
951        line: usize,
952        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
953        namespace: String,
954        /// Symbol to display (e.g., "│", "●", "★")
955        symbol: String,
956        /// Color as RGB tuple
957        color: (u8, u8, u8),
958        /// Priority for display when multiple indicators exist (higher wins)
959        priority: i32,
960    },
961
962    /// Clear all line indicators for a specific namespace
963    ClearLineIndicators {
964        buffer_id: BufferId,
965        /// Namespace to clear (e.g., "git-gutter")
966        namespace: String,
967    },
968
969    /// Set file explorer decorations for a namespace
970    SetFileExplorerDecorations {
971        /// Namespace for grouping (e.g., "git-status")
972        namespace: String,
973        /// Decorations to apply
974        decorations: Vec<FileExplorerDecoration>,
975    },
976
977    /// Clear file explorer decorations for a namespace
978    ClearFileExplorerDecorations {
979        /// Namespace to clear (e.g., "git-status")
980        namespace: String,
981    },
982
983    /// Open a file at a specific line and column
984    /// Line and column are 1-indexed to match git grep output
985    OpenFileAtLocation {
986        path: PathBuf,
987        line: Option<usize>,   // 1-indexed, None = go to start
988        column: Option<usize>, // 1-indexed, None = go to line start
989    },
990
991    /// Open a file in a specific split at a given line and column
992    /// Line and column are 1-indexed to match git grep output
993    OpenFileInSplit {
994        split_id: usize,
995        path: PathBuf,
996        line: Option<usize>,   // 1-indexed, None = go to start
997        column: Option<usize>, // 1-indexed, None = go to line start
998    },
999
1000    /// Start a prompt (minibuffer) with a custom type identifier
1001    /// This allows plugins to create interactive prompts
1002    StartPrompt {
1003        label: String,
1004        prompt_type: String, // e.g., "git-grep", "git-find-file"
1005    },
1006
1007    /// Start a prompt with pre-filled initial value
1008    StartPromptWithInitial {
1009        label: String,
1010        prompt_type: String,
1011        initial_value: String,
1012    },
1013
1014    /// Start an async prompt that returns result via callback
1015    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1016    StartPromptAsync {
1017        label: String,
1018        initial_value: String,
1019        callback_id: JsCallbackId,
1020    },
1021
1022    /// Update the suggestions list for the current prompt
1023    /// Uses the editor's Suggestion type
1024    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1025
1026    /// Add a menu item to an existing menu
1027    /// Add a menu item to an existing menu
1028    AddMenuItem {
1029        menu_label: String,
1030        item: MenuItem,
1031        position: MenuPosition,
1032    },
1033
1034    /// Add a new top-level menu
1035    AddMenu { menu: Menu, position: MenuPosition },
1036
1037    /// Remove a menu item from a menu
1038    RemoveMenuItem {
1039        menu_label: String,
1040        item_label: String,
1041    },
1042
1043    /// Remove a top-level menu
1044    RemoveMenu { menu_label: String },
1045
1046    /// Create a new virtual buffer (not backed by a file)
1047    CreateVirtualBuffer {
1048        /// Display name (e.g., "*Diagnostics*")
1049        name: String,
1050        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1051        mode: String,
1052        /// Whether the buffer is read-only
1053        read_only: bool,
1054    },
1055
1056    /// Create a virtual buffer and set its content in one operation
1057    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1058    /// because it doesn't require tracking the buffer ID
1059    CreateVirtualBufferWithContent {
1060        /// Display name (e.g., "*Diagnostics*")
1061        name: String,
1062        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1063        mode: String,
1064        /// Whether the buffer is read-only
1065        read_only: bool,
1066        /// Entries with text and embedded properties
1067        entries: Vec<TextPropertyEntry>,
1068        /// Whether to show line numbers in the gutter
1069        show_line_numbers: bool,
1070        /// Whether to show cursors in the buffer
1071        show_cursors: bool,
1072        /// Whether editing is disabled (blocks editing commands)
1073        editing_disabled: bool,
1074        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1075        hidden_from_tabs: bool,
1076        /// Optional request ID for async response
1077        request_id: Option<u64>,
1078    },
1079
1080    /// Create a virtual buffer in a horizontal split
1081    /// Opens the buffer in a new pane below the current one
1082    CreateVirtualBufferInSplit {
1083        /// Display name (e.g., "*Diagnostics*")
1084        name: String,
1085        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1086        mode: String,
1087        /// Whether the buffer is read-only
1088        read_only: bool,
1089        /// Entries with text and embedded properties
1090        entries: Vec<TextPropertyEntry>,
1091        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1092        ratio: f32,
1093        /// Split direction ("horizontal" or "vertical"), default horizontal
1094        direction: Option<String>,
1095        /// Optional panel ID for idempotent operations (if panel exists, update content)
1096        panel_id: Option<String>,
1097        /// Whether to show line numbers in the buffer (default true)
1098        show_line_numbers: bool,
1099        /// Whether to show cursors in the buffer (default true)
1100        show_cursors: bool,
1101        /// Whether editing is disabled for this buffer (default false)
1102        editing_disabled: bool,
1103        /// Whether line wrapping is enabled for this split (None = use global setting)
1104        line_wrap: Option<bool>,
1105        /// Optional request ID for async response (if set, editor will send back buffer ID)
1106        request_id: Option<u64>,
1107    },
1108
1109    /// Set the content of a virtual buffer with text properties
1110    SetVirtualBufferContent {
1111        buffer_id: BufferId,
1112        /// Entries with text and embedded properties
1113        entries: Vec<TextPropertyEntry>,
1114    },
1115
1116    /// Get text properties at the cursor position in a buffer
1117    GetTextPropertiesAtCursor { buffer_id: BufferId },
1118
1119    /// Define a buffer mode with keybindings
1120    DefineMode {
1121        name: String,
1122        parent: Option<String>,
1123        bindings: Vec<(String, String)>, // (key_string, command_name)
1124        read_only: bool,
1125    },
1126
1127    /// Switch the current split to display a buffer
1128    ShowBuffer { buffer_id: BufferId },
1129
1130    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1131    CreateVirtualBufferInExistingSplit {
1132        /// Display name (e.g., "*Commit Details*")
1133        name: String,
1134        /// Mode name for buffer-local keybindings
1135        mode: String,
1136        /// Whether the buffer is read-only
1137        read_only: bool,
1138        /// Entries with text and embedded properties
1139        entries: Vec<TextPropertyEntry>,
1140        /// Target split ID where the buffer should be displayed
1141        split_id: SplitId,
1142        /// Whether to show line numbers in the buffer (default true)
1143        show_line_numbers: bool,
1144        /// Whether to show cursors in the buffer (default true)
1145        show_cursors: bool,
1146        /// Whether editing is disabled for this buffer (default false)
1147        editing_disabled: bool,
1148        /// Whether line wrapping is enabled for this split (None = use global setting)
1149        line_wrap: Option<bool>,
1150        /// Optional request ID for async response
1151        request_id: Option<u64>,
1152    },
1153
1154    /// Close a buffer and remove it from all splits
1155    CloseBuffer { buffer_id: BufferId },
1156
1157    /// Create a composite buffer that displays multiple source buffers
1158    /// Used for side-by-side diff, unified diff, and 3-way merge views
1159    CreateCompositeBuffer {
1160        /// Display name (shown in tab bar)
1161        name: String,
1162        /// Mode name for keybindings (e.g., "diff-view")
1163        mode: String,
1164        /// Layout configuration
1165        layout: CompositeLayoutConfig,
1166        /// Source pane configurations
1167        sources: Vec<CompositeSourceConfig>,
1168        /// Diff hunks for line alignment (optional)
1169        hunks: Option<Vec<CompositeHunk>>,
1170        /// Request ID for async response
1171        request_id: Option<u64>,
1172    },
1173
1174    /// Update alignment for a composite buffer (e.g., after source edit)
1175    UpdateCompositeAlignment {
1176        buffer_id: BufferId,
1177        hunks: Vec<CompositeHunk>,
1178    },
1179
1180    /// Close a composite buffer
1181    CloseCompositeBuffer { buffer_id: BufferId },
1182
1183    /// Focus a specific split
1184    FocusSplit { split_id: SplitId },
1185
1186    /// Set the buffer displayed in a specific split
1187    SetSplitBuffer {
1188        split_id: SplitId,
1189        buffer_id: BufferId,
1190    },
1191
1192    /// Set the scroll position of a specific split
1193    SetSplitScroll { split_id: SplitId, top_byte: usize },
1194
1195    /// Request syntax highlights for a buffer range
1196    RequestHighlights {
1197        buffer_id: BufferId,
1198        range: Range<usize>,
1199        request_id: u64,
1200    },
1201
1202    /// Close a split (if not the last one)
1203    CloseSplit { split_id: SplitId },
1204
1205    /// Set the ratio of a split container
1206    SetSplitRatio {
1207        split_id: SplitId,
1208        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1209        ratio: f32,
1210    },
1211
1212    /// Distribute splits evenly - make all given splits equal size
1213    DistributeSplitsEvenly {
1214        /// Split IDs to distribute evenly
1215        split_ids: Vec<SplitId>,
1216    },
1217
1218    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1219    SetBufferCursor {
1220        buffer_id: BufferId,
1221        /// Byte offset position for the cursor
1222        position: usize,
1223    },
1224
1225    /// Send an arbitrary LSP request and return the raw JSON response
1226    SendLspRequest {
1227        language: String,
1228        method: String,
1229        #[ts(type = "any")]
1230        params: Option<JsonValue>,
1231        request_id: u64,
1232    },
1233
1234    /// Set the internal clipboard content
1235    SetClipboard { text: String },
1236
1237    /// Delete the current selection in the active buffer
1238    /// This deletes all selected text across all cursors
1239    DeleteSelection,
1240
1241    /// Set or unset a custom context
1242    /// Custom contexts are plugin-defined states that can be used to control command visibility
1243    /// For example, "config-editor" context could make config editor commands available
1244    SetContext {
1245        /// Context name (e.g., "config-editor")
1246        name: String,
1247        /// Whether the context is active
1248        active: bool,
1249    },
1250
1251    /// Set the hunks for the Review Diff tool
1252    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1253
1254    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1255    /// Used by vi mode plugin to run motions and calculate cursor ranges
1256    ExecuteAction {
1257        /// Action name (e.g., "move_word_right", "move_line_end")
1258        action_name: String,
1259    },
1260
1261    /// Execute multiple actions in sequence, each with an optional repeat count
1262    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1263    /// All actions execute atomically with no plugin roundtrips between them
1264    ExecuteActions {
1265        /// List of actions to execute in sequence
1266        actions: Vec<ActionSpec>,
1267    },
1268
1269    /// Get text from a buffer range (for yank operations)
1270    GetBufferText {
1271        /// Buffer ID
1272        buffer_id: BufferId,
1273        /// Start byte offset
1274        start: usize,
1275        /// End byte offset
1276        end: usize,
1277        /// Request ID for async response
1278        request_id: u64,
1279    },
1280
1281    /// Get byte offset of the start of a line (async)
1282    /// Line is 0-indexed (0 = first line)
1283    GetLineStartPosition {
1284        /// Buffer ID (0 for active buffer)
1285        buffer_id: BufferId,
1286        /// Line number (0-indexed)
1287        line: u32,
1288        /// Request ID for async response
1289        request_id: u64,
1290    },
1291
1292    /// Get byte offset of the end of a line (async)
1293    /// Line is 0-indexed (0 = first line)
1294    /// Returns the byte offset after the last character of the line (before newline)
1295    GetLineEndPosition {
1296        /// Buffer ID (0 for active buffer)
1297        buffer_id: BufferId,
1298        /// Line number (0-indexed)
1299        line: u32,
1300        /// Request ID for async response
1301        request_id: u64,
1302    },
1303
1304    /// Get the total number of lines in a buffer (async)
1305    GetBufferLineCount {
1306        /// Buffer ID (0 for active buffer)
1307        buffer_id: BufferId,
1308        /// Request ID for async response
1309        request_id: u64,
1310    },
1311
1312    /// Scroll a split to center a specific line in the viewport
1313    /// Line is 0-indexed (0 = first line)
1314    ScrollToLineCenter {
1315        /// Split ID to scroll
1316        split_id: SplitId,
1317        /// Buffer ID containing the line
1318        buffer_id: BufferId,
1319        /// Line number to center (0-indexed)
1320        line: usize,
1321    },
1322
1323    /// Set the global editor mode (for modal editing like vi mode)
1324    /// When set, the mode's keybindings take precedence over normal editing
1325    SetEditorMode {
1326        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1327        mode: Option<String>,
1328    },
1329
1330    /// Show an action popup with buttons for user interaction
1331    /// When the user selects an action, the ActionPopupResult hook is fired
1332    ShowActionPopup {
1333        /// Unique identifier for the popup (used in ActionPopupResult)
1334        popup_id: String,
1335        /// Title text for the popup
1336        title: String,
1337        /// Body message (supports basic formatting)
1338        message: String,
1339        /// Action buttons to display
1340        actions: Vec<ActionPopupAction>,
1341    },
1342
1343    /// Disable LSP for a specific language and persist to config
1344    DisableLspForLanguage {
1345        /// The language to disable LSP for (e.g., "python", "rust")
1346        language: String,
1347    },
1348
1349    /// Set the workspace root URI for a specific language's LSP server
1350    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1351    /// If the LSP is already running, it will be restarted with the new root
1352    SetLspRootUri {
1353        /// The language to set root URI for (e.g., "csharp", "rust")
1354        language: String,
1355        /// The root URI (file:// URL format)
1356        uri: String,
1357    },
1358
1359    /// Create a scroll sync group for anchor-based synchronized scrolling
1360    /// Used for side-by-side diff views where two panes need to scroll together
1361    /// The plugin provides the group ID (must be unique per plugin)
1362    CreateScrollSyncGroup {
1363        /// Plugin-assigned group ID
1364        group_id: u32,
1365        /// The left (primary) split - scroll position is tracked in this split's line space
1366        left_split: SplitId,
1367        /// The right (secondary) split - position is derived from anchors
1368        right_split: SplitId,
1369    },
1370
1371    /// Set sync anchors for a scroll sync group
1372    /// Anchors map corresponding line numbers between left and right buffers
1373    SetScrollSyncAnchors {
1374        /// The group ID returned by CreateScrollSyncGroup
1375        group_id: u32,
1376        /// List of (left_line, right_line) pairs marking corresponding positions
1377        anchors: Vec<(usize, usize)>,
1378    },
1379
1380    /// Remove a scroll sync group
1381    RemoveScrollSyncGroup {
1382        /// The group ID returned by CreateScrollSyncGroup
1383        group_id: u32,
1384    },
1385
1386    /// Save a buffer to a specific file path
1387    /// Used by :w filename command to save unnamed buffers or save-as
1388    SaveBufferToPath {
1389        /// Buffer ID to save
1390        buffer_id: BufferId,
1391        /// Path to save to
1392        path: PathBuf,
1393    },
1394
1395    /// Load a plugin from a file path
1396    /// The plugin will be initialized and start receiving events
1397    LoadPlugin {
1398        /// Path to the plugin file (.ts or .js)
1399        path: PathBuf,
1400        /// Callback ID for async response (success/failure)
1401        callback_id: JsCallbackId,
1402    },
1403
1404    /// Unload a plugin by name
1405    /// The plugin will stop receiving events and be removed from memory
1406    UnloadPlugin {
1407        /// Plugin name (as registered)
1408        name: String,
1409        /// Callback ID for async response (success/failure)
1410        callback_id: JsCallbackId,
1411    },
1412
1413    /// Reload a plugin by name (unload + load)
1414    /// Useful for development when plugin code changes
1415    ReloadPlugin {
1416        /// Plugin name (as registered)
1417        name: String,
1418        /// Callback ID for async response (success/failure)
1419        callback_id: JsCallbackId,
1420    },
1421
1422    /// List all loaded plugins
1423    /// Returns plugin info (name, path, enabled) for all loaded plugins
1424    ListPlugins {
1425        /// Callback ID for async response (JSON array of plugin info)
1426        callback_id: JsCallbackId,
1427    },
1428
1429    /// Reload the theme registry from disk
1430    /// Call this after installing a theme package or saving a new theme
1431    ReloadThemes,
1432
1433    /// Register a TextMate grammar file for a language
1434    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1435    RegisterGrammar {
1436        /// Language identifier (e.g., "elixir", "zig")
1437        language: String,
1438        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1439        grammar_path: String,
1440        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1441        extensions: Vec<String>,
1442    },
1443
1444    /// Register language configuration (comment prefix, indentation, formatter)
1445    /// This is applied immediately to the runtime config
1446    RegisterLanguageConfig {
1447        /// Language identifier (e.g., "elixir")
1448        language: String,
1449        /// Language configuration
1450        config: LanguagePackConfig,
1451    },
1452
1453    /// Register an LSP server for a language
1454    /// This is applied immediately to the LSP manager and runtime config
1455    RegisterLspServer {
1456        /// Language identifier (e.g., "elixir")
1457        language: String,
1458        /// LSP server configuration
1459        config: LspServerPackConfig,
1460    },
1461
1462    /// Reload the grammar registry to apply registered grammars
1463    /// Call this after registering one or more grammars to rebuild the syntax set
1464    ReloadGrammars,
1465}
1466
1467// =============================================================================
1468// Language Pack Configuration Types
1469// =============================================================================
1470
1471/// Language configuration for language packs
1472///
1473/// This is a simplified version of the full LanguageConfig, containing only
1474/// the fields that can be set via the plugin API.
1475#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
1476#[serde(rename_all = "camelCase")]
1477#[ts(export)]
1478pub struct LanguagePackConfig {
1479    /// Comment prefix for line comments (e.g., "//" or "#")
1480    #[serde(default)]
1481    pub comment_prefix: Option<String>,
1482
1483    /// Block comment start marker (e.g., slash-star)
1484    #[serde(default)]
1485    pub block_comment_start: Option<String>,
1486
1487    /// Block comment end marker (e.g., star-slash)
1488    #[serde(default)]
1489    pub block_comment_end: Option<String>,
1490
1491    /// Whether to use tabs instead of spaces for indentation
1492    #[serde(default)]
1493    pub use_tabs: Option<bool>,
1494
1495    /// Tab size (number of spaces per tab level)
1496    #[serde(default)]
1497    pub tab_size: Option<usize>,
1498
1499    /// Whether auto-indent is enabled
1500    #[serde(default)]
1501    pub auto_indent: Option<bool>,
1502
1503    /// Whether to show whitespace tab indicators (→) for this language
1504    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
1505    #[serde(default)]
1506    pub show_whitespace_tabs: Option<bool>,
1507
1508    /// Formatter configuration
1509    #[serde(default)]
1510    pub formatter: Option<FormatterPackConfig>,
1511}
1512
1513/// Formatter configuration for language packs
1514#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1515#[serde(rename_all = "camelCase")]
1516#[ts(export)]
1517pub struct FormatterPackConfig {
1518    /// Command to run (e.g., "prettier", "rustfmt")
1519    pub command: String,
1520
1521    /// Arguments to pass to the formatter
1522    #[serde(default)]
1523    pub args: Vec<String>,
1524}
1525
1526/// LSP server configuration for language packs
1527#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1528#[serde(rename_all = "camelCase")]
1529#[ts(export)]
1530pub struct LspServerPackConfig {
1531    /// Command to start the LSP server
1532    pub command: String,
1533
1534    /// Arguments to pass to the command
1535    #[serde(default)]
1536    pub args: Vec<String>,
1537
1538    /// Whether to auto-start the server when a matching file is opened
1539    #[serde(default)]
1540    pub auto_start: Option<bool>,
1541
1542    /// LSP initialization options
1543    #[serde(default)]
1544    #[ts(type = "Record<string, unknown> | null")]
1545    pub initialization_options: Option<JsonValue>,
1546}
1547
1548/// Hunk status for Review Diff
1549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1550#[ts(export)]
1551pub enum HunkStatus {
1552    Pending,
1553    Staged,
1554    Discarded,
1555}
1556
1557/// A high-level hunk directive for the Review Diff tool
1558#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1559#[ts(export)]
1560pub struct ReviewHunk {
1561    pub id: String,
1562    pub file: String,
1563    pub context_header: String,
1564    pub status: HunkStatus,
1565    /// 0-indexed line range in the base (HEAD) version
1566    pub base_range: Option<(usize, usize)>,
1567    /// 0-indexed line range in the modified (Working) version
1568    pub modified_range: Option<(usize, usize)>,
1569}
1570
1571/// Action button for action popups
1572#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1573#[serde(deny_unknown_fields)]
1574#[ts(export, rename = "TsActionPopupAction")]
1575pub struct ActionPopupAction {
1576    /// Unique action identifier (returned in ActionPopupResult)
1577    pub id: String,
1578    /// Display text for the button (can include command hints)
1579    pub label: String,
1580}
1581
1582/// Options for showActionPopup
1583#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1584#[serde(deny_unknown_fields)]
1585#[ts(export)]
1586pub struct ActionPopupOptions {
1587    /// Unique identifier for the popup (used in ActionPopupResult)
1588    pub id: String,
1589    /// Title text for the popup
1590    pub title: String,
1591    /// Body message (supports basic formatting)
1592    pub message: String,
1593    /// Action buttons to display
1594    pub actions: Vec<ActionPopupAction>,
1595}
1596
1597/// Syntax highlight span for a buffer range
1598#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1599#[ts(export)]
1600pub struct TsHighlightSpan {
1601    pub start: u32,
1602    pub end: u32,
1603    #[ts(type = "[number, number, number]")]
1604    pub color: (u8, u8, u8),
1605    pub bold: bool,
1606    pub italic: bool,
1607}
1608
1609/// Result from spawning a process with spawnProcess
1610#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1611#[ts(export)]
1612pub struct SpawnResult {
1613    /// Complete stdout as string
1614    pub stdout: String,
1615    /// Complete stderr as string
1616    pub stderr: String,
1617    /// Process exit code (0 usually means success, -1 if killed)
1618    pub exit_code: i32,
1619}
1620
1621/// Result from spawning a background process
1622#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1623#[ts(export)]
1624pub struct BackgroundProcessResult {
1625    /// Unique process ID for later reference
1626    #[ts(type = "number")]
1627    pub process_id: u64,
1628    /// Process exit code (0 usually means success, -1 if killed)
1629    /// Only present when the process has exited
1630    pub exit_code: i32,
1631}
1632
1633/// Entry for virtual buffer content with optional text properties (JS API version)
1634#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1635#[serde(deny_unknown_fields)]
1636#[ts(export, rename = "TextPropertyEntry")]
1637pub struct JsTextPropertyEntry {
1638    /// Text content for this entry
1639    pub text: String,
1640    /// Optional properties attached to this text (e.g., file path, line number)
1641    #[serde(default)]
1642    #[ts(optional, type = "Record<string, unknown>")]
1643    pub properties: Option<HashMap<String, JsonValue>>,
1644}
1645
1646/// Directory entry returned by readDir
1647#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1648#[ts(export)]
1649pub struct DirEntry {
1650    /// File/directory name
1651    pub name: String,
1652    /// True if this is a file
1653    pub is_file: bool,
1654    /// True if this is a directory
1655    pub is_dir: bool,
1656}
1657
1658/// Position in a document (line and character)
1659#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1660#[ts(export)]
1661pub struct JsPosition {
1662    /// Zero-indexed line number
1663    pub line: u32,
1664    /// Zero-indexed character offset
1665    pub character: u32,
1666}
1667
1668/// Range in a document (start and end positions)
1669#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1670#[ts(export)]
1671pub struct JsRange {
1672    /// Start position
1673    pub start: JsPosition,
1674    /// End position
1675    pub end: JsPosition,
1676}
1677
1678/// Diagnostic from LSP
1679#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1680#[ts(export)]
1681pub struct JsDiagnostic {
1682    /// Document URI
1683    pub uri: String,
1684    /// Diagnostic message
1685    pub message: String,
1686    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
1687    pub severity: Option<u8>,
1688    /// Range in the document
1689    pub range: JsRange,
1690    /// Source of the diagnostic (e.g., "typescript", "eslint")
1691    #[ts(optional)]
1692    pub source: Option<String>,
1693}
1694
1695/// Options for createVirtualBuffer
1696#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1697#[serde(deny_unknown_fields)]
1698#[ts(export)]
1699pub struct CreateVirtualBufferOptions {
1700    /// Buffer name (displayed in tabs/title)
1701    pub name: String,
1702    /// Mode for keybindings (e.g., "git-log", "search-results")
1703    #[serde(default)]
1704    #[ts(optional)]
1705    pub mode: Option<String>,
1706    /// Whether buffer is read-only (default: false)
1707    #[serde(default, rename = "readOnly")]
1708    #[ts(optional, rename = "readOnly")]
1709    pub read_only: Option<bool>,
1710    /// Show line numbers in gutter (default: false)
1711    #[serde(default, rename = "showLineNumbers")]
1712    #[ts(optional, rename = "showLineNumbers")]
1713    pub show_line_numbers: Option<bool>,
1714    /// Show cursor (default: true)
1715    #[serde(default, rename = "showCursors")]
1716    #[ts(optional, rename = "showCursors")]
1717    pub show_cursors: Option<bool>,
1718    /// Disable text editing (default: false)
1719    #[serde(default, rename = "editingDisabled")]
1720    #[ts(optional, rename = "editingDisabled")]
1721    pub editing_disabled: Option<bool>,
1722    /// Hide from tab bar (default: false)
1723    #[serde(default, rename = "hiddenFromTabs")]
1724    #[ts(optional, rename = "hiddenFromTabs")]
1725    pub hidden_from_tabs: Option<bool>,
1726    /// Initial content entries with optional properties
1727    #[serde(default)]
1728    #[ts(optional)]
1729    pub entries: Option<Vec<JsTextPropertyEntry>>,
1730}
1731
1732/// Options for createVirtualBufferInSplit
1733#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1734#[serde(deny_unknown_fields)]
1735#[ts(export)]
1736pub struct CreateVirtualBufferInSplitOptions {
1737    /// Buffer name (displayed in tabs/title)
1738    pub name: String,
1739    /// Mode for keybindings (e.g., "git-log", "search-results")
1740    #[serde(default)]
1741    #[ts(optional)]
1742    pub mode: Option<String>,
1743    /// Whether buffer is read-only (default: false)
1744    #[serde(default, rename = "readOnly")]
1745    #[ts(optional, rename = "readOnly")]
1746    pub read_only: Option<bool>,
1747    /// Split ratio 0.0-1.0 (default: 0.5)
1748    #[serde(default)]
1749    #[ts(optional)]
1750    pub ratio: Option<f32>,
1751    /// Split direction: "horizontal" or "vertical"
1752    #[serde(default)]
1753    #[ts(optional)]
1754    pub direction: Option<String>,
1755    /// Panel ID to split from
1756    #[serde(default, rename = "panelId")]
1757    #[ts(optional, rename = "panelId")]
1758    pub panel_id: Option<String>,
1759    /// Show line numbers in gutter (default: true)
1760    #[serde(default, rename = "showLineNumbers")]
1761    #[ts(optional, rename = "showLineNumbers")]
1762    pub show_line_numbers: Option<bool>,
1763    /// Show cursor (default: true)
1764    #[serde(default, rename = "showCursors")]
1765    #[ts(optional, rename = "showCursors")]
1766    pub show_cursors: Option<bool>,
1767    /// Disable text editing (default: false)
1768    #[serde(default, rename = "editingDisabled")]
1769    #[ts(optional, rename = "editingDisabled")]
1770    pub editing_disabled: Option<bool>,
1771    /// Enable line wrapping
1772    #[serde(default, rename = "lineWrap")]
1773    #[ts(optional, rename = "lineWrap")]
1774    pub line_wrap: Option<bool>,
1775    /// Initial content entries with optional properties
1776    #[serde(default)]
1777    #[ts(optional)]
1778    pub entries: Option<Vec<JsTextPropertyEntry>>,
1779}
1780
1781/// Options for createVirtualBufferInExistingSplit
1782#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1783#[serde(deny_unknown_fields)]
1784#[ts(export)]
1785pub struct CreateVirtualBufferInExistingSplitOptions {
1786    /// Buffer name (displayed in tabs/title)
1787    pub name: String,
1788    /// Target split ID (required)
1789    #[serde(rename = "splitId")]
1790    #[ts(rename = "splitId")]
1791    pub split_id: usize,
1792    /// Mode for keybindings (e.g., "git-log", "search-results")
1793    #[serde(default)]
1794    #[ts(optional)]
1795    pub mode: Option<String>,
1796    /// Whether buffer is read-only (default: false)
1797    #[serde(default, rename = "readOnly")]
1798    #[ts(optional, rename = "readOnly")]
1799    pub read_only: Option<bool>,
1800    /// Show line numbers in gutter (default: true)
1801    #[serde(default, rename = "showLineNumbers")]
1802    #[ts(optional, rename = "showLineNumbers")]
1803    pub show_line_numbers: Option<bool>,
1804    /// Show cursor (default: true)
1805    #[serde(default, rename = "showCursors")]
1806    #[ts(optional, rename = "showCursors")]
1807    pub show_cursors: Option<bool>,
1808    /// Disable text editing (default: false)
1809    #[serde(default, rename = "editingDisabled")]
1810    #[ts(optional, rename = "editingDisabled")]
1811    pub editing_disabled: Option<bool>,
1812    /// Enable line wrapping
1813    #[serde(default, rename = "lineWrap")]
1814    #[ts(optional, rename = "lineWrap")]
1815    pub line_wrap: Option<bool>,
1816    /// Initial content entries with optional properties
1817    #[serde(default)]
1818    #[ts(optional)]
1819    pub entries: Option<Vec<JsTextPropertyEntry>>,
1820}
1821
1822/// Result of getTextPropertiesAtCursor - array of property objects
1823///
1824/// Each element contains the properties from a text property span that overlaps
1825/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
1826#[derive(Debug, Clone, Serialize, TS)]
1827#[ts(export, type = "Array<Record<string, unknown>>")]
1828pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
1829
1830// Implement FromJs for option types using rquickjs_serde
1831#[cfg(feature = "plugins")]
1832mod fromjs_impls {
1833    use super::*;
1834    use rquickjs::{Ctx, FromJs, Value};
1835
1836    impl<'js> FromJs<'js> for JsTextPropertyEntry {
1837        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1838            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1839                from: "object",
1840                to: "JsTextPropertyEntry",
1841                message: Some(e.to_string()),
1842            })
1843        }
1844    }
1845
1846    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
1847        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1848            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1849                from: "object",
1850                to: "CreateVirtualBufferOptions",
1851                message: Some(e.to_string()),
1852            })
1853        }
1854    }
1855
1856    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
1857        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1858            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1859                from: "object",
1860                to: "CreateVirtualBufferInSplitOptions",
1861                message: Some(e.to_string()),
1862            })
1863        }
1864    }
1865
1866    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
1867        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1868            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1869                from: "object",
1870                to: "CreateVirtualBufferInExistingSplitOptions",
1871                message: Some(e.to_string()),
1872            })
1873        }
1874    }
1875
1876    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
1877        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1878            rquickjs_serde::to_value(ctx.clone(), &self.0)
1879                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1880        }
1881    }
1882
1883    // === Additional input types for type-safe plugin API ===
1884
1885    impl<'js> FromJs<'js> for ActionSpec {
1886        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1887            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1888                from: "object",
1889                to: "ActionSpec",
1890                message: Some(e.to_string()),
1891            })
1892        }
1893    }
1894
1895    impl<'js> FromJs<'js> for ActionPopupAction {
1896        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1897            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1898                from: "object",
1899                to: "ActionPopupAction",
1900                message: Some(e.to_string()),
1901            })
1902        }
1903    }
1904
1905    impl<'js> FromJs<'js> for ActionPopupOptions {
1906        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1907            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1908                from: "object",
1909                to: "ActionPopupOptions",
1910                message: Some(e.to_string()),
1911            })
1912        }
1913    }
1914
1915    impl<'js> FromJs<'js> for ViewTokenWire {
1916        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1917            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1918                from: "object",
1919                to: "ViewTokenWire",
1920                message: Some(e.to_string()),
1921            })
1922        }
1923    }
1924
1925    impl<'js> FromJs<'js> for ViewTokenStyle {
1926        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1927            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1928                from: "object",
1929                to: "ViewTokenStyle",
1930                message: Some(e.to_string()),
1931            })
1932        }
1933    }
1934
1935    impl<'js> FromJs<'js> for LayoutHints {
1936        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1937            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1938                from: "object",
1939                to: "LayoutHints",
1940                message: Some(e.to_string()),
1941            })
1942        }
1943    }
1944
1945    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
1946        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1947            // Use two-step deserialization for complex nested structures
1948            let json: serde_json::Value =
1949                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1950                    from: "object",
1951                    to: "CreateCompositeBufferOptions (json)",
1952                    message: Some(e.to_string()),
1953                })?;
1954            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
1955                from: "json",
1956                to: "CreateCompositeBufferOptions",
1957                message: Some(e.to_string()),
1958            })
1959        }
1960    }
1961
1962    impl<'js> FromJs<'js> for CompositeHunk {
1963        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1964            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1965                from: "object",
1966                to: "CompositeHunk",
1967                message: Some(e.to_string()),
1968            })
1969        }
1970    }
1971
1972    impl<'js> FromJs<'js> for LanguagePackConfig {
1973        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1974            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1975                from: "object",
1976                to: "LanguagePackConfig",
1977                message: Some(e.to_string()),
1978            })
1979        }
1980    }
1981
1982    impl<'js> FromJs<'js> for LspServerPackConfig {
1983        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1984            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1985                from: "object",
1986                to: "LspServerPackConfig",
1987                message: Some(e.to_string()),
1988            })
1989        }
1990    }
1991}
1992
1993/// Plugin API context - provides safe access to editor functionality
1994pub struct PluginApi {
1995    /// Hook registry (shared with editor)
1996    hooks: Arc<RwLock<HookRegistry>>,
1997
1998    /// Command registry (shared with editor)
1999    commands: Arc<RwLock<CommandRegistry>>,
2000
2001    /// Command queue for sending commands to editor
2002    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2003
2004    /// Snapshot of editor state (read-only for plugins)
2005    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2006}
2007
2008impl PluginApi {
2009    /// Create a new plugin API context
2010    pub fn new(
2011        hooks: Arc<RwLock<HookRegistry>>,
2012        commands: Arc<RwLock<CommandRegistry>>,
2013        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2014        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2015    ) -> Self {
2016        Self {
2017            hooks,
2018            commands,
2019            command_sender,
2020            state_snapshot,
2021        }
2022    }
2023
2024    /// Register a hook callback
2025    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2026        let mut hooks = self.hooks.write().unwrap();
2027        hooks.add_hook(hook_name, callback);
2028    }
2029
2030    /// Remove all hooks for a specific name
2031    pub fn unregister_hooks(&self, hook_name: &str) {
2032        let mut hooks = self.hooks.write().unwrap();
2033        hooks.remove_hooks(hook_name);
2034    }
2035
2036    /// Register a command
2037    pub fn register_command(&self, command: Command) {
2038        let commands = self.commands.read().unwrap();
2039        commands.register(command);
2040    }
2041
2042    /// Unregister a command by name
2043    pub fn unregister_command(&self, name: &str) {
2044        let commands = self.commands.read().unwrap();
2045        commands.unregister(name);
2046    }
2047
2048    /// Send a command to the editor (async/non-blocking)
2049    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2050        self.command_sender
2051            .send(command)
2052            .map_err(|e| format!("Failed to send command: {}", e))
2053    }
2054
2055    /// Insert text at a position in a buffer
2056    pub fn insert_text(
2057        &self,
2058        buffer_id: BufferId,
2059        position: usize,
2060        text: String,
2061    ) -> Result<(), String> {
2062        self.send_command(PluginCommand::InsertText {
2063            buffer_id,
2064            position,
2065            text,
2066        })
2067    }
2068
2069    /// Delete a range of text from a buffer
2070    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2071        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2072    }
2073
2074    /// Add an overlay (decoration) to a buffer
2075    /// Add an overlay to a buffer with styling options
2076    ///
2077    /// Returns an opaque handle that can be used to remove the overlay later.
2078    ///
2079    /// Colors can be specified as RGB arrays or theme key strings.
2080    /// Theme keys are resolved at render time, so overlays update with theme changes.
2081    pub fn add_overlay(
2082        &self,
2083        buffer_id: BufferId,
2084        namespace: Option<String>,
2085        range: Range<usize>,
2086        options: OverlayOptions,
2087    ) -> Result<(), String> {
2088        self.send_command(PluginCommand::AddOverlay {
2089            buffer_id,
2090            namespace: namespace.map(OverlayNamespace::from_string),
2091            range,
2092            options,
2093        })
2094    }
2095
2096    /// Remove an overlay from a buffer by its handle
2097    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
2098        self.send_command(PluginCommand::RemoveOverlay {
2099            buffer_id,
2100            handle: OverlayHandle::from_string(handle),
2101        })
2102    }
2103
2104    /// Clear all overlays in a namespace from a buffer
2105    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
2106        self.send_command(PluginCommand::ClearNamespace {
2107            buffer_id,
2108            namespace: OverlayNamespace::from_string(namespace),
2109        })
2110    }
2111
2112    /// Clear all overlays that overlap with a byte range
2113    /// Used for targeted invalidation when content changes
2114    pub fn clear_overlays_in_range(
2115        &self,
2116        buffer_id: BufferId,
2117        start: usize,
2118        end: usize,
2119    ) -> Result<(), String> {
2120        self.send_command(PluginCommand::ClearOverlaysInRange {
2121            buffer_id,
2122            start,
2123            end,
2124        })
2125    }
2126
2127    /// Set the status message
2128    pub fn set_status(&self, message: String) -> Result<(), String> {
2129        self.send_command(PluginCommand::SetStatus { message })
2130    }
2131
2132    /// Open a file at a specific line and column (1-indexed)
2133    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
2134    pub fn open_file_at_location(
2135        &self,
2136        path: PathBuf,
2137        line: Option<usize>,
2138        column: Option<usize>,
2139    ) -> Result<(), String> {
2140        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
2141    }
2142
2143    /// Open a file in a specific split at a line and column
2144    ///
2145    /// Similar to open_file_at_location but targets a specific split pane.
2146    /// The split_id is the ID of the split pane to open the file in.
2147    pub fn open_file_in_split(
2148        &self,
2149        split_id: usize,
2150        path: PathBuf,
2151        line: Option<usize>,
2152        column: Option<usize>,
2153    ) -> Result<(), String> {
2154        self.send_command(PluginCommand::OpenFileInSplit {
2155            split_id,
2156            path,
2157            line,
2158            column,
2159        })
2160    }
2161
2162    /// Start a prompt (minibuffer) with a custom type identifier
2163    /// The prompt_type is used to filter hooks in plugin code
2164    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
2165        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
2166    }
2167
2168    /// Set the suggestions for the current prompt
2169    /// This updates the prompt's autocomplete/selection list
2170    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
2171        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
2172    }
2173
2174    /// Add a menu item to an existing menu
2175    pub fn add_menu_item(
2176        &self,
2177        menu_label: String,
2178        item: MenuItem,
2179        position: MenuPosition,
2180    ) -> Result<(), String> {
2181        self.send_command(PluginCommand::AddMenuItem {
2182            menu_label,
2183            item,
2184            position,
2185        })
2186    }
2187
2188    /// Add a new top-level menu
2189    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
2190        self.send_command(PluginCommand::AddMenu { menu, position })
2191    }
2192
2193    /// Remove a menu item from a menu
2194    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
2195        self.send_command(PluginCommand::RemoveMenuItem {
2196            menu_label,
2197            item_label,
2198        })
2199    }
2200
2201    /// Remove a top-level menu
2202    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
2203        self.send_command(PluginCommand::RemoveMenu { menu_label })
2204    }
2205
2206    // === Virtual Buffer Methods ===
2207
2208    /// Create a new virtual buffer (not backed by a file)
2209    ///
2210    /// Virtual buffers are used for special displays like diagnostic lists,
2211    /// search results, etc. They have their own mode for keybindings.
2212    pub fn create_virtual_buffer(
2213        &self,
2214        name: String,
2215        mode: String,
2216        read_only: bool,
2217    ) -> Result<(), String> {
2218        self.send_command(PluginCommand::CreateVirtualBuffer {
2219            name,
2220            mode,
2221            read_only,
2222        })
2223    }
2224
2225    /// Create a virtual buffer and set its content in one operation
2226    ///
2227    /// This is the preferred way to create virtual buffers since it doesn't
2228    /// require tracking the buffer ID. The buffer is created and populated
2229    /// atomically.
2230    pub fn create_virtual_buffer_with_content(
2231        &self,
2232        name: String,
2233        mode: String,
2234        read_only: bool,
2235        entries: Vec<TextPropertyEntry>,
2236    ) -> Result<(), String> {
2237        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
2238            name,
2239            mode,
2240            read_only,
2241            entries,
2242            show_line_numbers: true,
2243            show_cursors: true,
2244            editing_disabled: false,
2245            hidden_from_tabs: false,
2246            request_id: None,
2247        })
2248    }
2249
2250    /// Set the content of a virtual buffer with text properties
2251    ///
2252    /// Each entry contains text and metadata properties (e.g., source location).
2253    pub fn set_virtual_buffer_content(
2254        &self,
2255        buffer_id: BufferId,
2256        entries: Vec<TextPropertyEntry>,
2257    ) -> Result<(), String> {
2258        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
2259    }
2260
2261    /// Get text properties at cursor position in a buffer
2262    ///
2263    /// This triggers a command that will make properties available to plugins.
2264    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
2265        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
2266    }
2267
2268    /// Define a buffer mode with keybindings
2269    ///
2270    /// Modes can inherit from parent modes (e.g., "diagnostics-list" inherits from "special").
2271    /// Bindings are specified as (key_string, command_name) pairs.
2272    pub fn define_mode(
2273        &self,
2274        name: String,
2275        parent: Option<String>,
2276        bindings: Vec<(String, String)>,
2277        read_only: bool,
2278    ) -> Result<(), String> {
2279        self.send_command(PluginCommand::DefineMode {
2280            name,
2281            parent,
2282            bindings,
2283            read_only,
2284        })
2285    }
2286
2287    /// Switch the current split to display a buffer
2288    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
2289        self.send_command(PluginCommand::ShowBuffer { buffer_id })
2290    }
2291
2292    /// Set the scroll position of a specific split
2293    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
2294        self.send_command(PluginCommand::SetSplitScroll {
2295            split_id: SplitId(split_id),
2296            top_byte,
2297        })
2298    }
2299
2300    /// Request syntax highlights for a buffer range
2301    pub fn get_highlights(
2302        &self,
2303        buffer_id: BufferId,
2304        range: Range<usize>,
2305        request_id: u64,
2306    ) -> Result<(), String> {
2307        self.send_command(PluginCommand::RequestHighlights {
2308            buffer_id,
2309            range,
2310            request_id,
2311        })
2312    }
2313
2314    // === Query Methods ===
2315
2316    /// Get the currently active buffer ID
2317    pub fn get_active_buffer_id(&self) -> BufferId {
2318        let snapshot = self.state_snapshot.read().unwrap();
2319        snapshot.active_buffer_id
2320    }
2321
2322    /// Get the currently active split ID
2323    pub fn get_active_split_id(&self) -> usize {
2324        let snapshot = self.state_snapshot.read().unwrap();
2325        snapshot.active_split_id
2326    }
2327
2328    /// Get information about a specific buffer
2329    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
2330        let snapshot = self.state_snapshot.read().unwrap();
2331        snapshot.buffers.get(&buffer_id).cloned()
2332    }
2333
2334    /// Get all buffer IDs
2335    pub fn list_buffers(&self) -> Vec<BufferInfo> {
2336        let snapshot = self.state_snapshot.read().unwrap();
2337        snapshot.buffers.values().cloned().collect()
2338    }
2339
2340    /// Get primary cursor information for the active buffer
2341    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
2342        let snapshot = self.state_snapshot.read().unwrap();
2343        snapshot.primary_cursor.clone()
2344    }
2345
2346    /// Get all cursor information for the active buffer
2347    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
2348        let snapshot = self.state_snapshot.read().unwrap();
2349        snapshot.all_cursors.clone()
2350    }
2351
2352    /// Get viewport information for the active buffer
2353    pub fn get_viewport(&self) -> Option<ViewportInfo> {
2354        let snapshot = self.state_snapshot.read().unwrap();
2355        snapshot.viewport.clone()
2356    }
2357
2358    /// Get access to the state snapshot Arc (for internal use)
2359    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
2360        Arc::clone(&self.state_snapshot)
2361    }
2362}
2363
2364impl Clone for PluginApi {
2365    fn clone(&self) -> Self {
2366        Self {
2367            hooks: Arc::clone(&self.hooks),
2368            commands: Arc::clone(&self.commands),
2369            command_sender: self.command_sender.clone(),
2370            state_snapshot: Arc::clone(&self.state_snapshot),
2371        }
2372    }
2373}
2374
2375#[cfg(test)]
2376mod tests {
2377    use super::*;
2378
2379    #[test]
2380    fn test_plugin_api_creation() {
2381        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2382        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2383        let (tx, _rx) = std::sync::mpsc::channel();
2384        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2385
2386        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2387
2388        // Should not panic
2389        let _clone = api.clone();
2390    }
2391
2392    #[test]
2393    fn test_register_hook() {
2394        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2395        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2396        let (tx, _rx) = std::sync::mpsc::channel();
2397        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2398
2399        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
2400
2401        api.register_hook("test-hook", Box::new(|_| true));
2402
2403        let hook_registry = hooks.read().unwrap();
2404        assert_eq!(hook_registry.hook_count("test-hook"), 1);
2405    }
2406
2407    #[test]
2408    fn test_send_command() {
2409        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2410        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2411        let (tx, rx) = std::sync::mpsc::channel();
2412        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2413
2414        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2415
2416        let result = api.insert_text(BufferId(1), 0, "test".to_string());
2417        assert!(result.is_ok());
2418
2419        // Verify command was sent
2420        let received = rx.try_recv();
2421        assert!(received.is_ok());
2422
2423        match received.unwrap() {
2424            PluginCommand::InsertText {
2425                buffer_id,
2426                position,
2427                text,
2428            } => {
2429                assert_eq!(buffer_id.0, 1);
2430                assert_eq!(position, 0);
2431                assert_eq!(text, "test");
2432            }
2433            _ => panic!("Wrong command type"),
2434        }
2435    }
2436
2437    #[test]
2438    fn test_add_overlay_command() {
2439        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2440        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2441        let (tx, rx) = std::sync::mpsc::channel();
2442        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2443
2444        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2445
2446        let result = api.add_overlay(
2447            BufferId(1),
2448            Some("test-overlay".to_string()),
2449            0..10,
2450            OverlayOptions {
2451                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
2452                bg: None,
2453                underline: true,
2454                bold: false,
2455                italic: false,
2456                extend_to_line_end: false,
2457            },
2458        );
2459        assert!(result.is_ok());
2460
2461        let received = rx.try_recv().unwrap();
2462        match received {
2463            PluginCommand::AddOverlay {
2464                buffer_id,
2465                namespace,
2466                range,
2467                options,
2468            } => {
2469                assert_eq!(buffer_id.0, 1);
2470                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
2471                assert_eq!(range, 0..10);
2472                assert!(matches!(
2473                    options.fg,
2474                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
2475                ));
2476                assert!(options.bg.is_none());
2477                assert!(options.underline);
2478                assert!(!options.bold);
2479                assert!(!options.italic);
2480                assert!(!options.extend_to_line_end);
2481            }
2482            _ => panic!("Wrong command type"),
2483        }
2484    }
2485
2486    #[test]
2487    fn test_set_status_command() {
2488        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2489        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2490        let (tx, rx) = std::sync::mpsc::channel();
2491        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2492
2493        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2494
2495        let result = api.set_status("Test status".to_string());
2496        assert!(result.is_ok());
2497
2498        let received = rx.try_recv().unwrap();
2499        match received {
2500            PluginCommand::SetStatus { message } => {
2501                assert_eq!(message, "Test status");
2502            }
2503            _ => panic!("Wrong command type"),
2504        }
2505    }
2506
2507    #[test]
2508    fn test_get_active_buffer_id() {
2509        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2510        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2511        let (tx, _rx) = std::sync::mpsc::channel();
2512        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2513
2514        // Set active buffer to 5
2515        {
2516            let mut snapshot = state_snapshot.write().unwrap();
2517            snapshot.active_buffer_id = BufferId(5);
2518        }
2519
2520        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2521
2522        let active_id = api.get_active_buffer_id();
2523        assert_eq!(active_id.0, 5);
2524    }
2525
2526    #[test]
2527    fn test_get_buffer_info() {
2528        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2529        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2530        let (tx, _rx) = std::sync::mpsc::channel();
2531        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2532
2533        // Add buffer info
2534        {
2535            let mut snapshot = state_snapshot.write().unwrap();
2536            let buffer_info = BufferInfo {
2537                id: BufferId(1),
2538                path: Some(std::path::PathBuf::from("/test/file.txt")),
2539                modified: true,
2540                length: 100,
2541            };
2542            snapshot.buffers.insert(BufferId(1), buffer_info);
2543        }
2544
2545        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2546
2547        let info = api.get_buffer_info(BufferId(1));
2548        assert!(info.is_some());
2549        let info = info.unwrap();
2550        assert_eq!(info.id.0, 1);
2551        assert_eq!(
2552            info.path.as_ref().unwrap().to_str().unwrap(),
2553            "/test/file.txt"
2554        );
2555        assert!(info.modified);
2556        assert_eq!(info.length, 100);
2557
2558        // Non-existent buffer
2559        let no_info = api.get_buffer_info(BufferId(999));
2560        assert!(no_info.is_none());
2561    }
2562
2563    #[test]
2564    fn test_list_buffers() {
2565        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2566        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2567        let (tx, _rx) = std::sync::mpsc::channel();
2568        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2569
2570        // Add multiple buffers
2571        {
2572            let mut snapshot = state_snapshot.write().unwrap();
2573            snapshot.buffers.insert(
2574                BufferId(1),
2575                BufferInfo {
2576                    id: BufferId(1),
2577                    path: Some(std::path::PathBuf::from("/file1.txt")),
2578                    modified: false,
2579                    length: 50,
2580                },
2581            );
2582            snapshot.buffers.insert(
2583                BufferId(2),
2584                BufferInfo {
2585                    id: BufferId(2),
2586                    path: Some(std::path::PathBuf::from("/file2.txt")),
2587                    modified: true,
2588                    length: 100,
2589                },
2590            );
2591            snapshot.buffers.insert(
2592                BufferId(3),
2593                BufferInfo {
2594                    id: BufferId(3),
2595                    path: None,
2596                    modified: false,
2597                    length: 0,
2598                },
2599            );
2600        }
2601
2602        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2603
2604        let buffers = api.list_buffers();
2605        assert_eq!(buffers.len(), 3);
2606
2607        // Verify all buffers are present
2608        assert!(buffers.iter().any(|b| b.id.0 == 1));
2609        assert!(buffers.iter().any(|b| b.id.0 == 2));
2610        assert!(buffers.iter().any(|b| b.id.0 == 3));
2611    }
2612
2613    #[test]
2614    fn test_get_primary_cursor() {
2615        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2616        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2617        let (tx, _rx) = std::sync::mpsc::channel();
2618        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2619
2620        // Add cursor info
2621        {
2622            let mut snapshot = state_snapshot.write().unwrap();
2623            snapshot.primary_cursor = Some(CursorInfo {
2624                position: 42,
2625                selection: Some(10..42),
2626            });
2627        }
2628
2629        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2630
2631        let cursor = api.get_primary_cursor();
2632        assert!(cursor.is_some());
2633        let cursor = cursor.unwrap();
2634        assert_eq!(cursor.position, 42);
2635        assert_eq!(cursor.selection, Some(10..42));
2636    }
2637
2638    #[test]
2639    fn test_get_all_cursors() {
2640        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2641        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2642        let (tx, _rx) = std::sync::mpsc::channel();
2643        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2644
2645        // Add multiple cursors
2646        {
2647            let mut snapshot = state_snapshot.write().unwrap();
2648            snapshot.all_cursors = vec![
2649                CursorInfo {
2650                    position: 10,
2651                    selection: None,
2652                },
2653                CursorInfo {
2654                    position: 20,
2655                    selection: Some(15..20),
2656                },
2657                CursorInfo {
2658                    position: 30,
2659                    selection: Some(25..30),
2660                },
2661            ];
2662        }
2663
2664        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2665
2666        let cursors = api.get_all_cursors();
2667        assert_eq!(cursors.len(), 3);
2668        assert_eq!(cursors[0].position, 10);
2669        assert_eq!(cursors[0].selection, None);
2670        assert_eq!(cursors[1].position, 20);
2671        assert_eq!(cursors[1].selection, Some(15..20));
2672        assert_eq!(cursors[2].position, 30);
2673        assert_eq!(cursors[2].selection, Some(25..30));
2674    }
2675
2676    #[test]
2677    fn test_get_viewport() {
2678        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2679        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2680        let (tx, _rx) = std::sync::mpsc::channel();
2681        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2682
2683        // Add viewport info
2684        {
2685            let mut snapshot = state_snapshot.write().unwrap();
2686            snapshot.viewport = Some(ViewportInfo {
2687                top_byte: 100,
2688                left_column: 5,
2689                width: 80,
2690                height: 24,
2691            });
2692        }
2693
2694        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2695
2696        let viewport = api.get_viewport();
2697        assert!(viewport.is_some());
2698        let viewport = viewport.unwrap();
2699        assert_eq!(viewport.top_byte, 100);
2700        assert_eq!(viewport.left_column, 5);
2701        assert_eq!(viewport.width, 80);
2702        assert_eq!(viewport.height, 24);
2703    }
2704
2705    #[test]
2706    fn test_composite_buffer_options_rejects_unknown_fields() {
2707        // Valid JSON with correct field names
2708        let valid_json = r#"{
2709            "name": "test",
2710            "mode": "diff",
2711            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2712            "sources": [{"bufferId": 1, "label": "old"}]
2713        }"#;
2714        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
2715        assert!(
2716            result.is_ok(),
2717            "Valid JSON should parse: {:?}",
2718            result.err()
2719        );
2720
2721        // Invalid JSON with unknown field (buffer_id instead of bufferId)
2722        let invalid_json = r#"{
2723            "name": "test",
2724            "mode": "diff",
2725            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2726            "sources": [{"buffer_id": 1, "label": "old"}]
2727        }"#;
2728        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
2729        assert!(
2730            result.is_err(),
2731            "JSON with unknown field should fail to parse"
2732        );
2733        let err = result.unwrap_err().to_string();
2734        assert!(
2735            err.contains("unknown field") || err.contains("buffer_id"),
2736            "Error should mention unknown field: {}",
2737            err
2738        );
2739    }
2740
2741    #[test]
2742    fn test_composite_hunk_rejects_unknown_fields() {
2743        // Valid JSON with correct field names
2744        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2745        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
2746        assert!(
2747            result.is_ok(),
2748            "Valid JSON should parse: {:?}",
2749            result.err()
2750        );
2751
2752        // Invalid JSON with unknown field (old_start instead of oldStart)
2753        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2754        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
2755        assert!(
2756            result.is_err(),
2757            "JSON with unknown field should fail to parse"
2758        );
2759        let err = result.unwrap_err().to_string();
2760        assert!(
2761            err.contains("unknown field") || err.contains("old_start"),
2762            "Error should mention unknown field: {}",
2763            err
2764        );
2765    }
2766
2767    #[test]
2768    fn test_plugin_response_line_end_position() {
2769        let response = PluginResponse::LineEndPosition {
2770            request_id: 42,
2771            position: Some(100),
2772        };
2773        let json = serde_json::to_string(&response).unwrap();
2774        assert!(json.contains("LineEndPosition"));
2775        assert!(json.contains("42"));
2776        assert!(json.contains("100"));
2777
2778        // Test None case
2779        let response_none = PluginResponse::LineEndPosition {
2780            request_id: 1,
2781            position: None,
2782        };
2783        let json_none = serde_json::to_string(&response_none).unwrap();
2784        assert!(json_none.contains("null"));
2785    }
2786
2787    #[test]
2788    fn test_plugin_response_buffer_line_count() {
2789        let response = PluginResponse::BufferLineCount {
2790            request_id: 99,
2791            count: Some(500),
2792        };
2793        let json = serde_json::to_string(&response).unwrap();
2794        assert!(json.contains("BufferLineCount"));
2795        assert!(json.contains("99"));
2796        assert!(json.contains("500"));
2797    }
2798
2799    #[test]
2800    fn test_plugin_command_get_line_end_position() {
2801        let command = PluginCommand::GetLineEndPosition {
2802            buffer_id: BufferId(1),
2803            line: 10,
2804            request_id: 123,
2805        };
2806        let json = serde_json::to_string(&command).unwrap();
2807        assert!(json.contains("GetLineEndPosition"));
2808        assert!(json.contains("10"));
2809    }
2810
2811    #[test]
2812    fn test_plugin_command_get_buffer_line_count() {
2813        let command = PluginCommand::GetBufferLineCount {
2814            buffer_id: BufferId(0),
2815            request_id: 456,
2816        };
2817        let json = serde_json::to_string(&command).unwrap();
2818        assert!(json.contains("GetBufferLineCount"));
2819        assert!(json.contains("456"));
2820    }
2821
2822    #[test]
2823    fn test_plugin_command_scroll_to_line_center() {
2824        let command = PluginCommand::ScrollToLineCenter {
2825            split_id: SplitId(1),
2826            buffer_id: BufferId(2),
2827            line: 50,
2828        };
2829        let json = serde_json::to_string(&command).unwrap();
2830        assert!(json.contains("ScrollToLineCenter"));
2831        assert!(json.contains("50"));
2832    }
2833}