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