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