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 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, 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>,
},
}
#[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>>,
}
#[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
}
#[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,
}
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()
}
fn serialize_opt_ranges_as_tuples<S>(
ranges: &Option<Vec<Range<usize>>>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match ranges {
Some(ranges) => {
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()
}
None => serializer.serialize_none(),
}
}
#[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>>,
#[serde(serialize_with = "serialize_opt_ranges_as_tuples")]
#[ts(type = "Array<[number, number]> | null")]
pub line_ranges: Option<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 LayoutHints {
pub compose_width: Option<u16>,
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),
}
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, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[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)]
pub ratios: Option<Vec<f32>>,
#[serde(default = "default_true", rename = "showSeparator")]
#[ts(rename = "showSeparator")]
pub show_separator: bool,
#[serde(default)]
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(rename = "addBg", type = "[number, number, number] | null")]
pub add_bg: Option<[u8; 3]>,
#[serde(default, rename = "removeBg")]
#[ts(rename = "removeBg", type = "[number, number, number] | null")]
pub remove_bg: Option<[u8; 3]>,
#[serde(default, rename = "modifyBg")]
#[ts(rename = "modifyBg", type = "[number, number, number] | null")]
pub modify_bg: Option<[u8; 3]>,
#[serde(default, rename = "gutterStyle")]
#[ts(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>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum ViewTokenWireKind {
Text(String),
Newline,
Space,
Break,
BinaryByte(u8),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(deny_unknown_fields)]
#[ts(export)]
pub struct ViewTokenStyle {
#[serde(default)]
#[ts(type = "[number, number, number] | null")]
pub fg: Option<(u8, u8, u8)>,
#[serde(default)]
#[ts(type = "[number, number, number] | null")]
pub bg: Option<(u8, u8, u8)>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: 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)]
#[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>,
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,
#[ts(type = "any")]
pub diagnostics: HashMap<String, Vec<lsp_types::Diagnostic>>,
#[ts(type = "any")]
pub folding_ranges: HashMap<String, Vec<lsp_types::FoldingRange>>,
#[ts(type = "any")]
pub config: serde_json::Value,
#[ts(type = "any")]
pub user_config: serde_json::Value,
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,
}
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,
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(".")),
diagnostics: HashMap::new(),
folding_ranges: HashMap::new(),
config: serde_json::Value::Null,
user_config: serde_json::Value::Null,
editor_mode: None,
plugin_view_states: HashMap::new(),
plugin_view_states_split: 0,
}
}
}
impl Default for EditorStateSnapshot {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum MenuPosition {
Top,
Bottom,
Before(String),
After(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 },
ReloadConfig,
RegisterCommand { command: Command },
UnregisterCommand { name: String },
OpenFileInBackground { path: PathBuf },
InsertAtCursor { text: String },
SpawnProcess {
command: String,
args: Vec<String>,
cwd: Option<String>,
callback_id: JsCallbackId,
},
Delay {
callback_id: JsCallbackId,
duration_ms: u64,
},
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>,
},
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, },
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: (u8, u8, u8),
bg_color: Option<(u8, u8, u8)>,
above: bool,
namespace: String,
priority: i32,
},
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,
},
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, },
StartPromptWithInitial {
label: String,
prompt_type: String,
initial_value: String,
},
StartPromptAsync {
label: String,
initial_value: String,
callback_id: JsCallbackId,
},
SetPromptSuggestions { suggestions: Vec<Suggestion> },
SetPromptInputSync { sync: bool },
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,
request_id: Option<u64>,
},
SetVirtualBufferContent {
buffer_id: BufferId,
entries: Vec<TextPropertyEntry>,
},
GetTextPropertiesAtCursor { buffer_id: BufferId },
DefineMode {
name: String,
parent: Option<String>,
bindings: Vec<(String, String)>, read_only: bool,
},
ShowBuffer { buffer_id: BufferId },
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 },
CreateCompositeBuffer {
name: String,
mode: String,
layout: CompositeLayoutConfig,
sources: Vec<CompositeSourceConfig>,
hunks: Option<Vec<CompositeHunk>>,
request_id: Option<u64>,
},
UpdateCompositeAlignment {
buffer_id: BufferId,
hunks: Vec<CompositeHunk>,
},
CloseCompositeBuffer { 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,
},
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,
},
ScrollToLineCenter {
split_id: SplitId,
buffer_id: BufferId,
line: usize,
},
SetEditorMode {
mode: Option<String>,
},
ShowActionPopup {
popup_id: String,
title: String,
message: String,
actions: Vec<ActionPopupAction>,
},
DisableLspForLanguage {
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,
RegisterGrammar {
language: String,
grammar_path: String,
extensions: Vec<String>,
},
RegisterLanguageConfig {
language: String,
config: LanguagePackConfig,
},
RegisterLspServer {
language: String,
config: LspServerPackConfig,
},
ReloadGrammars,
CreateTerminal {
cwd: Option<String>,
direction: Option<String>,
ratio: Option<f32>,
focus: Option<bool>,
request_id: u64,
},
SendTerminalInput {
terminal_id: TerminalId,
data: String,
},
CloseTerminal {
terminal_id: TerminalId,
},
}
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 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>,
}
#[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)]
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(deny_unknown_fields)]
#[ts(export, rename = "TextPropertyEntry")]
pub struct JsTextPropertyEntry {
pub text: String,
#[serde(default)]
#[ts(optional, type = "Record<string, unknown>")]
pub properties: Option<HashMap<String, JsonValue>>,
}
#[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>>,
}
#[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>,
}
#[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};
impl<'js> FromJs<'js> for JsTextPropertyEntry {
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: "JsTextPropertyEntry",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for CreateVirtualBufferOptions {
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: "CreateVirtualBufferOptions",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for CreateVirtualBufferInSplitOptions {
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: "CreateVirtualBufferInSplitOptions",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for CreateVirtualBufferInExistingSplitOptions {
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: "CreateVirtualBufferInExistingSplitOptions",
message: Some(e.to_string()),
})
}
}
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 ActionSpec {
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: "ActionSpec",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for ActionPopupAction {
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: "ActionPopupAction",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for ActionPopupOptions {
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: "ActionPopupOptions",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for ViewTokenWire {
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: "ViewTokenWire",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for ViewTokenStyle {
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: "ViewTokenStyle",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for LayoutHints {
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: "LayoutHints",
message: Some(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()),
})
}
}
impl<'js> FromJs<'js> for CompositeHunk {
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: "CompositeHunk",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for LanguagePackConfig {
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: "LanguagePackConfig",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for LspServerPackConfig {
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: "LspServerPackConfig",
message: Some(e.to_string()),
})
}
}
impl<'js> FromJs<'js> for CreateTerminalOptions {
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: "CreateTerminalOptions",
message: Some(e.to_string()),
})
}
}
}
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 })
}
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 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,
parent: Option<String>,
bindings: Vec<(String, String)>,
read_only: bool,
) -> Result<(), String> {
self.send_command(PluginCommand::DefineMode {
name,
parent,
bindings,
read_only,
})
}
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 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),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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,
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(),
};
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(),
},
);
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(),
},
);
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(),
},
);
}
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),
});
}
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,
},
CursorInfo {
position: 20,
selection: Some(15..20),
},
CursorInfo {
position: 30,
selection: Some(25..30),
},
];
}
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"));
}
}