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