use crate::command::{Command, Suggestion};
use crate::file_explorer::FileExplorerDecoration;
use crate::hooks::{HookCallback, HookRegistry};
use crate::menu::{Menu, MenuItem};
use crate::overlay::{OverlayHandle, OverlayNamespace};
use crate::text_property::{TextProperty, TextPropertyEntry};
use crate::BufferId;
use crate::SplitId;
use crate::TerminalId;
use crate::WindowId;
use lsp_types;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use ts_rs::TS;
pub struct CommandRegistry {
commands: std::sync::RwLock<Vec<Command>>,
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: std::sync::RwLock::new(Vec::new()),
}
}
pub fn register(&self, command: Command) {
let mut commands = self.commands.write().unwrap();
commands.retain(|c| c.name != command.name);
commands.push(command);
}
pub fn unregister(&self, name: &str) {
let mut commands = self.commands.write().unwrap();
commands.retain(|c| c.name != name);
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsCallbackId(pub u64);
impl JsCallbackId {
pub fn new(id: u64) -> Self {
Self(id)
}
pub fn as_u64(self) -> u64 {
self.0
}
}
impl From<u64> for JsCallbackId {
fn from(id: u64) -> Self {
Self(id)
}
}
impl From<JsCallbackId> for u64 {
fn from(id: JsCallbackId) -> u64 {
id.0
}
}
impl std::fmt::Display for JsCallbackId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TerminalResult {
#[ts(type = "number")]
pub buffer_id: u64,
#[ts(type = "number")]
pub terminal_id: u64,
#[ts(type = "number | null")]
pub split_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct VirtualBufferResult {
#[ts(type = "number")]
pub buffer_id: u64,
#[ts(type = "number | null")]
pub split_id: Option<u64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct AnimationRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub enum PluginAnimationEdge {
Top,
Bottom,
Left,
Right,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[ts(export)]
pub enum PluginAnimationKind {
#[serde(rename_all = "camelCase")]
SlideIn {
from: PluginAnimationEdge,
duration_ms: u32,
delay_ms: u32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct BufferGroupResult {
#[ts(type = "number")]
pub group_id: u64,
#[ts(type = "Record<string, number>")]
pub panels: HashMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginResponse {
VirtualBufferCreated {
request_id: u64,
buffer_id: BufferId,
split_id: Option<SplitId>,
},
TerminalCreated {
request_id: u64,
buffer_id: BufferId,
terminal_id: TerminalId,
split_id: Option<SplitId>,
},
LspRequest {
request_id: u64,
#[ts(type = "any")]
result: Result<JsonValue, String>,
},
HighlightsComputed {
request_id: u64,
spans: Vec<TsHighlightSpan>,
},
BufferText {
request_id: u64,
text: Result<String, String>,
},
LineStartPosition {
request_id: u64,
position: Option<usize>,
},
LineEndPosition {
request_id: u64,
position: Option<usize>,
},
BufferLineCount {
request_id: u64,
count: Option<usize>,
},
CompositeBufferCreated {
request_id: u64,
buffer_id: BufferId,
},
SplitByLabel {
request_id: u64,
split_id: Option<SplitId>,
},
WatchPathRegistered {
request_id: u64,
result: Result<u64, String>,
},
}
impl PluginResponse {
pub fn request_id(&self) -> u64 {
match self {
Self::VirtualBufferCreated { request_id, .. }
| Self::TerminalCreated { request_id, .. }
| Self::LspRequest { request_id, .. }
| Self::HighlightsComputed { request_id, .. }
| Self::BufferText { request_id, .. }
| Self::LineStartPosition { request_id, .. }
| Self::LineEndPosition { request_id, .. }
| Self::BufferLineCount { request_id, .. }
| Self::CompositeBufferCreated { request_id, .. }
| Self::SplitByLabel { request_id, .. }
| Self::WatchPathRegistered { request_id, .. } => *request_id,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginAsyncMessage {
ProcessOutput {
process_id: u64,
stdout: String,
stderr: String,
exit_code: i32,
},
DelayComplete {
callback_id: u64,
},
ProcessStdout { process_id: u64, data: String },
ProcessStderr { process_id: u64, data: String },
ProcessExit {
process_id: u64,
callback_id: u64,
exit_code: i32,
},
LspResponse {
language: String,
request_id: u64,
#[ts(type = "any")]
result: Result<JsonValue, String>,
},
PluginResponse(crate::api::PluginResponse),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CursorInfo {
pub position: usize,
#[cfg_attr(
feature = "plugins",
ts(type = "{ start: number; end: number } | null")
)]
pub selection: Option<Range<usize>>,
#[serde(default)]
pub line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ActionSpec {
pub action: String,
#[serde(default = "default_action_count")]
pub count: u32,
}
fn default_action_count() -> u32 {
1
}
fn default_window_id() -> WindowId {
WindowId(1)
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct WindowInfo {
#[ts(type = "number")]
pub id: WindowId,
pub label: String,
#[ts(type = "string")]
pub root: PathBuf,
#[ts(type = "string | null")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub project_path: Option<PathBuf>,
#[ts(type = "boolean")]
#[serde(skip_serializing_if = "is_false_field", default)]
pub shared_worktree: bool,
}
fn is_false_field(b: &bool) -> bool {
!b
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BufferInfo {
#[ts(type = "number")]
pub id: BufferId,
#[serde(serialize_with = "serialize_path")]
#[ts(type = "string")]
pub path: Option<PathBuf>,
pub modified: bool,
pub length: usize,
pub is_virtual: bool,
pub view_mode: String,
pub is_composing_in_any_split: bool,
pub compose_width: Option<u16>,
pub language: String,
#[serde(default)]
pub is_preview: bool,
#[serde(default)]
#[ts(type = "number[]")]
pub splits: Vec<SplitId>,
}
fn serialize_path<S: serde::Serializer>(path: &Option<PathBuf>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(
&path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
)
}
fn serialize_ranges_as_tuples<S>(ranges: &[Range<usize>], serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(ranges.len()))?;
for range in ranges {
seq.serialize_element(&(range.start, range.end))?;
}
seq.end()
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BufferSavedDiff {
pub equal: bool,
#[serde(serialize_with = "serialize_ranges_as_tuples")]
#[ts(type = "Array<[number, number]>")]
pub byte_ranges: Vec<Range<usize>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct ViewportInfo {
pub top_byte: usize,
pub top_line: Option<usize>,
pub left_column: usize,
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct SplitSnapshot {
pub split_id: usize,
pub buffer_id: BufferId,
pub viewport: ViewportInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct KeyEventPayload {
pub key: String,
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct LayoutHints {
#[ts(optional)]
pub compose_width: Option<u16>,
#[ts(optional)]
pub column_guides: Option<Vec<u16>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(untagged)]
#[ts(export)]
pub enum OverlayColorSpec {
#[ts(type = "[number, number, number]")]
Rgb(u8, u8, u8),
ThemeKey(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct VirtualLineTextOverlay {
pub start: u32,
pub end: u32,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub underline: bool,
}
impl OverlayColorSpec {
pub fn rgb(r: u8, g: u8, b: u8) -> Self {
Self::Rgb(r, g, b)
}
pub fn theme_key(key: impl Into<String>) -> Self {
Self::ThemeKey(key.into())
}
pub fn as_rgb(&self) -> Option<(u8, u8, u8)> {
match self {
Self::Rgb(r, g, b) => Some((*r, *g, *b)),
Self::ThemeKey(_) => None,
}
}
pub fn as_theme_key(&self) -> Option<&str> {
match self {
Self::ThemeKey(key) => Some(key),
Self::Rgb(_, _, _) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
#[derive(Default)]
pub struct OverlayOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fg: Option<OverlayColorSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<OverlayColorSpec>,
#[serde(default)]
pub underline: bool,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub strikethrough: bool,
#[serde(default)]
pub extend_to_line_end: bool,
#[serde(default)]
pub fg_on_collision_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct StyledText {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, type = "Partial<OverlayOptions>")]
pub style: Option<OverlayOptions>,
}
#[cfg(feature = "plugins")]
impl<'js> rquickjs::FromJs<'js> for StyledText {
fn from_js(_ctx: &rquickjs::Ctx<'js>, value: rquickjs::Value<'js>) -> rquickjs::Result<Self> {
rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
from: "object",
to: "StyledText",
message: Some(e.to_string()),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct CompletionItem {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub kind: Option<String>,
}
impl From<String> for CompletionItem {
fn from(value: String) -> Self {
Self { value, kind: None }
}
}
impl From<&str> for CompletionItem {
fn from(value: &str) -> Self {
Self {
value: value.to_string(),
kind: None,
}
}
}
pub mod completion_items_serde {
use super::CompletionItem;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Bare(String),
Typed(CompletionItem),
}
pub fn serialize<S>(items: &[CompletionItem], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
items.serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Vec<CompletionItem>, D::Error>
where
D: Deserializer<'de>,
{
let raw: Vec<Either> = Vec::deserialize(d)?;
Ok(raw
.into_iter()
.map(|e| match e {
Either::Bare(s) => CompletionItem {
value: s,
kind: None,
},
Either::Typed(item) => item,
})
.collect())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeLayoutConfig")]
pub struct CompositeLayoutConfig {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub layout_type: String,
#[serde(default)]
#[ts(optional)]
pub ratios: Option<Vec<f32>>,
#[serde(default = "default_true", rename = "showSeparator")]
#[ts(rename = "showSeparator")]
pub show_separator: bool,
#[serde(default)]
#[ts(optional)]
pub spacing: Option<u16>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeSourceConfig")]
pub struct CompositeSourceConfig {
#[serde(rename = "bufferId")]
#[ts(rename = "bufferId")]
pub buffer_id: usize,
pub label: String,
#[serde(default)]
pub editable: bool,
#[serde(default)]
pub style: Option<CompositePaneStyle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositePaneStyle")]
pub struct CompositePaneStyle {
#[serde(default, rename = "addBg")]
#[ts(optional, rename = "addBg", type = "[number, number, number]")]
pub add_bg: Option<[u8; 3]>,
#[serde(default, rename = "removeBg")]
#[ts(optional, rename = "removeBg", type = "[number, number, number]")]
pub remove_bg: Option<[u8; 3]>,
#[serde(default, rename = "modifyBg")]
#[ts(optional, rename = "modifyBg", type = "[number, number, number]")]
pub modify_bg: Option<[u8; 3]>,
#[serde(default, rename = "gutterStyle")]
#[ts(optional, rename = "gutterStyle")]
pub gutter_style: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCompositeHunk")]
pub struct CompositeHunk {
#[serde(rename = "oldStart")]
#[ts(rename = "oldStart")]
pub old_start: usize,
#[serde(rename = "oldCount")]
#[ts(rename = "oldCount")]
pub old_count: usize,
#[serde(rename = "newStart")]
#[ts(rename = "newStart")]
pub new_start: usize,
#[serde(rename = "newCount")]
#[ts(rename = "newCount")]
pub new_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsCreateCompositeBufferOptions")]
pub struct CreateCompositeBufferOptions {
#[serde(default)]
pub name: String,
#[serde(default)]
pub mode: String,
pub layout: CompositeLayoutConfig,
pub sources: Vec<CompositeSourceConfig>,
#[serde(default)]
pub hunks: Option<Vec<CompositeHunk>>,
#[serde(default, rename = "initialFocusHunk")]
#[ts(optional, rename = "initialFocusHunk")]
pub initial_focus_hunk: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum ViewTokenWireKind {
Text(String),
Newline,
Space,
Break,
BinaryByte(u8),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(untagged)]
#[ts(export)]
pub enum TokenColor {
#[ts(type = "[number, number, number]")]
Rgb(u8, u8, u8),
Named(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ViewTokenStyle {
#[serde(default)]
pub fg: Option<TokenColor>,
#[serde(default)]
pub bg: Option<TokenColor>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub underline: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ViewTokenWire {
#[ts(type = "number | null")]
pub source_offset: Option<usize>,
pub kind: ViewTokenWireKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub style: Option<ViewTokenStyle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ViewTransformPayload {
pub range: Range<usize>,
pub tokens: Vec<ViewTokenWire>,
pub layout_hints: Option<LayoutHints>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct EditorStateSnapshot {
pub active_buffer_id: BufferId,
pub active_split_id: usize,
pub buffers: HashMap<BufferId, BufferInfo>,
pub buffer_saved_diffs: HashMap<BufferId, BufferSavedDiff>,
pub primary_cursor: Option<CursorInfo>,
pub all_cursors: Vec<CursorInfo>,
pub viewport: Option<ViewportInfo>,
#[serde(default)]
pub splits: Vec<SplitSnapshot>,
pub buffer_cursor_positions: HashMap<BufferId, usize>,
pub buffer_text_properties: HashMap<BufferId, Vec<TextProperty>>,
pub selected_text: Option<String>,
pub clipboard: String,
pub working_dir: PathBuf,
#[serde(default)]
pub windows: Vec<WindowInfo>,
#[serde(default = "default_window_id")]
pub active_window_id: WindowId,
#[serde(default)]
pub authority_label: String,
#[serde(default)]
pub workspace_trust_level: String,
#[serde(default)]
pub env_active: bool,
#[serde(skip)]
#[ts(type = "any")]
pub diagnostics: Arc<HashMap<String, Vec<lsp_types::Diagnostic>>>,
#[serde(skip)]
#[ts(type = "any")]
pub folding_ranges: Arc<HashMap<String, Vec<lsp_types::FoldingRange>>>,
#[serde(skip)]
#[ts(type = "any")]
pub config: Arc<serde_json::Value>,
#[serde(skip)]
#[ts(type = "any")]
pub user_config: Arc<serde_json::Value>,
#[ts(type = "GrammarInfo[]")]
pub available_grammars: Vec<GrammarInfoSnapshot>,
#[serde(skip)]
#[ts(skip)]
pub last_grammar_gen: u64,
pub editor_mode: Option<String>,
#[ts(type = "any")]
pub plugin_view_states: HashMap<BufferId, HashMap<String, serde_json::Value>>,
#[serde(skip)]
#[ts(skip)]
pub plugin_view_states_split: usize,
#[serde(skip)]
#[ts(skip)]
pub keybinding_labels: HashMap<String, String>,
#[ts(type = "any")]
pub plugin_global_states: HashMap<String, HashMap<String, serde_json::Value>>,
#[serde(default)]
#[ts(type = "any")]
pub active_session_plugin_states: HashMap<String, HashMap<String, serde_json::Value>>,
#[serde(default)]
pub terminal_width: u16,
#[serde(default)]
pub terminal_height: u16,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct ScreenSize {
pub width: u16,
pub height: u16,
}
impl EditorStateSnapshot {
pub fn new() -> Self {
Self {
active_buffer_id: BufferId(0),
active_split_id: 0,
buffers: HashMap::new(),
buffer_saved_diffs: HashMap::new(),
primary_cursor: None,
all_cursors: Vec::new(),
viewport: None,
splits: Vec::new(),
buffer_cursor_positions: HashMap::new(),
buffer_text_properties: HashMap::new(),
selected_text: None,
clipboard: String::new(),
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
windows: Vec::new(),
active_window_id: WindowId(1),
authority_label: String::new(),
workspace_trust_level: String::new(),
env_active: false,
diagnostics: Arc::new(HashMap::new()),
folding_ranges: Arc::new(HashMap::new()),
config: Arc::new(serde_json::Value::Null),
user_config: Arc::new(serde_json::Value::Null),
available_grammars: Vec::new(),
last_grammar_gen: 0,
editor_mode: None,
plugin_view_states: HashMap::new(),
plugin_view_states_split: 0,
keybinding_labels: HashMap::new(),
plugin_global_states: HashMap::new(),
active_session_plugin_states: HashMap::new(),
terminal_width: 0,
terminal_height: 0,
}
}
}
impl Default for EditorStateSnapshot {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct GrammarInfoSnapshot {
pub name: String,
pub source: String,
pub file_extensions: Vec<String>,
pub short_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum MenuPosition {
Top,
Bottom,
Before(String),
After(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct HintEntry {
pub keys: String,
pub label: String,
}
fn default_cursor_byte() -> i32 {
-1
}
fn default_list_selected() -> i32 {
-1
}
fn default_list_visible_rows() -> u32 {
20
}
fn default_tree_selected() -> i32 {
-1
}
fn default_tree_visible_rows() -> u32 {
20
}
fn default_text_rows() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TreeNode {
pub text: crate::text_property::TextPropertyEntry,
#[serde(default)]
pub depth: u32,
#[serde(default)]
pub has_children: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked: Option<bool>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub enum ButtonKind {
#[default]
Normal,
Primary,
Danger,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, rename_all = "camelCase")]
pub enum WidgetSpec {
Row {
children: Vec<WidgetSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
#[serde(default)]
wrap: bool,
},
Col {
children: Vec<WidgetSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
HintBar {
entries: Vec<HintEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Toggle {
checked: bool,
label: String,
#[serde(default)]
focused: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Button {
label: String,
#[serde(default)]
focused: bool,
#[serde(default)]
intent: ButtonKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
#[serde(default)]
disabled: bool,
},
Spacer {
#[serde(default)]
cols: u32,
#[serde(default)]
flex: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
List {
items: Vec<crate::text_property::TextPropertyEntry>,
#[serde(default)]
item_keys: Vec<String>,
#[serde(default = "default_list_selected")]
selected_index: i32,
#[serde(default = "default_list_visible_rows")]
visible_rows: u32,
#[serde(default = "default_true")]
focusable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Tree {
nodes: Vec<TreeNode>,
#[serde(default)]
item_keys: Vec<String>,
#[serde(default = "default_tree_selected")]
selected_index: i32,
#[serde(default = "default_tree_visible_rows")]
visible_rows: u32,
#[serde(default)]
expanded_keys: Vec<String>,
#[serde(default)]
checkable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Text {
#[serde(default)]
value: String,
#[serde(default = "default_cursor_byte")]
cursor_byte: i32,
#[serde(default)]
focused: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
placeholder: Option<String>,
#[serde(default = "default_text_rows")]
rows: u32,
#[serde(default)]
field_width: u32,
#[serde(default)]
max_visible_chars: u32,
#[serde(default)]
full_width: bool,
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
with = "completion_items_serde"
)]
#[ts(type = "Array<string | CompletionItem>")]
completions: Vec<CompletionItem>,
#[serde(default)]
completions_visible_rows: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
LabeledSection {
#[serde(default)]
label: String,
child: Box<WidgetSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
width_pct: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
WindowEmbed {
window_id: u32,
rows: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Raw {
entries: Vec<crate::text_property::TextPropertyEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
Overlay {
child: Box<WidgetSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
}
impl WidgetSpec {
pub fn children(&self) -> Box<dyn Iterator<Item = &WidgetSpec> + '_> {
match self {
WidgetSpec::Row { children, .. } | WidgetSpec::Col { children, .. } => {
Box::new(children.iter())
}
WidgetSpec::LabeledSection { child, .. } | WidgetSpec::Overlay { child, .. } => {
Box::new(std::iter::once(child.as_ref()))
}
_ => Box::new(std::iter::empty()),
}
}
pub fn children_mut(&mut self) -> Box<dyn Iterator<Item = &mut WidgetSpec> + '_> {
match self {
WidgetSpec::Row { children, .. } | WidgetSpec::Col { children, .. } => {
Box::new(children.iter_mut())
}
WidgetSpec::LabeledSection { child, .. } | WidgetSpec::Overlay { child, .. } => {
Box::new(std::iter::once(child.as_mut()))
}
_ => Box::new(std::iter::empty()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, rename_all = "camelCase")]
pub enum WidgetAction {
FocusAdvance { delta: i32 },
Activate,
SelectMove { delta: i32 },
TextInputKey { key: String },
TextInputChar { text: String },
Key { key: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
#[ts(export, rename_all = "camelCase")]
pub enum WidgetMutation {
SetValue {
widget_key: String,
value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cursor_byte: Option<i32>,
},
SetCompletions {
widget_key: String,
#[serde(with = "completion_items_serde")]
#[ts(type = "Array<string | CompletionItem>")]
items: Vec<CompletionItem>,
},
SetChecked { widget_key: String, checked: bool },
SetSelectedIndex { widget_key: String, index: i32 },
SetItems {
widget_key: String,
items: Vec<crate::text_property::TextPropertyEntry>,
#[serde(default)]
item_keys: Vec<String>,
},
SetExpandedKeys {
widget_key: String,
keys: Vec<String>,
},
SetCheckedKeys {
widget_key: String,
checked: bool,
keys: Vec<String>,
},
AppendTreeNodes {
widget_key: String,
new_nodes: Vec<crate::api::TreeNode>,
#[serde(default)]
new_item_keys: Vec<String>,
},
SetRawEntries {
widget_key: String,
entries: Vec<crate::text_property::TextPropertyEntry>,
},
SetFocusKey { widget_key: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum PluginCommand {
InsertText {
buffer_id: BufferId,
position: usize,
text: String,
},
DeleteRange {
buffer_id: BufferId,
range: Range<usize>,
},
AddOverlay {
buffer_id: BufferId,
namespace: Option<OverlayNamespace>,
range: Range<usize>,
options: OverlayOptions,
},
RemoveOverlay {
buffer_id: BufferId,
handle: OverlayHandle,
},
SetStatus { message: String },
ApplyTheme { theme_name: String },
OverrideThemeColors { overrides: HashMap<String, [u8; 3]> },
ReloadConfig,
SetSetting {
plugin_name: String,
path: String,
#[ts(type = "unknown")]
value: JsonValue,
},
AddPluginConfigField {
plugin_name: String,
field_name: String,
#[ts(type = "unknown")]
field_schema: JsonValue,
},
RegisterCommand { command: Command },
RegisterStatusBarElement {
plugin_name: String,
token_name: String,
title: String,
},
SetStatusBarValue {
buffer_id: u64,
key: String,
value: String,
},
UnregisterCommand { name: String },
CreateWindow { root: PathBuf, label: String },
CreateWindowWithTerminal {
root: PathBuf,
label: String,
cwd: Option<String>,
command: Option<Vec<String>>,
title: Option<String>,
request_id: u64,
},
SetActiveWindow { id: WindowId },
CloseWindow { id: WindowId },
PrewarmWindow { id: WindowId },
WatchPath {
path: PathBuf,
recursive: bool,
request_id: u64,
},
UnwatchPath { handle: u64 },
PreviewWindowInRect { id: Option<WindowId> },
OpenFileInBackground {
path: PathBuf,
#[serde(default)]
window_id: Option<WindowId>,
},
InsertAtCursor { text: String },
SpawnProcess {
command: String,
args: Vec<String>,
cwd: Option<String>,
#[serde(default)]
stdout_to: Option<PathBuf>,
callback_id: JsCallbackId,
},
Delay {
callback_id: JsCallbackId,
duration_ms: u64,
},
HttpFetch {
url: String,
target_path: PathBuf,
callback_id: JsCallbackId,
},
SpawnBackgroundProcess {
process_id: u64,
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
KillBackgroundProcess { process_id: u64 },
SpawnProcessWait {
process_id: u64,
callback_id: JsCallbackId,
},
SetLayoutHints {
buffer_id: BufferId,
split_id: Option<SplitId>,
range: Range<usize>,
hints: LayoutHints,
},
SetLineNumbers { buffer_id: BufferId, enabled: bool },
SetViewMode { buffer_id: BufferId, mode: String },
SetLineWrap {
buffer_id: BufferId,
split_id: Option<SplitId>,
enabled: bool,
},
SubmitViewTransform {
buffer_id: BufferId,
split_id: Option<SplitId>,
payload: ViewTransformPayload,
},
ClearViewTransform {
buffer_id: BufferId,
split_id: Option<SplitId>,
},
SetViewState {
buffer_id: BufferId,
key: String,
#[ts(type = "any")]
value: Option<serde_json::Value>,
},
SetGlobalState {
plugin_name: String,
key: String,
#[ts(type = "any")]
value: Option<serde_json::Value>,
},
SetWindowState {
plugin_name: String,
key: String,
#[ts(type = "any")]
value: Option<serde_json::Value>,
},
ClearAllOverlays { buffer_id: BufferId },
ClearNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearOverlaysInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
AddVirtualText {
buffer_id: BufferId,
virtual_text_id: String,
position: usize,
text: String,
color: (u8, u8, u8),
use_bg: bool, before: bool, },
AddVirtualTextStyled {
buffer_id: BufferId,
virtual_text_id: String,
position: usize,
text: String,
fg: Option<OverlayColorSpec>,
bg: Option<OverlayColorSpec>,
bold: bool,
italic: bool,
before: bool,
},
RemoveVirtualText {
buffer_id: BufferId,
virtual_text_id: String,
},
RemoveVirtualTextsByPrefix { buffer_id: BufferId, prefix: String },
ClearVirtualTexts { buffer_id: BufferId },
AddVirtualLine {
buffer_id: BufferId,
position: usize,
text: String,
fg_color: Option<OverlayColorSpec>,
bg_color: Option<OverlayColorSpec>,
above: bool,
namespace: String,
priority: i32,
gutter_glyph: Option<String>,
gutter_color: Option<OverlayColorSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
text_overlays: Vec<VirtualLineTextOverlay>,
},
ClearVirtualTextNamespace {
buffer_id: BufferId,
namespace: String,
},
AddConceal {
buffer_id: BufferId,
namespace: OverlayNamespace,
start: usize,
end: usize,
replacement: Option<String>,
},
ClearConcealNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearConcealsInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
AddFold {
buffer_id: BufferId,
start: usize,
end: usize,
placeholder: Option<String>,
},
ClearFolds { buffer_id: BufferId },
SetFoldingRanges {
buffer_id: BufferId,
#[ts(type = "any")]
ranges: Vec<lsp_types::FoldingRange>,
},
AddSoftBreak {
buffer_id: BufferId,
namespace: OverlayNamespace,
position: usize,
indent: u16,
},
ClearSoftBreakNamespace {
buffer_id: BufferId,
namespace: OverlayNamespace,
},
ClearSoftBreaksInRange {
buffer_id: BufferId,
start: usize,
end: usize,
},
RefreshLines { buffer_id: BufferId },
RefreshAllLines,
HookCompleted { hook_name: String },
SetLineIndicator {
buffer_id: BufferId,
line: usize,
namespace: String,
symbol: String,
color: (u8, u8, u8),
priority: i32,
},
SetLineIndicators {
buffer_id: BufferId,
lines: Vec<usize>,
namespace: String,
symbol: String,
color: (u8, u8, u8),
priority: i32,
},
ClearLineIndicators {
buffer_id: BufferId,
namespace: String,
},
SetFileExplorerDecorations {
namespace: String,
decorations: Vec<FileExplorerDecoration>,
},
ClearFileExplorerDecorations {
namespace: String,
},
OpenFileAtLocation {
path: PathBuf,
line: Option<usize>, column: Option<usize>, },
OpenFileInSplit {
split_id: usize,
path: PathBuf,
line: Option<usize>, column: Option<usize>, },
StartPrompt {
label: String,
prompt_type: String, #[serde(default)]
floating_overlay: bool,
},
StartPromptWithInitial {
label: String,
prompt_type: String,
initial_value: String,
#[serde(default)]
floating_overlay: bool,
},
StartPromptAsync {
label: String,
initial_value: String,
callback_id: JsCallbackId,
},
AwaitNextKey { callback_id: JsCallbackId },
SetKeyCaptureActive { active: bool },
SetPromptSuggestions { suggestions: Vec<Suggestion> },
SetPromptInputSync { sync: bool },
SetPromptTitle { title: Vec<StyledText> },
SetPromptFooter { footer: Vec<StyledText> },
SetPromptToolbar { spec: Option<WidgetSpec> },
SetPromptStatus { status: String },
ToggleOverlayToolbarWidget { key: String },
SetPromptSelectedIndex { index: u32 },
AddMenuItem {
menu_label: String,
item: MenuItem,
position: MenuPosition,
},
AddMenu { menu: Menu, position: MenuPosition },
RemoveMenuItem {
menu_label: String,
item_label: String,
},
RemoveMenu { menu_label: String },
CreateVirtualBuffer {
name: String,
mode: String,
read_only: bool,
},
CreateVirtualBufferWithContent {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
hidden_from_tabs: bool,
request_id: Option<u64>,
},
CreateVirtualBufferInSplit {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
ratio: f32,
direction: Option<String>,
panel_id: Option<String>,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
line_wrap: Option<bool>,
before: bool,
role: Option<String>,
request_id: Option<u64>,
},
SetVirtualBufferContent {
buffer_id: BufferId,
entries: Vec<TextPropertyEntry>,
},
GetTextPropertiesAtCursor { buffer_id: BufferId },
CreateBufferGroup {
name: String,
mode: String,
layout_json: String,
request_id: Option<u64>,
},
SetPanelContent {
group_id: usize,
panel_name: String,
entries: Vec<TextPropertyEntry>,
},
CloseBufferGroup { group_id: usize },
FocusPanel { group_id: usize, panel_name: String },
DefineMode {
name: String,
bindings: Vec<(String, String)>, read_only: bool,
allow_text_input: bool,
inherit_normal_bindings: bool,
plugin_name: Option<String>,
},
ShowBuffer { buffer_id: BufferId },
StartAnimationArea {
id: u64,
rect: AnimationRect,
kind: PluginAnimationKind,
},
StartAnimationVirtualBuffer {
id: u64,
buffer_id: BufferId,
kind: PluginAnimationKind,
},
CancelAnimation { id: u64 },
CreateVirtualBufferInExistingSplit {
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
split_id: SplitId,
show_line_numbers: bool,
show_cursors: bool,
editing_disabled: bool,
line_wrap: Option<bool>,
request_id: Option<u64>,
},
CloseBuffer { buffer_id: BufferId },
CloseOtherBuffersInSplit {
buffer_id: BufferId,
split_id: SplitId,
},
CloseAllBuffersInSplit { split_id: SplitId },
CloseBuffersToRightInSplit {
buffer_id: BufferId,
split_id: SplitId,
},
CloseBuffersToLeftInSplit {
buffer_id: BufferId,
split_id: SplitId,
},
MoveTabLeft,
MoveTabRight,
CreateCompositeBuffer {
name: String,
mode: String,
layout: CompositeLayoutConfig,
sources: Vec<CompositeSourceConfig>,
hunks: Option<Vec<CompositeHunk>>,
initial_focus_hunk: Option<usize>,
request_id: Option<u64>,
},
UpdateCompositeAlignment {
buffer_id: BufferId,
hunks: Vec<CompositeHunk>,
},
CloseCompositeBuffer { buffer_id: BufferId },
FlushLayout,
CompositeNextHunk { buffer_id: BufferId },
CompositePrevHunk { buffer_id: BufferId },
FocusSplit { split_id: SplitId },
SetSplitBuffer {
split_id: SplitId,
buffer_id: BufferId,
},
SetSplitScroll { split_id: SplitId, top_byte: usize },
RequestHighlights {
buffer_id: BufferId,
range: Range<usize>,
request_id: u64,
},
CloseSplit { split_id: SplitId },
SetSplitRatio {
split_id: SplitId,
ratio: f32,
},
SetSplitLabel { split_id: SplitId, label: String },
ClearSplitLabel { split_id: SplitId },
GetSplitByLabel { label: String, request_id: u64 },
DistributeSplitsEvenly {
split_ids: Vec<SplitId>,
},
SetBufferCursor {
buffer_id: BufferId,
position: usize,
},
SetBufferShowCursors { buffer_id: BufferId, show: bool },
SendLspRequest {
language: String,
method: String,
#[ts(type = "any")]
params: Option<JsonValue>,
request_id: u64,
},
SetClipboard { text: String },
DeleteSelection,
SetContext {
name: String,
active: bool,
},
SetReviewDiffHunks { hunks: Vec<ReviewHunk> },
ExecuteAction {
action_name: String,
},
ExecuteActions {
actions: Vec<ActionSpec>,
},
GetBufferText {
buffer_id: BufferId,
start: usize,
end: usize,
request_id: u64,
},
GetLineStartPosition {
buffer_id: BufferId,
line: u32,
request_id: u64,
},
GetLineEndPosition {
buffer_id: BufferId,
line: u32,
request_id: u64,
},
GetBufferLineCount {
buffer_id: BufferId,
request_id: u64,
},
OpenFileStreaming {
path: PathBuf,
request_id: u64,
},
RefreshBufferFromDisk {
buffer_id: BufferId,
request_id: u64,
},
SetBufferGroupPanelBuffer {
group_id: usize,
panel_name: String,
buffer_id: BufferId,
request_id: u64,
},
ScrollToLineCenter {
split_id: SplitId,
buffer_id: BufferId,
line: usize,
},
ScrollBufferToLine {
buffer_id: BufferId,
line: usize,
},
SetEditorMode {
mode: Option<String>,
},
ShowActionPopup {
popup_id: String,
title: String,
message: String,
actions: Vec<ActionPopupAction>,
},
SetLspMenuContributions {
plugin_id: String,
language: String,
items: Vec<LspMenuItem>,
},
DisableLspForLanguage {
language: String,
},
RestartLspForLanguage {
language: String,
},
SetLspRootUri {
language: String,
uri: String,
},
CreateScrollSyncGroup {
group_id: u32,
left_split: SplitId,
right_split: SplitId,
},
SetScrollSyncAnchors {
group_id: u32,
anchors: Vec<(usize, usize)>,
},
RemoveScrollSyncGroup {
group_id: u32,
},
SaveBufferToPath {
buffer_id: BufferId,
path: PathBuf,
},
LoadPlugin {
path: PathBuf,
callback_id: JsCallbackId,
},
UnloadPlugin {
name: String,
callback_id: JsCallbackId,
},
ReloadPlugin {
name: String,
callback_id: JsCallbackId,
},
ListPlugins {
callback_id: JsCallbackId,
},
ReloadThemes { apply_theme: Option<String> },
RegisterGrammar {
language: String,
grammar_path: String,
extensions: Vec<String>,
},
RegisterLanguageConfig {
language: String,
config: LanguagePackConfig,
},
RegisterLspServer {
language: String,
config: LspServerPackConfig,
},
ReloadGrammars { callback_id: JsCallbackId },
CreateTerminal {
cwd: Option<String>,
direction: Option<String>,
ratio: Option<f32>,
focus: Option<bool>,
persistent: bool,
#[serde(default)]
window_id: Option<WindowId>,
#[serde(default)]
command: Option<Vec<String>>,
#[serde(default)]
title: Option<String>,
request_id: u64,
},
SendTerminalInput {
terminal_id: TerminalId,
data: String,
},
CloseTerminal {
terminal_id: TerminalId,
},
SignalWindow { id: WindowId, signal: String },
GrepProject {
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: usize,
whole_words: bool,
callback_id: JsCallbackId,
},
BeginSearch {
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: usize,
whole_words: bool,
handle_id: u64,
},
ReplaceInBuffer {
file_path: PathBuf,
matches: Vec<(usize, usize)>,
replacement: String,
callback_id: JsCallbackId,
},
SetAuthority {
#[ts(type = "unknown")]
payload: JsonValue,
},
ClearAuthority,
SetEnv {
snippet: String,
#[serde(default)]
dir: Option<String>,
},
ClearEnv,
SetRemoteIndicatorState {
#[ts(type = "unknown")]
state: JsonValue,
},
ClearRemoteIndicatorState,
SpawnHostProcess {
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
KillHostProcess { process_id: u64 },
MountWidgetPanel {
panel_id: u64,
buffer_id: BufferId,
spec: WidgetSpec,
},
UpdateWidgetPanel { panel_id: u64, spec: WidgetSpec },
UnmountWidgetPanel { panel_id: u64 },
WidgetCommand { panel_id: u64, action: WidgetAction },
WidgetMutate {
panel_id: u64,
mutation: WidgetMutation,
},
MountFloatingWidget {
panel_id: u64,
spec: WidgetSpec,
width_pct: u8,
height_pct: u8,
},
UpdateFloatingWidget { panel_id: u64, spec: WidgetSpec },
UnmountFloatingWidget { panel_id: u64 },
}
impl PluginCommand {
pub fn debug_variant_name(&self) -> String {
let dbg = format!("{:?}", self);
dbg.split([' ', '{', '(']).next().unwrap_or("?").to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct LanguagePackConfig {
#[serde(default)]
pub comment_prefix: Option<String>,
#[serde(default)]
pub block_comment_start: Option<String>,
#[serde(default)]
pub block_comment_end: Option<String>,
#[serde(default)]
pub use_tabs: Option<bool>,
#[serde(default)]
pub tab_size: Option<usize>,
#[serde(default)]
pub auto_indent: Option<bool>,
#[serde(default)]
pub show_whitespace_tabs: Option<bool>,
#[serde(default)]
pub formatter: Option<FormatterPackConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FormatterPackConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ProcessLimitsPackConfig {
#[serde(default)]
pub max_memory_percent: Option<u32>,
#[serde(default)]
pub max_cpu_percent: Option<u32>,
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct LspServerPackConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub auto_start: Option<bool>,
#[serde(default)]
#[ts(type = "Record<string, unknown> | null")]
pub initialization_options: Option<JsonValue>,
#[serde(default)]
pub process_limits: Option<ProcessLimitsPackConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export)]
pub enum HunkStatus {
Pending,
Staged,
Discarded,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ReviewHunk {
pub id: String,
pub file: String,
pub context_header: String,
pub status: HunkStatus,
pub base_range: Option<(usize, usize)>,
pub modified_range: Option<(usize, usize)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsActionPopupAction")]
pub struct ActionPopupAction {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export, rename = "TsLspMenuItem")]
pub struct LspMenuItem {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ActionPopupOptions {
pub id: String,
pub title: String,
pub message: String,
pub actions: Vec<ActionPopupAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct TsHighlightSpan {
pub start: u32,
pub end: u32,
#[ts(type = "[number, number, number]")]
pub color: (u8, u8, u8),
pub bold: bool,
pub italic: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct SpawnResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct BackgroundProcessResult {
#[ts(type = "number")]
pub process_id: u64,
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct GrepMatch {
pub file: String,
#[ts(type = "number")]
pub buffer_id: usize,
#[ts(type = "number")]
pub byte_offset: usize,
#[ts(type = "number")]
pub length: usize,
#[ts(type = "number")]
pub line: usize,
#[ts(type = "number")]
pub column: usize,
pub context: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct SearchTakeResult {
pub matches: Vec<GrepMatch>,
pub done: bool,
#[ts(type = "number")]
pub total_seen: usize,
pub truncated: bool,
#[ts(optional, type = "string | null")]
pub error: Option<String>,
}
#[derive(Debug, Default)]
pub struct SearchState {
pub pending: Vec<GrepMatch>,
pub total_seen: usize,
pub truncated: bool,
pub done: bool,
pub error: Option<String>,
}
#[derive(Debug)]
pub struct SearchHandleState {
pub state: std::sync::Mutex<SearchState>,
pub cancel: std::sync::atomic::AtomicBool,
}
impl SearchHandleState {
pub fn new() -> Self {
Self {
state: std::sync::Mutex::new(SearchState::default()),
cancel: std::sync::atomic::AtomicBool::new(false),
}
}
}
impl Default for SearchHandleState {
fn default() -> Self {
Self::new()
}
}
pub type SearchHandleRegistry = Arc<std::sync::Mutex<HashMap<u64, Arc<SearchHandleState>>>>;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct ReplaceResult {
#[ts(type = "number")]
pub replacements: usize,
#[ts(type = "number")]
pub buffer_id: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename = "TextPropertyEntry", rename_all = "camelCase")]
pub struct JsTextPropertyEntry {
pub text: String,
#[serde(default)]
#[ts(optional, type = "Record<string, unknown>")]
pub properties: Option<HashMap<String, JsonValue>>,
#[serde(default)]
#[ts(optional, type = "Partial<OverlayOptions>")]
pub style: Option<OverlayOptions>,
#[serde(default)]
#[ts(optional)]
pub inline_overlays: Option<Vec<crate::text_property::InlineOverlay>>,
#[serde(default)]
#[ts(optional)]
pub pad_to_chars: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub truncate_to_chars: Option<u32>,
#[serde(default)]
#[ts(optional)]
pub segments: Option<Vec<crate::text_property::StyledSegment>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct DirEntry {
pub name: String,
pub is_file: bool,
pub is_dir: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsPosition {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsRange {
pub start: JsPosition,
pub end: JsPosition,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct JsDiagnostic {
pub uri: String,
pub message: String,
pub severity: Option<u8>,
pub range: JsRange,
#[ts(optional)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferOptions {
pub name: String,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "hiddenFromTabs")]
#[ts(optional, rename = "hiddenFromTabs")]
pub hidden_from_tabs: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferInSplitOptions {
pub name: String,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub ratio: Option<f32>,
#[serde(default)]
#[ts(optional)]
pub direction: Option<String>,
#[serde(default, rename = "panelId")]
#[ts(optional, rename = "panelId")]
pub panel_id: Option<String>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "lineWrap")]
#[ts(optional, rename = "lineWrap")]
pub line_wrap: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub before: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
#[serde(default)]
#[ts(optional)]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateVirtualBufferInExistingSplitOptions {
pub name: String,
#[serde(rename = "splitId")]
#[ts(rename = "splitId")]
pub split_id: usize,
#[serde(default)]
#[ts(optional)]
pub mode: Option<String>,
#[serde(default, rename = "readOnly")]
#[ts(optional, rename = "readOnly")]
pub read_only: Option<bool>,
#[serde(default, rename = "showLineNumbers")]
#[ts(optional, rename = "showLineNumbers")]
pub show_line_numbers: Option<bool>,
#[serde(default, rename = "showCursors")]
#[ts(optional, rename = "showCursors")]
pub show_cursors: Option<bool>,
#[serde(default, rename = "editingDisabled")]
#[ts(optional, rename = "editingDisabled")]
pub editing_disabled: Option<bool>,
#[serde(default, rename = "lineWrap")]
#[ts(optional, rename = "lineWrap")]
pub line_wrap: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub entries: Option<Vec<JsTextPropertyEntry>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct CreateTerminalOptions {
#[serde(default)]
#[ts(optional)]
pub cwd: Option<String>,
#[serde(default)]
#[ts(optional)]
pub direction: Option<String>,
#[serde(default)]
#[ts(optional)]
pub ratio: Option<f32>,
#[serde(default)]
#[ts(optional)]
pub focus: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub persistent: Option<bool>,
#[serde(default, rename = "windowId")]
#[ts(optional, rename = "windowId")]
pub window_id: Option<WindowId>,
#[serde(default)]
#[ts(optional)]
pub command: Option<Vec<String>>,
#[serde(default)]
#[ts(optional)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct CreateWindowWithTerminalOptions {
pub root: String,
#[serde(default)]
pub label: String,
#[serde(default)]
#[ts(optional)]
pub cwd: Option<String>,
#[serde(default)]
#[ts(optional)]
pub command: Option<Vec<String>>,
#[serde(default)]
#[ts(optional)]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct SessionWithTerminalResult {
#[ts(type = "number")]
pub window_id: u64,
#[ts(type = "number")]
pub terminal_id: u64,
#[ts(type = "number")]
pub buffer_id: u64,
}
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, type = "Array<Record<string, unknown>>")]
pub struct TextPropertiesAtCursor(pub Vec<HashMap<String, JsonValue>>);
#[cfg(feature = "plugins")]
mod fromjs_impls {
use super::*;
use rquickjs::{Ctx, FromJs, Value};
macro_rules! impl_from_js_via_serde {
($($T:ty),+ $(,)?) => {
$(
impl<'js> FromJs<'js> for $T {
fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
from: "object",
to: stringify!($T),
message: Some(e.to_string()),
})
}
}
)+
};
}
impl_from_js_via_serde!(
JsTextPropertyEntry,
CreateVirtualBufferOptions,
CreateVirtualBufferInSplitOptions,
CreateVirtualBufferInExistingSplitOptions,
ActionSpec,
ActionPopupAction,
ActionPopupOptions,
LspMenuItem,
ViewTokenWire,
ViewTokenStyle,
LayoutHints,
CompositeHunk,
LanguagePackConfig,
LspServerPackConfig,
ProcessLimitsPackConfig,
CreateTerminalOptions,
CreateWindowWithTerminalOptions,
);
impl<'js> rquickjs::IntoJs<'js> for TextPropertiesAtCursor {
fn into_js(self, ctx: &Ctx<'js>) -> rquickjs::Result<Value<'js>> {
rquickjs_serde::to_value(ctx.clone(), &self.0)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
}
impl<'js> FromJs<'js> for CreateCompositeBufferOptions {
fn from_js(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Self> {
let json: serde_json::Value =
rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
from: "object",
to: "CreateCompositeBufferOptions (json)",
message: Some(e.to_string()),
})?;
serde_json::from_value(json).map_err(|e| rquickjs::Error::FromJs {
from: "json",
to: "CreateCompositeBufferOptions",
message: Some(e.to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rquickjs::{Context, Runtime};
fn with_js<R>(f: impl for<'js> FnOnce(Ctx<'js>) -> R) -> R {
let rt = Runtime::new().expect("create rquickjs runtime");
let ctx = Context::full(&rt).expect("create rquickjs context");
ctx.with(f)
}
fn eval_as<T>(src: &str) -> T
where
for<'js> T: rquickjs::FromJs<'js>,
{
with_js(|ctx| {
let value: Value = ctx
.eval::<Value, _>(src.as_bytes())
.expect("eval JS source");
T::from_js(&ctx, value).expect("from_js decode")
})
}
#[test]
fn js_text_property_entry_decodes_text_and_properties() {
let got: JsTextPropertyEntry =
eval_as("({text: 'hello', properties: {file: '/x.rs'}})");
assert_eq!(got.text, "hello");
let props = got.properties.expect("properties present");
assert_eq!(props.get("file").and_then(|v| v.as_str()), Some("/x.rs"));
}
#[test]
fn create_virtual_buffer_options_decodes_name() {
let got: CreateVirtualBufferOptions = eval_as("({name: 'logs', readOnly: true})");
assert_eq!(got.name, "logs");
assert_eq!(got.read_only, Some(true));
}
#[test]
fn create_virtual_buffer_in_split_options_decodes_ratio() {
let got: CreateVirtualBufferInSplitOptions =
eval_as("({name: 'diag', ratio: 0.25, direction: 'horizontal'})");
assert_eq!(got.name, "diag");
assert!(matches!(got.ratio, Some(r) if (r - 0.25).abs() < 1e-6));
assert_eq!(got.direction.as_deref(), Some("horizontal"));
}
#[test]
fn create_virtual_buffer_in_existing_split_options_decodes_splitid() {
let got: CreateVirtualBufferInExistingSplitOptions =
eval_as("({name: 'n', splitId: 7})");
assert_eq!(got.name, "n");
assert_eq!(got.split_id, 7);
}
#[test]
fn create_terminal_options_decodes_cwd_and_focus() {
let got: CreateTerminalOptions =
eval_as("({cwd: '/tmp', direction: 'vertical', focus: false})");
assert_eq!(got.cwd.as_deref(), Some("/tmp"));
assert_eq!(got.direction.as_deref(), Some("vertical"));
assert_eq!(got.focus, Some(false));
}
#[test]
fn action_spec_decodes_action_and_count() {
let got: ActionSpec = eval_as("({action: 'move_word_right', count: 5})");
assert_eq!(got.action, "move_word_right");
assert_eq!(got.count, 5);
}
#[test]
fn action_popup_action_decodes_id_and_label() {
let got: ActionPopupAction = eval_as("({id: 'ok', label: 'OK'})");
assert_eq!(got.id, "ok");
assert_eq!(got.label, "OK");
}
#[test]
fn action_popup_options_decodes_actions_list() {
let got: ActionPopupOptions = eval_as(
"({id: 'p', title: 't', message: 'm', \
actions: [{id: 'ok', label: 'OK'}]})",
);
assert_eq!(got.id, "p");
assert_eq!(got.title, "t");
assert_eq!(got.message, "m");
assert_eq!(got.actions.len(), 1);
assert_eq!(got.actions[0].id, "ok");
}
#[test]
fn view_token_wire_decodes_offset_and_kind() {
let got: ViewTokenWire = eval_as("({source_offset: 42, kind: 'Newline'})");
assert_eq!(got.source_offset, Some(42));
assert!(matches!(got.kind, ViewTokenWireKind::Newline));
}
#[test]
fn view_token_style_decodes_boolean_flags() {
let got: ViewTokenStyle = eval_as("({bold: true, italic: true})");
assert!(got.bold);
assert!(got.italic);
assert!(got.fg.is_none());
}
#[test]
fn layout_hints_decodes_compose_width() {
let got: LayoutHints = eval_as("({composeWidth: 120})");
assert_eq!(got.compose_width, Some(120));
assert!(got.column_guides.is_none());
}
#[test]
fn create_composite_buffer_options_decodes_name_and_sources() {
let got: CreateCompositeBufferOptions = eval_as(
"({name: 'diff', mode: 'm', \
layout: {type: 'side-by-side', ratios: [0.5, 0.5], showSeparator: true}, \
sources: [{bufferId: 3, label: 'OLD'}]})",
);
assert_eq!(got.name, "diff");
assert_eq!(got.layout.layout_type, "side-by-side");
assert_eq!(got.sources.len(), 1);
assert_eq!(got.sources[0].buffer_id, 3);
assert_eq!(got.sources[0].label, "OLD");
}
#[test]
fn composite_hunk_decodes_all_fields() {
let got: CompositeHunk =
eval_as("({oldStart: 1, oldCount: 2, newStart: 3, newCount: 4})");
assert_eq!(got.old_start, 1);
assert_eq!(got.old_count, 2);
assert_eq!(got.new_start, 3);
assert_eq!(got.new_count, 4);
}
#[test]
fn language_pack_config_decodes_comment_prefix_and_tab_size() {
let got: LanguagePackConfig =
eval_as("({commentPrefix: '//', tabSize: 7, useTabs: true})");
assert_eq!(got.comment_prefix.as_deref(), Some("//"));
assert_eq!(got.tab_size, Some(7));
assert_eq!(got.use_tabs, Some(true));
}
#[test]
fn lsp_server_pack_config_decodes_command_and_args() {
let got: LspServerPackConfig =
eval_as("({command: 'rust-analyzer', args: ['--log'], autoStart: true})");
assert_eq!(got.command, "rust-analyzer");
assert_eq!(got.args, vec!["--log".to_string()]);
assert_eq!(got.auto_start, Some(true));
}
#[test]
fn process_limits_pack_config_decodes_percentages() {
let got: ProcessLimitsPackConfig =
eval_as("({maxMemoryPercent: 75, maxCpuPercent: 50, enabled: true})");
assert_eq!(got.max_memory_percent, Some(75));
assert_eq!(got.max_cpu_percent, Some(50));
assert_eq!(got.enabled, Some(true));
}
#[test]
fn text_properties_at_cursor_into_js_preserves_length() {
use rquickjs::IntoJs;
with_js(|ctx| {
let mut entry = std::collections::HashMap::new();
entry.insert("k".to_string(), serde_json::json!("v"));
let payload = TextPropertiesAtCursor(vec![entry.clone(), entry]);
let v = payload.into_js(&ctx).expect("into_js");
let arr = v.as_array().expect("expected JS array");
assert_eq!(arr.len(), 2);
});
}
}
}
pub struct PluginApi {
hooks: Arc<RwLock<HookRegistry>>,
commands: Arc<RwLock<CommandRegistry>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
}
impl PluginApi {
pub fn new(
hooks: Arc<RwLock<HookRegistry>>,
commands: Arc<RwLock<CommandRegistry>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
) -> Self {
Self {
hooks,
commands,
command_sender,
state_snapshot,
}
}
pub fn register_hook(&self, hook_name: &str, callback: HookCallback) {
let mut hooks = self.hooks.write().unwrap();
hooks.add_hook(hook_name, callback);
}
pub fn unregister_hooks(&self, hook_name: &str) {
let mut hooks = self.hooks.write().unwrap();
hooks.remove_hooks(hook_name);
}
pub fn register_command(&self, command: Command) {
let commands = self.commands.read().unwrap();
commands.register(command);
}
pub fn unregister_command(&self, name: &str) {
let commands = self.commands.read().unwrap();
commands.unregister(name);
}
pub fn send_command(&self, command: PluginCommand) -> Result<(), String> {
self.command_sender
.send(command)
.map_err(|e| format!("Failed to send command: {}", e))
}
pub fn insert_text(
&self,
buffer_id: BufferId,
position: usize,
text: String,
) -> Result<(), String> {
self.send_command(PluginCommand::InsertText {
buffer_id,
position,
text,
})
}
pub fn delete_range(&self, buffer_id: BufferId, range: Range<usize>) -> Result<(), String> {
self.send_command(PluginCommand::DeleteRange { buffer_id, range })
}
pub fn add_overlay(
&self,
buffer_id: BufferId,
namespace: Option<String>,
range: Range<usize>,
options: OverlayOptions,
) -> Result<(), String> {
self.send_command(PluginCommand::AddOverlay {
buffer_id,
namespace: namespace.map(OverlayNamespace::from_string),
range,
options,
})
}
pub fn remove_overlay(&self, buffer_id: BufferId, handle: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveOverlay {
buffer_id,
handle: OverlayHandle::from_string(handle),
})
}
pub fn clear_namespace(&self, buffer_id: BufferId, namespace: String) -> Result<(), String> {
self.send_command(PluginCommand::ClearNamespace {
buffer_id,
namespace: OverlayNamespace::from_string(namespace),
})
}
pub fn clear_overlays_in_range(
&self,
buffer_id: BufferId,
start: usize,
end: usize,
) -> Result<(), String> {
self.send_command(PluginCommand::ClearOverlaysInRange {
buffer_id,
start,
end,
})
}
pub fn set_status(&self, message: String) -> Result<(), String> {
self.send_command(PluginCommand::SetStatus { message })
}
pub fn open_file_at_location(
&self,
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
) -> Result<(), String> {
self.send_command(PluginCommand::OpenFileAtLocation { path, line, column })
}
pub fn open_file_in_split(
&self,
split_id: usize,
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
) -> Result<(), String> {
self.send_command(PluginCommand::OpenFileInSplit {
split_id,
path,
line,
column,
})
}
pub fn start_prompt(&self, label: String, prompt_type: String) -> Result<(), String> {
self.send_command(PluginCommand::StartPrompt {
label,
prompt_type,
floating_overlay: false,
})
}
pub fn set_prompt_suggestions(&self, suggestions: Vec<Suggestion>) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptSuggestions { suggestions })
}
pub fn set_prompt_input_sync(&self, sync: bool) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptInputSync { sync })
}
pub fn set_prompt_title(&self, title: Vec<StyledText>) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptTitle { title })
}
pub fn set_prompt_footer(&self, footer: Vec<StyledText>) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptFooter { footer })
}
pub fn set_prompt_toolbar(&self, spec: Option<WidgetSpec>) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptToolbar { spec })
}
pub fn set_prompt_status(&self, status: String) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptStatus { status })
}
pub fn set_prompt_selected_index(&self, index: u32) -> Result<(), String> {
self.send_command(PluginCommand::SetPromptSelectedIndex { index })
}
pub fn add_menu_item(
&self,
menu_label: String,
item: MenuItem,
position: MenuPosition,
) -> Result<(), String> {
self.send_command(PluginCommand::AddMenuItem {
menu_label,
item,
position,
})
}
pub fn add_menu(&self, menu: Menu, position: MenuPosition) -> Result<(), String> {
self.send_command(PluginCommand::AddMenu { menu, position })
}
pub fn remove_menu_item(&self, menu_label: String, item_label: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveMenuItem {
menu_label,
item_label,
})
}
pub fn remove_menu(&self, menu_label: String) -> Result<(), String> {
self.send_command(PluginCommand::RemoveMenu { menu_label })
}
pub fn create_virtual_buffer(
&self,
name: String,
mode: String,
read_only: bool,
) -> Result<(), String> {
self.send_command(PluginCommand::CreateVirtualBuffer {
name,
mode,
read_only,
})
}
pub fn create_virtual_buffer_with_content(
&self,
name: String,
mode: String,
read_only: bool,
entries: Vec<TextPropertyEntry>,
) -> Result<(), String> {
self.send_command(PluginCommand::CreateVirtualBufferWithContent {
name,
mode,
read_only,
entries,
show_line_numbers: true,
show_cursors: true,
editing_disabled: false,
hidden_from_tabs: false,
request_id: None,
})
}
pub fn set_virtual_buffer_content(
&self,
buffer_id: BufferId,
entries: Vec<TextPropertyEntry>,
) -> Result<(), String> {
self.send_command(PluginCommand::SetVirtualBufferContent { buffer_id, entries })
}
pub fn get_text_properties_at_cursor(&self, buffer_id: BufferId) -> Result<(), String> {
self.send_command(PluginCommand::GetTextPropertiesAtCursor { buffer_id })
}
pub fn define_mode(
&self,
name: String,
bindings: Vec<(String, String)>,
read_only: bool,
allow_text_input: bool,
) -> Result<(), String> {
self.send_command(PluginCommand::DefineMode {
name,
bindings,
read_only,
allow_text_input,
inherit_normal_bindings: false,
plugin_name: None,
})
}
pub fn show_buffer(&self, buffer_id: BufferId) -> Result<(), String> {
self.send_command(PluginCommand::ShowBuffer { buffer_id })
}
pub fn set_split_scroll(&self, split_id: usize, top_byte: usize) -> Result<(), String> {
self.send_command(PluginCommand::SetSplitScroll {
split_id: SplitId(split_id),
top_byte,
})
}
pub fn get_highlights(
&self,
buffer_id: BufferId,
range: Range<usize>,
request_id: u64,
) -> Result<(), String> {
self.send_command(PluginCommand::RequestHighlights {
buffer_id,
range,
request_id,
})
}
pub fn get_active_buffer_id(&self) -> BufferId {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.active_buffer_id
}
pub fn get_active_split_id(&self) -> usize {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.active_split_id
}
pub fn get_buffer_info(&self, buffer_id: BufferId) -> Option<BufferInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.buffers.get(&buffer_id).cloned()
}
pub fn list_buffers(&self) -> Vec<BufferInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.buffers.values().cloned().collect()
}
pub fn get_primary_cursor(&self) -> Option<CursorInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.primary_cursor.clone()
}
pub fn get_all_cursors(&self) -> Vec<CursorInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.all_cursors.clone()
}
pub fn get_viewport(&self) -> Option<ViewportInfo> {
let snapshot = self.state_snapshot.read().unwrap();
snapshot.viewport.clone()
}
pub fn get_screen_size(&self) -> ScreenSize {
let snapshot = self.state_snapshot.read().unwrap();
ScreenSize {
width: snapshot.terminal_width,
height: snapshot.terminal_height,
}
}
pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
Arc::clone(&self.state_snapshot)
}
}
impl Clone for PluginApi {
fn clone(&self) -> Self {
Self {
hooks: Arc::clone(&self.hooks),
commands: Arc::clone(&self.commands),
command_sender: self.command_sender.clone(),
state_snapshot: Arc::clone(&self.state_snapshot),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionCandidate {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub insert_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub score: i64,
#[serde(default)]
pub is_snippet: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_data: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionContext {
pub prefix: String,
pub cursor_byte: usize,
pub word_start_byte: usize,
pub buffer_len: usize,
pub is_large_file: bool,
pub text_around_cursor: String,
pub cursor_offset_in_text: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub language_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, rename_all = "camelCase")]
pub struct TsCompletionProviderRegistration {
pub id: String,
pub display_name: String,
#[serde(default = "default_plugin_provider_priority")]
pub priority: u32,
#[serde(default)]
pub language_ids: Vec<String>,
}
fn default_plugin_provider_priority() -> u32 {
50
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_plugin_api_creation() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let _clone = api.clone();
}
#[test]
fn test_register_hook() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks.clone(), commands, tx, state_snapshot);
api.register_hook("test-hook", Box::new(|_| true));
let hook_registry = hooks.read().unwrap();
assert_eq!(hook_registry.hook_count("test-hook"), 1);
}
#[test]
fn test_send_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.insert_text(BufferId(1), 0, "test".to_string());
assert!(result.is_ok());
let received = rx.try_recv();
assert!(received.is_ok());
match received.unwrap() {
PluginCommand::InsertText {
buffer_id,
position,
text,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(position, 0);
assert_eq!(text, "test");
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_add_overlay_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.add_overlay(
BufferId(1),
Some("test-overlay".to_string()),
0..10,
OverlayOptions {
fg: Some(OverlayColorSpec::ThemeKey("ui.status_bar_fg".to_string())),
bg: None,
underline: true,
bold: false,
italic: false,
strikethrough: false,
extend_to_line_end: false,
fg_on_collision_only: false,
url: None,
},
);
assert!(result.is_ok());
let received = rx.try_recv().unwrap();
match received {
PluginCommand::AddOverlay {
buffer_id,
namespace,
range,
options,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
assert_eq!(range, 0..10);
assert!(matches!(
options.fg,
Some(OverlayColorSpec::ThemeKey(ref k)) if k == "ui.status_bar_fg"
));
assert!(options.bg.is_none());
assert!(options.underline);
assert!(!options.bold);
assert!(!options.italic);
assert!(!options.extend_to_line_end);
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_set_status_command() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let result = api.set_status("Test status".to_string());
assert!(result.is_ok());
let received = rx.try_recv().unwrap();
match received {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "Test status");
}
_ => panic!("Wrong command type"),
}
}
#[test]
fn test_get_active_buffer_id() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.active_buffer_id = BufferId(5);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let active_id = api.get_active_buffer_id();
assert_eq!(active_id.0, 5);
}
#[test]
fn test_get_buffer_info() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
let buffer_info = BufferInfo {
id: BufferId(1),
path: Some(std::path::PathBuf::from("/test/file.txt")),
modified: true,
length: 100,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
};
snapshot.buffers.insert(BufferId(1), buffer_info);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let info = api.get_buffer_info(BufferId(1));
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.id.0, 1);
assert_eq!(
info.path.as_ref().unwrap().to_str().unwrap(),
"/test/file.txt"
);
assert!(info.modified);
assert_eq!(info.length, 100);
let no_info = api.get_buffer_info(BufferId(999));
assert!(no_info.is_none());
}
#[test]
fn test_list_buffers() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.buffers.insert(
BufferId(1),
BufferInfo {
id: BufferId(1),
path: Some(std::path::PathBuf::from("/file1.txt")),
modified: false,
length: 50,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
snapshot.buffers.insert(
BufferId(2),
BufferInfo {
id: BufferId(2),
path: Some(std::path::PathBuf::from("/file2.txt")),
modified: true,
length: 100,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
snapshot.buffers.insert(
BufferId(3),
BufferInfo {
id: BufferId(3),
path: None,
modified: false,
length: 0,
is_virtual: true,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let buffers = api.list_buffers();
assert_eq!(buffers.len(), 3);
assert!(buffers.iter().any(|b| b.id.0 == 1));
assert!(buffers.iter().any(|b| b.id.0 == 2));
assert!(buffers.iter().any(|b| b.id.0 == 3));
}
#[test]
fn test_get_primary_cursor() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.primary_cursor = Some(CursorInfo {
position: 42,
selection: Some(10..42),
line: Some(3),
});
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let cursor = api.get_primary_cursor();
assert!(cursor.is_some());
let cursor = cursor.unwrap();
assert_eq!(cursor.position, 42);
assert_eq!(cursor.selection, Some(10..42));
}
#[test]
fn test_get_all_cursors() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.all_cursors = vec![
CursorInfo {
position: 10,
selection: None,
line: Some(0),
},
CursorInfo {
position: 20,
selection: Some(15..20),
line: Some(1),
},
CursorInfo {
position: 30,
selection: Some(25..30),
line: Some(2),
},
];
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let cursors = api.get_all_cursors();
assert_eq!(cursors.len(), 3);
assert_eq!(cursors[0].position, 10);
assert_eq!(cursors[0].selection, None);
assert_eq!(cursors[1].position, 20);
assert_eq!(cursors[1].selection, Some(15..20));
assert_eq!(cursors[2].position, 30);
assert_eq!(cursors[2].selection, Some(25..30));
}
#[test]
fn test_get_viewport() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut snapshot = state_snapshot.write().unwrap();
snapshot.viewport = Some(ViewportInfo {
top_byte: 100,
top_line: Some(5),
left_column: 5,
width: 80,
height: 24,
});
}
let api = PluginApi::new(hooks, commands, tx, state_snapshot);
let viewport = api.get_viewport();
assert!(viewport.is_some());
let viewport = viewport.unwrap();
assert_eq!(viewport.top_byte, 100);
assert_eq!(viewport.left_column, 5);
assert_eq!(viewport.width, 80);
assert_eq!(viewport.height, 24);
}
#[test]
fn test_composite_buffer_options_rejects_unknown_fields() {
let valid_json = r#"{
"name": "test",
"mode": "diff",
"layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
"sources": [{"bufferId": 1, "label": "old"}]
}"#;
let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(valid_json);
assert!(
result.is_ok(),
"Valid JSON should parse: {:?}",
result.err()
);
let invalid_json = r#"{
"name": "test",
"mode": "diff",
"layout": {"type": "side-by-side", "ratios": [0.5, 0.5], "showSeparator": true},
"sources": [{"buffer_id": 1, "label": "old"}]
}"#;
let result: Result<CreateCompositeBufferOptions, _> = serde_json::from_str(invalid_json);
assert!(
result.is_err(),
"JSON with unknown field should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field") || err.contains("buffer_id"),
"Error should mention unknown field: {}",
err
);
}
#[test]
fn test_composite_hunk_rejects_unknown_fields() {
let valid_json = r#"{"oldStart": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
let result: Result<CompositeHunk, _> = serde_json::from_str(valid_json);
assert!(
result.is_ok(),
"Valid JSON should parse: {:?}",
result.err()
);
let invalid_json = r#"{"old_start": 0, "oldCount": 5, "newStart": 0, "newCount": 7}"#;
let result: Result<CompositeHunk, _> = serde_json::from_str(invalid_json);
assert!(
result.is_err(),
"JSON with unknown field should fail to parse"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown field") || err.contains("old_start"),
"Error should mention unknown field: {}",
err
);
}
#[test]
fn test_plugin_response_line_end_position() {
let response = PluginResponse::LineEndPosition {
request_id: 42,
position: Some(100),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("LineEndPosition"));
assert!(json.contains("42"));
assert!(json.contains("100"));
let response_none = PluginResponse::LineEndPosition {
request_id: 1,
position: None,
};
let json_none = serde_json::to_string(&response_none).unwrap();
assert!(json_none.contains("null"));
}
#[test]
fn test_plugin_response_buffer_line_count() {
let response = PluginResponse::BufferLineCount {
request_id: 99,
count: Some(500),
};
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("BufferLineCount"));
assert!(json.contains("99"));
assert!(json.contains("500"));
}
#[test]
fn test_plugin_command_get_line_end_position() {
let command = PluginCommand::GetLineEndPosition {
buffer_id: BufferId(1),
line: 10,
request_id: 123,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("GetLineEndPosition"));
assert!(json.contains("10"));
}
#[test]
fn test_plugin_command_get_buffer_line_count() {
let command = PluginCommand::GetBufferLineCount {
buffer_id: BufferId(0),
request_id: 456,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("GetBufferLineCount"));
assert!(json.contains("456"));
}
#[test]
fn test_plugin_command_scroll_to_line_center() {
let command = PluginCommand::ScrollToLineCenter {
split_id: SplitId(1),
buffer_id: BufferId(2),
line: 50,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("ScrollToLineCenter"));
assert!(json.contains("50"));
}
#[test]
fn js_callback_id_conversions_and_display() {
for raw in [0u64, 1, 42, u64::MAX] {
let id = JsCallbackId::new(raw);
assert_eq!(id.as_u64(), raw);
assert_eq!(u64::from(id), raw);
assert_eq!(JsCallbackId::from(raw), id);
assert_eq!(id.to_string(), raw.to_string());
}
}
#[test]
fn serde_defaults_fire_when_fields_are_omitted() {
let spec: ActionSpec = serde_json::from_str(r#"{"action": "move_left"}"#).unwrap();
assert_eq!(spec.count, 1);
let spec: ActionSpec =
serde_json::from_str(r#"{"action": "move_left", "count": 5}"#).unwrap();
assert_eq!(spec.count, 5);
let layout: CompositeLayoutConfig =
serde_json::from_str(r#"{"type": "side-by-side"}"#).unwrap();
assert!(layout.show_separator);
let layout: CompositeLayoutConfig =
serde_json::from_str(r#"{"type": "side-by-side", "showSeparator": false}"#).unwrap();
assert!(!layout.show_separator);
let reg: TsCompletionProviderRegistration =
serde_json::from_str(r#"{"id": "p", "displayName": "P"}"#).unwrap();
assert_eq!(reg.priority, 50);
let reg: TsCompletionProviderRegistration =
serde_json::from_str(r#"{"id": "p", "displayName": "P", "priority": 3}"#).unwrap();
assert_eq!(reg.priority, 3);
}
fn mk_cmd(name: &str) -> Command {
Command {
name: name.to_string(),
description: String::new(),
action_name: String::new(),
plugin_name: String::new(),
custom_contexts: Vec::new(),
terminal_bypass: false,
}
}
#[test]
fn command_registry_register_and_unregister_semantics() {
let r = CommandRegistry::new();
r.register(mk_cmd("a"));
r.register(mk_cmd("b"));
assert_eq!(r.commands.read().unwrap().len(), 2);
r.register(mk_cmd("a"));
let names: Vec<String> = r
.commands
.read()
.unwrap()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["b".to_string(), "a".to_string()]);
r.unregister("a");
let names: Vec<String> = r
.commands
.read()
.unwrap()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["b".to_string()]);
r.unregister("nope");
assert_eq!(r.commands.read().unwrap().len(), 1);
}
#[test]
fn overlay_color_spec_accessors_are_variant_specific() {
let rgb = OverlayColorSpec::rgb(12, 34, 56);
assert_eq!(rgb.as_rgb(), Some((12, 34, 56)));
assert_eq!(rgb.as_theme_key(), None);
let tk = OverlayColorSpec::theme_key("ui.status_bar_bg");
assert_eq!(tk.as_rgb(), None);
assert_eq!(tk.as_theme_key(), Some("ui.status_bar_bg"));
}
#[test]
fn plugin_command_debug_variant_name_returns_real_variant() {
let c = PluginCommand::SetStatus {
message: "hi".into(),
};
assert_eq!(c.debug_variant_name(), "SetStatus");
let c2 = PluginCommand::InsertText {
buffer_id: BufferId(1),
position: 0,
text: String::new(),
};
assert_eq!(c2.debug_variant_name(), "InsertText");
}
type MkApi = (
PluginApi,
std::sync::mpsc::Receiver<PluginCommand>,
Arc<RwLock<HookRegistry>>,
Arc<RwLock<CommandRegistry>>,
Arc<RwLock<EditorStateSnapshot>>,
);
fn mk_api() -> MkApi {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let (tx, rx) = std::sync::mpsc::channel();
let snap = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let api = PluginApi::new(hooks.clone(), commands.clone(), tx, snap.clone());
(api, rx, hooks, commands, snap)
}
#[test]
fn plugin_api_unregister_hooks_clears_registry() {
let (api, _rx, hooks, _cmds, _snap) = mk_api();
api.register_hook("h", Box::new(|_| true));
assert_eq!(hooks.read().unwrap().hook_count("h"), 1);
api.unregister_hooks("h");
assert_eq!(hooks.read().unwrap().hook_count("h"), 0);
}
#[test]
fn plugin_api_register_and_unregister_command_write_through() {
let (api, _rx, _hooks, cmds, _snap) = mk_api();
api.register_command(mk_cmd("x"));
assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 1);
api.unregister_command("x");
assert_eq!(cmds.read().unwrap().commands.read().unwrap().len(), 0);
}
macro_rules! assert_dispatches {
($call:expr, $pattern:pat $(if $guard:expr)?) => {{
let (api, rx, _h, _c, _s) = mk_api();
let _ = $call(&api);
match rx.try_recv().expect("no command sent") {
$pattern $(if $guard)? => {}
other => panic!("unexpected command variant: {:?}", other),
}
}};
}
#[test]
fn plugin_api_send_command_methods_dispatch_correctly() {
assert_dispatches!(
|a: &PluginApi| a.delete_range(BufferId(7), 3..9),
PluginCommand::DeleteRange { buffer_id, range }
if buffer_id == BufferId(7) && range == (3..9)
);
assert_dispatches!(
|a: &PluginApi| a.remove_overlay(BufferId(2), "h-1".into()),
PluginCommand::RemoveOverlay { buffer_id, handle }
if buffer_id == BufferId(2) && handle.as_str() == "h-1"
);
assert_dispatches!(
|a: &PluginApi| a.clear_namespace(BufferId(3), "diag".into()),
PluginCommand::ClearNamespace { buffer_id, namespace }
if buffer_id == BufferId(3) && namespace.as_str() == "diag"
);
assert_dispatches!(
|a: &PluginApi| a.clear_overlays_in_range(BufferId(4), 10, 20),
PluginCommand::ClearOverlaysInRange { buffer_id, start, end }
if buffer_id == BufferId(4) && start == 10 && end == 20
);
assert_dispatches!(
|a: &PluginApi| a.open_file_at_location(
PathBuf::from("/tmp/x.rs"), Some(4), Some(8)
),
PluginCommand::OpenFileAtLocation { path, line, column }
if path == Path::new("/tmp/x.rs")
&& line == Some(4)
&& column == Some(8)
);
assert_dispatches!(
|a: &PluginApi| a.open_file_in_split(
2, PathBuf::from("/tmp/y.rs"), Some(5), None
),
PluginCommand::OpenFileInSplit { split_id, path, line, column }
if split_id == 2
&& path == Path::new("/tmp/y.rs")
&& line == Some(5)
&& column.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.start_prompt("label".into(), "cmd".into()),
PluginCommand::StartPrompt { label, prompt_type, floating_overlay }
if label == "label" && prompt_type == "cmd" && !floating_overlay
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_suggestions(vec![
Suggestion::new("one".into()),
Suggestion::new("two".into()),
]),
PluginCommand::SetPromptSuggestions { suggestions }
if suggestions.len() == 2
&& suggestions[0].text == "one"
&& suggestions[1].text == "two"
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_input_sync(true),
PluginCommand::SetPromptInputSync { sync } if sync
);
assert_dispatches!(
|a: &PluginApi| a.set_prompt_input_sync(false),
PluginCommand::SetPromptInputSync { sync } if !sync
);
assert_dispatches!(
|a: &PluginApi| a.add_menu_item(
"File".into(),
MenuItem::Label { info: "info".into() },
MenuPosition::Bottom,
),
PluginCommand::AddMenuItem { menu_label, item, position }
if menu_label == "File"
&& matches!(item, MenuItem::Label { ref info } if info == "info")
&& matches!(position, MenuPosition::Bottom)
);
assert_dispatches!(
|a: &PluginApi| a.add_menu(
Menu {
id: None,
label: "Help".into(),
items: vec![],
when: None,
},
MenuPosition::After("Edit".into()),
),
PluginCommand::AddMenu { menu, position }
if menu.label == "Help"
&& matches!(position, MenuPosition::After(ref s) if s == "Edit")
);
assert_dispatches!(
|a: &PluginApi| a.remove_menu_item("File".into(), "Open".into()),
PluginCommand::RemoveMenuItem { menu_label, item_label }
if menu_label == "File" && item_label == "Open"
);
assert_dispatches!(
|a: &PluginApi| a.remove_menu("File".into()),
PluginCommand::RemoveMenu { menu_label } if menu_label == "File"
);
assert_dispatches!(
|a: &PluginApi| a.create_virtual_buffer("buf".into(), "mode".into(), true),
PluginCommand::CreateVirtualBuffer { name, mode, read_only }
if name == "buf" && mode == "mode" && read_only
);
assert_dispatches!(
|a: &PluginApi| a.create_virtual_buffer_with_content(
"n".into(), "m".into(), false, vec![]
),
PluginCommand::CreateVirtualBufferWithContent {
name, mode, read_only, show_line_numbers, show_cursors,
editing_disabled, hidden_from_tabs, request_id, ..
}
if name == "n" && mode == "m" && !read_only
&& show_line_numbers && show_cursors
&& !editing_disabled && !hidden_from_tabs
&& request_id.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.set_virtual_buffer_content(BufferId(9), vec![]),
PluginCommand::SetVirtualBufferContent { buffer_id, entries }
if buffer_id == BufferId(9) && entries.is_empty()
);
assert_dispatches!(
|a: &PluginApi| a.get_text_properties_at_cursor(BufferId(11)),
PluginCommand::GetTextPropertiesAtCursor { buffer_id }
if buffer_id == BufferId(11)
);
assert_dispatches!(
|a: &PluginApi| a.define_mode(
"m".into(),
vec![("j".into(), "move_down".into())],
true,
false,
),
PluginCommand::DefineMode {
name, bindings, read_only, allow_text_input, inherit_normal_bindings, plugin_name
}
if name == "m"
&& bindings.len() == 1
&& bindings[0].0 == "j"
&& bindings[0].1 == "move_down"
&& read_only
&& !allow_text_input
&& !inherit_normal_bindings
&& plugin_name.is_none()
);
assert_dispatches!(
|a: &PluginApi| a.show_buffer(BufferId(77)),
PluginCommand::ShowBuffer { buffer_id } if buffer_id == BufferId(77)
);
assert_dispatches!(
|a: &PluginApi| a.set_split_scroll(5, 128),
PluginCommand::SetSplitScroll { split_id, top_byte }
if split_id == SplitId(5) && top_byte == 128
);
assert_dispatches!(
|a: &PluginApi| a.get_highlights(BufferId(1), 0..10, 7),
PluginCommand::RequestHighlights { buffer_id, range, request_id }
if buffer_id == BufferId(1) && range == (0..10) && request_id == 7
);
}
#[test]
fn plugin_api_get_active_split_id_reads_snapshot() {
let (api, _rx, _h, _c, snap) = mk_api();
snap.write().unwrap().active_split_id = 42;
assert_eq!(api.get_active_split_id(), 42);
}
#[test]
fn plugin_api_state_snapshot_handle_shares_underlying_arc() {
let (api, _rx, _h, _c, snap) = mk_api();
snap.write().unwrap().active_buffer_id = BufferId(42);
let h = api.state_snapshot_handle();
assert_eq!(h.read().unwrap().active_buffer_id, BufferId(42));
assert!(Arc::ptr_eq(&h, &snap));
}
#[test]
fn plugin_command_kill_host_process_serde_round_trip() {
let cmd = PluginCommand::KillHostProcess { process_id: 1234 };
let json = serde_json::to_value(&cmd).unwrap();
assert_eq!(json["KillHostProcess"]["process_id"], 1234);
let decoded: PluginCommand = serde_json::from_value(json).unwrap();
match decoded {
PluginCommand::KillHostProcess { process_id } => assert_eq!(process_id, 1234),
other => panic!("expected KillHostProcess, got {:?}", other),
}
}
fn dummy_match(line: usize) -> GrepMatch {
GrepMatch {
file: "fixture.rs".to_string(),
buffer_id: 0,
byte_offset: 0,
length: 4,
line,
column: 1,
context: "match".to_string(),
}
}
#[test]
fn search_handle_batches_between_takes() {
let handle = Arc::new(SearchHandleState::new());
for chunk in [vec![dummy_match(1), dummy_match(2)], vec![dummy_match(3)]] {
let count = chunk.len();
let mut state = handle.state.lock().unwrap();
state.pending.extend(chunk);
state.total_seen += count;
}
let drained: Vec<_> = {
let mut s = handle.state.lock().unwrap();
std::mem::take(&mut s.pending)
};
assert_eq!(drained.len(), 3);
assert_eq!(handle.state.lock().unwrap().total_seen, 3);
let empty: Vec<_> = {
let mut s = handle.state.lock().unwrap();
std::mem::take(&mut s.pending)
};
assert!(empty.is_empty());
}
#[test]
fn search_handle_cancel_is_observable() {
let handle = Arc::new(SearchHandleState::new());
assert!(!handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
handle
.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(handle.cancel.load(std::sync::atomic::Ordering::Relaxed));
assert!(!handle.state.lock().unwrap().done);
}
#[test]
fn search_handle_done_transition_is_visible_to_consumer() {
let handle = Arc::new(SearchHandleState::new());
{
let mut s = handle.state.lock().unwrap();
s.pending.push(dummy_match(7));
s.total_seen += 1;
s.truncated = true;
s.done = true;
}
let (matches, done, truncated) = {
let mut s = handle.state.lock().unwrap();
(std::mem::take(&mut s.pending), s.done, s.truncated)
};
assert_eq!(matches.len(), 1);
assert!(done);
assert!(truncated);
}
#[test]
fn search_handle_concurrent_producer_consumer() {
let handle = Arc::new(SearchHandleState::new());
let producer = Arc::clone(&handle);
let writer = std::thread::spawn(move || {
for line in 1..=200 {
let mut s = producer.state.lock().unwrap();
s.pending.push(dummy_match(line));
s.total_seen += 1;
}
producer.state.lock().unwrap().done = true;
});
let mut drained: Vec<GrepMatch> = Vec::new();
loop {
let (mut batch, done) = {
let mut s = handle.state.lock().unwrap();
(std::mem::take(&mut s.pending), s.done)
};
drained.append(&mut batch);
if done {
let mut tail = handle.state.lock().unwrap();
drained.append(&mut std::mem::take(&mut tail.pending));
break;
}
std::thread::yield_now();
}
writer.join().unwrap();
assert_eq!(drained.len(), 200);
}
}