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 /// When true, the prompt renders as a centred floating
1443 /// overlay rather than a bottom-row minibuffer. Used for
1444 /// Live Grep (issue #1796). Defaults to false at the wire
1445 /// level via `#[serde(default)]`.
1446 #[serde(default)]
1447 floating_overlay: bool,
1448 },
1449
1450 /// Start a prompt with pre-filled initial value
1451 StartPromptWithInitial {
1452 label: String,
1453 prompt_type: String,
1454 initial_value: String,
1455 /// See `StartPrompt::floating_overlay`.
1456 #[serde(default)]
1457 floating_overlay: bool,
1458 },
1459
1460 /// Start an async prompt that returns result via callback
1461 /// The callback_id is used to resolve the promise when the prompt is confirmed or cancelled
1462 StartPromptAsync {
1463 label: String,
1464 initial_value: String,
1465 callback_id: JsCallbackId,
1466 },
1467
1468 /// Request the next keypress for the calling plugin.
1469 ///
1470 /// The editor enqueues `callback_id` and resolves it with a
1471 /// `KeyEventPayload` JSON value the next time a key arrives in
1472 /// `Editor::handle_key`. Multiple pending requests are FIFO.
1473 /// While at least one request is pending, the next key is consumed
1474 /// by the resolution and does not propagate to mode bindings or
1475 /// other dispatch — this is the primitive that lets a plugin run a
1476 /// short input loop (flash labels, vi find-char, replace-char,
1477 /// etc.) without binding every printable key in `defineMode`.
1478 AwaitNextKey { callback_id: JsCallbackId },
1479
1480 /// Begin or end "key capture" mode for the calling plugin.
1481 ///
1482 /// Without this, a plugin running a `getNextKey()` loop has a
1483 /// race: keys typed by the user (or pasted, or auto-repeated)
1484 /// can arrive between two consecutive `getNextKey()` calls while
1485 /// the plugin is still mid-redraw, and would otherwise fall
1486 /// through to the editor's normal dispatch (inserting into the
1487 /// buffer, etc.).
1488 ///
1489 /// While capture is active, every key arriving in
1490 /// `Editor::handle_key` (after terminal-input dispatch) is
1491 /// either resolved against a pending `AwaitNextKey` callback
1492 /// (existing behaviour) or, if no callback is pending, *buffered*
1493 /// in a FIFO queue. When the next `AwaitNextKey` is processed,
1494 /// the queue is drained first. This gives plugins lossless,
1495 /// in-order delivery of every key the user typed regardless of
1496 /// timing.
1497 ///
1498 /// `EndKeyCapture` clears any unconsumed buffered keys; they do
1499 /// NOT replay into the editor's normal dispatch path (that would
1500 /// be surprising — the user's intent was for the plugin to
1501 /// consume them).
1502 SetKeyCaptureActive { active: bool },
1503
1504 /// Update the suggestions list for the current prompt
1505 /// Uses the editor's Suggestion type
1506 SetPromptSuggestions { suggestions: Vec<Suggestion> },
1507
1508 /// When enabled, navigating suggestions updates the prompt input text
1509 SetPromptInputSync { sync: bool },
1510
1511 /// Set the title shown in a floating-overlay prompt's frame
1512 /// header (issue #1796). `None` clears any previously-set title
1513 /// and falls back to the prompt-type default. Has no visible
1514 /// effect on non-overlay prompts.
1515 SetPromptTitle { title: Option<String> },
1516
1517 /// Add a menu item to an existing menu
1518 /// Add a menu item to an existing menu
1519 AddMenuItem {
1520 menu_label: String,
1521 item: MenuItem,
1522 position: MenuPosition,
1523 },
1524
1525 /// Add a new top-level menu
1526 AddMenu { menu: Menu, position: MenuPosition },
1527
1528 /// Remove a menu item from a menu
1529 RemoveMenuItem {
1530 menu_label: String,
1531 item_label: String,
1532 },
1533
1534 /// Remove a top-level menu
1535 RemoveMenu { menu_label: String },
1536
1537 /// Create a new virtual buffer (not backed by a file)
1538 CreateVirtualBuffer {
1539 /// Display name (e.g., "*Diagnostics*")
1540 name: String,
1541 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1542 mode: String,
1543 /// Whether the buffer is read-only
1544 read_only: bool,
1545 },
1546
1547 /// Create a virtual buffer and set its content in one operation
1548 /// This is preferred over CreateVirtualBuffer + SetVirtualBufferContent
1549 /// because it doesn't require tracking the buffer ID
1550 CreateVirtualBufferWithContent {
1551 /// Display name (e.g., "*Diagnostics*")
1552 name: String,
1553 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1554 mode: String,
1555 /// Whether the buffer is read-only
1556 read_only: bool,
1557 /// Entries with text and embedded properties
1558 entries: Vec<TextPropertyEntry>,
1559 /// Whether to show line numbers in the gutter
1560 show_line_numbers: bool,
1561 /// Whether to show cursors in the buffer
1562 show_cursors: bool,
1563 /// Whether editing is disabled (blocks editing commands)
1564 editing_disabled: bool,
1565 /// Whether this buffer should be hidden from tabs (for composite source buffers)
1566 hidden_from_tabs: bool,
1567 /// Optional request ID for async response
1568 request_id: Option<u64>,
1569 },
1570
1571 /// Create a virtual buffer in a horizontal split
1572 /// Opens the buffer in a new pane below the current one
1573 CreateVirtualBufferInSplit {
1574 /// Display name (e.g., "*Diagnostics*")
1575 name: String,
1576 /// Mode name for buffer-local keybindings (e.g., "diagnostics-list")
1577 mode: String,
1578 /// Whether the buffer is read-only
1579 read_only: bool,
1580 /// Entries with text and embedded properties
1581 entries: Vec<TextPropertyEntry>,
1582 /// Split ratio (0.0 to 1.0, where 0.5 = equal split)
1583 ratio: f32,
1584 /// Split direction ("horizontal" or "vertical"), default horizontal
1585 direction: Option<String>,
1586 /// Optional panel ID for idempotent operations (if panel exists, update content)
1587 panel_id: Option<String>,
1588 /// Whether to show line numbers in the buffer (default true)
1589 show_line_numbers: bool,
1590 /// Whether to show cursors in the buffer (default true)
1591 show_cursors: bool,
1592 /// Whether editing is disabled for this buffer (default false)
1593 editing_disabled: bool,
1594 /// Whether line wrapping is enabled for this split (None = use global setting)
1595 line_wrap: Option<bool>,
1596 /// Place the new buffer before (left/top of) the existing content (default: false/after)
1597 before: bool,
1598 /// Optional split role tag. When `Some("utility_dock")`, the
1599 /// dispatcher routes the buffer to the existing dock leaf if
1600 /// one exists; otherwise it seeds a new dock leaf with the
1601 /// requested direction/ratio.
1602 role: Option<String>,
1603 /// Optional request ID for async response (if set, editor will send back buffer ID)
1604 request_id: Option<u64>,
1605 },
1606
1607 /// Set the content of a virtual buffer with text properties
1608 SetVirtualBufferContent {
1609 buffer_id: BufferId,
1610 /// Entries with text and embedded properties
1611 entries: Vec<TextPropertyEntry>,
1612 },
1613
1614 /// Get text properties at the cursor position in a buffer
1615 GetTextPropertiesAtCursor { buffer_id: BufferId },
1616
1617 /// Create a buffer group: multiple panels appearing as one tab.
1618 /// Each panel is a real buffer with its own scrollbar and viewport.
1619 CreateBufferGroup {
1620 /// Display name (shown in tab bar)
1621 name: String,
1622 /// Mode for keybindings
1623 mode: String,
1624 /// Layout tree as JSON string (parsed by the handler)
1625 layout_json: String,
1626 /// Optional request ID for async response
1627 request_id: Option<u64>,
1628 },
1629
1630 /// Set the content of a panel within a buffer group.
1631 SetPanelContent {
1632 /// Group ID
1633 group_id: usize,
1634 /// Panel name (e.g., "tree", "picker")
1635 panel_name: String,
1636 /// Content entries
1637 entries: Vec<TextPropertyEntry>,
1638 },
1639
1640 /// Close a buffer group (closes all panels and splits)
1641 CloseBufferGroup { group_id: usize },
1642
1643 /// Focus a specific panel within a buffer group
1644 FocusPanel { group_id: usize, panel_name: String },
1645
1646 /// Define a buffer mode with keybindings
1647 DefineMode {
1648 name: String,
1649 bindings: Vec<(String, String)>, // (key_string, command_name)
1650 read_only: bool,
1651 /// When true, unbound character keys dispatch as `mode_text_input:<char>`.
1652 allow_text_input: bool,
1653 /// When true, keys not bound by this mode fall through to the Normal
1654 /// context (motion, selection, copy) instead of being dropped.
1655 inherit_normal_bindings: bool,
1656 /// Name of the plugin that defined this mode (for attribution)
1657 plugin_name: Option<String>,
1658 },
1659
1660 /// Switch the current split to display a buffer
1661 ShowBuffer { buffer_id: BufferId },
1662
1663 /// Start a frame-buffer animation over a given screen region. The `id`
1664 /// is allocated on the plugin side so the JS call can return it
1665 /// synchronously; the editor uses it verbatim.
1666 StartAnimationArea {
1667 id: u64,
1668 rect: AnimationRect,
1669 kind: PluginAnimationKind,
1670 },
1671
1672 /// Start an animation over the on-screen Rect currently occupied by a
1673 /// virtual buffer. If the buffer is not visible, the editor ignores
1674 /// the command.
1675 StartAnimationVirtualBuffer {
1676 id: u64,
1677 buffer_id: BufferId,
1678 kind: PluginAnimationKind,
1679 },
1680
1681 /// Cancel an animation by the ID returned from `animateArea` /
1682 /// `animateVirtualBuffer`. No-op if the ID is unknown or already done.
1683 CancelAnimation { id: u64 },
1684
1685 /// Create a virtual buffer in an existing split (replaces current buffer in that split)
1686 CreateVirtualBufferInExistingSplit {
1687 /// Display name (e.g., "*Commit Details*")
1688 name: String,
1689 /// Mode name for buffer-local keybindings
1690 mode: String,
1691 /// Whether the buffer is read-only
1692 read_only: bool,
1693 /// Entries with text and embedded properties
1694 entries: Vec<TextPropertyEntry>,
1695 /// Target split ID where the buffer should be displayed
1696 split_id: SplitId,
1697 /// Whether to show line numbers in the buffer (default true)
1698 show_line_numbers: bool,
1699 /// Whether to show cursors in the buffer (default true)
1700 show_cursors: bool,
1701 /// Whether editing is disabled for this buffer (default false)
1702 editing_disabled: bool,
1703 /// Whether line wrapping is enabled for this split (None = use global setting)
1704 line_wrap: Option<bool>,
1705 /// Optional request ID for async response
1706 request_id: Option<u64>,
1707 },
1708
1709 /// Close a buffer and remove it from all splits
1710 CloseBuffer { buffer_id: BufferId },
1711
1712 /// Create a composite buffer that displays multiple source buffers
1713 /// Used for side-by-side diff, unified diff, and 3-way merge views
1714 CreateCompositeBuffer {
1715 /// Display name (shown in tab bar)
1716 name: String,
1717 /// Mode name for keybindings (e.g., "diff-view")
1718 mode: String,
1719 /// Layout configuration
1720 layout: CompositeLayoutConfig,
1721 /// Source pane configurations
1722 sources: Vec<CompositeSourceConfig>,
1723 /// Diff hunks for line alignment (optional)
1724 hunks: Option<Vec<CompositeHunk>>,
1725 /// When set, first render scrolls to center this hunk (0-indexed)
1726 initial_focus_hunk: Option<usize>,
1727 /// Request ID for async response
1728 request_id: Option<u64>,
1729 },
1730
1731 /// Update alignment for a composite buffer (e.g., after source edit)
1732 UpdateCompositeAlignment {
1733 buffer_id: BufferId,
1734 hunks: Vec<CompositeHunk>,
1735 },
1736
1737 /// Close a composite buffer
1738 CloseCompositeBuffer { buffer_id: BufferId },
1739
1740 /// Force-materialize render-dependent state (like `layoutIfNeeded` in UIKit).
1741 ///
1742 /// Creates `CompositeViewState` for any visible composite buffer that doesn't
1743 /// have one, and syncs viewport dimensions from split layout. This ensures
1744 /// subsequent commands can read/modify view state that is normally created
1745 /// lazily during the render cycle.
1746 FlushLayout,
1747
1748 /// Navigate to the next hunk in a composite buffer
1749 CompositeNextHunk { buffer_id: BufferId },
1750
1751 /// Navigate to the previous hunk in a composite buffer
1752 CompositePrevHunk { buffer_id: BufferId },
1753
1754 /// Focus a specific split
1755 FocusSplit { split_id: SplitId },
1756
1757 /// Set the buffer displayed in a specific split
1758 SetSplitBuffer {
1759 split_id: SplitId,
1760 buffer_id: BufferId,
1761 },
1762
1763 /// Set the scroll position of a specific split
1764 SetSplitScroll { split_id: SplitId, top_byte: usize },
1765
1766 /// Request syntax highlights for a buffer range
1767 RequestHighlights {
1768 buffer_id: BufferId,
1769 range: Range<usize>,
1770 request_id: u64,
1771 },
1772
1773 /// Close a split (if not the last one)
1774 CloseSplit { split_id: SplitId },
1775
1776 /// Set the ratio of a split container
1777 SetSplitRatio {
1778 split_id: SplitId,
1779 /// Ratio between 0.0 and 1.0 (0.5 = equal split)
1780 ratio: f32,
1781 },
1782
1783 /// Set a label on a leaf split (e.g., "sidebar")
1784 SetSplitLabel { split_id: SplitId, label: String },
1785
1786 /// Remove a label from a split
1787 ClearSplitLabel { split_id: SplitId },
1788
1789 /// Find a split by its label (async)
1790 GetSplitByLabel { label: String, request_id: u64 },
1791
1792 /// Distribute splits evenly - make all given splits equal size
1793 DistributeSplitsEvenly {
1794 /// Split IDs to distribute evenly
1795 split_ids: Vec<SplitId>,
1796 },
1797
1798 /// Set cursor position in a buffer (also scrolls viewport to show cursor)
1799 SetBufferCursor {
1800 buffer_id: BufferId,
1801 /// Byte offset position for the cursor
1802 position: usize,
1803 },
1804
1805 /// Toggle whether the editor draws a native caret for this buffer.
1806 ///
1807 /// Buffer-group panel buffers default to `show_cursors = false`, which not
1808 /// only hides the caret but also blocks all movement actions in
1809 /// `action_to_events`. Plugins that want native cursor motion in a panel
1810 /// buffer (e.g. for magit-style row navigation) flip this to `true` after
1811 /// `createBufferGroup` returns.
1812 SetBufferShowCursors { buffer_id: BufferId, show: bool },
1813
1814 /// Send an arbitrary LSP request and return the raw JSON response
1815 SendLspRequest {
1816 language: String,
1817 method: String,
1818 #[ts(type = "any")]
1819 params: Option<JsonValue>,
1820 request_id: u64,
1821 },
1822
1823 /// Set the internal clipboard content
1824 SetClipboard { text: String },
1825
1826 /// Delete the current selection in the active buffer
1827 /// This deletes all selected text across all cursors
1828 DeleteSelection,
1829
1830 /// Set or unset a custom context
1831 /// Custom contexts are plugin-defined states that can be used to control command visibility
1832 /// For example, "config-editor" context could make config editor commands available
1833 SetContext {
1834 /// Context name (e.g., "config-editor")
1835 name: String,
1836 /// Whether the context is active
1837 active: bool,
1838 },
1839
1840 /// Set the hunks for the Review Diff tool
1841 SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
1842
1843 /// Execute an editor action by name (e.g., "move_word_right", "delete_line")
1844 /// Used by vi mode plugin to run motions and calculate cursor ranges
1845 ExecuteAction {
1846 /// Action name (e.g., "move_word_right", "move_line_end")
1847 action_name: String,
1848 },
1849
1850 /// Execute multiple actions in sequence, each with an optional repeat count
1851 /// Used by vi mode for count prefix (e.g., "3dw" = delete 3 words)
1852 /// All actions execute atomically with no plugin roundtrips between them
1853 ExecuteActions {
1854 /// List of actions to execute in sequence
1855 actions: Vec<ActionSpec>,
1856 },
1857
1858 /// Get text from a buffer range (for yank operations)
1859 GetBufferText {
1860 /// Buffer ID
1861 buffer_id: BufferId,
1862 /// Start byte offset
1863 start: usize,
1864 /// End byte offset
1865 end: usize,
1866 /// Request ID for async response
1867 request_id: u64,
1868 },
1869
1870 /// Get byte offset of the start of a line (async)
1871 /// Line is 0-indexed (0 = first line)
1872 GetLineStartPosition {
1873 /// Buffer ID (0 for active buffer)
1874 buffer_id: BufferId,
1875 /// Line number (0-indexed)
1876 line: u32,
1877 /// Request ID for async response
1878 request_id: u64,
1879 },
1880
1881 /// Get byte offset of the end of a line (async)
1882 /// Line is 0-indexed (0 = first line)
1883 /// Returns the byte offset after the last character of the line (before newline)
1884 GetLineEndPosition {
1885 /// Buffer ID (0 for active buffer)
1886 buffer_id: BufferId,
1887 /// Line number (0-indexed)
1888 line: u32,
1889 /// Request ID for async response
1890 request_id: u64,
1891 },
1892
1893 /// Get the total number of lines in a buffer (async)
1894 GetBufferLineCount {
1895 /// Buffer ID (0 for active buffer)
1896 buffer_id: BufferId,
1897 /// Request ID for async response
1898 request_id: u64,
1899 },
1900
1901 /// Scroll a split to center a specific line in the viewport
1902 /// Line is 0-indexed (0 = first line)
1903 ScrollToLineCenter {
1904 /// Split ID to scroll
1905 split_id: SplitId,
1906 /// Buffer ID containing the line
1907 buffer_id: BufferId,
1908 /// Line number to center (0-indexed)
1909 line: usize,
1910 },
1911
1912 /// Scroll any split/panel that displays `buffer_id` so the given
1913 /// line is visible in the viewport. Unlike `ScrollToLineCenter` this
1914 /// does not require a split id — it walks all splits (including
1915 /// inner panels of a buffer group) and updates every viewport that
1916 /// shows this buffer. Line is 0-indexed.
1917 ScrollBufferToLine {
1918 /// Buffer ID to scroll
1919 buffer_id: BufferId,
1920 /// Line number to bring into view (0-indexed)
1921 line: usize,
1922 },
1923
1924 /// Set the global editor mode (for modal editing like vi mode)
1925 /// When set, the mode's keybindings take precedence over normal editing
1926 SetEditorMode {
1927 /// Mode name (e.g., "vi-normal", "vi-insert") or None to clear
1928 mode: Option<String>,
1929 },
1930
1931 /// Show an action popup with buttons for user interaction
1932 /// When the user selects an action, the ActionPopupResult hook is fired
1933 ShowActionPopup {
1934 /// Unique identifier for the popup (used in ActionPopupResult)
1935 popup_id: String,
1936 /// Title text for the popup
1937 title: String,
1938 /// Body message (supports basic formatting)
1939 message: String,
1940 /// Action buttons to display
1941 actions: Vec<ActionPopupAction>,
1942 },
1943
1944 /// Disable LSP for a specific language and persist to config
1945 DisableLspForLanguage {
1946 /// The language to disable LSP for (e.g., "python", "rust")
1947 language: String,
1948 },
1949
1950 /// Restart LSP server for a specific language
1951 RestartLspForLanguage {
1952 /// The language to restart LSP for (e.g., "python", "rust")
1953 language: String,
1954 },
1955
1956 /// Set the workspace root URI for a specific language's LSP server
1957 /// This allows plugins to specify project roots (e.g., directory containing .csproj)
1958 /// If the LSP is already running, it will be restarted with the new root
1959 SetLspRootUri {
1960 /// The language to set root URI for (e.g., "csharp", "rust")
1961 language: String,
1962 /// The root URI (file:// URL format)
1963 uri: String,
1964 },
1965
1966 /// Create a scroll sync group for anchor-based synchronized scrolling
1967 /// Used for side-by-side diff views where two panes need to scroll together
1968 /// The plugin provides the group ID (must be unique per plugin)
1969 CreateScrollSyncGroup {
1970 /// Plugin-assigned group ID
1971 group_id: u32,
1972 /// The left (primary) split - scroll position is tracked in this split's line space
1973 left_split: SplitId,
1974 /// The right (secondary) split - position is derived from anchors
1975 right_split: SplitId,
1976 },
1977
1978 /// Set sync anchors for a scroll sync group
1979 /// Anchors map corresponding line numbers between left and right buffers
1980 SetScrollSyncAnchors {
1981 /// The group ID returned by CreateScrollSyncGroup
1982 group_id: u32,
1983 /// List of (left_line, right_line) pairs marking corresponding positions
1984 anchors: Vec<(usize, usize)>,
1985 },
1986
1987 /// Remove a scroll sync group
1988 RemoveScrollSyncGroup {
1989 /// The group ID returned by CreateScrollSyncGroup
1990 group_id: u32,
1991 },
1992
1993 /// Save a buffer to a specific file path
1994 /// Used by :w filename command to save unnamed buffers or save-as
1995 SaveBufferToPath {
1996 /// Buffer ID to save
1997 buffer_id: BufferId,
1998 /// Path to save to
1999 path: PathBuf,
2000 },
2001
2002 /// Load a plugin from a file path
2003 /// The plugin will be initialized and start receiving events
2004 LoadPlugin {
2005 /// Path to the plugin file (.ts or .js)
2006 path: PathBuf,
2007 /// Callback ID for async response (success/failure)
2008 callback_id: JsCallbackId,
2009 },
2010
2011 /// Unload a plugin by name
2012 /// The plugin will stop receiving events and be removed from memory
2013 UnloadPlugin {
2014 /// Plugin name (as registered)
2015 name: String,
2016 /// Callback ID for async response (success/failure)
2017 callback_id: JsCallbackId,
2018 },
2019
2020 /// Reload a plugin by name (unload + load)
2021 /// Useful for development when plugin code changes
2022 ReloadPlugin {
2023 /// Plugin name (as registered)
2024 name: String,
2025 /// Callback ID for async response (success/failure)
2026 callback_id: JsCallbackId,
2027 },
2028
2029 /// List all loaded plugins
2030 /// Returns plugin info (name, path, enabled) for all loaded plugins
2031 ListPlugins {
2032 /// Callback ID for async response (JSON array of plugin info)
2033 callback_id: JsCallbackId,
2034 },
2035
2036 /// Reload the theme registry from disk
2037 /// Call this after installing a theme package or saving a new theme.
2038 /// If `apply_theme` is set, apply that theme immediately after reloading.
2039 ReloadThemes { apply_theme: Option<String> },
2040
2041 /// Register a TextMate grammar file for a language
2042 /// The grammar will be added to pending_grammars until ReloadGrammars is called
2043 RegisterGrammar {
2044 /// Language identifier (e.g., "elixir", "zig")
2045 language: String,
2046 /// Path to the grammar file (.sublime-syntax or .tmLanguage)
2047 grammar_path: String,
2048 /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
2049 extensions: Vec<String>,
2050 },
2051
2052 /// Register language configuration (comment prefix, indentation, formatter)
2053 /// This is applied immediately to the runtime config
2054 RegisterLanguageConfig {
2055 /// Language identifier (e.g., "elixir")
2056 language: String,
2057 /// Language configuration
2058 config: LanguagePackConfig,
2059 },
2060
2061 /// Register an LSP server for a language
2062 /// This is applied immediately to the LSP manager and runtime config
2063 RegisterLspServer {
2064 /// Language identifier (e.g., "elixir")
2065 language: String,
2066 /// LSP server configuration
2067 config: LspServerPackConfig,
2068 },
2069
2070 /// Reload the grammar registry to apply registered grammars (async)
2071 /// Call this after registering one or more grammars to rebuild the syntax set.
2072 /// The callback is resolved when the background grammar build completes.
2073 ReloadGrammars { callback_id: JsCallbackId },
2074
2075 // ==================== Terminal Commands ====================
2076 /// Create a new terminal in a split (async, returns TerminalResult)
2077 /// This spawns a PTY-backed terminal that plugins can write to and read from.
2078 CreateTerminal {
2079 /// Working directory for the terminal (defaults to editor cwd)
2080 cwd: Option<String>,
2081 /// Split direction ("horizontal" or "vertical"), default vertical
2082 direction: Option<String>,
2083 /// Split ratio (0.0 to 1.0), default 0.5
2084 ratio: Option<f32>,
2085 /// Whether to focus the new terminal split (default true)
2086 focus: Option<bool>,
2087 /// Whether this terminal survives editor restarts. When false, the
2088 /// terminal is excluded from workspace serialization and its backing
2089 /// file is kept unique-per-spawn so no scrollback from a prior run
2090 /// leaks in. Plugin-created terminals default to `false` since they
2091 /// are typically one-off tool UIs (rebuilds, exec shells, etc.).
2092 persistent: bool,
2093 /// Callback ID for async response
2094 request_id: u64,
2095 },
2096
2097 /// Send input data to a terminal by its terminal ID
2098 SendTerminalInput {
2099 /// The terminal ID (from TerminalResult)
2100 terminal_id: TerminalId,
2101 /// Data to write to the terminal PTY (UTF-8 string, may include escape sequences)
2102 data: String,
2103 },
2104
2105 /// Close a terminal by its terminal ID
2106 CloseTerminal {
2107 /// The terminal ID to close
2108 terminal_id: TerminalId,
2109 },
2110
2111 /// Project-wide grep search (async)
2112 /// Searches all project files via FileSystem trait, respecting .gitignore.
2113 /// For open buffers with dirty edits, searches the buffer's piece tree.
2114 GrepProject {
2115 /// Search pattern (literal string)
2116 pattern: String,
2117 /// Whether the pattern is a fixed string (true) or regex (false)
2118 fixed_string: bool,
2119 /// Whether the search is case-sensitive
2120 case_sensitive: bool,
2121 /// Maximum number of results to return
2122 max_results: usize,
2123 /// Whether to match whole words only
2124 whole_words: bool,
2125 /// Callback ID for async response
2126 callback_id: JsCallbackId,
2127 },
2128
2129 /// Project-wide streaming grep search (async, parallel)
2130 /// Like GrepProject but streams results incrementally via progress callback.
2131 /// Searches files in parallel using tokio tasks, sending per-file results
2132 /// back to the plugin as they complete.
2133 GrepProjectStreaming {
2134 /// Search pattern
2135 pattern: String,
2136 /// Whether the pattern is a fixed string (true) or regex (false)
2137 fixed_string: bool,
2138 /// Whether the search is case-sensitive
2139 case_sensitive: bool,
2140 /// Maximum number of results to return
2141 max_results: usize,
2142 /// Whether to match whole words only
2143 whole_words: bool,
2144 /// Search ID — used to route progress callbacks and for cancellation
2145 search_id: u64,
2146 /// Callback ID for the completion promise
2147 callback_id: JsCallbackId,
2148 },
2149
2150 /// Replace matches in a buffer (async)
2151 /// Opens the file if not already open, applies edits through the buffer model,
2152 /// groups as a single undo action, and saves via FileSystem trait.
2153 ReplaceInBuffer {
2154 /// File path to edit (will open if not already in a buffer)
2155 file_path: PathBuf,
2156 /// Matches to replace, each is (byte_offset, length)
2157 matches: Vec<(usize, usize)>,
2158 /// Replacement text
2159 replacement: String,
2160 /// Callback ID for async response
2161 callback_id: JsCallbackId,
2162 },
2163
2164 /// Install a new authority.
2165 ///
2166 /// Authority is opaque to core. The payload is a tagged JSON object
2167 /// (filesystem kind + spawner kind + terminal wrapper + display
2168 /// label) that `fresh-editor` deserializes into its concrete
2169 /// `AuthorityPayload` type. Using `serde_json::Value` here keeps
2170 /// fresh-core from growing backend-specific knowledge; see
2171 /// `crates/fresh-editor/src/services/authority/mod.rs` for the
2172 /// canonical schema.
2173 ///
2174 /// Fire-and-forget: the transition piggy-backs on the existing
2175 /// editor restart flow, so the plugin that sent this command will
2176 /// be re-loaded as part of the restart. Any follow-up work the
2177 /// plugin wants to do after the switch belongs in its post-restart
2178 /// init code, not in a callback here.
2179 SetAuthority {
2180 #[ts(type = "unknown")]
2181 payload: JsonValue,
2182 },
2183
2184 /// Restore the default local authority. Same semantics as
2185 /// `SetAuthority` with a local payload — triggers an editor
2186 /// restart.
2187 ClearAuthority,
2188
2189 /// Override the Remote Indicator's displayed state for the rest
2190 /// of the current editor session (until a restart, or until the
2191 /// plugin sends another override / `ClearRemoteIndicatorState`).
2192 ///
2193 /// The derived state — computed from the active authority's
2194 /// connection info — keeps running underneath and is what the
2195 /// indicator shows whenever an override is not in effect.
2196 /// Plugins use this to surface lifecycle states that have no
2197 /// authority-level truth yet (e.g. "Connecting" during
2198 /// `devcontainer up`, "FailedAttach" after a non-zero exit).
2199 ///
2200 /// `state` is a tagged enum keyed by `kind`:
2201 /// - `{ "kind": "local" }`
2202 /// - `{ "kind": "connecting", "label": "..." }`
2203 /// - `{ "kind": "connected", "label": "..." }`
2204 /// - `{ "kind": "failed_attach", "error": "..." }`
2205 /// - `{ "kind": "disconnected", "label": "..." }`
2206 ///
2207 /// The exact schema lives in
2208 /// `crates/fresh-editor/src/view/ui/status_bar.rs`; fresh-core
2209 /// takes it opaquely so new variants can land without touching
2210 /// core plumbing.
2211 SetRemoteIndicatorState {
2212 #[ts(type = "unknown")]
2213 state: JsonValue,
2214 },
2215
2216 /// Drop any active Remote Indicator override and fall back to
2217 /// the authority-derived state. Safe to call without a prior
2218 /// `SetRemoteIndicatorState`.
2219 ClearRemoteIndicatorState,
2220
2221 /// Spawn a process on the host, regardless of the currently
2222 /// installed authority.
2223 ///
2224 /// Intended for plugin internals that must run host-side work
2225 /// (e.g. `devcontainer up`) before installing an authority that
2226 /// would otherwise route the spawn elsewhere. Behaves like
2227 /// `SpawnProcess` but always uses `LocalProcessSpawner`.
2228 ///
2229 /// The TS-side handle exposes `.kill()` on the returned
2230 /// `ProcessHandle`, serviced by `KillHostProcess` below — this
2231 /// lets callers abort a long-running host spawn (e.g.
2232 /// `devcontainer up`) via a user action like "Cancel Startup".
2233 SpawnHostProcess {
2234 command: String,
2235 args: Vec<String>,
2236 cwd: Option<String>,
2237 callback_id: JsCallbackId,
2238 },
2239
2240 /// Cancel a host-side process previously started via
2241 /// `SpawnHostProcess`. `process_id` is the callback id returned
2242 /// by `spawnHostProcess` (the TS handle stores it and forwards
2243 /// when the caller invokes `.kill()`).
2244 ///
2245 /// No-op when the id is unknown — the process may have already
2246 /// exited, or the caller may hold a stale handle. SIGKILL on
2247 /// Unix per `tokio::process::Child::start_kill`; children of the
2248 /// killed process may leak (see Q-C2 in
2249 /// `DEVCONTAINER_SPEC_GAP_PLAN.md`).
2250 KillHostProcess { process_id: u64 },
2251}
2252
2253impl PluginCommand {
2254 /// Extract the enum variant name from the Debug representation.
2255 pub fn debug_variant_name(&self) -> String {
2256 let dbg = format!("{:?}", self);
2257 dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
2258 }
2259}
2260
2261// =============================================================================
2262// Language Pack Configuration Types
2263// =============================================================================
2264
2265/// Language configuration for language packs
2266///
2267/// This is a simplified version of the full LanguageConfig, containing only
2268/// the fields that can be set via the plugin API.
2269#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
2270#[serde(rename_all = "camelCase")]
2271#[ts(export)]
2272pub struct LanguagePackConfig {
2273 /// Comment prefix for line comments (e.g., "//" or "#")
2274 #[serde(default)]
2275 pub comment_prefix: Option<String>,
2276
2277 /// Block comment start marker (e.g., slash-star)
2278 #[serde(default)]
2279 pub block_comment_start: Option<String>,
2280
2281 /// Block comment end marker (e.g., star-slash)
2282 #[serde(default)]
2283 pub block_comment_end: Option<String>,
2284
2285 /// Whether to use tabs instead of spaces for indentation
2286 #[serde(default)]
2287 pub use_tabs: Option<bool>,
2288
2289 /// Tab size (number of spaces per tab level)
2290 #[serde(default)]
2291 pub tab_size: Option<usize>,
2292
2293 /// Whether auto-indent is enabled
2294 #[serde(default)]
2295 pub auto_indent: Option<bool>,
2296
2297 /// Whether to show whitespace tab indicators (→) for this language
2298 /// Defaults to true. Set to false for languages like Go/Hare that use tabs for indentation.
2299 #[serde(default)]
2300 pub show_whitespace_tabs: Option<bool>,
2301
2302 /// Formatter configuration
2303 #[serde(default)]
2304 pub formatter: Option<FormatterPackConfig>,
2305}
2306
2307/// Formatter configuration for language packs
2308#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2309#[serde(rename_all = "camelCase")]
2310#[ts(export)]
2311pub struct FormatterPackConfig {
2312 /// Command to run (e.g., "prettier", "rustfmt")
2313 pub command: String,
2314
2315 /// Arguments to pass to the formatter
2316 #[serde(default)]
2317 pub args: Vec<String>,
2318}
2319
2320/// Process resource limits for LSP servers
2321#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2322#[serde(rename_all = "camelCase")]
2323#[ts(export)]
2324pub struct ProcessLimitsPackConfig {
2325 /// Maximum memory usage as percentage of total system memory (null = no limit)
2326 #[serde(default)]
2327 pub max_memory_percent: Option<u32>,
2328
2329 /// Maximum CPU usage as percentage of total CPU (null = no limit)
2330 #[serde(default)]
2331 pub max_cpu_percent: Option<u32>,
2332
2333 /// Enable resource limiting
2334 #[serde(default)]
2335 pub enabled: Option<bool>,
2336}
2337
2338/// LSP server configuration for language packs
2339#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2340#[serde(rename_all = "camelCase")]
2341#[ts(export)]
2342pub struct LspServerPackConfig {
2343 /// Command to start the LSP server
2344 pub command: String,
2345
2346 /// Arguments to pass to the command
2347 #[serde(default)]
2348 pub args: Vec<String>,
2349
2350 /// Whether to auto-start the server when a matching file is opened
2351 #[serde(default)]
2352 pub auto_start: Option<bool>,
2353
2354 /// LSP initialization options
2355 #[serde(default)]
2356 #[ts(type = "Record<string, unknown> | null")]
2357 pub initialization_options: Option<JsonValue>,
2358
2359 /// Process resource limits (memory and CPU)
2360 #[serde(default)]
2361 pub process_limits: Option<ProcessLimitsPackConfig>,
2362}
2363
2364/// Hunk status for Review Diff
2365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
2366#[ts(export)]
2367pub enum HunkStatus {
2368 Pending,
2369 Staged,
2370 Discarded,
2371}
2372
2373/// A high-level hunk directive for the Review Diff tool
2374#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2375#[ts(export)]
2376pub struct ReviewHunk {
2377 pub id: String,
2378 pub file: String,
2379 pub context_header: String,
2380 pub status: HunkStatus,
2381 /// 0-indexed line range in the base (HEAD) version
2382 pub base_range: Option<(usize, usize)>,
2383 /// 0-indexed line range in the modified (Working) version
2384 pub modified_range: Option<(usize, usize)>,
2385}
2386
2387/// Action button for action popups
2388#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2389#[serde(deny_unknown_fields)]
2390#[ts(export, rename = "TsActionPopupAction")]
2391pub struct ActionPopupAction {
2392 /// Unique action identifier (returned in ActionPopupResult)
2393 pub id: String,
2394 /// Display text for the button (can include command hints)
2395 pub label: String,
2396}
2397
2398/// Options for showActionPopup
2399#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2400#[serde(deny_unknown_fields)]
2401#[ts(export)]
2402pub struct ActionPopupOptions {
2403 /// Unique identifier for the popup (used in ActionPopupResult)
2404 pub id: String,
2405 /// Title text for the popup
2406 pub title: String,
2407 /// Body message (supports basic formatting)
2408 pub message: String,
2409 /// Action buttons to display
2410 pub actions: Vec<ActionPopupAction>,
2411}
2412
2413/// Syntax highlight span for a buffer range
2414#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2415#[ts(export)]
2416pub struct TsHighlightSpan {
2417 pub start: u32,
2418 pub end: u32,
2419 #[ts(type = "[number, number, number]")]
2420 pub color: (u8, u8, u8),
2421 pub bold: bool,
2422 pub italic: bool,
2423}
2424
2425/// Result from spawning a process with spawnProcess
2426#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2427#[ts(export)]
2428pub struct SpawnResult {
2429 /// Complete stdout as string
2430 pub stdout: String,
2431 /// Complete stderr as string
2432 pub stderr: String,
2433 /// Process exit code (0 usually means success, -1 if killed)
2434 pub exit_code: i32,
2435}
2436
2437/// Result from spawning a background process
2438#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2439#[ts(export)]
2440pub struct BackgroundProcessResult {
2441 /// Unique process ID for later reference
2442 #[ts(type = "number")]
2443 pub process_id: u64,
2444 /// Process exit code (0 usually means success, -1 if killed)
2445 /// Only present when the process has exited
2446 pub exit_code: i32,
2447}
2448
2449/// A single match from project-wide grep
2450#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2451#[serde(rename_all = "camelCase")]
2452#[ts(export, rename_all = "camelCase")]
2453pub struct GrepMatch {
2454 /// Absolute file path
2455 pub file: String,
2456 /// Buffer ID if the file is open (0 if not)
2457 #[ts(type = "number")]
2458 pub buffer_id: usize,
2459 /// Byte offset of match start in the file/buffer content
2460 #[ts(type = "number")]
2461 pub byte_offset: usize,
2462 /// Match length in bytes
2463 #[ts(type = "number")]
2464 pub length: usize,
2465 /// 1-indexed line number
2466 #[ts(type = "number")]
2467 pub line: usize,
2468 /// 1-indexed column number
2469 #[ts(type = "number")]
2470 pub column: usize,
2471 /// The matched line content (for display)
2472 pub context: String,
2473}
2474
2475/// Result from replacing matches in a buffer
2476#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2477#[serde(rename_all = "camelCase")]
2478#[ts(export, rename_all = "camelCase")]
2479pub struct ReplaceResult {
2480 /// Number of replacements made
2481 #[ts(type = "number")]
2482 pub replacements: usize,
2483 /// Buffer ID of the edited buffer
2484 #[ts(type = "number")]
2485 pub buffer_id: usize,
2486}
2487
2488/// Entry for virtual buffer content with optional text properties (JS API version)
2489#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2490#[serde(deny_unknown_fields, rename_all = "camelCase")]
2491#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
2492pub struct JsTextPropertyEntry {
2493 /// Text content for this entry
2494 pub text: String,
2495 /// Optional properties attached to this text (e.g., file path, line number)
2496 #[serde(default)]
2497 #[ts(optional, type = "Record<string, unknown>")]
2498 pub properties: Option<HashMap<String, JsonValue>>,
2499 /// Optional whole-entry styling
2500 #[serde(default)]
2501 #[ts(optional, type = "Partial<OverlayOptions>")]
2502 pub style: Option<OverlayOptions>,
2503 /// Optional sub-range styling within this entry
2504 #[serde(default)]
2505 #[ts(optional)]
2506 pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
2507}
2508
2509/// Directory entry returned by readDir
2510#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2511#[ts(export)]
2512pub struct DirEntry {
2513 /// File/directory name
2514 pub name: String,
2515 /// True if this is a file
2516 pub is_file: bool,
2517 /// True if this is a directory
2518 pub is_dir: bool,
2519}
2520
2521/// Position in a document (line and character)
2522#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2523#[ts(export)]
2524pub struct JsPosition {
2525 /// Zero-indexed line number
2526 pub line: u32,
2527 /// Zero-indexed character offset
2528 pub character: u32,
2529}
2530
2531/// Range in a document (start and end positions)
2532#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2533#[ts(export)]
2534pub struct JsRange {
2535 /// Start position
2536 pub start: JsPosition,
2537 /// End position
2538 pub end: JsPosition,
2539}
2540
2541/// Diagnostic from LSP
2542#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2543#[ts(export)]
2544pub struct JsDiagnostic {
2545 /// Document URI
2546 pub uri: String,
2547 /// Diagnostic message
2548 pub message: String,
2549 /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint, null=unknown
2550 pub severity: Option<u8>,
2551 /// Range in the document
2552 pub range: JsRange,
2553 /// Source of the diagnostic (e.g., "typescript", "eslint")
2554 #[ts(optional)]
2555 pub source: Option<String>,
2556}
2557
2558/// Options for createVirtualBuffer
2559#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2560#[serde(deny_unknown_fields)]
2561#[ts(export)]
2562pub struct CreateVirtualBufferOptions {
2563 /// Buffer name (displayed in tabs/title)
2564 pub name: String,
2565 /// Mode for keybindings (e.g., "git-log", "search-results")
2566 #[serde(default)]
2567 #[ts(optional)]
2568 pub mode: Option<String>,
2569 /// Whether buffer is read-only (default: false)
2570 #[serde(default, rename = "readOnly")]
2571 #[ts(optional, rename = "readOnly")]
2572 pub read_only: Option<bool>,
2573 /// Show line numbers in gutter (default: false)
2574 #[serde(default, rename = "showLineNumbers")]
2575 #[ts(optional, rename = "showLineNumbers")]
2576 pub show_line_numbers: Option<bool>,
2577 /// Show cursor (default: true)
2578 #[serde(default, rename = "showCursors")]
2579 #[ts(optional, rename = "showCursors")]
2580 pub show_cursors: Option<bool>,
2581 /// Disable text editing (default: false)
2582 #[serde(default, rename = "editingDisabled")]
2583 #[ts(optional, rename = "editingDisabled")]
2584 pub editing_disabled: Option<bool>,
2585 /// Hide from tab bar (default: false)
2586 #[serde(default, rename = "hiddenFromTabs")]
2587 #[ts(optional, rename = "hiddenFromTabs")]
2588 pub hidden_from_tabs: Option<bool>,
2589 /// Initial content entries with optional properties
2590 #[serde(default)]
2591 #[ts(optional)]
2592 pub entries: Option<Vec<JsTextPropertyEntry>>,
2593}
2594
2595/// Options for createVirtualBufferInSplit
2596#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2597#[serde(deny_unknown_fields)]
2598#[ts(export)]
2599pub struct CreateVirtualBufferInSplitOptions {
2600 /// Buffer name (displayed in tabs/title)
2601 pub name: String,
2602 /// Mode for keybindings (e.g., "git-log", "search-results")
2603 #[serde(default)]
2604 #[ts(optional)]
2605 pub mode: Option<String>,
2606 /// Whether buffer is read-only (default: false)
2607 #[serde(default, rename = "readOnly")]
2608 #[ts(optional, rename = "readOnly")]
2609 pub read_only: Option<bool>,
2610 /// Split ratio 0.0-1.0 (default: 0.5)
2611 #[serde(default)]
2612 #[ts(optional)]
2613 pub ratio: Option<f32>,
2614 /// Split direction: "horizontal" or "vertical"
2615 #[serde(default)]
2616 #[ts(optional)]
2617 pub direction: Option<String>,
2618 /// Panel ID to split from
2619 #[serde(default, rename = "panelId")]
2620 #[ts(optional, rename = "panelId")]
2621 pub panel_id: Option<String>,
2622 /// Show line numbers in gutter (default: true)
2623 #[serde(default, rename = "showLineNumbers")]
2624 #[ts(optional, rename = "showLineNumbers")]
2625 pub show_line_numbers: Option<bool>,
2626 /// Show cursor (default: true)
2627 #[serde(default, rename = "showCursors")]
2628 #[ts(optional, rename = "showCursors")]
2629 pub show_cursors: Option<bool>,
2630 /// Disable text editing (default: false)
2631 #[serde(default, rename = "editingDisabled")]
2632 #[ts(optional, rename = "editingDisabled")]
2633 pub editing_disabled: Option<bool>,
2634 /// Enable line wrapping
2635 #[serde(default, rename = "lineWrap")]
2636 #[ts(optional, rename = "lineWrap")]
2637 pub line_wrap: Option<bool>,
2638 /// Place the new buffer before (left/top of) the existing content (default: false)
2639 #[serde(default)]
2640 #[ts(optional)]
2641 pub before: Option<bool>,
2642 /// Initial content entries with optional properties
2643 #[serde(default)]
2644 #[ts(optional)]
2645 pub entries: Option<Vec<JsTextPropertyEntry>>,
2646 /// Split role tag. When set to `"utility_dock"`, the dispatcher
2647 /// routes this buffer to the existing dock leaf if one exists,
2648 /// instead of creating a new split. See
2649 /// `docs/internal/tui-editor-layout-design.md` Section 2.
2650 #[serde(default)]
2651 #[ts(optional)]
2652 pub role: Option<String>,
2653}
2654
2655/// Options for createVirtualBufferInExistingSplit
2656#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2657#[serde(deny_unknown_fields)]
2658#[ts(export)]
2659pub struct CreateVirtualBufferInExistingSplitOptions {
2660 /// Buffer name (displayed in tabs/title)
2661 pub name: String,
2662 /// Target split ID (required)
2663 #[serde(rename = "splitId")]
2664 #[ts(rename = "splitId")]
2665 pub split_id: usize,
2666 /// Mode for keybindings (e.g., "git-log", "search-results")
2667 #[serde(default)]
2668 #[ts(optional)]
2669 pub mode: Option<String>,
2670 /// Whether buffer is read-only (default: false)
2671 #[serde(default, rename = "readOnly")]
2672 #[ts(optional, rename = "readOnly")]
2673 pub read_only: Option<bool>,
2674 /// Show line numbers in gutter (default: true)
2675 #[serde(default, rename = "showLineNumbers")]
2676 #[ts(optional, rename = "showLineNumbers")]
2677 pub show_line_numbers: Option<bool>,
2678 /// Show cursor (default: true)
2679 #[serde(default, rename = "showCursors")]
2680 #[ts(optional, rename = "showCursors")]
2681 pub show_cursors: Option<bool>,
2682 /// Disable text editing (default: false)
2683 #[serde(default, rename = "editingDisabled")]
2684 #[ts(optional, rename = "editingDisabled")]
2685 pub editing_disabled: Option<bool>,
2686 /// Enable line wrapping
2687 #[serde(default, rename = "lineWrap")]
2688 #[ts(optional, rename = "lineWrap")]
2689 pub line_wrap: Option<bool>,
2690 /// Initial content entries with optional properties
2691 #[serde(default)]
2692 #[ts(optional)]
2693 pub entries: Option<Vec<JsTextPropertyEntry>>,
2694}
2695
2696/// Options for createTerminal
2697#[derive(Debug, Clone, Serialize, Deserialize, TS)]
2698#[serde(deny_unknown_fields)]
2699#[ts(export)]
2700pub struct CreateTerminalOptions {
2701 /// Working directory for the terminal (defaults to editor cwd)
2702 #[serde(default)]
2703 #[ts(optional)]
2704 pub cwd: Option<String>,
2705 /// Split direction: "horizontal" or "vertical" (default: "vertical")
2706 #[serde(default)]
2707 #[ts(optional)]
2708 pub direction: Option<String>,
2709 /// Split ratio 0.0-1.0 (default: 0.5)
2710 #[serde(default)]
2711 #[ts(optional)]
2712 pub ratio: Option<f32>,
2713 /// Whether to focus the new terminal split (default: true)
2714 #[serde(default)]
2715 #[ts(optional)]
2716 pub focus: Option<bool>,
2717 /// Whether this terminal is part of the user's persisted workspace.
2718 /// Defaults to `false` for plugin-created terminals — they are typically
2719 /// one-off tool UIs (rebuilds, exec shells, build output) and should
2720 /// start with empty scrollback on each invocation. Set to `true` only
2721 /// when the plugin owns a terminal that the user should see restored
2722 /// across editor restarts.
2723 #[serde(default)]
2724 #[ts(optional)]
2725 pub persistent: Option<bool>,
2726}
2727
2728/// Result of getTextPropertiesAtCursor - array of property objects
2729///
2730/// Each element contains the properties from a text property span that overlaps
2731/// with the cursor position. Properties are dynamic key-value pairs set by plugins.
2732#[derive(Debug, Clone, Serialize, TS)]
2733#[ts(export, type = "Array<Record<string, unknown>>")]
2734pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
2735
2736// Implement FromJs for option types using rquickjs_serde
2737#[cfg(feature = "plugins")]
2738mod fromjs_impls {
2739 use super::*;
2740 use rquickjs::{Ctx, FromJs, Value};
2741
2742 // All types that deserialize from a JS value via rquickjs_serde follow
2743 // the same 8-line pattern differing only in the type name. This macro
2744 // expands that pattern once so adding a new plugin-API type costs one line
2745 // here instead of a copy-pasted block.
2746 macro_rules! impl_from_js_via_serde {
2747 ($($T:ty),+ $(,)?) => {
2748 $(
2749 impl<'js> FromJs<'js> for $T {
2750 fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2751 rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2752 from: "object",
2753 to: stringify!($T),
2754 message: Some(e.to_string()),
2755 })
2756 }
2757 }
2758 )+
2759 };
2760 }
2761
2762 impl_from_js_via_serde!(
2763 JsTextPropertyEntry,
2764 CreateVirtualBufferOptions,
2765 CreateVirtualBufferInSplitOptions,
2766 CreateVirtualBufferInExistingSplitOptions,
2767 ActionSpec,
2768 ActionPopupAction,
2769 ActionPopupOptions,
2770 ViewTokenWire,
2771 ViewTokenStyle,
2772 LayoutHints,
2773 CompositeHunk,
2774 LanguagePackConfig,
2775 LspServerPackConfig,
2776 ProcessLimitsPackConfig,
2777 CreateTerminalOptions,
2778 );
2779
2780 impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
2781 fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2782 rquickjs_serde::to_value(ctx.clone(), &self.0)
2783 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2784 }
2785 }
2786
2787 impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
2788 fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
2789 // Two-step deserialization: rquickjs_serde cannot handle the nested
2790 // enums in this struct directly, so go via serde_json as an intermediary.
2791 let json: serde_json::Value =
2792 rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
2793 from: "object",
2794 to: "CreateCompositeBufferOptions (json)",
2795 message: Some(e.to_string()),
2796 })?;
2797 serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
2798 from: "json",
2799 to: "CreateCompositeBufferOptions",
2800 message: Some(e.to_string()),
2801 })
2802 }
2803 }
2804
2805 // ── Tests for FromJs / IntoJs impls ────────────────────────────────────
2806 //
2807 // Each impl is a one-liner that delegates to `rquickjs_serde`. A mutant
2808 // that replaces the body with `Ok(Default::default())` drops the
2809 // decoded payload on the floor. Every test below asserts a
2810 // non-defaultable field value, so the mutant cannot pass.
2811 //
2812 // Note: many of the target structs do not implement `Default`, making
2813 // those mutants unviable (they fail to compile) — cargo-mutants still
2814 // lists them as candidates. The tests below serve double-duty as
2815 // behavioural regression protection for the JS → Rust conversion layer.
2816 #[cfg(test)]
2817 mod tests {
2818 use super::*;
2819 use rquickjs::{Context, Runtime};
2820
2821 /// Run a closure within a fresh QuickJS context so that `FromJs`
2822 /// impls can be exercised end-to-end.
2823 fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
2824 let rt = Runtime::new().expect("create rquickjs runtime");
2825 let ctx = Context::full(&rt).expect("create rquickjs context");
2826 ctx.with(f)
2827 }
2828
2829 /// Evaluate a JS object literal and decode it as `T` via `FromJs`.
2830 fn eval_as<T>(src: &str) -> T
2831 where
2832 for<'js> T: rquickjs::FromJs<'js>,
2833 {
2834 with_js(|ctx| {
2835 let value: Value = ctx
2836 .eval::<Value, _>(src.as_bytes())
2837 .expect("eval JS source");
2838 T::from_js(&ctx, value).expect("from_js decode")
2839 })
2840 }
2841
2842 #[test]
2843 fn js_text_property_entry_decodes_text_and_properties() {
2844 let got: JsTextPropertyEntry =
2845 eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
2846 assert_eq!(got.text, "hello");
2847 let props = got.properties.expect("properties present");
2848 assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
2849 }
2850
2851 #[test]
2852 fn create_virtual_buffer_options_decodes_name() {
2853 let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
2854 assert_eq!(got.name, "logs");
2855 assert_eq!(got.read_only, Some(true));
2856 }
2857
2858 #[test]
2859 fn create_virtual_buffer_in_split_options_decodes_ratio() {
2860 let got: CreateVirtualBufferInSplitOptions =
2861 eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
2862 assert_eq!(got.name, "diag");
2863 assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
2864 assert_eq!(got.direction.as_deref(), Some("horizontal"));
2865 }
2866
2867 #[test]
2868 fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
2869 let got: CreateVirtualBufferInExistingSplitOptions =
2870 eval_as("({name: 'n', splitId: 7})");
2871 assert_eq!(got.name, "n");
2872 assert_eq!(got.split_id, 7);
2873 }
2874
2875 #[test]
2876 fn create_terminal_options_decodes_cwd_and_focus() {
2877 let got: CreateTerminalOptions =
2878 eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
2879 assert_eq!(got.cwd.as_deref(), Some("/tmp"));
2880 assert_eq!(got.direction.as_deref(), Some("vertical"));
2881 assert_eq!(got.focus, Some(false));
2882 }
2883
2884 #[test]
2885 fn action_spec_decodes_action_and_count() {
2886 let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
2887 assert_eq!(got.action, "move_word_right");
2888 assert_eq!(got.count, 5);
2889 }
2890
2891 #[test]
2892 fn action_popup_action_decodes_id_and_label() {
2893 let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
2894 assert_eq!(got.id, "ok");
2895 assert_eq!(got.label, "OK");
2896 }
2897
2898 #[test]
2899 fn action_popup_options_decodes_actions_list() {
2900 let got: ActionPopupOptions = eval_as(
2901 "({id: 'p', title: 't', message: 'm', \
2902 actions: [{id: 'ok', label: 'OK'}]})",
2903 );
2904 assert_eq!(got.id, "p");
2905 assert_eq!(got.title, "t");
2906 assert_eq!(got.message, "m");
2907 assert_eq!(got.actions.len(), 1);
2908 assert_eq!(got.actions[0].id, "ok");
2909 }
2910
2911 #[test]
2912 fn view_token_wire_decodes_offset_and_kind() {
2913 // Using `Newline` (a unit variant) avoids the tuple-variant
2914 // wire-format ambiguity in rquickjs_serde while still exercising
2915 // the `FromJs` impl end-to-end.
2916 let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
2917 assert_eq!(got.source_offset, Some(42));
2918 assert!(matches!(got.kind, ViewTokenWireKind::Newline));
2919 }
2920
2921 #[test]
2922 fn view_token_style_decodes_boolean_flags() {
2923 // `fg`/`bg` are `Option<(u8, u8, u8)>` which rquickjs_serde does
2924 // not decode from plain JS arrays, so we pin down the boolean
2925 // flags — enough to prove the body actually ran.
2926 let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
2927 assert!(got.bold);
2928 assert!(got.italic);
2929 assert!(got.fg.is_none());
2930 }
2931
2932 #[test]
2933 fn layout_hints_decodes_compose_width() {
2934 let got: LayoutHints = eval_as("({composeWidth: 120})");
2935 assert_eq!(got.compose_width, Some(120));
2936 assert!(got.column_guides.is_none());
2937 }
2938
2939 #[test]
2940 fn create_composite_buffer_options_decodes_name_and_sources() {
2941 let got: CreateCompositeBufferOptions = eval_as(
2942 "({name: 'diff', mode: 'm', \
2943 layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
2944 sources: [{bufferId: 3, label: 'OLD'}]})",
2945 );
2946 assert_eq!(got.name, "diff");
2947 assert_eq!(got.layout.layout_type, "side-by-side");
2948 assert_eq!(got.sources.len(), 1);
2949 assert_eq!(got.sources[0].buffer_id, 3);
2950 assert_eq!(got.sources[0].label, "OLD");
2951 }
2952
2953 #[test]
2954 fn composite_hunk_decodes_all_fields() {
2955 let got: CompositeHunk =
2956 eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
2957 assert_eq!(got.old_start, 1);
2958 assert_eq!(got.old_count, 2);
2959 assert_eq!(got.new_start, 3);
2960 assert_eq!(got.new_count, 4);
2961 }
2962
2963 #[test]
2964 fn language_pack_config_decodes_comment_prefix_and_tab_size() {
2965 let got: LanguagePackConfig =
2966 eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
2967 assert_eq!(got.comment_prefix.as_deref(), Some("//"));
2968 assert_eq!(got.tab_size, Some(7));
2969 assert_eq!(got.use_tabs, Some(true));
2970 }
2971
2972 #[test]
2973 fn lsp_server_pack_config_decodes_command_and_args() {
2974 let got: LspServerPackConfig =
2975 eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
2976 assert_eq!(got.command, "rust-analyzer");
2977 assert_eq!(got.args, vec!["--log".to_string()]);
2978 assert_eq!(got.auto_start, Some(true));
2979 }
2980
2981 #[test]
2982 fn process_limits_pack_config_decodes_percentages() {
2983 let got: ProcessLimitsPackConfig =
2984 eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
2985 assert_eq!(got.max_memory_percent, Some(75));
2986 assert_eq!(got.max_cpu_percent, Some(50));
2987 assert_eq!(got.enabled, Some(true));
2988 }
2989
2990 /// `TextPropertiesAtCursor::into_js` must serialise the inner vector
2991 /// into a JS array whose length matches the payload. A mutant that
2992 /// returns a default (`undefined` / empty) value would fail either
2993 /// the array check or the length check.
2994 #[test]
2995 fn text_properties_at_cursor_into_js_preserves_length() {
2996 use rquickjs::IntoJs;
2997 with_js(|ctx| {
2998 let mut entry = std::collections::HashMap::new();
2999 entry.insert("k".to_string(), serde_json::json!("v"));
3000 let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
3001
3002 let v = payload.into_js(&ctx).expect("into_js");
3003 let arr = v.as_array().expect("expected JS array");
3004 assert_eq!(arr.len(), 2);
3005 });
3006 }
3007 }
3008}
3009
3010/// Plugin API context - provides safe access to editor functionality
3011pub struct PluginApi {
3012 /// Hook registry (shared with editor)
3013 hooks: Arc<RwLock<HookRegistry>>,
3014
3015 /// Command registry (shared with editor)
3016 commands: Arc<RwLock<CommandRegistry>>,
3017
3018 /// Command queue for sending commands to editor
3019 command_sender: std::sync::mpsc::Sender<PluginCommand>,
3020
3021 /// Snapshot of editor state (read-only for plugins)
3022 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3023}
3024
3025impl PluginApi {
3026 /// Create a new plugin API context
3027 pub fn new(
3028 hooks: Arc<RwLock<HookRegistry>>,
3029 commands: Arc<RwLock<CommandRegistry>>,
3030 command_sender: std::sync::mpsc::Sender<PluginCommand>,
3031 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
3032 ) -> Self {
3033 Self {
3034 hooks,
3035 commands,
3036 command_sender,
3037 state_snapshot,
3038 }
3039 }
3040
3041 /// Register a hook callback
3042 pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
3043 let mut hooks = self.hooks.write().unwrap();
3044 hooks.add_hook(hook_name, callback);
3045 }
3046
3047 /// Remove all hooks for a specific name
3048 pub fn unregister_hooks(&self, hook_name: &str) {
3049 let mut hooks = self.hooks.write().unwrap();
3050 hooks.remove_hooks(hook_name);
3051 }
3052
3053 /// Register a command
3054 pub fn register_command(&self, command: Command) {
3055 let commands = self.commands.read().unwrap();
3056 commands.register(command);
3057 }
3058
3059 /// Unregister a command by name
3060 pub fn unregister_command(&self, name: &str) {
3061 let commands = self.commands.read().unwrap();
3062 commands.unregister(name);
3063 }
3064
3065 /// Send a command to the editor (async/non-blocking)
3066 pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
3067 self.command_sender
3068 .send(command)
3069 .map_err(|e| format!("Failed to send command: {}", e))
3070 }
3071
3072 /// Insert text at a position in a buffer
3073 pub fn insert_text(
3074 &self,
3075 buffer_id: BufferId,
3076 position: usize,
3077 text: String,
3078 ) -> Result<(), String> {
3079 self.send_command(PluginCommand::InsertText {
3080 buffer_id,
3081 position,
3082 text,
3083 })
3084 }
3085
3086 /// Delete a range of text from a buffer
3087 pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
3088 self.send_command(PluginCommand::DeleteRange { buffer_id, range })
3089 }
3090
3091 /// Add an overlay (decoration) to a buffer
3092 /// Add an overlay to a buffer with styling options
3093 ///
3094 /// Returns an opaque handle that can be used to remove the overlay later.
3095 ///
3096 /// Colors can be specified as RGB arrays or theme key strings.
3097 /// Theme keys are resolved at render time, so overlays update with theme changes.
3098 pub fn add_overlay(
3099 &self,
3100 buffer_id: BufferId,
3101 namespace: Option<String>,
3102 range: Range<usize>,
3103 options: OverlayOptions,
3104 ) -> Result<(), String> {
3105 self.send_command(PluginCommand::AddOverlay {
3106 buffer_id,
3107 namespace: namespace.map(OverlayNamespace::from_string),
3108 range,
3109 options,
3110 })
3111 }
3112
3113 /// Remove an overlay from a buffer by its handle
3114 pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
3115 self.send_command(PluginCommand::RemoveOverlay {
3116 buffer_id,
3117 handle: OverlayHandle::from_string(handle),
3118 })
3119 }
3120
3121 /// Clear all overlays in a namespace from a buffer
3122 pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
3123 self.send_command(PluginCommand::ClearNamespace {
3124 buffer_id,
3125 namespace: OverlayNamespace::from_string(namespace),
3126 })
3127 }
3128
3129 /// Clear all overlays that overlap with a byte range
3130 /// Used for targeted invalidation when content changes
3131 pub fn clear_overlays_in_range(
3132 &self,
3133 buffer_id: BufferId,
3134 start: usize,
3135 end: usize,
3136 ) -> Result<(), String> {
3137 self.send_command(PluginCommand::ClearOverlaysInRange {
3138 buffer_id,
3139 start,
3140 end,
3141 })
3142 }
3143
3144 /// Set the status message
3145 pub fn set_status(&self, message: String) -> Result<(), String> {
3146 self.send_command(PluginCommand::SetStatus { message })
3147 }
3148
3149 /// Open a file at a specific line and column (1-indexed)
3150 /// This is useful for jumping to locations from git grep, LSP definitions, etc.
3151 pub fn open_file_at_location(
3152 &self,
3153 path: PathBuf,
3154 line: Option<usize>,
3155 column: Option<usize>,
3156 ) -> Result<(), String> {
3157 self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
3158 }
3159
3160 /// Open a file in a specific split at a line and column
3161 ///
3162 /// Similar to open_file_at_location but targets a specific split pane.
3163 /// The split_id is the ID of the split pane to open the file in.
3164 pub fn open_file_in_split(
3165 &self,
3166 split_id: usize,
3167 path: PathBuf,
3168 line: Option<usize>,
3169 column: Option<usize>,
3170 ) -> Result<(), String> {
3171 self.send_command(PluginCommand::OpenFileInSplit {
3172 split_id,
3173 path,
3174 line,
3175 column,
3176 })
3177 }
3178
3179 /// Start a prompt (minibuffer) with a custom type identifier
3180 /// The prompt_type is used to filter hooks in plugin code
3181 pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
3182 self.send_command(PluginCommand::StartPrompt {
3183 label,
3184 prompt_type,
3185 floating_overlay: false,
3186 })
3187 }
3188
3189 /// Set the suggestions for the current prompt
3190 /// This updates the prompt's autocomplete/selection list
3191 pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
3192 self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
3193 }
3194
3195 /// Enable/disable syncing prompt input text when navigating suggestions
3196 pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
3197 self.send_command(PluginCommand::SetPromptInputSync { sync })
3198 }
3199
3200 /// Set the floating-overlay prompt's title (issue #1796). `None`
3201 /// clears the title and falls back to the prompt-type default.
3202 pub fn set_prompt_title(&self, title: Option<String>) -> Result<(), String> {
3203 self.send_command(PluginCommand::SetPromptTitle { title })
3204 }
3205
3206 /// Add a menu item to an existing menu
3207 pub fn add_menu_item(
3208 &self,
3209 menu_label: String,
3210 item: MenuItem,
3211 position: MenuPosition,
3212 ) -> Result<(), String> {
3213 self.send_command(PluginCommand::AddMenuItem {
3214 menu_label,
3215 item,
3216 position,
3217 })
3218 }
3219
3220 /// Add a new top-level menu
3221 pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
3222 self.send_command(PluginCommand::AddMenu { menu, position })
3223 }
3224
3225 /// Remove a menu item from a menu
3226 pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
3227 self.send_command(PluginCommand::RemoveMenuItem {
3228 menu_label,
3229 item_label,
3230 })
3231 }
3232
3233 /// Remove a top-level menu
3234 pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
3235 self.send_command(PluginCommand::RemoveMenu { menu_label })
3236 }
3237
3238 // === Virtual Buffer Methods ===
3239
3240 /// Create a new virtual buffer (not backed by a file)
3241 ///
3242 /// Virtual buffers are used for special displays like diagnostic lists,
3243 /// search results, etc. They have their own mode for keybindings.
3244 pub fn create_virtual_buffer(
3245 &self,
3246 name: String,
3247 mode: String,
3248 read_only: bool,
3249 ) -> Result<(), String> {
3250 self.send_command(PluginCommand::CreateVirtualBuffer {
3251 name,
3252 mode,
3253 read_only,
3254 })
3255 }
3256
3257 /// Create a virtual buffer and set its content in one operation
3258 ///
3259 /// This is the preferred way to create virtual buffers since it doesn't
3260 /// require tracking the buffer ID. The buffer is created and populated
3261 /// atomically.
3262 pub fn create_virtual_buffer_with_content(
3263 &self,
3264 name: String,
3265 mode: String,
3266 read_only: bool,
3267 entries: Vec<TextPropertyEntry>,
3268 ) -> Result<(), String> {
3269 self.send_command(PluginCommand::CreateVirtualBufferWithContent {
3270 name,
3271 mode,
3272 read_only,
3273 entries,
3274 show_line_numbers: true,
3275 show_cursors: true,
3276 editing_disabled: false,
3277 hidden_from_tabs: false,
3278 request_id: None,
3279 })
3280 }
3281
3282 /// Set the content of a virtual buffer with text properties
3283 ///
3284 /// Each entry contains text and metadata properties (e.g., source location).
3285 pub fn set_virtual_buffer_content(
3286 &self,
3287 buffer_id: BufferId,
3288 entries: Vec<TextPropertyEntry>,
3289 ) -> Result<(), String> {
3290 self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
3291 }
3292
3293 /// Get text properties at cursor position in a buffer
3294 ///
3295 /// This triggers a command that will make properties available to plugins.
3296 pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
3297 self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
3298 }
3299
3300 /// Define a buffer mode with keybindings
3301 ///
3302 /// Bindings are specified as (key_string, command_name) pairs.
3303 pub fn define_mode(
3304 &self,
3305 name: String,
3306 bindings: Vec<(String, String)>,
3307 read_only: bool,
3308 allow_text_input: bool,
3309 ) -> Result<(), String> {
3310 self.send_command(PluginCommand::DefineMode {
3311 name,
3312 bindings,
3313 read_only,
3314 allow_text_input,
3315 inherit_normal_bindings: false,
3316 plugin_name: None,
3317 })
3318 }
3319
3320 /// Switch the current split to display a buffer
3321 pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
3322 self.send_command(PluginCommand::ShowBuffer { buffer_id })
3323 }
3324
3325 /// Set the scroll position of a specific split
3326 pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
3327 self.send_command(PluginCommand::SetSplitScroll {
3328 split_id: SplitId(split_id),
3329 top_byte,
3330 })
3331 }
3332
3333 /// Request syntax highlights for a buffer range
3334 pub fn get_highlights(
3335 &self,
3336 buffer_id: BufferId,
3337 range: Range<usize>,
3338 request_id: u64,
3339 ) -> Result<(), String> {
3340 self.send_command(PluginCommand::RequestHighlights {
3341 buffer_id,
3342 range,
3343 request_id,
3344 })
3345 }
3346
3347 // === Query Methods ===
3348
3349 /// Get the currently active buffer ID
3350 pub fn get_active_buffer_id(&self) -> BufferId {
3351 let snapshot = self.state_snapshot.read().unwrap();
3352 snapshot.active_buffer_id
3353 }
3354
3355 /// Get the currently active split ID
3356 pub fn get_active_split_id(&self) -> usize {
3357 let snapshot = self.state_snapshot.read().unwrap();
3358 snapshot.active_split_id
3359 }
3360
3361 /// Get information about a specific buffer
3362 pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
3363 let snapshot = self.state_snapshot.read().unwrap();
3364 snapshot.buffers.get(&buffer_id).cloned()
3365 }
3366
3367 /// Get all buffer IDs
3368 pub fn list_buffers(&self) -> Vec<BufferInfo> {
3369 let snapshot = self.state_snapshot.read().unwrap();
3370 snapshot.buffers.values().cloned().collect()
3371 }
3372
3373 /// Get primary cursor information for the active buffer
3374 pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
3375 let snapshot = self.state_snapshot.read().unwrap();
3376 snapshot.primary_cursor.clone()
3377 }
3378
3379 /// Get all cursor information for the active buffer
3380 pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
3381 let snapshot = self.state_snapshot.read().unwrap();
3382 snapshot.all_cursors.clone()
3383 }
3384
3385 /// Get viewport information for the active buffer
3386 pub fn get_viewport(&self) -> Option<ViewportInfo> {
3387 let snapshot = self.state_snapshot.read().unwrap();
3388 snapshot.viewport.clone()
3389 }
3390
3391 /// Get access to the state snapshot Arc (for internal use)
3392 pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
3393 Arc::clone(&self.state_snapshot)
3394 }
3395}
3396
3397impl Clone for PluginApi {
3398 fn clone(&self) -> Self {
3399 Self {
3400 hooks: Arc::clone(&self.hooks),
3401 commands: Arc::clone(&self.commands),
3402 command_sender: self.command_sender.clone(),
3403 state_snapshot: Arc::clone(&self.state_snapshot),
3404 }
3405 }
3406}
3407
3408// ============================================================================
3409// Pluggable Completion Service — TypeScript Plugin API Types
3410// ============================================================================
3411//
3412// These types are the bridge between the Rust `CompletionService` and
3413// TypeScript plugins that want to provide completion candidates. They are
3414// serialised to/from JSON via serde and generate TypeScript definitions via
3415// ts-rs so that the plugin API stays in sync automatically.
3416
3417/// A completion candidate produced by a TypeScript plugin provider.
3418///
3419/// This mirrors `CompletionCandidate` in the Rust `completion::provider`
3420/// module but uses serde-friendly primitives for the JS ↔ Rust boundary.
3421#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3422#[serde(rename_all = "camelCase", deny_unknown_fields)]
3423#[ts(export, rename_all = "camelCase")]
3424pub struct TsCompletionCandidate {
3425 /// Display text shown in the completion popup.
3426 pub label: String,
3427
3428 /// Text to insert when accepted. Falls back to `label` if omitted.
3429 #[serde(skip_serializing_if = "Option::is_none")]
3430 pub insert_text: Option<String>,
3431
3432 /// Short detail string shown next to the label.
3433 #[serde(skip_serializing_if = "Option::is_none")]
3434 pub detail: Option<String>,
3435
3436 /// Single-character icon hint (e.g. `"λ"`, `"v"`).
3437 #[serde(skip_serializing_if = "Option::is_none")]
3438 pub icon: Option<String>,
3439
3440 /// Provider-assigned relevance score (higher = better).
3441 #[serde(default)]
3442 pub score: i64,
3443
3444 /// Whether `insert_text` uses LSP snippet syntax (`$0`, `${1:ph}`, …).
3445 #[serde(default)]
3446 pub is_snippet: bool,
3447
3448 /// Opaque data carried through to the `completionAccepted` hook.
3449 #[serde(skip_serializing_if = "Option::is_none")]
3450 pub provider_data: Option<String>,
3451}
3452
3453/// Context sent to a TypeScript plugin's `provideCompletions` handler.
3454///
3455/// Plugins receive this as a read-only snapshot so they never need direct
3456/// buffer access (which would be unsafe for huge files).
3457#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3458#[serde(rename_all = "camelCase")]
3459#[ts(export, rename_all = "camelCase")]
3460pub struct TsCompletionContext {
3461 /// The word prefix typed so far.
3462 pub prefix: String,
3463
3464 /// Byte offset of the cursor.
3465 pub cursor_byte: usize,
3466
3467 /// Byte offset of the word start (for replacement range).
3468 pub word_start_byte: usize,
3469
3470 /// Total buffer size in bytes.
3471 pub buffer_len: usize,
3472
3473 /// Whether the buffer is a lazily-loaded huge file.
3474 pub is_large_file: bool,
3475
3476 /// A text excerpt around the cursor (the contents of the safe scan window).
3477 /// Plugins should search only this string, not request the full buffer.
3478 pub text_around_cursor: String,
3479
3480 /// Byte offset within `text_around_cursor` that corresponds to the cursor.
3481 pub cursor_offset_in_text: usize,
3482
3483 /// File language id (e.g. `"rust"`, `"typescript"`), if known.
3484 #[serde(skip_serializing_if = "Option::is_none")]
3485 pub language_id: Option<String>,
3486}
3487
3488/// Registration payload sent by a plugin to register a completion provider.
3489#[derive(Debug, Clone, Serialize, Deserialize, TS)]
3490#[serde(rename_all = "camelCase", deny_unknown_fields)]
3491#[ts(export, rename_all = "camelCase")]
3492pub struct TsCompletionProviderRegistration {
3493 /// Unique id for this provider (e.g., `"my-snippets"`).
3494 pub id: String,
3495
3496 /// Human-readable name shown in status/debug UI.
3497 pub display_name: String,
3498
3499 /// Priority tier (lower = higher priority). Convention:
3500 /// 0 = LSP, 10 = ctags, 20 = buffer words, 30 = dabbrev, 50 = plugin.
3501 #[serde(default = "default_plugin_provider_priority")]
3502 pub priority: u32,
3503
3504 /// Optional list of language ids this provider is active for.
3505 /// If empty/omitted, the provider is active for all languages.
3506 #[serde(default)]
3507 pub language_ids: Vec<String>,
3508}
3509
3510fn default_plugin_provider_priority() -> u32 {
3511 50
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516 use super::*;
3517
3518 #[test]
3519 fn test_plugin_api_creation() {
3520 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3521 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3522 let (tx, _rx) = std::sync::mpsc::channel();
3523 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3524
3525 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3526
3527 // Should not panic
3528 let _clone = api.clone();
3529 }
3530
3531 #[test]
3532 fn test_register_hook() {
3533 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3534 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3535 let (tx, _rx) = std::sync::mpsc::channel();
3536 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3537
3538 let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
3539
3540 api.register_hook("test-hook", Box::new(|_| true));
3541
3542 let hook_registry = hooks.read().unwrap();
3543 assert_eq!(hook_registry.hook_count("test-hook"), 1);
3544 }
3545
3546 #[test]
3547 fn test_send_command() {
3548 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3549 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3550 let (tx, rx) = std::sync::mpsc::channel();
3551 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3552
3553 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3554
3555 let result = api.insert_text(BufferId(1), 0, "test".to_string());
3556 assert!(result.is_ok());
3557
3558 // Verify command was sent
3559 let received = rx.try_recv();
3560 assert!(received.is_ok());
3561
3562 match received.unwrap() {
3563 PluginCommand::InsertText {
3564 buffer_id,
3565 position,
3566 text,
3567 } => {
3568 assert_eq!(buffer_id.0, 1);
3569 assert_eq!(position, 0);
3570 assert_eq!(text, "test");
3571 }
3572 _ => panic!("Wrong command type"),
3573 }
3574 }
3575
3576 #[test]
3577 fn test_add_overlay_command() {
3578 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3579 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3580 let (tx, rx) = std::sync::mpsc::channel();
3581 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3582
3583 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3584
3585 let result = api.add_overlay(
3586 BufferId(1),
3587 Some("test-overlay".to_string()),
3588 0..10,
3589 OverlayOptions {
3590 fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
3591 bg: None,
3592 underline: true,
3593 bold: false,
3594 italic: false,
3595 strikethrough: false,
3596 extend_to_line_end: false,
3597 url: None,
3598 },
3599 );
3600 assert!(result.is_ok());
3601
3602 let received = rx.try_recv().unwrap();
3603 match received {
3604 PluginCommand::AddOverlay {
3605 buffer_id,
3606 namespace,
3607 range,
3608 options,
3609 } => {
3610 assert_eq!(buffer_id.0, 1);
3611 assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
3612 assert_eq!(range, 0..10);
3613 assert!(matches!(
3614 options.fg,
3615 Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
3616 ));
3617 assert!(options.bg.is_none());
3618 assert!(options.underline);
3619 assert!(!options.bold);
3620 assert!(!options.italic);
3621 assert!(!options.extend_to_line_end);
3622 }
3623 _ => panic!("Wrong command type"),
3624 }
3625 }
3626
3627 #[test]
3628 fn test_set_status_command() {
3629 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3630 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3631 let (tx, rx) = std::sync::mpsc::channel();
3632 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3633
3634 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3635
3636 let result = api.set_status("Test status".to_string());
3637 assert!(result.is_ok());
3638
3639 let received = rx.try_recv().unwrap();
3640 match received {
3641 PluginCommand::SetStatus { message } => {
3642 assert_eq!(message, "Test status");
3643 }
3644 _ => panic!("Wrong command type"),
3645 }
3646 }
3647
3648 #[test]
3649 fn test_get_active_buffer_id() {
3650 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3651 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3652 let (tx, _rx) = std::sync::mpsc::channel();
3653 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3654
3655 // Set active buffer to 5
3656 {
3657 let mut snapshot = state_snapshot.write().unwrap();
3658 snapshot.active_buffer_id = BufferId(5);
3659 }
3660
3661 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3662
3663 let active_id = api.get_active_buffer_id();
3664 assert_eq!(active_id.0, 5);
3665 }
3666
3667 #[test]
3668 fn test_get_buffer_info() {
3669 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3670 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3671 let (tx, _rx) = std::sync::mpsc::channel();
3672 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3673
3674 // Add buffer info
3675 {
3676 let mut snapshot = state_snapshot.write().unwrap();
3677 let buffer_info = BufferInfo {
3678 id: BufferId(1),
3679 path: Some(std::path::PathBuf::from("/test/file.txt")),
3680 modified: true,
3681 length: 100,
3682 is_virtual: false,
3683 view_mode: "source".to_string(),
3684 is_composing_in_any_split: false,
3685 compose_width: None,
3686 language: "text".to_string(),
3687 is_preview: false,
3688 splits: Vec::new(),
3689 };
3690 snapshot.buffers.insert(BufferId(1), buffer_info);
3691 }
3692
3693 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3694
3695 let info = api.get_buffer_info(BufferId(1));
3696 assert!(info.is_some());
3697 let info = info.unwrap();
3698 assert_eq!(info.id.0, 1);
3699 assert_eq!(
3700 info.path.as_ref().unwrap().to_str().unwrap(),
3701 "/test/file.txt"
3702 );
3703 assert!(info.modified);
3704 assert_eq!(info.length, 100);
3705
3706 // Non-existent buffer
3707 let no_info = api.get_buffer_info(BufferId(999));
3708 assert!(no_info.is_none());
3709 }
3710
3711 #[test]
3712 fn test_list_buffers() {
3713 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3714 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3715 let (tx, _rx) = std::sync::mpsc::channel();
3716 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3717
3718 // Add multiple buffers
3719 {
3720 let mut snapshot = state_snapshot.write().unwrap();
3721 snapshot.buffers.insert(
3722 BufferId(1),
3723 BufferInfo {
3724 id: BufferId(1),
3725 path: Some(std::path::PathBuf::from("/file1.txt")),
3726 modified: false,
3727 length: 50,
3728 is_virtual: false,
3729 view_mode: "source".to_string(),
3730 is_composing_in_any_split: false,
3731 compose_width: None,
3732 language: "text".to_string(),
3733 is_preview: false,
3734 splits: Vec::new(),
3735 },
3736 );
3737 snapshot.buffers.insert(
3738 BufferId(2),
3739 BufferInfo {
3740 id: BufferId(2),
3741 path: Some(std::path::PathBuf::from("/file2.txt")),
3742 modified: true,
3743 length: 100,
3744 is_virtual: false,
3745 view_mode: "source".to_string(),
3746 is_composing_in_any_split: false,
3747 compose_width: None,
3748 language: "text".to_string(),
3749 is_preview: false,
3750 splits: Vec::new(),
3751 },
3752 );
3753 snapshot.buffers.insert(
3754 BufferId(3),
3755 BufferInfo {
3756 id: BufferId(3),
3757 path: None,
3758 modified: false,
3759 length: 0,
3760 is_virtual: true,
3761 view_mode: "source".to_string(),
3762 is_composing_in_any_split: false,
3763 compose_width: None,
3764 language: "text".to_string(),
3765 is_preview: false,
3766 splits: Vec::new(),
3767 },
3768 );
3769 }
3770
3771 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3772
3773 let buffers = api.list_buffers();
3774 assert_eq!(buffers.len(), 3);
3775
3776 // Verify all buffers are present
3777 assert!(buffers.iter().any(|b| b.id.0 == 1));
3778 assert!(buffers.iter().any(|b| b.id.0 == 2));
3779 assert!(buffers.iter().any(|b| b.id.0 == 3));
3780 }
3781
3782 #[test]
3783 fn test_get_primary_cursor() {
3784 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3785 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3786 let (tx, _rx) = std::sync::mpsc::channel();
3787 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3788
3789 // Add cursor info
3790 {
3791 let mut snapshot = state_snapshot.write().unwrap();
3792 snapshot.primary_cursor = Some(CursorInfo {
3793 position: 42,
3794 selection: Some(10..42),
3795 });
3796 }
3797
3798 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3799
3800 let cursor = api.get_primary_cursor();
3801 assert!(cursor.is_some());
3802 let cursor = cursor.unwrap();
3803 assert_eq!(cursor.position, 42);
3804 assert_eq!(cursor.selection, Some(10..42));
3805 }
3806
3807 #[test]
3808 fn test_get_all_cursors() {
3809 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3810 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3811 let (tx, _rx) = std::sync::mpsc::channel();
3812 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3813
3814 // Add multiple cursors
3815 {
3816 let mut snapshot = state_snapshot.write().unwrap();
3817 snapshot.all_cursors = vec![
3818 CursorInfo {
3819 position: 10,
3820 selection: None,
3821 },
3822 CursorInfo {
3823 position: 20,
3824 selection: Some(15..20),
3825 },
3826 CursorInfo {
3827 position: 30,
3828 selection: Some(25..30),
3829 },
3830 ];
3831 }
3832
3833 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3834
3835 let cursors = api.get_all_cursors();
3836 assert_eq!(cursors.len(), 3);
3837 assert_eq!(cursors[0].position, 10);
3838 assert_eq!(cursors[0].selection, None);
3839 assert_eq!(cursors[1].position, 20);
3840 assert_eq!(cursors[1].selection, Some(15..20));
3841 assert_eq!(cursors[2].position, 30);
3842 assert_eq!(cursors[2].selection, Some(25..30));
3843 }
3844
3845 #[test]
3846 fn test_get_viewport() {
3847 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
3848 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
3849 let (tx, _rx) = std::sync::mpsc::channel();
3850 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
3851
3852 // Add viewport info
3853 {
3854 let mut snapshot = state_snapshot.write().unwrap();
3855 snapshot.viewport = Some(ViewportInfo {
3856 top_byte: 100,
3857 top_line: Some(5),
3858 left_column: 5,
3859 width: 80,
3860 height: 24,
3861 });
3862 }
3863
3864 let api = PluginApi::new(hooks, commands, tx, state_snapshot);
3865
3866 let viewport = api.get_viewport();
3867 assert!(viewport.is_some());
3868 let viewport = viewport.unwrap();
3869 assert_eq!(viewport.top_byte, 100);
3870 assert_eq!(viewport.left_column, 5);
3871 assert_eq!(viewport.width, 80);
3872 assert_eq!(viewport.height, 24);
3873 }
3874
3875 #[test]
3876 fn test_composite_buffer_options_rejects_unknown_fields() {
3877 // Valid JSON with correct field names
3878 let valid_json = r#"{
3879 "name": "test",
3880 "mode": "diff",
3881 "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3882 "sources": [{"bufferId": 1, "label": "old"}]
3883 }"#;
3884 let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
3885 assert!(
3886 result.is_ok(),
3887 "Valid JSON should parse: {:?}",
3888 result.err()
3889 );
3890
3891 // Invalid JSON with unknown field (buffer_id instead of bufferId)
3892 let invalid_json = r#"{
3893 "name": "test",
3894 "mode": "diff",
3895 "layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
3896 "sources": [{"buffer_id": 1, "label": "old"}]
3897 }"#;
3898 let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
3899 assert!(
3900 result.is_err(),
3901 "JSON with unknown field should fail to parse"
3902 );
3903 let err = result.unwrap_err().to_string();
3904 assert!(
3905 err.contains("unknown field") || err.contains("buffer_id"),
3906 "Error should mention unknown field: {}",
3907 err
3908 );
3909 }
3910
3911 #[test]
3912 fn test_composite_hunk_rejects_unknown_fields() {
3913 // Valid JSON with correct field names
3914 let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3915 let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
3916 assert!(
3917 result.is_ok(),
3918 "Valid JSON should parse: {:?}",
3919 result.err()
3920 );
3921
3922 // Invalid JSON with unknown field (old_start instead of oldStart)
3923 let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
3924 let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
3925 assert!(
3926 result.is_err(),
3927 "JSON with unknown field should fail to parse"
3928 );
3929 let err = result.unwrap_err().to_string();
3930 assert!(
3931 err.contains("unknown field") || err.contains("old_start"),
3932 "Error should mention unknown field: {}",
3933 err
3934 );
3935 }
3936
3937 #[test]
3938 fn test_plugin_response_line_end_position() {
3939 let response = PluginResponse::LineEndPosition {
3940 request_id: 42,
3941 position: Some(100),
3942 };
3943 let json = serde_json::to_string(&response).unwrap();
3944 assert!(json.contains("LineEndPosition"));
3945 assert!(json.contains("42"));
3946 assert!(json.contains("100"));
3947
3948 // Test None case
3949 let response_none = PluginResponse::LineEndPosition {
3950 request_id: 1,
3951 position: None,
3952 };
3953 let json_none = serde_json::to_string(&response_none).unwrap();
3954 assert!(json_none.contains("null"));
3955 }
3956
3957 #[test]
3958 fn test_plugin_response_buffer_line_count() {
3959 let response = PluginResponse::BufferLineCount {
3960 request_id: 99,
3961 count: Some(500),
3962 };
3963 let json = serde_json::to_string(&response).unwrap();
3964 assert!(json.contains("BufferLineCount"));
3965 assert!(json.contains("99"));
3966 assert!(json.contains("500"));
3967 }
3968
3969 #[test]
3970 fn test_plugin_command_get_line_end_position() {
3971 let command = PluginCommand::GetLineEndPosition {
3972 buffer_id: BufferId(1),
3973 line: 10,
3974 request_id: 123,
3975 };
3976 let json = serde_json::to_string(&command).unwrap();
3977 assert!(json.contains("GetLineEndPosition"));
3978 assert!(json.contains("10"));
3979 }
3980
3981 #[test]
3982 fn test_plugin_command_get_buffer_line_count() {
3983 let command = PluginCommand::GetBufferLineCount {
3984 buffer_id: BufferId(0),
3985 request_id: 456,
3986 };
3987 let json = serde_json::to_string(&command).unwrap();
3988 assert!(json.contains("GetBufferLineCount"));
3989 assert!(json.contains("456"));
3990 }
3991
3992 #[test]
3993 fn test_plugin_command_scroll_to_line_center() {
3994 let command = PluginCommand::ScrollToLineCenter {
3995 split_id: SplitId(1),
3996 buffer_id: BufferId(2),
3997 line: 50,
3998 };
3999 let json = serde_json::to_string(&command).unwrap();
4000 assert!(json.contains("ScrollToLineCenter"));
4001 assert!(json.contains("50"));
4002 }
4003
4004 /// `JsCallbackId` round-trips through `u64` via `new` / `as_u64` / `From`
4005 /// and renders as its underlying integer via `Display`.
4006 #[test]
4007 fn js_callback_id_conversions_and_display() {
4008 for raw in [0u64, 1, 42, u64::MAX] {
4009 let id = JsCallbackId::new(raw);
4010 assert_eq!(id.as_u64(), raw);
4011 assert_eq!(u64::from(id), raw);
4012 assert_eq!(JsCallbackId::from(raw), id);
4013 assert_eq!(id.to_string(), raw.to_string());
4014 }
4015 }
4016
4017 /// Serde `default = ...` helpers fire when the field is omitted and are
4018 /// overridden by explicit values. One test per struct pins each helper
4019 /// to its documented default.
4020 #[test]
4021 fn serde_defaults_fire_when_fields_are_omitted() {
4022 // default_action_count → 1
4023 let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
4024 assert_eq!(spec.count, 1);
4025 let spec: ActionSpec =
4026 serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
4027 assert_eq!(spec.count, 5);
4028
4029 // default_true → showSeparator = true
4030 let layout: CompositeLayoutConfig =
4031 serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
4032 assert!(layout.show_separator);
4033 let layout: CompositeLayoutConfig =
4034 serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
4035 assert!(!layout.show_separator);
4036
4037 // default_plugin_provider_priority → 50
4038 let reg: TsCompletionProviderRegistration =
4039 serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
4040 assert_eq!(reg.priority, 50);
4041 let reg: TsCompletionProviderRegistration =
4042 serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
4043 assert_eq!(reg.priority, 3);
4044 }
4045
4046 // ── Behavioural tests added to kill the mutants reported by cargo-mutants ──
4047 //
4048 // These tests pin down observable behaviour for tiny methods whose bodies
4049 // were replaceable with a constant (e.g. `()`, `Ok(())`, `None`, or a
4050 // default value) without any existing test noticing.
4051
4052 /// Helper: build a minimal `Command` with a given name.
4053 fn mk_cmd(name: &str) -> Command {
4054 Command {
4055 name: name.to_string(),
4056 description: String::new(),
4057 action_name: String::new(),
4058 plugin_name: String::new(),
4059 custom_contexts: Vec::new(),
4060 }
4061 }
4062
4063 /// `CommandRegistry::register` appends new commands and replaces any
4064 /// existing entry with the same name; `unregister` removes exactly the
4065 /// matching entry and is a no-op for unknown names.
4066 ///
4067 /// Kills: replace register with `()`; `!= → ==` in register;
4068 /// replace unregister with `()`; `!= → ==` in unregister.
4069 #[test]
4070 fn command_registry_register_and_unregister_semantics() {
4071 let r = CommandRegistry::new();
4072
4073 r.register(mk_cmd("a"));
4074 r.register(mk_cmd("b"));
4075 assert_eq!(r.commands.read().unwrap().len(), 2);
4076
4077 // Re-registering "a" must keep "b" (retain filters by `!=`); the
4078 // `== → !=` mutant would drop "b" and leave two copies of "a".
4079 r.register(mk_cmd("a"));
4080 let names: Vec<String> = r
4081 .commands
4082 .read()
4083 .unwrap()
4084 .iter()
4085 .map(|c| c.name.clone())
4086 .collect();
4087 assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
4088
4089 // Unregister must remove exactly "a" and preserve "b"; the `== → !=`
4090 // mutant would keep "a" and drop "b".
4091 r.unregister("a");
4092 let names: Vec<String> = r
4093 .commands
4094 .read()
4095 .unwrap()
4096 .iter()
4097 .map(|c| c.name.clone())
4098 .collect();
4099 assert_eq!(names, vec!["b".to_string()]);
4100
4101 // Unregistering an unknown name is a no-op.
4102 r.unregister("nope");
4103 assert_eq!(r.commands.read().unwrap().len(), 1);
4104 }
4105
4106 /// `OverlayColorSpec::as_rgb` returns the exact stored tuple for the RGB
4107 /// variant and `None` for the theme-key variant; `as_theme_key` is the
4108 /// dual. Uses a triple with no zero or one components and a theme key
4109 /// that is neither empty nor `"xyzzy"` to kill every constant-return
4110 /// mutant reported by cargo-mutants at once.
4111 #[test]
4112 fn overlay_color_spec_accessors_are_variant_specific() {
4113 let rgb = OverlayColorSpec::rgb(12, 34, 56);
4114 assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
4115 assert_eq!(rgb.as_theme_key(), None);
4116
4117 let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
4118 assert_eq!(tk.as_rgb(), None);
4119 assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
4120 }
4121
4122 /// `PluginCommand::debug_variant_name` returns the actual variant name
4123 /// derived from the `Debug` impl, not an empty or hard-coded string.
4124 #[test]
4125 fn plugin_command_debug_variant_name_returns_real_variant() {
4126 let c = PluginCommand::SetStatus {
4127 message: "hi".into(),
4128 };
4129 assert_eq!(c.debug_variant_name(), "SetStatus");
4130
4131 let c2 = PluginCommand::InsertText {
4132 buffer_id: BufferId(1),
4133 position: 0,
4134 text: String::new(),
4135 };
4136 assert_eq!(c2.debug_variant_name(), "InsertText");
4137 }
4138
4139 // ── PluginApi dispatch / mutation tests ────────────────────────────────
4140 //
4141 // Each `PluginApi` method is a one-liner that either pushes a
4142 // `PluginCommand` onto the channel or mutates a shared registry. The
4143 // mutants replace the body with `Ok(())` / `()`, i.e. the side effect
4144 // disappears. One assertion per method ties the side effect down.
4145
4146 fn mk_api() -> (
4147 PluginApi,
4148 std::sync::mpsc::Receiver<PluginCommand>,
4149 Arc<RwLock<HookRegistry>>,
4150 Arc<RwLock<CommandRegistry>>,
4151 Arc<RwLock<EditorStateSnapshot>>,
4152 ) {
4153 let hooks = Arc::new(RwLock::new(HookRegistry::new()));
4154 let commands = Arc::new(RwLock::new(CommandRegistry::new()));
4155 let (tx, rx) = std::sync::mpsc::channel();
4156 let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
4157 let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
4158 (api, rx, hooks, commands, snap)
4159 }
4160
4161 /// `unregister_hooks` must actually clear hooks registered under the
4162 /// same name; replacing the body with `()` leaves the count at 1.
4163 #[test]
4164 fn plugin_api_unregister_hooks_clears_registry() {
4165 let (api, _rx, hooks, _cmds, _snap) = mk_api();
4166 api.register_hook("h", Box::new(|_| true));
4167 assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
4168 api.unregister_hooks("h");
4169 assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
4170 }
4171
4172 /// `register_command` / `unregister_command` must actually write through
4173 /// to the shared `CommandRegistry`.
4174 #[test]
4175 fn plugin_api_register_and_unregister_command_write_through() {
4176 let (api, _rx, _hooks, cmds, _snap) = mk_api();
4177
4178 api.register_command(mk_cmd("x"));
4179 assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
4180
4181 api.unregister_command("x");
4182 assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
4183 }
4184
4185 /// Macro: assert that calling `$call` on a fresh `PluginApi` produces
4186 /// exactly one `PluginCommand` matching `$pattern` with the additional
4187 /// invariants in `$guard`.
4188 macro_rules! assert_dispatches {
4189 ($call:expr, $pattern:pat $(if $guard:expr)?) => {{
4190 let (api, rx, _h, _c, _s) = mk_api();
4191 let _ = $call(&api);
4192 match rx.try_recv().expect("no command sent") {
4193 $pattern $(if $guard)? => {}
4194 other => panic!("unexpected command variant: {:?}", other),
4195 }
4196 }};
4197 }
4198
4199 /// Every simple `send_command`-based method on `PluginApi` translates
4200 /// its arguments into the documented `PluginCommand` variant with the
4201 /// expected fields.
4202 #[test]
4203 fn plugin_api_send_command_methods_dispatch_correctly() {
4204 // delete_range
4205 assert_dispatches!(
4206 |a: &PluginApi| a.delete_range(BufferId(7), 3..9),
4207 PluginCommand::DeleteRange { buffer_id, range }
4208 if buffer_id == BufferId(7) && range == (3..9)
4209 );
4210
4211 // remove_overlay
4212 assert_dispatches!(
4213 |a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
4214 PluginCommand::RemoveOverlay { buffer_id, handle }
4215 if buffer_id == BufferId(2) && handle.as_str() == "h-1"
4216 );
4217
4218 // clear_namespace
4219 assert_dispatches!(
4220 |a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
4221 PluginCommand::ClearNamespace { buffer_id, namespace }
4222 if buffer_id == BufferId(3) && namespace.as_str() == "diag"
4223 );
4224
4225 // clear_overlays_in_range
4226 assert_dispatches!(
4227 |a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
4228 PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
4229 if buffer_id == BufferId(4) && start == 10 && end == 20
4230 );
4231
4232 // open_file_at_location
4233 assert_dispatches!(
4234 |a: &PluginApi| a.open_file_at_location(
4235 PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
4236 ),
4237 PluginCommand::OpenFileAtLocation { path, line, column }
4238 if path == PathBuf::from("/tmp/x.rs")
4239 && line == Some(4)
4240 && column == Some(8)
4241 );
4242
4243 // open_file_in_split
4244 assert_dispatches!(
4245 |a: &PluginApi| a.open_file_in_split(
4246 2, PathBuf::from("/tmp/y.rs"), Some(5), None
4247 ),
4248 PluginCommand::OpenFileInSplit { split_id, path, line, column }
4249 if split_id == 2
4250 && path == PathBuf::from("/tmp/y.rs")
4251 && line == Some(5)
4252 && column.is_none()
4253 );
4254
4255 // start_prompt
4256 assert_dispatches!(
4257 |a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
4258 PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
4259 if label == "label" && prompt_type == "cmd" && !floating_overlay
4260 );
4261
4262 // set_prompt_suggestions
4263 assert_dispatches!(
4264 |a: &PluginApi| a.set_prompt_suggestions(vec![
4265 Suggestion::new("one".into()),
4266 Suggestion::new("two".into()),
4267 ]),
4268 PluginCommand::SetPromptSuggestions { suggestions }
4269 if suggestions.len() == 2
4270 && suggestions[0].text == "one"
4271 && suggestions[1].text == "two"
4272 );
4273
4274 // set_prompt_input_sync
4275 assert_dispatches!(
4276 |a: &PluginApi| a.set_prompt_input_sync(true),
4277 PluginCommand::SetPromptInputSync { sync } if sync
4278 );
4279 assert_dispatches!(
4280 |a: &PluginApi| a.set_prompt_input_sync(false),
4281 PluginCommand::SetPromptInputSync { sync } if !sync
4282 );
4283
4284 // add_menu_item
4285 assert_dispatches!(
4286 |a: &PluginApi| a.add_menu_item(
4287 "File".into(),
4288 MenuItem::Label { info: "info".into() },
4289 MenuPosition::Bottom,
4290 ),
4291 PluginCommand::AddMenuItem { menu_label, item, position }
4292 if menu_label == "File"
4293 && matches!(item, MenuItem::Label { ref info } if info == "info")
4294 && matches!(position, MenuPosition::Bottom)
4295 );
4296
4297 // add_menu
4298 assert_dispatches!(
4299 |a: &PluginApi| a.add_menu(
4300 Menu {
4301 id: None,
4302 label: "Help".into(),
4303 items: vec![],
4304 when: None,
4305 },
4306 MenuPosition::After("Edit".into()),
4307 ),
4308 PluginCommand::AddMenu { menu, position }
4309 if menu.label == "Help"
4310 && matches!(position, MenuPosition::After(ref s) if s == "Edit")
4311 );
4312
4313 // remove_menu_item
4314 assert_dispatches!(
4315 |a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
4316 PluginCommand::RemoveMenuItem { menu_label, item_label }
4317 if menu_label == "File" && item_label == "Open"
4318 );
4319
4320 // remove_menu
4321 assert_dispatches!(
4322 |a: &PluginApi| a.remove_menu("File".into()),
4323 PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
4324 );
4325
4326 // create_virtual_buffer
4327 assert_dispatches!(
4328 |a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
4329 PluginCommand::CreateVirtualBuffer { name, mode, read_only }
4330 if name == "buf" && mode == "mode" && read_only
4331 );
4332
4333 // create_virtual_buffer_with_content
4334 assert_dispatches!(
4335 |a: &PluginApi| a.create_virtual_buffer_with_content(
4336 "n".into(), "m".into(), false, vec![]
4337 ),
4338 PluginCommand::CreateVirtualBufferWithContent {
4339 name, mode, read_only, show_line_numbers, show_cursors,
4340 editing_disabled, hidden_from_tabs, request_id, ..
4341 }
4342 if name == "n" && mode == "m" && !read_only
4343 && show_line_numbers && show_cursors
4344 && !editing_disabled && !hidden_from_tabs
4345 && request_id.is_none()
4346 );
4347
4348 // set_virtual_buffer_content
4349 assert_dispatches!(
4350 |a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
4351 PluginCommand::SetVirtualBufferContent { buffer_id, entries }
4352 if buffer_id == BufferId(9) && entries.is_empty()
4353 );
4354
4355 // get_text_properties_at_cursor
4356 assert_dispatches!(
4357 |a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
4358 PluginCommand::GetTextPropertiesAtCursor { buffer_id }
4359 if buffer_id == BufferId(11)
4360 );
4361
4362 // define_mode
4363 assert_dispatches!(
4364 |a: &PluginApi| a.define_mode(
4365 "m".into(),
4366 vec![("j".into(), "move_down".into())],
4367 true,
4368 false,
4369 ),
4370 PluginCommand::DefineMode {
4371 name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
4372 }
4373 if name == "m"
4374 && bindings.len() == 1
4375 && bindings[0].0 == "j"
4376 && bindings[0].1 == "move_down"
4377 && read_only
4378 && !allow_text_input
4379 && !inherit_normal_bindings
4380 && plugin_name.is_none()
4381 );
4382
4383 // show_buffer
4384 assert_dispatches!(
4385 |a: &PluginApi| a.show_buffer(BufferId(77)),
4386 PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
4387 );
4388
4389 // set_split_scroll
4390 assert_dispatches!(
4391 |a: &PluginApi| a.set_split_scroll(5, 128),
4392 PluginCommand::SetSplitScroll { split_id, top_byte }
4393 if split_id == SplitId(5) && top_byte == 128
4394 );
4395
4396 // get_highlights
4397 assert_dispatches!(
4398 |a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
4399 PluginCommand::RequestHighlights { buffer_id, range, request_id }
4400 if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
4401 );
4402 }
4403
4404 /// `get_active_split_id` reads the snapshot verbatim; a non-{0,1}
4405 /// sentinel value kills both the `0` and `1` constant-return mutants.
4406 #[test]
4407 fn plugin_api_get_active_split_id_reads_snapshot() {
4408 let (api, _rx, _h, _c, snap) = mk_api();
4409 snap.write().unwrap().active_split_id = 42;
4410 assert_eq!(api.get_active_split_id(), 42);
4411 }
4412
4413 /// `state_snapshot_handle` returns a clone of the same `Arc`, not a
4414 /// freshly-defaulted snapshot. A distinguishing field value on the
4415 /// original state proves that the handle sees it.
4416 #[test]
4417 fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
4418 let (api, _rx, _h, _c, snap) = mk_api();
4419 snap.write().unwrap().active_buffer_id = BufferId(42);
4420
4421 let h = api.state_snapshot_handle();
4422 assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
4423 assert!(Arc::ptr_eq(&h, &snap));
4424 }
4425
4426 /// `KillHostProcess` survives a round-trip through serde: the
4427 /// `process_id` field stays identified by name and the variant
4428 /// retains its tag shape. If a future contributor renames the
4429 /// field or splits it into a tuple, the plugin-runtime TS side
4430 /// (which hand-builds the command JSON for the dispatcher) would
4431 /// silently break — this test pins the wire format.
4432 #[test]
4433 fn plugin_command_kill_host_process_serde_round_trip() {
4434 let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
4435 let json = serde_json::to_value(&cmd).unwrap();
4436 assert_eq!(json["KillHostProcess"]["process_id"], 1234);
4437 let decoded: PluginCommand = serde_json::from_value(json).unwrap();
4438 match decoded {
4439 PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
4440 other => panic!("expected KillHostProcess, got {:?}", other),
4441 }
4442 }
4443}