Skip to main content

fresh_core/
api.rs

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