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