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
6use crate::command::{Command, Suggestion};
7use crate::file_explorer::FileExplorerDecoration;
8use crate::hooks::{HookCallback, HookRegistry};
9use crate::menu::{Menu, MenuItem};
10use crate::overlay::{OverlayHandle, OverlayNamespace};
11use crate::text_property::{TextProperty, TextPropertyEntry};
12use crate::BufferId;
13use crate::SplitId;
14use lsp_types;
15use serde::{Deserialize, Serialize};
16use serde_json::Value as JsonValue;
17use std::collections::HashMap;
18use std::ops::Range;
19use std::path::PathBuf;
20use std::sync::{Arc, RwLock};
21use ts_rs::TS;
22
23/// Minimal command registry for PluginApi.
24/// This is a stub that provides basic command storage for plugin use.
25/// The editor's full CommandRegistry lives in fresh-editor.
26pub struct CommandRegistry {
27    commands: std::sync::RwLock<Vec<Command>>,
28}
29
30impl CommandRegistry {
31    /// Create a new empty command registry
32    pub fn new() -> Self {
33        Self {
34            commands: std::sync::RwLock::new(Vec::new()),
35        }
36    }
37
38    /// Register a command
39    pub fn register(&self, command: Command) {
40        let mut commands = self.commands.write().unwrap();
41        commands.retain(|c| c.name != command.name);
42        commands.push(command);
43    }
44
45    /// Unregister a command by name  
46    pub fn unregister(&self, name: &str) {
47        let mut commands = self.commands.write().unwrap();
48        commands.retain(|c| c.name != name);
49    }
50}
51
52impl Default for CommandRegistry {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58/// A callback ID for JavaScript promises in the plugin runtime.
59///
60/// This newtype distinguishes JS promise callbacks (resolved via `resolve_callback`)
61/// from Rust oneshot channel IDs (resolved via `send_plugin_response`).
62/// Using a newtype prevents accidentally mixing up these two callback mechanisms.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
64#[ts(export)]
65pub struct JsCallbackId(pub u64);
66
67impl JsCallbackId {
68    /// Create a new JS callback ID
69    pub fn new(id: u64) -> Self {
70        Self(id)
71    }
72
73    /// Get the underlying u64 value
74    pub fn as_u64(self) -> u64 {
75        self.0
76    }
77}
78
79impl From<u64> for JsCallbackId {
80    fn from(id: u64) -> Self {
81        Self(id)
82    }
83}
84
85impl From<JsCallbackId> for u64 {
86    fn from(id: JsCallbackId) -> u64 {
87        id.0
88    }
89}
90
91impl std::fmt::Display for JsCallbackId {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{}", self.0)
94    }
95}
96
97/// Response from the editor for async plugin operations
98#[derive(Debug, Clone, Serialize, Deserialize, TS)]
99#[ts(export)]
100pub enum PluginResponse {
101    /// Response to CreateVirtualBufferInSplit with the created buffer ID and split ID
102    VirtualBufferCreated {
103        request_id: u64,
104        buffer_id: BufferId,
105        split_id: Option<SplitId>,
106    },
107    /// Response to a plugin-initiated LSP request
108    LspRequest {
109        request_id: u64,
110        #[ts(type = "any")]
111        result: Result<JsonValue, String>,
112    },
113    /// Response to RequestHighlights
114    HighlightsComputed {
115        request_id: u64,
116        spans: Vec<TsHighlightSpan>,
117    },
118    /// Response to GetBufferText with the text content
119    BufferText {
120        request_id: u64,
121        text: Result<String, String>,
122    },
123    /// Response to CreateCompositeBuffer with the created buffer ID
124    CompositeBufferCreated {
125        request_id: u64,
126        buffer_id: BufferId,
127    },
128}
129
130/// Messages sent from async plugin tasks to the synchronous main loop
131#[derive(Debug, Clone, Serialize, Deserialize, TS)]
132#[ts(export)]
133pub enum PluginAsyncMessage {
134    /// Plugin process completed with output
135    ProcessOutput {
136        /// Unique ID for this process
137        process_id: u64,
138        /// Standard output
139        stdout: String,
140        /// Standard error
141        stderr: String,
142        /// Exit code
143        exit_code: i32,
144    },
145    /// Plugin delay/timer completed
146    DelayComplete {
147        /// Callback ID to resolve
148        callback_id: u64,
149    },
150    /// Background process stdout data
151    ProcessStdout { process_id: u64, data: String },
152    /// Background process stderr data
153    ProcessStderr { process_id: u64, data: String },
154    /// Background process exited
155    ProcessExit {
156        process_id: u64,
157        callback_id: u64,
158        exit_code: i32,
159    },
160    /// Response for a plugin-initiated LSP request
161    LspResponse {
162        language: String,
163        request_id: u64,
164        #[ts(type = "any")]
165        result: Result<JsonValue, String>,
166    },
167    /// Generic plugin response (e.g., GetBufferText result)
168    PluginResponse(crate::api::PluginResponse),
169}
170
171/// Information about a cursor in the editor
172#[derive(Debug, Clone, Serialize, Deserialize, TS)]
173#[ts(export)]
174pub struct CursorInfo {
175    /// Byte position of the cursor
176    pub position: usize,
177    /// Selection range (if any)
178    #[cfg_attr(
179        feature = "plugins",
180        ts(type = "{ start: number; end: number } | null")
181    )]
182    pub selection: Option<Range<usize>>,
183}
184
185/// Specification for an action to execute, with optional repeat count
186#[derive(Debug, Clone, Serialize, Deserialize, TS)]
187#[ts(export)]
188pub struct ActionSpec {
189    /// Action name (e.g., "move_word_right", "delete_line")
190    pub action: String,
191    /// Number of times to repeat the action (default 1)
192    pub count: u32,
193}
194
195/// Information about a buffer
196#[derive(Debug, Clone, Serialize, Deserialize, TS)]
197#[ts(export)]
198pub struct BufferInfo {
199    /// Buffer ID
200    #[ts(type = "number")]
201    pub id: BufferId,
202    /// File path (if any)
203    #[serde(serialize_with = "serialize_path")]
204    #[ts(type = "string")]
205    pub path: Option<PathBuf>,
206    /// Whether the buffer has been modified
207    pub modified: bool,
208    /// Length of buffer in bytes
209    pub length: usize,
210}
211
212fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
213    s.serialize_str(
214        &path
215            .as_ref()
216            .map(|p| p.to_string_lossy().to_string())
217            .unwrap_or_default(),
218    )
219}
220
221/// Serialize ranges as [start, end] tuples for JS compatibility
222fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
223where
224    S: serde::Serializer,
225{
226    use serde::ser::SerializeSeq;
227    let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
228    for range in ranges {
229        seq.serialize_element(&(range.start, range.end))?;
230    }
231    seq.end()
232}
233
234/// Serialize optional ranges as [start, end] tuples for JS compatibility
235fn serialize_opt_ranges_as_tuples<S>(
236    ranges: &Option<Vec<Range<usize>>>,
237    serializer: S,
238) -> Result<S::Ok, S::Error>
239where
240    S: serde::Serializer,
241{
242    match ranges {
243        Some(ranges) => {
244            use serde::ser::SerializeSeq;
245            let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
246            for range in ranges {
247                seq.serialize_element(&(range.start, range.end))?;
248            }
249            seq.end()
250        }
251        None => serializer.serialize_none(),
252    }
253}
254
255/// Diff between current buffer content and last saved snapshot
256#[derive(Debug, Clone, Serialize, Deserialize, TS)]
257#[ts(export)]
258pub struct BufferSavedDiff {
259    pub equal: bool,
260    #[serde(serialize_with = "serialize_ranges_as_tuples")]
261    #[ts(type = "Array<[number, number]>")]
262    pub byte_ranges: Vec<Range<usize>>,
263    #[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
264    #[ts(type = "Array<[number, number]> | null")]
265    pub line_ranges: Option<Vec<Range<usize>>>,
266}
267
268/// Information about the viewport
269#[derive(Debug, Clone, Serialize, Deserialize, TS)]
270#[ts(export)]
271pub struct ViewportInfo {
272    /// Byte position of the first visible line
273    pub top_byte: usize,
274    /// Left column offset (horizontal scroll)
275    pub left_column: usize,
276    /// Viewport width
277    pub width: u16,
278    /// Viewport height
279    pub height: u16,
280}
281
282/// Layout hints supplied by plugins (e.g., Compose mode)
283#[derive(Debug, Clone, Serialize, Deserialize, TS)]
284#[ts(export)]
285pub struct LayoutHints {
286    /// Optional compose width for centering/wrapping
287    pub compose_width: Option<u16>,
288    /// Optional column guides for aligned tables
289    pub column_guides: Option<Vec<u16>>,
290}
291
292// ============================================================================
293// Composite Buffer Configuration (for multi-buffer single-tab views)
294// ============================================================================
295
296/// Layout configuration for composite buffers
297#[derive(Debug, Clone, Serialize, Deserialize, TS)]
298#[ts(export, rename = "TsCompositeLayoutConfig")]
299pub struct CompositeLayoutConfig {
300    /// Layout type: "side-by-side", "stacked", or "unified"
301    #[serde(rename = "type")]
302    #[ts(rename = "type")]
303    pub layout_type: String,
304    /// Width ratios for side-by-side (e.g., [0.5, 0.5])
305    #[serde(default)]
306    pub ratios: Option<Vec<f32>>,
307    /// Show separator between panes
308    #[serde(default = "default_true")]
309    pub show_separator: bool,
310    /// Spacing for stacked layout
311    #[serde(default)]
312    pub spacing: Option<u16>,
313}
314
315fn default_true() -> bool {
316    true
317}
318
319/// Source pane configuration for composite buffers
320#[derive(Debug, Clone, Serialize, Deserialize, TS)]
321#[ts(export, rename = "TsCompositeSourceConfig")]
322pub struct CompositeSourceConfig {
323    /// Buffer ID of the source buffer
324    pub buffer_id: usize,
325    /// Label for this pane (e.g., "OLD", "NEW")
326    pub label: String,
327    /// Whether this pane is editable
328    #[serde(default)]
329    pub editable: bool,
330    /// Style configuration
331    #[serde(default)]
332    pub style: Option<CompositePaneStyle>,
333}
334
335/// Style configuration for a composite pane
336#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
337#[ts(export, rename = "TsCompositePaneStyle")]
338pub struct CompositePaneStyle {
339    /// Background color for added lines (RGB)
340    #[serde(default)]
341    #[ts(type = "[number, number, number] | null")]
342    pub add_bg: Option<(u8, u8, u8)>,
343    /// Background color for removed lines (RGB)
344    #[serde(default)]
345    #[ts(type = "[number, number, number] | null")]
346    pub remove_bg: Option<(u8, u8, u8)>,
347    /// Background color for modified lines (RGB)
348    #[serde(default)]
349    #[ts(type = "[number, number, number] | null")]
350    pub modify_bg: Option<(u8, u8, u8)>,
351    /// Gutter style: "line-numbers", "diff-markers", "both", or "none"
352    #[serde(default)]
353    pub gutter_style: Option<String>,
354}
355
356/// Diff hunk for composite buffer alignment
357#[derive(Debug, Clone, Serialize, Deserialize, TS)]
358#[ts(export, rename = "TsCompositeHunk")]
359pub struct CompositeHunk {
360    /// Starting line in old buffer (0-indexed)
361    pub old_start: usize,
362    /// Number of lines in old buffer
363    pub old_count: usize,
364    /// Starting line in new buffer (0-indexed)
365    pub new_start: usize,
366    /// Number of lines in new buffer
367    pub new_count: usize,
368}
369
370/// Wire-format view token kind (serialized for plugin transforms)
371#[derive(Debug, Clone, Serialize, Deserialize, TS)]
372#[ts(export)]
373pub enum ViewTokenWireKind {
374    Text(String),
375    Newline,
376    Space,
377    /// Visual line break inserted by wrapping (not from source)
378    /// Always has source_offset: None
379    Break,
380    /// A single binary byte that should be rendered as <XX>
381    /// Used in binary file mode to ensure cursor positioning works correctly
382    /// (all 4 display chars of <XX> map to the same source byte)
383    BinaryByte(u8),
384}
385
386/// Styling for view tokens (used for injected annotations)
387///
388/// This allows plugins to specify styling for tokens that don't have a source
389/// mapping (source_offset: None), such as annotation headers in git blame.
390/// For tokens with source_offset: Some(_), syntax highlighting is applied instead.
391#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
392#[ts(export)]
393pub struct ViewTokenStyle {
394    /// Foreground color as RGB tuple
395    #[serde(default)]
396    #[ts(type = "[number, number, number] | null")]
397    pub fg: Option<(u8, u8, u8)>,
398    /// Background color as RGB tuple
399    #[serde(default)]
400    #[ts(type = "[number, number, number] | null")]
401    pub bg: Option<(u8, u8, u8)>,
402    /// Whether to render in bold
403    #[serde(default)]
404    pub bold: bool,
405    /// Whether to render in italic
406    #[serde(default)]
407    pub italic: bool,
408}
409
410/// Wire-format view token with optional source mapping and styling
411#[derive(Debug, Clone, Serialize, Deserialize, TS)]
412#[ts(export)]
413pub struct ViewTokenWire {
414    /// Source byte offset in the buffer. None for injected content (annotations).
415    pub source_offset: Option<usize>,
416    /// The token content
417    pub kind: ViewTokenWireKind,
418    /// Optional styling for injected content (only used when source_offset is None)
419    #[serde(default)]
420    pub style: Option<ViewTokenStyle>,
421}
422
423/// Transformed view stream payload (plugin-provided)
424#[derive(Debug, Clone, Serialize, Deserialize, TS)]
425#[ts(export)]
426pub struct ViewTransformPayload {
427    /// Byte range this transform applies to (viewport)
428    pub range: Range<usize>,
429    /// Tokens in wire format
430    pub tokens: Vec<ViewTokenWire>,
431    /// Layout hints
432    pub layout_hints: Option<LayoutHints>,
433}
434
435/// Snapshot of editor state for plugin queries
436/// This is updated by the editor on each loop iteration
437#[derive(Debug, Clone, Serialize, Deserialize, TS)]
438#[ts(export)]
439pub struct EditorStateSnapshot {
440    /// Currently active buffer ID
441    pub active_buffer_id: BufferId,
442    /// Currently active split ID
443    pub active_split_id: usize,
444    /// Information about all open buffers
445    pub buffers: HashMap<BufferId, BufferInfo>,
446    /// Diff vs last saved snapshot for each buffer (line counts may be unknown)
447    pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
448    /// Primary cursor position for the active buffer
449    pub primary_cursor: Option<CursorInfo>,
450    /// All cursor positions for the active buffer
451    pub all_cursors: Vec<CursorInfo>,
452    /// Viewport information for the active buffer
453    pub viewport: Option<ViewportInfo>,
454    /// Cursor positions per buffer (for buffers other than active)
455    pub buffer_cursor_positions: HashMap<BufferId, usize>,
456    /// Text properties per buffer (for virtual buffers with properties)
457    pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
458    /// Selected text from the primary cursor (if any selection exists)
459    /// This is populated on each update to avoid needing full buffer access
460    pub selected_text: Option<String>,
461    /// Internal clipboard content (for plugins that need clipboard access)
462    pub clipboard: String,
463    /// Editor's working directory (for file operations and spawning processes)
464    pub working_dir: PathBuf,
465    /// LSP diagnostics per file URI
466    /// Maps file URI string to Vec of diagnostics for that file
467    #[ts(type = "any")]
468    pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
469    /// Runtime config as serde_json::Value (merged user config + defaults)
470    /// This is the runtime config, not just the user's config file
471    #[ts(type = "any")]
472    pub config: serde_json::Value,
473    /// User config as serde_json::Value (only what's in the user's config file)
474    /// Fields not present here are using default values
475    #[ts(type = "any")]
476    pub user_config: serde_json::Value,
477    /// Global editor mode for modal editing (e.g., "vi-normal", "vi-insert")
478    /// When set, this mode's keybindings take precedence over normal key handling
479    pub editor_mode: Option<String>,
480}
481
482impl EditorStateSnapshot {
483    pub fn new() -> Self {
484        Self {
485            active_buffer_id: BufferId(0),
486            active_split_id: 0,
487            buffers: HashMap::new(),
488            buffer_saved_diffs: HashMap::new(),
489            primary_cursor: None,
490            all_cursors: Vec::new(),
491            viewport: None,
492            buffer_cursor_positions: HashMap::new(),
493            buffer_text_properties: HashMap::new(),
494            selected_text: None,
495            clipboard: String::new(),
496            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
497            diagnostics: HashMap::new(),
498            config: serde_json::Value::Null,
499            user_config: serde_json::Value::Null,
500            editor_mode: None,
501        }
502    }
503}
504
505impl Default for EditorStateSnapshot {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511/// Position for inserting menu items or menus
512#[derive(Debug, Clone, Serialize, Deserialize, TS)]
513#[ts(export)]
514pub enum MenuPosition {
515    /// Add at the beginning
516    Top,
517    /// Add at the end
518    Bottom,
519    /// Add before a specific label
520    Before(String),
521    /// Add after a specific label
522    After(String),
523}
524
525/// Plugin command - allows plugins to send commands to the editor
526#[derive(Debug, Clone, Serialize, Deserialize, TS)]
527#[ts(export)]
528pub enum PluginCommand {
529    /// Insert text at a position in a buffer
530    InsertText {
531        buffer_id: BufferId,
532        position: usize,
533        text: String,
534    },
535
536    /// Delete a range of text from a buffer
537    DeleteRange {
538        buffer_id: BufferId,
539        range: Range<usize>,
540    },
541
542    /// Add an overlay to a buffer, returns handle via response channel
543    AddOverlay {
544        buffer_id: BufferId,
545        namespace: Option<OverlayNamespace>,
546        range: Range<usize>,
547        color: (u8, u8, u8),
548        bg_color: Option<(u8, u8, u8)>,
549        underline: bool,
550        bold: bool,
551        italic: bool,
552        extend_to_line_end: bool,
553    },
554
555    /// Remove an overlay by its opaque handle
556    RemoveOverlay {
557        buffer_id: BufferId,
558        handle: OverlayHandle,
559    },
560
561    /// Set status message
562    SetStatus { message: String },
563
564    /// Apply a theme by name
565    ApplyTheme { theme_name: String },
566
567    /// Reload configuration from file
568    /// After a plugin saves config changes, it should call this to reload the config
569    ReloadConfig,
570
571    /// Register a custom command
572    RegisterCommand { command: Command },
573
574    /// Unregister a command by name
575    UnregisterCommand { name: String },
576
577    /// Open a file in the editor (in background, without switching focus)
578    OpenFileInBackground { path: PathBuf },
579
580    /// Insert text at the current cursor position in the active buffer
581    InsertAtCursor { text: String },
582
583    /// Spawn an async process
584    SpawnProcess {
585        command: String,
586        args: Vec<String>,
587        cwd: Option<String>,
588        callback_id: JsCallbackId,
589    },
590
591    /// Delay/sleep for a duration (async, resolves callback when done)
592    Delay {
593        callback_id: JsCallbackId,
594        duration_ms: u64,
595    },
596
597    /// Spawn a long-running background process
598    /// Unlike SpawnProcess, this returns immediately with a process handle
599    /// and provides streaming output via hooks
600    SpawnBackgroundProcess {
601        /// Unique ID for this process (generated by plugin runtime)
602        process_id: u64,
603        /// Command to execute
604        command: String,
605        /// Arguments to pass
606        args: Vec<String>,
607        /// Working directory (optional)
608        cwd: Option<String>,
609        /// Callback ID to call when process exits
610        callback_id: JsCallbackId,
611    },
612
613    /// Kill a background process by ID
614    KillBackgroundProcess { process_id: u64 },
615
616    /// Wait for a process to complete and get its result
617    /// Used with processes started via SpawnProcess
618    SpawnProcessWait {
619        /// Process ID to wait for
620        process_id: u64,
621        /// Callback ID for async response
622        callback_id: JsCallbackId,
623    },
624
625    /// Set layout hints for a buffer/viewport
626    SetLayoutHints {
627        buffer_id: BufferId,
628        split_id: Option<SplitId>,
629        range: Range<usize>,
630        hints: LayoutHints,
631    },
632
633    /// Enable/disable line numbers for a buffer
634    SetLineNumbers { buffer_id: BufferId, enabled: bool },
635
636    /// Submit a transformed view stream for a viewport
637    SubmitViewTransform {
638        buffer_id: BufferId,
639        split_id: Option<SplitId>,
640        payload: ViewTransformPayload,
641    },
642
643    /// Clear view transform for a buffer/split (returns to normal rendering)
644    ClearViewTransform {
645        buffer_id: BufferId,
646        split_id: Option<SplitId>,
647    },
648
649    /// Remove all overlays from a buffer
650    ClearAllOverlays { buffer_id: BufferId },
651
652    /// Remove all overlays in a namespace
653    ClearNamespace {
654        buffer_id: BufferId,
655        namespace: OverlayNamespace,
656    },
657
658    /// Remove all overlays that overlap with a byte range
659    /// Used for targeted invalidation when content in a range changes
660    ClearOverlaysInRange {
661        buffer_id: BufferId,
662        start: usize,
663        end: usize,
664    },
665
666    /// Add virtual text (inline text that doesn't exist in the buffer)
667    /// Used for color swatches, type hints, parameter hints, etc.
668    AddVirtualText {
669        buffer_id: BufferId,
670        virtual_text_id: String,
671        position: usize,
672        text: String,
673        color: (u8, u8, u8),
674        use_bg: bool, // true = use color as background, false = use as foreground
675        before: bool, // true = before char, false = after char
676    },
677
678    /// Remove a virtual text by ID
679    RemoveVirtualText {
680        buffer_id: BufferId,
681        virtual_text_id: String,
682    },
683
684    /// Remove virtual texts whose ID starts with the given prefix
685    RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
686
687    /// Clear all virtual texts from a buffer
688    ClearVirtualTexts { buffer_id: BufferId },
689
690    /// Add a virtual LINE (full line above/below a position)
691    /// Used for git blame headers, code coverage, inline documentation, etc.
692    /// These lines do NOT show line numbers in the gutter.
693    AddVirtualLine {
694        buffer_id: BufferId,
695        /// Byte position to anchor the line to
696        position: usize,
697        /// Full line content to display
698        text: String,
699        /// Foreground color (RGB)
700        fg_color: (u8, u8, u8),
701        /// Background color (RGB), None = transparent
702        bg_color: Option<(u8, u8, u8)>,
703        /// true = above the line containing position, false = below
704        above: bool,
705        /// Namespace for bulk removal (e.g., "git-blame")
706        namespace: String,
707        /// Priority for ordering multiple lines at same position (higher = later)
708        priority: i32,
709    },
710
711    /// Clear all virtual texts in a namespace
712    /// This is the primary way to remove a plugin's virtual lines before updating them.
713    ClearVirtualTextNamespace {
714        buffer_id: BufferId,
715        namespace: String,
716    },
717
718    /// Refresh lines for a buffer (clear seen_lines cache to re-trigger lines_changed hook)
719    RefreshLines { buffer_id: BufferId },
720
721    /// Set a line indicator in the gutter's indicator column
722    /// Used for git gutter, breakpoints, bookmarks, etc.
723    SetLineIndicator {
724        buffer_id: BufferId,
725        /// Line number (0-indexed)
726        line: usize,
727        /// Namespace for grouping (e.g., "git-gutter", "breakpoints")
728        namespace: String,
729        /// Symbol to display (e.g., "│", "●", "★")
730        symbol: String,
731        /// Color as RGB tuple
732        color: (u8, u8, u8),
733        /// Priority for display when multiple indicators exist (higher wins)
734        priority: i32,
735    },
736
737    /// Clear all line indicators for a specific namespace
738    ClearLineIndicators {
739        buffer_id: BufferId,
740        /// Namespace to clear (e.g., "git-gutter")
741        namespace: String,
742    },
743
744    /// Set file explorer decorations for a namespace
745    SetFileExplorerDecorations {
746        /// Namespace for grouping (e.g., "git-status")
747        namespace: String,
748        /// Decorations to apply
749        decorations: Vec<FileExplorerDecoration>,
750    },
751
752    /// Clear file explorer decorations for a namespace
753    ClearFileExplorerDecorations {
754        /// Namespace to clear (e.g., "git-status")
755        namespace: String,
756    },
757
758    /// Open a file at a specific line and column
759    /// Line and column are 1-indexed to match git grep output
760    OpenFileAtLocation {
761        path: PathBuf,
762        line: Option<usize>,   // 1-indexed, None = go to start
763        column: Option<usize>, // 1-indexed, None = go to line start
764    },
765
766    /// Open a file in a specific split at a given line and column
767    /// Line and column are 1-indexed to match git grep output
768    OpenFileInSplit {
769        split_id: usize,
770        path: PathBuf,
771        line: Option<usize>,   // 1-indexed, None = go to start
772        column: Option<usize>, // 1-indexed, None = go to line start
773    },
774
775    /// Start a prompt (minibuffer) with a custom type identifier
776    /// This allows plugins to create interactive prompts
777    StartPrompt {
778        label: String,
779        prompt_type: String, // e.g., "git-grep", "git-find-file"
780    },
781
782    /// Start a prompt with pre-filled initial value
783    StartPromptWithInitial {
784        label: String,
785        prompt_type: String,
786        initial_value: String,
787    },
788
789    /// Update the suggestions list for the current prompt
790    /// Uses the editor's Suggestion type
791    SetPromptSuggestions { suggestions: Vec<Suggestion> },
792
793    /// Add a menu item to an existing menu
794    /// Add a menu item to an existing menu
795    AddMenuItem {
796        menu_label: String,
797        item: MenuItem,
798        position: MenuPosition,
799    },
800
801    /// Add a new top-level menu
802    AddMenu { menu: Menu, position: MenuPosition },
803
804    /// Remove a menu item from a menu
805    RemoveMenuItem {
806        menu_label: String,
807        item_label: String,
808    },
809
810    /// Remove a top-level menu
811    RemoveMenu { menu_label: String },
812
813    /// Create a new virtual buffer (not backed by a file)
814    CreateVirtualBuffer {
815        /// Display name (e.g., "*Diagnostics*")
816        name: String,
817        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
818        mode: String,
819        /// Whether the buffer is read-only
820        read_only: bool,
821    },
822
823    /// Create a virtual buffer and set its content in one operation
824    /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
825    /// because it doesn't require tracking the buffer ID
826    CreateVirtualBufferWithContent {
827        /// Display name (e.g., "*Diagnostics*")
828        name: String,
829        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
830        mode: String,
831        /// Whether the buffer is read-only
832        read_only: bool,
833        /// Entries with text and embedded properties
834        entries: Vec<TextPropertyEntry>,
835        /// Whether to show line numbers in the gutter
836        show_line_numbers: bool,
837        /// Whether to show cursors in the buffer
838        show_cursors: bool,
839        /// Whether editing is disabled (blocks editing commands)
840        editing_disabled: bool,
841        /// Whether this buffer should be hidden from tabs (for composite source buffers)
842        hidden_from_tabs: bool,
843        /// Optional request ID for async response
844        request_id: Option<u64>,
845    },
846
847    /// Create a virtual buffer in a horizontal split
848    /// Opens the buffer in a new pane below the current one
849    CreateVirtualBufferInSplit {
850        /// Display name (e.g., "*Diagnostics*")
851        name: String,
852        /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
853        mode: String,
854        /// Whether the buffer is read-only
855        read_only: bool,
856        /// Entries with text and embedded properties
857        entries: Vec<TextPropertyEntry>,
858        /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
859        ratio: f32,
860        /// Split direction ("horizontal" or "vertical"), default horizontal
861        direction: Option<String>,
862        /// Optional panel ID for idempotent operations (if panel exists, update content)
863        panel_id: Option<String>,
864        /// Whether to show line numbers in the buffer (default true)
865        show_line_numbers: bool,
866        /// Whether to show cursors in the buffer (default true)
867        show_cursors: bool,
868        /// Whether editing is disabled for this buffer (default false)
869        editing_disabled: bool,
870        /// Whether line wrapping is enabled for this split (None = use global setting)
871        line_wrap: Option<bool>,
872        /// Optional request ID for async response (if set, editor will send back buffer ID)
873        request_id: Option<u64>,
874    },
875
876    /// Set the content of a virtual buffer with text properties
877    SetVirtualBufferContent {
878        buffer_id: BufferId,
879        /// Entries with text and embedded properties
880        entries: Vec<TextPropertyEntry>,
881    },
882
883    /// Get text properties at the cursor position in a buffer
884    GetTextPropertiesAtCursor { buffer_id: BufferId },
885
886    /// Define a buffer mode with keybindings
887    DefineMode {
888        name: String,
889        parent: Option<String>,
890        bindings: Vec<(String, String)>, // (key_string, command_name)
891        read_only: bool,
892    },
893
894    /// Switch the current split to display a buffer
895    ShowBuffer { buffer_id: BufferId },
896
897    /// Create a virtual buffer in an existing split (replaces current buffer in that split)
898    CreateVirtualBufferInExistingSplit {
899        /// Display name (e.g., "*Commit Details*")
900        name: String,
901        /// Mode name for buffer-local keybindings
902        mode: String,
903        /// Whether the buffer is read-only
904        read_only: bool,
905        /// Entries with text and embedded properties
906        entries: Vec<TextPropertyEntry>,
907        /// Target split ID where the buffer should be displayed
908        split_id: SplitId,
909        /// Whether to show line numbers in the buffer (default true)
910        show_line_numbers: bool,
911        /// Whether to show cursors in the buffer (default true)
912        show_cursors: bool,
913        /// Whether editing is disabled for this buffer (default false)
914        editing_disabled: bool,
915        /// Whether line wrapping is enabled for this split (None = use global setting)
916        line_wrap: Option<bool>,
917        /// Optional request ID for async response
918        request_id: Option<u64>,
919    },
920
921    /// Close a buffer and remove it from all splits
922    CloseBuffer { buffer_id: BufferId },
923
924    /// Create a composite buffer that displays multiple source buffers
925    /// Used for side-by-side diff, unified diff, and 3-way merge views
926    CreateCompositeBuffer {
927        /// Display name (shown in tab bar)
928        name: String,
929        /// Mode name for keybindings (e.g., "diff-view")
930        mode: String,
931        /// Layout configuration
932        layout: CompositeLayoutConfig,
933        /// Source pane configurations
934        sources: Vec<CompositeSourceConfig>,
935        /// Diff hunks for line alignment (optional)
936        hunks: Option<Vec<CompositeHunk>>,
937        /// Request ID for async response
938        request_id: Option<u64>,
939    },
940
941    /// Update alignment for a composite buffer (e.g., after source edit)
942    UpdateCompositeAlignment {
943        buffer_id: BufferId,
944        hunks: Vec<CompositeHunk>,
945    },
946
947    /// Close a composite buffer
948    CloseCompositeBuffer { buffer_id: BufferId },
949
950    /// Focus a specific split
951    FocusSplit { split_id: SplitId },
952
953    /// Set the buffer displayed in a specific split
954    SetSplitBuffer {
955        split_id: SplitId,
956        buffer_id: BufferId,
957    },
958
959    /// Set the scroll position of a specific split
960    SetSplitScroll { split_id: SplitId, top_byte: usize },
961
962    /// Request syntax highlights for a buffer range
963    RequestHighlights {
964        buffer_id: BufferId,
965        range: Range<usize>,
966        request_id: u64,
967    },
968
969    /// Close a split (if not the last one)
970    CloseSplit { split_id: SplitId },
971
972    /// Set the ratio of a split container
973    SetSplitRatio {
974        split_id: SplitId,
975        /// Ratio between 0.0 and 1.0 (0.5 = equal split)
976        ratio: f32,
977    },
978
979    /// Distribute splits evenly - make all given splits equal size
980    DistributeSplitsEvenly {
981        /// Split IDs to distribute evenly
982        split_ids: Vec<SplitId>,
983    },
984
985    /// Set cursor position in a buffer (also scrolls viewport to show cursor)
986    SetBufferCursor {
987        buffer_id: BufferId,
988        /// Byte offset position for the cursor
989        position: usize,
990    },
991
992    /// Send an arbitrary LSP request and return the raw JSON response
993    SendLspRequest {
994        language: String,
995        method: String,
996        #[ts(type = "any")]
997        params: Option<JsonValue>,
998        request_id: u64,
999    },
1000
1001    /// Set the internal clipboard content
1002    SetClipboard { text: String },
1003
1004    /// Delete the current selection in the active buffer
1005    /// This deletes all selected text across all cursors
1006    DeleteSelection,
1007
1008    /// Set or unset a custom context
1009    /// Custom contexts are plugin-defined states that can be used to control command visibility
1010    /// For example, "config-editor" context could make config editor commands available
1011    SetContext {
1012        /// Context name (e.g., "config-editor")
1013        name: String,
1014        /// Whether the context is active
1015        active: bool,
1016    },
1017
1018    /// Set the hunks for the Review Diff tool
1019    SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1020
1021    /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1022    /// Used by vi mode plugin to run motions and calculate cursor ranges
1023    ExecuteAction {
1024        /// Action name (e.g., "move_word_right", "move_line_end")
1025        action_name: String,
1026    },
1027
1028    /// Execute multiple actions in sequence, each with an optional repeat count
1029    /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1030    /// All actions execute atomically with no plugin roundtrips between them
1031    ExecuteActions {
1032        /// List of actions to execute in sequence
1033        actions: Vec<ActionSpec>,
1034    },
1035
1036    /// Get text from a buffer range (for yank operations)
1037    GetBufferText {
1038        /// Buffer ID
1039        buffer_id: BufferId,
1040        /// Start byte offset
1041        start: usize,
1042        /// End byte offset
1043        end: usize,
1044        /// Request ID for async response
1045        request_id: u64,
1046    },
1047
1048    /// Set the global editor mode (for modal editing like vi mode)
1049    /// When set, the mode's keybindings take precedence over normal editing
1050    SetEditorMode {
1051        /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1052        mode: Option<String>,
1053    },
1054
1055    /// Show an action popup with buttons for user interaction
1056    /// When the user selects an action, the ActionPopupResult hook is fired
1057    ShowActionPopup {
1058        /// Unique identifier for the popup (used in ActionPopupResult)
1059        popup_id: String,
1060        /// Title text for the popup
1061        title: String,
1062        /// Body message (supports basic formatting)
1063        message: String,
1064        /// Action buttons to display
1065        actions: Vec<ActionPopupAction>,
1066    },
1067
1068    /// Disable LSP for a specific language and persist to config
1069    DisableLspForLanguage {
1070        /// The language to disable LSP for (e.g., "python", "rust")
1071        language: String,
1072    },
1073
1074    /// Create a scroll sync group for anchor-based synchronized scrolling
1075    /// Used for side-by-side diff views where two panes need to scroll together
1076    /// The plugin provides the group ID (must be unique per plugin)
1077    CreateScrollSyncGroup {
1078        /// Plugin-assigned group ID
1079        group_id: u32,
1080        /// The left (primary) split - scroll position is tracked in this split's line space
1081        left_split: SplitId,
1082        /// The right (secondary) split - position is derived from anchors
1083        right_split: SplitId,
1084    },
1085
1086    /// Set sync anchors for a scroll sync group
1087    /// Anchors map corresponding line numbers between left and right buffers
1088    SetScrollSyncAnchors {
1089        /// The group ID returned by CreateScrollSyncGroup
1090        group_id: u32,
1091        /// List of (left_line, right_line) pairs marking corresponding positions
1092        anchors: Vec<(usize, usize)>,
1093    },
1094
1095    /// Remove a scroll sync group
1096    RemoveScrollSyncGroup {
1097        /// The group ID returned by CreateScrollSyncGroup
1098        group_id: u32,
1099    },
1100
1101    /// Save a buffer to a specific file path
1102    /// Used by :w filename command to save unnamed buffers or save-as
1103    SaveBufferToPath {
1104        /// Buffer ID to save
1105        buffer_id: BufferId,
1106        /// Path to save to
1107        path: PathBuf,
1108    },
1109}
1110
1111/// Hunk status for Review Diff
1112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
1113#[ts(export)]
1114pub enum HunkStatus {
1115    Pending,
1116    Staged,
1117    Discarded,
1118}
1119
1120/// A high-level hunk directive for the Review Diff tool
1121#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1122#[ts(export)]
1123pub struct ReviewHunk {
1124    pub id: String,
1125    pub file: String,
1126    pub context_header: String,
1127    pub status: HunkStatus,
1128    /// 0-indexed line range in the base (HEAD) version
1129    pub base_range: Option<(usize, usize)>,
1130    /// 0-indexed line range in the modified (Working) version
1131    pub modified_range: Option<(usize, usize)>,
1132}
1133
1134/// Action button for action popups
1135#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1136#[ts(export, rename = "TsActionPopupAction")]
1137pub struct ActionPopupAction {
1138    /// Unique action identifier (returned in ActionPopupResult)
1139    pub id: String,
1140    /// Display text for the button (can include command hints)
1141    pub label: String,
1142}
1143
1144/// Syntax highlight span for a buffer range
1145#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1146#[ts(export)]
1147pub struct TsHighlightSpan {
1148    pub start: u32,
1149    pub end: u32,
1150    #[ts(type = "[number, number, number]")]
1151    pub color: (u8, u8, u8),
1152    pub bold: bool,
1153    pub italic: bool,
1154}
1155
1156/// Result from spawning a process with spawnProcess
1157#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1158#[ts(export)]
1159pub struct SpawnResult {
1160    /// Complete stdout as string
1161    pub stdout: String,
1162    /// Complete stderr as string
1163    pub stderr: String,
1164    /// Process exit code (0 usually means success, -1 if killed)
1165    pub exit_code: i32,
1166}
1167
1168/// Result from spawning a background process
1169#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1170#[ts(export)]
1171pub struct BackgroundProcessResult {
1172    /// Unique process ID for later reference
1173    #[ts(type = "number")]
1174    pub process_id: u64,
1175    /// Process exit code (0 usually means success, -1 if killed)
1176    /// Only present when the process has exited
1177    pub exit_code: i32,
1178}
1179
1180/// Entry for virtual buffer content with optional text properties (JS API version)
1181#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1182#[ts(export, rename = "TextPropertyEntry")]
1183pub struct JsTextPropertyEntry {
1184    /// Text content for this entry
1185    pub text: String,
1186    /// Optional properties attached to this text (e.g., file path, line number)
1187    #[serde(default)]
1188    #[ts(optional, type = "Record<string, unknown>")]
1189    pub properties: Option<HashMap<String, JsonValue>>,
1190}
1191
1192/// Options for createVirtualBuffer
1193#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1194#[ts(export)]
1195pub struct CreateVirtualBufferOptions {
1196    /// Buffer name (displayed in tabs/title)
1197    pub name: String,
1198    /// Mode for keybindings (e.g., "git-log", "search-results")
1199    #[serde(default)]
1200    #[ts(optional)]
1201    pub mode: Option<String>,
1202    /// Whether buffer is read-only (default: false)
1203    #[serde(default, rename = "readOnly")]
1204    #[ts(optional, rename = "readOnly")]
1205    pub read_only: Option<bool>,
1206    /// Show line numbers in gutter (default: false)
1207    #[serde(default, rename = "showLineNumbers")]
1208    #[ts(optional, rename = "showLineNumbers")]
1209    pub show_line_numbers: Option<bool>,
1210    /// Show cursor (default: true)
1211    #[serde(default, rename = "showCursors")]
1212    #[ts(optional, rename = "showCursors")]
1213    pub show_cursors: Option<bool>,
1214    /// Disable text editing (default: false)
1215    #[serde(default, rename = "editingDisabled")]
1216    #[ts(optional, rename = "editingDisabled")]
1217    pub editing_disabled: Option<bool>,
1218    /// Hide from tab bar (default: false)
1219    #[serde(default, rename = "hiddenFromTabs")]
1220    #[ts(optional, rename = "hiddenFromTabs")]
1221    pub hidden_from_tabs: Option<bool>,
1222    /// Initial content entries with optional properties
1223    #[serde(default)]
1224    #[ts(optional)]
1225    pub entries: Option<Vec<JsTextPropertyEntry>>,
1226}
1227
1228/// Options for createVirtualBufferInSplit
1229#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1230#[ts(export)]
1231pub struct CreateVirtualBufferInSplitOptions {
1232    /// Buffer name (displayed in tabs/title)
1233    pub name: String,
1234    /// Mode for keybindings (e.g., "git-log", "search-results")
1235    #[serde(default)]
1236    #[ts(optional)]
1237    pub mode: Option<String>,
1238    /// Whether buffer is read-only (default: false)
1239    #[serde(default, rename = "readOnly")]
1240    #[ts(optional, rename = "readOnly")]
1241    pub read_only: Option<bool>,
1242    /// Split ratio 0.0-1.0 (default: 0.5)
1243    #[serde(default)]
1244    #[ts(optional)]
1245    pub ratio: Option<f32>,
1246    /// Split direction: "horizontal" or "vertical"
1247    #[serde(default)]
1248    #[ts(optional)]
1249    pub direction: Option<String>,
1250    /// Panel ID to split from
1251    #[serde(default, rename = "panelId")]
1252    #[ts(optional, rename = "panelId")]
1253    pub panel_id: Option<String>,
1254    /// Show line numbers in gutter (default: true)
1255    #[serde(default, rename = "showLineNumbers")]
1256    #[ts(optional, rename = "showLineNumbers")]
1257    pub show_line_numbers: Option<bool>,
1258    /// Show cursor (default: true)
1259    #[serde(default, rename = "showCursors")]
1260    #[ts(optional, rename = "showCursors")]
1261    pub show_cursors: Option<bool>,
1262    /// Disable text editing (default: false)
1263    #[serde(default, rename = "editingDisabled")]
1264    #[ts(optional, rename = "editingDisabled")]
1265    pub editing_disabled: Option<bool>,
1266    /// Enable line wrapping
1267    #[serde(default, rename = "lineWrap")]
1268    #[ts(optional, rename = "lineWrap")]
1269    pub line_wrap: Option<bool>,
1270    /// Initial content entries with optional properties
1271    #[serde(default)]
1272    #[ts(optional)]
1273    pub entries: Option<Vec<JsTextPropertyEntry>>,
1274}
1275
1276/// Options for createVirtualBufferInExistingSplit
1277#[derive(Debug, Clone, Serialize, Deserialize, TS)]
1278#[ts(export)]
1279pub struct CreateVirtualBufferInExistingSplitOptions {
1280    /// Buffer name (displayed in tabs/title)
1281    pub name: String,
1282    /// Target split ID (required)
1283    #[serde(rename = "splitId")]
1284    #[ts(rename = "splitId")]
1285    pub split_id: usize,
1286    /// Mode for keybindings (e.g., "git-log", "search-results")
1287    #[serde(default)]
1288    #[ts(optional)]
1289    pub mode: Option<String>,
1290    /// Whether buffer is read-only (default: false)
1291    #[serde(default, rename = "readOnly")]
1292    #[ts(optional, rename = "readOnly")]
1293    pub read_only: Option<bool>,
1294    /// Show line numbers in gutter (default: true)
1295    #[serde(default, rename = "showLineNumbers")]
1296    #[ts(optional, rename = "showLineNumbers")]
1297    pub show_line_numbers: Option<bool>,
1298    /// Show cursor (default: true)
1299    #[serde(default, rename = "showCursors")]
1300    #[ts(optional, rename = "showCursors")]
1301    pub show_cursors: Option<bool>,
1302    /// Disable text editing (default: false)
1303    #[serde(default, rename = "editingDisabled")]
1304    #[ts(optional, rename = "editingDisabled")]
1305    pub editing_disabled: Option<bool>,
1306    /// Enable line wrapping
1307    #[serde(default, rename = "lineWrap")]
1308    #[ts(optional, rename = "lineWrap")]
1309    pub line_wrap: Option<bool>,
1310    /// Initial content entries with optional properties
1311    #[serde(default)]
1312    #[ts(optional)]
1313    pub entries: Option<Vec<JsTextPropertyEntry>>,
1314}
1315
1316/// Result of getTextPropertiesAtCursor - array of property objects
1317///
1318/// Each element contains the properties from a text property span that overlaps
1319/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
1320#[derive(Debug, Clone, Serialize, TS)]
1321#[ts(export, type = "Array<Record<string, unknown>>")]
1322pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
1323
1324// Implement FromJs for option types using rquickjs_serde
1325#[cfg(feature = "plugins")]
1326mod fromjs_impls {
1327    use super::*;
1328    use rquickjs::{Ctx, FromJs, Value};
1329
1330    impl<'js> FromJs<'js> for JsTextPropertyEntry {
1331        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1332            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1333                from: "object",
1334                to: "JsTextPropertyEntry",
1335                message: Some(e.to_string()),
1336            })
1337        }
1338    }
1339
1340    impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
1341        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1342            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1343                from: "object",
1344                to: "CreateVirtualBufferOptions",
1345                message: Some(e.to_string()),
1346            })
1347        }
1348    }
1349
1350    impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
1351        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1352            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1353                from: "object",
1354                to: "CreateVirtualBufferInSplitOptions",
1355                message: Some(e.to_string()),
1356            })
1357        }
1358    }
1359
1360    impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
1361        fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
1362            rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
1363                from: "object",
1364                to: "CreateVirtualBufferInExistingSplitOptions",
1365                message: Some(e.to_string()),
1366            })
1367        }
1368    }
1369
1370    impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
1371        fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1372            rquickjs_serde::to_value(ctx.clone(), &self.0)
1373                .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1374        }
1375    }
1376}
1377
1378/// Plugin API context - provides safe access to editor functionality
1379pub struct PluginApi {
1380    /// Hook registry (shared with editor)
1381    hooks: Arc<RwLock<HookRegistry>>,
1382
1383    /// Command registry (shared with editor)
1384    commands: Arc<RwLock<CommandRegistry>>,
1385
1386    /// Command queue for sending commands to editor
1387    command_sender: std::sync::mpsc::Sender<PluginCommand>,
1388
1389    /// Snapshot of editor state (read-only for plugins)
1390    state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1391}
1392
1393impl PluginApi {
1394    /// Create a new plugin API context
1395    pub fn new(
1396        hooks: Arc<RwLock<HookRegistry>>,
1397        commands: Arc<RwLock<CommandRegistry>>,
1398        command_sender: std::sync::mpsc::Sender<PluginCommand>,
1399        state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
1400    ) -> Self {
1401        Self {
1402            hooks,
1403            commands,
1404            command_sender,
1405            state_snapshot,
1406        }
1407    }
1408
1409    /// Register a hook callback
1410    pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
1411        let mut hooks = self.hooks.write().unwrap();
1412        hooks.add_hook(hook_name, callback);
1413    }
1414
1415    /// Remove all hooks for a specific name
1416    pub fn unregister_hooks(&self, hook_name: &str) {
1417        let mut hooks = self.hooks.write().unwrap();
1418        hooks.remove_hooks(hook_name);
1419    }
1420
1421    /// Register a command
1422    pub fn register_command(&self, command: Command) {
1423        let commands = self.commands.read().unwrap();
1424        commands.register(command);
1425    }
1426
1427    /// Unregister a command by name
1428    pub fn unregister_command(&self, name: &str) {
1429        let commands = self.commands.read().unwrap();
1430        commands.unregister(name);
1431    }
1432
1433    /// Send a command to the editor (async/non-blocking)
1434    pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
1435        self.command_sender
1436            .send(command)
1437            .map_err(|e| format!("Failed to send command: {}", e))
1438    }
1439
1440    /// Insert text at a position in a buffer
1441    pub fn insert_text(
1442        &self,
1443        buffer_id: BufferId,
1444        position: usize,
1445        text: String,
1446    ) -> Result<(), String> {
1447        self.send_command(PluginCommand::InsertText {
1448            buffer_id,
1449            position,
1450            text,
1451        })
1452    }
1453
1454    /// Delete a range of text from a buffer
1455    pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
1456        self.send_command(PluginCommand::DeleteRange { buffer_id, range })
1457    }
1458
1459    /// Add an overlay (decoration) to a buffer
1460    /// Returns an opaque handle that can be used to remove the overlay later
1461    #[allow(clippy::too_many_arguments)]
1462    pub fn add_overlay(
1463        &self,
1464        buffer_id: BufferId,
1465        namespace: Option<String>,
1466        range: Range<usize>,
1467        color: (u8, u8, u8),
1468        bg_color: Option<(u8, u8, u8)>,
1469        underline: bool,
1470        bold: bool,
1471        italic: bool,
1472        extend_to_line_end: bool,
1473    ) -> Result<(), String> {
1474        self.send_command(PluginCommand::AddOverlay {
1475            buffer_id,
1476            namespace: namespace.map(OverlayNamespace::from_string),
1477            range,
1478            color,
1479            bg_color,
1480            underline,
1481            bold,
1482            italic,
1483            extend_to_line_end,
1484        })
1485    }
1486
1487    /// Remove an overlay from a buffer by its handle
1488    pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
1489        self.send_command(PluginCommand::RemoveOverlay {
1490            buffer_id,
1491            handle: OverlayHandle::from_string(handle),
1492        })
1493    }
1494
1495    /// Clear all overlays in a namespace from a buffer
1496    pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
1497        self.send_command(PluginCommand::ClearNamespace {
1498            buffer_id,
1499            namespace: OverlayNamespace::from_string(namespace),
1500        })
1501    }
1502
1503    /// Clear all overlays that overlap with a byte range
1504    /// Used for targeted invalidation when content changes
1505    pub fn clear_overlays_in_range(
1506        &self,
1507        buffer_id: BufferId,
1508        start: usize,
1509        end: usize,
1510    ) -> Result<(), String> {
1511        self.send_command(PluginCommand::ClearOverlaysInRange {
1512            buffer_id,
1513            start,
1514            end,
1515        })
1516    }
1517
1518    /// Set the status message
1519    pub fn set_status(&self, message: String) -> Result<(), String> {
1520        self.send_command(PluginCommand::SetStatus { message })
1521    }
1522
1523    /// Open a file at a specific line and column (1-indexed)
1524    /// This is useful for jumping to locations from git grep, LSP definitions, etc.
1525    pub fn open_file_at_location(
1526        &self,
1527        path: PathBuf,
1528        line: Option<usize>,
1529        column: Option<usize>,
1530    ) -> Result<(), String> {
1531        self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
1532    }
1533
1534    /// Open a file in a specific split at a line and column
1535    ///
1536    /// Similar to open_file_at_location but targets a specific split pane.
1537    /// The split_id is the ID of the split pane to open the file in.
1538    pub fn open_file_in_split(
1539        &self,
1540        split_id: usize,
1541        path: PathBuf,
1542        line: Option<usize>,
1543        column: Option<usize>,
1544    ) -> Result<(), String> {
1545        self.send_command(PluginCommand::OpenFileInSplit {
1546            split_id,
1547            path,
1548            line,
1549            column,
1550        })
1551    }
1552
1553    /// Start a prompt (minibuffer) with a custom type identifier
1554    /// The prompt_type is used to filter hooks in plugin code
1555    pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
1556        self.send_command(PluginCommand::StartPrompt { label, prompt_type })
1557    }
1558
1559    /// Set the suggestions for the current prompt
1560    /// This updates the prompt's autocomplete/selection list
1561    pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
1562        self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
1563    }
1564
1565    /// Add a menu item to an existing menu
1566    pub fn add_menu_item(
1567        &self,
1568        menu_label: String,
1569        item: MenuItem,
1570        position: MenuPosition,
1571    ) -> Result<(), String> {
1572        self.send_command(PluginCommand::AddMenuItem {
1573            menu_label,
1574            item,
1575            position,
1576        })
1577    }
1578
1579    /// Add a new top-level menu
1580    pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
1581        self.send_command(PluginCommand::AddMenu { menu, position })
1582    }
1583
1584    /// Remove a menu item from a menu
1585    pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
1586        self.send_command(PluginCommand::RemoveMenuItem {
1587            menu_label,
1588            item_label,
1589        })
1590    }
1591
1592    /// Remove a top-level menu
1593    pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
1594        self.send_command(PluginCommand::RemoveMenu { menu_label })
1595    }
1596
1597    // === Virtual Buffer Methods ===
1598
1599    /// Create a new virtual buffer (not backed by a file)
1600    ///
1601    /// Virtual buffers are used for special displays like diagnostic lists,
1602    /// search results, etc. They have their own mode for keybindings.
1603    pub fn create_virtual_buffer(
1604        &self,
1605        name: String,
1606        mode: String,
1607        read_only: bool,
1608    ) -> Result<(), String> {
1609        self.send_command(PluginCommand::CreateVirtualBuffer {
1610            name,
1611            mode,
1612            read_only,
1613        })
1614    }
1615
1616    /// Create a virtual buffer and set its content in one operation
1617    ///
1618    /// This is the preferred way to create virtual buffers since it doesn't
1619    /// require tracking the buffer ID. The buffer is created and populated
1620    /// atomically.
1621    pub fn create_virtual_buffer_with_content(
1622        &self,
1623        name: String,
1624        mode: String,
1625        read_only: bool,
1626        entries: Vec<TextPropertyEntry>,
1627    ) -> Result<(), String> {
1628        self.send_command(PluginCommand::CreateVirtualBufferWithContent {
1629            name,
1630            mode,
1631            read_only,
1632            entries,
1633            show_line_numbers: true,
1634            show_cursors: true,
1635            editing_disabled: false,
1636            hidden_from_tabs: false,
1637            request_id: None,
1638        })
1639    }
1640
1641    /// Set the content of a virtual buffer with text properties
1642    ///
1643    /// Each entry contains text and metadata properties (e.g., source location).
1644    pub fn set_virtual_buffer_content(
1645        &self,
1646        buffer_id: BufferId,
1647        entries: Vec<TextPropertyEntry>,
1648    ) -> Result<(), String> {
1649        self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
1650    }
1651
1652    /// Get text properties at cursor position in a buffer
1653    ///
1654    /// This triggers a command that will make properties available to plugins.
1655    pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
1656        self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
1657    }
1658
1659    /// Define a buffer mode with keybindings
1660    ///
1661    /// Modes can inherit from parent modes (e.g., "diagnostics-list" inherits from "special").
1662    /// Bindings are specified as (key_string, command_name) pairs.
1663    pub fn define_mode(
1664        &self,
1665        name: String,
1666        parent: Option<String>,
1667        bindings: Vec<(String, String)>,
1668        read_only: bool,
1669    ) -> Result<(), String> {
1670        self.send_command(PluginCommand::DefineMode {
1671            name,
1672            parent,
1673            bindings,
1674            read_only,
1675        })
1676    }
1677
1678    /// Switch the current split to display a buffer
1679    pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
1680        self.send_command(PluginCommand::ShowBuffer { buffer_id })
1681    }
1682
1683    /// Set the scroll position of a specific split
1684    pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
1685        self.send_command(PluginCommand::SetSplitScroll {
1686            split_id: SplitId(split_id),
1687            top_byte,
1688        })
1689    }
1690
1691    /// Request syntax highlights for a buffer range
1692    pub fn get_highlights(
1693        &self,
1694        buffer_id: BufferId,
1695        range: Range<usize>,
1696        request_id: u64,
1697    ) -> Result<(), String> {
1698        self.send_command(PluginCommand::RequestHighlights {
1699            buffer_id,
1700            range,
1701            request_id,
1702        })
1703    }
1704
1705    // === Query Methods ===
1706
1707    /// Get the currently active buffer ID
1708    pub fn get_active_buffer_id(&self) -> BufferId {
1709        let snapshot = self.state_snapshot.read().unwrap();
1710        snapshot.active_buffer_id
1711    }
1712
1713    /// Get the currently active split ID
1714    pub fn get_active_split_id(&self) -> usize {
1715        let snapshot = self.state_snapshot.read().unwrap();
1716        snapshot.active_split_id
1717    }
1718
1719    /// Get information about a specific buffer
1720    pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
1721        let snapshot = self.state_snapshot.read().unwrap();
1722        snapshot.buffers.get(&buffer_id).cloned()
1723    }
1724
1725    /// Get all buffer IDs
1726    pub fn list_buffers(&self) -> Vec<BufferInfo> {
1727        let snapshot = self.state_snapshot.read().unwrap();
1728        snapshot.buffers.values().cloned().collect()
1729    }
1730
1731    /// Get primary cursor information for the active buffer
1732    pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
1733        let snapshot = self.state_snapshot.read().unwrap();
1734        snapshot.primary_cursor.clone()
1735    }
1736
1737    /// Get all cursor information for the active buffer
1738    pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
1739        let snapshot = self.state_snapshot.read().unwrap();
1740        snapshot.all_cursors.clone()
1741    }
1742
1743    /// Get viewport information for the active buffer
1744    pub fn get_viewport(&self) -> Option<ViewportInfo> {
1745        let snapshot = self.state_snapshot.read().unwrap();
1746        snapshot.viewport.clone()
1747    }
1748
1749    /// Get access to the state snapshot Arc (for internal use)
1750    pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
1751        Arc::clone(&self.state_snapshot)
1752    }
1753}
1754
1755impl Clone for PluginApi {
1756    fn clone(&self) -> Self {
1757        Self {
1758            hooks: Arc::clone(&self.hooks),
1759            commands: Arc::clone(&self.commands),
1760            command_sender: self.command_sender.clone(),
1761            state_snapshot: Arc::clone(&self.state_snapshot),
1762        }
1763    }
1764}
1765
1766#[cfg(test)]
1767mod tests {
1768    use super::*;
1769
1770    #[test]
1771    fn test_plugin_api_creation() {
1772        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1773        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1774        let (tx, _rx) = std::sync::mpsc::channel();
1775        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1776
1777        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1778
1779        // Should not panic
1780        let _clone = api.clone();
1781    }
1782
1783    #[test]
1784    fn test_register_hook() {
1785        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1786        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1787        let (tx, _rx) = std::sync::mpsc::channel();
1788        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1789
1790        let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
1791
1792        api.register_hook("test-hook", Box::new(|_| true));
1793
1794        let hook_registry = hooks.read().unwrap();
1795        assert_eq!(hook_registry.hook_count("test-hook"), 1);
1796    }
1797
1798    #[test]
1799    fn test_send_command() {
1800        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1801        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1802        let (tx, rx) = std::sync::mpsc::channel();
1803        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1804
1805        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1806
1807        let result = api.insert_text(BufferId(1), 0, "test".to_string());
1808        assert!(result.is_ok());
1809
1810        // Verify command was sent
1811        let received = rx.try_recv();
1812        assert!(received.is_ok());
1813
1814        match received.unwrap() {
1815            PluginCommand::InsertText {
1816                buffer_id,
1817                position,
1818                text,
1819            } => {
1820                assert_eq!(buffer_id.0, 1);
1821                assert_eq!(position, 0);
1822                assert_eq!(text, "test");
1823            }
1824            _ => panic!("Wrong command type"),
1825        }
1826    }
1827
1828    #[test]
1829    fn test_add_overlay_command() {
1830        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1831        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1832        let (tx, rx) = std::sync::mpsc::channel();
1833        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1834
1835        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1836
1837        let result = api.add_overlay(
1838            BufferId(1),
1839            Some("test-overlay".to_string()),
1840            0..10,
1841            (255, 0, 0),
1842            None,
1843            true,
1844            false,
1845            false,
1846            false,
1847        );
1848        assert!(result.is_ok());
1849
1850        let received = rx.try_recv().unwrap();
1851        match received {
1852            PluginCommand::AddOverlay {
1853                buffer_id,
1854                namespace,
1855                range,
1856                color,
1857                bg_color,
1858                underline,
1859                bold,
1860                italic,
1861                extend_to_line_end,
1862            } => {
1863                assert_eq!(buffer_id.0, 1);
1864                assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
1865                assert_eq!(range, 0..10);
1866                assert_eq!(color, (255, 0, 0));
1867                assert_eq!(bg_color, None);
1868                assert!(underline);
1869                assert!(!bold);
1870                assert!(!italic);
1871                assert!(!extend_to_line_end);
1872            }
1873            _ => panic!("Wrong command type"),
1874        }
1875    }
1876
1877    #[test]
1878    fn test_set_status_command() {
1879        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1880        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1881        let (tx, rx) = std::sync::mpsc::channel();
1882        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1883
1884        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1885
1886        let result = api.set_status("Test status".to_string());
1887        assert!(result.is_ok());
1888
1889        let received = rx.try_recv().unwrap();
1890        match received {
1891            PluginCommand::SetStatus { message } => {
1892                assert_eq!(message, "Test status");
1893            }
1894            _ => panic!("Wrong command type"),
1895        }
1896    }
1897
1898    #[test]
1899    fn test_get_active_buffer_id() {
1900        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1901        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1902        let (tx, _rx) = std::sync::mpsc::channel();
1903        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1904
1905        // Set active buffer to 5
1906        {
1907            let mut snapshot = state_snapshot.write().unwrap();
1908            snapshot.active_buffer_id = BufferId(5);
1909        }
1910
1911        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1912
1913        let active_id = api.get_active_buffer_id();
1914        assert_eq!(active_id.0, 5);
1915    }
1916
1917    #[test]
1918    fn test_get_buffer_info() {
1919        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1920        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1921        let (tx, _rx) = std::sync::mpsc::channel();
1922        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1923
1924        // Add buffer info
1925        {
1926            let mut snapshot = state_snapshot.write().unwrap();
1927            let buffer_info = BufferInfo {
1928                id: BufferId(1),
1929                path: Some(std::path::PathBuf::from("/test/file.txt")),
1930                modified: true,
1931                length: 100,
1932            };
1933            snapshot.buffers.insert(BufferId(1), buffer_info);
1934        }
1935
1936        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1937
1938        let info = api.get_buffer_info(BufferId(1));
1939        assert!(info.is_some());
1940        let info = info.unwrap();
1941        assert_eq!(info.id.0, 1);
1942        assert_eq!(
1943            info.path.as_ref().unwrap().to_str().unwrap(),
1944            "/test/file.txt"
1945        );
1946        assert!(info.modified);
1947        assert_eq!(info.length, 100);
1948
1949        // Non-existent buffer
1950        let no_info = api.get_buffer_info(BufferId(999));
1951        assert!(no_info.is_none());
1952    }
1953
1954    #[test]
1955    fn test_list_buffers() {
1956        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
1957        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
1958        let (tx, _rx) = std::sync::mpsc::channel();
1959        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
1960
1961        // Add multiple buffers
1962        {
1963            let mut snapshot = state_snapshot.write().unwrap();
1964            snapshot.buffers.insert(
1965                BufferId(1),
1966                BufferInfo {
1967                    id: BufferId(1),
1968                    path: Some(std::path::PathBuf::from("/file1.txt")),
1969                    modified: false,
1970                    length: 50,
1971                },
1972            );
1973            snapshot.buffers.insert(
1974                BufferId(2),
1975                BufferInfo {
1976                    id: BufferId(2),
1977                    path: Some(std::path::PathBuf::from("/file2.txt")),
1978                    modified: true,
1979                    length: 100,
1980                },
1981            );
1982            snapshot.buffers.insert(
1983                BufferId(3),
1984                BufferInfo {
1985                    id: BufferId(3),
1986                    path: None,
1987                    modified: false,
1988                    length: 0,
1989                },
1990            );
1991        }
1992
1993        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
1994
1995        let buffers = api.list_buffers();
1996        assert_eq!(buffers.len(), 3);
1997
1998        // Verify all buffers are present
1999        assert!(buffers.iter().any(|b| b.id.0 == 1));
2000        assert!(buffers.iter().any(|b| b.id.0 == 2));
2001        assert!(buffers.iter().any(|b| b.id.0 == 3));
2002    }
2003
2004    #[test]
2005    fn test_get_primary_cursor() {
2006        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2007        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2008        let (tx, _rx) = std::sync::mpsc::channel();
2009        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2010
2011        // Add cursor info
2012        {
2013            let mut snapshot = state_snapshot.write().unwrap();
2014            snapshot.primary_cursor = Some(CursorInfo {
2015                position: 42,
2016                selection: Some(10..42),
2017            });
2018        }
2019
2020        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2021
2022        let cursor = api.get_primary_cursor();
2023        assert!(cursor.is_some());
2024        let cursor = cursor.unwrap();
2025        assert_eq!(cursor.position, 42);
2026        assert_eq!(cursor.selection, Some(10..42));
2027    }
2028
2029    #[test]
2030    fn test_get_all_cursors() {
2031        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2032        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2033        let (tx, _rx) = std::sync::mpsc::channel();
2034        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2035
2036        // Add multiple cursors
2037        {
2038            let mut snapshot = state_snapshot.write().unwrap();
2039            snapshot.all_cursors = vec![
2040                CursorInfo {
2041                    position: 10,
2042                    selection: None,
2043                },
2044                CursorInfo {
2045                    position: 20,
2046                    selection: Some(15..20),
2047                },
2048                CursorInfo {
2049                    position: 30,
2050                    selection: Some(25..30),
2051                },
2052            ];
2053        }
2054
2055        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2056
2057        let cursors = api.get_all_cursors();
2058        assert_eq!(cursors.len(), 3);
2059        assert_eq!(cursors[0].position, 10);
2060        assert_eq!(cursors[0].selection, None);
2061        assert_eq!(cursors[1].position, 20);
2062        assert_eq!(cursors[1].selection, Some(15..20));
2063        assert_eq!(cursors[2].position, 30);
2064        assert_eq!(cursors[2].selection, Some(25..30));
2065    }
2066
2067    #[test]
2068    fn test_get_viewport() {
2069        let hooks = Arc::new(RwLock::new(HookRegistry::new()));
2070        let commands = Arc::new(RwLock::new(CommandRegistry::new()));
2071        let (tx, _rx) = std::sync::mpsc::channel();
2072        let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
2073
2074        // Add viewport info
2075        {
2076            let mut snapshot = state_snapshot.write().unwrap();
2077            snapshot.viewport = Some(ViewportInfo {
2078                top_byte: 100,
2079                left_column: 5,
2080                width: 80,
2081                height: 24,
2082            });
2083        }
2084
2085        let api = PluginApi::new(hooks, commands, tx, state_snapshot);
2086
2087        let viewport = api.get_viewport();
2088        assert!(viewport.is_some());
2089        let viewport = viewport.unwrap();
2090        assert_eq!(viewport.top_byte, 100);
2091        assert_eq!(viewport.left_column, 5);
2092        assert_eq!(viewport.width, 80);
2093        assert_eq!(viewport.height, 24);
2094    }
2095}