Skip to main content

fresh_core/
api.rs

1//! Plugin API: Safe interface for plugins to interact with the editor
2//!
3//! This module provides a safe, controlled API for plugins (Lua, WASM, etc.)
4//! to interact with the editor without direct access to internal state.
5//!
6//! # Type Safety Architecture
7//!
8//! Rust structs in this module serve as the **single source of truth** for the
9//! TypeScript plugin API. The type safety system works as follows:
10//!
11//! ```text
12//! Rust struct                  Generated TypeScript
13//! ───────────                  ────────────────────
14//! #[derive(TS, Deserialize)]   type ActionPopupOptions = {
15//! #[serde(deny_unknown_fields)]    id: string;
16//! struct ActionPopupOptions {      title: string;
17//!     id: String,                  message: string;
18//!     title: String,               actions: TsActionPopupAction[];
19//!     ...                      };
20//! }
21//! ```
22//!
23//! ## Key Patterns
24//!
25//! 1. **`#[derive(TS)]`** - Generates TypeScript type definitions via ts-rs
26//! 2. **`#[serde(deny_unknown_fields)]`** - Rejects typos/unknown fields at runtime
27//! 3. **`impl FromJs`** - Bridges rquickjs values to typed Rust structs
28//!
29//! ## Validation Layers
30//!
31//! | Layer                  | What it catches                          |
32//! |------------------------|------------------------------------------|
33//! | TypeScript compile     | Wrong field names, missing required fields |
34//! | Rust runtime (serde)   | Typos like `popup_id` instead of `id`    |
35//! | Rust compile           | Type mismatches in method signatures     |
36//!
37//! ## Limitations & Tradeoffs
38//!
39//! - **Manual parsing for complex types**: Some methods (e.g., `submitViewTransform`)
40//!   still use manual object parsing due to enum serialization complexity
41//! - **Two-step deserialization**: Complex nested structs may need
42//!   `rquickjs::Value → serde_json::Value → typed struct` due to rquickjs_serde limits
43//! - **Duplicate attributes**: Both `#[serde(...)]` and `#[ts(...)]` needed since
44//!   they control different things (runtime serialization vs compile-time codegen)
45
46use crate::command::{Command, Suggestion};
47use crate::file_explorer::FileExplorerDecoration;
48use crate::hooks::{HookCallback, HookRegistry};
49use crate::menu::{Menu, MenuItem};
50use crate::overlay::{OverlayHandle, OverlayNamespace};
51use crate::text_property::{TextProperty, TextPropertyEntry};
52use crate::BufferId;
53use crate::SplitId;
54use crate::TerminalId;
55use lsp_types;
56use serde::{Deserialize, Serialize};
57use serde_json::Value as JsonValue;
58use std::collections::HashMap;
59use std::ops::Range;
60use std::path::PathBuf;
61use std::sync::{Arc, RwLock};
62use ts_rs::TS;
63
64/// Minimal command registry for PluginApi.
65/// This is a stub that provides basic command storage for plugin use.
66/// The editor's full CommandRegistry lives in fresh-editor.
67pub struct CommandRegistry {
68    commands: std::sync::RwLock<Vec<Command>>,
69}
70
71impl CommandRegistry {
72    /// Create a new empty command registry
73    pub fn new() -> Self {
74        Self {
75            commands: std::sync::RwLock::new(Vec::new()),
76        }
77    }
78
79    /// Register a command
80    pub fn register(&self, command: Command) {
81        let mut commands = self.commands.write().unwrap();
82        commands.retain(|c| c.name != command.name);
83        commands.push(command);
84    }
85
86    /// Unregister a command by name  
87    pub fn unregister(&self, name: &str) {
88        let mut commands = self.commands.write().unwrap();
89        commands.retain(|c| c.name != name);
90    }
91}
92
93impl Default for CommandRegistry {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99/// A callback ID for JavaScript promises in the plugin runtime.
100///
101/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
102/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
103/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
105#[ts(export)]
106pub struct JsCallbackId(pub u64);
107
108impl JsCallbackId {
109    /// Create a new JS callback ID
110    pub fn new(id: u64) -> Self {
111        Self(id)
112    }
113
114    /// Get the underlying u64 value
115    pub fn as_u64(self) -> u64 {
116        self.0
117    }
118}
119
120impl From<u64> for JsCallbackId {
121    fn from(id: u64) -> Self {
122        Self(id)
123    }
124}
125
126impl From<JsCallbackId> for u64 {
127    fn from(id: JsCallbackId) -> u64 {
128        id.0
129    }
130}
131
132impl std::fmt::Display for JsCallbackId {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138/// Result of creating a terminal
139#[derive(Debug, Clone, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, rename_all = "camelCase")]
142pub struct TerminalResult {
143    /// The created buffer ID (for use with setSplitBuffer, etc.)
144    #[ts(type = "number")]
145    pub buffer_id: u64,
146    /// The terminal ID (for use with sendTerminalInput, closeTerminal)
147    #[ts(type = "number")]
148    pub terminal_id: u64,
149    /// The split ID (if created in a new split)
150    #[ts(type = "number | null")]
151    pub split_id: Option<u64>,
152}
153
154/// Result of creating a virtual buffer
155#[derive(Debug, Clone, Serialize, Deserialize, TS)]
156#[serde(rename_all = "camelCase")]
157#[ts(export, rename_all = "camelCase")]
158pub struct VirtualBufferResult {
159    /// The created buffer ID
160    #[ts(type = "number")]
161    pub buffer_id: u64,
162    /// The split ID (if created in a new split)
163    #[ts(type = "number | null")]
164    pub split_id: Option<u64>,
165}
166
167/// Response from the editor for async plugin operations
168#[derive(Debug, Clone, Serialize, Deserialize, TS)]
169#[ts(export)]
170pub enum PluginResponse {
171    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
172    VirtualBufferCreated {
173        request_id: u64,
174        buffer_id: BufferId,
175        split_id: Option<SplitId>,
176    },
177    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
178    TerminalCreated {
179        request_id: u64,
180        buffer_id: BufferId,
181        terminal_id: TerminalId,
182        split_id: Option<SplitId>,
183    },
184    /// Response to a plugin-initiated LSP request
185    LspRequest {
186        request_id: u64,
187        #[ts(type = "any")]
188        result: Result<JsonValue, String>,
189    },
190    /// Response to RequestHighlights
191    HighlightsComputed {
192        request_id: u64,
193        spans: Vec<TsHighlightSpan>,
194    },
195    /// Response to GetBufferText with the text content
196    BufferText {
197        request_id: u64,
198        text: Result<String, String>,
199    },
200    /// Response to GetLineStartPosition with the byte offset
201    LineStartPosition {
202        request_id: u64,
203        /// None if line is out of range, Some(offset) for valid line
204        position: Option<usize>,
205    },
206    /// Response to GetLineEndPosition with the byte offset
207    LineEndPosition {
208        request_id: u64,
209        /// None if line is out of range, Some(offset) for valid line
210        position: Option<usize>,
211    },
212    /// Response to GetBufferLineCount with the total number of lines
213    BufferLineCount {
214        request_id: u64,
215        /// None if buffer not found, Some(count) for valid buffer
216        count: Option<usize>,
217    },
218    /// Response to CreateCompositeBuffer with the created buffer ID
219    CompositeBufferCreated {
220        request_id: u64,
221        buffer_id: BufferId,
222    },
223    /// Response to GetSplitByLabel with the found split ID (if any)
224    SplitByLabel {
225        request_id: u64,
226        split_id: Option<SplitId>,
227    },
228}
229
230/// Messages sent from async plugin tasks to the synchronous main loop
231#[derive(Debug, Clone, Serialize, Deserialize, TS)]
232#[ts(export)]
233pub enum PluginAsyncMessage {
234    /// Plugin process completed with output
235    ProcessOutput {
236        /// Unique ID for this process
237        process_id: u64,
238        /// Standard output
239        stdout: String,
240        /// Standard error
241        stderr: String,
242        /// Exit code
243        exit_code: i32,
244    },
245    /// Plugin delay/timer completed
246    DelayComplete {
247        /// Callback ID to resolve
248        callback_id: u64,
249    },
250    /// Background process stdout data
251    ProcessStdout { process_id: u64, data: String },
252    /// Background process stderr data
253    ProcessStderr { process_id: u64, data: String },
254    /// Background process exited
255    ProcessExit {
256        process_id: u64,
257        callback_id: u64,
258        exit_code: i32,
259    },
260    /// Response for a plugin-initiated LSP request
261    LspResponse {
262        language: String,
263        request_id: u64,
264        #[ts(type = "any")]
265        result: Result<JsonValue, String>,
266    },
267    /// Generic plugin response (e.g., GetBufferText result)
268    PluginResponse(crate::api::PluginResponse),
269
270    /// Streaming grep: partial results for one file
271    GrepStreamingProgress {
272        /// Search ID to route to the correct progress callback
273        search_id: u64,
274        /// Matches from a single file
275        matches_json: String,
276    },
277
278    /// Streaming grep: search complete
279    GrepStreamingComplete {
280        /// Search ID
281        search_id: u64,
282        /// Callback ID for the completion promise
283        callback_id: u64,
284        /// Total number of matches found
285        total_matches: usize,
286        /// Whether the search was stopped early due to reaching max_results
287        truncated: bool,
288    },
289}
290
291/// Information about a cursor in the editor
292#[derive(Debug, Clone, Serialize, Deserialize, TS)]
293#[ts(export)]
294pub struct CursorInfo {
295    /// Byte position of the cursor
296    pub position: usize,
297    /// Selection range (if any)
298    #[cfg_attr(
299        feature = "plugins",
300        ts(type = "{ start: number; end: number } | null")
301    )]
302    pub selection: Option<Range<usize>>,
303}
304
305/// Specification for an action to execute, with optional repeat count
306#[derive(Debug, Clone, Serialize, Deserialize, TS)]
307#[serde(deny_unknown_fields)]
308#[ts(export)]
309pub struct ActionSpec {
310    /// Action name (e.g., "move_word_right", "delete_line")
311    pub action: String,
312    /// Number of times to repeat the action (default 1)
313    #[serde(default = "default_action_count")]
314    pub count: u32,
315}
316
317fn default_action_count() -> u32 {
318    1
319}
320
321/// Information about a buffer
322#[derive(Debug, Clone, Serialize, Deserialize, TS)]
323#[ts(export)]
324pub struct BufferInfo {
325    /// Buffer ID
326    #[ts(type = "number")]
327    pub id: BufferId,
328    /// File path (if any)
329    #[serde(serialize_with = "serialize_path")]
330    #[ts(type = "string")]
331    pub path: Option<PathBuf>,
332    /// Whether the buffer has been modified
333    pub modified: bool,
334    /// Length of buffer in bytes
335    pub length: usize,
336    /// Whether this is a virtual buffer (not backed by a file)
337    pub is_virtual: bool,
338    /// Current view mode of the active split: "source" or "compose"
339    pub view_mode: String,
340    /// True if any split showing this buffer has compose mode enabled.
341    /// Plugins should use this (not `view_mode`) to decide whether to maintain
342    /// decorations, since decorations live on the buffer and are filtered
343    /// per-split at render time.
344    pub is_composing_in_any_split: bool,
345    /// Compose width (if set), from the active split's view state
346    pub compose_width: Option<u16>,
347    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
348    pub language: String,
349}
350
351fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
352    s.serialize_str(
353        &path
354            .as_ref()
355            .map(|p| p.to_string_lossy().to_string())
356            .unwrap_or_default(),
357    )
358}
359
360/// Serialize ranges as [start, end] tuples for JS compatibility
361fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
362where
363    S: serde::Serializer,
364{
365    use serde::ser::SerializeSeq;
366    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
367    for range in ranges {
368        seq.serialize_element(&(range.start, range.end))?;
369    }
370    seq.end()
371}
372
373/// Diff between current buffer content and last saved snapshot
374#[derive(Debug, Clone, Serialize, Deserialize, TS)]
375#[ts(export)]
376pub struct BufferSavedDiff {
377    pub equal: bool,
378    #[serde(serialize_with = "serialize_ranges_as_tuples")]
379    #[ts(type = "Array<[number, number]>")]
380    pub byte_ranges: Vec<Range<usize>>,
381}
382
383/// Information about the viewport
384#[derive(Debug, Clone, Serialize, Deserialize, TS)]
385#[serde(rename_all = "camelCase")]
386#[ts(export, rename_all = "camelCase")]
387pub struct ViewportInfo {
388    /// Byte position of the first visible line
389    pub top_byte: usize,
390    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
391    pub top_line: Option<usize>,
392    /// Left column offset (horizontal scroll)
393    pub left_column: usize,
394    /// Viewport width
395    pub width: u16,
396    /// Viewport height
397    pub height: u16,
398}
399
400/// Layout hints supplied by plugins (e.g., Compose mode)
401#[derive(Debug, Clone, Serialize, Deserialize, TS)]
402#[serde(rename_all = "camelCase")]
403#[ts(export, rename_all = "camelCase")]
404pub struct LayoutHints {
405    /// Optional compose width for centering/wrapping
406    #[ts(optional)]
407    pub compose_width: Option<u16>,
408    /// Optional column guides for aligned tables
409    #[ts(optional)]
410    pub column_guides: Option<Vec<u16>>,
411}
412
413// ============================================================================
414// Overlay Types with Theme Support
415// ============================================================================
416
417/// Color specification that can be either RGB values or a theme key.
418///
419/// Theme keys reference colors from the current theme, e.g.:
420/// - "ui.status_bar_bg" - UI status bar background
421/// - "editor.selection_bg" - Editor selection background
422/// - "syntax.keyword" - Syntax highlighting for keywords
423/// - "diagnostic.error" - Error diagnostic color
424///
425/// When a theme key is used, the color is resolved at render time,
426/// so overlays automatically update when the theme changes.
427#[derive(Debug, Clone, Serialize, Deserialize, TS)]
428#[serde(untagged)]
429#[ts(export)]
430pub enum OverlayColorSpec {
431    /// RGB color as [r, g, b] array
432    #[ts(type = "[number, number, number]")]
433    Rgb(u8, u8, u8),
434    /// Theme key reference (e.g., "ui.status_bar_bg")
435    ThemeKey(String),
436}
437
438impl OverlayColorSpec {
439    /// Create an RGB color spec
440    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
441        Self::Rgb(r, g, b)
442    }
443
444    /// Create a theme key color spec
445    pub fn theme_key(key: impl Into<String>) -> Self {
446        Self::ThemeKey(key.into())
447    }
448
449    /// Convert to RGB if this is an RGB spec, None if it's a theme key
450    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
451        match self {
452            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
453            Self::ThemeKey(_) => None,
454        }
455    }
456
457    /// Get the theme key if this is a theme key spec
458    pub fn as_theme_key(&self) -> Option<&str> {
459        match self {
460            Self::ThemeKey(key) => Some(key),
461            Self::Rgb(_, _, _) => None,
462        }
463    }
464}
465
466/// Options for adding an overlay with theme support.
467///
468/// This struct provides a type-safe way to specify overlay styling
469/// with optional theme key references for colors.
470#[derive(Debug, Clone, Serialize, Deserialize, TS)]
471#[serde(deny_unknown_fields, rename_all = "camelCase")]
472#[ts(export, rename_all = "camelCase")]
473#[derive(Default)]
474pub struct OverlayOptions {
475    /// Foreground color - RGB array or theme key string
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub fg: Option<OverlayColorSpec>,
478
479    /// Background color - RGB array or theme key string
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub bg: Option<OverlayColorSpec>,
482
483    /// Whether to render with underline
484    #[serde(default)]
485    pub underline: bool,
486
487    /// Whether to render in bold
488    #[serde(default)]
489    pub bold: bool,
490
491    /// Whether to render in italic
492    #[serde(default)]
493    pub italic: bool,
494
495    /// Whether to render with strikethrough
496    #[serde(default)]
497    pub strikethrough: bool,
498
499    /// Whether to extend background color to end of line
500    #[serde(default)]
501    pub extend_to_line_end: bool,
502
503    /// Optional URL for OSC 8 terminal hyperlinks.
504    /// When set, the overlay text becomes a clickable hyperlink in terminals
505    /// that support OSC 8 escape sequences.
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub url: Option<String>,
508}
509
510// ============================================================================
511// Composite Buffer Configuration (for multi-buffer single-tab views)
512// ============================================================================
513
514/// Layout configuration for composite buffers
515#[derive(Debug, Clone, Serialize, Deserialize, TS)]
516#[serde(deny_unknown_fields)]
517#[ts(export, rename = "TsCompositeLayoutConfig")]
518pub struct CompositeLayoutConfig {
519    /// Layout type: "side-by-side", "stacked", or "unified"
520    #[serde(rename = "type")]
521    #[ts(rename = "type")]
522    pub layout_type: String,
523    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
524    #[serde(default)]
525    #[ts(optional)]
526    pub ratios: Option<Vec<f32>>,
527    /// Show separator between panes
528    #[serde(default = "default_true", rename = "showSeparator")]
529    #[ts(rename = "showSeparator")]
530    pub show_separator: bool,
531    /// Spacing for stacked layout
532    #[serde(default)]
533    #[ts(optional)]
534    pub spacing: Option<u16>,
535}
536
537fn default_true() -> bool {
538    true
539}
540
541/// Source pane configuration for composite buffers
542#[derive(Debug, Clone, Serialize, Deserialize, TS)]
543#[serde(deny_unknown_fields)]
544#[ts(export, rename = "TsCompositeSourceConfig")]
545pub struct CompositeSourceConfig {
546    /// Buffer ID of the source buffer (required)
547    #[serde(rename = "bufferId")]
548    #[ts(rename = "bufferId")]
549    pub buffer_id: usize,
550    /// Label for this pane (e.g., "OLD", "NEW")
551    pub label: String,
552    /// Whether this pane is editable
553    #[serde(default)]
554    pub editable: bool,
555    /// Style configuration
556    #[serde(default)]
557    pub style: Option<CompositePaneStyle>,
558}
559
560/// Style configuration for a composite pane
561#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
562#[serde(deny_unknown_fields)]
563#[ts(export, rename = "TsCompositePaneStyle")]
564pub struct CompositePaneStyle {
565    /// Background color for added lines (RGB)
566    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
567    #[serde(default, rename = "addBg")]
568    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
569    pub add_bg: Option<[u8; 3]>,
570    /// Background color for removed lines (RGB)
571    #[serde(default, rename = "removeBg")]
572    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
573    pub remove_bg: Option<[u8; 3]>,
574    /// Background color for modified lines (RGB)
575    #[serde(default, rename = "modifyBg")]
576    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
577    pub modify_bg: Option<[u8; 3]>,
578    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
579    #[serde(default, rename = "gutterStyle")]
580    #[ts(optional, rename = "gutterStyle")]
581    pub gutter_style: Option<String>,
582}
583
584/// Diff hunk for composite buffer alignment
585#[derive(Debug, Clone, Serialize, Deserialize, TS)]
586#[serde(deny_unknown_fields)]
587#[ts(export, rename = "TsCompositeHunk")]
588pub struct CompositeHunk {
589    /// Starting line in old buffer (0-indexed)
590    #[serde(rename = "oldStart")]
591    #[ts(rename = "oldStart")]
592    pub old_start: usize,
593    /// Number of lines in old buffer
594    #[serde(rename = "oldCount")]
595    #[ts(rename = "oldCount")]
596    pub old_count: usize,
597    /// Starting line in new buffer (0-indexed)
598    #[serde(rename = "newStart")]
599    #[ts(rename = "newStart")]
600    pub new_start: usize,
601    /// Number of lines in new buffer
602    #[serde(rename = "newCount")]
603    #[ts(rename = "newCount")]
604    pub new_count: usize,
605}
606
607/// Options for creating a composite buffer (used by plugin API)
608#[derive(Debug, Clone, Serialize, Deserialize, TS)]
609#[serde(deny_unknown_fields)]
610#[ts(export, rename = "TsCreateCompositeBufferOptions")]
611pub struct CreateCompositeBufferOptions {
612    /// Buffer name (displayed in tabs/title)
613    #[serde(default)]
614    pub name: String,
615    /// Mode for keybindings
616    #[serde(default)]
617    pub mode: String,
618    /// Layout configuration
619    pub layout: CompositeLayoutConfig,
620    /// Source pane configurations
621    pub sources: Vec<CompositeSourceConfig>,
622    /// Diff hunks for alignment (optional)
623    #[serde(default)]
624    pub hunks: Option<Vec<CompositeHunk>>,
625    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
626    /// This avoids timing issues with imperative scroll commands that depend on
627    /// render-created state (viewport dimensions, view state).
628    #[serde(default, rename = "initialFocusHunk")]
629    #[ts(optional, rename = "initialFocusHunk")]
630    pub initial_focus_hunk: Option<usize>,
631}
632
633/// Wire-format view token kind (serialized for plugin transforms)
634#[derive(Debug, Clone, Serialize, Deserialize, TS)]
635#[ts(export)]
636pub enum ViewTokenWireKind {
637    Text(String),
638    Newline,
639    Space,
640    /// Visual line break inserted by wrapping (not from source)
641    /// Always has source_offset: None
642    Break,
643    /// A single binary byte that should be rendered as <XX>
644    /// Used in binary file mode to ensure cursor positioning works correctly
645    /// (all 4 display chars of <XX> map to the same source byte)
646    BinaryByte(u8),
647}
648
649/// Styling for view tokens (used for injected annotations)
650///
651/// This allows plugins to specify styling for tokens that don't have a source
652/// mapping (sourceOffset: None), such as annotation headers in git blame.
653/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
654#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
655#[serde(deny_unknown_fields)]
656#[ts(export)]
657pub struct ViewTokenStyle {
658    /// Foreground color as RGB tuple
659    #[serde(default)]
660    #[ts(type = "[number, number, number] | null")]
661    pub fg: Option<(u8, u8, u8)>,
662    /// Background color as RGB tuple
663    #[serde(default)]
664    #[ts(type = "[number, number, number] | null")]
665    pub bg: Option<(u8, u8, u8)>,
666    /// Whether to render in bold
667    #[serde(default)]
668    pub bold: bool,
669    /// Whether to render in italic
670    #[serde(default)]
671    pub italic: bool,
672}
673
674/// Wire-format view token with optional source mapping and styling
675#[derive(Debug, Clone, Serialize, Deserialize, TS)]
676#[serde(deny_unknown_fields)]
677#[ts(export)]
678pub struct ViewTokenWire {
679    /// Source byte offset in the buffer. None for injected content (annotations).
680    #[ts(type = "number | null")]
681    pub source_offset: Option<usize>,
682    /// The token content
683    pub kind: ViewTokenWireKind,
684    /// Optional styling for injected content (only used when source_offset is None)
685    #[serde(default)]
686    #[ts(optional)]
687    pub style: Option<ViewTokenStyle>,
688}
689
690/// Transformed view stream payload (plugin-provided)
691#[derive(Debug, Clone, Serialize, Deserialize, TS)]
692#[ts(export)]
693pub struct ViewTransformPayload {
694    /// Byte range this transform applies to (viewport)
695    pub range: Range<usize>,
696    /// Tokens in wire format
697    pub tokens: Vec<ViewTokenWire>,
698    /// Layout hints
699    pub layout_hints: Option<LayoutHints>,
700}
701
702/// Snapshot of editor state for plugin queries
703/// This is updated by the editor on each loop iteration
704#[derive(Debug, Clone, Serialize, Deserialize, TS)]
705#[ts(export)]
706pub struct EditorStateSnapshot {
707    /// Currently active buffer ID
708    pub active_buffer_id: BufferId,
709    /// Currently active split ID
710    pub active_split_id: usize,
711    /// Information about all open buffers
712    pub buffers: HashMap<BufferId, BufferInfo>,
713    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
714    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
715    /// Primary cursor position for the active buffer
716    pub primary_cursor: Option<CursorInfo>,
717    /// All cursor positions for the active buffer
718    pub all_cursors: Vec<CursorInfo>,
719    /// Viewport information for the active buffer
720    pub viewport: Option<ViewportInfo>,
721    /// Cursor positions per buffer (for buffers other than active)
722    pub buffer_cursor_positions: HashMap<BufferId, usize>,
723    /// Text properties per buffer (for virtual buffers with properties)
724    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
725    /// Selected text from the primary cursor (if any selection exists)
726    /// This is populated on each update to avoid needing full buffer access
727    pub selected_text: Option<String>,
728    /// Internal clipboard content (for plugins that need clipboard access)
729    pub clipboard: String,
730    /// Editor's working directory (for file operations and spawning processes)
731    pub working_dir: PathBuf,
732    /// LSP diagnostics per file URI
733    /// Maps file URI string to Vec of diagnostics for that file
734    #[ts(type = "any")]
735    pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
736    /// LSP folding ranges per file URI
737    /// Maps file URI string to Vec of folding ranges for that file
738    #[ts(type = "any")]
739    pub folding_ranges: HashMap<String, Vec<lsp_types::FoldingRange>>,
740    /// Runtime config as serde_json::Value (merged user config + defaults)
741    /// This is the runtime config, not just the user's config file
742    #[ts(type = "any")]
743    pub config: serde_json::Value,
744    /// User config as serde_json::Value (only what's in the user's config file)
745    /// Fields not present here are using default values
746    #[ts(type = "any")]
747    pub user_config: serde_json::Value,
748    /// Available grammars with provenance info, updated when grammar registry changes
749    #[ts(type = "GrammarInfo[]")]
750    pub available_grammars: Vec<GrammarInfoSnapshot>,
751    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
752    /// When set, this mode's keybindings take precedence over normal key handling
753    pub editor_mode: Option<String>,
754
755    /// Plugin-managed per-buffer view state for the active split.
756    /// Updated from BufferViewState.plugin_state during snapshot updates.
757    /// Also written directly by JS plugins via setViewState for immediate read-back.
758    #[ts(type = "any")]
759    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
760
761    /// Tracks which split was active when plugin_view_states was last populated.
762    /// When the active split changes, plugin_view_states is fully repopulated.
763    #[serde(skip)]
764    #[ts(skip)]
765    pub plugin_view_states_split: usize,
766
767    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
768    /// Updated when modes are registered via defineMode().
769    #[serde(skip)]
770    #[ts(skip)]
771    pub keybinding_labels: HashMap<String, String>,
772
773    /// Plugin-managed global state, isolated per plugin.
774    /// Outer key is plugin name, inner key is the state key set by the plugin.
775    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
776    /// Currently we isolate by plugin name, but we may want a more robust approach
777    /// (e.g. preventing plugins from reading each other's state, or providing
778    /// explicit cross-plugin state sharing APIs).
779    #[ts(type = "any")]
780    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
781}
782
783impl EditorStateSnapshot {
784    pub fn new() -> Self {
785        Self {
786            active_buffer_id: BufferId(0),
787            active_split_id: 0,
788            buffers: HashMap::new(),
789            buffer_saved_diffs: HashMap::new(),
790            primary_cursor: None,
791            all_cursors: Vec::new(),
792            viewport: None,
793            buffer_cursor_positions: HashMap::new(),
794            buffer_text_properties: HashMap::new(),
795            selected_text: None,
796            clipboard: String::new(),
797            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
798            diagnostics: HashMap::new(),
799            folding_ranges: HashMap::new(),
800            config: serde_json::Value::Null,
801            user_config: serde_json::Value::Null,
802            available_grammars: Vec::new(),
803            editor_mode: None,
804            plugin_view_states: HashMap::new(),
805            plugin_view_states_split: 0,
806            keybinding_labels: HashMap::new(),
807            plugin_global_states: HashMap::new(),
808        }
809    }
810}
811
812impl Default for EditorStateSnapshot {
813    fn default() -> Self {
814        Self::new()
815    }
816}
817
818/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
819#[derive(Debug, Clone, Serialize, Deserialize, TS)]
820#[ts(export)]
821pub struct GrammarInfoSnapshot {
822    /// The grammar name as used in config files (case-insensitive matching)
823    pub name: String,
824    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
825    pub source: String,
826    /// File extensions associated with this grammar
827    pub file_extensions: Vec<String>,
828    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
829    pub short_name: Option<String>,
830}
831
832/// Position for inserting menu items or menus
833#[derive(Debug, Clone, Serialize, Deserialize, TS)]
834#[ts(export)]
835pub enum MenuPosition {
836    /// Add at the beginning
837    Top,
838    /// Add at the end
839    Bottom,
840    /// Add before a specific label
841    Before(String),
842    /// Add after a specific label
843    After(String),
844}
845
846/// Plugin command - allows plugins to send commands to the editor
847#[derive(Debug, Clone, Serialize, Deserialize, TS)]
848#[ts(export)]
849pub enum PluginCommand {
850    /// Insert text at a position in a buffer
851    InsertText {
852        buffer_id: BufferId,
853        position: usize,
854        text: String,
855    },
856
857    /// Delete a range of text from a buffer
858    DeleteRange {
859        buffer_id: BufferId,
860        range: Range<usize>,
861    },
862
863    /// Add an overlay to a buffer, returns handle via response channel
864    ///
865    /// Colors can be specified as RGB tuples or theme keys. When theme keys
866    /// are provided, they take precedence and are resolved at render time.
867    AddOverlay {
868        buffer_id: BufferId,
869        namespace: Option<OverlayNamespace>,
870        range: Range<usize>,
871        /// Overlay styling options (colors, modifiers, etc.)
872        options: OverlayOptions,
873    },
874
875    /// Remove an overlay by its opaque handle
876    RemoveOverlay {
877        buffer_id: BufferId,
878        handle: OverlayHandle,
879    },
880
881    /// Set status message
882    SetStatus { message: String },
883
884    /// Apply a theme by name
885    ApplyTheme { theme_name: String },
886
887    /// Reload configuration from file
888    /// After a plugin saves config changes, it should call this to reload the config
889    ReloadConfig,
890
891    /// Register a custom command
892    RegisterCommand { command: Command },
893
894    /// Unregister a command by name
895    UnregisterCommand { name: String },
896
897    /// Open a file in the editor (in background, without switching focus)
898    OpenFileInBackground { path: PathBuf },
899
900    /// Insert text at the current cursor position in the active buffer
901    InsertAtCursor { text: String },
902
903    /// Spawn an async process
904    SpawnProcess {
905        command: String,
906        args: Vec<String>,
907        cwd: Option<String>,
908        callback_id: JsCallbackId,
909    },
910
911    /// Delay/sleep for a duration (async, resolves callback when done)
912    Delay {
913        callback_id: JsCallbackId,
914        duration_ms: u64,
915    },
916
917    /// Spawn a long-running background process
918    /// Unlike SpawnProcess, this returns immediately with a process handle
919    /// and provides streaming output via hooks
920    SpawnBackgroundProcess {
921        /// Unique ID for this process (generated by plugin runtime)
922        process_id: u64,
923        /// Command to execute
924        command: String,
925        /// Arguments to pass
926        args: Vec<String>,
927        /// Working directory (optional)
928        cwd: Option<String>,
929        /// Callback ID to call when process exits
930        callback_id: JsCallbackId,
931    },
932
933    /// Kill a background process by ID
934    KillBackgroundProcess { process_id: u64 },
935
936    /// Wait for a process to complete and get its result
937    /// Used with processes started via SpawnProcess
938    SpawnProcessWait {
939        /// Process ID to wait for
940        process_id: u64,
941        /// Callback ID for async response
942        callback_id: JsCallbackId,
943    },
944
945    /// Set layout hints for a buffer/viewport
946    SetLayoutHints {
947        buffer_id: BufferId,
948        split_id: Option<SplitId>,
949        range: Range<usize>,
950        hints: LayoutHints,
951    },
952
953    /// Enable/disable line numbers for a buffer
954    SetLineNumbers { buffer_id: BufferId, enabled: bool },
955
956    /// Set the view mode for a buffer ("source" or "compose")
957    SetViewMode { buffer_id: BufferId, mode: String },
958
959    /// Enable/disable line wrapping for a buffer
960    SetLineWrap {
961        buffer_id: BufferId,
962        split_id: Option<SplitId>,
963        enabled: bool,
964    },
965
966    /// Submit a transformed view stream for a viewport
967    SubmitViewTransform {
968        buffer_id: BufferId,
969        split_id: Option<SplitId>,
970        payload: ViewTransformPayload,
971    },
972
973    /// Clear view transform for a buffer/split (returns to normal rendering)
974    ClearViewTransform {
975        buffer_id: BufferId,
976        split_id: Option<SplitId>,
977    },
978
979    /// Set plugin-managed view state for a buffer in the active split.
980    /// Stored in BufferViewState.plugin_state and persisted across sessions.
981    SetViewState {
982        buffer_id: BufferId,
983        key: String,
984        #[ts(type = "any")]
985        value: Option<serde_json::Value>,
986    },
987
988    /// Set plugin-managed global state (not tied to any buffer or split).
989    /// Isolated per plugin by plugin_name.
990    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
991    SetGlobalState {
992        plugin_name: String,
993        key: String,
994        #[ts(type = "any")]
995        value: Option<serde_json::Value>,
996    },
997
998    /// Remove all overlays from a buffer
999    ClearAllOverlays { buffer_id: BufferId },
1000
1001    /// Remove all overlays in a namespace
1002    ClearNamespace {
1003        buffer_id: BufferId,
1004        namespace: OverlayNamespace,
1005    },
1006
1007    /// Remove all overlays that overlap with a byte range
1008    /// Used for targeted invalidation when content in a range changes
1009    ClearOverlaysInRange {
1010        buffer_id: BufferId,
1011        start: usize,
1012        end: usize,
1013    },
1014
1015    /// Add virtual text (inline text that doesn't exist in the buffer)
1016    /// Used for color swatches, type hints, parameter hints, etc.
1017    AddVirtualText {
1018        buffer_id: BufferId,
1019        virtual_text_id: String,
1020        position: usize,
1021        text: String,
1022        color: (u8, u8, u8),
1023        use_bg: bool, // true = use color as background, false = use as foreground
1024        before: bool, // true = before char, false = after char
1025    },
1026
1027    /// Remove a virtual text by ID
1028    RemoveVirtualText {
1029        buffer_id: BufferId,
1030        virtual_text_id: String,
1031    },
1032
1033    /// Remove virtual texts whose ID starts with the given prefix
1034    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
1035
1036    /// Clear all virtual texts from a buffer
1037    ClearVirtualTexts { buffer_id: BufferId },
1038
1039    /// Add a virtual LINE (full line above/below a position)
1040    /// Used for git blame headers, code coverage, inline documentation, etc.
1041    /// These lines do NOT show line numbers in the gutter.
1042    AddVirtualLine {
1043        buffer_id: BufferId,
1044        /// Byte position to anchor the line to
1045        position: usize,
1046        /// Full line content to display
1047        text: String,
1048        /// Foreground color (RGB)
1049        fg_color: (u8, u8, u8),
1050        /// Background color (RGB), None = transparent
1051        bg_color: Option<(u8, u8, u8)>,
1052        /// true = above the line containing position, false = below
1053        above: bool,
1054        /// Namespace for bulk removal (e.g., "git-blame")
1055        namespace: String,
1056        /// Priority for ordering multiple lines at same position (higher = later)
1057        priority: i32,
1058    },
1059
1060    /// Clear all virtual texts in a namespace
1061    /// This is the primary way to remove a plugin's virtual lines before updating them.
1062    ClearVirtualTextNamespace {
1063        buffer_id: BufferId,
1064        namespace: String,
1065    },
1066
1067    /// Add a conceal range that hides or replaces a byte range during rendering.
1068    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1069    AddConceal {
1070        buffer_id: BufferId,
1071        /// Namespace for bulk removal (shared with overlay namespace system)
1072        namespace: OverlayNamespace,
1073        /// Byte range to conceal
1074        start: usize,
1075        end: usize,
1076        /// Optional replacement text to show instead. None = hide completely.
1077        replacement: Option<String>,
1078    },
1079
1080    /// Clear all conceal ranges in a namespace
1081    ClearConcealNamespace {
1082        buffer_id: BufferId,
1083        namespace: OverlayNamespace,
1084    },
1085
1086    /// Remove all conceal ranges that overlap with a byte range
1087    /// Used for targeted invalidation when content in a range changes
1088    ClearConcealsInRange {
1089        buffer_id: BufferId,
1090        start: usize,
1091        end: usize,
1092    },
1093
1094    /// Add a soft break point for marker-based line wrapping.
1095    /// The break is stored as a marker that auto-adjusts on buffer edits,
1096    /// eliminating the flicker caused by async view_transform round-trips.
1097    AddSoftBreak {
1098        buffer_id: BufferId,
1099        /// Namespace for bulk removal (shared with overlay namespace system)
1100        namespace: OverlayNamespace,
1101        /// Byte offset where the break should be injected
1102        position: usize,
1103        /// Number of hanging indent spaces after the break
1104        indent: u16,
1105    },
1106
1107    /// Clear all soft breaks in a namespace
1108    ClearSoftBreakNamespace {
1109        buffer_id: BufferId,
1110        namespace: OverlayNamespace,
1111    },
1112
1113    /// Remove all soft breaks that fall within a byte range
1114    ClearSoftBreaksInRange {
1115        buffer_id: BufferId,
1116        start: usize,
1117        end: usize,
1118    },
1119
1120    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1121    RefreshLines { buffer_id: BufferId },
1122
1123    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1124    /// Sent when a plugin registers for the lines_changed hook to handle the race
1125    /// where render marks lines as "seen" before the plugin has registered.
1126    RefreshAllLines,
1127
1128    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1129    /// Used by the render loop to wait deterministically for plugin responses
1130    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1131    HookCompleted { hook_name: String },
1132
1133    /// Set a line indicator in the gutter's indicator column
1134    /// Used for git gutter, breakpoints, bookmarks, etc.
1135    SetLineIndicator {
1136        buffer_id: BufferId,
1137        /// Line number (0-indexed)
1138        line: usize,
1139        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1140        namespace: String,
1141        /// Symbol to display (e.g., "│", "●", "★")
1142        symbol: String,
1143        /// Color as RGB tuple
1144        color: (u8, u8, u8),
1145        /// Priority for display when multiple indicators exist (higher wins)
1146        priority: i32,
1147    },
1148
1149    /// Batch set line indicators in the gutter's indicator column
1150    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1151    SetLineIndicators {
1152        buffer_id: BufferId,
1153        /// Line numbers (0-indexed)
1154        lines: Vec<usize>,
1155        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1156        namespace: String,
1157        /// Symbol to display (e.g., "│", "●", "★")
1158        symbol: String,
1159        /// Color as RGB tuple
1160        color: (u8, u8, u8),
1161        /// Priority for display when multiple indicators exist (higher wins)
1162        priority: i32,
1163    },
1164
1165    /// Clear all line indicators for a specific namespace
1166    ClearLineIndicators {
1167        buffer_id: BufferId,
1168        /// Namespace to clear (e.g., "git-gutter")
1169        namespace: String,
1170    },
1171
1172    /// Set file explorer decorations for a namespace
1173    SetFileExplorerDecorations {
1174        /// Namespace for grouping (e.g., "git-status")
1175        namespace: String,
1176        /// Decorations to apply
1177        decorations: Vec<FileExplorerDecoration>,
1178    },
1179
1180    /// Clear file explorer decorations for a namespace
1181    ClearFileExplorerDecorations {
1182        /// Namespace to clear (e.g., "git-status")
1183        namespace: String,
1184    },
1185
1186    /// Open a file at a specific line and column
1187    /// Line and column are 1-indexed to match git grep output
1188    OpenFileAtLocation {
1189        path: PathBuf,
1190        line: Option<usize>,   // 1-indexed, None = go to start
1191        column: Option<usize>, // 1-indexed, None = go to line start
1192    },
1193
1194    /// Open a file in a specific split at a given line and column
1195    /// Line and column are 1-indexed to match git grep output
1196    OpenFileInSplit {
1197        split_id: usize,
1198        path: PathBuf,
1199        line: Option<usize>,   // 1-indexed, None = go to start
1200        column: Option<usize>, // 1-indexed, None = go to line start
1201    },
1202
1203    /// Start a prompt (minibuffer) with a custom type identifier
1204    /// This allows plugins to create interactive prompts
1205    StartPrompt {
1206        label: String,
1207        prompt_type: String, // e.g., "git-grep", "git-find-file"
1208    },
1209
1210    /// Start a prompt with pre-filled initial value
1211    StartPromptWithInitial {
1212        label: String,
1213        prompt_type: String,
1214        initial_value: String,
1215    },
1216
1217    /// Start an async prompt that returns result via callback
1218    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1219    StartPromptAsync {
1220        label: String,
1221        initial_value: String,
1222        callback_id: JsCallbackId,
1223    },
1224
1225    /// Update the suggestions list for the current prompt
1226    /// Uses the editor's Suggestion type
1227    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1228
1229    /// When enabled, navigating suggestions updates the prompt input text
1230    SetPromptInputSync { sync: bool },
1231
1232    /// Add a menu item to an existing menu
1233    /// Add a menu item to an existing menu
1234    AddMenuItem {
1235        menu_label: String,
1236        item: MenuItem,
1237        position: MenuPosition,
1238    },
1239
1240    /// Add a new top-level menu
1241    AddMenu { menu: Menu, position: MenuPosition },
1242
1243    /// Remove a menu item from a menu
1244    RemoveMenuItem {
1245        menu_label: String,
1246        item_label: String,
1247    },
1248
1249    /// Remove a top-level menu
1250    RemoveMenu { menu_label: String },
1251
1252    /// Create a new virtual buffer (not backed by a file)
1253    CreateVirtualBuffer {
1254        /// Display name (e.g., "*Diagnostics*")
1255        name: String,
1256        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1257        mode: String,
1258        /// Whether the buffer is read-only
1259        read_only: bool,
1260    },
1261
1262    /// Create a virtual buffer and set its content in one operation
1263    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1264    /// because it doesn't require tracking the buffer ID
1265    CreateVirtualBufferWithContent {
1266        /// Display name (e.g., "*Diagnostics*")
1267        name: String,
1268        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1269        mode: String,
1270        /// Whether the buffer is read-only
1271        read_only: bool,
1272        /// Entries with text and embedded properties
1273        entries: Vec<TextPropertyEntry>,
1274        /// Whether to show line numbers in the gutter
1275        show_line_numbers: bool,
1276        /// Whether to show cursors in the buffer
1277        show_cursors: bool,
1278        /// Whether editing is disabled (blocks editing commands)
1279        editing_disabled: bool,
1280        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1281        hidden_from_tabs: bool,
1282        /// Optional request ID for async response
1283        request_id: Option<u64>,
1284    },
1285
1286    /// Create a virtual buffer in a horizontal split
1287    /// Opens the buffer in a new pane below the current one
1288    CreateVirtualBufferInSplit {
1289        /// Display name (e.g., "*Diagnostics*")
1290        name: String,
1291        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1292        mode: String,
1293        /// Whether the buffer is read-only
1294        read_only: bool,
1295        /// Entries with text and embedded properties
1296        entries: Vec<TextPropertyEntry>,
1297        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1298        ratio: f32,
1299        /// Split direction ("horizontal" or "vertical"), default horizontal
1300        direction: Option<String>,
1301        /// Optional panel ID for idempotent operations (if panel exists, update content)
1302        panel_id: Option<String>,
1303        /// Whether to show line numbers in the buffer (default true)
1304        show_line_numbers: bool,
1305        /// Whether to show cursors in the buffer (default true)
1306        show_cursors: bool,
1307        /// Whether editing is disabled for this buffer (default false)
1308        editing_disabled: bool,
1309        /// Whether line wrapping is enabled for this split (None = use global setting)
1310        line_wrap: Option<bool>,
1311        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1312        before: bool,
1313        /// Optional request ID for async response (if set, editor will send back buffer ID)
1314        request_id: Option<u64>,
1315    },
1316
1317    /// Set the content of a virtual buffer with text properties
1318    SetVirtualBufferContent {
1319        buffer_id: BufferId,
1320        /// Entries with text and embedded properties
1321        entries: Vec<TextPropertyEntry>,
1322    },
1323
1324    /// Get text properties at the cursor position in a buffer
1325    GetTextPropertiesAtCursor { buffer_id: BufferId },
1326
1327    /// Define a buffer mode with keybindings
1328    DefineMode {
1329        name: String,
1330        bindings: Vec<(String, String)>, // (key_string, command_name)
1331        read_only: bool,
1332        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1333        allow_text_input: bool,
1334        /// Name of the plugin that defined this mode (for attribution)
1335        plugin_name: Option<String>,
1336    },
1337
1338    /// Switch the current split to display a buffer
1339    ShowBuffer { buffer_id: BufferId },
1340
1341    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1342    CreateVirtualBufferInExistingSplit {
1343        /// Display name (e.g., "*Commit Details*")
1344        name: String,
1345        /// Mode name for buffer-local keybindings
1346        mode: String,
1347        /// Whether the buffer is read-only
1348        read_only: bool,
1349        /// Entries with text and embedded properties
1350        entries: Vec<TextPropertyEntry>,
1351        /// Target split ID where the buffer should be displayed
1352        split_id: SplitId,
1353        /// Whether to show line numbers in the buffer (default true)
1354        show_line_numbers: bool,
1355        /// Whether to show cursors in the buffer (default true)
1356        show_cursors: bool,
1357        /// Whether editing is disabled for this buffer (default false)
1358        editing_disabled: bool,
1359        /// Whether line wrapping is enabled for this split (None = use global setting)
1360        line_wrap: Option<bool>,
1361        /// Optional request ID for async response
1362        request_id: Option<u64>,
1363    },
1364
1365    /// Close a buffer and remove it from all splits
1366    CloseBuffer { buffer_id: BufferId },
1367
1368    /// Create a composite buffer that displays multiple source buffers
1369    /// Used for side-by-side diff, unified diff, and 3-way merge views
1370    CreateCompositeBuffer {
1371        /// Display name (shown in tab bar)
1372        name: String,
1373        /// Mode name for keybindings (e.g., "diff-view")
1374        mode: String,
1375        /// Layout configuration
1376        layout: CompositeLayoutConfig,
1377        /// Source pane configurations
1378        sources: Vec<CompositeSourceConfig>,
1379        /// Diff hunks for line alignment (optional)
1380        hunks: Option<Vec<CompositeHunk>>,
1381        /// When set, first render scrolls to center this hunk (0-indexed)
1382        initial_focus_hunk: Option<usize>,
1383        /// Request ID for async response
1384        request_id: Option<u64>,
1385    },
1386
1387    /// Update alignment for a composite buffer (e.g., after source edit)
1388    UpdateCompositeAlignment {
1389        buffer_id: BufferId,
1390        hunks: Vec<CompositeHunk>,
1391    },
1392
1393    /// Close a composite buffer
1394    CloseCompositeBuffer { buffer_id: BufferId },
1395
1396    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1397    ///
1398    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1399    /// have one, and syncs viewport dimensions from split layout. This ensures
1400    /// subsequent commands can read/modify view state that is normally created
1401    /// lazily during the render cycle.
1402    FlushLayout,
1403
1404    /// Navigate to the next hunk in a composite buffer
1405    CompositeNextHunk { buffer_id: BufferId },
1406
1407    /// Navigate to the previous hunk in a composite buffer
1408    CompositePrevHunk { buffer_id: BufferId },
1409
1410    /// Focus a specific split
1411    FocusSplit { split_id: SplitId },
1412
1413    /// Set the buffer displayed in a specific split
1414    SetSplitBuffer {
1415        split_id: SplitId,
1416        buffer_id: BufferId,
1417    },
1418
1419    /// Set the scroll position of a specific split
1420    SetSplitScroll { split_id: SplitId, top_byte: usize },
1421
1422    /// Request syntax highlights for a buffer range
1423    RequestHighlights {
1424        buffer_id: BufferId,
1425        range: Range<usize>,
1426        request_id: u64,
1427    },
1428
1429    /// Close a split (if not the last one)
1430    CloseSplit { split_id: SplitId },
1431
1432    /// Set the ratio of a split container
1433    SetSplitRatio {
1434        split_id: SplitId,
1435        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1436        ratio: f32,
1437    },
1438
1439    /// Set a label on a leaf split (e.g., "sidebar")
1440    SetSplitLabel { split_id: SplitId, label: String },
1441
1442    /// Remove a label from a split
1443    ClearSplitLabel { split_id: SplitId },
1444
1445    /// Find a split by its label (async)
1446    GetSplitByLabel { label: String, request_id: u64 },
1447
1448    /// Distribute splits evenly - make all given splits equal size
1449    DistributeSplitsEvenly {
1450        /// Split IDs to distribute evenly
1451        split_ids: Vec<SplitId>,
1452    },
1453
1454    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1455    SetBufferCursor {
1456        buffer_id: BufferId,
1457        /// Byte offset position for the cursor
1458        position: usize,
1459    },
1460
1461    /// Send an arbitrary LSP request and return the raw JSON response
1462    SendLspRequest {
1463        language: String,
1464        method: String,
1465        #[ts(type = "any")]
1466        params: Option<JsonValue>,
1467        request_id: u64,
1468    },
1469
1470    /// Set the internal clipboard content
1471    SetClipboard { text: String },
1472
1473    /// Delete the current selection in the active buffer
1474    /// This deletes all selected text across all cursors
1475    DeleteSelection,
1476
1477    /// Set or unset a custom context
1478    /// Custom contexts are plugin-defined states that can be used to control command visibility
1479    /// For example, "config-editor" context could make config editor commands available
1480    SetContext {
1481        /// Context name (e.g., "config-editor")
1482        name: String,
1483        /// Whether the context is active
1484        active: bool,
1485    },
1486
1487    /// Set the hunks for the Review Diff tool
1488    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1489
1490    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1491    /// Used by vi mode plugin to run motions and calculate cursor ranges
1492    ExecuteAction {
1493        /// Action name (e.g., "move_word_right", "move_line_end")
1494        action_name: String,
1495    },
1496
1497    /// Execute multiple actions in sequence, each with an optional repeat count
1498    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1499    /// All actions execute atomically with no plugin roundtrips between them
1500    ExecuteActions {
1501        /// List of actions to execute in sequence
1502        actions: Vec<ActionSpec>,
1503    },
1504
1505    /// Get text from a buffer range (for yank operations)
1506    GetBufferText {
1507        /// Buffer ID
1508        buffer_id: BufferId,
1509        /// Start byte offset
1510        start: usize,
1511        /// End byte offset
1512        end: usize,
1513        /// Request ID for async response
1514        request_id: u64,
1515    },
1516
1517    /// Get byte offset of the start of a line (async)
1518    /// Line is 0-indexed (0 = first line)
1519    GetLineStartPosition {
1520        /// Buffer ID (0 for active buffer)
1521        buffer_id: BufferId,
1522        /// Line number (0-indexed)
1523        line: u32,
1524        /// Request ID for async response
1525        request_id: u64,
1526    },
1527
1528    /// Get byte offset of the end of a line (async)
1529    /// Line is 0-indexed (0 = first line)
1530    /// Returns the byte offset after the last character of the line (before newline)
1531    GetLineEndPosition {
1532        /// Buffer ID (0 for active buffer)
1533        buffer_id: BufferId,
1534        /// Line number (0-indexed)
1535        line: u32,
1536        /// Request ID for async response
1537        request_id: u64,
1538    },
1539
1540    /// Get the total number of lines in a buffer (async)
1541    GetBufferLineCount {
1542        /// Buffer ID (0 for active buffer)
1543        buffer_id: BufferId,
1544        /// Request ID for async response
1545        request_id: u64,
1546    },
1547
1548    /// Scroll a split to center a specific line in the viewport
1549    /// Line is 0-indexed (0 = first line)
1550    ScrollToLineCenter {
1551        /// Split ID to scroll
1552        split_id: SplitId,
1553        /// Buffer ID containing the line
1554        buffer_id: BufferId,
1555        /// Line number to center (0-indexed)
1556        line: usize,
1557    },
1558
1559    /// Set the global editor mode (for modal editing like vi mode)
1560    /// When set, the mode's keybindings take precedence over normal editing
1561    SetEditorMode {
1562        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1563        mode: Option<String>,
1564    },
1565
1566    /// Show an action popup with buttons for user interaction
1567    /// When the user selects an action, the ActionPopupResult hook is fired
1568    ShowActionPopup {
1569        /// Unique identifier for the popup (used in ActionPopupResult)
1570        popup_id: String,
1571        /// Title text for the popup
1572        title: String,
1573        /// Body message (supports basic formatting)
1574        message: String,
1575        /// Action buttons to display
1576        actions: Vec<ActionPopupAction>,
1577    },
1578
1579    /// Disable LSP for a specific language and persist to config
1580    DisableLspForLanguage {
1581        /// The language to disable LSP for (e.g., "python", "rust")
1582        language: String,
1583    },
1584
1585    /// Restart LSP server for a specific language
1586    RestartLspForLanguage {
1587        /// The language to restart LSP for (e.g., "python", "rust")
1588        language: String,
1589    },
1590
1591    /// Set the workspace root URI for a specific language's LSP server
1592    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1593    /// If the LSP is already running, it will be restarted with the new root
1594    SetLspRootUri {
1595        /// The language to set root URI for (e.g., "csharp", "rust")
1596        language: String,
1597        /// The root URI (file:// URL format)
1598        uri: String,
1599    },
1600
1601    /// Create a scroll sync group for anchor-based synchronized scrolling
1602    /// Used for side-by-side diff views where two panes need to scroll together
1603    /// The plugin provides the group ID (must be unique per plugin)
1604    CreateScrollSyncGroup {
1605        /// Plugin-assigned group ID
1606        group_id: u32,
1607        /// The left (primary) split - scroll position is tracked in this split's line space
1608        left_split: SplitId,
1609        /// The right (secondary) split - position is derived from anchors
1610        right_split: SplitId,
1611    },
1612
1613    /// Set sync anchors for a scroll sync group
1614    /// Anchors map corresponding line numbers between left and right buffers
1615    SetScrollSyncAnchors {
1616        /// The group ID returned by CreateScrollSyncGroup
1617        group_id: u32,
1618        /// List of (left_line, right_line) pairs marking corresponding positions
1619        anchors: Vec<(usize, usize)>,
1620    },
1621
1622    /// Remove a scroll sync group
1623    RemoveScrollSyncGroup {
1624        /// The group ID returned by CreateScrollSyncGroup
1625        group_id: u32,
1626    },
1627
1628    /// Save a buffer to a specific file path
1629    /// Used by :w filename command to save unnamed buffers or save-as
1630    SaveBufferToPath {
1631        /// Buffer ID to save
1632        buffer_id: BufferId,
1633        /// Path to save to
1634        path: PathBuf,
1635    },
1636
1637    /// Load a plugin from a file path
1638    /// The plugin will be initialized and start receiving events
1639    LoadPlugin {
1640        /// Path to the plugin file (.ts or .js)
1641        path: PathBuf,
1642        /// Callback ID for async response (success/failure)
1643        callback_id: JsCallbackId,
1644    },
1645
1646    /// Unload a plugin by name
1647    /// The plugin will stop receiving events and be removed from memory
1648    UnloadPlugin {
1649        /// Plugin name (as registered)
1650        name: String,
1651        /// Callback ID for async response (success/failure)
1652        callback_id: JsCallbackId,
1653    },
1654
1655    /// Reload a plugin by name (unload + load)
1656    /// Useful for development when plugin code changes
1657    ReloadPlugin {
1658        /// Plugin name (as registered)
1659        name: String,
1660        /// Callback ID for async response (success/failure)
1661        callback_id: JsCallbackId,
1662    },
1663
1664    /// List all loaded plugins
1665    /// Returns plugin info (name, path, enabled) for all loaded plugins
1666    ListPlugins {
1667        /// Callback ID for async response (JSON array of plugin info)
1668        callback_id: JsCallbackId,
1669    },
1670
1671    /// Reload the theme registry from disk
1672    /// Call this after installing a theme package or saving a new theme.
1673    /// If `apply_theme` is set, apply that theme immediately after reloading.
1674    ReloadThemes { apply_theme: Option<String> },
1675
1676    /// Register a TextMate grammar file for a language
1677    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1678    RegisterGrammar {
1679        /// Language identifier (e.g., "elixir", "zig")
1680        language: String,
1681        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1682        grammar_path: String,
1683        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1684        extensions: Vec<String>,
1685    },
1686
1687    /// Register language configuration (comment prefix, indentation, formatter)
1688    /// This is applied immediately to the runtime config
1689    RegisterLanguageConfig {
1690        /// Language identifier (e.g., "elixir")
1691        language: String,
1692        /// Language configuration
1693        config: LanguagePackConfig,
1694    },
1695
1696    /// Register an LSP server for a language
1697    /// This is applied immediately to the LSP manager and runtime config
1698    RegisterLspServer {
1699        /// Language identifier (e.g., "elixir")
1700        language: String,
1701        /// LSP server configuration
1702        config: LspServerPackConfig,
1703    },
1704
1705    /// Reload the grammar registry to apply registered grammars (async)
1706    /// Call this after registering one or more grammars to rebuild the syntax set.
1707    /// The callback is resolved when the background grammar build completes.
1708    ReloadGrammars { callback_id: JsCallbackId },
1709
1710    // ==================== Terminal Commands ====================
1711    /// Create a new terminal in a split (async, returns TerminalResult)
1712    /// This spawns a PTY-backed terminal that plugins can write to and read from.
1713    CreateTerminal {
1714        /// Working directory for the terminal (defaults to editor cwd)
1715        cwd: Option<String>,
1716        /// Split direction ("horizontal" or "vertical"), default vertical
1717        direction: Option<String>,
1718        /// Split ratio (0.0 to 1.0), default 0.5
1719        ratio: Option<f32>,
1720        /// Whether to focus the new terminal split (default true)
1721        focus: Option<bool>,
1722        /// Callback ID for async response
1723        request_id: u64,
1724    },
1725
1726    /// Send input data to a terminal by its terminal ID
1727    SendTerminalInput {
1728        /// The terminal ID (from TerminalResult)
1729        terminal_id: TerminalId,
1730        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
1731        data: String,
1732    },
1733
1734    /// Close a terminal by its terminal ID
1735    CloseTerminal {
1736        /// The terminal ID to close
1737        terminal_id: TerminalId,
1738    },
1739
1740    /// Project-wide grep search (async)
1741    /// Searches all project files via FileSystem trait, respecting .gitignore.
1742    /// For open buffers with dirty edits, searches the buffer's piece tree.
1743    GrepProject {
1744        /// Search pattern (literal string)
1745        pattern: String,
1746        /// Whether the pattern is a fixed string (true) or regex (false)
1747        fixed_string: bool,
1748        /// Whether the search is case-sensitive
1749        case_sensitive: bool,
1750        /// Maximum number of results to return
1751        max_results: usize,
1752        /// Whether to match whole words only
1753        whole_words: bool,
1754        /// Callback ID for async response
1755        callback_id: JsCallbackId,
1756    },
1757
1758    /// Project-wide streaming grep search (async, parallel)
1759    /// Like GrepProject but streams results incrementally via progress callback.
1760    /// Searches files in parallel using tokio tasks, sending per-file results
1761    /// back to the plugin as they complete.
1762    GrepProjectStreaming {
1763        /// Search pattern
1764        pattern: String,
1765        /// Whether the pattern is a fixed string (true) or regex (false)
1766        fixed_string: bool,
1767        /// Whether the search is case-sensitive
1768        case_sensitive: bool,
1769        /// Maximum number of results to return
1770        max_results: usize,
1771        /// Whether to match whole words only
1772        whole_words: bool,
1773        /// Search ID — used to route progress callbacks and for cancellation
1774        search_id: u64,
1775        /// Callback ID for the completion promise
1776        callback_id: JsCallbackId,
1777    },
1778
1779    /// Replace matches in a buffer (async)
1780    /// Opens the file if not already open, applies edits through the buffer model,
1781    /// groups as a single undo action, and saves via FileSystem trait.
1782    ReplaceInBuffer {
1783        /// File path to edit (will open if not already in a buffer)
1784        file_path: PathBuf,
1785        /// Matches to replace, each is (byte_offset, length)
1786        matches: Vec<(usize, usize)>,
1787        /// Replacement text
1788        replacement: String,
1789        /// Callback ID for async response
1790        callback_id: JsCallbackId,
1791    },
1792}
1793
1794impl PluginCommand {
1795    /// Extract the enum variant name from the Debug representation.
1796    pub fn debug_variant_name(&self) -> String {
1797        let dbg = format!("{:?}", self);
1798        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
1799    }
1800}
1801
1802// =============================================================================
1803// Language Pack Configuration Types
1804// =============================================================================
1805
1806/// Language configuration for language packs
1807///
1808/// This is a simplified version of the full LanguageConfig, containing only
1809/// the fields that can be set via the plugin API.
1810#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
1811#[serde(rename_all = "camelCase")]
1812#[ts(export)]
1813pub struct LanguagePackConfig {
1814    /// Comment prefix for line comments (e.g., "//" or "#")
1815    #[serde(default)]
1816    pub comment_prefix: Option<String>,
1817
1818    /// Block comment start marker (e.g., slash-star)
1819    #[serde(default)]
1820    pub block_comment_start: Option<String>,
1821
1822    /// Block comment end marker (e.g., star-slash)
1823    #[serde(default)]
1824    pub block_comment_end: Option<String>,
1825
1826    /// Whether to use tabs instead of spaces for indentation
1827    #[serde(default)]
1828    pub use_tabs: Option<bool>,
1829
1830    /// Tab size (number of spaces per tab level)
1831    #[serde(default)]
1832    pub tab_size: Option<usize>,
1833
1834    /// Whether auto-indent is enabled
1835    #[serde(default)]
1836    pub auto_indent: Option<bool>,
1837
1838    /// Whether to show whitespace tab indicators (→) for this language
1839    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
1840    #[serde(default)]
1841    pub show_whitespace_tabs: Option<bool>,
1842
1843    /// Formatter configuration
1844    #[serde(default)]
1845    pub formatter: Option<FormatterPackConfig>,
1846}
1847
1848/// Formatter configuration for language packs
1849#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1850#[serde(rename_all = "camelCase")]
1851#[ts(export)]
1852pub struct FormatterPackConfig {
1853    /// Command to run (e.g., "prettier", "rustfmt")
1854    pub command: String,
1855
1856    /// Arguments to pass to the formatter
1857    #[serde(default)]
1858    pub args: Vec<String>,
1859}
1860
1861/// Process resource limits for LSP servers
1862#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1863#[serde(rename_all = "camelCase")]
1864#[ts(export)]
1865pub struct ProcessLimitsPackConfig {
1866    /// Maximum memory usage as percentage of total system memory (null = no limit)
1867    #[serde(default)]
1868    pub max_memory_percent: Option<u32>,
1869
1870    /// Maximum CPU usage as percentage of total CPU (null = no limit)
1871    #[serde(default)]
1872    pub max_cpu_percent: Option<u32>,
1873
1874    /// Enable resource limiting
1875    #[serde(default)]
1876    pub enabled: Option<bool>,
1877}
1878
1879/// LSP server configuration for language packs
1880#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1881#[serde(rename_all = "camelCase")]
1882#[ts(export)]
1883pub struct LspServerPackConfig {
1884    /// Command to start the LSP server
1885    pub command: String,
1886
1887    /// Arguments to pass to the command
1888    #[serde(default)]
1889    pub args: Vec<String>,
1890
1891    /// Whether to auto-start the server when a matching file is opened
1892    #[serde(default)]
1893    pub auto_start: Option<bool>,
1894
1895    /// LSP initialization options
1896    #[serde(default)]
1897    #[ts(type = "Record<string, unknown> | null")]
1898    pub initialization_options: Option<JsonValue>,
1899
1900    /// Process resource limits (memory and CPU)
1901    #[serde(default)]
1902    pub process_limits: Option<ProcessLimitsPackConfig>,
1903}
1904
1905/// Hunk status for Review Diff
1906#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1907#[ts(export)]
1908pub enum HunkStatus {
1909    Pending,
1910    Staged,
1911    Discarded,
1912}
1913
1914/// A high-level hunk directive for the Review Diff tool
1915#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1916#[ts(export)]
1917pub struct ReviewHunk {
1918    pub id: String,
1919    pub file: String,
1920    pub context_header: String,
1921    pub status: HunkStatus,
1922    /// 0-indexed line range in the base (HEAD) version
1923    pub base_range: Option<(usize, usize)>,
1924    /// 0-indexed line range in the modified (Working) version
1925    pub modified_range: Option<(usize, usize)>,
1926}
1927
1928/// Action button for action popups
1929#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1930#[serde(deny_unknown_fields)]
1931#[ts(export, rename = "TsActionPopupAction")]
1932pub struct ActionPopupAction {
1933    /// Unique action identifier (returned in ActionPopupResult)
1934    pub id: String,
1935    /// Display text for the button (can include command hints)
1936    pub label: String,
1937}
1938
1939/// Options for showActionPopup
1940#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1941#[serde(deny_unknown_fields)]
1942#[ts(export)]
1943pub struct ActionPopupOptions {
1944    /// Unique identifier for the popup (used in ActionPopupResult)
1945    pub id: String,
1946    /// Title text for the popup
1947    pub title: String,
1948    /// Body message (supports basic formatting)
1949    pub message: String,
1950    /// Action buttons to display
1951    pub actions: Vec<ActionPopupAction>,
1952}
1953
1954/// Syntax highlight span for a buffer range
1955#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1956#[ts(export)]
1957pub struct TsHighlightSpan {
1958    pub start: u32,
1959    pub end: u32,
1960    #[ts(type = "[number, number, number]")]
1961    pub color: (u8, u8, u8),
1962    pub bold: bool,
1963    pub italic: bool,
1964}
1965
1966/// Result from spawning a process with spawnProcess
1967#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1968#[ts(export)]
1969pub struct SpawnResult {
1970    /// Complete stdout as string
1971    pub stdout: String,
1972    /// Complete stderr as string
1973    pub stderr: String,
1974    /// Process exit code (0 usually means success, -1 if killed)
1975    pub exit_code: i32,
1976}
1977
1978/// Result from spawning a background process
1979#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1980#[ts(export)]
1981pub struct BackgroundProcessResult {
1982    /// Unique process ID for later reference
1983    #[ts(type = "number")]
1984    pub process_id: u64,
1985    /// Process exit code (0 usually means success, -1 if killed)
1986    /// Only present when the process has exited
1987    pub exit_code: i32,
1988}
1989
1990/// A single match from project-wide grep
1991#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1992#[serde(rename_all = "camelCase")]
1993#[ts(export, rename_all = "camelCase")]
1994pub struct GrepMatch {
1995    /// Absolute file path
1996    pub file: String,
1997    /// Buffer ID if the file is open (0 if not)
1998    #[ts(type = "number")]
1999    pub buffer_id: usize,
2000    /// Byte offset of match start in the file/buffer content
2001    #[ts(type = "number")]
2002    pub byte_offset: usize,
2003    /// Match length in bytes
2004    #[ts(type = "number")]
2005    pub length: usize,
2006    /// 1-indexed line number
2007    #[ts(type = "number")]
2008    pub line: usize,
2009    /// 1-indexed column number
2010    #[ts(type = "number")]
2011    pub column: usize,
2012    /// The matched line content (for display)
2013    pub context: String,
2014}
2015
2016/// Result from replacing matches in a buffer
2017#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2018#[serde(rename_all = "camelCase")]
2019#[ts(export, rename_all = "camelCase")]
2020pub struct ReplaceResult {
2021    /// Number of replacements made
2022    #[ts(type = "number")]
2023    pub replacements: usize,
2024    /// Buffer ID of the edited buffer
2025    #[ts(type = "number")]
2026    pub buffer_id: usize,
2027}
2028
2029/// Entry for virtual buffer content with optional text properties (JS API version)
2030#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2031#[serde(deny_unknown_fields, rename_all = "camelCase")]
2032#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2033pub struct JsTextPropertyEntry {
2034    /// Text content for this entry
2035    pub text: String,
2036    /// Optional properties attached to this text (e.g., file path, line number)
2037    #[serde(default)]
2038    #[ts(optional, type = "Record<string, unknown>")]
2039    pub properties: Option<HashMap<String, JsonValue>>,
2040    /// Optional whole-entry styling
2041    #[serde(default)]
2042    #[ts(optional, type = "Partial<OverlayOptions>")]
2043    pub style: Option<OverlayOptions>,
2044    /// Optional sub-range styling within this entry
2045    #[serde(default)]
2046    #[ts(optional)]
2047    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2048}
2049
2050/// Directory entry returned by readDir
2051#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2052#[ts(export)]
2053pub struct DirEntry {
2054    /// File/directory name
2055    pub name: String,
2056    /// True if this is a file
2057    pub is_file: bool,
2058    /// True if this is a directory
2059    pub is_dir: bool,
2060}
2061
2062/// Position in a document (line and character)
2063#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2064#[ts(export)]
2065pub struct JsPosition {
2066    /// Zero-indexed line number
2067    pub line: u32,
2068    /// Zero-indexed character offset
2069    pub character: u32,
2070}
2071
2072/// Range in a document (start and end positions)
2073#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2074#[ts(export)]
2075pub struct JsRange {
2076    /// Start position
2077    pub start: JsPosition,
2078    /// End position
2079    pub end: JsPosition,
2080}
2081
2082/// Diagnostic from LSP
2083#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2084#[ts(export)]
2085pub struct JsDiagnostic {
2086    /// Document URI
2087    pub uri: String,
2088    /// Diagnostic message
2089    pub message: String,
2090    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2091    pub severity: Option<u8>,
2092    /// Range in the document
2093    pub range: JsRange,
2094    /// Source of the diagnostic (e.g., "typescript", "eslint")
2095    #[ts(optional)]
2096    pub source: Option<String>,
2097}
2098
2099/// Options for createVirtualBuffer
2100#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2101#[serde(deny_unknown_fields)]
2102#[ts(export)]
2103pub struct CreateVirtualBufferOptions {
2104    /// Buffer name (displayed in tabs/title)
2105    pub name: String,
2106    /// Mode for keybindings (e.g., "git-log", "search-results")
2107    #[serde(default)]
2108    #[ts(optional)]
2109    pub mode: Option<String>,
2110    /// Whether buffer is read-only (default: false)
2111    #[serde(default, rename = "readOnly")]
2112    #[ts(optional, rename = "readOnly")]
2113    pub read_only: Option<bool>,
2114    /// Show line numbers in gutter (default: false)
2115    #[serde(default, rename = "showLineNumbers")]
2116    #[ts(optional, rename = "showLineNumbers")]
2117    pub show_line_numbers: Option<bool>,
2118    /// Show cursor (default: true)
2119    #[serde(default, rename = "showCursors")]
2120    #[ts(optional, rename = "showCursors")]
2121    pub show_cursors: Option<bool>,
2122    /// Disable text editing (default: false)
2123    #[serde(default, rename = "editingDisabled")]
2124    #[ts(optional, rename = "editingDisabled")]
2125    pub editing_disabled: Option<bool>,
2126    /// Hide from tab bar (default: false)
2127    #[serde(default, rename = "hiddenFromTabs")]
2128    #[ts(optional, rename = "hiddenFromTabs")]
2129    pub hidden_from_tabs: Option<bool>,
2130    /// Initial content entries with optional properties
2131    #[serde(default)]
2132    #[ts(optional)]
2133    pub entries: Option<Vec<JsTextPropertyEntry>>,
2134}
2135
2136/// Options for createVirtualBufferInSplit
2137#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2138#[serde(deny_unknown_fields)]
2139#[ts(export)]
2140pub struct CreateVirtualBufferInSplitOptions {
2141    /// Buffer name (displayed in tabs/title)
2142    pub name: String,
2143    /// Mode for keybindings (e.g., "git-log", "search-results")
2144    #[serde(default)]
2145    #[ts(optional)]
2146    pub mode: Option<String>,
2147    /// Whether buffer is read-only (default: false)
2148    #[serde(default, rename = "readOnly")]
2149    #[ts(optional, rename = "readOnly")]
2150    pub read_only: Option<bool>,
2151    /// Split ratio 0.0-1.0 (default: 0.5)
2152    #[serde(default)]
2153    #[ts(optional)]
2154    pub ratio: Option<f32>,
2155    /// Split direction: "horizontal" or "vertical"
2156    #[serde(default)]
2157    #[ts(optional)]
2158    pub direction: Option<String>,
2159    /// Panel ID to split from
2160    #[serde(default, rename = "panelId")]
2161    #[ts(optional, rename = "panelId")]
2162    pub panel_id: Option<String>,
2163    /// Show line numbers in gutter (default: true)
2164    #[serde(default, rename = "showLineNumbers")]
2165    #[ts(optional, rename = "showLineNumbers")]
2166    pub show_line_numbers: Option<bool>,
2167    /// Show cursor (default: true)
2168    #[serde(default, rename = "showCursors")]
2169    #[ts(optional, rename = "showCursors")]
2170    pub show_cursors: Option<bool>,
2171    /// Disable text editing (default: false)
2172    #[serde(default, rename = "editingDisabled")]
2173    #[ts(optional, rename = "editingDisabled")]
2174    pub editing_disabled: Option<bool>,
2175    /// Enable line wrapping
2176    #[serde(default, rename = "lineWrap")]
2177    #[ts(optional, rename = "lineWrap")]
2178    pub line_wrap: Option<bool>,
2179    /// Place the new buffer before (left/top of) the existing content (default: false)
2180    #[serde(default)]
2181    #[ts(optional)]
2182    pub before: Option<bool>,
2183    /// Initial content entries with optional properties
2184    #[serde(default)]
2185    #[ts(optional)]
2186    pub entries: Option<Vec<JsTextPropertyEntry>>,
2187}
2188
2189/// Options for createVirtualBufferInExistingSplit
2190#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2191#[serde(deny_unknown_fields)]
2192#[ts(export)]
2193pub struct CreateVirtualBufferInExistingSplitOptions {
2194    /// Buffer name (displayed in tabs/title)
2195    pub name: String,
2196    /// Target split ID (required)
2197    #[serde(rename = "splitId")]
2198    #[ts(rename = "splitId")]
2199    pub split_id: usize,
2200    /// Mode for keybindings (e.g., "git-log", "search-results")
2201    #[serde(default)]
2202    #[ts(optional)]
2203    pub mode: Option<String>,
2204    /// Whether buffer is read-only (default: false)
2205    #[serde(default, rename = "readOnly")]
2206    #[ts(optional, rename = "readOnly")]
2207    pub read_only: Option<bool>,
2208    /// Show line numbers in gutter (default: true)
2209    #[serde(default, rename = "showLineNumbers")]
2210    #[ts(optional, rename = "showLineNumbers")]
2211    pub show_line_numbers: Option<bool>,
2212    /// Show cursor (default: true)
2213    #[serde(default, rename = "showCursors")]
2214    #[ts(optional, rename = "showCursors")]
2215    pub show_cursors: Option<bool>,
2216    /// Disable text editing (default: false)
2217    #[serde(default, rename = "editingDisabled")]
2218    #[ts(optional, rename = "editingDisabled")]
2219    pub editing_disabled: Option<bool>,
2220    /// Enable line wrapping
2221    #[serde(default, rename = "lineWrap")]
2222    #[ts(optional, rename = "lineWrap")]
2223    pub line_wrap: Option<bool>,
2224    /// Initial content entries with optional properties
2225    #[serde(default)]
2226    #[ts(optional)]
2227    pub entries: Option<Vec<JsTextPropertyEntry>>,
2228}
2229
2230/// Options for createTerminal
2231#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2232#[serde(deny_unknown_fields)]
2233#[ts(export)]
2234pub struct CreateTerminalOptions {
2235    /// Working directory for the terminal (defaults to editor cwd)
2236    #[serde(default)]
2237    #[ts(optional)]
2238    pub cwd: Option<String>,
2239    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2240    #[serde(default)]
2241    #[ts(optional)]
2242    pub direction: Option<String>,
2243    /// Split ratio 0.0-1.0 (default: 0.5)
2244    #[serde(default)]
2245    #[ts(optional)]
2246    pub ratio: Option<f32>,
2247    /// Whether to focus the new terminal split (default: true)
2248    #[serde(default)]
2249    #[ts(optional)]
2250    pub focus: Option<bool>,
2251}
2252
2253/// Result of getTextPropertiesAtCursor - array of property objects
2254///
2255/// Each element contains the properties from a text property span that overlaps
2256/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2257#[derive(Debug, Clone, Serialize, TS)]
2258#[ts(export, type = "Array<Record<string, unknown>>")]
2259pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2260
2261// Implement FromJs for option types using rquickjs_serde
2262#[cfg(feature = "plugins")]
2263mod fromjs_impls {
2264    use super::*;
2265    use rquickjs::{Ctx, FromJs, Value};
2266
2267    impl<'js> FromJs<'js> for JsTextPropertyEntry {
2268        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2269            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2270                from: "object",
2271                to: "JsTextPropertyEntry",
2272                message: Some(e.to_string()),
2273            })
2274        }
2275    }
2276
2277    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
2278        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2279            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2280                from: "object",
2281                to: "CreateVirtualBufferOptions",
2282                message: Some(e.to_string()),
2283            })
2284        }
2285    }
2286
2287    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
2288        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2289            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2290                from: "object",
2291                to: "CreateVirtualBufferInSplitOptions",
2292                message: Some(e.to_string()),
2293            })
2294        }
2295    }
2296
2297    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
2298        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2299            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2300                from: "object",
2301                to: "CreateVirtualBufferInExistingSplitOptions",
2302                message: Some(e.to_string()),
2303            })
2304        }
2305    }
2306
2307    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2308        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2309            rquickjs_serde::to_value(ctx.clone(), &self.0)
2310                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2311        }
2312    }
2313
2314    // === Additional input types for type-safe plugin API ===
2315
2316    impl<'js> FromJs<'js> for ActionSpec {
2317        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2318            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2319                from: "object",
2320                to: "ActionSpec",
2321                message: Some(e.to_string()),
2322            })
2323        }
2324    }
2325
2326    impl<'js> FromJs<'js> for ActionPopupAction {
2327        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2328            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2329                from: "object",
2330                to: "ActionPopupAction",
2331                message: Some(e.to_string()),
2332            })
2333        }
2334    }
2335
2336    impl<'js> FromJs<'js> for ActionPopupOptions {
2337        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2338            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2339                from: "object",
2340                to: "ActionPopupOptions",
2341                message: Some(e.to_string()),
2342            })
2343        }
2344    }
2345
2346    impl<'js> FromJs<'js> for ViewTokenWire {
2347        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2348            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2349                from: "object",
2350                to: "ViewTokenWire",
2351                message: Some(e.to_string()),
2352            })
2353        }
2354    }
2355
2356    impl<'js> FromJs<'js> for ViewTokenStyle {
2357        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2358            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2359                from: "object",
2360                to: "ViewTokenStyle",
2361                message: Some(e.to_string()),
2362            })
2363        }
2364    }
2365
2366    impl<'js> FromJs<'js> for LayoutHints {
2367        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2368            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2369                from: "object",
2370                to: "LayoutHints",
2371                message: Some(e.to_string()),
2372            })
2373        }
2374    }
2375
2376    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2377        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2378            // Use two-step deserialization for complex nested structures
2379            let json: serde_json::Value =
2380                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2381                    from: "object",
2382                    to: "CreateCompositeBufferOptions (json)",
2383                    message: Some(e.to_string()),
2384                })?;
2385            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2386                from: "json",
2387                to: "CreateCompositeBufferOptions",
2388                message: Some(e.to_string()),
2389            })
2390        }
2391    }
2392
2393    impl<'js> FromJs<'js> for CompositeHunk {
2394        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2395            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2396                from: "object",
2397                to: "CompositeHunk",
2398                message: Some(e.to_string()),
2399            })
2400        }
2401    }
2402
2403    impl<'js> FromJs<'js> for LanguagePackConfig {
2404        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2405            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2406                from: "object",
2407                to: "LanguagePackConfig",
2408                message: Some(e.to_string()),
2409            })
2410        }
2411    }
2412
2413    impl<'js> FromJs<'js> for LspServerPackConfig {
2414        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2415            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2416                from: "object",
2417                to: "LspServerPackConfig",
2418                message: Some(e.to_string()),
2419            })
2420        }
2421    }
2422
2423    impl<'js> FromJs<'js> for ProcessLimitsPackConfig {
2424        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2425            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2426                from: "object",
2427                to: "ProcessLimitsPackConfig",
2428                message: Some(e.to_string()),
2429            })
2430        }
2431    }
2432
2433    impl<'js> FromJs<'js> for CreateTerminalOptions {
2434        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2435            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2436                from: "object",
2437                to: "CreateTerminalOptions",
2438                message: Some(e.to_string()),
2439            })
2440        }
2441    }
2442}
2443
2444/// Plugin API context - provides safe access to editor functionality
2445pub struct PluginApi {
2446    /// Hook registry (shared with editor)
2447    hooks: Arc<RwLock<HookRegistry>>,
2448
2449    /// Command registry (shared with editor)
2450    commands: Arc<RwLock<CommandRegistry>>,
2451
2452    /// Command queue for sending commands to editor
2453    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2454
2455    /// Snapshot of editor state (read-only for plugins)
2456    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2457}
2458
2459impl PluginApi {
2460    /// Create a new plugin API context
2461    pub fn new(
2462        hooks: Arc<RwLock<HookRegistry>>,
2463        commands: Arc<RwLock<CommandRegistry>>,
2464        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2465        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2466    ) -> Self {
2467        Self {
2468            hooks,
2469            commands,
2470            command_sender,
2471            state_snapshot,
2472        }
2473    }
2474
2475    /// Register a hook callback
2476    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2477        let mut hooks = self.hooks.write().unwrap();
2478        hooks.add_hook(hook_name, callback);
2479    }
2480
2481    /// Remove all hooks for a specific name
2482    pub fn unregister_hooks(&self, hook_name: &str) {
2483        let mut hooks = self.hooks.write().unwrap();
2484        hooks.remove_hooks(hook_name);
2485    }
2486
2487    /// Register a command
2488    pub fn register_command(&self, command: Command) {
2489        let commands = self.commands.read().unwrap();
2490        commands.register(command);
2491    }
2492
2493    /// Unregister a command by name
2494    pub fn unregister_command(&self, name: &str) {
2495        let commands = self.commands.read().unwrap();
2496        commands.unregister(name);
2497    }
2498
2499    /// Send a command to the editor (async/non-blocking)
2500    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2501        self.command_sender
2502            .send(command)
2503            .map_err(|e| format!("Failed to send command: {}", e))
2504    }
2505
2506    /// Insert text at a position in a buffer
2507    pub fn insert_text(
2508        &self,
2509        buffer_id: BufferId,
2510        position: usize,
2511        text: String,
2512    ) -> Result<(), String> {
2513        self.send_command(PluginCommand::InsertText {
2514            buffer_id,
2515            position,
2516            text,
2517        })
2518    }
2519
2520    /// Delete a range of text from a buffer
2521    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2522        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2523    }
2524
2525    /// Add an overlay (decoration) to a buffer
2526    /// Add an overlay to a buffer with styling options
2527    ///
2528    /// Returns an opaque handle that can be used to remove the overlay later.
2529    ///
2530    /// Colors can be specified as RGB arrays or theme key strings.
2531    /// Theme keys are resolved at render time, so overlays update with theme changes.
2532    pub fn add_overlay(
2533        &self,
2534        buffer_id: BufferId,
2535        namespace: Option<String>,
2536        range: Range<usize>,
2537        options: OverlayOptions,
2538    ) -> Result<(), String> {
2539        self.send_command(PluginCommand::AddOverlay {
2540            buffer_id,
2541            namespace: namespace.map(OverlayNamespace::from_string),
2542            range,
2543            options,
2544        })
2545    }
2546
2547    /// Remove an overlay from a buffer by its handle
2548    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
2549        self.send_command(PluginCommand::RemoveOverlay {
2550            buffer_id,
2551            handle: OverlayHandle::from_string(handle),
2552        })
2553    }
2554
2555    /// Clear all overlays in a namespace from a buffer
2556    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
2557        self.send_command(PluginCommand::ClearNamespace {
2558            buffer_id,
2559            namespace: OverlayNamespace::from_string(namespace),
2560        })
2561    }
2562
2563    /// Clear all overlays that overlap with a byte range
2564    /// Used for targeted invalidation when content changes
2565    pub fn clear_overlays_in_range(
2566        &self,
2567        buffer_id: BufferId,
2568        start: usize,
2569        end: usize,
2570    ) -> Result<(), String> {
2571        self.send_command(PluginCommand::ClearOverlaysInRange {
2572            buffer_id,
2573            start,
2574            end,
2575        })
2576    }
2577
2578    /// Set the status message
2579    pub fn set_status(&self, message: String) -> Result<(), String> {
2580        self.send_command(PluginCommand::SetStatus { message })
2581    }
2582
2583    /// Open a file at a specific line and column (1-indexed)
2584    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
2585    pub fn open_file_at_location(
2586        &self,
2587        path: PathBuf,
2588        line: Option<usize>,
2589        column: Option<usize>,
2590    ) -> Result<(), String> {
2591        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
2592    }
2593
2594    /// Open a file in a specific split at a line and column
2595    ///
2596    /// Similar to open_file_at_location but targets a specific split pane.
2597    /// The split_id is the ID of the split pane to open the file in.
2598    pub fn open_file_in_split(
2599        &self,
2600        split_id: usize,
2601        path: PathBuf,
2602        line: Option<usize>,
2603        column: Option<usize>,
2604    ) -> Result<(), String> {
2605        self.send_command(PluginCommand::OpenFileInSplit {
2606            split_id,
2607            path,
2608            line,
2609            column,
2610        })
2611    }
2612
2613    /// Start a prompt (minibuffer) with a custom type identifier
2614    /// The prompt_type is used to filter hooks in plugin code
2615    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
2616        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
2617    }
2618
2619    /// Set the suggestions for the current prompt
2620    /// This updates the prompt's autocomplete/selection list
2621    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
2622        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
2623    }
2624
2625    /// Enable/disable syncing prompt input text when navigating suggestions
2626    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
2627        self.send_command(PluginCommand::SetPromptInputSync { sync })
2628    }
2629
2630    /// Add a menu item to an existing menu
2631    pub fn add_menu_item(
2632        &self,
2633        menu_label: String,
2634        item: MenuItem,
2635        position: MenuPosition,
2636    ) -> Result<(), String> {
2637        self.send_command(PluginCommand::AddMenuItem {
2638            menu_label,
2639            item,
2640            position,
2641        })
2642    }
2643
2644    /// Add a new top-level menu
2645    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
2646        self.send_command(PluginCommand::AddMenu { menu, position })
2647    }
2648
2649    /// Remove a menu item from a menu
2650    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
2651        self.send_command(PluginCommand::RemoveMenuItem {
2652            menu_label,
2653            item_label,
2654        })
2655    }
2656
2657    /// Remove a top-level menu
2658    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
2659        self.send_command(PluginCommand::RemoveMenu { menu_label })
2660    }
2661
2662    // === Virtual Buffer Methods ===
2663
2664    /// Create a new virtual buffer (not backed by a file)
2665    ///
2666    /// Virtual buffers are used for special displays like diagnostic lists,
2667    /// search results, etc. They have their own mode for keybindings.
2668    pub fn create_virtual_buffer(
2669        &self,
2670        name: String,
2671        mode: String,
2672        read_only: bool,
2673    ) -> Result<(), String> {
2674        self.send_command(PluginCommand::CreateVirtualBuffer {
2675            name,
2676            mode,
2677            read_only,
2678        })
2679    }
2680
2681    /// Create a virtual buffer and set its content in one operation
2682    ///
2683    /// This is the preferred way to create virtual buffers since it doesn't
2684    /// require tracking the buffer ID. The buffer is created and populated
2685    /// atomically.
2686    pub fn create_virtual_buffer_with_content(
2687        &self,
2688        name: String,
2689        mode: String,
2690        read_only: bool,
2691        entries: Vec<TextPropertyEntry>,
2692    ) -> Result<(), String> {
2693        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
2694            name,
2695            mode,
2696            read_only,
2697            entries,
2698            show_line_numbers: true,
2699            show_cursors: true,
2700            editing_disabled: false,
2701            hidden_from_tabs: false,
2702            request_id: None,
2703        })
2704    }
2705
2706    /// Set the content of a virtual buffer with text properties
2707    ///
2708    /// Each entry contains text and metadata properties (e.g., source location).
2709    pub fn set_virtual_buffer_content(
2710        &self,
2711        buffer_id: BufferId,
2712        entries: Vec<TextPropertyEntry>,
2713    ) -> Result<(), String> {
2714        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
2715    }
2716
2717    /// Get text properties at cursor position in a buffer
2718    ///
2719    /// This triggers a command that will make properties available to plugins.
2720    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
2721        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
2722    }
2723
2724    /// Define a buffer mode with keybindings
2725    ///
2726    /// Bindings are specified as (key_string, command_name) pairs.
2727    pub fn define_mode(
2728        &self,
2729        name: String,
2730        bindings: Vec<(String, String)>,
2731        read_only: bool,
2732        allow_text_input: bool,
2733    ) -> Result<(), String> {
2734        self.send_command(PluginCommand::DefineMode {
2735            name,
2736            bindings,
2737            read_only,
2738            allow_text_input,
2739            plugin_name: None,
2740        })
2741    }
2742
2743    /// Switch the current split to display a buffer
2744    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
2745        self.send_command(PluginCommand::ShowBuffer { buffer_id })
2746    }
2747
2748    /// Set the scroll position of a specific split
2749    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
2750        self.send_command(PluginCommand::SetSplitScroll {
2751            split_id: SplitId(split_id),
2752            top_byte,
2753        })
2754    }
2755
2756    /// Request syntax highlights for a buffer range
2757    pub fn get_highlights(
2758        &self,
2759        buffer_id: BufferId,
2760        range: Range<usize>,
2761        request_id: u64,
2762    ) -> Result<(), String> {
2763        self.send_command(PluginCommand::RequestHighlights {
2764            buffer_id,
2765            range,
2766            request_id,
2767        })
2768    }
2769
2770    // === Query Methods ===
2771
2772    /// Get the currently active buffer ID
2773    pub fn get_active_buffer_id(&self) -> BufferId {
2774        let snapshot = self.state_snapshot.read().unwrap();
2775        snapshot.active_buffer_id
2776    }
2777
2778    /// Get the currently active split ID
2779    pub fn get_active_split_id(&self) -> usize {
2780        let snapshot = self.state_snapshot.read().unwrap();
2781        snapshot.active_split_id
2782    }
2783
2784    /// Get information about a specific buffer
2785    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
2786        let snapshot = self.state_snapshot.read().unwrap();
2787        snapshot.buffers.get(&buffer_id).cloned()
2788    }
2789
2790    /// Get all buffer IDs
2791    pub fn list_buffers(&self) -> Vec<BufferInfo> {
2792        let snapshot = self.state_snapshot.read().unwrap();
2793        snapshot.buffers.values().cloned().collect()
2794    }
2795
2796    /// Get primary cursor information for the active buffer
2797    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
2798        let snapshot = self.state_snapshot.read().unwrap();
2799        snapshot.primary_cursor.clone()
2800    }
2801
2802    /// Get all cursor information for the active buffer
2803    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
2804        let snapshot = self.state_snapshot.read().unwrap();
2805        snapshot.all_cursors.clone()
2806    }
2807
2808    /// Get viewport information for the active buffer
2809    pub fn get_viewport(&self) -> Option<ViewportInfo> {
2810        let snapshot = self.state_snapshot.read().unwrap();
2811        snapshot.viewport.clone()
2812    }
2813
2814    /// Get access to the state snapshot Arc (for internal use)
2815    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
2816        Arc::clone(&self.state_snapshot)
2817    }
2818}
2819
2820impl Clone for PluginApi {
2821    fn clone(&self) -> Self {
2822        Self {
2823            hooks: Arc::clone(&self.hooks),
2824            commands: Arc::clone(&self.commands),
2825            command_sender: self.command_sender.clone(),
2826            state_snapshot: Arc::clone(&self.state_snapshot),
2827        }
2828    }
2829}
2830
2831// ============================================================================
2832// Pluggable Completion Service — TypeScript Plugin API Types
2833// ============================================================================
2834//
2835// These types are the bridge between the Rust `CompletionService` and
2836// TypeScript plugins that want to provide completion candidates.  They are
2837// serialised to/from JSON via serde and generate TypeScript definitions via
2838// ts-rs so that the plugin API stays in sync automatically.
2839
2840/// A completion candidate produced by a TypeScript plugin provider.
2841///
2842/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
2843/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
2844#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2845#[serde(rename_all = "camelCase", deny_unknown_fields)]
2846#[ts(export, rename_all = "camelCase")]
2847pub struct TsCompletionCandidate {
2848    /// Display text shown in the completion popup.
2849    pub label: String,
2850
2851    /// Text to insert when accepted. Falls back to `label` if omitted.
2852    #[serde(skip_serializing_if = "Option::is_none")]
2853    pub insert_text: Option<String>,
2854
2855    /// Short detail string shown next to the label.
2856    #[serde(skip_serializing_if = "Option::is_none")]
2857    pub detail: Option<String>,
2858
2859    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
2860    #[serde(skip_serializing_if = "Option::is_none")]
2861    pub icon: Option<String>,
2862
2863    /// Provider-assigned relevance score (higher = better).
2864    #[serde(default)]
2865    pub score: i64,
2866
2867    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
2868    #[serde(default)]
2869    pub is_snippet: bool,
2870
2871    /// Opaque data carried through to the `completionAccepted` hook.
2872    #[serde(skip_serializing_if = "Option::is_none")]
2873    pub provider_data: Option<String>,
2874}
2875
2876/// Context sent to a TypeScript plugin's `provideCompletions` handler.
2877///
2878/// Plugins receive this as a read-only snapshot so they never need direct
2879/// buffer access (which would be unsafe for huge files).
2880#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2881#[serde(rename_all = "camelCase")]
2882#[ts(export, rename_all = "camelCase")]
2883pub struct TsCompletionContext {
2884    /// The word prefix typed so far.
2885    pub prefix: String,
2886
2887    /// Byte offset of the cursor.
2888    pub cursor_byte: usize,
2889
2890    /// Byte offset of the word start (for replacement range).
2891    pub word_start_byte: usize,
2892
2893    /// Total buffer size in bytes.
2894    pub buffer_len: usize,
2895
2896    /// Whether the buffer is a lazily-loaded huge file.
2897    pub is_large_file: bool,
2898
2899    /// A text excerpt around the cursor (the contents of the safe scan window).
2900    /// Plugins should search only this string, not request the full buffer.
2901    pub text_around_cursor: String,
2902
2903    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
2904    pub cursor_offset_in_text: usize,
2905
2906    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
2907    #[serde(skip_serializing_if = "Option::is_none")]
2908    pub language_id: Option<String>,
2909}
2910
2911/// Registration payload sent by a plugin to register a completion provider.
2912#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2913#[serde(rename_all = "camelCase", deny_unknown_fields)]
2914#[ts(export, rename_all = "camelCase")]
2915pub struct TsCompletionProviderRegistration {
2916    /// Unique id for this provider (e.g., `"my-snippets"`).
2917    pub id: String,
2918
2919    /// Human-readable name shown in status/debug UI.
2920    pub display_name: String,
2921
2922    /// Priority tier (lower = higher priority). Convention:
2923    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
2924    #[serde(default = "default_plugin_provider_priority")]
2925    pub priority: u32,
2926
2927    /// Optional list of language ids this provider is active for.
2928    /// If empty/omitted, the provider is active for all languages.
2929    #[serde(default)]
2930    pub language_ids: Vec<String>,
2931}
2932
2933fn default_plugin_provider_priority() -> u32 {
2934    50
2935}
2936
2937#[cfg(test)]
2938mod tests {
2939    use super::*;
2940
2941    #[test]
2942    fn test_plugin_api_creation() {
2943        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2944        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2945        let (tx, _rx) = std::sync::mpsc::channel();
2946        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2947
2948        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2949
2950        // Should not panic
2951        let _clone = api.clone();
2952    }
2953
2954    #[test]
2955    fn test_register_hook() {
2956        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2957        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2958        let (tx, _rx) = std::sync::mpsc::channel();
2959        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2960
2961        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
2962
2963        api.register_hook("test-hook", Box::new(|_| true));
2964
2965        let hook_registry = hooks.read().unwrap();
2966        assert_eq!(hook_registry.hook_count("test-hook"), 1);
2967    }
2968
2969    #[test]
2970    fn test_send_command() {
2971        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2972        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2973        let (tx, rx) = std::sync::mpsc::channel();
2974        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2975
2976        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2977
2978        let result = api.insert_text(BufferId(1), 0, "test".to_string());
2979        assert!(result.is_ok());
2980
2981        // Verify command was sent
2982        let received = rx.try_recv();
2983        assert!(received.is_ok());
2984
2985        match received.unwrap() {
2986            PluginCommand::InsertText {
2987                buffer_id,
2988                position,
2989                text,
2990            } => {
2991                assert_eq!(buffer_id.0, 1);
2992                assert_eq!(position, 0);
2993                assert_eq!(text, "test");
2994            }
2995            _ => panic!("Wrong command type"),
2996        }
2997    }
2998
2999    #[test]
3000    fn test_add_overlay_command() {
3001        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3002        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3003        let (tx, rx) = std::sync::mpsc::channel();
3004        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3005
3006        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3007
3008        let result = api.add_overlay(
3009            BufferId(1),
3010            Some("test-overlay".to_string()),
3011            0..10,
3012            OverlayOptions {
3013                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3014                bg: None,
3015                underline: true,
3016                bold: false,
3017                italic: false,
3018                strikethrough: false,
3019                extend_to_line_end: false,
3020                url: None,
3021            },
3022        );
3023        assert!(result.is_ok());
3024
3025        let received = rx.try_recv().unwrap();
3026        match received {
3027            PluginCommand::AddOverlay {
3028                buffer_id,
3029                namespace,
3030                range,
3031                options,
3032            } => {
3033                assert_eq!(buffer_id.0, 1);
3034                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3035                assert_eq!(range, 0..10);
3036                assert!(matches!(
3037                    options.fg,
3038                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3039                ));
3040                assert!(options.bg.is_none());
3041                assert!(options.underline);
3042                assert!(!options.bold);
3043                assert!(!options.italic);
3044                assert!(!options.extend_to_line_end);
3045            }
3046            _ => panic!("Wrong command type"),
3047        }
3048    }
3049
3050    #[test]
3051    fn test_set_status_command() {
3052        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3053        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3054        let (tx, rx) = std::sync::mpsc::channel();
3055        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3056
3057        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3058
3059        let result = api.set_status("Test status".to_string());
3060        assert!(result.is_ok());
3061
3062        let received = rx.try_recv().unwrap();
3063        match received {
3064            PluginCommand::SetStatus { message } => {
3065                assert_eq!(message, "Test status");
3066            }
3067            _ => panic!("Wrong command type"),
3068        }
3069    }
3070
3071    #[test]
3072    fn test_get_active_buffer_id() {
3073        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3074        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3075        let (tx, _rx) = std::sync::mpsc::channel();
3076        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3077
3078        // Set active buffer to 5
3079        {
3080            let mut snapshot = state_snapshot.write().unwrap();
3081            snapshot.active_buffer_id = BufferId(5);
3082        }
3083
3084        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3085
3086        let active_id = api.get_active_buffer_id();
3087        assert_eq!(active_id.0, 5);
3088    }
3089
3090    #[test]
3091    fn test_get_buffer_info() {
3092        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3093        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3094        let (tx, _rx) = std::sync::mpsc::channel();
3095        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3096
3097        // Add buffer info
3098        {
3099            let mut snapshot = state_snapshot.write().unwrap();
3100            let buffer_info = BufferInfo {
3101                id: BufferId(1),
3102                path: Some(std::path::PathBuf::from("/test/file.txt")),
3103                modified: true,
3104                length: 100,
3105                is_virtual: false,
3106                view_mode: "source".to_string(),
3107                is_composing_in_any_split: false,
3108                compose_width: None,
3109                language: "text".to_string(),
3110            };
3111            snapshot.buffers.insert(BufferId(1), buffer_info);
3112        }
3113
3114        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3115
3116        let info = api.get_buffer_info(BufferId(1));
3117        assert!(info.is_some());
3118        let info = info.unwrap();
3119        assert_eq!(info.id.0, 1);
3120        assert_eq!(
3121            info.path.as_ref().unwrap().to_str().unwrap(),
3122            "/test/file.txt"
3123        );
3124        assert!(info.modified);
3125        assert_eq!(info.length, 100);
3126
3127        // Non-existent buffer
3128        let no_info = api.get_buffer_info(BufferId(999));
3129        assert!(no_info.is_none());
3130    }
3131
3132    #[test]
3133    fn test_list_buffers() {
3134        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3135        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3136        let (tx, _rx) = std::sync::mpsc::channel();
3137        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3138
3139        // Add multiple buffers
3140        {
3141            let mut snapshot = state_snapshot.write().unwrap();
3142            snapshot.buffers.insert(
3143                BufferId(1),
3144                BufferInfo {
3145                    id: BufferId(1),
3146                    path: Some(std::path::PathBuf::from("/file1.txt")),
3147                    modified: false,
3148                    length: 50,
3149                    is_virtual: false,
3150                    view_mode: "source".to_string(),
3151                    is_composing_in_any_split: false,
3152                    compose_width: None,
3153                    language: "text".to_string(),
3154                },
3155            );
3156            snapshot.buffers.insert(
3157                BufferId(2),
3158                BufferInfo {
3159                    id: BufferId(2),
3160                    path: Some(std::path::PathBuf::from("/file2.txt")),
3161                    modified: true,
3162                    length: 100,
3163                    is_virtual: false,
3164                    view_mode: "source".to_string(),
3165                    is_composing_in_any_split: false,
3166                    compose_width: None,
3167                    language: "text".to_string(),
3168                },
3169            );
3170            snapshot.buffers.insert(
3171                BufferId(3),
3172                BufferInfo {
3173                    id: BufferId(3),
3174                    path: None,
3175                    modified: false,
3176                    length: 0,
3177                    is_virtual: true,
3178                    view_mode: "source".to_string(),
3179                    is_composing_in_any_split: false,
3180                    compose_width: None,
3181                    language: "text".to_string(),
3182                },
3183            );
3184        }
3185
3186        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3187
3188        let buffers = api.list_buffers();
3189        assert_eq!(buffers.len(), 3);
3190
3191        // Verify all buffers are present
3192        assert!(buffers.iter().any(|b| b.id.0 == 1));
3193        assert!(buffers.iter().any(|b| b.id.0 == 2));
3194        assert!(buffers.iter().any(|b| b.id.0 == 3));
3195    }
3196
3197    #[test]
3198    fn test_get_primary_cursor() {
3199        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3200        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3201        let (tx, _rx) = std::sync::mpsc::channel();
3202        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3203
3204        // Add cursor info
3205        {
3206            let mut snapshot = state_snapshot.write().unwrap();
3207            snapshot.primary_cursor = Some(CursorInfo {
3208                position: 42,
3209                selection: Some(10..42),
3210            });
3211        }
3212
3213        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3214
3215        let cursor = api.get_primary_cursor();
3216        assert!(cursor.is_some());
3217        let cursor = cursor.unwrap();
3218        assert_eq!(cursor.position, 42);
3219        assert_eq!(cursor.selection, Some(10..42));
3220    }
3221
3222    #[test]
3223    fn test_get_all_cursors() {
3224        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3225        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3226        let (tx, _rx) = std::sync::mpsc::channel();
3227        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3228
3229        // Add multiple cursors
3230        {
3231            let mut snapshot = state_snapshot.write().unwrap();
3232            snapshot.all_cursors = vec![
3233                CursorInfo {
3234                    position: 10,
3235                    selection: None,
3236                },
3237                CursorInfo {
3238                    position: 20,
3239                    selection: Some(15..20),
3240                },
3241                CursorInfo {
3242                    position: 30,
3243                    selection: Some(25..30),
3244                },
3245            ];
3246        }
3247
3248        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3249
3250        let cursors = api.get_all_cursors();
3251        assert_eq!(cursors.len(), 3);
3252        assert_eq!(cursors[0].position, 10);
3253        assert_eq!(cursors[0].selection, None);
3254        assert_eq!(cursors[1].position, 20);
3255        assert_eq!(cursors[1].selection, Some(15..20));
3256        assert_eq!(cursors[2].position, 30);
3257        assert_eq!(cursors[2].selection, Some(25..30));
3258    }
3259
3260    #[test]
3261    fn test_get_viewport() {
3262        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3263        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3264        let (tx, _rx) = std::sync::mpsc::channel();
3265        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3266
3267        // Add viewport info
3268        {
3269            let mut snapshot = state_snapshot.write().unwrap();
3270            snapshot.viewport = Some(ViewportInfo {
3271                top_byte: 100,
3272                top_line: Some(5),
3273                left_column: 5,
3274                width: 80,
3275                height: 24,
3276            });
3277        }
3278
3279        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3280
3281        let viewport = api.get_viewport();
3282        assert!(viewport.is_some());
3283        let viewport = viewport.unwrap();
3284        assert_eq!(viewport.top_byte, 100);
3285        assert_eq!(viewport.left_column, 5);
3286        assert_eq!(viewport.width, 80);
3287        assert_eq!(viewport.height, 24);
3288    }
3289
3290    #[test]
3291    fn test_composite_buffer_options_rejects_unknown_fields() {
3292        // Valid JSON with correct field names
3293        let valid_json = r#"{
3294            "name": "test",
3295            "mode": "diff",
3296            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3297            "sources": [{"bufferId": 1, "label": "old"}]
3298        }"#;
3299        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3300        assert!(
3301            result.is_ok(),
3302            "Valid JSON should parse: {:?}",
3303            result.err()
3304        );
3305
3306        // Invalid JSON with unknown field (buffer_id instead of bufferId)
3307        let invalid_json = r#"{
3308            "name": "test",
3309            "mode": "diff",
3310            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3311            "sources": [{"buffer_id": 1, "label": "old"}]
3312        }"#;
3313        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3314        assert!(
3315            result.is_err(),
3316            "JSON with unknown field should fail to parse"
3317        );
3318        let err = result.unwrap_err().to_string();
3319        assert!(
3320            err.contains("unknown field") || err.contains("buffer_id"),
3321            "Error should mention unknown field: {}",
3322            err
3323        );
3324    }
3325
3326    #[test]
3327    fn test_composite_hunk_rejects_unknown_fields() {
3328        // Valid JSON with correct field names
3329        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3330        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3331        assert!(
3332            result.is_ok(),
3333            "Valid JSON should parse: {:?}",
3334            result.err()
3335        );
3336
3337        // Invalid JSON with unknown field (old_start instead of oldStart)
3338        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3339        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3340        assert!(
3341            result.is_err(),
3342            "JSON with unknown field should fail to parse"
3343        );
3344        let err = result.unwrap_err().to_string();
3345        assert!(
3346            err.contains("unknown field") || err.contains("old_start"),
3347            "Error should mention unknown field: {}",
3348            err
3349        );
3350    }
3351
3352    #[test]
3353    fn test_plugin_response_line_end_position() {
3354        let response = PluginResponse::LineEndPosition {
3355            request_id: 42,
3356            position: Some(100),
3357        };
3358        let json = serde_json::to_string(&response).unwrap();
3359        assert!(json.contains("LineEndPosition"));
3360        assert!(json.contains("42"));
3361        assert!(json.contains("100"));
3362
3363        // Test None case
3364        let response_none = PluginResponse::LineEndPosition {
3365            request_id: 1,
3366            position: None,
3367        };
3368        let json_none = serde_json::to_string(&response_none).unwrap();
3369        assert!(json_none.contains("null"));
3370    }
3371
3372    #[test]
3373    fn test_plugin_response_buffer_line_count() {
3374        let response = PluginResponse::BufferLineCount {
3375            request_id: 99,
3376            count: Some(500),
3377        };
3378        let json = serde_json::to_string(&response).unwrap();
3379        assert!(json.contains("BufferLineCount"));
3380        assert!(json.contains("99"));
3381        assert!(json.contains("500"));
3382    }
3383
3384    #[test]
3385    fn test_plugin_command_get_line_end_position() {
3386        let command = PluginCommand::GetLineEndPosition {
3387            buffer_id: BufferId(1),
3388            line: 10,
3389            request_id: 123,
3390        };
3391        let json = serde_json::to_string(&command).unwrap();
3392        assert!(json.contains("GetLineEndPosition"));
3393        assert!(json.contains("10"));
3394    }
3395
3396    #[test]
3397    fn test_plugin_command_get_buffer_line_count() {
3398        let command = PluginCommand::GetBufferLineCount {
3399            buffer_id: BufferId(0),
3400            request_id: 456,
3401        };
3402        let json = serde_json::to_string(&command).unwrap();
3403        assert!(json.contains("GetBufferLineCount"));
3404        assert!(json.contains("456"));
3405    }
3406
3407    #[test]
3408    fn test_plugin_command_scroll_to_line_center() {
3409        let command = PluginCommand::ScrollToLineCenter {
3410            split_id: SplitId(1),
3411            buffer_id: BufferId(2),
3412            line: 50,
3413        };
3414        let json = serde_json::to_string(&command).unwrap();
3415        assert!(json.contains("ScrollToLineCenter"));
3416        assert!(json.contains("50"));
3417    }
3418}