Skip to main content

fresh_core/
api.rs

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