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