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