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 lsp_types;
55use serde::{Deserialize, Serialize};
56use serde_json::Value as JsonValue;
57use std::collections::HashMap;
58use std::ops::Range;
59use std::path::PathBuf;
60use std::sync::{Arc, RwLock};
61use ts_rs::TS;
62
63/// Minimal command registry for PluginApi.
64/// This is a stub that provides basic command storage for plugin use.
65/// The editor's full CommandRegistry lives in fresh-editor.
66pub struct CommandRegistry {
67    commands: std::sync::RwLock<Vec<Command>>,
68}
69
70impl CommandRegistry {
71    /// Create a new empty command registry
72    pub fn new() -> Self {
73        Self {
74            commands: std::sync::RwLock::new(Vec::new()),
75        }
76    }
77
78    /// Register a command
79    pub fn register(&self, command: Command) {
80        let mut commands = self.commands.write().unwrap();
81        commands.retain(|c| c.name != command.name);
82        commands.push(command);
83    }
84
85    /// Unregister a command by name  
86    pub fn unregister(&self, name: &str) {
87        let mut commands = self.commands.write().unwrap();
88        commands.retain(|c| c.name != name);
89    }
90}
91
92impl Default for CommandRegistry {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// A callback ID for JavaScript promises in the plugin runtime.
99///
100/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
101/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
102/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
104#[ts(export)]
105pub struct JsCallbackId(pub u64);
106
107impl JsCallbackId {
108    /// Create a new JS callback ID
109    pub fn new(id: u64) -> Self {
110        Self(id)
111    }
112
113    /// Get the underlying u64 value
114    pub fn as_u64(self) -> u64 {
115        self.0
116    }
117}
118
119impl From<u64> for JsCallbackId {
120    fn from(id: u64) -> Self {
121        Self(id)
122    }
123}
124
125impl From<JsCallbackId> for u64 {
126    fn from(id: JsCallbackId) -> u64 {
127        id.0
128    }
129}
130
131impl std::fmt::Display for JsCallbackId {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}", self.0)
134    }
135}
136
137/// Result of creating a virtual buffer
138#[derive(Debug, Clone, Serialize, Deserialize, TS)]
139#[serde(rename_all = "camelCase")]
140#[ts(export, rename_all = "camelCase")]
141pub struct VirtualBufferResult {
142    /// The created buffer ID
143    #[ts(type = "number")]
144    pub buffer_id: u64,
145    /// The split ID (if created in a new split)
146    #[ts(type = "number | null")]
147    pub split_id: Option<u64>,
148}
149
150/// Response from the editor for async plugin operations
151#[derive(Debug, Clone, Serialize, Deserialize, TS)]
152#[ts(export)]
153pub enum PluginResponse {
154    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
155    VirtualBufferCreated {
156        request_id: u64,
157        buffer_id: BufferId,
158        split_id: Option<SplitId>,
159    },
160    /// Response to a plugin-initiated LSP request
161    LspRequest {
162        request_id: u64,
163        #[ts(type = "any")]
164        result: Result<JsonValue, String>,
165    },
166    /// Response to RequestHighlights
167    HighlightsComputed {
168        request_id: u64,
169        spans: Vec<TsHighlightSpan>,
170    },
171    /// Response to GetBufferText with the text content
172    BufferText {
173        request_id: u64,
174        text: Result<String, String>,
175    },
176    /// Response to GetLineStartPosition with the byte offset
177    LineStartPosition {
178        request_id: u64,
179        /// None if line is out of range, Some(offset) for valid line
180        position: Option<usize>,
181    },
182    /// Response to CreateCompositeBuffer with the created buffer ID
183    CompositeBufferCreated {
184        request_id: u64,
185        buffer_id: BufferId,
186    },
187}
188
189/// Messages sent from async plugin tasks to the synchronous main loop
190#[derive(Debug, Clone, Serialize, Deserialize, TS)]
191#[ts(export)]
192pub enum PluginAsyncMessage {
193    /// Plugin process completed with output
194    ProcessOutput {
195        /// Unique ID for this process
196        process_id: u64,
197        /// Standard output
198        stdout: String,
199        /// Standard error
200        stderr: String,
201        /// Exit code
202        exit_code: i32,
203    },
204    /// Plugin delay/timer completed
205    DelayComplete {
206        /// Callback ID to resolve
207        callback_id: u64,
208    },
209    /// Background process stdout data
210    ProcessStdout { process_id: u64, data: String },
211    /// Background process stderr data
212    ProcessStderr { process_id: u64, data: String },
213    /// Background process exited
214    ProcessExit {
215        process_id: u64,
216        callback_id: u64,
217        exit_code: i32,
218    },
219    /// Response for a plugin-initiated LSP request
220    LspResponse {
221        language: String,
222        request_id: u64,
223        #[ts(type = "any")]
224        result: Result<JsonValue, String>,
225    },
226    /// Generic plugin response (e.g., GetBufferText result)
227    PluginResponse(crate::api::PluginResponse),
228}
229
230/// Information about a cursor in the editor
231#[derive(Debug, Clone, Serialize, Deserialize, TS)]
232#[ts(export)]
233pub struct CursorInfo {
234    /// Byte position of the cursor
235    pub position: usize,
236    /// Selection range (if any)
237    #[cfg_attr(
238        feature = "plugins",
239        ts(type = "{ start: number; end: number } | null")
240    )]
241    pub selection: Option<Range<usize>>,
242}
243
244/// Specification for an action to execute, with optional repeat count
245#[derive(Debug, Clone, Serialize, Deserialize, TS)]
246#[serde(deny_unknown_fields)]
247#[ts(export)]
248pub struct ActionSpec {
249    /// Action name (e.g., "move_word_right", "delete_line")
250    pub action: String,
251    /// Number of times to repeat the action (default 1)
252    #[serde(default = "default_action_count")]
253    pub count: u32,
254}
255
256fn default_action_count() -> u32 {
257    1
258}
259
260/// Information about a buffer
261#[derive(Debug, Clone, Serialize, Deserialize, TS)]
262#[ts(export)]
263pub struct BufferInfo {
264    /// Buffer ID
265    #[ts(type = "number")]
266    pub id: BufferId,
267    /// File path (if any)
268    #[serde(serialize_with = "serialize_path")]
269    #[ts(type = "string")]
270    pub path: Option<PathBuf>,
271    /// Whether the buffer has been modified
272    pub modified: bool,
273    /// Length of buffer in bytes
274    pub length: usize,
275}
276
277fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
278    s.serialize_str(
279        &path
280            .as_ref()
281            .map(|p| p.to_string_lossy().to_string())
282            .unwrap_or_default(),
283    )
284}
285
286/// Serialize ranges as [start, end] tuples for JS compatibility
287fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
288where
289    S: serde::Serializer,
290{
291    use serde::ser::SerializeSeq;
292    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
293    for range in ranges {
294        seq.serialize_element(&(range.start, range.end))?;
295    }
296    seq.end()
297}
298
299/// Serialize optional ranges as [start, end] tuples for JS compatibility
300fn serialize_opt_ranges_as_tuples<S>(
301    ranges: &Option<Vec<Range<usize>>>,
302    serializer: S,
303) -> Result<S::Ok, S::Error>
304where
305    S: serde::Serializer,
306{
307    match ranges {
308        Some(ranges) => {
309            use serde::ser::SerializeSeq;
310            let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
311            for range in ranges {
312                seq.serialize_element(&(range.start, range.end))?;
313            }
314            seq.end()
315        }
316        None => serializer.serialize_none(),
317    }
318}
319
320/// Diff between current buffer content and last saved snapshot
321#[derive(Debug, Clone, Serialize, Deserialize, TS)]
322#[ts(export)]
323pub struct BufferSavedDiff {
324    pub equal: bool,
325    #[serde(serialize_with = "serialize_ranges_as_tuples")]
326    #[ts(type = "Array<[number, number]>")]
327    pub byte_ranges: Vec<Range<usize>>,
328    #[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
329    #[ts(type = "Array<[number, number]> | null")]
330    pub line_ranges: Option<Vec<Range<usize>>>,
331}
332
333/// Information about the viewport
334#[derive(Debug, Clone, Serialize, Deserialize, TS)]
335#[serde(rename_all = "camelCase")]
336#[ts(export, rename_all = "camelCase")]
337pub struct ViewportInfo {
338    /// Byte position of the first visible line
339    pub top_byte: usize,
340    /// Left column offset (horizontal scroll)
341    pub left_column: usize,
342    /// Viewport width
343    pub width: u16,
344    /// Viewport height
345    pub height: u16,
346}
347
348/// Layout hints supplied by plugins (e.g., Compose mode)
349#[derive(Debug, Clone, Serialize, Deserialize, TS)]
350#[serde(rename_all = "camelCase")]
351#[ts(export, rename_all = "camelCase")]
352pub struct LayoutHints {
353    /// Optional compose width for centering/wrapping
354    pub compose_width: Option<u16>,
355    /// Optional column guides for aligned tables
356    pub column_guides: Option<Vec<u16>>,
357}
358
359// ============================================================================
360// Composite Buffer Configuration (for multi-buffer single-tab views)
361// ============================================================================
362
363/// Layout configuration for composite buffers
364#[derive(Debug, Clone, Serialize, Deserialize, TS)]
365#[serde(deny_unknown_fields)]
366#[ts(export, rename = "TsCompositeLayoutConfig")]
367pub struct CompositeLayoutConfig {
368    /// Layout type: "side-by-side", "stacked", or "unified"
369    #[serde(rename = "type")]
370    #[ts(rename = "type")]
371    pub layout_type: String,
372    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
373    #[serde(default)]
374    pub ratios: Option<Vec<f32>>,
375    /// Show separator between panes
376    #[serde(default = "default_true", rename = "showSeparator")]
377    #[ts(rename = "showSeparator")]
378    pub show_separator: bool,
379    /// Spacing for stacked layout
380    #[serde(default)]
381    pub spacing: Option<u16>,
382}
383
384fn default_true() -> bool {
385    true
386}
387
388/// Source pane configuration for composite buffers
389#[derive(Debug, Clone, Serialize, Deserialize, TS)]
390#[serde(deny_unknown_fields)]
391#[ts(export, rename = "TsCompositeSourceConfig")]
392pub struct CompositeSourceConfig {
393    /// Buffer ID of the source buffer (required)
394    #[serde(rename = "bufferId")]
395    #[ts(rename = "bufferId")]
396    pub buffer_id: usize,
397    /// Label for this pane (e.g., "OLD", "NEW")
398    pub label: String,
399    /// Whether this pane is editable
400    #[serde(default)]
401    pub editable: bool,
402    /// Style configuration
403    #[serde(default)]
404    pub style: Option<CompositePaneStyle>,
405}
406
407/// Style configuration for a composite pane
408#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
409#[serde(deny_unknown_fields)]
410#[ts(export, rename = "TsCompositePaneStyle")]
411pub struct CompositePaneStyle {
412    /// Background color for added lines (RGB)
413    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
414    #[serde(default, rename = "addBg")]
415    #[ts(rename = "addBg", type = "[number, number, number] | null")]
416    pub add_bg: Option<[u8; 3]>,
417    /// Background color for removed lines (RGB)
418    #[serde(default, rename = "removeBg")]
419    #[ts(rename = "removeBg", type = "[number, number, number] | null")]
420    pub remove_bg: Option<[u8; 3]>,
421    /// Background color for modified lines (RGB)
422    #[serde(default, rename = "modifyBg")]
423    #[ts(rename = "modifyBg", type = "[number, number, number] | null")]
424    pub modify_bg: Option<[u8; 3]>,
425    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
426    #[serde(default, rename = "gutterStyle")]
427    #[ts(rename = "gutterStyle")]
428    pub gutter_style: Option<String>,
429}
430
431/// Diff hunk for composite buffer alignment
432#[derive(Debug, Clone, Serialize, Deserialize, TS)]
433#[serde(deny_unknown_fields)]
434#[ts(export, rename = "TsCompositeHunk")]
435pub struct CompositeHunk {
436    /// Starting line in old buffer (0-indexed)
437    #[serde(rename = "oldStart")]
438    #[ts(rename = "oldStart")]
439    pub old_start: usize,
440    /// Number of lines in old buffer
441    #[serde(rename = "oldCount")]
442    #[ts(rename = "oldCount")]
443    pub old_count: usize,
444    /// Starting line in new buffer (0-indexed)
445    #[serde(rename = "newStart")]
446    #[ts(rename = "newStart")]
447    pub new_start: usize,
448    /// Number of lines in new buffer
449    #[serde(rename = "newCount")]
450    #[ts(rename = "newCount")]
451    pub new_count: usize,
452}
453
454/// Options for creating a composite buffer (used by plugin API)
455#[derive(Debug, Clone, Serialize, Deserialize, TS)]
456#[serde(deny_unknown_fields)]
457#[ts(export, rename = "TsCreateCompositeBufferOptions")]
458pub struct CreateCompositeBufferOptions {
459    /// Buffer name (displayed in tabs/title)
460    #[serde(default)]
461    pub name: String,
462    /// Mode for keybindings
463    #[serde(default)]
464    pub mode: String,
465    /// Layout configuration
466    pub layout: CompositeLayoutConfig,
467    /// Source pane configurations
468    pub sources: Vec<CompositeSourceConfig>,
469    /// Diff hunks for alignment (optional)
470    #[serde(default)]
471    pub hunks: Option<Vec<CompositeHunk>>,
472}
473
474/// Wire-format view token kind (serialized for plugin transforms)
475#[derive(Debug, Clone, Serialize, Deserialize, TS)]
476#[ts(export)]
477pub enum ViewTokenWireKind {
478    Text(String),
479    Newline,
480    Space,
481    /// Visual line break inserted by wrapping (not from source)
482    /// Always has source_offset: None
483    Break,
484    /// A single binary byte that should be rendered as <XX>
485    /// Used in binary file mode to ensure cursor positioning works correctly
486    /// (all 4 display chars of <XX> map to the same source byte)
487    BinaryByte(u8),
488}
489
490/// Styling for view tokens (used for injected annotations)
491///
492/// This allows plugins to specify styling for tokens that don't have a source
493/// mapping (sourceOffset: None), such as annotation headers in git blame.
494/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
495#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
496#[serde(deny_unknown_fields)]
497#[ts(export)]
498pub struct ViewTokenStyle {
499    /// Foreground color as RGB tuple
500    #[serde(default)]
501    #[ts(type = "[number, number, number] | null")]
502    pub fg: Option<(u8, u8, u8)>,
503    /// Background color as RGB tuple
504    #[serde(default)]
505    #[ts(type = "[number, number, number] | null")]
506    pub bg: Option<(u8, u8, u8)>,
507    /// Whether to render in bold
508    #[serde(default)]
509    pub bold: bool,
510    /// Whether to render in italic
511    #[serde(default)]
512    pub italic: bool,
513}
514
515/// Wire-format view token with optional source mapping and styling
516#[derive(Debug, Clone, Serialize, Deserialize, TS)]
517#[serde(deny_unknown_fields)]
518#[ts(export)]
519pub struct ViewTokenWire {
520    /// Source byte offset in the buffer. None for injected content (annotations).
521    #[ts(type = "number | null")]
522    pub source_offset: Option<usize>,
523    /// The token content
524    pub kind: ViewTokenWireKind,
525    /// Optional styling for injected content (only used when source_offset is None)
526    #[serde(default)]
527    #[ts(optional)]
528    pub style: Option<ViewTokenStyle>,
529}
530
531/// Transformed view stream payload (plugin-provided)
532#[derive(Debug, Clone, Serialize, Deserialize, TS)]
533#[ts(export)]
534pub struct ViewTransformPayload {
535    /// Byte range this transform applies to (viewport)
536    pub range: Range<usize>,
537    /// Tokens in wire format
538    pub tokens: Vec<ViewTokenWire>,
539    /// Layout hints
540    pub layout_hints: Option<LayoutHints>,
541}
542
543/// Snapshot of editor state for plugin queries
544/// This is updated by the editor on each loop iteration
545#[derive(Debug, Clone, Serialize, Deserialize, TS)]
546#[ts(export)]
547pub struct EditorStateSnapshot {
548    /// Currently active buffer ID
549    pub active_buffer_id: BufferId,
550    /// Currently active split ID
551    pub active_split_id: usize,
552    /// Information about all open buffers
553    pub buffers: HashMap<BufferId, BufferInfo>,
554    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
555    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
556    /// Primary cursor position for the active buffer
557    pub primary_cursor: Option<CursorInfo>,
558    /// All cursor positions for the active buffer
559    pub all_cursors: Vec<CursorInfo>,
560    /// Viewport information for the active buffer
561    pub viewport: Option<ViewportInfo>,
562    /// Cursor positions per buffer (for buffers other than active)
563    pub buffer_cursor_positions: HashMap<BufferId, usize>,
564    /// Text properties per buffer (for virtual buffers with properties)
565    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
566    /// Selected text from the primary cursor (if any selection exists)
567    /// This is populated on each update to avoid needing full buffer access
568    pub selected_text: Option<String>,
569    /// Internal clipboard content (for plugins that need clipboard access)
570    pub clipboard: String,
571    /// Editor's working directory (for file operations and spawning processes)
572    pub working_dir: PathBuf,
573    /// LSP diagnostics per file URI
574    /// Maps file URI string to Vec of diagnostics for that file
575    #[ts(type = "any")]
576    pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
577    /// Runtime config as serde_json::Value (merged user config + defaults)
578    /// This is the runtime config, not just the user's config file
579    #[ts(type = "any")]
580    pub config: serde_json::Value,
581    /// User config as serde_json::Value (only what's in the user's config file)
582    /// Fields not present here are using default values
583    #[ts(type = "any")]
584    pub user_config: serde_json::Value,
585    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
586    /// When set, this mode's keybindings take precedence over normal key handling
587    pub editor_mode: Option<String>,
588}
589
590impl EditorStateSnapshot {
591    pub fn new() -> Self {
592        Self {
593            active_buffer_id: BufferId(0),
594            active_split_id: 0,
595            buffers: HashMap::new(),
596            buffer_saved_diffs: HashMap::new(),
597            primary_cursor: None,
598            all_cursors: Vec::new(),
599            viewport: None,
600            buffer_cursor_positions: HashMap::new(),
601            buffer_text_properties: HashMap::new(),
602            selected_text: None,
603            clipboard: String::new(),
604            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
605            diagnostics: HashMap::new(),
606            config: serde_json::Value::Null,
607            user_config: serde_json::Value::Null,
608            editor_mode: None,
609        }
610    }
611}
612
613impl Default for EditorStateSnapshot {
614    fn default() -> Self {
615        Self::new()
616    }
617}
618
619/// Position for inserting menu items or menus
620#[derive(Debug, Clone, Serialize, Deserialize, TS)]
621#[ts(export)]
622pub enum MenuPosition {
623    /// Add at the beginning
624    Top,
625    /// Add at the end
626    Bottom,
627    /// Add before a specific label
628    Before(String),
629    /// Add after a specific label
630    After(String),
631}
632
633/// Plugin command - allows plugins to send commands to the editor
634#[derive(Debug, Clone, Serialize, Deserialize, TS)]
635#[ts(export)]
636pub enum PluginCommand {
637    /// Insert text at a position in a buffer
638    InsertText {
639        buffer_id: BufferId,
640        position: usize,
641        text: String,
642    },
643
644    /// Delete a range of text from a buffer
645    DeleteRange {
646        buffer_id: BufferId,
647        range: Range<usize>,
648    },
649
650    /// Add an overlay to a buffer, returns handle via response channel
651    AddOverlay {
652        buffer_id: BufferId,
653        namespace: Option<OverlayNamespace>,
654        range: Range<usize>,
655        color: (u8, u8, u8),
656        bg_color: Option<(u8, u8, u8)>,
657        underline: bool,
658        bold: bool,
659        italic: bool,
660        extend_to_line_end: bool,
661    },
662
663    /// Remove an overlay by its opaque handle
664    RemoveOverlay {
665        buffer_id: BufferId,
666        handle: OverlayHandle,
667    },
668
669    /// Set status message
670    SetStatus { message: String },
671
672    /// Apply a theme by name
673    ApplyTheme { theme_name: String },
674
675    /// Reload configuration from file
676    /// After a plugin saves config changes, it should call this to reload the config
677    ReloadConfig,
678
679    /// Register a custom command
680    RegisterCommand { command: Command },
681
682    /// Unregister a command by name
683    UnregisterCommand { name: String },
684
685    /// Open a file in the editor (in background, without switching focus)
686    OpenFileInBackground { path: PathBuf },
687
688    /// Insert text at the current cursor position in the active buffer
689    InsertAtCursor { text: String },
690
691    /// Spawn an async process
692    SpawnProcess {
693        command: String,
694        args: Vec<String>,
695        cwd: Option<String>,
696        callback_id: JsCallbackId,
697    },
698
699    /// Delay/sleep for a duration (async, resolves callback when done)
700    Delay {
701        callback_id: JsCallbackId,
702        duration_ms: u64,
703    },
704
705    /// Spawn a long-running background process
706    /// Unlike SpawnProcess, this returns immediately with a process handle
707    /// and provides streaming output via hooks
708    SpawnBackgroundProcess {
709        /// Unique ID for this process (generated by plugin runtime)
710        process_id: u64,
711        /// Command to execute
712        command: String,
713        /// Arguments to pass
714        args: Vec<String>,
715        /// Working directory (optional)
716        cwd: Option<String>,
717        /// Callback ID to call when process exits
718        callback_id: JsCallbackId,
719    },
720
721    /// Kill a background process by ID
722    KillBackgroundProcess { process_id: u64 },
723
724    /// Wait for a process to complete and get its result
725    /// Used with processes started via SpawnProcess
726    SpawnProcessWait {
727        /// Process ID to wait for
728        process_id: u64,
729        /// Callback ID for async response
730        callback_id: JsCallbackId,
731    },
732
733    /// Set layout hints for a buffer/viewport
734    SetLayoutHints {
735        buffer_id: BufferId,
736        split_id: Option<SplitId>,
737        range: Range<usize>,
738        hints: LayoutHints,
739    },
740
741    /// Enable/disable line numbers for a buffer
742    SetLineNumbers { buffer_id: BufferId, enabled: bool },
743
744    /// Submit a transformed view stream for a viewport
745    SubmitViewTransform {
746        buffer_id: BufferId,
747        split_id: Option<SplitId>,
748        payload: ViewTransformPayload,
749    },
750
751    /// Clear view transform for a buffer/split (returns to normal rendering)
752    ClearViewTransform {
753        buffer_id: BufferId,
754        split_id: Option<SplitId>,
755    },
756
757    /// Remove all overlays from a buffer
758    ClearAllOverlays { buffer_id: BufferId },
759
760    /// Remove all overlays in a namespace
761    ClearNamespace {
762        buffer_id: BufferId,
763        namespace: OverlayNamespace,
764    },
765
766    /// Remove all overlays that overlap with a byte range
767    /// Used for targeted invalidation when content in a range changes
768    ClearOverlaysInRange {
769        buffer_id: BufferId,
770        start: usize,
771        end: usize,
772    },
773
774    /// Add virtual text (inline text that doesn't exist in the buffer)
775    /// Used for color swatches, type hints, parameter hints, etc.
776    AddVirtualText {
777        buffer_id: BufferId,
778        virtual_text_id: String,
779        position: usize,
780        text: String,
781        color: (u8, u8, u8),
782        use_bg: bool, // true = use color as background, false = use as foreground
783        before: bool, // true = before char, false = after char
784    },
785
786    /// Remove a virtual text by ID
787    RemoveVirtualText {
788        buffer_id: BufferId,
789        virtual_text_id: String,
790    },
791
792    /// Remove virtual texts whose ID starts with the given prefix
793    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
794
795    /// Clear all virtual texts from a buffer
796    ClearVirtualTexts { buffer_id: BufferId },
797
798    /// Add a virtual LINE (full line above/below a position)
799    /// Used for git blame headers, code coverage, inline documentation, etc.
800    /// These lines do NOT show line numbers in the gutter.
801    AddVirtualLine {
802        buffer_id: BufferId,
803        /// Byte position to anchor the line to
804        position: usize,
805        /// Full line content to display
806        text: String,
807        /// Foreground color (RGB)
808        fg_color: (u8, u8, u8),
809        /// Background color (RGB), None = transparent
810        bg_color: Option<(u8, u8, u8)>,
811        /// true = above the line containing position, false = below
812        above: bool,
813        /// Namespace for bulk removal (e.g., "git-blame")
814        namespace: String,
815        /// Priority for ordering multiple lines at same position (higher = later)
816        priority: i32,
817    },
818
819    /// Clear all virtual texts in a namespace
820    /// This is the primary way to remove a plugin's virtual lines before updating them.
821    ClearVirtualTextNamespace {
822        buffer_id: BufferId,
823        namespace: String,
824    },
825
826    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
827    RefreshLines { buffer_id: BufferId },
828
829    /// Set a line indicator in the gutter's indicator column
830    /// Used for git gutter, breakpoints, bookmarks, etc.
831    SetLineIndicator {
832        buffer_id: BufferId,
833        /// Line number (0-indexed)
834        line: usize,
835        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
836        namespace: String,
837        /// Symbol to display (e.g., "│", "●", "★")
838        symbol: String,
839        /// Color as RGB tuple
840        color: (u8, u8, u8),
841        /// Priority for display when multiple indicators exist (higher wins)
842        priority: i32,
843    },
844
845    /// Clear all line indicators for a specific namespace
846    ClearLineIndicators {
847        buffer_id: BufferId,
848        /// Namespace to clear (e.g., "git-gutter")
849        namespace: String,
850    },
851
852    /// Set file explorer decorations for a namespace
853    SetFileExplorerDecorations {
854        /// Namespace for grouping (e.g., "git-status")
855        namespace: String,
856        /// Decorations to apply
857        decorations: Vec<FileExplorerDecoration>,
858    },
859
860    /// Clear file explorer decorations for a namespace
861    ClearFileExplorerDecorations {
862        /// Namespace to clear (e.g., "git-status")
863        namespace: String,
864    },
865
866    /// Open a file at a specific line and column
867    /// Line and column are 1-indexed to match git grep output
868    OpenFileAtLocation {
869        path: PathBuf,
870        line: Option<usize>,   // 1-indexed, None = go to start
871        column: Option<usize>, // 1-indexed, None = go to line start
872    },
873
874    /// Open a file in a specific split at a given line and column
875    /// Line and column are 1-indexed to match git grep output
876    OpenFileInSplit {
877        split_id: usize,
878        path: PathBuf,
879        line: Option<usize>,   // 1-indexed, None = go to start
880        column: Option<usize>, // 1-indexed, None = go to line start
881    },
882
883    /// Start a prompt (minibuffer) with a custom type identifier
884    /// This allows plugins to create interactive prompts
885    StartPrompt {
886        label: String,
887        prompt_type: String, // e.g., "git-grep", "git-find-file"
888    },
889
890    /// Start a prompt with pre-filled initial value
891    StartPromptWithInitial {
892        label: String,
893        prompt_type: String,
894        initial_value: String,
895    },
896
897    /// Start an async prompt that returns result via callback
898    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
899    StartPromptAsync {
900        label: String,
901        initial_value: String,
902        callback_id: JsCallbackId,
903    },
904
905    /// Update the suggestions list for the current prompt
906    /// Uses the editor's Suggestion type
907    SetPromptSuggestions { suggestions: Vec<Suggestion> },
908
909    /// Add a menu item to an existing menu
910    /// Add a menu item to an existing menu
911    AddMenuItem {
912        menu_label: String,
913        item: MenuItem,
914        position: MenuPosition,
915    },
916
917    /// Add a new top-level menu
918    AddMenu { menu: Menu, position: MenuPosition },
919
920    /// Remove a menu item from a menu
921    RemoveMenuItem {
922        menu_label: String,
923        item_label: String,
924    },
925
926    /// Remove a top-level menu
927    RemoveMenu { menu_label: String },
928
929    /// Create a new virtual buffer (not backed by a file)
930    CreateVirtualBuffer {
931        /// Display name (e.g., "*Diagnostics*")
932        name: String,
933        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
934        mode: String,
935        /// Whether the buffer is read-only
936        read_only: bool,
937    },
938
939    /// Create a virtual buffer and set its content in one operation
940    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
941    /// because it doesn't require tracking the buffer ID
942    CreateVirtualBufferWithContent {
943        /// Display name (e.g., "*Diagnostics*")
944        name: String,
945        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
946        mode: String,
947        /// Whether the buffer is read-only
948        read_only: bool,
949        /// Entries with text and embedded properties
950        entries: Vec<TextPropertyEntry>,
951        /// Whether to show line numbers in the gutter
952        show_line_numbers: bool,
953        /// Whether to show cursors in the buffer
954        show_cursors: bool,
955        /// Whether editing is disabled (blocks editing commands)
956        editing_disabled: bool,
957        /// Whether this buffer should be hidden from tabs (for composite source buffers)
958        hidden_from_tabs: bool,
959        /// Optional request ID for async response
960        request_id: Option<u64>,
961    },
962
963    /// Create a virtual buffer in a horizontal split
964    /// Opens the buffer in a new pane below the current one
965    CreateVirtualBufferInSplit {
966        /// Display name (e.g., "*Diagnostics*")
967        name: String,
968        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
969        mode: String,
970        /// Whether the buffer is read-only
971        read_only: bool,
972        /// Entries with text and embedded properties
973        entries: Vec<TextPropertyEntry>,
974        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
975        ratio: f32,
976        /// Split direction ("horizontal" or "vertical"), default horizontal
977        direction: Option<String>,
978        /// Optional panel ID for idempotent operations (if panel exists, update content)
979        panel_id: Option<String>,
980        /// Whether to show line numbers in the buffer (default true)
981        show_line_numbers: bool,
982        /// Whether to show cursors in the buffer (default true)
983        show_cursors: bool,
984        /// Whether editing is disabled for this buffer (default false)
985        editing_disabled: bool,
986        /// Whether line wrapping is enabled for this split (None = use global setting)
987        line_wrap: Option<bool>,
988        /// Optional request ID for async response (if set, editor will send back buffer ID)
989        request_id: Option<u64>,
990    },
991
992    /// Set the content of a virtual buffer with text properties
993    SetVirtualBufferContent {
994        buffer_id: BufferId,
995        /// Entries with text and embedded properties
996        entries: Vec<TextPropertyEntry>,
997    },
998
999    /// Get text properties at the cursor position in a buffer
1000    GetTextPropertiesAtCursor { buffer_id: BufferId },
1001
1002    /// Define a buffer mode with keybindings
1003    DefineMode {
1004        name: String,
1005        parent: Option<String>,
1006        bindings: Vec<(String, String)>, // (key_string, command_name)
1007        read_only: bool,
1008    },
1009
1010    /// Switch the current split to display a buffer
1011    ShowBuffer { buffer_id: BufferId },
1012
1013    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1014    CreateVirtualBufferInExistingSplit {
1015        /// Display name (e.g., "*Commit Details*")
1016        name: String,
1017        /// Mode name for buffer-local keybindings
1018        mode: String,
1019        /// Whether the buffer is read-only
1020        read_only: bool,
1021        /// Entries with text and embedded properties
1022        entries: Vec<TextPropertyEntry>,
1023        /// Target split ID where the buffer should be displayed
1024        split_id: SplitId,
1025        /// Whether to show line numbers in the buffer (default true)
1026        show_line_numbers: bool,
1027        /// Whether to show cursors in the buffer (default true)
1028        show_cursors: bool,
1029        /// Whether editing is disabled for this buffer (default false)
1030        editing_disabled: bool,
1031        /// Whether line wrapping is enabled for this split (None = use global setting)
1032        line_wrap: Option<bool>,
1033        /// Optional request ID for async response
1034        request_id: Option<u64>,
1035    },
1036
1037    /// Close a buffer and remove it from all splits
1038    CloseBuffer { buffer_id: BufferId },
1039
1040    /// Create a composite buffer that displays multiple source buffers
1041    /// Used for side-by-side diff, unified diff, and 3-way merge views
1042    CreateCompositeBuffer {
1043        /// Display name (shown in tab bar)
1044        name: String,
1045        /// Mode name for keybindings (e.g., "diff-view")
1046        mode: String,
1047        /// Layout configuration
1048        layout: CompositeLayoutConfig,
1049        /// Source pane configurations
1050        sources: Vec<CompositeSourceConfig>,
1051        /// Diff hunks for line alignment (optional)
1052        hunks: Option<Vec<CompositeHunk>>,
1053        /// Request ID for async response
1054        request_id: Option<u64>,
1055    },
1056
1057    /// Update alignment for a composite buffer (e.g., after source edit)
1058    UpdateCompositeAlignment {
1059        buffer_id: BufferId,
1060        hunks: Vec<CompositeHunk>,
1061    },
1062
1063    /// Close a composite buffer
1064    CloseCompositeBuffer { buffer_id: BufferId },
1065
1066    /// Focus a specific split
1067    FocusSplit { split_id: SplitId },
1068
1069    /// Set the buffer displayed in a specific split
1070    SetSplitBuffer {
1071        split_id: SplitId,
1072        buffer_id: BufferId,
1073    },
1074
1075    /// Set the scroll position of a specific split
1076    SetSplitScroll { split_id: SplitId, top_byte: usize },
1077
1078    /// Request syntax highlights for a buffer range
1079    RequestHighlights {
1080        buffer_id: BufferId,
1081        range: Range<usize>,
1082        request_id: u64,
1083    },
1084
1085    /// Close a split (if not the last one)
1086    CloseSplit { split_id: SplitId },
1087
1088    /// Set the ratio of a split container
1089    SetSplitRatio {
1090        split_id: SplitId,
1091        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1092        ratio: f32,
1093    },
1094
1095    /// Distribute splits evenly - make all given splits equal size
1096    DistributeSplitsEvenly {
1097        /// Split IDs to distribute evenly
1098        split_ids: Vec<SplitId>,
1099    },
1100
1101    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1102    SetBufferCursor {
1103        buffer_id: BufferId,
1104        /// Byte offset position for the cursor
1105        position: usize,
1106    },
1107
1108    /// Send an arbitrary LSP request and return the raw JSON response
1109    SendLspRequest {
1110        language: String,
1111        method: String,
1112        #[ts(type = "any")]
1113        params: Option<JsonValue>,
1114        request_id: u64,
1115    },
1116
1117    /// Set the internal clipboard content
1118    SetClipboard { text: String },
1119
1120    /// Delete the current selection in the active buffer
1121    /// This deletes all selected text across all cursors
1122    DeleteSelection,
1123
1124    /// Set or unset a custom context
1125    /// Custom contexts are plugin-defined states that can be used to control command visibility
1126    /// For example, "config-editor" context could make config editor commands available
1127    SetContext {
1128        /// Context name (e.g., "config-editor")
1129        name: String,
1130        /// Whether the context is active
1131        active: bool,
1132    },
1133
1134    /// Set the hunks for the Review Diff tool
1135    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1136
1137    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1138    /// Used by vi mode plugin to run motions and calculate cursor ranges
1139    ExecuteAction {
1140        /// Action name (e.g., "move_word_right", "move_line_end")
1141        action_name: String,
1142    },
1143
1144    /// Execute multiple actions in sequence, each with an optional repeat count
1145    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1146    /// All actions execute atomically with no plugin roundtrips between them
1147    ExecuteActions {
1148        /// List of actions to execute in sequence
1149        actions: Vec<ActionSpec>,
1150    },
1151
1152    /// Get text from a buffer range (for yank operations)
1153    GetBufferText {
1154        /// Buffer ID
1155        buffer_id: BufferId,
1156        /// Start byte offset
1157        start: usize,
1158        /// End byte offset
1159        end: usize,
1160        /// Request ID for async response
1161        request_id: u64,
1162    },
1163
1164    /// Get byte offset of the start of a line (async)
1165    /// Line is 0-indexed (0 = first line)
1166    GetLineStartPosition {
1167        /// Buffer ID (0 for active buffer)
1168        buffer_id: BufferId,
1169        /// Line number (0-indexed)
1170        line: u32,
1171        /// Request ID for async response
1172        request_id: u64,
1173    },
1174
1175    /// Set the global editor mode (for modal editing like vi mode)
1176    /// When set, the mode's keybindings take precedence over normal editing
1177    SetEditorMode {
1178        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1179        mode: Option<String>,
1180    },
1181
1182    /// Show an action popup with buttons for user interaction
1183    /// When the user selects an action, the ActionPopupResult hook is fired
1184    ShowActionPopup {
1185        /// Unique identifier for the popup (used in ActionPopupResult)
1186        popup_id: String,
1187        /// Title text for the popup
1188        title: String,
1189        /// Body message (supports basic formatting)
1190        message: String,
1191        /// Action buttons to display
1192        actions: Vec<ActionPopupAction>,
1193    },
1194
1195    /// Disable LSP for a specific language and persist to config
1196    DisableLspForLanguage {
1197        /// The language to disable LSP for (e.g., "python", "rust")
1198        language: String,
1199    },
1200
1201    /// Create a scroll sync group for anchor-based synchronized scrolling
1202    /// Used for side-by-side diff views where two panes need to scroll together
1203    /// The plugin provides the group ID (must be unique per plugin)
1204    CreateScrollSyncGroup {
1205        /// Plugin-assigned group ID
1206        group_id: u32,
1207        /// The left (primary) split - scroll position is tracked in this split's line space
1208        left_split: SplitId,
1209        /// The right (secondary) split - position is derived from anchors
1210        right_split: SplitId,
1211    },
1212
1213    /// Set sync anchors for a scroll sync group
1214    /// Anchors map corresponding line numbers between left and right buffers
1215    SetScrollSyncAnchors {
1216        /// The group ID returned by CreateScrollSyncGroup
1217        group_id: u32,
1218        /// List of (left_line, right_line) pairs marking corresponding positions
1219        anchors: Vec<(usize, usize)>,
1220    },
1221
1222    /// Remove a scroll sync group
1223    RemoveScrollSyncGroup {
1224        /// The group ID returned by CreateScrollSyncGroup
1225        group_id: u32,
1226    },
1227
1228    /// Save a buffer to a specific file path
1229    /// Used by :w filename command to save unnamed buffers or save-as
1230    SaveBufferToPath {
1231        /// Buffer ID to save
1232        buffer_id: BufferId,
1233        /// Path to save to
1234        path: PathBuf,
1235    },
1236}
1237
1238/// Hunk status for Review Diff
1239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1240#[ts(export)]
1241pub enum HunkStatus {
1242    Pending,
1243    Staged,
1244    Discarded,
1245}
1246
1247/// A high-level hunk directive for the Review Diff tool
1248#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1249#[ts(export)]
1250pub struct ReviewHunk {
1251    pub id: String,
1252    pub file: String,
1253    pub context_header: String,
1254    pub status: HunkStatus,
1255    /// 0-indexed line range in the base (HEAD) version
1256    pub base_range: Option<(usize, usize)>,
1257    /// 0-indexed line range in the modified (Working) version
1258    pub modified_range: Option<(usize, usize)>,
1259}
1260
1261/// Action button for action popups
1262#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1263#[serde(deny_unknown_fields)]
1264#[ts(export, rename = "TsActionPopupAction")]
1265pub struct ActionPopupAction {
1266    /// Unique action identifier (returned in ActionPopupResult)
1267    pub id: String,
1268    /// Display text for the button (can include command hints)
1269    pub label: String,
1270}
1271
1272/// Options for showActionPopup
1273#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1274#[serde(deny_unknown_fields)]
1275#[ts(export)]
1276pub struct ActionPopupOptions {
1277    /// Unique identifier for the popup (used in ActionPopupResult)
1278    pub id: String,
1279    /// Title text for the popup
1280    pub title: String,
1281    /// Body message (supports basic formatting)
1282    pub message: String,
1283    /// Action buttons to display
1284    pub actions: Vec<ActionPopupAction>,
1285}
1286
1287/// Syntax highlight span for a buffer range
1288#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1289#[ts(export)]
1290pub struct TsHighlightSpan {
1291    pub start: u32,
1292    pub end: u32,
1293    #[ts(type = "[number, number, number]")]
1294    pub color: (u8, u8, u8),
1295    pub bold: bool,
1296    pub italic: bool,
1297}
1298
1299/// Result from spawning a process with spawnProcess
1300#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1301#[ts(export)]
1302pub struct SpawnResult {
1303    /// Complete stdout as string
1304    pub stdout: String,
1305    /// Complete stderr as string
1306    pub stderr: String,
1307    /// Process exit code (0 usually means success, -1 if killed)
1308    pub exit_code: i32,
1309}
1310
1311/// Result from spawning a background process
1312#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1313#[ts(export)]
1314pub struct BackgroundProcessResult {
1315    /// Unique process ID for later reference
1316    #[ts(type = "number")]
1317    pub process_id: u64,
1318    /// Process exit code (0 usually means success, -1 if killed)
1319    /// Only present when the process has exited
1320    pub exit_code: i32,
1321}
1322
1323/// Entry for virtual buffer content with optional text properties (JS API version)
1324#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1325#[serde(deny_unknown_fields)]
1326#[ts(export, rename = "TextPropertyEntry")]
1327pub struct JsTextPropertyEntry {
1328    /// Text content for this entry
1329    pub text: String,
1330    /// Optional properties attached to this text (e.g., file path, line number)
1331    #[serde(default)]
1332    #[ts(optional, type = "Record<string, unknown>")]
1333    pub properties: Option<HashMap<String, JsonValue>>,
1334}
1335
1336/// Directory entry returned by readDir
1337#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1338#[ts(export)]
1339pub struct DirEntry {
1340    /// File/directory name
1341    pub name: String,
1342    /// True if this is a file
1343    pub is_file: bool,
1344    /// True if this is a directory
1345    pub is_dir: bool,
1346}
1347
1348/// Position in a document (line and character)
1349#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1350#[ts(export)]
1351pub struct JsPosition {
1352    /// Zero-indexed line number
1353    pub line: u32,
1354    /// Zero-indexed character offset
1355    pub character: u32,
1356}
1357
1358/// Range in a document (start and end positions)
1359#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1360#[ts(export)]
1361pub struct JsRange {
1362    /// Start position
1363    pub start: JsPosition,
1364    /// End position
1365    pub end: JsPosition,
1366}
1367
1368/// Diagnostic from LSP
1369#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1370#[ts(export)]
1371pub struct JsDiagnostic {
1372    /// Document URI
1373    pub uri: String,
1374    /// Diagnostic message
1375    pub message: String,
1376    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
1377    pub severity: Option<u8>,
1378    /// Range in the document
1379    pub range: JsRange,
1380    /// Source of the diagnostic (e.g., "typescript", "eslint")
1381    #[ts(optional)]
1382    pub source: Option<String>,
1383}
1384
1385/// Options for createVirtualBuffer
1386#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1387#[serde(deny_unknown_fields)]
1388#[ts(export)]
1389pub struct CreateVirtualBufferOptions {
1390    /// Buffer name (displayed in tabs/title)
1391    pub name: String,
1392    /// Mode for keybindings (e.g., "git-log", "search-results")
1393    #[serde(default)]
1394    #[ts(optional)]
1395    pub mode: Option<String>,
1396    /// Whether buffer is read-only (default: false)
1397    #[serde(default, rename = "readOnly")]
1398    #[ts(optional, rename = "readOnly")]
1399    pub read_only: Option<bool>,
1400    /// Show line numbers in gutter (default: false)
1401    #[serde(default, rename = "showLineNumbers")]
1402    #[ts(optional, rename = "showLineNumbers")]
1403    pub show_line_numbers: Option<bool>,
1404    /// Show cursor (default: true)
1405    #[serde(default, rename = "showCursors")]
1406    #[ts(optional, rename = "showCursors")]
1407    pub show_cursors: Option<bool>,
1408    /// Disable text editing (default: false)
1409    #[serde(default, rename = "editingDisabled")]
1410    #[ts(optional, rename = "editingDisabled")]
1411    pub editing_disabled: Option<bool>,
1412    /// Hide from tab bar (default: false)
1413    #[serde(default, rename = "hiddenFromTabs")]
1414    #[ts(optional, rename = "hiddenFromTabs")]
1415    pub hidden_from_tabs: Option<bool>,
1416    /// Initial content entries with optional properties
1417    #[serde(default)]
1418    #[ts(optional)]
1419    pub entries: Option<Vec<JsTextPropertyEntry>>,
1420}
1421
1422/// Options for createVirtualBufferInSplit
1423#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1424#[serde(deny_unknown_fields)]
1425#[ts(export)]
1426pub struct CreateVirtualBufferInSplitOptions {
1427    /// Buffer name (displayed in tabs/title)
1428    pub name: String,
1429    /// Mode for keybindings (e.g., "git-log", "search-results")
1430    #[serde(default)]
1431    #[ts(optional)]
1432    pub mode: Option<String>,
1433    /// Whether buffer is read-only (default: false)
1434    #[serde(default, rename = "readOnly")]
1435    #[ts(optional, rename = "readOnly")]
1436    pub read_only: Option<bool>,
1437    /// Split ratio 0.0-1.0 (default: 0.5)
1438    #[serde(default)]
1439    #[ts(optional)]
1440    pub ratio: Option<f32>,
1441    /// Split direction: "horizontal" or "vertical"
1442    #[serde(default)]
1443    #[ts(optional)]
1444    pub direction: Option<String>,
1445    /// Panel ID to split from
1446    #[serde(default, rename = "panelId")]
1447    #[ts(optional, rename = "panelId")]
1448    pub panel_id: Option<String>,
1449    /// Show line numbers in gutter (default: true)
1450    #[serde(default, rename = "showLineNumbers")]
1451    #[ts(optional, rename = "showLineNumbers")]
1452    pub show_line_numbers: Option<bool>,
1453    /// Show cursor (default: true)
1454    #[serde(default, rename = "showCursors")]
1455    #[ts(optional, rename = "showCursors")]
1456    pub show_cursors: Option<bool>,
1457    /// Disable text editing (default: false)
1458    #[serde(default, rename = "editingDisabled")]
1459    #[ts(optional, rename = "editingDisabled")]
1460    pub editing_disabled: Option<bool>,
1461    /// Enable line wrapping
1462    #[serde(default, rename = "lineWrap")]
1463    #[ts(optional, rename = "lineWrap")]
1464    pub line_wrap: Option<bool>,
1465    /// Initial content entries with optional properties
1466    #[serde(default)]
1467    #[ts(optional)]
1468    pub entries: Option<Vec<JsTextPropertyEntry>>,
1469}
1470
1471/// Options for createVirtualBufferInExistingSplit
1472#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1473#[serde(deny_unknown_fields)]
1474#[ts(export)]
1475pub struct CreateVirtualBufferInExistingSplitOptions {
1476    /// Buffer name (displayed in tabs/title)
1477    pub name: String,
1478    /// Target split ID (required)
1479    #[serde(rename = "splitId")]
1480    #[ts(rename = "splitId")]
1481    pub split_id: usize,
1482    /// Mode for keybindings (e.g., "git-log", "search-results")
1483    #[serde(default)]
1484    #[ts(optional)]
1485    pub mode: Option<String>,
1486    /// Whether buffer is read-only (default: false)
1487    #[serde(default, rename = "readOnly")]
1488    #[ts(optional, rename = "readOnly")]
1489    pub read_only: Option<bool>,
1490    /// Show line numbers in gutter (default: true)
1491    #[serde(default, rename = "showLineNumbers")]
1492    #[ts(optional, rename = "showLineNumbers")]
1493    pub show_line_numbers: Option<bool>,
1494    /// Show cursor (default: true)
1495    #[serde(default, rename = "showCursors")]
1496    #[ts(optional, rename = "showCursors")]
1497    pub show_cursors: Option<bool>,
1498    /// Disable text editing (default: false)
1499    #[serde(default, rename = "editingDisabled")]
1500    #[ts(optional, rename = "editingDisabled")]
1501    pub editing_disabled: Option<bool>,
1502    /// Enable line wrapping
1503    #[serde(default, rename = "lineWrap")]
1504    #[ts(optional, rename = "lineWrap")]
1505    pub line_wrap: Option<bool>,
1506    /// Initial content entries with optional properties
1507    #[serde(default)]
1508    #[ts(optional)]
1509    pub entries: Option<Vec<JsTextPropertyEntry>>,
1510}
1511
1512/// Result of getTextPropertiesAtCursor - array of property objects
1513///
1514/// Each element contains the properties from a text property span that overlaps
1515/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
1516#[derive(Debug, Clone, Serialize, TS)]
1517#[ts(export, type = "Array<Record<string, unknown>>")]
1518pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
1519
1520// Implement FromJs for option types using rquickjs_serde
1521#[cfg(feature = "plugins")]
1522mod fromjs_impls {
1523    use super::*;
1524    use rquickjs::{Ctx, FromJs, Value};
1525
1526    impl<'js> FromJs<'js> for JsTextPropertyEntry {
1527        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1528            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1529                from: "object",
1530                to: "JsTextPropertyEntry",
1531                message: Some(e.to_string()),
1532            })
1533        }
1534    }
1535
1536    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
1537        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1538            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1539                from: "object",
1540                to: "CreateVirtualBufferOptions",
1541                message: Some(e.to_string()),
1542            })
1543        }
1544    }
1545
1546    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
1547        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1548            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1549                from: "object",
1550                to: "CreateVirtualBufferInSplitOptions",
1551                message: Some(e.to_string()),
1552            })
1553        }
1554    }
1555
1556    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
1557        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1558            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1559                from: "object",
1560                to: "CreateVirtualBufferInExistingSplitOptions",
1561                message: Some(e.to_string()),
1562            })
1563        }
1564    }
1565
1566    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
1567        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1568            rquickjs_serde::to_value(ctx.clone(), &self.0)
1569                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1570        }
1571    }
1572
1573    // === Additional input types for type-safe plugin API ===
1574
1575    impl<'js> FromJs<'js> for ActionSpec {
1576        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1577            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1578                from: "object",
1579                to: "ActionSpec",
1580                message: Some(e.to_string()),
1581            })
1582        }
1583    }
1584
1585    impl<'js> FromJs<'js> for ActionPopupAction {
1586        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1587            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1588                from: "object",
1589                to: "ActionPopupAction",
1590                message: Some(e.to_string()),
1591            })
1592        }
1593    }
1594
1595    impl<'js> FromJs<'js> for ActionPopupOptions {
1596        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1597            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1598                from: "object",
1599                to: "ActionPopupOptions",
1600                message: Some(e.to_string()),
1601            })
1602        }
1603    }
1604
1605    impl<'js> FromJs<'js> for ViewTokenWire {
1606        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1607            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1608                from: "object",
1609                to: "ViewTokenWire",
1610                message: Some(e.to_string()),
1611            })
1612        }
1613    }
1614
1615    impl<'js> FromJs<'js> for ViewTokenStyle {
1616        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1617            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1618                from: "object",
1619                to: "ViewTokenStyle",
1620                message: Some(e.to_string()),
1621            })
1622        }
1623    }
1624
1625    impl<'js> FromJs<'js> for LayoutHints {
1626        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1627            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1628                from: "object",
1629                to: "LayoutHints",
1630                message: Some(e.to_string()),
1631            })
1632        }
1633    }
1634
1635    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
1636        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1637            // Use two-step deserialization for complex nested structures
1638            let json: serde_json::Value =
1639                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1640                    from: "object",
1641                    to: "CreateCompositeBufferOptions (json)",
1642                    message: Some(e.to_string()),
1643                })?;
1644            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
1645                from: "json",
1646                to: "CreateCompositeBufferOptions",
1647                message: Some(e.to_string()),
1648            })
1649        }
1650    }
1651
1652    impl<'js> FromJs<'js> for CompositeHunk {
1653        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1654            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1655                from: "object",
1656                to: "CompositeHunk",
1657                message: Some(e.to_string()),
1658            })
1659        }
1660    }
1661}
1662
1663/// Plugin API context - provides safe access to editor functionality
1664pub struct PluginApi {
1665    /// Hook registry (shared with editor)
1666    hooks: Arc<RwLock<HookRegistry>>,
1667
1668    /// Command registry (shared with editor)
1669    commands: Arc<RwLock<CommandRegistry>>,
1670
1671    /// Command queue for sending commands to editor
1672    command_sender: std::sync::mpsc::Sender<PluginCommand>,
1673
1674    /// Snapshot of editor state (read-only for plugins)
1675    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1676}
1677
1678impl PluginApi {
1679    /// Create a new plugin API context
1680    pub fn new(
1681        hooks: Arc<RwLock<HookRegistry>>,
1682        commands: Arc<RwLock<CommandRegistry>>,
1683        command_sender: std::sync::mpsc::Sender<PluginCommand>,
1684        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1685    ) -> Self {
1686        Self {
1687            hooks,
1688            commands,
1689            command_sender,
1690            state_snapshot,
1691        }
1692    }
1693
1694    /// Register a hook callback
1695    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
1696        let mut hooks = self.hooks.write().unwrap();
1697        hooks.add_hook(hook_name, callback);
1698    }
1699
1700    /// Remove all hooks for a specific name
1701    pub fn unregister_hooks(&self, hook_name: &str) {
1702        let mut hooks = self.hooks.write().unwrap();
1703        hooks.remove_hooks(hook_name);
1704    }
1705
1706    /// Register a command
1707    pub fn register_command(&self, command: Command) {
1708        let commands = self.commands.read().unwrap();
1709        commands.register(command);
1710    }
1711
1712    /// Unregister a command by name
1713    pub fn unregister_command(&self, name: &str) {
1714        let commands = self.commands.read().unwrap();
1715        commands.unregister(name);
1716    }
1717
1718    /// Send a command to the editor (async/non-blocking)
1719    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
1720        self.command_sender
1721            .send(command)
1722            .map_err(|e| format!("Failed to send command: {}", e))
1723    }
1724
1725    /// Insert text at a position in a buffer
1726    pub fn insert_text(
1727        &self,
1728        buffer_id: BufferId,
1729        position: usize,
1730        text: String,
1731    ) -> Result<(), String> {
1732        self.send_command(PluginCommand::InsertText {
1733            buffer_id,
1734            position,
1735            text,
1736        })
1737    }
1738
1739    /// Delete a range of text from a buffer
1740    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
1741        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
1742    }
1743
1744    /// Add an overlay (decoration) to a buffer
1745    /// Returns an opaque handle that can be used to remove the overlay later
1746    #[allow(clippy::too_many_arguments)]
1747    pub fn add_overlay(
1748        &self,
1749        buffer_id: BufferId,
1750        namespace: Option<String>,
1751        range: Range<usize>,
1752        color: (u8, u8, u8),
1753        bg_color: Option<(u8, u8, u8)>,
1754        underline: bool,
1755        bold: bool,
1756        italic: bool,
1757        extend_to_line_end: bool,
1758    ) -> Result<(), String> {
1759        self.send_command(PluginCommand::AddOverlay {
1760            buffer_id,
1761            namespace: namespace.map(OverlayNamespace::from_string),
1762            range,
1763            color,
1764            bg_color,
1765            underline,
1766            bold,
1767            italic,
1768            extend_to_line_end,
1769        })
1770    }
1771
1772    /// Remove an overlay from a buffer by its handle
1773    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
1774        self.send_command(PluginCommand::RemoveOverlay {
1775            buffer_id,
1776            handle: OverlayHandle::from_string(handle),
1777        })
1778    }
1779
1780    /// Clear all overlays in a namespace from a buffer
1781    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
1782        self.send_command(PluginCommand::ClearNamespace {
1783            buffer_id,
1784            namespace: OverlayNamespace::from_string(namespace),
1785        })
1786    }
1787
1788    /// Clear all overlays that overlap with a byte range
1789    /// Used for targeted invalidation when content changes
1790    pub fn clear_overlays_in_range(
1791        &self,
1792        buffer_id: BufferId,
1793        start: usize,
1794        end: usize,
1795    ) -> Result<(), String> {
1796        self.send_command(PluginCommand::ClearOverlaysInRange {
1797            buffer_id,
1798            start,
1799            end,
1800        })
1801    }
1802
1803    /// Set the status message
1804    pub fn set_status(&self, message: String) -> Result<(), String> {
1805        self.send_command(PluginCommand::SetStatus { message })
1806    }
1807
1808    /// Open a file at a specific line and column (1-indexed)
1809    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
1810    pub fn open_file_at_location(
1811        &self,
1812        path: PathBuf,
1813        line: Option<usize>,
1814        column: Option<usize>,
1815    ) -> Result<(), String> {
1816        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
1817    }
1818
1819    /// Open a file in a specific split at a line and column
1820    ///
1821    /// Similar to open_file_at_location but targets a specific split pane.
1822    /// The split_id is the ID of the split pane to open the file in.
1823    pub fn open_file_in_split(
1824        &self,
1825        split_id: usize,
1826        path: PathBuf,
1827        line: Option<usize>,
1828        column: Option<usize>,
1829    ) -> Result<(), String> {
1830        self.send_command(PluginCommand::OpenFileInSplit {
1831            split_id,
1832            path,
1833            line,
1834            column,
1835        })
1836    }
1837
1838    /// Start a prompt (minibuffer) with a custom type identifier
1839    /// The prompt_type is used to filter hooks in plugin code
1840    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
1841        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
1842    }
1843
1844    /// Set the suggestions for the current prompt
1845    /// This updates the prompt's autocomplete/selection list
1846    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
1847        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
1848    }
1849
1850    /// Add a menu item to an existing menu
1851    pub fn add_menu_item(
1852        &self,
1853        menu_label: String,
1854        item: MenuItem,
1855        position: MenuPosition,
1856    ) -> Result<(), String> {
1857        self.send_command(PluginCommand::AddMenuItem {
1858            menu_label,
1859            item,
1860            position,
1861        })
1862    }
1863
1864    /// Add a new top-level menu
1865    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
1866        self.send_command(PluginCommand::AddMenu { menu, position })
1867    }
1868
1869    /// Remove a menu item from a menu
1870    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
1871        self.send_command(PluginCommand::RemoveMenuItem {
1872            menu_label,
1873            item_label,
1874        })
1875    }
1876
1877    /// Remove a top-level menu
1878    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
1879        self.send_command(PluginCommand::RemoveMenu { menu_label })
1880    }
1881
1882    // === Virtual Buffer Methods ===
1883
1884    /// Create a new virtual buffer (not backed by a file)
1885    ///
1886    /// Virtual buffers are used for special displays like diagnostic lists,
1887    /// search results, etc. They have their own mode for keybindings.
1888    pub fn create_virtual_buffer(
1889        &self,
1890        name: String,
1891        mode: String,
1892        read_only: bool,
1893    ) -> Result<(), String> {
1894        self.send_command(PluginCommand::CreateVirtualBuffer {
1895            name,
1896            mode,
1897            read_only,
1898        })
1899    }
1900
1901    /// Create a virtual buffer and set its content in one operation
1902    ///
1903    /// This is the preferred way to create virtual buffers since it doesn't
1904    /// require tracking the buffer ID. The buffer is created and populated
1905    /// atomically.
1906    pub fn create_virtual_buffer_with_content(
1907        &self,
1908        name: String,
1909        mode: String,
1910        read_only: bool,
1911        entries: Vec<TextPropertyEntry>,
1912    ) -> Result<(), String> {
1913        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
1914            name,
1915            mode,
1916            read_only,
1917            entries,
1918            show_line_numbers: true,
1919            show_cursors: true,
1920            editing_disabled: false,
1921            hidden_from_tabs: false,
1922            request_id: None,
1923        })
1924    }
1925
1926    /// Set the content of a virtual buffer with text properties
1927    ///
1928    /// Each entry contains text and metadata properties (e.g., source location).
1929    pub fn set_virtual_buffer_content(
1930        &self,
1931        buffer_id: BufferId,
1932        entries: Vec<TextPropertyEntry>,
1933    ) -> Result<(), String> {
1934        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
1935    }
1936
1937    /// Get text properties at cursor position in a buffer
1938    ///
1939    /// This triggers a command that will make properties available to plugins.
1940    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
1941        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
1942    }
1943
1944    /// Define a buffer mode with keybindings
1945    ///
1946    /// Modes can inherit from parent modes (e.g., "diagnostics-list" inherits from "special").
1947    /// Bindings are specified as (key_string, command_name) pairs.
1948    pub fn define_mode(
1949        &self,
1950        name: String,
1951        parent: Option<String>,
1952        bindings: Vec<(String, String)>,
1953        read_only: bool,
1954    ) -> Result<(), String> {
1955        self.send_command(PluginCommand::DefineMode {
1956            name,
1957            parent,
1958            bindings,
1959            read_only,
1960        })
1961    }
1962
1963    /// Switch the current split to display a buffer
1964    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
1965        self.send_command(PluginCommand::ShowBuffer { buffer_id })
1966    }
1967
1968    /// Set the scroll position of a specific split
1969    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
1970        self.send_command(PluginCommand::SetSplitScroll {
1971            split_id: SplitId(split_id),
1972            top_byte,
1973        })
1974    }
1975
1976    /// Request syntax highlights for a buffer range
1977    pub fn get_highlights(
1978        &self,
1979        buffer_id: BufferId,
1980        range: Range<usize>,
1981        request_id: u64,
1982    ) -> Result<(), String> {
1983        self.send_command(PluginCommand::RequestHighlights {
1984            buffer_id,
1985            range,
1986            request_id,
1987        })
1988    }
1989
1990    // === Query Methods ===
1991
1992    /// Get the currently active buffer ID
1993    pub fn get_active_buffer_id(&self) -> BufferId {
1994        let snapshot = self.state_snapshot.read().unwrap();
1995        snapshot.active_buffer_id
1996    }
1997
1998    /// Get the currently active split ID
1999    pub fn get_active_split_id(&self) -> usize {
2000        let snapshot = self.state_snapshot.read().unwrap();
2001        snapshot.active_split_id
2002    }
2003
2004    /// Get information about a specific buffer
2005    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
2006        let snapshot = self.state_snapshot.read().unwrap();
2007        snapshot.buffers.get(&buffer_id).cloned()
2008    }
2009
2010    /// Get all buffer IDs
2011    pub fn list_buffers(&self) -> Vec<BufferInfo> {
2012        let snapshot = self.state_snapshot.read().unwrap();
2013        snapshot.buffers.values().cloned().collect()
2014    }
2015
2016    /// Get primary cursor information for the active buffer
2017    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
2018        let snapshot = self.state_snapshot.read().unwrap();
2019        snapshot.primary_cursor.clone()
2020    }
2021
2022    /// Get all cursor information for the active buffer
2023    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
2024        let snapshot = self.state_snapshot.read().unwrap();
2025        snapshot.all_cursors.clone()
2026    }
2027
2028    /// Get viewport information for the active buffer
2029    pub fn get_viewport(&self) -> Option<ViewportInfo> {
2030        let snapshot = self.state_snapshot.read().unwrap();
2031        snapshot.viewport.clone()
2032    }
2033
2034    /// Get access to the state snapshot Arc (for internal use)
2035    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
2036        Arc::clone(&self.state_snapshot)
2037    }
2038}
2039
2040impl Clone for PluginApi {
2041    fn clone(&self) -> Self {
2042        Self {
2043            hooks: Arc::clone(&self.hooks),
2044            commands: Arc::clone(&self.commands),
2045            command_sender: self.command_sender.clone(),
2046            state_snapshot: Arc::clone(&self.state_snapshot),
2047        }
2048    }
2049}
2050
2051#[cfg(test)]
2052mod tests {
2053    use super::*;
2054
2055    #[test]
2056    fn test_plugin_api_creation() {
2057        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2058        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2059        let (tx, _rx) = std::sync::mpsc::channel();
2060        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2061
2062        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2063
2064        // Should not panic
2065        let _clone = api.clone();
2066    }
2067
2068    #[test]
2069    fn test_register_hook() {
2070        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2071        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2072        let (tx, _rx) = std::sync::mpsc::channel();
2073        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2074
2075        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
2076
2077        api.register_hook("test-hook", Box::new(|_| true));
2078
2079        let hook_registry = hooks.read().unwrap();
2080        assert_eq!(hook_registry.hook_count("test-hook"), 1);
2081    }
2082
2083    #[test]
2084    fn test_send_command() {
2085        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2086        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2087        let (tx, rx) = std::sync::mpsc::channel();
2088        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2089
2090        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2091
2092        let result = api.insert_text(BufferId(1), 0, "test".to_string());
2093        assert!(result.is_ok());
2094
2095        // Verify command was sent
2096        let received = rx.try_recv();
2097        assert!(received.is_ok());
2098
2099        match received.unwrap() {
2100            PluginCommand::InsertText {
2101                buffer_id,
2102                position,
2103                text,
2104            } => {
2105                assert_eq!(buffer_id.0, 1);
2106                assert_eq!(position, 0);
2107                assert_eq!(text, "test");
2108            }
2109            _ => panic!("Wrong command type"),
2110        }
2111    }
2112
2113    #[test]
2114    fn test_add_overlay_command() {
2115        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2116        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2117        let (tx, rx) = std::sync::mpsc::channel();
2118        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2119
2120        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2121
2122        let result = api.add_overlay(
2123            BufferId(1),
2124            Some("test-overlay".to_string()),
2125            0..10,
2126            (255, 0, 0),
2127            None,
2128            true,
2129            false,
2130            false,
2131            false,
2132        );
2133        assert!(result.is_ok());
2134
2135        let received = rx.try_recv().unwrap();
2136        match received {
2137            PluginCommand::AddOverlay {
2138                buffer_id,
2139                namespace,
2140                range,
2141                color,
2142                bg_color,
2143                underline,
2144                bold,
2145                italic,
2146                extend_to_line_end,
2147            } => {
2148                assert_eq!(buffer_id.0, 1);
2149                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
2150                assert_eq!(range, 0..10);
2151                assert_eq!(color, (255, 0, 0));
2152                assert_eq!(bg_color, None);
2153                assert!(underline);
2154                assert!(!bold);
2155                assert!(!italic);
2156                assert!(!extend_to_line_end);
2157            }
2158            _ => panic!("Wrong command type"),
2159        }
2160    }
2161
2162    #[test]
2163    fn test_set_status_command() {
2164        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2165        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2166        let (tx, rx) = std::sync::mpsc::channel();
2167        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2168
2169        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2170
2171        let result = api.set_status("Test status".to_string());
2172        assert!(result.is_ok());
2173
2174        let received = rx.try_recv().unwrap();
2175        match received {
2176            PluginCommand::SetStatus { message } => {
2177                assert_eq!(message, "Test status");
2178            }
2179            _ => panic!("Wrong command type"),
2180        }
2181    }
2182
2183    #[test]
2184    fn test_get_active_buffer_id() {
2185        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2186        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2187        let (tx, _rx) = std::sync::mpsc::channel();
2188        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2189
2190        // Set active buffer to 5
2191        {
2192            let mut snapshot = state_snapshot.write().unwrap();
2193            snapshot.active_buffer_id = BufferId(5);
2194        }
2195
2196        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2197
2198        let active_id = api.get_active_buffer_id();
2199        assert_eq!(active_id.0, 5);
2200    }
2201
2202    #[test]
2203    fn test_get_buffer_info() {
2204        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2205        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2206        let (tx, _rx) = std::sync::mpsc::channel();
2207        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2208
2209        // Add buffer info
2210        {
2211            let mut snapshot = state_snapshot.write().unwrap();
2212            let buffer_info = BufferInfo {
2213                id: BufferId(1),
2214                path: Some(std::path::PathBuf::from("/test/file.txt")),
2215                modified: true,
2216                length: 100,
2217            };
2218            snapshot.buffers.insert(BufferId(1), buffer_info);
2219        }
2220
2221        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2222
2223        let info = api.get_buffer_info(BufferId(1));
2224        assert!(info.is_some());
2225        let info = info.unwrap();
2226        assert_eq!(info.id.0, 1);
2227        assert_eq!(
2228            info.path.as_ref().unwrap().to_str().unwrap(),
2229            "/test/file.txt"
2230        );
2231        assert!(info.modified);
2232        assert_eq!(info.length, 100);
2233
2234        // Non-existent buffer
2235        let no_info = api.get_buffer_info(BufferId(999));
2236        assert!(no_info.is_none());
2237    }
2238
2239    #[test]
2240    fn test_list_buffers() {
2241        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2242        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2243        let (tx, _rx) = std::sync::mpsc::channel();
2244        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2245
2246        // Add multiple buffers
2247        {
2248            let mut snapshot = state_snapshot.write().unwrap();
2249            snapshot.buffers.insert(
2250                BufferId(1),
2251                BufferInfo {
2252                    id: BufferId(1),
2253                    path: Some(std::path::PathBuf::from("/file1.txt")),
2254                    modified: false,
2255                    length: 50,
2256                },
2257            );
2258            snapshot.buffers.insert(
2259                BufferId(2),
2260                BufferInfo {
2261                    id: BufferId(2),
2262                    path: Some(std::path::PathBuf::from("/file2.txt")),
2263                    modified: true,
2264                    length: 100,
2265                },
2266            );
2267            snapshot.buffers.insert(
2268                BufferId(3),
2269                BufferInfo {
2270                    id: BufferId(3),
2271                    path: None,
2272                    modified: false,
2273                    length: 0,
2274                },
2275            );
2276        }
2277
2278        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2279
2280        let buffers = api.list_buffers();
2281        assert_eq!(buffers.len(), 3);
2282
2283        // Verify all buffers are present
2284        assert!(buffers.iter().any(|b| b.id.0 == 1));
2285        assert!(buffers.iter().any(|b| b.id.0 == 2));
2286        assert!(buffers.iter().any(|b| b.id.0 == 3));
2287    }
2288
2289    #[test]
2290    fn test_get_primary_cursor() {
2291        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2292        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2293        let (tx, _rx) = std::sync::mpsc::channel();
2294        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2295
2296        // Add cursor info
2297        {
2298            let mut snapshot = state_snapshot.write().unwrap();
2299            snapshot.primary_cursor = Some(CursorInfo {
2300                position: 42,
2301                selection: Some(10..42),
2302            });
2303        }
2304
2305        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2306
2307        let cursor = api.get_primary_cursor();
2308        assert!(cursor.is_some());
2309        let cursor = cursor.unwrap();
2310        assert_eq!(cursor.position, 42);
2311        assert_eq!(cursor.selection, Some(10..42));
2312    }
2313
2314    #[test]
2315    fn test_get_all_cursors() {
2316        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2317        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2318        let (tx, _rx) = std::sync::mpsc::channel();
2319        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2320
2321        // Add multiple cursors
2322        {
2323            let mut snapshot = state_snapshot.write().unwrap();
2324            snapshot.all_cursors = vec![
2325                CursorInfo {
2326                    position: 10,
2327                    selection: None,
2328                },
2329                CursorInfo {
2330                    position: 20,
2331                    selection: Some(15..20),
2332                },
2333                CursorInfo {
2334                    position: 30,
2335                    selection: Some(25..30),
2336                },
2337            ];
2338        }
2339
2340        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2341
2342        let cursors = api.get_all_cursors();
2343        assert_eq!(cursors.len(), 3);
2344        assert_eq!(cursors[0].position, 10);
2345        assert_eq!(cursors[0].selection, None);
2346        assert_eq!(cursors[1].position, 20);
2347        assert_eq!(cursors[1].selection, Some(15..20));
2348        assert_eq!(cursors[2].position, 30);
2349        assert_eq!(cursors[2].selection, Some(25..30));
2350    }
2351
2352    #[test]
2353    fn test_get_viewport() {
2354        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2355        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2356        let (tx, _rx) = std::sync::mpsc::channel();
2357        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2358
2359        // Add viewport info
2360        {
2361            let mut snapshot = state_snapshot.write().unwrap();
2362            snapshot.viewport = Some(ViewportInfo {
2363                top_byte: 100,
2364                left_column: 5,
2365                width: 80,
2366                height: 24,
2367            });
2368        }
2369
2370        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2371
2372        let viewport = api.get_viewport();
2373        assert!(viewport.is_some());
2374        let viewport = viewport.unwrap();
2375        assert_eq!(viewport.top_byte, 100);
2376        assert_eq!(viewport.left_column, 5);
2377        assert_eq!(viewport.width, 80);
2378        assert_eq!(viewport.height, 24);
2379    }
2380
2381    #[test]
2382    fn test_composite_buffer_options_rejects_unknown_fields() {
2383        // Valid JSON with correct field names
2384        let valid_json = r#"{
2385            "name": "test",
2386            "mode": "diff",
2387            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2388            "sources": [{"bufferId": 1, "label": "old"}]
2389        }"#;
2390        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
2391        assert!(
2392            result.is_ok(),
2393            "Valid JSON should parse: {:?}",
2394            result.err()
2395        );
2396
2397        // Invalid JSON with unknown field (buffer_id instead of bufferId)
2398        let invalid_json = r#"{
2399            "name": "test",
2400            "mode": "diff",
2401            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
2402            "sources": [{"buffer_id": 1, "label": "old"}]
2403        }"#;
2404        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
2405        assert!(
2406            result.is_err(),
2407            "JSON with unknown field should fail to parse"
2408        );
2409        let err = result.unwrap_err().to_string();
2410        assert!(
2411            err.contains("unknown field") || err.contains("buffer_id"),
2412            "Error should mention unknown field: {}",
2413            err
2414        );
2415    }
2416
2417    #[test]
2418    fn test_composite_hunk_rejects_unknown_fields() {
2419        // Valid JSON with correct field names
2420        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2421        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
2422        assert!(
2423            result.is_ok(),
2424            "Valid JSON should parse: {:?}",
2425            result.err()
2426        );
2427
2428        // Invalid JSON with unknown field (old_start instead of oldStart)
2429        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
2430        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
2431        assert!(
2432            result.is_err(),
2433            "JSON with unknown field should fail to parse"
2434        );
2435        let err = result.unwrap_err().to_string();
2436        assert!(
2437            err.contains("unknown field") || err.contains("old_start"),
2438            "Error should mention unknown field: {}",
2439            err
2440        );
2441    }
2442}