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/// A rectangular region, in cells. Used by the animation plugin API so
168/// callers can target arbitrary screen regions without going through a
169/// virtual buffer.
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
171#[serde(rename_all = "camelCase")]
172#[ts(export, rename_all = "camelCase")]
173pub struct AnimationRect {
174    pub x: u16,
175    pub y: u16,
176    pub width: u16,
177    pub height: u16,
178}
179
180/// Edge a slide-in effect enters from.
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
182#[serde(rename_all = "camelCase")]
183#[ts(export, rename_all = "camelCase")]
184pub enum PluginAnimationEdge {
185    Top,
186    Bottom,
187    Left,
188    Right,
189}
190
191/// Plugin-facing animation description. Tagged by `kind`. Additional
192/// variants can be added later; plugins must handle the `kind` they send.
193#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
194#[serde(tag = "kind", rename_all = "camelCase")]
195#[ts(export)]
196pub enum PluginAnimationKind {
197    #[serde(rename_all = "camelCase")]
198    SlideIn {
199        from: PluginAnimationEdge,
200        duration_ms: u32,
201        delay_ms: u32,
202    },
203}
204
205/// Result of creating a buffer group
206#[derive(Debug, Clone, Serialize, Deserialize, TS)]
207#[serde(rename_all = "camelCase")]
208#[ts(export, rename_all = "camelCase")]
209pub struct BufferGroupResult {
210    /// The group ID
211    #[ts(type = "number")]
212    pub group_id: u64,
213    /// Panel buffer IDs, keyed by panel name
214    #[ts(type = "Record<string, number>")]
215    pub panels: HashMap<String, u64>,
216}
217
218/// Response from the editor for async plugin operations
219#[derive(Debug, Clone, Serialize, Deserialize, TS)]
220#[ts(export)]
221pub enum PluginResponse {
222    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
223    VirtualBufferCreated {
224        request_id: u64,
225        buffer_id: BufferId,
226        split_id: Option<SplitId>,
227    },
228    /// Response to CreateTerminal with the created buffer, terminal, and split IDs
229    TerminalCreated {
230        request_id: u64,
231        buffer_id: BufferId,
232        terminal_id: TerminalId,
233        split_id: Option<SplitId>,
234    },
235    /// Response to a plugin-initiated LSP request
236    LspRequest {
237        request_id: u64,
238        #[ts(type = "any")]
239        result: Result<JsonValue, String>,
240    },
241    /// Response to RequestHighlights
242    HighlightsComputed {
243        request_id: u64,
244        spans: Vec<TsHighlightSpan>,
245    },
246    /// Response to GetBufferText with the text content
247    BufferText {
248        request_id: u64,
249        text: Result<String, String>,
250    },
251    /// Response to GetLineStartPosition with the byte offset
252    LineStartPosition {
253        request_id: u64,
254        /// None if line is out of range, Some(offset) for valid line
255        position: Option<usize>,
256    },
257    /// Response to GetLineEndPosition with the byte offset
258    LineEndPosition {
259        request_id: u64,
260        /// None if line is out of range, Some(offset) for valid line
261        position: Option<usize>,
262    },
263    /// Response to GetBufferLineCount with the total number of lines
264    BufferLineCount {
265        request_id: u64,
266        /// None if buffer not found, Some(count) for valid buffer
267        count: Option<usize>,
268    },
269    /// Response to CreateCompositeBuffer with the created buffer ID
270    CompositeBufferCreated {
271        request_id: u64,
272        buffer_id: BufferId,
273    },
274    /// Response to GetSplitByLabel with the found split ID (if any)
275    SplitByLabel {
276        request_id: u64,
277        split_id: Option<SplitId>,
278    },
279}
280
281/// Messages sent from async plugin tasks to the synchronous main loop
282#[derive(Debug, Clone, Serialize, Deserialize, TS)]
283#[ts(export)]
284pub enum PluginAsyncMessage {
285    /// Plugin process completed with output
286    ProcessOutput {
287        /// Unique ID for this process
288        process_id: u64,
289        /// Standard output
290        stdout: String,
291        /// Standard error
292        stderr: String,
293        /// Exit code
294        exit_code: i32,
295    },
296    /// Plugin delay/timer completed
297    DelayComplete {
298        /// Callback ID to resolve
299        callback_id: u64,
300    },
301    /// Background process stdout data
302    ProcessStdout { process_id: u64, data: String },
303    /// Background process stderr data
304    ProcessStderr { process_id: u64, data: String },
305    /// Background process exited
306    ProcessExit {
307        process_id: u64,
308        callback_id: u64,
309        exit_code: i32,
310    },
311    /// Response for a plugin-initiated LSP request
312    LspResponse {
313        language: String,
314        request_id: u64,
315        #[ts(type = "any")]
316        result: Result<JsonValue, String>,
317    },
318    /// Generic plugin response (e.g., GetBufferText result)
319    PluginResponse(crate::api::PluginResponse),
320
321    /// Streaming grep: partial results for one file
322    GrepStreamingProgress {
323        /// Search ID to route to the correct progress callback
324        search_id: u64,
325        /// Matches from a single file
326        matches_json: String,
327    },
328
329    /// Streaming grep: search complete
330    GrepStreamingComplete {
331        /// Search ID
332        search_id: u64,
333        /// Callback ID for the completion promise
334        callback_id: u64,
335        /// Total number of matches found
336        total_matches: usize,
337        /// Whether the search was stopped early due to reaching max_results
338        truncated: bool,
339    },
340}
341
342/// Information about a cursor in the editor
343#[derive(Debug, Clone, Serialize, Deserialize, TS)]
344#[ts(export)]
345pub struct CursorInfo {
346    /// Byte position of the cursor
347    pub position: usize,
348    /// Selection range (if any)
349    #[cfg_attr(
350        feature = "plugins",
351        ts(type = "{ start: number; end: number } | null")
352    )]
353    pub selection: Option<Range<usize>>,
354}
355
356/// Specification for an action to execute, with optional repeat count
357#[derive(Debug, Clone, Serialize, Deserialize, TS)]
358#[serde(deny_unknown_fields)]
359#[ts(export)]
360pub struct ActionSpec {
361    /// Action name (e.g., "move_word_right", "delete_line")
362    pub action: String,
363    /// Number of times to repeat the action (default 1)
364    #[serde(default = "default_action_count")]
365    pub count: u32,
366}
367
368fn default_action_count() -> u32 {
369    1
370}
371
372/// Information about a buffer
373#[derive(Debug, Clone, Serialize, Deserialize, TS)]
374#[ts(export)]
375pub struct BufferInfo {
376    /// Buffer ID
377    #[ts(type = "number")]
378    pub id: BufferId,
379    /// File path (if any)
380    #[serde(serialize_with = "serialize_path")]
381    #[ts(type = "string")]
382    pub path: Option<PathBuf>,
383    /// Whether the buffer has been modified
384    pub modified: bool,
385    /// Length of buffer in bytes
386    pub length: usize,
387    /// Whether this is a virtual buffer (not backed by a file)
388    pub is_virtual: bool,
389    /// Current view mode of the active split: "source" or "compose"
390    pub view_mode: String,
391    /// True if any split showing this buffer has compose mode enabled.
392    /// Plugins should use this (not `view_mode`) to decide whether to maintain
393    /// decorations, since decorations live on the buffer and are filtered
394    /// per-split at render time.
395    pub is_composing_in_any_split: bool,
396    /// Compose width (if set), from the active split's view state
397    pub compose_width: Option<u16>,
398    /// The detected language for this buffer (e.g., "rust", "markdown", "text")
399    pub language: String,
400    /// Whether this tab was opened in "preview" (ephemeral) mode — true when
401    /// opened via single-click in the file explorer and not yet committed
402    /// (no edit, no double-click, no tab-click, no layout change). Plugins
403    /// that react to buffer lifecycle events should generally treat preview
404    /// buffers as transient; e.g. a diagnostics panel may want to skip
405    /// refreshing itself for a preview tab.
406    #[serde(default)]
407    pub is_preview: bool,
408    /// Split ids that currently hold this buffer (empty when the buffer is
409    /// open but not visible in any split — e.g. background-opened tabs
410    /// that haven't been focused). Lets plugins implement "focus existing
411    /// buffer if visible, else open new" without having to track split
412    /// ids across editor restarts (which reassign them). The list is a
413    /// snapshot at the last `update_plugin_state_snapshot` tick.
414    #[serde(default)]
415    #[ts(type = "number[]")]
416    pub splits: Vec<SplitId>,
417}
418
419fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
420    s.serialize_str(
421        &path
422            .as_ref()
423            .map(|p| p.to_string_lossy().to_string())
424            .unwrap_or_default(),
425    )
426}
427
428/// Serialize ranges as [start, end] tuples for JS compatibility
429fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
430where
431    S: serde::Serializer,
432{
433    use serde::ser::SerializeSeq;
434    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
435    for range in ranges {
436        seq.serialize_element(&(range.start, range.end))?;
437    }
438    seq.end()
439}
440
441/// Diff between current buffer content and last saved snapshot
442#[derive(Debug, Clone, Serialize, Deserialize, TS)]
443#[ts(export)]
444pub struct BufferSavedDiff {
445    pub equal: bool,
446    #[serde(serialize_with = "serialize_ranges_as_tuples")]
447    #[ts(type = "Array<[number, number]>")]
448    pub byte_ranges: Vec<Range<usize>>,
449}
450
451/// Information about the viewport
452#[derive(Debug, Clone, Serialize, Deserialize, TS)]
453#[serde(rename_all = "camelCase")]
454#[ts(export, rename_all = "camelCase")]
455pub struct ViewportInfo {
456    /// Byte position of the first visible line
457    pub top_byte: usize,
458    /// Line number of the first visible line (None when line index unavailable, e.g. large file before scan)
459    pub top_line: Option<usize>,
460    /// Left column offset (horizontal scroll)
461    pub left_column: usize,
462    /// Viewport width
463    pub width: u16,
464    /// Viewport height
465    pub height: u16,
466}
467
468/// Per-split state surfaced to plugins via `editor.listSplits()`.
469///
470/// Plugins that need to operate on every visible buffer (multi-split
471/// flash labels, syncing decorations across panes, ...) can iterate
472/// this list rather than only seeing the active split's `getViewport()`.
473#[derive(Debug, Clone, Serialize, Deserialize, TS)]
474#[serde(rename_all = "camelCase")]
475#[ts(export, rename_all = "camelCase")]
476pub struct SplitSnapshot {
477    /// Stable split identifier; matches the values used by
478    /// `setSplitBuffer`, `focusSplit`, `getSplitByLabel`, etc.
479    pub split_id: usize,
480    /// Buffer currently shown in this split.
481    pub buffer_id: BufferId,
482    /// Viewport (top byte / dimensions) for this split's active buffer.
483    pub viewport: ViewportInfo,
484}
485
486/// Payload delivered to a plugin's `editor.getNextKey()` Promise when
487/// the next keypress arrives in the editor's input dispatch.
488///
489/// `key` uses the same naming as `defineMode` bindings: lowercase
490/// names like `"escape"`, `"enter"`, `"tab"`, `"space"`, `"left"`,
491/// `"f1"`–`"f12"`, or a single character (e.g. `"a"`, `"!"`).
492/// Modifier flags are reported separately so plugins can recognise
493/// chord variants without parsing.
494#[derive(Debug, Clone, Serialize, Deserialize, TS)]
495#[serde(rename_all = "camelCase")]
496#[ts(export, rename_all = "camelCase")]
497pub struct KeyEventPayload {
498    /// Key name (e.g. `"a"`, `"escape"`, `"f1"`).
499    pub key: String,
500    /// Ctrl held.
501    pub ctrl: bool,
502    /// Alt held.
503    pub alt: bool,
504    /// Shift held (only meaningful for non-character keys; for
505    /// printable characters the case is already encoded in `key`).
506    pub shift: bool,
507    /// Super / Cmd / Meta held.
508    pub meta: bool,
509}
510
511/// Layout hints supplied by plugins (e.g., Compose mode)
512#[derive(Debug, Clone, Serialize, Deserialize, TS)]
513#[serde(rename_all = "camelCase")]
514#[ts(export, rename_all = "camelCase")]
515pub struct LayoutHints {
516    /// Optional compose width for centering/wrapping
517    #[ts(optional)]
518    pub compose_width: Option<u16>,
519    /// Optional column guides for aligned tables
520    #[ts(optional)]
521    pub column_guides: Option<Vec<u16>>,
522}
523
524// ============================================================================
525// Overlay Types with Theme Support
526// ============================================================================
527
528/// Color specification that can be either RGB values or a theme key.
529///
530/// Theme keys reference colors from the current theme, e.g.:
531/// - "ui.status_bar_bg" - UI status bar background
532/// - "editor.selection_bg" - Editor selection background
533/// - "syntax.keyword" - Syntax highlighting for keywords
534/// - "diagnostic.error" - Error diagnostic color
535///
536/// When a theme key is used, the color is resolved at render time,
537/// so overlays automatically update when the theme changes.
538#[derive(Debug, Clone, Serialize, Deserialize, TS)]
539#[serde(untagged)]
540#[ts(export)]
541pub enum OverlayColorSpec {
542    /// RGB color as [r, g, b] array
543    #[ts(type = "[number, number, number]")]
544    Rgb(u8, u8, u8),
545    /// Theme key reference (e.g., "ui.status_bar_bg")
546    ThemeKey(String),
547}
548
549impl OverlayColorSpec {
550    /// Create an RGB color spec
551    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
552        Self::Rgb(r, g, b)
553    }
554
555    /// Create a theme key color spec
556    pub fn theme_key(key: impl Into<String>) -> Self {
557        Self::ThemeKey(key.into())
558    }
559
560    /// Convert to RGB if this is an RGB spec, None if it's a theme key
561    pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
562        match self {
563            Self::Rgb(r, g, b) => Some((*r, *g, *b)),
564            Self::ThemeKey(_) => None,
565        }
566    }
567
568    /// Get the theme key if this is a theme key spec
569    pub fn as_theme_key(&self) -> Option<&str> {
570        match self {
571            Self::ThemeKey(key) => Some(key),
572            Self::Rgb(_, _, _) => None,
573        }
574    }
575}
576
577/// Options for adding an overlay with theme support.
578///
579/// This struct provides a type-safe way to specify overlay styling
580/// with optional theme key references for colors.
581#[derive(Debug, Clone, Serialize, Deserialize, TS)]
582#[serde(deny_unknown_fields, rename_all = "camelCase")]
583#[ts(export, rename_all = "camelCase")]
584#[derive(Default)]
585pub struct OverlayOptions {
586    /// Foreground color - RGB array or theme key string
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub fg: Option<OverlayColorSpec>,
589
590    /// Background color - RGB array or theme key string
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub bg: Option<OverlayColorSpec>,
593
594    /// Whether to render with underline
595    #[serde(default)]
596    pub underline: bool,
597
598    /// Whether to render in bold
599    #[serde(default)]
600    pub bold: bool,
601
602    /// Whether to render in italic
603    #[serde(default)]
604    pub italic: bool,
605
606    /// Whether to render with strikethrough
607    #[serde(default)]
608    pub strikethrough: bool,
609
610    /// Whether to extend background color to end of line
611    #[serde(default)]
612    pub extend_to_line_end: bool,
613
614    /// Optional URL for OSC 8 terminal hyperlinks.
615    /// When set, the overlay text becomes a clickable hyperlink in terminals
616    /// that support OSC 8 escape sequences.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub url: Option<String>,
619}
620
621// ============================================================================
622// Composite Buffer Configuration (for multi-buffer single-tab views)
623// ============================================================================
624
625/// Layout configuration for composite buffers
626#[derive(Debug, Clone, Serialize, Deserialize, TS)]
627#[serde(deny_unknown_fields)]
628#[ts(export, rename = "TsCompositeLayoutConfig")]
629pub struct CompositeLayoutConfig {
630    /// Layout type: "side-by-side", "stacked", or "unified"
631    #[serde(rename = "type")]
632    #[ts(rename = "type")]
633    pub layout_type: String,
634    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
635    #[serde(default)]
636    #[ts(optional)]
637    pub ratios: Option<Vec<f32>>,
638    /// Show separator between panes
639    #[serde(default = "default_true", rename = "showSeparator")]
640    #[ts(rename = "showSeparator")]
641    pub show_separator: bool,
642    /// Spacing for stacked layout
643    #[serde(default)]
644    #[ts(optional)]
645    pub spacing: Option<u16>,
646}
647
648fn default_true() -> bool {
649    true
650}
651
652/// Source pane configuration for composite buffers
653#[derive(Debug, Clone, Serialize, Deserialize, TS)]
654#[serde(deny_unknown_fields)]
655#[ts(export, rename = "TsCompositeSourceConfig")]
656pub struct CompositeSourceConfig {
657    /// Buffer ID of the source buffer (required)
658    #[serde(rename = "bufferId")]
659    #[ts(rename = "bufferId")]
660    pub buffer_id: usize,
661    /// Label for this pane (e.g., "OLD", "NEW")
662    pub label: String,
663    /// Whether this pane is editable
664    #[serde(default)]
665    pub editable: bool,
666    /// Style configuration
667    #[serde(default)]
668    pub style: Option<CompositePaneStyle>,
669}
670
671/// Style configuration for a composite pane
672#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
673#[serde(deny_unknown_fields)]
674#[ts(export, rename = "TsCompositePaneStyle")]
675pub struct CompositePaneStyle {
676    /// Background color for added lines (RGB)
677    /// Using [u8; 3] instead of (u8, u8, u8) for better rquickjs_serde compatibility
678    #[serde(default, rename = "addBg")]
679    #[ts(optional, rename = "addBg", type = "[number, number, number]")]
680    pub add_bg: Option<[u8; 3]>,
681    /// Background color for removed lines (RGB)
682    #[serde(default, rename = "removeBg")]
683    #[ts(optional, rename = "removeBg", type = "[number, number, number]")]
684    pub remove_bg: Option<[u8; 3]>,
685    /// Background color for modified lines (RGB)
686    #[serde(default, rename = "modifyBg")]
687    #[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
688    pub modify_bg: Option<[u8; 3]>,
689    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
690    #[serde(default, rename = "gutterStyle")]
691    #[ts(optional, rename = "gutterStyle")]
692    pub gutter_style: Option<String>,
693}
694
695/// Diff hunk for composite buffer alignment
696#[derive(Debug, Clone, Serialize, Deserialize, TS)]
697#[serde(deny_unknown_fields)]
698#[ts(export, rename = "TsCompositeHunk")]
699pub struct CompositeHunk {
700    /// Starting line in old buffer (0-indexed)
701    #[serde(rename = "oldStart")]
702    #[ts(rename = "oldStart")]
703    pub old_start: usize,
704    /// Number of lines in old buffer
705    #[serde(rename = "oldCount")]
706    #[ts(rename = "oldCount")]
707    pub old_count: usize,
708    /// Starting line in new buffer (0-indexed)
709    #[serde(rename = "newStart")]
710    #[ts(rename = "newStart")]
711    pub new_start: usize,
712    /// Number of lines in new buffer
713    #[serde(rename = "newCount")]
714    #[ts(rename = "newCount")]
715    pub new_count: usize,
716}
717
718/// Options for creating a composite buffer (used by plugin API)
719#[derive(Debug, Clone, Serialize, Deserialize, TS)]
720#[serde(deny_unknown_fields)]
721#[ts(export, rename = "TsCreateCompositeBufferOptions")]
722pub struct CreateCompositeBufferOptions {
723    /// Buffer name (displayed in tabs/title)
724    #[serde(default)]
725    pub name: String,
726    /// Mode for keybindings
727    #[serde(default)]
728    pub mode: String,
729    /// Layout configuration
730    pub layout: CompositeLayoutConfig,
731    /// Source pane configurations
732    pub sources: Vec<CompositeSourceConfig>,
733    /// Diff hunks for alignment (optional)
734    #[serde(default)]
735    pub hunks: Option<Vec<CompositeHunk>>,
736    /// When set, the first render will scroll to center the Nth hunk (0-indexed).
737    /// This avoids timing issues with imperative scroll commands that depend on
738    /// render-created state (viewport dimensions, view state).
739    #[serde(default, rename = "initialFocusHunk")]
740    #[ts(optional, rename = "initialFocusHunk")]
741    pub initial_focus_hunk: Option<usize>,
742}
743
744/// Wire-format view token kind (serialized for plugin transforms)
745#[derive(Debug, Clone, Serialize, Deserialize, TS)]
746#[ts(export)]
747pub enum ViewTokenWireKind {
748    Text(String),
749    Newline,
750    Space,
751    /// Visual line break inserted by wrapping (not from source)
752    /// Always has source_offset: None
753    Break,
754    /// A single binary byte that should be rendered as <XX>
755    /// Used in binary file mode to ensure cursor positioning works correctly
756    /// (all 4 display chars of <XX> map to the same source byte)
757    BinaryByte(u8),
758}
759
760/// Styling for view tokens (used for injected annotations)
761///
762/// This allows plugins to specify styling for tokens that don't have a source
763/// mapping (sourceOffset: None), such as annotation headers in git blame.
764/// For tokens with sourceOffset: Some(_), syntax highlighting is applied instead.
765#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
766#[serde(deny_unknown_fields)]
767#[ts(export)]
768pub struct ViewTokenStyle {
769    /// Foreground color as RGB tuple
770    #[serde(default)]
771    #[ts(type = "[number, number, number] | null")]
772    pub fg: Option<(u8, u8, u8)>,
773    /// Background color as RGB tuple
774    #[serde(default)]
775    #[ts(type = "[number, number, number] | null")]
776    pub bg: Option<(u8, u8, u8)>,
777    /// Whether to render in bold
778    #[serde(default)]
779    pub bold: bool,
780    /// Whether to render in italic
781    #[serde(default)]
782    pub italic: bool,
783}
784
785/// Wire-format view token with optional source mapping and styling
786#[derive(Debug, Clone, Serialize, Deserialize, TS)]
787#[serde(deny_unknown_fields)]
788#[ts(export)]
789pub struct ViewTokenWire {
790    /// Source byte offset in the buffer. None for injected content (annotations).
791    #[ts(type = "number | null")]
792    pub source_offset: Option<usize>,
793    /// The token content
794    pub kind: ViewTokenWireKind,
795    /// Optional styling for injected content (only used when source_offset is None)
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    #[ts(optional)]
798    pub style: Option<ViewTokenStyle>,
799}
800
801/// Transformed view stream payload (plugin-provided)
802#[derive(Debug, Clone, Serialize, Deserialize, TS)]
803#[ts(export)]
804pub struct ViewTransformPayload {
805    /// Byte range this transform applies to (viewport)
806    pub range: Range<usize>,
807    /// Tokens in wire format
808    pub tokens: Vec<ViewTokenWire>,
809    /// Layout hints
810    pub layout_hints: Option<LayoutHints>,
811}
812
813/// Snapshot of editor state for plugin queries
814/// This is updated by the editor on each loop iteration
815#[derive(Debug, Clone, Serialize, Deserialize, TS)]
816#[ts(export)]
817pub struct EditorStateSnapshot {
818    /// Currently active buffer ID
819    pub active_buffer_id: BufferId,
820    /// Currently active split ID
821    pub active_split_id: usize,
822    /// Information about all open buffers
823    pub buffers: HashMap<BufferId, BufferInfo>,
824    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
825    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
826    /// Primary cursor position for the active buffer
827    pub primary_cursor: Option<CursorInfo>,
828    /// All cursor positions for the active buffer
829    pub all_cursors: Vec<CursorInfo>,
830    /// Viewport information for the active buffer
831    pub viewport: Option<ViewportInfo>,
832    /// Per-split snapshots: split id, buffer shown, viewport.
833    /// Includes the active split.  Order is unspecified.
834    #[serde(default)]
835    pub splits: Vec<SplitSnapshot>,
836    /// Cursor positions per buffer (for buffers other than active)
837    pub buffer_cursor_positions: HashMap<BufferId, usize>,
838    /// Text properties per buffer (for virtual buffers with properties)
839    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
840    /// Selected text from the primary cursor (if any selection exists)
841    /// This is populated on each update to avoid needing full buffer access
842    pub selected_text: Option<String>,
843    /// Internal clipboard content (for plugins that need clipboard access)
844    pub clipboard: String,
845    /// Editor's working directory (for file operations and spawning processes)
846    pub working_dir: PathBuf,
847    /// Status-bar / explorer label for the active authority.
848    ///
849    /// Empty = the local (default) authority with nothing to render.
850    /// Non-empty means a non-local authority is installed (e.g.
851    /// `"Container:abc123def456"` for a devcontainer). Plugins can
852    /// read this via `editor.getAuthorityLabel()` to detect "already
853    /// attached" without having to track state across editor restarts.
854    #[serde(default)]
855    pub authority_label: String,
856    /// LSP diagnostics per file URI.
857    /// Maps file URI string to Vec of diagnostics for that file.
858    ///
859    /// Wrapped in `Arc` so snapshot refresh is a refcount bump rather than
860    /// a deep clone. The editor only mutates its own map through
861    /// `Arc::make_mut`, which CoW-clones while this snapshot still holds
862    /// a reference — a reader can never observe an in-place mutation.
863    ///
864    /// `#[serde(skip)]`: serde out-of-the-box can't serialize `Arc<T>`
865    /// (behind the `rc` cargo feature we don't enable). We never serialize
866    /// the snapshot as a whole — plugin readers pull out these Arcs and
867    /// serialize the *inner* value directly (e.g. `get_all_diagnostics`).
868    #[serde(skip)]
869    #[ts(type = "any")]
870    pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
871    /// LSP folding ranges per file URI.
872    /// Maps file URI string to Vec of folding ranges for that file.
873    /// Arc-wrapped for the same CoW invariant as `diagnostics`; see that
874    /// field for why this is `#[serde(skip)]`.
875    #[serde(skip)]
876    #[ts(type = "any")]
877    pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
878    /// Runtime config as serde_json::Value (merged user config + defaults).
879    /// This is the runtime config, not just the user's config file.
880    ///
881    /// Wrapped in `Arc` so the snapshot update is a refcount bump. The
882    /// editor reserializes its source `Config` only when the underlying
883    /// `Arc<Config>` pointer has moved (i.e., after a real mutation), and
884    /// swaps the whole `Arc<Value>` atomically — callers never see a
885    /// partially-updated blob. `#[serde(skip)]` for the same reason as
886    /// `diagnostics`.
887    #[serde(skip)]
888    #[ts(type = "any")]
889    pub config: Arc<serde_json::Value>,
890    /// User config as serde_json::Value (only what's in the user's config file).
891    /// Fields not present here are using default values.
892    /// Arc-wrapped; swapped as a whole when the user's file is reloaded.
893    /// `#[serde(skip)]` for the same reason as `diagnostics`.
894    #[serde(skip)]
895    #[ts(type = "any")]
896    pub user_config: Arc<serde_json::Value>,
897    /// Available grammars with provenance info, updated when grammar registry changes
898    #[ts(type = "GrammarInfo[]")]
899    pub available_grammars: Vec<GrammarInfoSnapshot>,
900    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
901    /// When set, this mode's keybindings take precedence over normal key handling
902    pub editor_mode: Option<String>,
903
904    /// Plugin-managed per-buffer view state for the active split.
905    /// Updated from BufferViewState.plugin_state during snapshot updates.
906    /// Also written directly by JS plugins via setViewState for immediate read-back.
907    #[ts(type = "any")]
908    pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
909
910    /// Tracks which split was active when plugin_view_states was last populated.
911    /// When the active split changes, plugin_view_states is fully repopulated.
912    #[serde(skip)]
913    #[ts(skip)]
914    pub plugin_view_states_split: usize,
915
916    /// Keybinding labels for plugin modes, keyed by "action\0mode" for fast lookup.
917    /// Updated when modes are registered via defineMode().
918    #[serde(skip)]
919    #[ts(skip)]
920    pub keybinding_labels: HashMap<String, String>,
921
922    /// Plugin-managed global state, isolated per plugin.
923    /// Outer key is plugin name, inner key is the state key set by the plugin.
924    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
925    /// Currently we isolate by plugin name, but we may want a more robust approach
926    /// (e.g. preventing plugins from reading each other's state, or providing
927    /// explicit cross-plugin state sharing APIs).
928    #[ts(type = "any")]
929    pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
930}
931
932impl EditorStateSnapshot {
933    pub fn new() -> Self {
934        Self {
935            active_buffer_id: BufferId(0),
936            active_split_id: 0,
937            buffers: HashMap::new(),
938            buffer_saved_diffs: HashMap::new(),
939            primary_cursor: None,
940            all_cursors: Vec::new(),
941            viewport: None,
942            splits: Vec::new(),
943            buffer_cursor_positions: HashMap::new(),
944            buffer_text_properties: HashMap::new(),
945            selected_text: None,
946            clipboard: String::new(),
947            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
948            authority_label: String::new(),
949            diagnostics: Arc::new(HashMap::new()),
950            folding_ranges: Arc::new(HashMap::new()),
951            config: Arc::new(serde_json::Value::Null),
952            user_config: Arc::new(serde_json::Value::Null),
953            available_grammars: Vec::new(),
954            editor_mode: None,
955            plugin_view_states: HashMap::new(),
956            plugin_view_states_split: 0,
957            keybinding_labels: HashMap::new(),
958            plugin_global_states: HashMap::new(),
959        }
960    }
961}
962
963impl Default for EditorStateSnapshot {
964    fn default() -> Self {
965        Self::new()
966    }
967}
968
969/// Grammar info exposed to plugins, mirroring the editor's grammar provenance tracking.
970#[derive(Debug, Clone, Serialize, Deserialize, TS)]
971#[ts(export)]
972pub struct GrammarInfoSnapshot {
973    /// The grammar name as used in config files (case-insensitive matching)
974    pub name: String,
975    /// Where this grammar was loaded from (e.g. "built-in", "plugin (myplugin)")
976    pub source: String,
977    /// File extensions associated with this grammar
978    pub file_extensions: Vec<String>,
979    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
980    pub short_name: Option<String>,
981}
982
983/// Position for inserting menu items or menus
984#[derive(Debug, Clone, Serialize, Deserialize, TS)]
985#[ts(export)]
986pub enum MenuPosition {
987    /// Add at the beginning
988    Top,
989    /// Add at the end
990    Bottom,
991    /// Add before a specific label
992    Before(String),
993    /// Add after a specific label
994    After(String),
995}
996
997/// Plugin command - allows plugins to send commands to the editor
998#[derive(Debug, Clone, Serialize, Deserialize, TS)]
999#[ts(export)]
1000pub enum PluginCommand {
1001    /// Insert text at a position in a buffer
1002    InsertText {
1003        buffer_id: BufferId,
1004        position: usize,
1005        text: String,
1006    },
1007
1008    /// Delete a range of text from a buffer
1009    DeleteRange {
1010        buffer_id: BufferId,
1011        range: Range<usize>,
1012    },
1013
1014    /// Add an overlay to a buffer, returns handle via response channel
1015    ///
1016    /// Colors can be specified as RGB tuples or theme keys. When theme keys
1017    /// are provided, they take precedence and are resolved at render time.
1018    AddOverlay {
1019        buffer_id: BufferId,
1020        namespace: Option<OverlayNamespace>,
1021        range: Range<usize>,
1022        /// Overlay styling options (colors, modifiers, etc.)
1023        options: OverlayOptions,
1024    },
1025
1026    /// Remove an overlay by its opaque handle
1027    RemoveOverlay {
1028        buffer_id: BufferId,
1029        handle: OverlayHandle,
1030    },
1031
1032    /// Set status message
1033    SetStatus { message: String },
1034
1035    /// Apply a theme by name
1036    ApplyTheme { theme_name: String },
1037
1038    /// Override specific theme color keys in-memory for the running session.
1039    /// Keys are the same `section.field` strings accepted by
1040    /// `Theme::resolve_theme_key` (e.g. `"editor.bg"`, `"ui.status_bar_fg"`).
1041    /// Values are `[r, g, b]` triplets. Unknown keys are silently dropped so
1042    /// a typo in a fast animation loop doesn't blow up the caller; the
1043    /// return channel isn't used — plugins can do a dry-run look-up via
1044    /// `getThemeSchema` if they want compile-time safety. Overrides are
1045    /// reset the next time the caller (or anyone else) invokes
1046    /// `applyTheme`, because that replaces the whole `Theme` from the
1047    /// registry.
1048    OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
1049
1050    /// Reload configuration from file
1051    /// After a plugin saves config changes, it should call this to reload the config
1052    ReloadConfig,
1053
1054    /// Write a single setting to the runtime overlay for this session.
1055    /// `path` is dot-separated (e.g. "editor.tab_size"). Last write wins.
1056    SetSetting {
1057        plugin_name: String,
1058        path: String,
1059        #[ts(type = "unknown")]
1060        value: JsonValue,
1061    },
1062
1063    /// Register a custom command
1064    RegisterCommand { command: Command },
1065
1066    /// Unregister a command by name
1067    UnregisterCommand { name: String },
1068
1069    /// Open a file in the editor (in background, without switching focus)
1070    OpenFileInBackground { path: PathBuf },
1071
1072    /// Insert text at the current cursor position in the active buffer
1073    InsertAtCursor { text: String },
1074
1075    /// Spawn an async process
1076    SpawnProcess {
1077        command: String,
1078        args: Vec<String>,
1079        cwd: Option<String>,
1080        callback_id: JsCallbackId,
1081    },
1082
1083    /// Delay/sleep for a duration (async, resolves callback when done)
1084    Delay {
1085        callback_id: JsCallbackId,
1086        duration_ms: u64,
1087    },
1088
1089    /// Spawn a long-running background process
1090    /// Unlike SpawnProcess, this returns immediately with a process handle
1091    /// and provides streaming output via hooks
1092    SpawnBackgroundProcess {
1093        /// Unique ID for this process (generated by plugin runtime)
1094        process_id: u64,
1095        /// Command to execute
1096        command: String,
1097        /// Arguments to pass
1098        args: Vec<String>,
1099        /// Working directory (optional)
1100        cwd: Option<String>,
1101        /// Callback ID to call when process exits
1102        callback_id: JsCallbackId,
1103    },
1104
1105    /// Kill a background process by ID
1106    KillBackgroundProcess { process_id: u64 },
1107
1108    /// Wait for a process to complete and get its result
1109    /// Used with processes started via SpawnProcess
1110    SpawnProcessWait {
1111        /// Process ID to wait for
1112        process_id: u64,
1113        /// Callback ID for async response
1114        callback_id: JsCallbackId,
1115    },
1116
1117    /// Set layout hints for a buffer/viewport
1118    SetLayoutHints {
1119        buffer_id: BufferId,
1120        split_id: Option<SplitId>,
1121        range: Range<usize>,
1122        hints: LayoutHints,
1123    },
1124
1125    /// Enable/disable line numbers for a buffer
1126    SetLineNumbers { buffer_id: BufferId, enabled: bool },
1127
1128    /// Set the view mode for a buffer ("source" or "compose")
1129    SetViewMode { buffer_id: BufferId, mode: String },
1130
1131    /// Enable/disable line wrapping for a buffer
1132    SetLineWrap {
1133        buffer_id: BufferId,
1134        split_id: Option<SplitId>,
1135        enabled: bool,
1136    },
1137
1138    /// Submit a transformed view stream for a viewport
1139    SubmitViewTransform {
1140        buffer_id: BufferId,
1141        split_id: Option<SplitId>,
1142        payload: ViewTransformPayload,
1143    },
1144
1145    /// Clear view transform for a buffer/split (returns to normal rendering)
1146    ClearViewTransform {
1147        buffer_id: BufferId,
1148        split_id: Option<SplitId>,
1149    },
1150
1151    /// Set plugin-managed view state for a buffer in the active split.
1152    /// Stored in BufferViewState.plugin_state and persisted across sessions.
1153    SetViewState {
1154        buffer_id: BufferId,
1155        key: String,
1156        #[ts(type = "any")]
1157        value: Option<serde_json::Value>,
1158    },
1159
1160    /// Set plugin-managed global state (not tied to any buffer or split).
1161    /// Isolated per plugin by plugin_name.
1162    /// TODO: Need to think about plugin isolation / namespacing strategy for these APIs.
1163    SetGlobalState {
1164        plugin_name: String,
1165        key: String,
1166        #[ts(type = "any")]
1167        value: Option<serde_json::Value>,
1168    },
1169
1170    /// Remove all overlays from a buffer
1171    ClearAllOverlays { buffer_id: BufferId },
1172
1173    /// Remove all overlays in a namespace
1174    ClearNamespace {
1175        buffer_id: BufferId,
1176        namespace: OverlayNamespace,
1177    },
1178
1179    /// Remove all overlays that overlap with a byte range
1180    /// Used for targeted invalidation when content in a range changes
1181    ClearOverlaysInRange {
1182        buffer_id: BufferId,
1183        start: usize,
1184        end: usize,
1185    },
1186
1187    /// Add virtual text (inline text that doesn't exist in the buffer)
1188    /// Used for color swatches, type hints, parameter hints, etc.
1189    AddVirtualText {
1190        buffer_id: BufferId,
1191        virtual_text_id: String,
1192        position: usize,
1193        text: String,
1194        color: (u8, u8, u8),
1195        use_bg: bool, // true = use color as background, false = use as foreground
1196        before: bool, // true = before char, false = after char
1197    },
1198
1199    /// Add virtual text with full styling — fg/bg can be RGB or theme
1200    /// keys (resolved at render time so theme changes apply live).
1201    /// This is the richer form of `AddVirtualText` that lets plugins
1202    /// produce themed labels (flash jump, type hints with semantic
1203    /// colours, …) without hard-coding RGB values.
1204    AddVirtualTextStyled {
1205        buffer_id: BufferId,
1206        virtual_text_id: String,
1207        position: usize,
1208        text: String,
1209        fg: Option<OverlayColorSpec>,
1210        bg: Option<OverlayColorSpec>,
1211        bold: bool,
1212        italic: bool,
1213        before: bool,
1214    },
1215
1216    /// Remove a virtual text by ID
1217    RemoveVirtualText {
1218        buffer_id: BufferId,
1219        virtual_text_id: String,
1220    },
1221
1222    /// Remove virtual texts whose ID starts with the given prefix
1223    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
1224
1225    /// Clear all virtual texts from a buffer
1226    ClearVirtualTexts { buffer_id: BufferId },
1227
1228    /// Add a virtual LINE (full line above/below a position)
1229    /// Used for git blame headers, code coverage, inline documentation, etc.
1230    /// These lines do NOT show line numbers in the gutter.
1231    AddVirtualLine {
1232        buffer_id: BufferId,
1233        /// Byte position to anchor the line to
1234        position: usize,
1235        /// Full line content to display
1236        text: String,
1237        /// Foreground color — RGB tuple or theme key string (e.g.
1238        /// `"editor.line_number_fg"`).  Resolved at render time so the line
1239        /// follows theme changes.
1240        fg_color: Option<OverlayColorSpec>,
1241        /// Background color — RGB tuple or theme key string.  None =
1242        /// transparent (inherits from underlying viewport background).
1243        bg_color: Option<OverlayColorSpec>,
1244        /// true = above the line containing position, false = below
1245        above: bool,
1246        /// Namespace for bulk removal (e.g., "git-blame")
1247        namespace: String,
1248        /// Priority for ordering multiple lines at same position (higher = later)
1249        priority: i32,
1250    },
1251
1252    /// Clear all virtual texts in a namespace
1253    /// This is the primary way to remove a plugin's virtual lines before updating them.
1254    ClearVirtualTextNamespace {
1255        buffer_id: BufferId,
1256        namespace: String,
1257    },
1258
1259    /// Add a conceal range that hides or replaces a byte range during rendering.
1260    /// Used for Typora-style seamless markdown: hiding syntax markers like `**`, `[](url)`, etc.
1261    AddConceal {
1262        buffer_id: BufferId,
1263        /// Namespace for bulk removal (shared with overlay namespace system)
1264        namespace: OverlayNamespace,
1265        /// Byte range to conceal
1266        start: usize,
1267        end: usize,
1268        /// Optional replacement text to show instead. None = hide completely.
1269        replacement: Option<String>,
1270    },
1271
1272    /// Clear all conceal ranges in a namespace
1273    ClearConcealNamespace {
1274        buffer_id: BufferId,
1275        namespace: OverlayNamespace,
1276    },
1277
1278    /// Remove all conceal ranges that overlap with a byte range
1279    /// Used for targeted invalidation when content in a range changes
1280    ClearConcealsInRange {
1281        buffer_id: BufferId,
1282        start: usize,
1283        end: usize,
1284    },
1285
1286    /// Add a collapsed fold range. Hides the byte range
1287    /// `[start, end)` from rendering — the line containing `start - 1`
1288    /// (the fold's "header") stays visible while the lines covered by
1289    /// the range are skipped. Used by plugins that want to expose
1290    /// outline-style collapse without rebuilding buffer content.
1291    AddFold {
1292        buffer_id: BufferId,
1293        start: usize,
1294        end: usize,
1295        /// Optional placeholder text to show on the header line
1296        /// (currently unused by the renderer; reserved for future use).
1297        placeholder: Option<String>,
1298    },
1299
1300    /// Clear every collapsed fold range on the buffer.
1301    ClearFolds { buffer_id: BufferId },
1302
1303    /// Add a soft break point for marker-based line wrapping.
1304    /// The break is stored as a marker that auto-adjusts on buffer edits,
1305    /// eliminating the flicker caused by async view_transform round-trips.
1306    AddSoftBreak {
1307        buffer_id: BufferId,
1308        /// Namespace for bulk removal (shared with overlay namespace system)
1309        namespace: OverlayNamespace,
1310        /// Byte offset where the break should be injected
1311        position: usize,
1312        /// Number of hanging indent spaces after the break
1313        indent: u16,
1314    },
1315
1316    /// Clear all soft breaks in a namespace
1317    ClearSoftBreakNamespace {
1318        buffer_id: BufferId,
1319        namespace: OverlayNamespace,
1320    },
1321
1322    /// Remove all soft breaks that fall within a byte range
1323    ClearSoftBreaksInRange {
1324        buffer_id: BufferId,
1325        start: usize,
1326        end: usize,
1327    },
1328
1329    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
1330    RefreshLines { buffer_id: BufferId },
1331
1332    /// Refresh lines for ALL buffers (clear entire seen_lines cache)
1333    /// Sent when a plugin registers for the lines_changed hook to handle the race
1334    /// where render marks lines as "seen" before the plugin has registered.
1335    RefreshAllLines,
1336
1337    /// Sentinel sent by the plugin thread after a hook has been fully processed.
1338    /// Used by the render loop to wait deterministically for plugin responses
1339    /// (e.g., conceal commands from `lines_changed`) instead of polling.
1340    HookCompleted { hook_name: String },
1341
1342    /// Set a line indicator in the gutter's indicator column
1343    /// Used for git gutter, breakpoints, bookmarks, etc.
1344    SetLineIndicator {
1345        buffer_id: BufferId,
1346        /// Line number (0-indexed)
1347        line: usize,
1348        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1349        namespace: String,
1350        /// Symbol to display (e.g., "│", "●", "★")
1351        symbol: String,
1352        /// Color as RGB tuple
1353        color: (u8, u8, u8),
1354        /// Priority for display when multiple indicators exist (higher wins)
1355        priority: i32,
1356    },
1357
1358    /// Batch set line indicators in the gutter's indicator column
1359    /// Optimized for setting many lines with the same namespace/symbol/color/priority
1360    SetLineIndicators {
1361        buffer_id: BufferId,
1362        /// Line numbers (0-indexed)
1363        lines: Vec<usize>,
1364        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
1365        namespace: String,
1366        /// Symbol to display (e.g., "│", "●", "★")
1367        symbol: String,
1368        /// Color as RGB tuple
1369        color: (u8, u8, u8),
1370        /// Priority for display when multiple indicators exist (higher wins)
1371        priority: i32,
1372    },
1373
1374    /// Clear all line indicators for a specific namespace
1375    ClearLineIndicators {
1376        buffer_id: BufferId,
1377        /// Namespace to clear (e.g., "git-gutter")
1378        namespace: String,
1379    },
1380
1381    /// Set file explorer decorations for a namespace
1382    SetFileExplorerDecorations {
1383        /// Namespace for grouping (e.g., "git-status")
1384        namespace: String,
1385        /// Decorations to apply
1386        decorations: Vec<FileExplorerDecoration>,
1387    },
1388
1389    /// Clear file explorer decorations for a namespace
1390    ClearFileExplorerDecorations {
1391        /// Namespace to clear (e.g., "git-status")
1392        namespace: String,
1393    },
1394
1395    /// Open a file at a specific line and column
1396    /// Line and column are 1-indexed to match git grep output
1397    OpenFileAtLocation {
1398        path: PathBuf,
1399        line: Option<usize>,   // 1-indexed, None = go to start
1400        column: Option<usize>, // 1-indexed, None = go to line start
1401    },
1402
1403    /// Open a file in a specific split at a given line and column
1404    /// Line and column are 1-indexed to match git grep output
1405    OpenFileInSplit {
1406        split_id: usize,
1407        path: PathBuf,
1408        line: Option<usize>,   // 1-indexed, None = go to start
1409        column: Option<usize>, // 1-indexed, None = go to line start
1410    },
1411
1412    /// Start a prompt (minibuffer) with a custom type identifier
1413    /// This allows plugins to create interactive prompts
1414    StartPrompt {
1415        label: String,
1416        prompt_type: String, // e.g., "git-grep", "git-find-file"
1417    },
1418
1419    /// Start a prompt with pre-filled initial value
1420    StartPromptWithInitial {
1421        label: String,
1422        prompt_type: String,
1423        initial_value: String,
1424    },
1425
1426    /// Start an async prompt that returns result via callback
1427    /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1428    StartPromptAsync {
1429        label: String,
1430        initial_value: String,
1431        callback_id: JsCallbackId,
1432    },
1433
1434    /// Request the next keypress for the calling plugin.
1435    ///
1436    /// The editor enqueues `callback_id` and resolves it with a
1437    /// `KeyEventPayload` JSON value the next time a key arrives in
1438    /// `Editor::handle_key`. Multiple pending requests are FIFO.
1439    /// While at least one request is pending, the next key is consumed
1440    /// by the resolution and does not propagate to mode bindings or
1441    /// other dispatch — this is the primitive that lets a plugin run a
1442    /// short input loop (flash labels, vi find-char, replace-char,
1443    /// etc.) without binding every printable key in `defineMode`.
1444    AwaitNextKey { callback_id: JsCallbackId },
1445
1446    /// Begin or end "key capture" mode for the calling plugin.
1447    ///
1448    /// Without this, a plugin running a `getNextKey()` loop has a
1449    /// race: keys typed by the user (or pasted, or auto-repeated)
1450    /// can arrive between two consecutive `getNextKey()` calls while
1451    /// the plugin is still mid-redraw, and would otherwise fall
1452    /// through to the editor's normal dispatch (inserting into the
1453    /// buffer, etc.).
1454    ///
1455    /// While capture is active, every key arriving in
1456    /// `Editor::handle_key` (after terminal-input dispatch) is
1457    /// either resolved against a pending `AwaitNextKey` callback
1458    /// (existing behaviour) or, if no callback is pending, *buffered*
1459    /// in a FIFO queue.  When the next `AwaitNextKey` is processed,
1460    /// the queue is drained first.  This gives plugins lossless,
1461    /// in-order delivery of every key the user typed regardless of
1462    /// timing.
1463    ///
1464    /// `EndKeyCapture` clears any unconsumed buffered keys; they do
1465    /// NOT replay into the editor's normal dispatch path (that would
1466    /// be surprising — the user's intent was for the plugin to
1467    /// consume them).
1468    SetKeyCaptureActive { active: bool },
1469
1470    /// Update the suggestions list for the current prompt
1471    /// Uses the editor's Suggestion type
1472    SetPromptSuggestions { suggestions: Vec<Suggestion> },
1473
1474    /// When enabled, navigating suggestions updates the prompt input text
1475    SetPromptInputSync { sync: bool },
1476
1477    /// Add a menu item to an existing menu
1478    /// Add a menu item to an existing menu
1479    AddMenuItem {
1480        menu_label: String,
1481        item: MenuItem,
1482        position: MenuPosition,
1483    },
1484
1485    /// Add a new top-level menu
1486    AddMenu { menu: Menu, position: MenuPosition },
1487
1488    /// Remove a menu item from a menu
1489    RemoveMenuItem {
1490        menu_label: String,
1491        item_label: String,
1492    },
1493
1494    /// Remove a top-level menu
1495    RemoveMenu { menu_label: String },
1496
1497    /// Create a new virtual buffer (not backed by a file)
1498    CreateVirtualBuffer {
1499        /// Display name (e.g., "*Diagnostics*")
1500        name: String,
1501        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1502        mode: String,
1503        /// Whether the buffer is read-only
1504        read_only: bool,
1505    },
1506
1507    /// Create a virtual buffer and set its content in one operation
1508    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1509    /// because it doesn't require tracking the buffer ID
1510    CreateVirtualBufferWithContent {
1511        /// Display name (e.g., "*Diagnostics*")
1512        name: String,
1513        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1514        mode: String,
1515        /// Whether the buffer is read-only
1516        read_only: bool,
1517        /// Entries with text and embedded properties
1518        entries: Vec<TextPropertyEntry>,
1519        /// Whether to show line numbers in the gutter
1520        show_line_numbers: bool,
1521        /// Whether to show cursors in the buffer
1522        show_cursors: bool,
1523        /// Whether editing is disabled (blocks editing commands)
1524        editing_disabled: bool,
1525        /// Whether this buffer should be hidden from tabs (for composite source buffers)
1526        hidden_from_tabs: bool,
1527        /// Optional request ID for async response
1528        request_id: Option<u64>,
1529    },
1530
1531    /// Create a virtual buffer in a horizontal split
1532    /// Opens the buffer in a new pane below the current one
1533    CreateVirtualBufferInSplit {
1534        /// Display name (e.g., "*Diagnostics*")
1535        name: String,
1536        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1537        mode: String,
1538        /// Whether the buffer is read-only
1539        read_only: bool,
1540        /// Entries with text and embedded properties
1541        entries: Vec<TextPropertyEntry>,
1542        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1543        ratio: f32,
1544        /// Split direction ("horizontal" or "vertical"), default horizontal
1545        direction: Option<String>,
1546        /// Optional panel ID for idempotent operations (if panel exists, update content)
1547        panel_id: Option<String>,
1548        /// Whether to show line numbers in the buffer (default true)
1549        show_line_numbers: bool,
1550        /// Whether to show cursors in the buffer (default true)
1551        show_cursors: bool,
1552        /// Whether editing is disabled for this buffer (default false)
1553        editing_disabled: bool,
1554        /// Whether line wrapping is enabled for this split (None = use global setting)
1555        line_wrap: Option<bool>,
1556        /// Place the new buffer before (left/top of) the existing content (default: false/after)
1557        before: bool,
1558        /// Optional request ID for async response (if set, editor will send back buffer ID)
1559        request_id: Option<u64>,
1560    },
1561
1562    /// Set the content of a virtual buffer with text properties
1563    SetVirtualBufferContent {
1564        buffer_id: BufferId,
1565        /// Entries with text and embedded properties
1566        entries: Vec<TextPropertyEntry>,
1567    },
1568
1569    /// Get text properties at the cursor position in a buffer
1570    GetTextPropertiesAtCursor { buffer_id: BufferId },
1571
1572    /// Create a buffer group: multiple panels appearing as one tab.
1573    /// Each panel is a real buffer with its own scrollbar and viewport.
1574    CreateBufferGroup {
1575        /// Display name (shown in tab bar)
1576        name: String,
1577        /// Mode for keybindings
1578        mode: String,
1579        /// Layout tree as JSON string (parsed by the handler)
1580        layout_json: String,
1581        /// Optional request ID for async response
1582        request_id: Option<u64>,
1583    },
1584
1585    /// Set the content of a panel within a buffer group.
1586    SetPanelContent {
1587        /// Group ID
1588        group_id: usize,
1589        /// Panel name (e.g., "tree", "picker")
1590        panel_name: String,
1591        /// Content entries
1592        entries: Vec<TextPropertyEntry>,
1593    },
1594
1595    /// Close a buffer group (closes all panels and splits)
1596    CloseBufferGroup { group_id: usize },
1597
1598    /// Focus a specific panel within a buffer group
1599    FocusPanel { group_id: usize, panel_name: String },
1600
1601    /// Define a buffer mode with keybindings
1602    DefineMode {
1603        name: String,
1604        bindings: Vec<(String, String)>, // (key_string, command_name)
1605        read_only: bool,
1606        /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1607        allow_text_input: bool,
1608        /// When true, keys not bound by this mode fall through to the Normal
1609        /// context (motion, selection, copy) instead of being dropped.
1610        inherit_normal_bindings: bool,
1611        /// Name of the plugin that defined this mode (for attribution)
1612        plugin_name: Option<String>,
1613    },
1614
1615    /// Switch the current split to display a buffer
1616    ShowBuffer { buffer_id: BufferId },
1617
1618    /// Start a frame-buffer animation over a given screen region. The `id`
1619    /// is allocated on the plugin side so the JS call can return it
1620    /// synchronously; the editor uses it verbatim.
1621    StartAnimationArea {
1622        id: u64,
1623        rect: AnimationRect,
1624        kind: PluginAnimationKind,
1625    },
1626
1627    /// Start an animation over the on-screen Rect currently occupied by a
1628    /// virtual buffer. If the buffer is not visible, the editor ignores
1629    /// the command.
1630    StartAnimationVirtualBuffer {
1631        id: u64,
1632        buffer_id: BufferId,
1633        kind: PluginAnimationKind,
1634    },
1635
1636    /// Cancel an animation by the ID returned from `animateArea` /
1637    /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
1638    CancelAnimation { id: u64 },
1639
1640    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1641    CreateVirtualBufferInExistingSplit {
1642        /// Display name (e.g., "*Commit Details*")
1643        name: String,
1644        /// Mode name for buffer-local keybindings
1645        mode: String,
1646        /// Whether the buffer is read-only
1647        read_only: bool,
1648        /// Entries with text and embedded properties
1649        entries: Vec<TextPropertyEntry>,
1650        /// Target split ID where the buffer should be displayed
1651        split_id: SplitId,
1652        /// Whether to show line numbers in the buffer (default true)
1653        show_line_numbers: bool,
1654        /// Whether to show cursors in the buffer (default true)
1655        show_cursors: bool,
1656        /// Whether editing is disabled for this buffer (default false)
1657        editing_disabled: bool,
1658        /// Whether line wrapping is enabled for this split (None = use global setting)
1659        line_wrap: Option<bool>,
1660        /// Optional request ID for async response
1661        request_id: Option<u64>,
1662    },
1663
1664    /// Close a buffer and remove it from all splits
1665    CloseBuffer { buffer_id: BufferId },
1666
1667    /// Create a composite buffer that displays multiple source buffers
1668    /// Used for side-by-side diff, unified diff, and 3-way merge views
1669    CreateCompositeBuffer {
1670        /// Display name (shown in tab bar)
1671        name: String,
1672        /// Mode name for keybindings (e.g., "diff-view")
1673        mode: String,
1674        /// Layout configuration
1675        layout: CompositeLayoutConfig,
1676        /// Source pane configurations
1677        sources: Vec<CompositeSourceConfig>,
1678        /// Diff hunks for line alignment (optional)
1679        hunks: Option<Vec<CompositeHunk>>,
1680        /// When set, first render scrolls to center this hunk (0-indexed)
1681        initial_focus_hunk: Option<usize>,
1682        /// Request ID for async response
1683        request_id: Option<u64>,
1684    },
1685
1686    /// Update alignment for a composite buffer (e.g., after source edit)
1687    UpdateCompositeAlignment {
1688        buffer_id: BufferId,
1689        hunks: Vec<CompositeHunk>,
1690    },
1691
1692    /// Close a composite buffer
1693    CloseCompositeBuffer { buffer_id: BufferId },
1694
1695    /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1696    ///
1697    /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1698    /// have one, and syncs viewport dimensions from split layout. This ensures
1699    /// subsequent commands can read/modify view state that is normally created
1700    /// lazily during the render cycle.
1701    FlushLayout,
1702
1703    /// Navigate to the next hunk in a composite buffer
1704    CompositeNextHunk { buffer_id: BufferId },
1705
1706    /// Navigate to the previous hunk in a composite buffer
1707    CompositePrevHunk { buffer_id: BufferId },
1708
1709    /// Focus a specific split
1710    FocusSplit { split_id: SplitId },
1711
1712    /// Set the buffer displayed in a specific split
1713    SetSplitBuffer {
1714        split_id: SplitId,
1715        buffer_id: BufferId,
1716    },
1717
1718    /// Set the scroll position of a specific split
1719    SetSplitScroll { split_id: SplitId, top_byte: usize },
1720
1721    /// Request syntax highlights for a buffer range
1722    RequestHighlights {
1723        buffer_id: BufferId,
1724        range: Range<usize>,
1725        request_id: u64,
1726    },
1727
1728    /// Close a split (if not the last one)
1729    CloseSplit { split_id: SplitId },
1730
1731    /// Set the ratio of a split container
1732    SetSplitRatio {
1733        split_id: SplitId,
1734        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1735        ratio: f32,
1736    },
1737
1738    /// Set a label on a leaf split (e.g., "sidebar")
1739    SetSplitLabel { split_id: SplitId, label: String },
1740
1741    /// Remove a label from a split
1742    ClearSplitLabel { split_id: SplitId },
1743
1744    /// Find a split by its label (async)
1745    GetSplitByLabel { label: String, request_id: u64 },
1746
1747    /// Distribute splits evenly - make all given splits equal size
1748    DistributeSplitsEvenly {
1749        /// Split IDs to distribute evenly
1750        split_ids: Vec<SplitId>,
1751    },
1752
1753    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1754    SetBufferCursor {
1755        buffer_id: BufferId,
1756        /// Byte offset position for the cursor
1757        position: usize,
1758    },
1759
1760    /// Toggle whether the editor draws a native caret for this buffer.
1761    ///
1762    /// Buffer-group panel buffers default to `show_cursors = false`, which not
1763    /// only hides the caret but also blocks all movement actions in
1764    /// `action_to_events`. Plugins that want native cursor motion in a panel
1765    /// buffer (e.g. for magit-style row navigation) flip this to `true` after
1766    /// `createBufferGroup` returns.
1767    SetBufferShowCursors { buffer_id: BufferId, show: bool },
1768
1769    /// Send an arbitrary LSP request and return the raw JSON response
1770    SendLspRequest {
1771        language: String,
1772        method: String,
1773        #[ts(type = "any")]
1774        params: Option<JsonValue>,
1775        request_id: u64,
1776    },
1777
1778    /// Set the internal clipboard content
1779    SetClipboard { text: String },
1780
1781    /// Delete the current selection in the active buffer
1782    /// This deletes all selected text across all cursors
1783    DeleteSelection,
1784
1785    /// Set or unset a custom context
1786    /// Custom contexts are plugin-defined states that can be used to control command visibility
1787    /// For example, "config-editor" context could make config editor commands available
1788    SetContext {
1789        /// Context name (e.g., "config-editor")
1790        name: String,
1791        /// Whether the context is active
1792        active: bool,
1793    },
1794
1795    /// Set the hunks for the Review Diff tool
1796    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1797
1798    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1799    /// Used by vi mode plugin to run motions and calculate cursor ranges
1800    ExecuteAction {
1801        /// Action name (e.g., "move_word_right", "move_line_end")
1802        action_name: String,
1803    },
1804
1805    /// Execute multiple actions in sequence, each with an optional repeat count
1806    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1807    /// All actions execute atomically with no plugin roundtrips between them
1808    ExecuteActions {
1809        /// List of actions to execute in sequence
1810        actions: Vec<ActionSpec>,
1811    },
1812
1813    /// Get text from a buffer range (for yank operations)
1814    GetBufferText {
1815        /// Buffer ID
1816        buffer_id: BufferId,
1817        /// Start byte offset
1818        start: usize,
1819        /// End byte offset
1820        end: usize,
1821        /// Request ID for async response
1822        request_id: u64,
1823    },
1824
1825    /// Get byte offset of the start of a line (async)
1826    /// Line is 0-indexed (0 = first line)
1827    GetLineStartPosition {
1828        /// Buffer ID (0 for active buffer)
1829        buffer_id: BufferId,
1830        /// Line number (0-indexed)
1831        line: u32,
1832        /// Request ID for async response
1833        request_id: u64,
1834    },
1835
1836    /// Get byte offset of the end of a line (async)
1837    /// Line is 0-indexed (0 = first line)
1838    /// Returns the byte offset after the last character of the line (before newline)
1839    GetLineEndPosition {
1840        /// Buffer ID (0 for active buffer)
1841        buffer_id: BufferId,
1842        /// Line number (0-indexed)
1843        line: u32,
1844        /// Request ID for async response
1845        request_id: u64,
1846    },
1847
1848    /// Get the total number of lines in a buffer (async)
1849    GetBufferLineCount {
1850        /// Buffer ID (0 for active buffer)
1851        buffer_id: BufferId,
1852        /// Request ID for async response
1853        request_id: u64,
1854    },
1855
1856    /// Scroll a split to center a specific line in the viewport
1857    /// Line is 0-indexed (0 = first line)
1858    ScrollToLineCenter {
1859        /// Split ID to scroll
1860        split_id: SplitId,
1861        /// Buffer ID containing the line
1862        buffer_id: BufferId,
1863        /// Line number to center (0-indexed)
1864        line: usize,
1865    },
1866
1867    /// Scroll any split/panel that displays `buffer_id` so the given
1868    /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
1869    /// does not require a split id — it walks all splits (including
1870    /// inner panels of a buffer group) and updates every viewport that
1871    /// shows this buffer. Line is 0-indexed.
1872    ScrollBufferToLine {
1873        /// Buffer ID to scroll
1874        buffer_id: BufferId,
1875        /// Line number to bring into view (0-indexed)
1876        line: usize,
1877    },
1878
1879    /// Set the global editor mode (for modal editing like vi mode)
1880    /// When set, the mode's keybindings take precedence over normal editing
1881    SetEditorMode {
1882        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1883        mode: Option<String>,
1884    },
1885
1886    /// Show an action popup with buttons for user interaction
1887    /// When the user selects an action, the ActionPopupResult hook is fired
1888    ShowActionPopup {
1889        /// Unique identifier for the popup (used in ActionPopupResult)
1890        popup_id: String,
1891        /// Title text for the popup
1892        title: String,
1893        /// Body message (supports basic formatting)
1894        message: String,
1895        /// Action buttons to display
1896        actions: Vec<ActionPopupAction>,
1897    },
1898
1899    /// Disable LSP for a specific language and persist to config
1900    DisableLspForLanguage {
1901        /// The language to disable LSP for (e.g., "python", "rust")
1902        language: String,
1903    },
1904
1905    /// Restart LSP server for a specific language
1906    RestartLspForLanguage {
1907        /// The language to restart LSP for (e.g., "python", "rust")
1908        language: String,
1909    },
1910
1911    /// Set the workspace root URI for a specific language's LSP server
1912    /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1913    /// If the LSP is already running, it will be restarted with the new root
1914    SetLspRootUri {
1915        /// The language to set root URI for (e.g., "csharp", "rust")
1916        language: String,
1917        /// The root URI (file:// URL format)
1918        uri: String,
1919    },
1920
1921    /// Create a scroll sync group for anchor-based synchronized scrolling
1922    /// Used for side-by-side diff views where two panes need to scroll together
1923    /// The plugin provides the group ID (must be unique per plugin)
1924    CreateScrollSyncGroup {
1925        /// Plugin-assigned group ID
1926        group_id: u32,
1927        /// The left (primary) split - scroll position is tracked in this split's line space
1928        left_split: SplitId,
1929        /// The right (secondary) split - position is derived from anchors
1930        right_split: SplitId,
1931    },
1932
1933    /// Set sync anchors for a scroll sync group
1934    /// Anchors map corresponding line numbers between left and right buffers
1935    SetScrollSyncAnchors {
1936        /// The group ID returned by CreateScrollSyncGroup
1937        group_id: u32,
1938        /// List of (left_line, right_line) pairs marking corresponding positions
1939        anchors: Vec<(usize, usize)>,
1940    },
1941
1942    /// Remove a scroll sync group
1943    RemoveScrollSyncGroup {
1944        /// The group ID returned by CreateScrollSyncGroup
1945        group_id: u32,
1946    },
1947
1948    /// Save a buffer to a specific file path
1949    /// Used by :w filename command to save unnamed buffers or save-as
1950    SaveBufferToPath {
1951        /// Buffer ID to save
1952        buffer_id: BufferId,
1953        /// Path to save to
1954        path: PathBuf,
1955    },
1956
1957    /// Load a plugin from a file path
1958    /// The plugin will be initialized and start receiving events
1959    LoadPlugin {
1960        /// Path to the plugin file (.ts or .js)
1961        path: PathBuf,
1962        /// Callback ID for async response (success/failure)
1963        callback_id: JsCallbackId,
1964    },
1965
1966    /// Unload a plugin by name
1967    /// The plugin will stop receiving events and be removed from memory
1968    UnloadPlugin {
1969        /// Plugin name (as registered)
1970        name: String,
1971        /// Callback ID for async response (success/failure)
1972        callback_id: JsCallbackId,
1973    },
1974
1975    /// Reload a plugin by name (unload + load)
1976    /// Useful for development when plugin code changes
1977    ReloadPlugin {
1978        /// Plugin name (as registered)
1979        name: String,
1980        /// Callback ID for async response (success/failure)
1981        callback_id: JsCallbackId,
1982    },
1983
1984    /// List all loaded plugins
1985    /// Returns plugin info (name, path, enabled) for all loaded plugins
1986    ListPlugins {
1987        /// Callback ID for async response (JSON array of plugin info)
1988        callback_id: JsCallbackId,
1989    },
1990
1991    /// Reload the theme registry from disk
1992    /// Call this after installing a theme package or saving a new theme.
1993    /// If `apply_theme` is set, apply that theme immediately after reloading.
1994    ReloadThemes { apply_theme: Option<String> },
1995
1996    /// Register a TextMate grammar file for a language
1997    /// The grammar will be added to pending_grammars until ReloadGrammars is called
1998    RegisterGrammar {
1999        /// Language identifier (e.g., "elixir", "zig")
2000        language: String,
2001        /// Path to the grammar file (.sublime-syntax or .tmLanguage)
2002        grammar_path: String,
2003        /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
2004        extensions: Vec<String>,
2005    },
2006
2007    /// Register language configuration (comment prefix, indentation, formatter)
2008    /// This is applied immediately to the runtime config
2009    RegisterLanguageConfig {
2010        /// Language identifier (e.g., "elixir")
2011        language: String,
2012        /// Language configuration
2013        config: LanguagePackConfig,
2014    },
2015
2016    /// Register an LSP server for a language
2017    /// This is applied immediately to the LSP manager and runtime config
2018    RegisterLspServer {
2019        /// Language identifier (e.g., "elixir")
2020        language: String,
2021        /// LSP server configuration
2022        config: LspServerPackConfig,
2023    },
2024
2025    /// Reload the grammar registry to apply registered grammars (async)
2026    /// Call this after registering one or more grammars to rebuild the syntax set.
2027    /// The callback is resolved when the background grammar build completes.
2028    ReloadGrammars { callback_id: JsCallbackId },
2029
2030    // ==================== Terminal Commands ====================
2031    /// Create a new terminal in a split (async, returns TerminalResult)
2032    /// This spawns a PTY-backed terminal that plugins can write to and read from.
2033    CreateTerminal {
2034        /// Working directory for the terminal (defaults to editor cwd)
2035        cwd: Option<String>,
2036        /// Split direction ("horizontal" or "vertical"), default vertical
2037        direction: Option<String>,
2038        /// Split ratio (0.0 to 1.0), default 0.5
2039        ratio: Option<f32>,
2040        /// Whether to focus the new terminal split (default true)
2041        focus: Option<bool>,
2042        /// Whether this terminal survives editor restarts. When false, the
2043        /// terminal is excluded from workspace serialization and its backing
2044        /// file is kept unique-per-spawn so no scrollback from a prior run
2045        /// leaks in. Plugin-created terminals default to `false` since they
2046        /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
2047        persistent: bool,
2048        /// Callback ID for async response
2049        request_id: u64,
2050    },
2051
2052    /// Send input data to a terminal by its terminal ID
2053    SendTerminalInput {
2054        /// The terminal ID (from TerminalResult)
2055        terminal_id: TerminalId,
2056        /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
2057        data: String,
2058    },
2059
2060    /// Close a terminal by its terminal ID
2061    CloseTerminal {
2062        /// The terminal ID to close
2063        terminal_id: TerminalId,
2064    },
2065
2066    /// Project-wide grep search (async)
2067    /// Searches all project files via FileSystem trait, respecting .gitignore.
2068    /// For open buffers with dirty edits, searches the buffer's piece tree.
2069    GrepProject {
2070        /// Search pattern (literal string)
2071        pattern: String,
2072        /// Whether the pattern is a fixed string (true) or regex (false)
2073        fixed_string: bool,
2074        /// Whether the search is case-sensitive
2075        case_sensitive: bool,
2076        /// Maximum number of results to return
2077        max_results: usize,
2078        /// Whether to match whole words only
2079        whole_words: bool,
2080        /// Callback ID for async response
2081        callback_id: JsCallbackId,
2082    },
2083
2084    /// Project-wide streaming grep search (async, parallel)
2085    /// Like GrepProject but streams results incrementally via progress callback.
2086    /// Searches files in parallel using tokio tasks, sending per-file results
2087    /// back to the plugin as they complete.
2088    GrepProjectStreaming {
2089        /// Search pattern
2090        pattern: String,
2091        /// Whether the pattern is a fixed string (true) or regex (false)
2092        fixed_string: bool,
2093        /// Whether the search is case-sensitive
2094        case_sensitive: bool,
2095        /// Maximum number of results to return
2096        max_results: usize,
2097        /// Whether to match whole words only
2098        whole_words: bool,
2099        /// Search ID — used to route progress callbacks and for cancellation
2100        search_id: u64,
2101        /// Callback ID for the completion promise
2102        callback_id: JsCallbackId,
2103    },
2104
2105    /// Replace matches in a buffer (async)
2106    /// Opens the file if not already open, applies edits through the buffer model,
2107    /// groups as a single undo action, and saves via FileSystem trait.
2108    ReplaceInBuffer {
2109        /// File path to edit (will open if not already in a buffer)
2110        file_path: PathBuf,
2111        /// Matches to replace, each is (byte_offset, length)
2112        matches: Vec<(usize, usize)>,
2113        /// Replacement text
2114        replacement: String,
2115        /// Callback ID for async response
2116        callback_id: JsCallbackId,
2117    },
2118
2119    /// Install a new authority.
2120    ///
2121    /// Authority is opaque to core. The payload is a tagged JSON object
2122    /// (filesystem kind + spawner kind + terminal wrapper + display
2123    /// label) that `fresh-editor` deserializes into its concrete
2124    /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
2125    /// fresh-core from growing backend-specific knowledge; see
2126    /// `crates/fresh-editor/src/services/authority/mod.rs` for the
2127    /// canonical schema.
2128    ///
2129    /// Fire-and-forget: the transition piggy-backs on the existing
2130    /// editor restart flow, so the plugin that sent this command will
2131    /// be re-loaded as part of the restart. Any follow-up work the
2132    /// plugin wants to do after the switch belongs in its post-restart
2133    /// init code, not in a callback here.
2134    SetAuthority {
2135        #[ts(type = "unknown")]
2136        payload: JsonValue,
2137    },
2138
2139    /// Restore the default local authority. Same semantics as
2140    /// `SetAuthority` with a local payload — triggers an editor
2141    /// restart.
2142    ClearAuthority,
2143
2144    /// Override the Remote Indicator's displayed state for the rest
2145    /// of the current editor session (until a restart, or until the
2146    /// plugin sends another override / `ClearRemoteIndicatorState`).
2147    ///
2148    /// The derived state — computed from the active authority's
2149    /// connection info — keeps running underneath and is what the
2150    /// indicator shows whenever an override is not in effect.
2151    /// Plugins use this to surface lifecycle states that have no
2152    /// authority-level truth yet (e.g. "Connecting" during
2153    /// `devcontainer up`, "FailedAttach" after a non-zero exit).
2154    ///
2155    /// `state` is a tagged enum keyed by `kind`:
2156    ///   - `{ "kind": "local" }`
2157    ///   - `{ "kind": "connecting", "label": "..." }`
2158    ///   - `{ "kind": "connected", "label": "..." }`
2159    ///   - `{ "kind": "failed_attach", "error": "..." }`
2160    ///   - `{ "kind": "disconnected", "label": "..." }`
2161    ///
2162    /// The exact schema lives in
2163    /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
2164    /// takes it opaquely so new variants can land without touching
2165    /// core plumbing.
2166    SetRemoteIndicatorState {
2167        #[ts(type = "unknown")]
2168        state: JsonValue,
2169    },
2170
2171    /// Drop any active Remote Indicator override and fall back to
2172    /// the authority-derived state. Safe to call without a prior
2173    /// `SetRemoteIndicatorState`.
2174    ClearRemoteIndicatorState,
2175
2176    /// Spawn a process on the host, regardless of the currently
2177    /// installed authority.
2178    ///
2179    /// Intended for plugin internals that must run host-side work
2180    /// (e.g. `devcontainer up`) before installing an authority that
2181    /// would otherwise route the spawn elsewhere. Behaves like
2182    /// `SpawnProcess` but always uses `LocalProcessSpawner`.
2183    ///
2184    /// The TS-side handle exposes `.kill()` on the returned
2185    /// `ProcessHandle`, serviced by `KillHostProcess` below — this
2186    /// lets callers abort a long-running host spawn (e.g.
2187    /// `devcontainer up`) via a user action like "Cancel Startup".
2188    SpawnHostProcess {
2189        command: String,
2190        args: Vec<String>,
2191        cwd: Option<String>,
2192        callback_id: JsCallbackId,
2193    },
2194
2195    /// Cancel a host-side process previously started via
2196    /// `SpawnHostProcess`. `process_id` is the callback id returned
2197    /// by `spawnHostProcess` (the TS handle stores it and forwards
2198    /// when the caller invokes `.kill()`).
2199    ///
2200    /// No-op when the id is unknown — the process may have already
2201    /// exited, or the caller may hold a stale handle. SIGKILL on
2202    /// Unix per `tokio::process::Child::start_kill`; children of the
2203    /// killed process may leak (see Q-C2 in
2204    /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
2205    KillHostProcess { process_id: u64 },
2206}
2207
2208impl PluginCommand {
2209    /// Extract the enum variant name from the Debug representation.
2210    pub fn debug_variant_name(&self) -> String {
2211        let dbg = format!("{:?}", self);
2212        dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
2213    }
2214}
2215
2216// =============================================================================
2217// Language Pack Configuration Types
2218// =============================================================================
2219
2220/// Language configuration for language packs
2221///
2222/// This is a simplified version of the full LanguageConfig, containing only
2223/// the fields that can be set via the plugin API.
2224#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
2225#[serde(rename_all = "camelCase")]
2226#[ts(export)]
2227pub struct LanguagePackConfig {
2228    /// Comment prefix for line comments (e.g., "//" or "#")
2229    #[serde(default)]
2230    pub comment_prefix: Option<String>,
2231
2232    /// Block comment start marker (e.g., slash-star)
2233    #[serde(default)]
2234    pub block_comment_start: Option<String>,
2235
2236    /// Block comment end marker (e.g., star-slash)
2237    #[serde(default)]
2238    pub block_comment_end: Option<String>,
2239
2240    /// Whether to use tabs instead of spaces for indentation
2241    #[serde(default)]
2242    pub use_tabs: Option<bool>,
2243
2244    /// Tab size (number of spaces per tab level)
2245    #[serde(default)]
2246    pub tab_size: Option<usize>,
2247
2248    /// Whether auto-indent is enabled
2249    #[serde(default)]
2250    pub auto_indent: Option<bool>,
2251
2252    /// Whether to show whitespace tab indicators (→) for this language
2253    /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
2254    #[serde(default)]
2255    pub show_whitespace_tabs: Option<bool>,
2256
2257    /// Formatter configuration
2258    #[serde(default)]
2259    pub formatter: Option<FormatterPackConfig>,
2260}
2261
2262/// Formatter configuration for language packs
2263#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2264#[serde(rename_all = "camelCase")]
2265#[ts(export)]
2266pub struct FormatterPackConfig {
2267    /// Command to run (e.g., "prettier", "rustfmt")
2268    pub command: String,
2269
2270    /// Arguments to pass to the formatter
2271    #[serde(default)]
2272    pub args: Vec<String>,
2273}
2274
2275/// Process resource limits for LSP servers
2276#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2277#[serde(rename_all = "camelCase")]
2278#[ts(export)]
2279pub struct ProcessLimitsPackConfig {
2280    /// Maximum memory usage as percentage of total system memory (null = no limit)
2281    #[serde(default)]
2282    pub max_memory_percent: Option<u32>,
2283
2284    /// Maximum CPU usage as percentage of total CPU (null = no limit)
2285    #[serde(default)]
2286    pub max_cpu_percent: Option<u32>,
2287
2288    /// Enable resource limiting
2289    #[serde(default)]
2290    pub enabled: Option<bool>,
2291}
2292
2293/// LSP server configuration for language packs
2294#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2295#[serde(rename_all = "camelCase")]
2296#[ts(export)]
2297pub struct LspServerPackConfig {
2298    /// Command to start the LSP server
2299    pub command: String,
2300
2301    /// Arguments to pass to the command
2302    #[serde(default)]
2303    pub args: Vec<String>,
2304
2305    /// Whether to auto-start the server when a matching file is opened
2306    #[serde(default)]
2307    pub auto_start: Option<bool>,
2308
2309    /// LSP initialization options
2310    #[serde(default)]
2311    #[ts(type = "Record<string, unknown> | null")]
2312    pub initialization_options: Option<JsonValue>,
2313
2314    /// Process resource limits (memory and CPU)
2315    #[serde(default)]
2316    pub process_limits: Option<ProcessLimitsPackConfig>,
2317}
2318
2319/// Hunk status for Review Diff
2320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
2321#[ts(export)]
2322pub enum HunkStatus {
2323    Pending,
2324    Staged,
2325    Discarded,
2326}
2327
2328/// A high-level hunk directive for the Review Diff tool
2329#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2330#[ts(export)]
2331pub struct ReviewHunk {
2332    pub id: String,
2333    pub file: String,
2334    pub context_header: String,
2335    pub status: HunkStatus,
2336    /// 0-indexed line range in the base (HEAD) version
2337    pub base_range: Option<(usize, usize)>,
2338    /// 0-indexed line range in the modified (Working) version
2339    pub modified_range: Option<(usize, usize)>,
2340}
2341
2342/// Action button for action popups
2343#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2344#[serde(deny_unknown_fields)]
2345#[ts(export, rename = "TsActionPopupAction")]
2346pub struct ActionPopupAction {
2347    /// Unique action identifier (returned in ActionPopupResult)
2348    pub id: String,
2349    /// Display text for the button (can include command hints)
2350    pub label: String,
2351}
2352
2353/// Options for showActionPopup
2354#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2355#[serde(deny_unknown_fields)]
2356#[ts(export)]
2357pub struct ActionPopupOptions {
2358    /// Unique identifier for the popup (used in ActionPopupResult)
2359    pub id: String,
2360    /// Title text for the popup
2361    pub title: String,
2362    /// Body message (supports basic formatting)
2363    pub message: String,
2364    /// Action buttons to display
2365    pub actions: Vec<ActionPopupAction>,
2366}
2367
2368/// Syntax highlight span for a buffer range
2369#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2370#[ts(export)]
2371pub struct TsHighlightSpan {
2372    pub start: u32,
2373    pub end: u32,
2374    #[ts(type = "[number, number, number]")]
2375    pub color: (u8, u8, u8),
2376    pub bold: bool,
2377    pub italic: bool,
2378}
2379
2380/// Result from spawning a process with spawnProcess
2381#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2382#[ts(export)]
2383pub struct SpawnResult {
2384    /// Complete stdout as string
2385    pub stdout: String,
2386    /// Complete stderr as string
2387    pub stderr: String,
2388    /// Process exit code (0 usually means success, -1 if killed)
2389    pub exit_code: i32,
2390}
2391
2392/// Result from spawning a background process
2393#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2394#[ts(export)]
2395pub struct BackgroundProcessResult {
2396    /// Unique process ID for later reference
2397    #[ts(type = "number")]
2398    pub process_id: u64,
2399    /// Process exit code (0 usually means success, -1 if killed)
2400    /// Only present when the process has exited
2401    pub exit_code: i32,
2402}
2403
2404/// A single match from project-wide grep
2405#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2406#[serde(rename_all = "camelCase")]
2407#[ts(export, rename_all = "camelCase")]
2408pub struct GrepMatch {
2409    /// Absolute file path
2410    pub file: String,
2411    /// Buffer ID if the file is open (0 if not)
2412    #[ts(type = "number")]
2413    pub buffer_id: usize,
2414    /// Byte offset of match start in the file/buffer content
2415    #[ts(type = "number")]
2416    pub byte_offset: usize,
2417    /// Match length in bytes
2418    #[ts(type = "number")]
2419    pub length: usize,
2420    /// 1-indexed line number
2421    #[ts(type = "number")]
2422    pub line: usize,
2423    /// 1-indexed column number
2424    #[ts(type = "number")]
2425    pub column: usize,
2426    /// The matched line content (for display)
2427    pub context: String,
2428}
2429
2430/// Result from replacing matches in a buffer
2431#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2432#[serde(rename_all = "camelCase")]
2433#[ts(export, rename_all = "camelCase")]
2434pub struct ReplaceResult {
2435    /// Number of replacements made
2436    #[ts(type = "number")]
2437    pub replacements: usize,
2438    /// Buffer ID of the edited buffer
2439    #[ts(type = "number")]
2440    pub buffer_id: usize,
2441}
2442
2443/// Entry for virtual buffer content with optional text properties (JS API version)
2444#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2445#[serde(deny_unknown_fields, rename_all = "camelCase")]
2446#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2447pub struct JsTextPropertyEntry {
2448    /// Text content for this entry
2449    pub text: String,
2450    /// Optional properties attached to this text (e.g., file path, line number)
2451    #[serde(default)]
2452    #[ts(optional, type = "Record<string, unknown>")]
2453    pub properties: Option<HashMap<String, JsonValue>>,
2454    /// Optional whole-entry styling
2455    #[serde(default)]
2456    #[ts(optional, type = "Partial<OverlayOptions>")]
2457    pub style: Option<OverlayOptions>,
2458    /// Optional sub-range styling within this entry
2459    #[serde(default)]
2460    #[ts(optional)]
2461    pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2462}
2463
2464/// Directory entry returned by readDir
2465#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2466#[ts(export)]
2467pub struct DirEntry {
2468    /// File/directory name
2469    pub name: String,
2470    /// True if this is a file
2471    pub is_file: bool,
2472    /// True if this is a directory
2473    pub is_dir: bool,
2474}
2475
2476/// Position in a document (line and character)
2477#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2478#[ts(export)]
2479pub struct JsPosition {
2480    /// Zero-indexed line number
2481    pub line: u32,
2482    /// Zero-indexed character offset
2483    pub character: u32,
2484}
2485
2486/// Range in a document (start and end positions)
2487#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2488#[ts(export)]
2489pub struct JsRange {
2490    /// Start position
2491    pub start: JsPosition,
2492    /// End position
2493    pub end: JsPosition,
2494}
2495
2496/// Diagnostic from LSP
2497#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2498#[ts(export)]
2499pub struct JsDiagnostic {
2500    /// Document URI
2501    pub uri: String,
2502    /// Diagnostic message
2503    pub message: String,
2504    /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2505    pub severity: Option<u8>,
2506    /// Range in the document
2507    pub range: JsRange,
2508    /// Source of the diagnostic (e.g., "typescript", "eslint")
2509    #[ts(optional)]
2510    pub source: Option<String>,
2511}
2512
2513/// Options for createVirtualBuffer
2514#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2515#[serde(deny_unknown_fields)]
2516#[ts(export)]
2517pub struct CreateVirtualBufferOptions {
2518    /// Buffer name (displayed in tabs/title)
2519    pub name: String,
2520    /// Mode for keybindings (e.g., "git-log", "search-results")
2521    #[serde(default)]
2522    #[ts(optional)]
2523    pub mode: Option<String>,
2524    /// Whether buffer is read-only (default: false)
2525    #[serde(default, rename = "readOnly")]
2526    #[ts(optional, rename = "readOnly")]
2527    pub read_only: Option<bool>,
2528    /// Show line numbers in gutter (default: false)
2529    #[serde(default, rename = "showLineNumbers")]
2530    #[ts(optional, rename = "showLineNumbers")]
2531    pub show_line_numbers: Option<bool>,
2532    /// Show cursor (default: true)
2533    #[serde(default, rename = "showCursors")]
2534    #[ts(optional, rename = "showCursors")]
2535    pub show_cursors: Option<bool>,
2536    /// Disable text editing (default: false)
2537    #[serde(default, rename = "editingDisabled")]
2538    #[ts(optional, rename = "editingDisabled")]
2539    pub editing_disabled: Option<bool>,
2540    /// Hide from tab bar (default: false)
2541    #[serde(default, rename = "hiddenFromTabs")]
2542    #[ts(optional, rename = "hiddenFromTabs")]
2543    pub hidden_from_tabs: Option<bool>,
2544    /// Initial content entries with optional properties
2545    #[serde(default)]
2546    #[ts(optional)]
2547    pub entries: Option<Vec<JsTextPropertyEntry>>,
2548}
2549
2550/// Options for createVirtualBufferInSplit
2551#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2552#[serde(deny_unknown_fields)]
2553#[ts(export)]
2554pub struct CreateVirtualBufferInSplitOptions {
2555    /// Buffer name (displayed in tabs/title)
2556    pub name: String,
2557    /// Mode for keybindings (e.g., "git-log", "search-results")
2558    #[serde(default)]
2559    #[ts(optional)]
2560    pub mode: Option<String>,
2561    /// Whether buffer is read-only (default: false)
2562    #[serde(default, rename = "readOnly")]
2563    #[ts(optional, rename = "readOnly")]
2564    pub read_only: Option<bool>,
2565    /// Split ratio 0.0-1.0 (default: 0.5)
2566    #[serde(default)]
2567    #[ts(optional)]
2568    pub ratio: Option<f32>,
2569    /// Split direction: "horizontal" or "vertical"
2570    #[serde(default)]
2571    #[ts(optional)]
2572    pub direction: Option<String>,
2573    /// Panel ID to split from
2574    #[serde(default, rename = "panelId")]
2575    #[ts(optional, rename = "panelId")]
2576    pub panel_id: Option<String>,
2577    /// Show line numbers in gutter (default: true)
2578    #[serde(default, rename = "showLineNumbers")]
2579    #[ts(optional, rename = "showLineNumbers")]
2580    pub show_line_numbers: Option<bool>,
2581    /// Show cursor (default: true)
2582    #[serde(default, rename = "showCursors")]
2583    #[ts(optional, rename = "showCursors")]
2584    pub show_cursors: Option<bool>,
2585    /// Disable text editing (default: false)
2586    #[serde(default, rename = "editingDisabled")]
2587    #[ts(optional, rename = "editingDisabled")]
2588    pub editing_disabled: Option<bool>,
2589    /// Enable line wrapping
2590    #[serde(default, rename = "lineWrap")]
2591    #[ts(optional, rename = "lineWrap")]
2592    pub line_wrap: Option<bool>,
2593    /// Place the new buffer before (left/top of) the existing content (default: false)
2594    #[serde(default)]
2595    #[ts(optional)]
2596    pub before: Option<bool>,
2597    /// Initial content entries with optional properties
2598    #[serde(default)]
2599    #[ts(optional)]
2600    pub entries: Option<Vec<JsTextPropertyEntry>>,
2601}
2602
2603/// Options for createVirtualBufferInExistingSplit
2604#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2605#[serde(deny_unknown_fields)]
2606#[ts(export)]
2607pub struct CreateVirtualBufferInExistingSplitOptions {
2608    /// Buffer name (displayed in tabs/title)
2609    pub name: String,
2610    /// Target split ID (required)
2611    #[serde(rename = "splitId")]
2612    #[ts(rename = "splitId")]
2613    pub split_id: usize,
2614    /// Mode for keybindings (e.g., "git-log", "search-results")
2615    #[serde(default)]
2616    #[ts(optional)]
2617    pub mode: Option<String>,
2618    /// Whether buffer is read-only (default: false)
2619    #[serde(default, rename = "readOnly")]
2620    #[ts(optional, rename = "readOnly")]
2621    pub read_only: Option<bool>,
2622    /// Show line numbers in gutter (default: true)
2623    #[serde(default, rename = "showLineNumbers")]
2624    #[ts(optional, rename = "showLineNumbers")]
2625    pub show_line_numbers: Option<bool>,
2626    /// Show cursor (default: true)
2627    #[serde(default, rename = "showCursors")]
2628    #[ts(optional, rename = "showCursors")]
2629    pub show_cursors: Option<bool>,
2630    /// Disable text editing (default: false)
2631    #[serde(default, rename = "editingDisabled")]
2632    #[ts(optional, rename = "editingDisabled")]
2633    pub editing_disabled: Option<bool>,
2634    /// Enable line wrapping
2635    #[serde(default, rename = "lineWrap")]
2636    #[ts(optional, rename = "lineWrap")]
2637    pub line_wrap: Option<bool>,
2638    /// Initial content entries with optional properties
2639    #[serde(default)]
2640    #[ts(optional)]
2641    pub entries: Option<Vec<JsTextPropertyEntry>>,
2642}
2643
2644/// Options for createTerminal
2645#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2646#[serde(deny_unknown_fields)]
2647#[ts(export)]
2648pub struct CreateTerminalOptions {
2649    /// Working directory for the terminal (defaults to editor cwd)
2650    #[serde(default)]
2651    #[ts(optional)]
2652    pub cwd: Option<String>,
2653    /// Split direction: "horizontal" or "vertical" (default: "vertical")
2654    #[serde(default)]
2655    #[ts(optional)]
2656    pub direction: Option<String>,
2657    /// Split ratio 0.0-1.0 (default: 0.5)
2658    #[serde(default)]
2659    #[ts(optional)]
2660    pub ratio: Option<f32>,
2661    /// Whether to focus the new terminal split (default: true)
2662    #[serde(default)]
2663    #[ts(optional)]
2664    pub focus: Option<bool>,
2665    /// Whether this terminal is part of the user's persisted workspace.
2666    /// Defaults to `false` for plugin-created terminals — they are typically
2667    /// one-off tool UIs (rebuilds, exec shells, build output) and should
2668    /// start with empty scrollback on each invocation. Set to `true` only
2669    /// when the plugin owns a terminal that the user should see restored
2670    /// across editor restarts.
2671    #[serde(default)]
2672    #[ts(optional)]
2673    pub persistent: Option<bool>,
2674}
2675
2676/// Result of getTextPropertiesAtCursor - array of property objects
2677///
2678/// Each element contains the properties from a text property span that overlaps
2679/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2680#[derive(Debug, Clone, Serialize, TS)]
2681#[ts(export, type = "Array<Record<string, unknown>>")]
2682pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2683
2684// Implement FromJs for option types using rquickjs_serde
2685#[cfg(feature = "plugins")]
2686mod fromjs_impls {
2687    use super::*;
2688    use rquickjs::{Ctx, FromJs, Value};
2689
2690    // All types that deserialize from a JS value via rquickjs_serde follow
2691    // the same 8-line pattern differing only in the type name. This macro
2692    // expands that pattern once so adding a new plugin-API type costs one line
2693    // here instead of a copy-pasted block.
2694    macro_rules! impl_from_js_via_serde {
2695        ($($T:ty),+ $(,)?) => {
2696            $(
2697                impl<'js> FromJs<'js> for $T {
2698                    fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2699                        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2700                            from: "object",
2701                            to: stringify!($T),
2702                            message: Some(e.to_string()),
2703                        })
2704                    }
2705                }
2706            )+
2707        };
2708    }
2709
2710    impl_from_js_via_serde!(
2711        JsTextPropertyEntry,
2712        CreateVirtualBufferOptions,
2713        CreateVirtualBufferInSplitOptions,
2714        CreateVirtualBufferInExistingSplitOptions,
2715        ActionSpec,
2716        ActionPopupAction,
2717        ActionPopupOptions,
2718        ViewTokenWire,
2719        ViewTokenStyle,
2720        LayoutHints,
2721        CompositeHunk,
2722        LanguagePackConfig,
2723        LspServerPackConfig,
2724        ProcessLimitsPackConfig,
2725        CreateTerminalOptions,
2726    );
2727
2728    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2729        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2730            rquickjs_serde::to_value(ctx.clone(), &self.0)
2731                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2732        }
2733    }
2734
2735    impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2736        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2737            // Two-step deserialization: rquickjs_serde cannot handle the nested
2738            // enums in this struct directly, so go via serde_json as an intermediary.
2739            let json: serde_json::Value =
2740                rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2741                    from: "object",
2742                    to: "CreateCompositeBufferOptions (json)",
2743                    message: Some(e.to_string()),
2744                })?;
2745            serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2746                from: "json",
2747                to: "CreateCompositeBufferOptions",
2748                message: Some(e.to_string()),
2749            })
2750        }
2751    }
2752
2753    // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
2754    //
2755    // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
2756    // that replaces the body with `Ok(Default::default())` drops the
2757    // decoded payload on the floor. Every test below asserts a
2758    // non-defaultable field value, so the mutant cannot pass.
2759    //
2760    // Note: many of the target structs do not implement `Default`, making
2761    // those mutants unviable (they fail to compile) — cargo-mutants still
2762    // lists them as candidates. The tests below serve double-duty as
2763    // behavioural regression protection for the JS → Rust conversion layer.
2764    #[cfg(test)]
2765    mod tests {
2766        use super::*;
2767        use rquickjs::{Context, Runtime};
2768
2769        /// Run a closure within a fresh QuickJS context so that `FromJs`
2770        /// impls can be exercised end-to-end.
2771        fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
2772            let rt = Runtime::new().expect("create rquickjs runtime");
2773            let ctx = Context::full(&rt).expect("create rquickjs context");
2774            ctx.with(f)
2775        }
2776
2777        /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
2778        fn eval_as<T>(src: &str) -> T
2779        where
2780            for<'js> T: rquickjs::FromJs<'js>,
2781        {
2782            with_js(|ctx| {
2783                let value: Value = ctx
2784                    .eval::<Value, _>(src.as_bytes())
2785                    .expect("eval JS source");
2786                T::from_js(&ctx, value).expect("from_js decode")
2787            })
2788        }
2789
2790        #[test]
2791        fn js_text_property_entry_decodes_text_and_properties() {
2792            let got: JsTextPropertyEntry =
2793                eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
2794            assert_eq!(got.text, "hello");
2795            let props = got.properties.expect("properties present");
2796            assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
2797        }
2798
2799        #[test]
2800        fn create_virtual_buffer_options_decodes_name() {
2801            let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
2802            assert_eq!(got.name, "logs");
2803            assert_eq!(got.read_only, Some(true));
2804        }
2805
2806        #[test]
2807        fn create_virtual_buffer_in_split_options_decodes_ratio() {
2808            let got: CreateVirtualBufferInSplitOptions =
2809                eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
2810            assert_eq!(got.name, "diag");
2811            assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
2812            assert_eq!(got.direction.as_deref(), Some("horizontal"));
2813        }
2814
2815        #[test]
2816        fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
2817            let got: CreateVirtualBufferInExistingSplitOptions =
2818                eval_as("({name: 'n', splitId: 7})");
2819            assert_eq!(got.name, "n");
2820            assert_eq!(got.split_id, 7);
2821        }
2822
2823        #[test]
2824        fn create_terminal_options_decodes_cwd_and_focus() {
2825            let got: CreateTerminalOptions =
2826                eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
2827            assert_eq!(got.cwd.as_deref(), Some("/tmp"));
2828            assert_eq!(got.direction.as_deref(), Some("vertical"));
2829            assert_eq!(got.focus, Some(false));
2830        }
2831
2832        #[test]
2833        fn action_spec_decodes_action_and_count() {
2834            let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
2835            assert_eq!(got.action, "move_word_right");
2836            assert_eq!(got.count, 5);
2837        }
2838
2839        #[test]
2840        fn action_popup_action_decodes_id_and_label() {
2841            let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
2842            assert_eq!(got.id, "ok");
2843            assert_eq!(got.label, "OK");
2844        }
2845
2846        #[test]
2847        fn action_popup_options_decodes_actions_list() {
2848            let got: ActionPopupOptions = eval_as(
2849                "({id: 'p', title: 't', message: 'm', \
2850                   actions: [{id: 'ok', label: 'OK'}]})",
2851            );
2852            assert_eq!(got.id, "p");
2853            assert_eq!(got.title, "t");
2854            assert_eq!(got.message, "m");
2855            assert_eq!(got.actions.len(), 1);
2856            assert_eq!(got.actions[0].id, "ok");
2857        }
2858
2859        #[test]
2860        fn view_token_wire_decodes_offset_and_kind() {
2861            // Using `Newline` (a unit variant) avoids the tuple-variant
2862            // wire-format ambiguity in rquickjs_serde while still exercising
2863            // the `FromJs` impl end-to-end.
2864            let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
2865            assert_eq!(got.source_offset, Some(42));
2866            assert!(matches!(got.kind, ViewTokenWireKind::Newline));
2867        }
2868
2869        #[test]
2870        fn view_token_style_decodes_boolean_flags() {
2871            // `fg`/`bg` are `Option<(u8, u8, u8)>` which rquickjs_serde does
2872            // not decode from plain JS arrays, so we pin down the boolean
2873            // flags — enough to prove the body actually ran.
2874            let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
2875            assert!(got.bold);
2876            assert!(got.italic);
2877            assert!(got.fg.is_none());
2878        }
2879
2880        #[test]
2881        fn layout_hints_decodes_compose_width() {
2882            let got: LayoutHints = eval_as("({composeWidth: 120})");
2883            assert_eq!(got.compose_width, Some(120));
2884            assert!(got.column_guides.is_none());
2885        }
2886
2887        #[test]
2888        fn create_composite_buffer_options_decodes_name_and_sources() {
2889            let got: CreateCompositeBufferOptions = eval_as(
2890                "({name: 'diff', mode: 'm', \
2891                   layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
2892                   sources: [{bufferId: 3, label: 'OLD'}]})",
2893            );
2894            assert_eq!(got.name, "diff");
2895            assert_eq!(got.layout.layout_type, "side-by-side");
2896            assert_eq!(got.sources.len(), 1);
2897            assert_eq!(got.sources[0].buffer_id, 3);
2898            assert_eq!(got.sources[0].label, "OLD");
2899        }
2900
2901        #[test]
2902        fn composite_hunk_decodes_all_fields() {
2903            let got: CompositeHunk =
2904                eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
2905            assert_eq!(got.old_start, 1);
2906            assert_eq!(got.old_count, 2);
2907            assert_eq!(got.new_start, 3);
2908            assert_eq!(got.new_count, 4);
2909        }
2910
2911        #[test]
2912        fn language_pack_config_decodes_comment_prefix_and_tab_size() {
2913            let got: LanguagePackConfig =
2914                eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
2915            assert_eq!(got.comment_prefix.as_deref(), Some("//"));
2916            assert_eq!(got.tab_size, Some(7));
2917            assert_eq!(got.use_tabs, Some(true));
2918        }
2919
2920        #[test]
2921        fn lsp_server_pack_config_decodes_command_and_args() {
2922            let got: LspServerPackConfig =
2923                eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
2924            assert_eq!(got.command, "rust-analyzer");
2925            assert_eq!(got.args, vec!["--log".to_string()]);
2926            assert_eq!(got.auto_start, Some(true));
2927        }
2928
2929        #[test]
2930        fn process_limits_pack_config_decodes_percentages() {
2931            let got: ProcessLimitsPackConfig =
2932                eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
2933            assert_eq!(got.max_memory_percent, Some(75));
2934            assert_eq!(got.max_cpu_percent, Some(50));
2935            assert_eq!(got.enabled, Some(true));
2936        }
2937
2938        /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
2939        /// into a JS array whose length matches the payload. A mutant that
2940        /// returns a default (`undefined` / empty) value would fail either
2941        /// the array check or the length check.
2942        #[test]
2943        fn text_properties_at_cursor_into_js_preserves_length() {
2944            use rquickjs::IntoJs;
2945            with_js(|ctx| {
2946                let mut entry = std::collections::HashMap::new();
2947                entry.insert("k".to_string(), serde_json::json!("v"));
2948                let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
2949
2950                let v = payload.into_js(&ctx).expect("into_js");
2951                let arr = v.as_array().expect("expected JS array");
2952                assert_eq!(arr.len(), 2);
2953            });
2954        }
2955    }
2956}
2957
2958/// Plugin API context - provides safe access to editor functionality
2959pub struct PluginApi {
2960    /// Hook registry (shared with editor)
2961    hooks: Arc<RwLock<HookRegistry>>,
2962
2963    /// Command registry (shared with editor)
2964    commands: Arc<RwLock<CommandRegistry>>,
2965
2966    /// Command queue for sending commands to editor
2967    command_sender: std::sync::mpsc::Sender<PluginCommand>,
2968
2969    /// Snapshot of editor state (read-only for plugins)
2970    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2971}
2972
2973impl PluginApi {
2974    /// Create a new plugin API context
2975    pub fn new(
2976        hooks: Arc<RwLock<HookRegistry>>,
2977        commands: Arc<RwLock<CommandRegistry>>,
2978        command_sender: std::sync::mpsc::Sender<PluginCommand>,
2979        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
2980    ) -> Self {
2981        Self {
2982            hooks,
2983            commands,
2984            command_sender,
2985            state_snapshot,
2986        }
2987    }
2988
2989    /// Register a hook callback
2990    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
2991        let mut hooks = self.hooks.write().unwrap();
2992        hooks.add_hook(hook_name, callback);
2993    }
2994
2995    /// Remove all hooks for a specific name
2996    pub fn unregister_hooks(&self, hook_name: &str) {
2997        let mut hooks = self.hooks.write().unwrap();
2998        hooks.remove_hooks(hook_name);
2999    }
3000
3001    /// Register a command
3002    pub fn register_command(&self, command: Command) {
3003        let commands = self.commands.read().unwrap();
3004        commands.register(command);
3005    }
3006
3007    /// Unregister a command by name
3008    pub fn unregister_command(&self, name: &str) {
3009        let commands = self.commands.read().unwrap();
3010        commands.unregister(name);
3011    }
3012
3013    /// Send a command to the editor (async/non-blocking)
3014    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
3015        self.command_sender
3016            .send(command)
3017            .map_err(|e| format!("Failed to send command: {}", e))
3018    }
3019
3020    /// Insert text at a position in a buffer
3021    pub fn insert_text(
3022        &self,
3023        buffer_id: BufferId,
3024        position: usize,
3025        text: String,
3026    ) -> Result<(), String> {
3027        self.send_command(PluginCommand::InsertText {
3028            buffer_id,
3029            position,
3030            text,
3031        })
3032    }
3033
3034    /// Delete a range of text from a buffer
3035    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
3036        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
3037    }
3038
3039    /// Add an overlay (decoration) to a buffer
3040    /// Add an overlay to a buffer with styling options
3041    ///
3042    /// Returns an opaque handle that can be used to remove the overlay later.
3043    ///
3044    /// Colors can be specified as RGB arrays or theme key strings.
3045    /// Theme keys are resolved at render time, so overlays update with theme changes.
3046    pub fn add_overlay(
3047        &self,
3048        buffer_id: BufferId,
3049        namespace: Option<String>,
3050        range: Range<usize>,
3051        options: OverlayOptions,
3052    ) -> Result<(), String> {
3053        self.send_command(PluginCommand::AddOverlay {
3054            buffer_id,
3055            namespace: namespace.map(OverlayNamespace::from_string),
3056            range,
3057            options,
3058        })
3059    }
3060
3061    /// Remove an overlay from a buffer by its handle
3062    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
3063        self.send_command(PluginCommand::RemoveOverlay {
3064            buffer_id,
3065            handle: OverlayHandle::from_string(handle),
3066        })
3067    }
3068
3069    /// Clear all overlays in a namespace from a buffer
3070    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
3071        self.send_command(PluginCommand::ClearNamespace {
3072            buffer_id,
3073            namespace: OverlayNamespace::from_string(namespace),
3074        })
3075    }
3076
3077    /// Clear all overlays that overlap with a byte range
3078    /// Used for targeted invalidation when content changes
3079    pub fn clear_overlays_in_range(
3080        &self,
3081        buffer_id: BufferId,
3082        start: usize,
3083        end: usize,
3084    ) -> Result<(), String> {
3085        self.send_command(PluginCommand::ClearOverlaysInRange {
3086            buffer_id,
3087            start,
3088            end,
3089        })
3090    }
3091
3092    /// Set the status message
3093    pub fn set_status(&self, message: String) -> Result<(), String> {
3094        self.send_command(PluginCommand::SetStatus { message })
3095    }
3096
3097    /// Open a file at a specific line and column (1-indexed)
3098    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
3099    pub fn open_file_at_location(
3100        &self,
3101        path: PathBuf,
3102        line: Option<usize>,
3103        column: Option<usize>,
3104    ) -> Result<(), String> {
3105        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
3106    }
3107
3108    /// Open a file in a specific split at a line and column
3109    ///
3110    /// Similar to open_file_at_location but targets a specific split pane.
3111    /// The split_id is the ID of the split pane to open the file in.
3112    pub fn open_file_in_split(
3113        &self,
3114        split_id: usize,
3115        path: PathBuf,
3116        line: Option<usize>,
3117        column: Option<usize>,
3118    ) -> Result<(), String> {
3119        self.send_command(PluginCommand::OpenFileInSplit {
3120            split_id,
3121            path,
3122            line,
3123            column,
3124        })
3125    }
3126
3127    /// Start a prompt (minibuffer) with a custom type identifier
3128    /// The prompt_type is used to filter hooks in plugin code
3129    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
3130        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
3131    }
3132
3133    /// Set the suggestions for the current prompt
3134    /// This updates the prompt's autocomplete/selection list
3135    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
3136        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
3137    }
3138
3139    /// Enable/disable syncing prompt input text when navigating suggestions
3140    pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
3141        self.send_command(PluginCommand::SetPromptInputSync { sync })
3142    }
3143
3144    /// Add a menu item to an existing menu
3145    pub fn add_menu_item(
3146        &self,
3147        menu_label: String,
3148        item: MenuItem,
3149        position: MenuPosition,
3150    ) -> Result<(), String> {
3151        self.send_command(PluginCommand::AddMenuItem {
3152            menu_label,
3153            item,
3154            position,
3155        })
3156    }
3157
3158    /// Add a new top-level menu
3159    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
3160        self.send_command(PluginCommand::AddMenu { menu, position })
3161    }
3162
3163    /// Remove a menu item from a menu
3164    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
3165        self.send_command(PluginCommand::RemoveMenuItem {
3166            menu_label,
3167            item_label,
3168        })
3169    }
3170
3171    /// Remove a top-level menu
3172    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
3173        self.send_command(PluginCommand::RemoveMenu { menu_label })
3174    }
3175
3176    // === Virtual Buffer Methods ===
3177
3178    /// Create a new virtual buffer (not backed by a file)
3179    ///
3180    /// Virtual buffers are used for special displays like diagnostic lists,
3181    /// search results, etc. They have their own mode for keybindings.
3182    pub fn create_virtual_buffer(
3183        &self,
3184        name: String,
3185        mode: String,
3186        read_only: bool,
3187    ) -> Result<(), String> {
3188        self.send_command(PluginCommand::CreateVirtualBuffer {
3189            name,
3190            mode,
3191            read_only,
3192        })
3193    }
3194
3195    /// Create a virtual buffer and set its content in one operation
3196    ///
3197    /// This is the preferred way to create virtual buffers since it doesn't
3198    /// require tracking the buffer ID. The buffer is created and populated
3199    /// atomically.
3200    pub fn create_virtual_buffer_with_content(
3201        &self,
3202        name: String,
3203        mode: String,
3204        read_only: bool,
3205        entries: Vec<TextPropertyEntry>,
3206    ) -> Result<(), String> {
3207        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
3208            name,
3209            mode,
3210            read_only,
3211            entries,
3212            show_line_numbers: true,
3213            show_cursors: true,
3214            editing_disabled: false,
3215            hidden_from_tabs: false,
3216            request_id: None,
3217        })
3218    }
3219
3220    /// Set the content of a virtual buffer with text properties
3221    ///
3222    /// Each entry contains text and metadata properties (e.g., source location).
3223    pub fn set_virtual_buffer_content(
3224        &self,
3225        buffer_id: BufferId,
3226        entries: Vec<TextPropertyEntry>,
3227    ) -> Result<(), String> {
3228        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
3229    }
3230
3231    /// Get text properties at cursor position in a buffer
3232    ///
3233    /// This triggers a command that will make properties available to plugins.
3234    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
3235        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
3236    }
3237
3238    /// Define a buffer mode with keybindings
3239    ///
3240    /// Bindings are specified as (key_string, command_name) pairs.
3241    pub fn define_mode(
3242        &self,
3243        name: String,
3244        bindings: Vec<(String, String)>,
3245        read_only: bool,
3246        allow_text_input: bool,
3247    ) -> Result<(), String> {
3248        self.send_command(PluginCommand::DefineMode {
3249            name,
3250            bindings,
3251            read_only,
3252            allow_text_input,
3253            inherit_normal_bindings: false,
3254            plugin_name: None,
3255        })
3256    }
3257
3258    /// Switch the current split to display a buffer
3259    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
3260        self.send_command(PluginCommand::ShowBuffer { buffer_id })
3261    }
3262
3263    /// Set the scroll position of a specific split
3264    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
3265        self.send_command(PluginCommand::SetSplitScroll {
3266            split_id: SplitId(split_id),
3267            top_byte,
3268        })
3269    }
3270
3271    /// Request syntax highlights for a buffer range
3272    pub fn get_highlights(
3273        &self,
3274        buffer_id: BufferId,
3275        range: Range<usize>,
3276        request_id: u64,
3277    ) -> Result<(), String> {
3278        self.send_command(PluginCommand::RequestHighlights {
3279            buffer_id,
3280            range,
3281            request_id,
3282        })
3283    }
3284
3285    // === Query Methods ===
3286
3287    /// Get the currently active buffer ID
3288    pub fn get_active_buffer_id(&self) -> BufferId {
3289        let snapshot = self.state_snapshot.read().unwrap();
3290        snapshot.active_buffer_id
3291    }
3292
3293    /// Get the currently active split ID
3294    pub fn get_active_split_id(&self) -> usize {
3295        let snapshot = self.state_snapshot.read().unwrap();
3296        snapshot.active_split_id
3297    }
3298
3299    /// Get information about a specific buffer
3300    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
3301        let snapshot = self.state_snapshot.read().unwrap();
3302        snapshot.buffers.get(&buffer_id).cloned()
3303    }
3304
3305    /// Get all buffer IDs
3306    pub fn list_buffers(&self) -> Vec<BufferInfo> {
3307        let snapshot = self.state_snapshot.read().unwrap();
3308        snapshot.buffers.values().cloned().collect()
3309    }
3310
3311    /// Get primary cursor information for the active buffer
3312    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
3313        let snapshot = self.state_snapshot.read().unwrap();
3314        snapshot.primary_cursor.clone()
3315    }
3316
3317    /// Get all cursor information for the active buffer
3318    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
3319        let snapshot = self.state_snapshot.read().unwrap();
3320        snapshot.all_cursors.clone()
3321    }
3322
3323    /// Get viewport information for the active buffer
3324    pub fn get_viewport(&self) -> Option<ViewportInfo> {
3325        let snapshot = self.state_snapshot.read().unwrap();
3326        snapshot.viewport.clone()
3327    }
3328
3329    /// Get access to the state snapshot Arc (for internal use)
3330    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
3331        Arc::clone(&self.state_snapshot)
3332    }
3333}
3334
3335impl Clone for PluginApi {
3336    fn clone(&self) -> Self {
3337        Self {
3338            hooks: Arc::clone(&self.hooks),
3339            commands: Arc::clone(&self.commands),
3340            command_sender: self.command_sender.clone(),
3341            state_snapshot: Arc::clone(&self.state_snapshot),
3342        }
3343    }
3344}
3345
3346// ============================================================================
3347// Pluggable Completion Service — TypeScript Plugin API Types
3348// ============================================================================
3349//
3350// These types are the bridge between the Rust `CompletionService` and
3351// TypeScript plugins that want to provide completion candidates.  They are
3352// serialised to/from JSON via serde and generate TypeScript definitions via
3353// ts-rs so that the plugin API stays in sync automatically.
3354
3355/// A completion candidate produced by a TypeScript plugin provider.
3356///
3357/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
3358/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
3359#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3360#[serde(rename_all = "camelCase", deny_unknown_fields)]
3361#[ts(export, rename_all = "camelCase")]
3362pub struct TsCompletionCandidate {
3363    /// Display text shown in the completion popup.
3364    pub label: String,
3365
3366    /// Text to insert when accepted. Falls back to `label` if omitted.
3367    #[serde(skip_serializing_if = "Option::is_none")]
3368    pub insert_text: Option<String>,
3369
3370    /// Short detail string shown next to the label.
3371    #[serde(skip_serializing_if = "Option::is_none")]
3372    pub detail: Option<String>,
3373
3374    /// Single-character icon hint (e.g. `"λ"`, `"v"`).
3375    #[serde(skip_serializing_if = "Option::is_none")]
3376    pub icon: Option<String>,
3377
3378    /// Provider-assigned relevance score (higher = better).
3379    #[serde(default)]
3380    pub score: i64,
3381
3382    /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
3383    #[serde(default)]
3384    pub is_snippet: bool,
3385
3386    /// Opaque data carried through to the `completionAccepted` hook.
3387    #[serde(skip_serializing_if = "Option::is_none")]
3388    pub provider_data: Option<String>,
3389}
3390
3391/// Context sent to a TypeScript plugin's `provideCompletions` handler.
3392///
3393/// Plugins receive this as a read-only snapshot so they never need direct
3394/// buffer access (which would be unsafe for huge files).
3395#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3396#[serde(rename_all = "camelCase")]
3397#[ts(export, rename_all = "camelCase")]
3398pub struct TsCompletionContext {
3399    /// The word prefix typed so far.
3400    pub prefix: String,
3401
3402    /// Byte offset of the cursor.
3403    pub cursor_byte: usize,
3404
3405    /// Byte offset of the word start (for replacement range).
3406    pub word_start_byte: usize,
3407
3408    /// Total buffer size in bytes.
3409    pub buffer_len: usize,
3410
3411    /// Whether the buffer is a lazily-loaded huge file.
3412    pub is_large_file: bool,
3413
3414    /// A text excerpt around the cursor (the contents of the safe scan window).
3415    /// Plugins should search only this string, not request the full buffer.
3416    pub text_around_cursor: String,
3417
3418    /// Byte offset within `text_around_cursor` that corresponds to the cursor.
3419    pub cursor_offset_in_text: usize,
3420
3421    /// File language id (e.g. `"rust"`, `"typescript"`), if known.
3422    #[serde(skip_serializing_if = "Option::is_none")]
3423    pub language_id: Option<String>,
3424}
3425
3426/// Registration payload sent by a plugin to register a completion provider.
3427#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3428#[serde(rename_all = "camelCase", deny_unknown_fields)]
3429#[ts(export, rename_all = "camelCase")]
3430pub struct TsCompletionProviderRegistration {
3431    /// Unique id for this provider (e.g., `"my-snippets"`).
3432    pub id: String,
3433
3434    /// Human-readable name shown in status/debug UI.
3435    pub display_name: String,
3436
3437    /// Priority tier (lower = higher priority). Convention:
3438    /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
3439    #[serde(default = "default_plugin_provider_priority")]
3440    pub priority: u32,
3441
3442    /// Optional list of language ids this provider is active for.
3443    /// If empty/omitted, the provider is active for all languages.
3444    #[serde(default)]
3445    pub language_ids: Vec<String>,
3446}
3447
3448fn default_plugin_provider_priority() -> u32 {
3449    50
3450}
3451
3452#[cfg(test)]
3453mod tests {
3454    use super::*;
3455
3456    #[test]
3457    fn test_plugin_api_creation() {
3458        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3459        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3460        let (tx, _rx) = std::sync::mpsc::channel();
3461        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3462
3463        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3464
3465        // Should not panic
3466        let _clone = api.clone();
3467    }
3468
3469    #[test]
3470    fn test_register_hook() {
3471        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3472        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3473        let (tx, _rx) = std::sync::mpsc::channel();
3474        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3475
3476        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
3477
3478        api.register_hook("test-hook", Box::new(|_| true));
3479
3480        let hook_registry = hooks.read().unwrap();
3481        assert_eq!(hook_registry.hook_count("test-hook"), 1);
3482    }
3483
3484    #[test]
3485    fn test_send_command() {
3486        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3487        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3488        let (tx, rx) = std::sync::mpsc::channel();
3489        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3490
3491        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3492
3493        let result = api.insert_text(BufferId(1), 0, "test".to_string());
3494        assert!(result.is_ok());
3495
3496        // Verify command was sent
3497        let received = rx.try_recv();
3498        assert!(received.is_ok());
3499
3500        match received.unwrap() {
3501            PluginCommand::InsertText {
3502                buffer_id,
3503                position,
3504                text,
3505            } => {
3506                assert_eq!(buffer_id.0, 1);
3507                assert_eq!(position, 0);
3508                assert_eq!(text, "test");
3509            }
3510            _ => panic!("Wrong command type"),
3511        }
3512    }
3513
3514    #[test]
3515    fn test_add_overlay_command() {
3516        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3517        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3518        let (tx, rx) = std::sync::mpsc::channel();
3519        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3520
3521        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3522
3523        let result = api.add_overlay(
3524            BufferId(1),
3525            Some("test-overlay".to_string()),
3526            0..10,
3527            OverlayOptions {
3528                fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3529                bg: None,
3530                underline: true,
3531                bold: false,
3532                italic: false,
3533                strikethrough: false,
3534                extend_to_line_end: false,
3535                url: None,
3536            },
3537        );
3538        assert!(result.is_ok());
3539
3540        let received = rx.try_recv().unwrap();
3541        match received {
3542            PluginCommand::AddOverlay {
3543                buffer_id,
3544                namespace,
3545                range,
3546                options,
3547            } => {
3548                assert_eq!(buffer_id.0, 1);
3549                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3550                assert_eq!(range, 0..10);
3551                assert!(matches!(
3552                    options.fg,
3553                    Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3554                ));
3555                assert!(options.bg.is_none());
3556                assert!(options.underline);
3557                assert!(!options.bold);
3558                assert!(!options.italic);
3559                assert!(!options.extend_to_line_end);
3560            }
3561            _ => panic!("Wrong command type"),
3562        }
3563    }
3564
3565    #[test]
3566    fn test_set_status_command() {
3567        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3568        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3569        let (tx, rx) = std::sync::mpsc::channel();
3570        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3571
3572        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3573
3574        let result = api.set_status("Test status".to_string());
3575        assert!(result.is_ok());
3576
3577        let received = rx.try_recv().unwrap();
3578        match received {
3579            PluginCommand::SetStatus { message } => {
3580                assert_eq!(message, "Test status");
3581            }
3582            _ => panic!("Wrong command type"),
3583        }
3584    }
3585
3586    #[test]
3587    fn test_get_active_buffer_id() {
3588        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3589        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3590        let (tx, _rx) = std::sync::mpsc::channel();
3591        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3592
3593        // Set active buffer to 5
3594        {
3595            let mut snapshot = state_snapshot.write().unwrap();
3596            snapshot.active_buffer_id = BufferId(5);
3597        }
3598
3599        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3600
3601        let active_id = api.get_active_buffer_id();
3602        assert_eq!(active_id.0, 5);
3603    }
3604
3605    #[test]
3606    fn test_get_buffer_info() {
3607        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3608        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3609        let (tx, _rx) = std::sync::mpsc::channel();
3610        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3611
3612        // Add buffer info
3613        {
3614            let mut snapshot = state_snapshot.write().unwrap();
3615            let buffer_info = BufferInfo {
3616                id: BufferId(1),
3617                path: Some(std::path::PathBuf::from("/test/file.txt")),
3618                modified: true,
3619                length: 100,
3620                is_virtual: false,
3621                view_mode: "source".to_string(),
3622                is_composing_in_any_split: false,
3623                compose_width: None,
3624                language: "text".to_string(),
3625                is_preview: false,
3626                splits: Vec::new(),
3627            };
3628            snapshot.buffers.insert(BufferId(1), buffer_info);
3629        }
3630
3631        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3632
3633        let info = api.get_buffer_info(BufferId(1));
3634        assert!(info.is_some());
3635        let info = info.unwrap();
3636        assert_eq!(info.id.0, 1);
3637        assert_eq!(
3638            info.path.as_ref().unwrap().to_str().unwrap(),
3639            "/test/file.txt"
3640        );
3641        assert!(info.modified);
3642        assert_eq!(info.length, 100);
3643
3644        // Non-existent buffer
3645        let no_info = api.get_buffer_info(BufferId(999));
3646        assert!(no_info.is_none());
3647    }
3648
3649    #[test]
3650    fn test_list_buffers() {
3651        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3652        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3653        let (tx, _rx) = std::sync::mpsc::channel();
3654        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3655
3656        // Add multiple buffers
3657        {
3658            let mut snapshot = state_snapshot.write().unwrap();
3659            snapshot.buffers.insert(
3660                BufferId(1),
3661                BufferInfo {
3662                    id: BufferId(1),
3663                    path: Some(std::path::PathBuf::from("/file1.txt")),
3664                    modified: false,
3665                    length: 50,
3666                    is_virtual: false,
3667                    view_mode: "source".to_string(),
3668                    is_composing_in_any_split: false,
3669                    compose_width: None,
3670                    language: "text".to_string(),
3671                    is_preview: false,
3672                    splits: Vec::new(),
3673                },
3674            );
3675            snapshot.buffers.insert(
3676                BufferId(2),
3677                BufferInfo {
3678                    id: BufferId(2),
3679                    path: Some(std::path::PathBuf::from("/file2.txt")),
3680                    modified: true,
3681                    length: 100,
3682                    is_virtual: false,
3683                    view_mode: "source".to_string(),
3684                    is_composing_in_any_split: false,
3685                    compose_width: None,
3686                    language: "text".to_string(),
3687                    is_preview: false,
3688                    splits: Vec::new(),
3689                },
3690            );
3691            snapshot.buffers.insert(
3692                BufferId(3),
3693                BufferInfo {
3694                    id: BufferId(3),
3695                    path: None,
3696                    modified: false,
3697                    length: 0,
3698                    is_virtual: true,
3699                    view_mode: "source".to_string(),
3700                    is_composing_in_any_split: false,
3701                    compose_width: None,
3702                    language: "text".to_string(),
3703                    is_preview: false,
3704                    splits: Vec::new(),
3705                },
3706            );
3707        }
3708
3709        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3710
3711        let buffers = api.list_buffers();
3712        assert_eq!(buffers.len(), 3);
3713
3714        // Verify all buffers are present
3715        assert!(buffers.iter().any(|b| b.id.0 == 1));
3716        assert!(buffers.iter().any(|b| b.id.0 == 2));
3717        assert!(buffers.iter().any(|b| b.id.0 == 3));
3718    }
3719
3720    #[test]
3721    fn test_get_primary_cursor() {
3722        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3723        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3724        let (tx, _rx) = std::sync::mpsc::channel();
3725        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3726
3727        // Add cursor info
3728        {
3729            let mut snapshot = state_snapshot.write().unwrap();
3730            snapshot.primary_cursor = Some(CursorInfo {
3731                position: 42,
3732                selection: Some(10..42),
3733            });
3734        }
3735
3736        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3737
3738        let cursor = api.get_primary_cursor();
3739        assert!(cursor.is_some());
3740        let cursor = cursor.unwrap();
3741        assert_eq!(cursor.position, 42);
3742        assert_eq!(cursor.selection, Some(10..42));
3743    }
3744
3745    #[test]
3746    fn test_get_all_cursors() {
3747        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3748        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3749        let (tx, _rx) = std::sync::mpsc::channel();
3750        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3751
3752        // Add multiple cursors
3753        {
3754            let mut snapshot = state_snapshot.write().unwrap();
3755            snapshot.all_cursors = vec![
3756                CursorInfo {
3757                    position: 10,
3758                    selection: None,
3759                },
3760                CursorInfo {
3761                    position: 20,
3762                    selection: Some(15..20),
3763                },
3764                CursorInfo {
3765                    position: 30,
3766                    selection: Some(25..30),
3767                },
3768            ];
3769        }
3770
3771        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3772
3773        let cursors = api.get_all_cursors();
3774        assert_eq!(cursors.len(), 3);
3775        assert_eq!(cursors[0].position, 10);
3776        assert_eq!(cursors[0].selection, None);
3777        assert_eq!(cursors[1].position, 20);
3778        assert_eq!(cursors[1].selection, Some(15..20));
3779        assert_eq!(cursors[2].position, 30);
3780        assert_eq!(cursors[2].selection, Some(25..30));
3781    }
3782
3783    #[test]
3784    fn test_get_viewport() {
3785        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3786        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3787        let (tx, _rx) = std::sync::mpsc::channel();
3788        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3789
3790        // Add viewport info
3791        {
3792            let mut snapshot = state_snapshot.write().unwrap();
3793            snapshot.viewport = Some(ViewportInfo {
3794                top_byte: 100,
3795                top_line: Some(5),
3796                left_column: 5,
3797                width: 80,
3798                height: 24,
3799            });
3800        }
3801
3802        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3803
3804        let viewport = api.get_viewport();
3805        assert!(viewport.is_some());
3806        let viewport = viewport.unwrap();
3807        assert_eq!(viewport.top_byte, 100);
3808        assert_eq!(viewport.left_column, 5);
3809        assert_eq!(viewport.width, 80);
3810        assert_eq!(viewport.height, 24);
3811    }
3812
3813    #[test]
3814    fn test_composite_buffer_options_rejects_unknown_fields() {
3815        // Valid JSON with correct field names
3816        let valid_json = r#"{
3817            "name": "test",
3818            "mode": "diff",
3819            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3820            "sources": [{"bufferId": 1, "label": "old"}]
3821        }"#;
3822        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3823        assert!(
3824            result.is_ok(),
3825            "Valid JSON should parse: {:?}",
3826            result.err()
3827        );
3828
3829        // Invalid JSON with unknown field (buffer_id instead of bufferId)
3830        let invalid_json = r#"{
3831            "name": "test",
3832            "mode": "diff",
3833            "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3834            "sources": [{"buffer_id": 1, "label": "old"}]
3835        }"#;
3836        let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3837        assert!(
3838            result.is_err(),
3839            "JSON with unknown field should fail to parse"
3840        );
3841        let err = result.unwrap_err().to_string();
3842        assert!(
3843            err.contains("unknown field") || err.contains("buffer_id"),
3844            "Error should mention unknown field: {}",
3845            err
3846        );
3847    }
3848
3849    #[test]
3850    fn test_composite_hunk_rejects_unknown_fields() {
3851        // Valid JSON with correct field names
3852        let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3853        let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3854        assert!(
3855            result.is_ok(),
3856            "Valid JSON should parse: {:?}",
3857            result.err()
3858        );
3859
3860        // Invalid JSON with unknown field (old_start instead of oldStart)
3861        let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3862        let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3863        assert!(
3864            result.is_err(),
3865            "JSON with unknown field should fail to parse"
3866        );
3867        let err = result.unwrap_err().to_string();
3868        assert!(
3869            err.contains("unknown field") || err.contains("old_start"),
3870            "Error should mention unknown field: {}",
3871            err
3872        );
3873    }
3874
3875    #[test]
3876    fn test_plugin_response_line_end_position() {
3877        let response = PluginResponse::LineEndPosition {
3878            request_id: 42,
3879            position: Some(100),
3880        };
3881        let json = serde_json::to_string(&response).unwrap();
3882        assert!(json.contains("LineEndPosition"));
3883        assert!(json.contains("42"));
3884        assert!(json.contains("100"));
3885
3886        // Test None case
3887        let response_none = PluginResponse::LineEndPosition {
3888            request_id: 1,
3889            position: None,
3890        };
3891        let json_none = serde_json::to_string(&response_none).unwrap();
3892        assert!(json_none.contains("null"));
3893    }
3894
3895    #[test]
3896    fn test_plugin_response_buffer_line_count() {
3897        let response = PluginResponse::BufferLineCount {
3898            request_id: 99,
3899            count: Some(500),
3900        };
3901        let json = serde_json::to_string(&response).unwrap();
3902        assert!(json.contains("BufferLineCount"));
3903        assert!(json.contains("99"));
3904        assert!(json.contains("500"));
3905    }
3906
3907    #[test]
3908    fn test_plugin_command_get_line_end_position() {
3909        let command = PluginCommand::GetLineEndPosition {
3910            buffer_id: BufferId(1),
3911            line: 10,
3912            request_id: 123,
3913        };
3914        let json = serde_json::to_string(&command).unwrap();
3915        assert!(json.contains("GetLineEndPosition"));
3916        assert!(json.contains("10"));
3917    }
3918
3919    #[test]
3920    fn test_plugin_command_get_buffer_line_count() {
3921        let command = PluginCommand::GetBufferLineCount {
3922            buffer_id: BufferId(0),
3923            request_id: 456,
3924        };
3925        let json = serde_json::to_string(&command).unwrap();
3926        assert!(json.contains("GetBufferLineCount"));
3927        assert!(json.contains("456"));
3928    }
3929
3930    #[test]
3931    fn test_plugin_command_scroll_to_line_center() {
3932        let command = PluginCommand::ScrollToLineCenter {
3933            split_id: SplitId(1),
3934            buffer_id: BufferId(2),
3935            line: 50,
3936        };
3937        let json = serde_json::to_string(&command).unwrap();
3938        assert!(json.contains("ScrollToLineCenter"));
3939        assert!(json.contains("50"));
3940    }
3941
3942    /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
3943    /// and renders as its underlying integer via `Display`.
3944    #[test]
3945    fn js_callback_id_conversions_and_display() {
3946        for raw in [0u64, 1, 42, u64::MAX] {
3947            let id = JsCallbackId::new(raw);
3948            assert_eq!(id.as_u64(), raw);
3949            assert_eq!(u64::from(id), raw);
3950            assert_eq!(JsCallbackId::from(raw), id);
3951            assert_eq!(id.to_string(), raw.to_string());
3952        }
3953    }
3954
3955    /// Serde `default = ...` helpers fire when the field is omitted and are
3956    /// overridden by explicit values. One test per struct pins each helper
3957    /// to its documented default.
3958    #[test]
3959    fn serde_defaults_fire_when_fields_are_omitted() {
3960        // default_action_count → 1
3961        let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
3962        assert_eq!(spec.count, 1);
3963        let spec: ActionSpec =
3964            serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
3965        assert_eq!(spec.count, 5);
3966
3967        // default_true → showSeparator = true
3968        let layout: CompositeLayoutConfig =
3969            serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
3970        assert!(layout.show_separator);
3971        let layout: CompositeLayoutConfig =
3972            serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
3973        assert!(!layout.show_separator);
3974
3975        // default_plugin_provider_priority → 50
3976        let reg: TsCompletionProviderRegistration =
3977            serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
3978        assert_eq!(reg.priority, 50);
3979        let reg: TsCompletionProviderRegistration =
3980            serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
3981        assert_eq!(reg.priority, 3);
3982    }
3983
3984    // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
3985    //
3986    // These tests pin down observable behaviour for tiny methods whose bodies
3987    // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
3988    // default value) without any existing test noticing.
3989
3990    /// Helper: build a minimal `Command` with a given name.
3991    fn mk_cmd(name: &str) -> Command {
3992        Command {
3993            name: name.to_string(),
3994            description: String::new(),
3995            action_name: String::new(),
3996            plugin_name: String::new(),
3997            custom_contexts: Vec::new(),
3998        }
3999    }
4000
4001    /// `CommandRegistry::register` appends new commands and replaces any
4002    /// existing entry with the same name; `unregister` removes exactly the
4003    /// matching entry and is a no-op for unknown names.
4004    ///
4005    /// Kills: replace register with `()`; `!= → ==` in register;
4006    ///        replace unregister with `()`; `!= → ==` in unregister.
4007    #[test]
4008    fn command_registry_register_and_unregister_semantics() {
4009        let r = CommandRegistry::new();
4010
4011        r.register(mk_cmd("a"));
4012        r.register(mk_cmd("b"));
4013        assert_eq!(r.commands.read().unwrap().len(), 2);
4014
4015        // Re-registering "a" must keep "b" (retain filters by `!=`); the
4016        // `== → !=` mutant would drop "b" and leave two copies of "a".
4017        r.register(mk_cmd("a"));
4018        let names: Vec<String> = r
4019            .commands
4020            .read()
4021            .unwrap()
4022            .iter()
4023            .map(|c| c.name.clone())
4024            .collect();
4025        assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
4026
4027        // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
4028        // mutant would keep "a" and drop "b".
4029        r.unregister("a");
4030        let names: Vec<String> = r
4031            .commands
4032            .read()
4033            .unwrap()
4034            .iter()
4035            .map(|c| c.name.clone())
4036            .collect();
4037        assert_eq!(names, vec!["b".to_string()]);
4038
4039        // Unregistering an unknown name is a no-op.
4040        r.unregister("nope");
4041        assert_eq!(r.commands.read().unwrap().len(), 1);
4042    }
4043
4044    /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
4045    /// variant and `None` for the theme-key variant; `as_theme_key` is the
4046    /// dual. Uses a triple with no zero or one components and a theme key
4047    /// that is neither empty nor `"xyzzy"` to kill every constant-return
4048    /// mutant reported by cargo-mutants at once.
4049    #[test]
4050    fn overlay_color_spec_accessors_are_variant_specific() {
4051        let rgb = OverlayColorSpec::rgb(12, 34, 56);
4052        assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
4053        assert_eq!(rgb.as_theme_key(), None);
4054
4055        let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
4056        assert_eq!(tk.as_rgb(), None);
4057        assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
4058    }
4059
4060    /// `PluginCommand::debug_variant_name` returns the actual variant name
4061    /// derived from the `Debug` impl, not an empty or hard-coded string.
4062    #[test]
4063    fn plugin_command_debug_variant_name_returns_real_variant() {
4064        let c = PluginCommand::SetStatus {
4065            message: "hi".into(),
4066        };
4067        assert_eq!(c.debug_variant_name(), "SetStatus");
4068
4069        let c2 = PluginCommand::InsertText {
4070            buffer_id: BufferId(1),
4071            position: 0,
4072            text: String::new(),
4073        };
4074        assert_eq!(c2.debug_variant_name(), "InsertText");
4075    }
4076
4077    // ── PluginApi dispatch / mutation tests ────────────────────────────────
4078    //
4079    // Each `PluginApi` method is a one-liner that either pushes a
4080    // `PluginCommand` onto the channel or mutates a shared registry. The
4081    // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
4082    // disappears. One assertion per method ties the side effect down.
4083
4084    fn mk_api() -> (
4085        PluginApi,
4086        std::sync::mpsc::Receiver<PluginCommand>,
4087        Arc<RwLock<HookRegistry>>,
4088        Arc<RwLock<CommandRegistry>>,
4089        Arc<RwLock<EditorStateSnapshot>>,
4090    ) {
4091        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
4092        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
4093        let (tx, rx) = std::sync::mpsc::channel();
4094        let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4095        let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
4096        (api, rx, hooks, commands, snap)
4097    }
4098
4099    /// `unregister_hooks` must actually clear hooks registered under the
4100    /// same name; replacing the body with `()` leaves the count at 1.
4101    #[test]
4102    fn plugin_api_unregister_hooks_clears_registry() {
4103        let (api, _rx, hooks, _cmds, _snap) = mk_api();
4104        api.register_hook("h", Box::new(|_| true));
4105        assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
4106        api.unregister_hooks("h");
4107        assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
4108    }
4109
4110    /// `register_command` / `unregister_command` must actually write through
4111    /// to the shared `CommandRegistry`.
4112    #[test]
4113    fn plugin_api_register_and_unregister_command_write_through() {
4114        let (api, _rx, _hooks, cmds, _snap) = mk_api();
4115
4116        api.register_command(mk_cmd("x"));
4117        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
4118
4119        api.unregister_command("x");
4120        assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
4121    }
4122
4123    /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
4124    /// exactly one `PluginCommand` matching `$pattern` with the additional
4125    /// invariants in `$guard`.
4126    macro_rules! assert_dispatches {
4127        ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
4128            let (api, rx, _h, _c, _s) = mk_api();
4129            let _ = $call(&api);
4130            match rx.try_recv().expect("no command sent") {
4131                $pattern $(if $guard)? => {}
4132                other => panic!("unexpected command variant: {:?}", other),
4133            }
4134        }};
4135    }
4136
4137    /// Every simple `send_command`-based method on `PluginApi` translates
4138    /// its arguments into the documented `PluginCommand` variant with the
4139    /// expected fields.
4140    #[test]
4141    fn plugin_api_send_command_methods_dispatch_correctly() {
4142        // delete_range
4143        assert_dispatches!(
4144            |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
4145            PluginCommand::DeleteRange { buffer_id, range }
4146                if buffer_id == BufferId(7) && range == (3..9)
4147        );
4148
4149        // remove_overlay
4150        assert_dispatches!(
4151            |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
4152            PluginCommand::RemoveOverlay { buffer_id, handle }
4153                if buffer_id == BufferId(2) && handle.as_str() == "h-1"
4154        );
4155
4156        // clear_namespace
4157        assert_dispatches!(
4158            |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
4159            PluginCommand::ClearNamespace { buffer_id, namespace }
4160                if buffer_id == BufferId(3) && namespace.as_str() == "diag"
4161        );
4162
4163        // clear_overlays_in_range
4164        assert_dispatches!(
4165            |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
4166            PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
4167                if buffer_id == BufferId(4) && start == 10 && end == 20
4168        );
4169
4170        // open_file_at_location
4171        assert_dispatches!(
4172            |a: &PluginApi| a.open_file_at_location(
4173                PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
4174            ),
4175            PluginCommand::OpenFileAtLocation { path, line, column }
4176                if path == PathBuf::from("/tmp/x.rs")
4177                    && line == Some(4)
4178                    && column == Some(8)
4179        );
4180
4181        // open_file_in_split
4182        assert_dispatches!(
4183            |a: &PluginApi| a.open_file_in_split(
4184                2, PathBuf::from("/tmp/y.rs"), Some(5), None
4185            ),
4186            PluginCommand::OpenFileInSplit { split_id, path, line, column }
4187                if split_id == 2
4188                    && path == PathBuf::from("/tmp/y.rs")
4189                    && line == Some(5)
4190                    && column.is_none()
4191        );
4192
4193        // start_prompt
4194        assert_dispatches!(
4195            |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
4196            PluginCommand::StartPrompt { label, prompt_type }
4197                if label == "label" && prompt_type == "cmd"
4198        );
4199
4200        // set_prompt_suggestions
4201        assert_dispatches!(
4202            |a: &PluginApi| a.set_prompt_suggestions(vec![
4203                Suggestion::new("one".into()),
4204                Suggestion::new("two".into()),
4205            ]),
4206            PluginCommand::SetPromptSuggestions { suggestions }
4207                if suggestions.len() == 2
4208                    && suggestions[0].text == "one"
4209                    && suggestions[1].text == "two"
4210        );
4211
4212        // set_prompt_input_sync
4213        assert_dispatches!(
4214            |a: &PluginApi| a.set_prompt_input_sync(true),
4215            PluginCommand::SetPromptInputSync { sync } if sync
4216        );
4217        assert_dispatches!(
4218            |a: &PluginApi| a.set_prompt_input_sync(false),
4219            PluginCommand::SetPromptInputSync { sync } if !sync
4220        );
4221
4222        // add_menu_item
4223        assert_dispatches!(
4224            |a: &PluginApi| a.add_menu_item(
4225                "File".into(),
4226                MenuItem::Label { info: "info".into() },
4227                MenuPosition::Bottom,
4228            ),
4229            PluginCommand::AddMenuItem { menu_label, item, position }
4230                if menu_label == "File"
4231                    && matches!(item, MenuItem::Label { ref info } if info == "info")
4232                    && matches!(position, MenuPosition::Bottom)
4233        );
4234
4235        // add_menu
4236        assert_dispatches!(
4237            |a: &PluginApi| a.add_menu(
4238                Menu {
4239                    id: None,
4240                    label: "Help".into(),
4241                    items: vec![],
4242                    when: None,
4243                },
4244                MenuPosition::After("Edit".into()),
4245            ),
4246            PluginCommand::AddMenu { menu, position }
4247                if menu.label == "Help"
4248                    && matches!(position, MenuPosition::After(ref s) if s == "Edit")
4249        );
4250
4251        // remove_menu_item
4252        assert_dispatches!(
4253            |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
4254            PluginCommand::RemoveMenuItem { menu_label, item_label }
4255                if menu_label == "File" && item_label == "Open"
4256        );
4257
4258        // remove_menu
4259        assert_dispatches!(
4260            |a: &PluginApi| a.remove_menu("File".into()),
4261            PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
4262        );
4263
4264        // create_virtual_buffer
4265        assert_dispatches!(
4266            |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
4267            PluginCommand::CreateVirtualBuffer { name, mode, read_only }
4268                if name == "buf" && mode == "mode" && read_only
4269        );
4270
4271        // create_virtual_buffer_with_content
4272        assert_dispatches!(
4273            |a: &PluginApi| a.create_virtual_buffer_with_content(
4274                "n".into(), "m".into(), false, vec![]
4275            ),
4276            PluginCommand::CreateVirtualBufferWithContent {
4277                name, mode, read_only, show_line_numbers, show_cursors,
4278                editing_disabled, hidden_from_tabs, request_id, ..
4279            }
4280                if name == "n" && mode == "m" && !read_only
4281                    && show_line_numbers && show_cursors
4282                    && !editing_disabled && !hidden_from_tabs
4283                    && request_id.is_none()
4284        );
4285
4286        // set_virtual_buffer_content
4287        assert_dispatches!(
4288            |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
4289            PluginCommand::SetVirtualBufferContent { buffer_id, entries }
4290                if buffer_id == BufferId(9) && entries.is_empty()
4291        );
4292
4293        // get_text_properties_at_cursor
4294        assert_dispatches!(
4295            |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
4296            PluginCommand::GetTextPropertiesAtCursor { buffer_id }
4297                if buffer_id == BufferId(11)
4298        );
4299
4300        // define_mode
4301        assert_dispatches!(
4302            |a: &PluginApi| a.define_mode(
4303                "m".into(),
4304                vec![("j".into(), "move_down".into())],
4305                true,
4306                false,
4307            ),
4308            PluginCommand::DefineMode {
4309                name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
4310            }
4311                if name == "m"
4312                    && bindings.len() == 1
4313                    && bindings[0].0 == "j"
4314                    && bindings[0].1 == "move_down"
4315                    && read_only
4316                    && !allow_text_input
4317                    && !inherit_normal_bindings
4318                    && plugin_name.is_none()
4319        );
4320
4321        // show_buffer
4322        assert_dispatches!(
4323            |a: &PluginApi| a.show_buffer(BufferId(77)),
4324            PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
4325        );
4326
4327        // set_split_scroll
4328        assert_dispatches!(
4329            |a: &PluginApi| a.set_split_scroll(5, 128),
4330            PluginCommand::SetSplitScroll { split_id, top_byte }
4331                if split_id == SplitId(5) && top_byte == 128
4332        );
4333
4334        // get_highlights
4335        assert_dispatches!(
4336            |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
4337            PluginCommand::RequestHighlights { buffer_id, range, request_id }
4338                if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
4339        );
4340    }
4341
4342    /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
4343    /// sentinel value kills both the `0` and `1` constant-return mutants.
4344    #[test]
4345    fn plugin_api_get_active_split_id_reads_snapshot() {
4346        let (api, _rx, _h, _c, snap) = mk_api();
4347        snap.write().unwrap().active_split_id = 42;
4348        assert_eq!(api.get_active_split_id(), 42);
4349    }
4350
4351    /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
4352    /// freshly-defaulted snapshot. A distinguishing field value on the
4353    /// original state proves that the handle sees it.
4354    #[test]
4355    fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
4356        let (api, _rx, _h, _c, snap) = mk_api();
4357        snap.write().unwrap().active_buffer_id = BufferId(42);
4358
4359        let h = api.state_snapshot_handle();
4360        assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
4361        assert!(Arc::ptr_eq(&h, &snap));
4362    }
4363
4364    /// `KillHostProcess` survives a round-trip through serde: the
4365    /// `process_id` field stays identified by name and the variant
4366    /// retains its tag shape. If a future contributor renames the
4367    /// field or splits it into a tuple, the plugin-runtime TS side
4368    /// (which hand-builds the command JSON for the dispatcher) would
4369    /// silently break — this test pins the wire format.
4370    #[test]
4371    fn plugin_command_kill_host_process_serde_round_trip() {
4372        let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
4373        let json = serde_json::to_value(&cmd).unwrap();
4374        assert_eq!(json["KillHostProcess"]["process_id"], 1234);
4375        let decoded: PluginCommand = serde_json::from_value(json).unwrap();
4376        match decoded {
4377            PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
4378            other => panic!("expected KillHostProcess, got {:?}", other),
4379        }
4380    }
4381}