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
271/// Information about a cursor in the editor
272#[derive(Debug, Clone, Serialize, Deserialize, TS)]
273#[ts(export)]
274pub struct CursorInfo {
275    /// Byte position of the cursor
276    pub position: usize,
277    /// Selection range (if any)
278    #[cfg_attr(
279        feature = "plugins",
280        ts(type = "{ start: number; end: number } | null")
281    )]
282    pub selection: Option<Range<usize>>,
283}
284
285/// Specification for an action to execute, with optional repeat count
286#[derive(Debug, Clone, Serialize, Deserialize, TS)]
287#[serde(deny_unknown_fields)]
288#[ts(export)]
289pub struct ActionSpec {
290    /// Action name (e.g., "move_word_right", "delete_line")
291    pub action: String,
292    /// Number of times to repeat the action (default 1)
293    #[serde(default = "default_action_count")]
294    pub count: u32,
295}
296
297fn default_action_count() -> u32 {
298    1
299}
300
301/// Information about a buffer
302#[derive(Debug, Clone, Serialize, Deserialize, TS)]
303#[ts(export)]
304pub struct BufferInfo {
305    /// Buffer ID
306    #[ts(type = "number")]
307    pub id: BufferId,
308    /// File path (if any)
309    #[serde(serialize_with = "serialize_path")]
310    #[ts(type = "string")]
311    pub path: Option<PathBuf>,
312    /// Whether the buffer has been modified
313    pub modified: bool,
314    /// Length of buffer in bytes
315    pub length: usize,
316    /// Whether this is a virtual buffer (not backed by a file)
317    pub is_virtual: bool,
318    /// Current view mode of the active split: "source" or "compose"
319    pub view_mode: String,
320    /// True if any split showing this buffer has compose mode enabled.
321    /// Plugins should use this (not `view_mode`) to decide whether to maintain
322    /// decorations, since decorations live on the buffer and are filtered
323    /// per-split at render time.
324    pub is_composing_in_any_split: bool,
325    /// Compose width (if set), from the active split's view state
326    pub compose_width: Option<u16>,
327    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
328    pub language: String,
329}
330
331fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
332    s.serialize_str(
333        &path
334            .as_ref()
335            .map(|p| p.to_string_lossy().to_string())
336            .unwrap_or_default(),
337    )
338}
339
340/// Serialize ranges as [start, end] tuples for JS compatibility
341fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
342where
343    S: serde::Serializer,
344{
345    use serde::ser::SerializeSeq;
346    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
347    for range in ranges {
348        seq.serialize_element(&(range.start, range.end))?;
349    }
350    seq.end()
351}
352
353/// Serialize optional ranges as [start, end] tuples for JS compatibility
354fn serialize_opt_ranges_as_tuples<S>(
355    ranges: &Option<Vec<Range<usize>>>,
356    serializer: S,
357) -> Result<S::Ok, S::Error>
358where
359    S: serde::Serializer,
360{
361    match ranges {
362        Some(ranges) => {
363            use serde::ser::SerializeSeq;
364            let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
365            for range in ranges {
366                seq.serialize_element(&(range.start, range.end))?;
367            }
368            seq.end()
369        }
370        None => serializer.serialize_none(),
371    }
372}
373
374/// Diff between current buffer content and last saved snapshot
375#[derive(Debug, Clone, Serialize, Deserialize, TS)]
376#[ts(export)]
377pub struct BufferSavedDiff {
378    pub equal: bool,
379    #[serde(serialize_with = "serialize_ranges_as_tuples")]
380    #[ts(type = "Array<[number, number]>")]
381    pub byte_ranges: Vec<Range<usize>>,
382    #[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
383    #[ts(type = "Array<[number, number]> | null")]
384    pub line_ranges: Option<Vec<Range<usize>>>,
385}
386
387/// Information about the viewport
388#[derive(Debug, Clone, Serialize, Deserialize, TS)]
389#[serde(rename_all = "camelCase")]
390#[ts(export, rename_all = "camelCase")]
391pub struct ViewportInfo {
392    /// Byte position of the first visible line
393    pub top_byte: usize,
394    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
395    pub top_line: Option<usize>,
396    /// Left column offset (horizontal scroll)
397    pub left_column: usize,
398    /// Viewport width
399    pub width: u16,
400    /// Viewport height
401    pub height: u16,
402}
403
404/// Layout hints supplied by plugins (e.g., Compose mode)
405#[derive(Debug, Clone, Serialize, Deserialize, TS)]
406#[serde(rename_all = "camelCase")]
407#[ts(export, rename_all = "camelCase")]
408pub struct LayoutHints {
409    /// Optional compose width for centering/wrapping
410    pub compose_width: Option<u16>,
411    /// Optional column guides for aligned tables
412    pub column_guides: Option<Vec<u16>>,
413}
414
415// ============================================================================
416// Overlay Types with Theme Support
417// ============================================================================
418
419/// Color specification that can be either RGB values or a theme key.
420///
421/// Theme keys reference colors from the current theme, e.g.:
422/// - "ui.status_bar_bg" - UI status bar background
423/// - "editor.selection_bg" - Editor selection background
424/// - "syntax.keyword" - Syntax highlighting for keywords
425/// - "diagnostic.error" - Error diagnostic color
426///
427/// When a theme key is used, the color is resolved at render time,
428/// so overlays automatically update when the theme changes.
429#[derive(Debug, Clone, Serialize, Deserialize, TS)]
430#[serde(untagged)]
431#[ts(export)]
432pub enum OverlayColorSpec {
433    /// RGB color as [r, g, b] array
434    #[ts(type = "[number, number, number]")]
435    Rgb(u8, u8, u8),
436    /// Theme key reference (e.g., "ui.status_bar_bg")
437    ThemeKey(String),
438}
439
440impl OverlayColorSpec {
441    /// Create an RGB color spec
442    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
443        Self::Rgb(r, g, b)
444    }
445
446    /// Create a theme key color spec
447    pub fn theme_key(key: impl Into<String>) -> Self {
448        Self::ThemeKey(key.into())
449    }
450
451    /// Convert to RGB if this is an RGB spec, None if it's a theme key
452    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
453        match self {
454            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
455            Self::ThemeKey(_) => None,
456        }
457    }
458
459    /// Get the theme key if this is a theme key spec
460    pub fn as_theme_key(&self) -> Option<&str> {
461        match self {
462            Self::ThemeKey(key) => Some(key),
463            Self::Rgb(_, _, _) => None,
464        }
465    }
466}
467
468/// Options for adding an overlay with theme support.
469///
470/// This struct provides a type-safe way to specify overlay styling
471/// with optional theme key references for colors.
472#[derive(Debug, Clone, Serialize, Deserialize, TS)]
473#[serde(deny_unknown_fields, rename_all = "camelCase")]
474#[ts(export, rename_all = "camelCase")]
475#[derive(Default)]
476pub struct OverlayOptions {
477    /// Foreground color - RGB array or theme key string
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub fg: Option<OverlayColorSpec>,
480
481    /// Background color - RGB array or theme key string
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub bg: Option<OverlayColorSpec>,
484
485    /// Whether to render with underline
486    #[serde(default)]
487    pub underline: bool,
488
489    /// Whether to render in bold
490    #[serde(default)]
491    pub bold: bool,
492
493    /// Whether to render in italic
494    #[serde(default)]
495    pub italic: bool,
496
497    /// Whether to render with strikethrough
498    #[serde(default)]
499    pub strikethrough: bool,
500
501    /// Whether to extend background color to end of line
502    #[serde(default)]
503    pub extend_to_line_end: bool,
504
505    /// Optional URL for OSC 8 terminal hyperlinks.
506    /// When set, the overlay text becomes a clickable hyperlink in terminals
507    /// that support OSC 8 escape sequences.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub url: Option<String>,
510}
511
512// ============================================================================
513// Composite Buffer Configuration (for multi-buffer single-tab views)
514// ============================================================================
515
516/// Layout configuration for composite buffers
517#[derive(Debug, Clone, Serialize, Deserialize, TS)]
518#[serde(deny_unknown_fields)]
519#[ts(export, rename = "TsCompositeLayoutConfig")]
520pub struct CompositeLayoutConfig {
521    /// Layout type: "side-by-side", "stacked", or "unified"
522    #[serde(rename = "type")]
523    #[ts(rename = "type")]
524    pub layout_type: String,
525    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
526    #[serde(default)]
527    pub ratios: Option<Vec<f32>>,
528    /// Show separator between panes
529    #[serde(default = "default_true", rename = "showSeparator")]
530    #[ts(rename = "showSeparator")]
531    pub show_separator: bool,
532    /// Spacing for stacked layout
533    #[serde(default)]
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(rename = "addBg", type = "[number, number, number] | null")]
569    pub add_bg: Option<[u8; 3]>,
570    /// Background color for removed lines (RGB)
571    #[serde(default, rename = "removeBg")]
572    #[ts(rename = "removeBg", type = "[number, number, number] | null")]
573    pub remove_bg: Option<[u8; 3]>,
574    /// Background color for modified lines (RGB)
575    #[serde(default, rename = "modifyBg")]
576    #[ts(rename = "modifyBg", type = "[number, number, number] | null")]
577    pub modify_bg: Option<[u8; 3]>,
578    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
579    #[serde(default, rename = "gutterStyle")]
580    #[ts(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    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
743    /// When set, this mode's keybindings take precedence over normal key handling
744    pub editor_mode: Option<String>,
745
746    /// Plugin-managed per-buffer view state for the active split.
747    /// Updated from BufferViewState.plugin_state during snapshot updates.
748    /// Also written directly by JS plugins via setViewState for immediate read-back.
749    #[ts(type = "any")]
750    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
751
752    /// Tracks which split was active when plugin_view_states was last populated.
753    /// When the active split changes, plugin_view_states is fully repopulated.
754    #[serde(skip)]
755    #[ts(skip)]
756    pub plugin_view_states_split: usize,
757}
758
759impl EditorStateSnapshot {
760    pub fn new() -> Self {
761        Self {
762            active_buffer_id: BufferId(0),
763            active_split_id: 0,
764            buffers: HashMap::new(),
765            buffer_saved_diffs: HashMap::new(),
766            primary_cursor: None,
767            all_cursors: Vec::new(),
768            viewport: None,
769            buffer_cursor_positions: HashMap::new(),
770            buffer_text_properties: HashMap::new(),
771            selected_text: None,
772            clipboard: String::new(),
773            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
774            diagnostics: HashMap::new(),
775            folding_ranges: HashMap::new(),
776            config: serde_json::Value::Null,
777            user_config: serde_json::Value::Null,
778            editor_mode: None,
779            plugin_view_states: HashMap::new(),
780            plugin_view_states_split: 0,
781        }
782    }
783}
784
785impl Default for EditorStateSnapshot {
786    fn default() -> Self {
787        Self::new()
788    }
789}
790
791/// Position for inserting menu items or menus
792#[derive(Debug, Clone, Serialize, Deserialize, TS)]
793#[ts(export)]
794pub enum MenuPosition {
795    /// Add at the beginning
796    Top,
797    /// Add at the end
798    Bottom,
799    /// Add before a specific label
800    Before(String),
801    /// Add after a specific label
802    After(String),
803}
804
805/// Plugin command - allows plugins to send commands to the editor
806#[derive(Debug, Clone, Serialize, Deserialize, TS)]
807#[ts(export)]
808pub enum PluginCommand {
809    /// Insert text at a position in a buffer
810    InsertText {
811        buffer_id: BufferId,
812        position: usize,
813        text: String,
814    },
815
816    /// Delete a range of text from a buffer
817    DeleteRange {
818        buffer_id: BufferId,
819        range: Range<usize>,
820    },
821
822    /// Add an overlay to a buffer, returns handle via response channel
823    ///
824    /// Colors can be specified as RGB tuples or theme keys. When theme keys
825    /// are provided, they take precedence and are resolved at render time.
826    AddOverlay {
827        buffer_id: BufferId,
828        namespace: Option<OverlayNamespace>,
829        range: Range<usize>,
830        /// Overlay styling options (colors, modifiers, etc.)
831        options: OverlayOptions,
832    },
833
834    /// Remove an overlay by its opaque handle
835    RemoveOverlay {
836        buffer_id: BufferId,
837        handle: OverlayHandle,
838    },
839
840    /// Set status message
841    SetStatus { message: String },
842
843    /// Apply a theme by name
844    ApplyTheme { theme_name: String },
845
846    /// Reload configuration from file
847    /// After a plugin saves config changes, it should call this to reload the config
848    ReloadConfig,
849
850    /// Register a custom command
851    RegisterCommand { command: Command },
852
853    /// Unregister a command by name
854    UnregisterCommand { name: String },
855
856    /// Open a file in the editor (in background, without switching focus)
857    OpenFileInBackground { path: PathBuf },
858
859    /// Insert text at the current cursor position in the active buffer
860    InsertAtCursor { text: String },
861
862    /// Spawn an async process
863    SpawnProcess {
864        command: String,
865        args: Vec<String>,
866        cwd: Option<String>,
867        callback_id: JsCallbackId,
868    },
869
870    /// Delay/sleep for a duration (async, resolves callback when done)
871    Delay {
872        callback_id: JsCallbackId,
873        duration_ms: u64,
874    },
875
876    /// Spawn a long-running background process
877    /// Unlike SpawnProcess, this returns immediately with a process handle
878    /// and provides streaming output via hooks
879    SpawnBackgroundProcess {
880        /// Unique ID for this process (generated by plugin runtime)
881        process_id: u64,
882        /// Command to execute
883        command: String,
884        /// Arguments to pass
885        args: Vec<String>,
886        /// Working directory (optional)
887        cwd: Option<String>,
888        /// Callback ID to call when process exits
889        callback_id: JsCallbackId,
890    },
891
892    /// Kill a background process by ID
893    KillBackgroundProcess { process_id: u64 },
894
895    /// Wait for a process to complete and get its result
896    /// Used with processes started via SpawnProcess
897    SpawnProcessWait {
898        /// Process ID to wait for
899        process_id: u64,
900        /// Callback ID for async response
901        callback_id: JsCallbackId,
902    },
903
904    /// Set layout hints for a buffer/viewport
905    SetLayoutHints {
906        buffer_id: BufferId,
907        split_id: Option<SplitId>,
908        range: Range<usize>,
909        hints: LayoutHints,
910    },
911
912    /// Enable/disable line numbers for a buffer
913    SetLineNumbers { buffer_id: BufferId, enabled: bool },
914
915    /// Set the view mode for a buffer ("source" or "compose")
916    SetViewMode { buffer_id: BufferId, mode: String },
917
918    /// Enable/disable line wrapping for a buffer
919    SetLineWrap {
920        buffer_id: BufferId,
921        split_id: Option<SplitId>,
922        enabled: bool,
923    },
924
925    /// Submit a transformed view stream for a viewport
926    SubmitViewTransform {
927        buffer_id: BufferId,
928        split_id: Option<SplitId>,
929        payload: ViewTransformPayload,
930    },
931
932    /// Clear view transform for a buffer/split (returns to normal rendering)
933    ClearViewTransform {
934        buffer_id: BufferId,
935        split_id: Option<SplitId>,
936    },
937
938    /// Set plugin-managed view state for a buffer in the active split.
939    /// Stored in BufferViewState.plugin_state and persisted across sessions.
940    SetViewState {
941        buffer_id: BufferId,
942        key: String,
943        #[ts(type = "any")]
944        value: Option<serde_json::Value>,
945    },
946
947    /// Remove all overlays from a buffer
948    ClearAllOverlays { buffer_id: BufferId },
949
950    /// Remove all overlays in a namespace
951    ClearNamespace {
952        buffer_id: BufferId,
953        namespace: OverlayNamespace,
954    },
955
956    /// Remove all overlays that overlap with a byte range
957    /// Used for targeted invalidation when content in a range changes
958    ClearOverlaysInRange {
959        buffer_id: BufferId,
960        start: usize,
961        end: usize,
962    },
963
964    /// Add virtual text (inline text that doesn't exist in the buffer)
965    /// Used for color swatches, type hints, parameter hints, etc.
966    AddVirtualText {
967        buffer_id: BufferId,
968        virtual_text_id: String,
969        position: usize,
970        text: String,
971        color: (u8, u8, u8),
972        use_bg: bool, // true = use color as background, false = use as foreground
973        before: bool, // true = before char, false = after char
974    },
975
976    /// Remove a virtual text by ID
977    RemoveVirtualText {
978        buffer_id: BufferId,
979        virtual_text_id: String,
980    },
981
982    /// Remove virtual texts whose ID starts with the given prefix
983    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
984
985    /// Clear all virtual texts from a buffer
986    ClearVirtualTexts { buffer_id: BufferId },
987
988    /// Add a virtual LINE (full line above/below a position)
989    /// Used for git blame headers, code coverage, inline documentation, etc.
990    /// These lines do NOT show line numbers in the gutter.
991    AddVirtualLine {
992        buffer_id: BufferId,
993        /// Byte position to anchor the line to
994        position: usize,
995        /// Full line content to display
996        text: String,
997        /// Foreground color (RGB)
998        fg_color: (u8, u8, u8),
999        /// Background color (RGB), None = transparent
1000        bg_color: Option<(u8, u8, u8)>,
1001        /// true = above the line containing position, false = below
1002        above: bool,
1003        /// Namespace for bulk removal (e.g., "git-blame")
1004        namespace: String,
1005        /// Priority for ordering multiple lines at same position (higher = later)
1006        priority: i32,
1007    },
1008
1009    /// Clear all virtual texts in a namespace
1010    /// This is the primary way to remove a plugin's virtual lines before updating them.
1011    ClearVirtualTextNamespace {
1012        buffer_id: BufferId,
1013        namespace: String,
1014    },
1015
1016    /// Add a conceal range that hides or replaces a byte range during rendering.
1017    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1018    AddConceal {
1019        buffer_id: BufferId,
1020        /// Namespace for bulk removal (shared with overlay namespace system)
1021        namespace: OverlayNamespace,
1022        /// Byte range to conceal
1023        start: usize,
1024        end: usize,
1025        /// Optional replacement text to show instead. None = hide completely.
1026        replacement: Option<String>,
1027    },
1028
1029    /// Clear all conceal ranges in a namespace
1030    ClearConcealNamespace {
1031        buffer_id: BufferId,
1032        namespace: OverlayNamespace,
1033    },
1034
1035    /// Remove all conceal ranges that overlap with a byte range
1036    /// Used for targeted invalidation when content in a range changes
1037    ClearConcealsInRange {
1038        buffer_id: BufferId,
1039        start: usize,
1040        end: usize,
1041    },
1042
1043    /// Add a soft break point for marker-based line wrapping.
1044    /// The break is stored as a marker that auto-adjusts on buffer edits,
1045    /// eliminating the flicker caused by async view_transform round-trips.
1046    AddSoftBreak {
1047        buffer_id: BufferId,
1048        /// Namespace for bulk removal (shared with overlay namespace system)
1049        namespace: OverlayNamespace,
1050        /// Byte offset where the break should be injected
1051        position: usize,
1052        /// Number of hanging indent spaces after the break
1053        indent: u16,
1054    },
1055
1056    /// Clear all soft breaks in a namespace
1057    ClearSoftBreakNamespace {
1058        buffer_id: BufferId,
1059        namespace: OverlayNamespace,
1060    },
1061
1062    /// Remove all soft breaks that fall within a byte range
1063    ClearSoftBreaksInRange {
1064        buffer_id: BufferId,
1065        start: usize,
1066        end: usize,
1067    },
1068
1069    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1070    RefreshLines { buffer_id: BufferId },
1071
1072    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1073    /// Sent when a plugin registers for the lines_changed hook to handle the race
1074    /// where render marks lines as "seen" before the plugin has registered.
1075    RefreshAllLines,
1076
1077    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1078    /// Used by the render loop to wait deterministically for plugin responses
1079    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1080    HookCompleted { hook_name: String },
1081
1082    /// Set a line indicator in the gutter's indicator column
1083    /// Used for git gutter, breakpoints, bookmarks, etc.
1084    SetLineIndicator {
1085        buffer_id: BufferId,
1086        /// Line number (0-indexed)
1087        line: usize,
1088        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1089        namespace: String,
1090        /// Symbol to display (e.g., "│", "●", "★")
1091        symbol: String,
1092        /// Color as RGB tuple
1093        color: (u8, u8, u8),
1094        /// Priority for display when multiple indicators exist (higher wins)
1095        priority: i32,
1096    },
1097
1098    /// Batch set line indicators in the gutter's indicator column
1099    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1100    SetLineIndicators {
1101        buffer_id: BufferId,
1102        /// Line numbers (0-indexed)
1103        lines: Vec<usize>,
1104        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1105        namespace: String,
1106        /// Symbol to display (e.g., "│", "●", "★")
1107        symbol: String,
1108        /// Color as RGB tuple
1109        color: (u8, u8, u8),
1110        /// Priority for display when multiple indicators exist (higher wins)
1111        priority: i32,
1112    },
1113
1114    /// Clear all line indicators for a specific namespace
1115    ClearLineIndicators {
1116        buffer_id: BufferId,
1117        /// Namespace to clear (e.g., "git-gutter")
1118        namespace: String,
1119    },
1120
1121    /// Set file explorer decorations for a namespace
1122    SetFileExplorerDecorations {
1123        /// Namespace for grouping (e.g., "git-status")
1124        namespace: String,
1125        /// Decorations to apply
1126        decorations: Vec<FileExplorerDecoration>,
1127    },
1128
1129    /// Clear file explorer decorations for a namespace
1130    ClearFileExplorerDecorations {
1131        /// Namespace to clear (e.g., "git-status")
1132        namespace: String,
1133    },
1134
1135    /// Open a file at a specific line and column
1136    /// Line and column are 1-indexed to match git grep output
1137    OpenFileAtLocation {
1138        path: PathBuf,
1139        line: Option<usize>,   // 1-indexed, None = go to start
1140        column: Option<usize>, // 1-indexed, None = go to line start
1141    },
1142
1143    /// Open a file in a specific split at a given line and column
1144    /// Line and column are 1-indexed to match git grep output
1145    OpenFileInSplit {
1146        split_id: usize,
1147        path: PathBuf,
1148        line: Option<usize>,   // 1-indexed, None = go to start
1149        column: Option<usize>, // 1-indexed, None = go to line start
1150    },
1151
1152    /// Start a prompt (minibuffer) with a custom type identifier
1153    /// This allows plugins to create interactive prompts
1154    StartPrompt {
1155        label: String,
1156        prompt_type: String, // e.g., "git-grep", "git-find-file"
1157    },
1158
1159    /// Start a prompt with pre-filled initial value
1160    StartPromptWithInitial {
1161        label: String,
1162        prompt_type: String,
1163        initial_value: String,
1164    },
1165
1166    /// Start an async prompt that returns result via callback
1167    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1168    StartPromptAsync {
1169        label: String,
1170        initial_value: String,
1171        callback_id: JsCallbackId,
1172    },
1173
1174    /// Update the suggestions list for the current prompt
1175    /// Uses the editor's Suggestion type
1176    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1177
1178    /// When enabled, navigating suggestions updates the prompt input text
1179    SetPromptInputSync { sync: bool },
1180
1181    /// Add a menu item to an existing menu
1182    /// Add a menu item to an existing menu
1183    AddMenuItem {
1184        menu_label: String,
1185        item: MenuItem,
1186        position: MenuPosition,
1187    },
1188
1189    /// Add a new top-level menu
1190    AddMenu { menu: Menu, position: MenuPosition },
1191
1192    /// Remove a menu item from a menu
1193    RemoveMenuItem {
1194        menu_label: String,
1195        item_label: String,
1196    },
1197
1198    /// Remove a top-level menu
1199    RemoveMenu { menu_label: String },
1200
1201    /// Create a new virtual buffer (not backed by a file)
1202    CreateVirtualBuffer {
1203        /// Display name (e.g., "*Diagnostics*")
1204        name: String,
1205        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1206        mode: String,
1207        /// Whether the buffer is read-only
1208        read_only: bool,
1209    },
1210
1211    /// Create a virtual buffer and set its content in one operation
1212    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1213    /// because it doesn't require tracking the buffer ID
1214    CreateVirtualBufferWithContent {
1215        /// Display name (e.g., "*Diagnostics*")
1216        name: String,
1217        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1218        mode: String,
1219        /// Whether the buffer is read-only
1220        read_only: bool,
1221        /// Entries with text and embedded properties
1222        entries: Vec<TextPropertyEntry>,
1223        /// Whether to show line numbers in the gutter
1224        show_line_numbers: bool,
1225        /// Whether to show cursors in the buffer
1226        show_cursors: bool,
1227        /// Whether editing is disabled (blocks editing commands)
1228        editing_disabled: bool,
1229        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1230        hidden_from_tabs: bool,
1231        /// Optional request ID for async response
1232        request_id: Option<u64>,
1233    },
1234
1235    /// Create a virtual buffer in a horizontal split
1236    /// Opens the buffer in a new pane below the current one
1237    CreateVirtualBufferInSplit {
1238        /// Display name (e.g., "*Diagnostics*")
1239        name: String,
1240        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1241        mode: String,
1242        /// Whether the buffer is read-only
1243        read_only: bool,
1244        /// Entries with text and embedded properties
1245        entries: Vec<TextPropertyEntry>,
1246        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1247        ratio: f32,
1248        /// Split direction ("horizontal" or "vertical"), default horizontal
1249        direction: Option<String>,
1250        /// Optional panel ID for idempotent operations (if panel exists, update content)
1251        panel_id: Option<String>,
1252        /// Whether to show line numbers in the buffer (default true)
1253        show_line_numbers: bool,
1254        /// Whether to show cursors in the buffer (default true)
1255        show_cursors: bool,
1256        /// Whether editing is disabled for this buffer (default false)
1257        editing_disabled: bool,
1258        /// Whether line wrapping is enabled for this split (None = use global setting)
1259        line_wrap: Option<bool>,
1260        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1261        before: bool,
1262        /// Optional request ID for async response (if set, editor will send back buffer ID)
1263        request_id: Option<u64>,
1264    },
1265
1266    /// Set the content of a virtual buffer with text properties
1267    SetVirtualBufferContent {
1268        buffer_id: BufferId,
1269        /// Entries with text and embedded properties
1270        entries: Vec<TextPropertyEntry>,
1271    },
1272
1273    /// Get text properties at the cursor position in a buffer
1274    GetTextPropertiesAtCursor { buffer_id: BufferId },
1275
1276    /// Define a buffer mode with keybindings
1277    DefineMode {
1278        name: String,
1279        parent: Option<String>,
1280        bindings: Vec<(String, String)>, // (key_string, command_name)
1281        read_only: bool,
1282    },
1283
1284    /// Switch the current split to display a buffer
1285    ShowBuffer { buffer_id: BufferId },
1286
1287    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1288    CreateVirtualBufferInExistingSplit {
1289        /// Display name (e.g., "*Commit Details*")
1290        name: String,
1291        /// Mode name for buffer-local keybindings
1292        mode: String,
1293        /// Whether the buffer is read-only
1294        read_only: bool,
1295        /// Entries with text and embedded properties
1296        entries: Vec<TextPropertyEntry>,
1297        /// Target split ID where the buffer should be displayed
1298        split_id: SplitId,
1299        /// Whether to show line numbers in the buffer (default true)
1300        show_line_numbers: bool,
1301        /// Whether to show cursors in the buffer (default true)
1302        show_cursors: bool,
1303        /// Whether editing is disabled for this buffer (default false)
1304        editing_disabled: bool,
1305        /// Whether line wrapping is enabled for this split (None = use global setting)
1306        line_wrap: Option<bool>,
1307        /// Optional request ID for async response
1308        request_id: Option<u64>,
1309    },
1310
1311    /// Close a buffer and remove it from all splits
1312    CloseBuffer { buffer_id: BufferId },
1313
1314    /// Create a composite buffer that displays multiple source buffers
1315    /// Used for side-by-side diff, unified diff, and 3-way merge views
1316    CreateCompositeBuffer {
1317        /// Display name (shown in tab bar)
1318        name: String,
1319        /// Mode name for keybindings (e.g., "diff-view")
1320        mode: String,
1321        /// Layout configuration
1322        layout: CompositeLayoutConfig,
1323        /// Source pane configurations
1324        sources: Vec<CompositeSourceConfig>,
1325        /// Diff hunks for line alignment (optional)
1326        hunks: Option<Vec<CompositeHunk>>,
1327        /// Request ID for async response
1328        request_id: Option<u64>,
1329    },
1330
1331    /// Update alignment for a composite buffer (e.g., after source edit)
1332    UpdateCompositeAlignment {
1333        buffer_id: BufferId,
1334        hunks: Vec<CompositeHunk>,
1335    },
1336
1337    /// Close a composite buffer
1338    CloseCompositeBuffer { buffer_id: BufferId },
1339
1340    /// Focus a specific split
1341    FocusSplit { split_id: SplitId },
1342
1343    /// Set the buffer displayed in a specific split
1344    SetSplitBuffer {
1345        split_id: SplitId,
1346        buffer_id: BufferId,
1347    },
1348
1349    /// Set the scroll position of a specific split
1350    SetSplitScroll { split_id: SplitId, top_byte: usize },
1351
1352    /// Request syntax highlights for a buffer range
1353    RequestHighlights {
1354        buffer_id: BufferId,
1355        range: Range<usize>,
1356        request_id: u64,
1357    },
1358
1359    /// Close a split (if not the last one)
1360    CloseSplit { split_id: SplitId },
1361
1362    /// Set the ratio of a split container
1363    SetSplitRatio {
1364        split_id: SplitId,
1365        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1366        ratio: f32,
1367    },
1368
1369    /// Set a label on a leaf split (e.g., "sidebar")
1370    SetSplitLabel { split_id: SplitId, label: String },
1371
1372    /// Remove a label from a split
1373    ClearSplitLabel { split_id: SplitId },
1374
1375    /// Find a split by its label (async)
1376    GetSplitByLabel { label: String, request_id: u64 },
1377
1378    /// Distribute splits evenly - make all given splits equal size
1379    DistributeSplitsEvenly {
1380        /// Split IDs to distribute evenly
1381        split_ids: Vec<SplitId>,
1382    },
1383
1384    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1385    SetBufferCursor {
1386        buffer_id: BufferId,
1387        /// Byte offset position for the cursor
1388        position: usize,
1389    },
1390
1391    /// Send an arbitrary LSP request and return the raw JSON response
1392    SendLspRequest {
1393        language: String,
1394        method: String,
1395        #[ts(type = "any")]
1396        params: Option<JsonValue>,
1397        request_id: u64,
1398    },
1399
1400    /// Set the internal clipboard content
1401    SetClipboard { text: String },
1402
1403    /// Delete the current selection in the active buffer
1404    /// This deletes all selected text across all cursors
1405    DeleteSelection,
1406
1407    /// Set or unset a custom context
1408    /// Custom contexts are plugin-defined states that can be used to control command visibility
1409    /// For example, "config-editor" context could make config editor commands available
1410    SetContext {
1411        /// Context name (e.g., "config-editor")
1412        name: String,
1413        /// Whether the context is active
1414        active: bool,
1415    },
1416
1417    /// Set the hunks for the Review Diff tool
1418    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1419
1420    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1421    /// Used by vi mode plugin to run motions and calculate cursor ranges
1422    ExecuteAction {
1423        /// Action name (e.g., "move_word_right", "move_line_end")
1424        action_name: String,
1425    },
1426
1427    /// Execute multiple actions in sequence, each with an optional repeat count
1428    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1429    /// All actions execute atomically with no plugin roundtrips between them
1430    ExecuteActions {
1431        /// List of actions to execute in sequence
1432        actions: Vec<ActionSpec>,
1433    },
1434
1435    /// Get text from a buffer range (for yank operations)
1436    GetBufferText {
1437        /// Buffer ID
1438        buffer_id: BufferId,
1439        /// Start byte offset
1440        start: usize,
1441        /// End byte offset
1442        end: usize,
1443        /// Request ID for async response
1444        request_id: u64,
1445    },
1446
1447    /// Get byte offset of the start of a line (async)
1448    /// Line is 0-indexed (0 = first line)
1449    GetLineStartPosition {
1450        /// Buffer ID (0 for active buffer)
1451        buffer_id: BufferId,
1452        /// Line number (0-indexed)
1453        line: u32,
1454        /// Request ID for async response
1455        request_id: u64,
1456    },
1457
1458    /// Get byte offset of the end of a line (async)
1459    /// Line is 0-indexed (0 = first line)
1460    /// Returns the byte offset after the last character of the line (before newline)
1461    GetLineEndPosition {
1462        /// Buffer ID (0 for active buffer)
1463        buffer_id: BufferId,
1464        /// Line number (0-indexed)
1465        line: u32,
1466        /// Request ID for async response
1467        request_id: u64,
1468    },
1469
1470    /// Get the total number of lines in a buffer (async)
1471    GetBufferLineCount {
1472        /// Buffer ID (0 for active buffer)
1473        buffer_id: BufferId,
1474        /// Request ID for async response
1475        request_id: u64,
1476    },
1477
1478    /// Scroll a split to center a specific line in the viewport
1479    /// Line is 0-indexed (0 = first line)
1480    ScrollToLineCenter {
1481        /// Split ID to scroll
1482        split_id: SplitId,
1483        /// Buffer ID containing the line
1484        buffer_id: BufferId,
1485        /// Line number to center (0-indexed)
1486        line: usize,
1487    },
1488
1489    /// Set the global editor mode (for modal editing like vi mode)
1490    /// When set, the mode's keybindings take precedence over normal editing
1491    SetEditorMode {
1492        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1493        mode: Option<String>,
1494    },
1495
1496    /// Show an action popup with buttons for user interaction
1497    /// When the user selects an action, the ActionPopupResult hook is fired
1498    ShowActionPopup {
1499        /// Unique identifier for the popup (used in ActionPopupResult)
1500        popup_id: String,
1501        /// Title text for the popup
1502        title: String,
1503        /// Body message (supports basic formatting)
1504        message: String,
1505        /// Action buttons to display
1506        actions: Vec<ActionPopupAction>,
1507    },
1508
1509    /// Disable LSP for a specific language and persist to config
1510    DisableLspForLanguage {
1511        /// The language to disable LSP for (e.g., "python", "rust")
1512        language: String,
1513    },
1514
1515    /// Set the workspace root URI for a specific language's LSP server
1516    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1517    /// If the LSP is already running, it will be restarted with the new root
1518    SetLspRootUri {
1519        /// The language to set root URI for (e.g., "csharp", "rust")
1520        language: String,
1521        /// The root URI (file:// URL format)
1522        uri: String,
1523    },
1524
1525    /// Create a scroll sync group for anchor-based synchronized scrolling
1526    /// Used for side-by-side diff views where two panes need to scroll together
1527    /// The plugin provides the group ID (must be unique per plugin)
1528    CreateScrollSyncGroup {
1529        /// Plugin-assigned group ID
1530        group_id: u32,
1531        /// The left (primary) split - scroll position is tracked in this split's line space
1532        left_split: SplitId,
1533        /// The right (secondary) split - position is derived from anchors
1534        right_split: SplitId,
1535    },
1536
1537    /// Set sync anchors for a scroll sync group
1538    /// Anchors map corresponding line numbers between left and right buffers
1539    SetScrollSyncAnchors {
1540        /// The group ID returned by CreateScrollSyncGroup
1541        group_id: u32,
1542        /// List of (left_line, right_line) pairs marking corresponding positions
1543        anchors: Vec<(usize, usize)>,
1544    },
1545
1546    /// Remove a scroll sync group
1547    RemoveScrollSyncGroup {
1548        /// The group ID returned by CreateScrollSyncGroup
1549        group_id: u32,
1550    },
1551
1552    /// Save a buffer to a specific file path
1553    /// Used by :w filename command to save unnamed buffers or save-as
1554    SaveBufferToPath {
1555        /// Buffer ID to save
1556        buffer_id: BufferId,
1557        /// Path to save to
1558        path: PathBuf,
1559    },
1560
1561    /// Load a plugin from a file path
1562    /// The plugin will be initialized and start receiving events
1563    LoadPlugin {
1564        /// Path to the plugin file (.ts or .js)
1565        path: PathBuf,
1566        /// Callback ID for async response (success/failure)
1567        callback_id: JsCallbackId,
1568    },
1569
1570    /// Unload a plugin by name
1571    /// The plugin will stop receiving events and be removed from memory
1572    UnloadPlugin {
1573        /// Plugin name (as registered)
1574        name: String,
1575        /// Callback ID for async response (success/failure)
1576        callback_id: JsCallbackId,
1577    },
1578
1579    /// Reload a plugin by name (unload + load)
1580    /// Useful for development when plugin code changes
1581    ReloadPlugin {
1582        /// Plugin name (as registered)
1583        name: String,
1584        /// Callback ID for async response (success/failure)
1585        callback_id: JsCallbackId,
1586    },
1587
1588    /// List all loaded plugins
1589    /// Returns plugin info (name, path, enabled) for all loaded plugins
1590    ListPlugins {
1591        /// Callback ID for async response (JSON array of plugin info)
1592        callback_id: JsCallbackId,
1593    },
1594
1595    /// Reload the theme registry from disk
1596    /// Call this after installing a theme package or saving a new theme
1597    ReloadThemes,
1598
1599    /// Register a TextMate grammar file for a language
1600    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1601    RegisterGrammar {
1602        /// Language identifier (e.g., "elixir", "zig")
1603        language: String,
1604        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
1605        grammar_path: String,
1606        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
1607        extensions: Vec<String>,
1608    },
1609
1610    /// Register language configuration (comment prefix, indentation, formatter)
1611    /// This is applied immediately to the runtime config
1612    RegisterLanguageConfig {
1613        /// Language identifier (e.g., "elixir")
1614        language: String,
1615        /// Language configuration
1616        config: LanguagePackConfig,
1617    },
1618
1619    /// Register an LSP server for a language
1620    /// This is applied immediately to the LSP manager and runtime config
1621    RegisterLspServer {
1622        /// Language identifier (e.g., "elixir")
1623        language: String,
1624        /// LSP server configuration
1625        config: LspServerPackConfig,
1626    },
1627
1628    /// Reload the grammar registry to apply registered grammars
1629    /// Call this after registering one or more grammars to rebuild the syntax set
1630    ReloadGrammars,
1631
1632    // ==================== Terminal Commands ====================
1633    /// Create a new terminal in a split (async, returns TerminalResult)
1634    /// This spawns a PTY-backed terminal that plugins can write to and read from.
1635    CreateTerminal {
1636        /// Working directory for the terminal (defaults to editor cwd)
1637        cwd: Option<String>,
1638        /// Split direction ("horizontal" or "vertical"), default vertical
1639        direction: Option<String>,
1640        /// Split ratio (0.0 to 1.0), default 0.5
1641        ratio: Option<f32>,
1642        /// Whether to focus the new terminal split (default true)
1643        focus: Option<bool>,
1644        /// Callback ID for async response
1645        request_id: u64,
1646    },
1647
1648    /// Send input data to a terminal by its terminal ID
1649    SendTerminalInput {
1650        /// The terminal ID (from TerminalResult)
1651        terminal_id: TerminalId,
1652        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
1653        data: String,
1654    },
1655
1656    /// Close a terminal by its terminal ID
1657    CloseTerminal {
1658        /// The terminal ID to close
1659        terminal_id: TerminalId,
1660    },
1661}
1662
1663impl PluginCommand {
1664    /// Extract the enum variant name from the Debug representation.
1665    pub fn debug_variant_name(&self) -> String {
1666        let dbg = format!("{:?}", self);
1667        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
1668    }
1669}
1670
1671// =============================================================================
1672// Language Pack Configuration Types
1673// =============================================================================
1674
1675/// Language configuration for language packs
1676///
1677/// This is a simplified version of the full LanguageConfig, containing only
1678/// the fields that can be set via the plugin API.
1679#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
1680#[serde(rename_all = "camelCase")]
1681#[ts(export)]
1682pub struct LanguagePackConfig {
1683    /// Comment prefix for line comments (e.g., "//" or "#")
1684    #[serde(default)]
1685    pub comment_prefix: Option<String>,
1686
1687    /// Block comment start marker (e.g., slash-star)
1688    #[serde(default)]
1689    pub block_comment_start: Option<String>,
1690
1691    /// Block comment end marker (e.g., star-slash)
1692    #[serde(default)]
1693    pub block_comment_end: Option<String>,
1694
1695    /// Whether to use tabs instead of spaces for indentation
1696    #[serde(default)]
1697    pub use_tabs: Option<bool>,
1698
1699    /// Tab size (number of spaces per tab level)
1700    #[serde(default)]
1701    pub tab_size: Option<usize>,
1702
1703    /// Whether auto-indent is enabled
1704    #[serde(default)]
1705    pub auto_indent: Option<bool>,
1706
1707    /// Whether to show whitespace tab indicators (→) for this language
1708    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
1709    #[serde(default)]
1710    pub show_whitespace_tabs: Option<bool>,
1711
1712    /// Formatter configuration
1713    #[serde(default)]
1714    pub formatter: Option<FormatterPackConfig>,
1715}
1716
1717/// Formatter configuration for language packs
1718#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1719#[serde(rename_all = "camelCase")]
1720#[ts(export)]
1721pub struct FormatterPackConfig {
1722    /// Command to run (e.g., "prettier", "rustfmt")
1723    pub command: String,
1724
1725    /// Arguments to pass to the formatter
1726    #[serde(default)]
1727    pub args: Vec<String>,
1728}
1729
1730/// LSP server configuration for language packs
1731#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1732#[serde(rename_all = "camelCase")]
1733#[ts(export)]
1734pub struct LspServerPackConfig {
1735    /// Command to start the LSP server
1736    pub command: String,
1737
1738    /// Arguments to pass to the command
1739    #[serde(default)]
1740    pub args: Vec<String>,
1741
1742    /// Whether to auto-start the server when a matching file is opened
1743    #[serde(default)]
1744    pub auto_start: Option<bool>,
1745
1746    /// LSP initialization options
1747    #[serde(default)]
1748    #[ts(type = "Record<string, unknown> | null")]
1749    pub initialization_options: Option<JsonValue>,
1750}
1751
1752/// Hunk status for Review Diff
1753#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1754#[ts(export)]
1755pub enum HunkStatus {
1756    Pending,
1757    Staged,
1758    Discarded,
1759}
1760
1761/// A high-level hunk directive for the Review Diff tool
1762#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1763#[ts(export)]
1764pub struct ReviewHunk {
1765    pub id: String,
1766    pub file: String,
1767    pub context_header: String,
1768    pub status: HunkStatus,
1769    /// 0-indexed line range in the base (HEAD) version
1770    pub base_range: Option<(usize, usize)>,
1771    /// 0-indexed line range in the modified (Working) version
1772    pub modified_range: Option<(usize, usize)>,
1773}
1774
1775/// Action button for action popups
1776#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1777#[serde(deny_unknown_fields)]
1778#[ts(export, rename = "TsActionPopupAction")]
1779pub struct ActionPopupAction {
1780    /// Unique action identifier (returned in ActionPopupResult)
1781    pub id: String,
1782    /// Display text for the button (can include command hints)
1783    pub label: String,
1784}
1785
1786/// Options for showActionPopup
1787#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1788#[serde(deny_unknown_fields)]
1789#[ts(export)]
1790pub struct ActionPopupOptions {
1791    /// Unique identifier for the popup (used in ActionPopupResult)
1792    pub id: String,
1793    /// Title text for the popup
1794    pub title: String,
1795    /// Body message (supports basic formatting)
1796    pub message: String,
1797    /// Action buttons to display
1798    pub actions: Vec<ActionPopupAction>,
1799}
1800
1801/// Syntax highlight span for a buffer range
1802#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1803#[ts(export)]
1804pub struct TsHighlightSpan {
1805    pub start: u32,
1806    pub end: u32,
1807    #[ts(type = "[number, number, number]")]
1808    pub color: (u8, u8, u8),
1809    pub bold: bool,
1810    pub italic: bool,
1811}
1812
1813/// Result from spawning a process with spawnProcess
1814#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1815#[ts(export)]
1816pub struct SpawnResult {
1817    /// Complete stdout as string
1818    pub stdout: String,
1819    /// Complete stderr as string
1820    pub stderr: String,
1821    /// Process exit code (0 usually means success, -1 if killed)
1822    pub exit_code: i32,
1823}
1824
1825/// Result from spawning a background process
1826#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1827#[ts(export)]
1828pub struct BackgroundProcessResult {
1829    /// Unique process ID for later reference
1830    #[ts(type = "number")]
1831    pub process_id: u64,
1832    /// Process exit code (0 usually means success, -1 if killed)
1833    /// Only present when the process has exited
1834    pub exit_code: i32,
1835}
1836
1837/// Entry for virtual buffer content with optional text properties (JS API version)
1838#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1839#[serde(deny_unknown_fields)]
1840#[ts(export, rename = "TextPropertyEntry")]
1841pub struct JsTextPropertyEntry {
1842    /// Text content for this entry
1843    pub text: String,
1844    /// Optional properties attached to this text (e.g., file path, line number)
1845    #[serde(default)]
1846    #[ts(optional, type = "Record<string, unknown>")]
1847    pub properties: Option<HashMap<String, JsonValue>>,
1848}
1849
1850/// Directory entry returned by readDir
1851#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1852#[ts(export)]
1853pub struct DirEntry {
1854    /// File/directory name
1855    pub name: String,
1856    /// True if this is a file
1857    pub is_file: bool,
1858    /// True if this is a directory
1859    pub is_dir: bool,
1860}
1861
1862/// Position in a document (line and character)
1863#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1864#[ts(export)]
1865pub struct JsPosition {
1866    /// Zero-indexed line number
1867    pub line: u32,
1868    /// Zero-indexed character offset
1869    pub character: u32,
1870}
1871
1872/// Range in a document (start and end positions)
1873#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1874#[ts(export)]
1875pub struct JsRange {
1876    /// Start position
1877    pub start: JsPosition,
1878    /// End position
1879    pub end: JsPosition,
1880}
1881
1882/// Diagnostic from LSP
1883#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1884#[ts(export)]
1885pub struct JsDiagnostic {
1886    /// Document URI
1887    pub uri: String,
1888    /// Diagnostic message
1889    pub message: String,
1890    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
1891    pub severity: Option<u8>,
1892    /// Range in the document
1893    pub range: JsRange,
1894    /// Source of the diagnostic (e.g., "typescript", "eslint")
1895    #[ts(optional)]
1896    pub source: Option<String>,
1897}
1898
1899/// Options for createVirtualBuffer
1900#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1901#[serde(deny_unknown_fields)]
1902#[ts(export)]
1903pub struct CreateVirtualBufferOptions {
1904    /// Buffer name (displayed in tabs/title)
1905    pub name: String,
1906    /// Mode for keybindings (e.g., "git-log", "search-results")
1907    #[serde(default)]
1908    #[ts(optional)]
1909    pub mode: Option<String>,
1910    /// Whether buffer is read-only (default: false)
1911    #[serde(default, rename = "readOnly")]
1912    #[ts(optional, rename = "readOnly")]
1913    pub read_only: Option<bool>,
1914    /// Show line numbers in gutter (default: false)
1915    #[serde(default, rename = "showLineNumbers")]
1916    #[ts(optional, rename = "showLineNumbers")]
1917    pub show_line_numbers: Option<bool>,
1918    /// Show cursor (default: true)
1919    #[serde(default, rename = "showCursors")]
1920    #[ts(optional, rename = "showCursors")]
1921    pub show_cursors: Option<bool>,
1922    /// Disable text editing (default: false)
1923    #[serde(default, rename = "editingDisabled")]
1924    #[ts(optional, rename = "editingDisabled")]
1925    pub editing_disabled: Option<bool>,
1926    /// Hide from tab bar (default: false)
1927    #[serde(default, rename = "hiddenFromTabs")]
1928    #[ts(optional, rename = "hiddenFromTabs")]
1929    pub hidden_from_tabs: Option<bool>,
1930    /// Initial content entries with optional properties
1931    #[serde(default)]
1932    #[ts(optional)]
1933    pub entries: Option<Vec<JsTextPropertyEntry>>,
1934}
1935
1936/// Options for createVirtualBufferInSplit
1937#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1938#[serde(deny_unknown_fields)]
1939#[ts(export)]
1940pub struct CreateVirtualBufferInSplitOptions {
1941    /// Buffer name (displayed in tabs/title)
1942    pub name: String,
1943    /// Mode for keybindings (e.g., "git-log", "search-results")
1944    #[serde(default)]
1945    #[ts(optional)]
1946    pub mode: Option<String>,
1947    /// Whether buffer is read-only (default: false)
1948    #[serde(default, rename = "readOnly")]
1949    #[ts(optional, rename = "readOnly")]
1950    pub read_only: Option<bool>,
1951    /// Split ratio 0.0-1.0 (default: 0.5)
1952    #[serde(default)]
1953    #[ts(optional)]
1954    pub ratio: Option<f32>,
1955    /// Split direction: "horizontal" or "vertical"
1956    #[serde(default)]
1957    #[ts(optional)]
1958    pub direction: Option<String>,
1959    /// Panel ID to split from
1960    #[serde(default, rename = "panelId")]
1961    #[ts(optional, rename = "panelId")]
1962    pub panel_id: Option<String>,
1963    /// Show line numbers in gutter (default: true)
1964    #[serde(default, rename = "showLineNumbers")]
1965    #[ts(optional, rename = "showLineNumbers")]
1966    pub show_line_numbers: Option<bool>,
1967    /// Show cursor (default: true)
1968    #[serde(default, rename = "showCursors")]
1969    #[ts(optional, rename = "showCursors")]
1970    pub show_cursors: Option<bool>,
1971    /// Disable text editing (default: false)
1972    #[serde(default, rename = "editingDisabled")]
1973    #[ts(optional, rename = "editingDisabled")]
1974    pub editing_disabled: Option<bool>,
1975    /// Enable line wrapping
1976    #[serde(default, rename = "lineWrap")]
1977    #[ts(optional, rename = "lineWrap")]
1978    pub line_wrap: Option<bool>,
1979    /// Place the new buffer before (left/top of) the existing content (default: false)
1980    #[serde(default)]
1981    #[ts(optional)]
1982    pub before: Option<bool>,
1983    /// Initial content entries with optional properties
1984    #[serde(default)]
1985    #[ts(optional)]
1986    pub entries: Option<Vec<JsTextPropertyEntry>>,
1987}
1988
1989/// Options for createVirtualBufferInExistingSplit
1990#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1991#[serde(deny_unknown_fields)]
1992#[ts(export)]
1993pub struct CreateVirtualBufferInExistingSplitOptions {
1994    /// Buffer name (displayed in tabs/title)
1995    pub name: String,
1996    /// Target split ID (required)
1997    #[serde(rename = "splitId")]
1998    #[ts(rename = "splitId")]
1999    pub split_id: usize,
2000    /// Mode for keybindings (e.g., "git-log", "search-results")
2001    #[serde(default)]
2002    #[ts(optional)]
2003    pub mode: Option<String>,
2004    /// Whether buffer is read-only (default: false)
2005    #[serde(default, rename = "readOnly")]
2006    #[ts(optional, rename = "readOnly")]
2007    pub read_only: Option<bool>,
2008    /// Show line numbers in gutter (default: true)
2009    #[serde(default, rename = "showLineNumbers")]
2010    #[ts(optional, rename = "showLineNumbers")]
2011    pub show_line_numbers: Option<bool>,
2012    /// Show cursor (default: true)
2013    #[serde(default, rename = "showCursors")]
2014    #[ts(optional, rename = "showCursors")]
2015    pub show_cursors: Option<bool>,
2016    /// Disable text editing (default: false)
2017    #[serde(default, rename = "editingDisabled")]
2018    #[ts(optional, rename = "editingDisabled")]
2019    pub editing_disabled: Option<bool>,
2020    /// Enable line wrapping
2021    #[serde(default, rename = "lineWrap")]
2022    #[ts(optional, rename = "lineWrap")]
2023    pub line_wrap: Option<bool>,
2024    /// Initial content entries with optional properties
2025    #[serde(default)]
2026    #[ts(optional)]
2027    pub entries: Option<Vec<JsTextPropertyEntry>>,
2028}
2029
2030/// Options for createTerminal
2031#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2032#[serde(deny_unknown_fields)]
2033#[ts(export)]
2034pub struct CreateTerminalOptions {
2035    /// Working directory for the terminal (defaults to editor cwd)
2036    #[serde(default)]
2037    #[ts(optional)]
2038    pub cwd: Option<String>,
2039    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2040    #[serde(default)]
2041    #[ts(optional)]
2042    pub direction: Option<String>,
2043    /// Split ratio 0.0-1.0 (default: 0.5)
2044    #[serde(default)]
2045    #[ts(optional)]
2046    pub ratio: Option<f32>,
2047    /// Whether to focus the new terminal split (default: true)
2048    #[serde(default)]
2049    #[ts(optional)]
2050    pub focus: Option<bool>,
2051}
2052
2053/// Result of getTextPropertiesAtCursor - array of property objects
2054///
2055/// Each element contains the properties from a text property span that overlaps
2056/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2057#[derive(Debug, Clone, Serialize, TS)]
2058#[ts(export, type = "Array<Record<string, unknown>>")]
2059pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2060
2061// Implement FromJs for option types using rquickjs_serde
2062#[cfg(feature = "plugins")]
2063mod fromjs_impls {
2064    use super::*;
2065    use rquickjs::{Ctx, FromJs, Value};
2066
2067    impl<'js> FromJs<'js> for JsTextPropertyEntry {
2068        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2069            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2070                from: "object",
2071                to: "JsTextPropertyEntry",
2072                message: Some(e.to_string()),
2073            })
2074        }
2075    }
2076
2077    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
2078        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2079            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2080                from: "object",
2081                to: "CreateVirtualBufferOptions",
2082                message: Some(e.to_string()),
2083            })
2084        }
2085    }
2086
2087    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
2088        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2089            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2090                from: "object",
2091                to: "CreateVirtualBufferInSplitOptions",
2092                message: Some(e.to_string()),
2093            })
2094        }
2095    }
2096
2097    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
2098        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2099            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2100                from: "object",
2101                to: "CreateVirtualBufferInExistingSplitOptions",
2102                message: Some(e.to_string()),
2103            })
2104        }
2105    }
2106
2107    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2108        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2109            rquickjs_serde::to_value(ctx.clone(), &self.0)
2110                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2111        }
2112    }
2113
2114    // === Additional input types for type-safe plugin API ===
2115
2116    impl<'js> FromJs<'js> for ActionSpec {
2117        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2118            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2119                from: "object",
2120                to: "ActionSpec",
2121                message: Some(e.to_string()),
2122            })
2123        }
2124    }
2125
2126    impl<'js> FromJs<'js> for ActionPopupAction {
2127        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2128            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2129                from: "object",
2130                to: "ActionPopupAction",
2131                message: Some(e.to_string()),
2132            })
2133        }
2134    }
2135
2136    impl<'js> FromJs<'js> for ActionPopupOptions {
2137        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2138            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2139                from: "object",
2140                to: "ActionPopupOptions",
2141                message: Some(e.to_string()),
2142            })
2143        }
2144    }
2145
2146    impl<'js> FromJs<'js> for ViewTokenWire {
2147        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2148            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2149                from: "object",
2150                to: "ViewTokenWire",
2151                message: Some(e.to_string()),
2152            })
2153        }
2154    }
2155
2156    impl<'js> FromJs<'js> for ViewTokenStyle {
2157        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2158            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2159                from: "object",
2160                to: "ViewTokenStyle",
2161                message: Some(e.to_string()),
2162            })
2163        }
2164    }
2165
2166    impl<'js> FromJs<'js> for LayoutHints {
2167        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2168            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2169                from: "object",
2170                to: "LayoutHints",
2171                message: Some(e.to_string()),
2172            })
2173        }
2174    }
2175
2176    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2177        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2178            // Use two-step deserialization for complex nested structures
2179            let json: serde_json::Value =
2180                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2181                    from: "object",
2182                    to: "CreateCompositeBufferOptions (json)",
2183                    message: Some(e.to_string()),
2184                })?;
2185            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2186                from: "json",
2187                to: "CreateCompositeBufferOptions",
2188                message: Some(e.to_string()),
2189            })
2190        }
2191    }
2192
2193    impl<'js> FromJs<'js> for CompositeHunk {
2194        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2195            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2196                from: "object",
2197                to: "CompositeHunk",
2198                message: Some(e.to_string()),
2199            })
2200        }
2201    }
2202
2203    impl<'js> FromJs<'js> for LanguagePackConfig {
2204        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2205            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2206                from: "object",
2207                to: "LanguagePackConfig",
2208                message: Some(e.to_string()),
2209            })
2210        }
2211    }
2212
2213    impl<'js> FromJs<'js> for LspServerPackConfig {
2214        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2215            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2216                from: "object",
2217                to: "LspServerPackConfig",
2218                message: Some(e.to_string()),
2219            })
2220        }
2221    }
2222
2223    impl<'js> FromJs<'js> for CreateTerminalOptions {
2224        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2225            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2226                from: "object",
2227                to: "CreateTerminalOptions",
2228                message: Some(e.to_string()),
2229            })
2230        }
2231    }
2232}
2233
2234/// Plugin API context - provides safe access to editor functionality
2235pub struct PluginApi {
2236    /// Hook registry (shared with editor)
2237    hooks: Arc<RwLock<HookRegistry>>,
2238
2239    /// Command registry (shared with editor)
2240    commands: Arc<RwLock<CommandRegistry>>,
2241
2242    /// Command queue for sending commands to editor
2243    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2244
2245    /// Snapshot of editor state (read-only for plugins)
2246    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2247}
2248
2249impl PluginApi {
2250    /// Create a new plugin API context
2251    pub fn new(
2252        hooks: Arc<RwLock<HookRegistry>>,
2253        commands: Arc<RwLock<CommandRegistry>>,
2254        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2255        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2256    ) -> Self {
2257        Self {
2258            hooks,
2259            commands,
2260            command_sender,
2261            state_snapshot,
2262        }
2263    }
2264
2265    /// Register a hook callback
2266    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2267        let mut hooks = self.hooks.write().unwrap();
2268        hooks.add_hook(hook_name, callback);
2269    }
2270
2271    /// Remove all hooks for a specific name
2272    pub fn unregister_hooks(&self, hook_name: &str) {
2273        let mut hooks = self.hooks.write().unwrap();
2274        hooks.remove_hooks(hook_name);
2275    }
2276
2277    /// Register a command
2278    pub fn register_command(&self, command: Command) {
2279        let commands = self.commands.read().unwrap();
2280        commands.register(command);
2281    }
2282
2283    /// Unregister a command by name
2284    pub fn unregister_command(&self, name: &str) {
2285        let commands = self.commands.read().unwrap();
2286        commands.unregister(name);
2287    }
2288
2289    /// Send a command to the editor (async/non-blocking)
2290    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
2291        self.command_sender
2292            .send(command)
2293            .map_err(|e| format!("Failed to send command: {}", e))
2294    }
2295
2296    /// Insert text at a position in a buffer
2297    pub fn insert_text(
2298        &self,
2299        buffer_id: BufferId,
2300        position: usize,
2301        text: String,
2302    ) -> Result<(), String> {
2303        self.send_command(PluginCommand::InsertText {
2304            buffer_id,
2305            position,
2306            text,
2307        })
2308    }
2309
2310    /// Delete a range of text from a buffer
2311    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
2312        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
2313    }
2314
2315    /// Add an overlay (decoration) to a buffer
2316    /// Add an overlay to a buffer with styling options
2317    ///
2318    /// Returns an opaque handle that can be used to remove the overlay later.
2319    ///
2320    /// Colors can be specified as RGB arrays or theme key strings.
2321    /// Theme keys are resolved at render time, so overlays update with theme changes.
2322    pub fn add_overlay(
2323        &self,
2324        buffer_id: BufferId,
2325        namespace: Option<String>,
2326        range: Range<usize>,
2327        options: OverlayOptions,
2328    ) -> Result<(), String> {
2329        self.send_command(PluginCommand::AddOverlay {
2330            buffer_id,
2331            namespace: namespace.map(OverlayNamespace::from_string),
2332            range,
2333            options,
2334        })
2335    }
2336
2337    /// Remove an overlay from a buffer by its handle
2338    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
2339        self.send_command(PluginCommand::RemoveOverlay {
2340            buffer_id,
2341            handle: OverlayHandle::from_string(handle),
2342        })
2343    }
2344
2345    /// Clear all overlays in a namespace from a buffer
2346    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
2347        self.send_command(PluginCommand::ClearNamespace {
2348            buffer_id,
2349            namespace: OverlayNamespace::from_string(namespace),
2350        })
2351    }
2352
2353    /// Clear all overlays that overlap with a byte range
2354    /// Used for targeted invalidation when content changes
2355    pub fn clear_overlays_in_range(
2356        &self,
2357        buffer_id: BufferId,
2358        start: usize,
2359        end: usize,
2360    ) -> Result<(), String> {
2361        self.send_command(PluginCommand::ClearOverlaysInRange {
2362            buffer_id,
2363            start,
2364            end,
2365        })
2366    }
2367
2368    /// Set the status message
2369    pub fn set_status(&self, message: String) -> Result<(), String> {
2370        self.send_command(PluginCommand::SetStatus { message })
2371    }
2372
2373    /// Open a file at a specific line and column (1-indexed)
2374    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
2375    pub fn open_file_at_location(
2376        &self,
2377        path: PathBuf,
2378        line: Option<usize>,
2379        column: Option<usize>,
2380    ) -> Result<(), String> {
2381        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
2382    }
2383
2384    /// Open a file in a specific split at a line and column
2385    ///
2386    /// Similar to open_file_at_location but targets a specific split pane.
2387    /// The split_id is the ID of the split pane to open the file in.
2388    pub fn open_file_in_split(
2389        &self,
2390        split_id: usize,
2391        path: PathBuf,
2392        line: Option<usize>,
2393        column: Option<usize>,
2394    ) -> Result<(), String> {
2395        self.send_command(PluginCommand::OpenFileInSplit {
2396            split_id,
2397            path,
2398            line,
2399            column,
2400        })
2401    }
2402
2403    /// Start a prompt (minibuffer) with a custom type identifier
2404    /// The prompt_type is used to filter hooks in plugin code
2405    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
2406        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
2407    }
2408
2409    /// Set the suggestions for the current prompt
2410    /// This updates the prompt's autocomplete/selection list
2411    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
2412        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
2413    }
2414
2415    /// Enable/disable syncing prompt input text when navigating suggestions
2416    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
2417        self.send_command(PluginCommand::SetPromptInputSync { sync })
2418    }
2419
2420    /// Add a menu item to an existing menu
2421    pub fn add_menu_item(
2422        &self,
2423        menu_label: String,
2424        item: MenuItem,
2425        position: MenuPosition,
2426    ) -> Result<(), String> {
2427        self.send_command(PluginCommand::AddMenuItem {
2428            menu_label,
2429            item,
2430            position,
2431        })
2432    }
2433
2434    /// Add a new top-level menu
2435    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
2436        self.send_command(PluginCommand::AddMenu { menu, position })
2437    }
2438
2439    /// Remove a menu item from a menu
2440    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
2441        self.send_command(PluginCommand::RemoveMenuItem {
2442            menu_label,
2443            item_label,
2444        })
2445    }
2446
2447    /// Remove a top-level menu
2448    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
2449        self.send_command(PluginCommand::RemoveMenu { menu_label })
2450    }
2451
2452    // === Virtual Buffer Methods ===
2453
2454    /// Create a new virtual buffer (not backed by a file)
2455    ///
2456    /// Virtual buffers are used for special displays like diagnostic lists,
2457    /// search results, etc. They have their own mode for keybindings.
2458    pub fn create_virtual_buffer(
2459        &self,
2460        name: String,
2461        mode: String,
2462        read_only: bool,
2463    ) -> Result<(), String> {
2464        self.send_command(PluginCommand::CreateVirtualBuffer {
2465            name,
2466            mode,
2467            read_only,
2468        })
2469    }
2470
2471    /// Create a virtual buffer and set its content in one operation
2472    ///
2473    /// This is the preferred way to create virtual buffers since it doesn't
2474    /// require tracking the buffer ID. The buffer is created and populated
2475    /// atomically.
2476    pub fn create_virtual_buffer_with_content(
2477        &self,
2478        name: String,
2479        mode: String,
2480        read_only: bool,
2481        entries: Vec<TextPropertyEntry>,
2482    ) -> Result<(), String> {
2483        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
2484            name,
2485            mode,
2486            read_only,
2487            entries,
2488            show_line_numbers: true,
2489            show_cursors: true,
2490            editing_disabled: false,
2491            hidden_from_tabs: false,
2492            request_id: None,
2493        })
2494    }
2495
2496    /// Set the content of a virtual buffer with text properties
2497    ///
2498    /// Each entry contains text and metadata properties (e.g., source location).
2499    pub fn set_virtual_buffer_content(
2500        &self,
2501        buffer_id: BufferId,
2502        entries: Vec<TextPropertyEntry>,
2503    ) -> Result<(), String> {
2504        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
2505    }
2506
2507    /// Get text properties at cursor position in a buffer
2508    ///
2509    /// This triggers a command that will make properties available to plugins.
2510    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
2511        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
2512    }
2513
2514    /// Define a buffer mode with keybindings
2515    ///
2516    /// Modes can inherit from parent modes (e.g., "diagnostics-list" inherits from "special").
2517    /// Bindings are specified as (key_string, command_name) pairs.
2518    pub fn define_mode(
2519        &self,
2520        name: String,
2521        parent: Option<String>,
2522        bindings: Vec<(String, String)>,
2523        read_only: bool,
2524    ) -> Result<(), String> {
2525        self.send_command(PluginCommand::DefineMode {
2526            name,
2527            parent,
2528            bindings,
2529            read_only,
2530        })
2531    }
2532
2533    /// Switch the current split to display a buffer
2534    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
2535        self.send_command(PluginCommand::ShowBuffer { buffer_id })
2536    }
2537
2538    /// Set the scroll position of a specific split
2539    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
2540        self.send_command(PluginCommand::SetSplitScroll {
2541            split_id: SplitId(split_id),
2542            top_byte,
2543        })
2544    }
2545
2546    /// Request syntax highlights for a buffer range
2547    pub fn get_highlights(
2548        &self,
2549        buffer_id: BufferId,
2550        range: Range<usize>,
2551        request_id: u64,
2552    ) -> Result<(), String> {
2553        self.send_command(PluginCommand::RequestHighlights {
2554            buffer_id,
2555            range,
2556            request_id,
2557        })
2558    }
2559
2560    // === Query Methods ===
2561
2562    /// Get the currently active buffer ID
2563    pub fn get_active_buffer_id(&self) -> BufferId {
2564        let snapshot = self.state_snapshot.read().unwrap();
2565        snapshot.active_buffer_id
2566    }
2567
2568    /// Get the currently active split ID
2569    pub fn get_active_split_id(&self) -> usize {
2570        let snapshot = self.state_snapshot.read().unwrap();
2571        snapshot.active_split_id
2572    }
2573
2574    /// Get information about a specific buffer
2575    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
2576        let snapshot = self.state_snapshot.read().unwrap();
2577        snapshot.buffers.get(&buffer_id).cloned()
2578    }
2579
2580    /// Get all buffer IDs
2581    pub fn list_buffers(&self) -> Vec<BufferInfo> {
2582        let snapshot = self.state_snapshot.read().unwrap();
2583        snapshot.buffers.values().cloned().collect()
2584    }
2585
2586    /// Get primary cursor information for the active buffer
2587    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
2588        let snapshot = self.state_snapshot.read().unwrap();
2589        snapshot.primary_cursor.clone()
2590    }
2591
2592    /// Get all cursor information for the active buffer
2593    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
2594        let snapshot = self.state_snapshot.read().unwrap();
2595        snapshot.all_cursors.clone()
2596    }
2597
2598    /// Get viewport information for the active buffer
2599    pub fn get_viewport(&self) -> Option<ViewportInfo> {
2600        let snapshot = self.state_snapshot.read().unwrap();
2601        snapshot.viewport.clone()
2602    }
2603
2604    /// Get access to the state snapshot Arc (for internal use)
2605    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
2606        Arc::clone(&self.state_snapshot)
2607    }
2608}
2609
2610impl Clone for PluginApi {
2611    fn clone(&self) -> Self {
2612        Self {
2613            hooks: Arc::clone(&self.hooks),
2614            commands: Arc::clone(&self.commands),
2615            command_sender: self.command_sender.clone(),
2616            state_snapshot: Arc::clone(&self.state_snapshot),
2617        }
2618    }
2619}
2620
2621#[cfg(test)]
2622mod tests {
2623    use super::*;
2624
2625    #[test]
2626    fn test_plugin_api_creation() {
2627        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2628        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2629        let (tx, _rx) = std::sync::mpsc::channel();
2630        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2631
2632        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2633
2634        // Should not panic
2635        let _clone = api.clone();
2636    }
2637
2638    #[test]
2639    fn test_register_hook() {
2640        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2641        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2642        let (tx, _rx) = std::sync::mpsc::channel();
2643        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2644
2645        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
2646
2647        api.register_hook("test-hook", Box::new(|_| true));
2648
2649        let hook_registry = hooks.read().unwrap();
2650        assert_eq!(hook_registry.hook_count("test-hook"), 1);
2651    }
2652
2653    #[test]
2654    fn test_send_command() {
2655        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2656        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2657        let (tx, rx) = std::sync::mpsc::channel();
2658        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2659
2660        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2661
2662        let result = api.insert_text(BufferId(1), 0, "test".to_string());
2663        assert!(result.is_ok());
2664
2665        // Verify command was sent
2666        let received = rx.try_recv();
2667        assert!(received.is_ok());
2668
2669        match received.unwrap() {
2670            PluginCommand::InsertText {
2671                buffer_id,
2672                position,
2673                text,
2674            } => {
2675                assert_eq!(buffer_id.0, 1);
2676                assert_eq!(position, 0);
2677                assert_eq!(text, "test");
2678            }
2679            _ => panic!("Wrong command type"),
2680        }
2681    }
2682
2683    #[test]
2684    fn test_add_overlay_command() {
2685        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2686        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2687        let (tx, rx) = std::sync::mpsc::channel();
2688        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2689
2690        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2691
2692        let result = api.add_overlay(
2693            BufferId(1),
2694            Some("test-overlay".to_string()),
2695            0..10,
2696            OverlayOptions {
2697                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
2698                bg: None,
2699                underline: true,
2700                bold: false,
2701                italic: false,
2702                strikethrough: false,
2703                extend_to_line_end: false,
2704                url: None,
2705            },
2706        );
2707        assert!(result.is_ok());
2708
2709        let received = rx.try_recv().unwrap();
2710        match received {
2711            PluginCommand::AddOverlay {
2712                buffer_id,
2713                namespace,
2714                range,
2715                options,
2716            } => {
2717                assert_eq!(buffer_id.0, 1);
2718                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
2719                assert_eq!(range, 0..10);
2720                assert!(matches!(
2721                    options.fg,
2722                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
2723                ));
2724                assert!(options.bg.is_none());
2725                assert!(options.underline);
2726                assert!(!options.bold);
2727                assert!(!options.italic);
2728                assert!(!options.extend_to_line_end);
2729            }
2730            _ => panic!("Wrong command type"),
2731        }
2732    }
2733
2734    #[test]
2735    fn test_set_status_command() {
2736        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2737        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2738        let (tx, rx) = std::sync::mpsc::channel();
2739        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2740
2741        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2742
2743        let result = api.set_status("Test status".to_string());
2744        assert!(result.is_ok());
2745
2746        let received = rx.try_recv().unwrap();
2747        match received {
2748            PluginCommand::SetStatus { message } => {
2749                assert_eq!(message, "Test status");
2750            }
2751            _ => panic!("Wrong command type"),
2752        }
2753    }
2754
2755    #[test]
2756    fn test_get_active_buffer_id() {
2757        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2758        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2759        let (tx, _rx) = std::sync::mpsc::channel();
2760        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2761
2762        // Set active buffer to 5
2763        {
2764            let mut snapshot = state_snapshot.write().unwrap();
2765            snapshot.active_buffer_id = BufferId(5);
2766        }
2767
2768        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2769
2770        let active_id = api.get_active_buffer_id();
2771        assert_eq!(active_id.0, 5);
2772    }
2773
2774    #[test]
2775    fn test_get_buffer_info() {
2776        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2777        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2778        let (tx, _rx) = std::sync::mpsc::channel();
2779        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2780
2781        // Add buffer info
2782        {
2783            let mut snapshot = state_snapshot.write().unwrap();
2784            let buffer_info = BufferInfo {
2785                id: BufferId(1),
2786                path: Some(std::path::PathBuf::from("/test/file.txt")),
2787                modified: true,
2788                length: 100,
2789                is_virtual: false,
2790                view_mode: "source".to_string(),
2791                is_composing_in_any_split: false,
2792                compose_width: None,
2793                language: "text".to_string(),
2794            };
2795            snapshot.buffers.insert(BufferId(1), buffer_info);
2796        }
2797
2798        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2799
2800        let info = api.get_buffer_info(BufferId(1));
2801        assert!(info.is_some());
2802        let info = info.unwrap();
2803        assert_eq!(info.id.0, 1);
2804        assert_eq!(
2805            info.path.as_ref().unwrap().to_str().unwrap(),
2806            "/test/file.txt"
2807        );
2808        assert!(info.modified);
2809        assert_eq!(info.length, 100);
2810
2811        // Non-existent buffer
2812        let no_info = api.get_buffer_info(BufferId(999));
2813        assert!(no_info.is_none());
2814    }
2815
2816    #[test]
2817    fn test_list_buffers() {
2818        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2819        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2820        let (tx, _rx) = std::sync::mpsc::channel();
2821        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2822
2823        // Add multiple buffers
2824        {
2825            let mut snapshot = state_snapshot.write().unwrap();
2826            snapshot.buffers.insert(
2827                BufferId(1),
2828                BufferInfo {
2829                    id: BufferId(1),
2830                    path: Some(std::path::PathBuf::from("/file1.txt")),
2831                    modified: false,
2832                    length: 50,
2833                    is_virtual: false,
2834                    view_mode: "source".to_string(),
2835                    is_composing_in_any_split: false,
2836                    compose_width: None,
2837                    language: "text".to_string(),
2838                },
2839            );
2840            snapshot.buffers.insert(
2841                BufferId(2),
2842                BufferInfo {
2843                    id: BufferId(2),
2844                    path: Some(std::path::PathBuf::from("/file2.txt")),
2845                    modified: true,
2846                    length: 100,
2847                    is_virtual: false,
2848                    view_mode: "source".to_string(),
2849                    is_composing_in_any_split: false,
2850                    compose_width: None,
2851                    language: "text".to_string(),
2852                },
2853            );
2854            snapshot.buffers.insert(
2855                BufferId(3),
2856                BufferInfo {
2857                    id: BufferId(3),
2858                    path: None,
2859                    modified: false,
2860                    length: 0,
2861                    is_virtual: true,
2862                    view_mode: "source".to_string(),
2863                    is_composing_in_any_split: false,
2864                    compose_width: None,
2865                    language: "text".to_string(),
2866                },
2867            );
2868        }
2869
2870        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2871
2872        let buffers = api.list_buffers();
2873        assert_eq!(buffers.len(), 3);
2874
2875        // Verify all buffers are present
2876        assert!(buffers.iter().any(|b| b.id.0 == 1));
2877        assert!(buffers.iter().any(|b| b.id.0 == 2));
2878        assert!(buffers.iter().any(|b| b.id.0 == 3));
2879    }
2880
2881    #[test]
2882    fn test_get_primary_cursor() {
2883        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2884        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2885        let (tx, _rx) = std::sync::mpsc::channel();
2886        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2887
2888        // Add cursor info
2889        {
2890            let mut snapshot = state_snapshot.write().unwrap();
2891            snapshot.primary_cursor = Some(CursorInfo {
2892                position: 42,
2893                selection: Some(10..42),
2894            });
2895        }
2896
2897        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2898
2899        let cursor = api.get_primary_cursor();
2900        assert!(cursor.is_some());
2901        let cursor = cursor.unwrap();
2902        assert_eq!(cursor.position, 42);
2903        assert_eq!(cursor.selection, Some(10..42));
2904    }
2905
2906    #[test]
2907    fn test_get_all_cursors() {
2908        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2909        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2910        let (tx, _rx) = std::sync::mpsc::channel();
2911        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2912
2913        // Add multiple cursors
2914        {
2915            let mut snapshot = state_snapshot.write().unwrap();
2916            snapshot.all_cursors = vec![
2917                CursorInfo {
2918                    position: 10,
2919                    selection: None,
2920                },
2921                CursorInfo {
2922                    position: 20,
2923                    selection: Some(15..20),
2924                },
2925                CursorInfo {
2926                    position: 30,
2927                    selection: Some(25..30),
2928                },
2929            ];
2930        }
2931
2932        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2933
2934        let cursors = api.get_all_cursors();
2935        assert_eq!(cursors.len(), 3);
2936        assert_eq!(cursors[0].position, 10);
2937        assert_eq!(cursors[0].selection, None);
2938        assert_eq!(cursors[1].position, 20);
2939        assert_eq!(cursors[1].selection, Some(15..20));
2940        assert_eq!(cursors[2].position, 30);
2941        assert_eq!(cursors[2].selection, Some(25..30));
2942    }
2943
2944    #[test]
2945    fn test_get_viewport() {
2946        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2947        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2948        let (tx, _rx) = std::sync::mpsc::channel();
2949        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2950
2951        // Add viewport info
2952        {
2953            let mut snapshot = state_snapshot.write().unwrap();
2954            snapshot.viewport = Some(ViewportInfo {
2955                top_byte: 100,
2956                top_line: Some(5),
2957                left_column: 5,
2958                width: 80,
2959                height: 24,
2960            });
2961        }
2962
2963        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2964
2965        let viewport = api.get_viewport();
2966        assert!(viewport.is_some());
2967        let viewport = viewport.unwrap();
2968        assert_eq!(viewport.top_byte, 100);
2969        assert_eq!(viewport.left_column, 5);
2970        assert_eq!(viewport.width, 80);
2971        assert_eq!(viewport.height, 24);
2972    }
2973
2974    #[test]
2975    fn test_composite_buffer_options_rejects_unknown_fields() {
2976        // Valid JSON with correct field names
2977        let valid_json = r#"{
2978            "name": "test",
2979            "mode": "diff",
2980            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2981            "sources": [{"bufferId": 1, "label": "old"}]
2982        }"#;
2983        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
2984        assert!(
2985            result.is_ok(),
2986            "Valid JSON should parse: {:?}",
2987            result.err()
2988        );
2989
2990        // Invalid JSON with unknown field (buffer_id instead of bufferId)
2991        let invalid_json = r#"{
2992            "name": "test",
2993            "mode": "diff",
2994            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2995            "sources": [{"buffer_id": 1, "label": "old"}]
2996        }"#;
2997        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
2998        assert!(
2999            result.is_err(),
3000            "JSON with unknown field should fail to parse"
3001        );
3002        let err = result.unwrap_err().to_string();
3003        assert!(
3004            err.contains("unknown field") || err.contains("buffer_id"),
3005            "Error should mention unknown field: {}",
3006            err
3007        );
3008    }
3009
3010    #[test]
3011    fn test_composite_hunk_rejects_unknown_fields() {
3012        // Valid JSON with correct field names
3013        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3014        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3015        assert!(
3016            result.is_ok(),
3017            "Valid JSON should parse: {:?}",
3018            result.err()
3019        );
3020
3021        // Invalid JSON with unknown field (old_start instead of oldStart)
3022        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3023        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3024        assert!(
3025            result.is_err(),
3026            "JSON with unknown field should fail to parse"
3027        );
3028        let err = result.unwrap_err().to_string();
3029        assert!(
3030            err.contains("unknown field") || err.contains("old_start"),
3031            "Error should mention unknown field: {}",
3032            err
3033        );
3034    }
3035
3036    #[test]
3037    fn test_plugin_response_line_end_position() {
3038        let response = PluginResponse::LineEndPosition {
3039            request_id: 42,
3040            position: Some(100),
3041        };
3042        let json = serde_json::to_string(&response).unwrap();
3043        assert!(json.contains("LineEndPosition"));
3044        assert!(json.contains("42"));
3045        assert!(json.contains("100"));
3046
3047        // Test None case
3048        let response_none = PluginResponse::LineEndPosition {
3049            request_id: 1,
3050            position: None,
3051        };
3052        let json_none = serde_json::to_string(&response_none).unwrap();
3053        assert!(json_none.contains("null"));
3054    }
3055
3056    #[test]
3057    fn test_plugin_response_buffer_line_count() {
3058        let response = PluginResponse::BufferLineCount {
3059            request_id: 99,
3060            count: Some(500),
3061        };
3062        let json = serde_json::to_string(&response).unwrap();
3063        assert!(json.contains("BufferLineCount"));
3064        assert!(json.contains("99"));
3065        assert!(json.contains("500"));
3066    }
3067
3068    #[test]
3069    fn test_plugin_command_get_line_end_position() {
3070        let command = PluginCommand::GetLineEndPosition {
3071            buffer_id: BufferId(1),
3072            line: 10,
3073            request_id: 123,
3074        };
3075        let json = serde_json::to_string(&command).unwrap();
3076        assert!(json.contains("GetLineEndPosition"));
3077        assert!(json.contains("10"));
3078    }
3079
3080    #[test]
3081    fn test_plugin_command_get_buffer_line_count() {
3082        let command = PluginCommand::GetBufferLineCount {
3083            buffer_id: BufferId(0),
3084            request_id: 456,
3085        };
3086        let json = serde_json::to_string(&command).unwrap();
3087        assert!(json.contains("GetBufferLineCount"));
3088        assert!(json.contains("456"));
3089    }
3090
3091    #[test]
3092    fn test_plugin_command_scroll_to_line_center() {
3093        let command = PluginCommand::ScrollToLineCenter {
3094            split_id: SplitId(1),
3095            buffer_id: BufferId(2),
3096            line: 50,
3097        };
3098        let json = serde_json::to_string(&command).unwrap();
3099        assert!(json.contains("ScrollToLineCenter"));
3100        assert!(json.contains("50"));
3101    }
3102}