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