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