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