use crate::input::commands::Suggestion;
use crate::model::event::BufferId;
use crate::model::event::SplitId;
use crate::services::plugins::api::{
EditorStateSnapshot, LayoutHints, PluginCommand, ViewTokenWire,
};
use anyhow::{anyhow, Result};
use deno_core::{
error::ModuleLoaderError, extension, op2, FastString, JsRuntime, ModuleLoadOptions,
ModuleLoadReferrer, ModuleLoadResponse, ModuleSource, ModuleSourceCode, ModuleSpecifier,
ModuleType, OpState, ResolutionKind, RuntimeOptions,
};
use deno_error::JsErrorBox;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, RwLock};
struct TypeScriptModuleLoader;
impl deno_core::ModuleLoader for TypeScriptModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: ResolutionKind,
) -> Result<ModuleSpecifier, ModuleLoaderError> {
deno_core::resolve_import(specifier, referrer)
.map_err(|e| JsErrorBox::generic(e.to_string()))
}
fn load(
&self,
module_specifier: &ModuleSpecifier,
_maybe_referrer: Option<&ModuleLoadReferrer>,
_options: ModuleLoadOptions,
) -> ModuleLoadResponse {
let specifier = module_specifier.clone();
let module_load = async move {
let path = specifier
.to_file_path()
.map_err(|_| JsErrorBox::generic(format!("Invalid file URL: {}", specifier)))?;
let code = std::fs::read_to_string(&path).map_err(|e| {
JsErrorBox::generic(format!("Failed to read {}: {}", path.display(), e))
})?;
let (code, module_type) = if path.extension().and_then(|s| s.to_str()) == Some("ts") {
let transpiled = transpile_typescript(&code, &specifier)?;
(transpiled, ModuleType::JavaScript)
} else {
(code, ModuleType::JavaScript)
};
let module_source = ModuleSource::new(
module_type,
ModuleSourceCode::String(code.into()),
&specifier,
None,
);
Ok(module_source)
};
ModuleLoadResponse::Async(Box::pin(module_load))
}
}
fn transpile_typescript(source: &str, specifier: &ModuleSpecifier) -> Result<String, JsErrorBox> {
use deno_ast::{EmitOptions, MediaType, ParseParams, TranspileOptions};
let parsed = deno_ast::parse_module(ParseParams {
specifier: specifier.clone(),
text: source.into(),
media_type: MediaType::TypeScript,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.map_err(|e| JsErrorBox::generic(format!("TypeScript parse error: {}", e)))?;
let transpiled = parsed
.transpile(
&TranspileOptions::default(),
&Default::default(),
&EmitOptions::default(),
)
.map_err(|e| JsErrorBox::generic(format!("TypeScript transpile error: {}", e)))?;
Ok(transpiled.into_source().text.to_string())
}
struct TsRuntimeState {
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
event_handlers: Rc<RefCell<HashMap<String, Vec<String>>>>,
pending_responses: Arc<
std::sync::Mutex<
HashMap<
u64,
tokio::sync::oneshot::Sender<crate::services::plugins::api::PluginResponse>,
>,
>,
>,
next_request_id: Rc<RefCell<u64>>,
background_processes: Rc<RefCell<HashMap<u64, tokio::process::Child>>>,
next_process_id: Rc<RefCell<u64>>,
}
#[op2(fast)]
fn op_fresh_set_status(state: &mut OpState, #[string] message: String) {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let _ = runtime_state.command_sender.send(PluginCommand::SetStatus {
message: message.clone(),
});
}
tracing::info!("TypeScript plugin set_status: {}", message);
}
#[op2(fast)]
fn op_fresh_apply_theme(state: &mut OpState, #[string] theme_name: String) {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let _ = runtime_state
.command_sender
.send(PluginCommand::ApplyTheme {
theme_name: theme_name.clone(),
});
}
tracing::info!("TypeScript plugin apply_theme: {}", theme_name);
}
#[op2(fast)]
fn op_fresh_reload_config(state: &mut OpState) {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let _ = runtime_state
.command_sender
.send(PluginCommand::ReloadConfig);
}
tracing::debug!("TypeScript plugin: reloading config");
}
#[op2]
#[serde]
fn op_fresh_get_config(state: &mut OpState) -> serde_json::Value {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot.config.clone();
};
}
serde_json::Value::Object(serde_json::Map::new())
}
#[op2]
#[serde]
fn op_fresh_get_user_config(state: &mut OpState) -> serde_json::Value {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot.user_config.clone();
};
}
serde_json::Value::Object(serde_json::Map::new())
}
#[op2(fast)]
fn op_fresh_debug(#[string] message: String) {
tracing::debug!("TypeScript plugin: {}", message);
}
#[op2(fast)]
fn op_fresh_set_clipboard(state: &mut OpState, #[string] text: String) {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let _ = runtime_state
.command_sender
.send(PluginCommand::SetClipboard { text: text.clone() });
}
tracing::debug!("TypeScript plugin set_clipboard: {} chars", text.len());
}
#[op2(fast)]
fn op_fresh_get_active_buffer_id(state: &mut OpState) -> u32 {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot.active_buffer_id.0 as u32;
};
}
0
}
#[op2(fast)]
fn op_fresh_get_cursor_position(state: &mut OpState) -> u32 {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(ref cursor) = snapshot.primary_cursor {
return cursor.position as u32;
}
};
}
0
}
#[op2]
#[string]
fn op_fresh_get_buffer_path(state: &mut OpState, buffer_id: u32) -> String {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(info) = snapshot.buffers.get(&BufferId(buffer_id as usize)) {
if let Some(ref path) = info.path {
return path.to_string_lossy().to_string();
}
}
};
}
String::new()
}
#[op2(fast)]
fn op_fresh_get_buffer_length(state: &mut OpState, buffer_id: u32) -> u32 {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(info) = snapshot.buffers.get(&BufferId(buffer_id as usize)) {
return info.length as u32;
}
};
}
0
}
#[op2(fast)]
fn op_fresh_is_buffer_modified(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(info) = snapshot.buffers.get(&BufferId(buffer_id as usize)) {
return info.modified;
}
};
}
false
}
#[op2(fast)]
fn op_fresh_insert_text(
state: &mut OpState,
buffer_id: u32,
position: u32,
#[string] text: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::InsertText {
buffer_id: BufferId(buffer_id as usize),
position: position as usize,
text,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_delete_range(state: &mut OpState, buffer_id: u32, start: u32, end: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::DeleteRange {
buffer_id: BufferId(buffer_id as usize),
range: (start as usize)..(end as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_add_overlay(
state: &mut OpState,
buffer_id: u32,
#[string] namespace: String,
start: u32,
end: u32,
r: u8,
g: u8,
b: u8,
underline: bool,
bold: bool,
italic: bool,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let ns = if namespace.is_empty() {
None
} else {
Some(crate::view::overlay::OverlayNamespace::from_string(
namespace,
))
};
let result = runtime_state
.command_sender
.send(PluginCommand::AddOverlay {
buffer_id: BufferId(buffer_id as usize),
namespace: ns,
range: (start as usize)..(end as usize),
color: (r, g, b),
underline,
bold,
italic,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_remove_overlay(state: &mut OpState, buffer_id: u32, #[string] handle: String) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::RemoveOverlay {
buffer_id: BufferId(buffer_id as usize),
handle: crate::view::overlay::OverlayHandle::from_string(handle),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_namespace(
state: &mut OpState,
buffer_id: u32,
#[string] namespace: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: crate::view::overlay::OverlayNamespace::from_string(namespace),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_overlays_in_range(
state: &mut OpState,
buffer_id: u32,
start: u32,
end: u32,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearOverlaysInRange {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_set_line_numbers(state: &mut OpState, buffer_id: u32, enabled: bool) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetLineNumbers {
buffer_id: BufferId(buffer_id as usize),
enabled,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_all_overlays(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearAllOverlays {
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
#[allow(clippy::too_many_arguments)]
fn op_fresh_add_virtual_text(
state: &mut OpState,
buffer_id: u32,
#[string] virtual_text_id: String,
position: u32,
#[string] text: String,
r: u8,
g: u8,
b: u8,
before: bool,
use_bg: bool,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::AddVirtualText {
buffer_id: BufferId(buffer_id as usize),
virtual_text_id,
position: position as usize,
text,
color: (r, g, b),
use_bg,
before,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_remove_virtual_text(
state: &mut OpState,
buffer_id: u32,
#[string] virtual_text_id: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::RemoveVirtualText {
buffer_id: BufferId(buffer_id as usize),
virtual_text_id,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_remove_virtual_texts_by_prefix(
state: &mut OpState,
buffer_id: u32,
#[string] prefix: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::RemoveVirtualTextsByPrefix {
buffer_id: BufferId(buffer_id as usize),
prefix,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_virtual_texts(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearVirtualTexts {
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
#[allow(clippy::too_many_arguments)]
fn op_fresh_add_virtual_line(
state: &mut OpState,
buffer_id: u32,
position: u32,
#[string] text: String,
fg_r: u8,
fg_g: u8,
fg_b: u8,
bg_r: i16,
bg_g: i16,
bg_b: i16,
above: bool,
#[string] namespace: String,
priority: i32,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let bg_color = if bg_r >= 0 && bg_g >= 0 && bg_b >= 0 {
Some((bg_r as u8, bg_g as u8, bg_b as u8))
} else {
None
};
let result = runtime_state
.command_sender
.send(PluginCommand::AddVirtualLine {
buffer_id: BufferId(buffer_id as usize),
position: position as usize,
text,
fg_color: (fg_r, fg_g, fg_b),
bg_color,
above,
namespace,
priority,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_virtual_text_namespace(
state: &mut OpState,
buffer_id: u32,
#[string] namespace: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearVirtualTextNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_refresh_lines(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::RefreshLines {
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
#[allow(clippy::too_many_arguments)]
fn op_fresh_set_line_indicator(
state: &mut OpState,
buffer_id: u32,
line: u32,
#[string] namespace: String,
#[string] symbol: String,
r: u8,
g: u8,
b: u8,
priority: i32,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetLineIndicator {
buffer_id: BufferId(buffer_id as usize),
line: line as usize,
namespace,
symbol,
color: (r, g, b),
priority,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_clear_line_indicators(
state: &mut OpState,
buffer_id: u32,
#[string] namespace: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ClearLineIndicators {
buffer_id: BufferId(buffer_id as usize),
namespace,
});
return result.is_ok();
}
false
}
#[op2]
fn op_fresh_submit_view_transform(
state: &mut OpState,
buffer_id: u32,
split_id: Option<u32>,
start: u32,
end: u32,
#[serde] tokens: Vec<ViewTokenWire>,
#[serde] layout_hints: Option<LayoutHints>,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let hints = layout_hints.unwrap_or(LayoutHints {
compose_width: None,
column_guides: None,
});
let split_id = split_id.map(|id| SplitId(id as usize));
let result = runtime_state
.command_sender
.send(PluginCommand::SetLayoutHints {
buffer_id: BufferId(buffer_id as usize),
split_id,
range: start as usize..end as usize,
hints: hints.clone(),
});
let _ = runtime_state
.command_sender
.send(PluginCommand::SubmitViewTransform {
buffer_id: BufferId(buffer_id as usize),
split_id,
payload: crate::services::plugins::api::ViewTransformPayload {
range: start as usize..end as usize,
tokens,
layout_hints: Some(hints),
},
});
return result.is_ok();
}
false
}
#[op2]
fn op_fresh_clear_view_transform(
state: &mut OpState,
buffer_id: u32,
split_id: Option<u32>,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let split_id = split_id.map(|id| SplitId(id as usize));
let result = runtime_state
.command_sender
.send(PluginCommand::ClearViewTransform {
buffer_id: BufferId(buffer_id as usize),
split_id,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_insert_at_cursor(state: &mut OpState, #[string] text: String) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::InsertAtCursor { text });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_register_command(
state: &mut OpState,
#[string] name: String,
#[string] description: String,
#[string] action: String,
#[string] contexts: String,
#[string] source: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let mut context_list: Vec<crate::input::keybindings::KeyContext> = Vec::new();
let mut custom_context_list: Vec<String> = Vec::new();
if !contexts.trim().is_empty() {
for ctx in contexts.split(',').map(|s| s.trim()) {
if ctx.is_empty() {
continue;
}
match ctx.to_lowercase().as_str() {
"global" => context_list.push(crate::input::keybindings::KeyContext::Global),
"normal" => context_list.push(crate::input::keybindings::KeyContext::Normal),
"prompt" => context_list.push(crate::input::keybindings::KeyContext::Prompt),
"popup" => context_list.push(crate::input::keybindings::KeyContext::Popup),
"fileexplorer" | "file_explorer" => {
context_list.push(crate::input::keybindings::KeyContext::FileExplorer)
}
"menu" => context_list.push(crate::input::keybindings::KeyContext::Menu),
_ => {
custom_context_list.push(ctx.to_string());
}
}
}
}
let command_source = if source.is_empty() {
crate::input::commands::CommandSource::Builtin
} else {
crate::input::commands::CommandSource::Plugin(source)
};
let command = crate::input::commands::Command {
name: name.clone(),
description,
action: crate::input::keybindings::Action::PluginAction(action),
contexts: context_list,
custom_contexts: custom_context_list,
source: command_source,
};
let result = runtime_state
.command_sender
.send(PluginCommand::RegisterCommand { command });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_unregister_command(state: &mut OpState, #[string] name: String) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::UnregisterCommand { name });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_set_context(state: &mut OpState, #[string] name: String, active: bool) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetContext { name, active });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_open_file(state: &mut OpState, #[string] path: String, line: u32, column: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::OpenFileAtLocation {
path: std::path::PathBuf::from(path),
line: if line == 0 { None } else { Some(line as usize) },
column: if column == 0 {
None
} else {
Some(column as usize)
},
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_get_active_split_id(state: &mut OpState) -> u32 {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot.active_split_id as u32;
};
}
0
}
#[op2(fast)]
fn op_fresh_get_cursor_line(state: &mut OpState) -> u32 {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(cursor) = &snapshot.primary_cursor {
let _ = cursor.position;
return 1;
}
};
}
1
}
#[op2]
#[serde]
fn op_fresh_get_all_cursor_positions(state: &mut OpState) -> Vec<u32> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot
.all_cursors
.iter()
.map(|c| c.position as u32)
.collect();
};
}
vec![]
}
#[op2(fast)]
fn op_fresh_open_file_in_split(
state: &mut OpState,
split_id: u32,
#[string] path: String,
line: u32,
column: u32,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::OpenFileInSplit {
split_id: split_id as usize,
path: std::path::PathBuf::from(path),
line: if line == 0 { None } else { Some(line as usize) },
column: if column == 0 {
None
} else {
Some(column as usize)
},
});
return result.is_ok();
}
false
}
#[derive(serde::Serialize)]
struct SpawnResult {
stdout: String,
stderr: String,
exit_code: i32,
}
#[op2(async)]
#[serde]
async fn op_fresh_spawn_process(
#[string] command: String,
#[serde] args: Vec<String>,
#[string] cwd: Option<String>,
) -> Result<SpawnResult, JsErrorBox> {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
if tokio::runtime::Handle::try_current().is_err() {
return Err(JsErrorBox::generic(
"spawnProcess requires an async runtime context (tokio)",
));
}
let mut cmd = Command::new(&command);
cmd.args(&args);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if let Some(ref dir) = cwd {
cmd.current_dir(dir);
}
let mut child = cmd
.spawn()
.map_err(|e| JsErrorBox::generic(format!("Failed to spawn process: {}", e)))?;
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_future = async {
if let Some(stdout) = stdout_handle {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
let mut output = String::new();
while let Ok(Some(line)) = lines.next_line().await {
output.push_str(&line);
output.push('\n');
}
output
} else {
String::new()
}
};
let stderr_future = async {
if let Some(stderr) = stderr_handle {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
let mut output = String::new();
while let Ok(Some(line)) = lines.next_line().await {
output.push_str(&line);
output.push('\n');
}
output
} else {
String::new()
}
};
let (stdout, stderr) = tokio::join!(stdout_future, stderr_future);
let exit_code = match child.wait().await {
Ok(status) => status.code().unwrap_or(-1),
Err(_) => -1,
};
Ok(SpawnResult {
stdout,
stderr,
exit_code,
})
}
#[derive(serde::Serialize)]
struct BackgroundProcessResult {
process_id: u64,
}
#[op2(async)]
#[serde]
async fn op_fresh_spawn_background_process(
state: Rc<RefCell<OpState>>,
#[string] command: String,
#[serde] args: Vec<String>,
#[string] cwd: Option<String>,
) -> Result<BackgroundProcessResult, JsErrorBox> {
use std::process::Stdio;
use tokio::process::Command;
let mut cmd = Command::new(&command);
cmd.args(&args);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
cmd.stdin(Stdio::null());
if let Some(ref dir) = cwd {
cmd.current_dir(dir);
}
let child = cmd
.spawn()
.map_err(|e| JsErrorBox::generic(format!("Failed to spawn process: {}", e)))?;
let process_id = {
let op_state = state.borrow();
if let Some(runtime_state) = op_state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let mut id = runtime_state.next_process_id.borrow_mut();
let process_id = *id;
*id += 1;
drop(id);
runtime_state
.background_processes
.borrow_mut()
.insert(process_id, child);
process_id
} else {
return Err(JsErrorBox::generic("Runtime state not available"));
}
};
Ok(BackgroundProcessResult { process_id })
}
#[op2(async)]
async fn op_fresh_kill_process(
state: Rc<RefCell<OpState>>,
#[bigint] process_id: u64,
) -> Result<bool, JsErrorBox> {
let child_opt = {
let op_state = state.borrow();
if let Some(runtime_state) = op_state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let child = runtime_state
.background_processes
.borrow_mut()
.remove(&process_id);
child
} else {
return Ok(false);
}
};
if let Some(mut child) = child_opt {
let _ = child.kill().await;
Ok(true)
} else {
Ok(false)
}
}
#[op2(fast)]
#[allow(clippy::result_unit_err)]
fn op_fresh_is_process_running(state: &mut OpState, #[bigint] process_id: u64) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let mut processes = runtime_state.background_processes.borrow_mut();
if let Some(child) = processes.get_mut(&process_id) {
match child.try_wait() {
Ok(Some(_)) => {
processes.remove(&process_id);
false
}
Ok(None) => {
true
}
Err(_) => {
processes.remove(&process_id);
false
}
}
} else {
false
}
} else {
false
}
}
#[op2(fast)]
fn op_fresh_on(
state: &mut OpState,
#[string] event_name: String,
#[string] handler_name: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let mut handlers = runtime_state.event_handlers.borrow_mut();
handlers
.entry(event_name.clone())
.or_insert_with(Vec::new)
.push(handler_name.clone());
tracing::debug!(
"Registered event handler '{}' for '{}'",
handler_name,
event_name
);
return true;
}
false
}
#[op2(fast)]
fn op_fresh_off(
state: &mut OpState,
#[string] event_name: String,
#[string] handler_name: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let mut handlers = runtime_state.event_handlers.borrow_mut();
if let Some(handler_list) = handlers.get_mut(&event_name) {
if let Some(pos) = handler_list.iter().position(|h| h == &handler_name) {
handler_list.remove(pos);
tracing::debug!(
"Unregistered event handler '{}' from '{}'",
handler_name,
event_name
);
return true;
}
}
}
false
}
#[op2]
#[serde]
fn op_fresh_get_handlers(state: &mut OpState, #[string] event_name: String) -> Vec<String> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let handlers = runtime_state.event_handlers.borrow();
if let Some(handler_list) = handlers.get(&event_name) {
return handler_list.clone();
}
}
Vec::new()
}
#[derive(serde::Serialize)]
struct FileStat {
exists: bool,
is_file: bool,
is_dir: bool,
size: u64,
readonly: bool,
}
#[derive(serde::Serialize)]
struct TsBufferInfo {
id: u32,
path: String,
modified: bool,
length: u32,
}
#[derive(serde::Serialize)]
struct TsBufferSavedDiff {
equal: bool,
byte_ranges: Vec<(u32, u32)>,
line_ranges: Option<Vec<(u32, u32)>>,
}
#[derive(serde::Serialize)]
struct TsSelectionRange {
start: u32,
end: u32,
}
#[derive(serde::Serialize)]
struct TsCursorInfo {
position: u32,
selection: Option<TsSelectionRange>,
}
#[derive(serde::Serialize)]
struct TsDiagnosticPosition {
line: u32,
character: u32,
}
#[derive(serde::Serialize)]
struct TsDiagnosticRange {
start: TsDiagnosticPosition,
end: TsDiagnosticPosition,
}
#[derive(serde::Serialize)]
struct TsDiagnostic {
uri: String,
severity: u8,
message: String,
source: Option<String>,
range: TsDiagnosticRange,
}
#[derive(serde::Serialize)]
struct TsViewportInfo {
top_byte: u32,
left_column: u32,
width: u32,
height: u32,
}
#[op2]
#[serde]
fn op_fresh_get_buffer_info(state: &mut OpState, buffer_id: u32) -> Option<TsBufferInfo> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(info) = snapshot.buffers.get(&BufferId(buffer_id as usize)) {
return Some(TsBufferInfo {
id: info.id.0 as u32,
path: info
.path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
modified: info.modified,
length: info.length as u32,
});
}
};
}
None
}
#[op2]
#[serde]
fn op_fresh_get_buffer_saved_diff(
state: &mut OpState,
buffer_id: u32,
) -> Option<TsBufferSavedDiff> {
let runtime_state = state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()?
.borrow()
.state_snapshot
.read()
.ok()?
.buffer_saved_diffs
.get(&BufferId(buffer_id as usize))
.cloned()?;
Some(TsBufferSavedDiff {
equal: runtime_state.equal,
byte_ranges: runtime_state
.byte_ranges
.iter()
.map(|r| (r.start as u32, r.end as u32))
.collect(),
line_ranges: runtime_state.line_ranges.as_ref().map(|ranges| {
ranges
.iter()
.map(|r| (r.start as u32, r.end as u32))
.collect()
}),
})
}
#[op2]
#[serde]
fn op_fresh_list_buffers(state: &mut OpState) -> Vec<TsBufferInfo> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot
.buffers
.values()
.map(|info| TsBufferInfo {
id: info.id.0 as u32,
path: info
.path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
modified: info.modified,
length: info.length as u32,
})
.collect();
};
}
Vec::new()
}
#[op2]
#[serde]
fn op_fresh_get_all_diagnostics(state: &mut OpState) -> Vec<TsDiagnostic> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
let mut result = Vec::new();
for (uri, diagnostics) in &snapshot.diagnostics {
for diag in diagnostics {
let severity = match diag.severity {
Some(lsp_types::DiagnosticSeverity::ERROR) => 1,
Some(lsp_types::DiagnosticSeverity::WARNING) => 2,
Some(lsp_types::DiagnosticSeverity::INFORMATION) => 3,
Some(lsp_types::DiagnosticSeverity::HINT) => 4,
_ => 0,
};
result.push(TsDiagnostic {
uri: uri.clone(),
severity,
message: diag.message.clone(),
source: diag.source.clone(),
range: TsDiagnosticRange {
start: TsDiagnosticPosition {
line: diag.range.start.line,
character: diag.range.start.character,
},
end: TsDiagnosticPosition {
line: diag.range.end.line,
character: diag.range.end.character,
},
},
});
}
}
return result;
};
}
Vec::new()
}
#[op2]
#[serde]
fn op_fresh_get_primary_cursor(state: &mut OpState) -> Option<TsCursorInfo> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(ref cursor) = snapshot.primary_cursor {
return Some(TsCursorInfo {
position: cursor.position as u32,
selection: cursor.selection.as_ref().map(|sel| TsSelectionRange {
start: sel.start as u32,
end: sel.end as u32,
}),
});
}
};
}
None
}
#[op2]
#[serde]
fn op_fresh_get_all_cursors(state: &mut OpState) -> Vec<TsCursorInfo> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot
.all_cursors
.iter()
.map(|cursor| TsCursorInfo {
position: cursor.position as u32,
selection: cursor.selection.as_ref().map(|sel| TsSelectionRange {
start: sel.start as u32,
end: sel.end as u32,
}),
})
.collect();
};
}
Vec::new()
}
#[op2]
#[serde]
fn op_fresh_get_viewport(state: &mut OpState) -> Option<TsViewportInfo> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
if let Some(ref vp) = snapshot.viewport {
return Some(TsViewportInfo {
top_byte: vp.top_byte as u32,
left_column: vp.left_column as u32,
width: vp.width as u32,
height: vp.height as u32,
});
}
};
}
None
}
#[derive(serde::Deserialize)]
struct TsSuggestion {
text: String,
description: Option<String>,
value: Option<String>,
disabled: Option<bool>,
keybinding: Option<String>,
}
#[op2(fast)]
fn op_fresh_start_prompt(
state: &mut OpState,
#[string] label: String,
#[string] prompt_type: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::StartPrompt { label, prompt_type });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_start_prompt_with_initial(
state: &mut OpState,
#[string] label: String,
#[string] prompt_type: String,
#[string] initial_value: String,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::StartPromptWithInitial {
label,
prompt_type,
initial_value,
});
return result.is_ok();
}
false
}
#[op2]
fn op_fresh_set_prompt_suggestions(
state: &mut OpState,
#[serde] suggestions: Vec<TsSuggestion>,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let converted: Vec<Suggestion> = suggestions
.into_iter()
.map(|s| Suggestion {
text: s.text,
description: s.description,
value: s.value,
disabled: s.disabled.unwrap_or(false),
keybinding: s.keybinding,
source: None,
})
.collect();
let result = runtime_state
.command_sender
.send(PluginCommand::SetPromptSuggestions {
suggestions: converted,
});
return result.is_ok();
}
false
}
#[op2(async)]
#[string]
async fn op_fresh_read_file(#[string] path: String) -> Result<String, JsErrorBox> {
tokio::fs::read_to_string(&path)
.await
.map_err(|e| JsErrorBox::generic(format!("Failed to read file {}: {}", path, e)))
}
#[op2(async)]
async fn op_fresh_write_file(
#[string] path: String,
#[string] content: String,
) -> Result<(), JsErrorBox> {
tokio::fs::write(&path, content)
.await
.map_err(|e| JsErrorBox::generic(format!("Failed to write file {}: {}", path, e)))
}
#[op2(fast)]
fn op_fresh_file_exists(#[string] path: String) -> bool {
std::path::Path::new(&path).exists()
}
#[op2]
#[serde]
fn op_fresh_file_stat(#[string] path: String) -> FileStat {
let path = std::path::Path::new(&path);
match std::fs::metadata(path) {
Ok(metadata) => FileStat {
exists: true,
is_file: metadata.is_file(),
is_dir: metadata.is_dir(),
size: metadata.len(),
readonly: metadata.permissions().readonly(),
},
Err(_) => FileStat {
exists: false,
is_file: false,
is_dir: false,
size: 0,
readonly: false,
},
}
}
#[op2]
#[string]
fn op_fresh_get_env(#[string] name: String) -> Option<String> {
std::env::var(&name).ok()
}
#[op2]
#[string]
fn op_fresh_get_cwd(state: &mut OpState) -> String {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
return snapshot.working_dir.to_string_lossy().to_string();
};
}
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
}
#[op2]
#[string]
fn op_fresh_path_join(#[serde] parts: Vec<String>) -> String {
let mut path = std::path::PathBuf::new();
for part in parts {
path.push(part);
}
path.to_string_lossy().to_string()
}
#[op2]
#[string]
fn op_fresh_path_dirname(#[string] path: String) -> String {
std::path::Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
#[op2]
#[string]
fn op_fresh_path_basename(#[string] path: String) -> String {
std::path::Path::new(&path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
}
#[op2]
#[string]
fn op_fresh_path_extname(#[string] path: String) -> String {
std::path::Path::new(&path)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default()
}
#[op2(fast)]
fn op_fresh_path_is_absolute(#[string] path: String) -> bool {
std::path::Path::new(&path).is_absolute()
}
#[derive(serde::Serialize)]
struct DirEntry {
name: String,
is_file: bool,
is_dir: bool,
}
#[op2]
#[serde]
fn op_fresh_read_dir(
state: &mut OpState,
#[string] path: String,
) -> Result<Vec<DirEntry>, JsErrorBox> {
let resolved_path = if std::path::Path::new(&path).is_absolute() {
std::path::PathBuf::from(&path)
} else {
let working_dir =
state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()
.and_then(|runtime_state| {
let runtime_state = runtime_state.borrow();
runtime_state
.state_snapshot
.read()
.ok()
.map(|snapshot| snapshot.working_dir.clone())
});
if let Some(wd) = working_dir {
wd.join(&path)
} else {
std::path::PathBuf::from(&path)
}
};
let entries = std::fs::read_dir(&resolved_path)
.map_err(|e| JsErrorBox::generic(format!("Failed to read directory {}: {}", path, e)))?;
let mut result = Vec::new();
for entry in entries {
let entry = entry
.map_err(|e| JsErrorBox::generic(format!("Failed to read directory entry: {}", e)))?;
let metadata = entry
.metadata()
.map_err(|e| JsErrorBox::generic(format!("Failed to get entry metadata: {}", e)))?;
result.push(DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
is_file: metadata.is_file(),
is_dir: metadata.is_dir(),
});
}
Ok(result)
}
#[derive(serde::Deserialize)]
struct TsTextPropertyEntry {
text: String,
properties: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(serde::Serialize)]
struct CreateVirtualBufferResult {
buffer_id: u32,
split_id: Option<u32>,
}
#[derive(serde::Deserialize)]
struct CreateVirtualBufferOptions {
name: String,
mode: String,
read_only: bool,
entries: Vec<TsTextPropertyEntry>,
ratio: f32,
direction: Option<String>,
panel_id: Option<String>,
show_line_numbers: Option<bool>,
show_cursors: Option<bool>,
editing_disabled: Option<bool>,
}
#[op2(async)]
#[serde]
async fn op_fresh_create_virtual_buffer_in_split(
state: Rc<RefCell<OpState>>,
#[serde] options: CreateVirtualBufferOptions,
) -> Result<CreateVirtualBufferResult, JsErrorBox> {
let receiver = {
let state = state.borrow();
let runtime_state = state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()
.ok_or_else(|| JsErrorBox::generic("Failed to get runtime state"))?;
let runtime_state = runtime_state.borrow();
let request_id = {
let mut id = runtime_state.next_request_id.borrow_mut();
let current = *id;
*id += 1;
current
};
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = runtime_state.pending_responses.lock().unwrap();
pending.insert(request_id, tx);
}
let entries: Vec<crate::primitives::text_property::TextPropertyEntry> = options
.entries
.into_iter()
.map(|e| crate::primitives::text_property::TextPropertyEntry {
text: e.text,
properties: e.properties,
})
.collect();
tracing::trace!(
"op_create_virtual_buffer_in_split: sending command with request_id={}",
request_id
);
runtime_state
.command_sender
.send(PluginCommand::CreateVirtualBufferInSplit {
name: options.name,
mode: options.mode,
read_only: options.read_only,
entries,
ratio: options.ratio,
direction: options.direction,
panel_id: options.panel_id,
show_line_numbers: options.show_line_numbers.unwrap_or(true),
show_cursors: options.show_cursors.unwrap_or(true),
editing_disabled: options.editing_disabled.unwrap_or(false),
request_id: Some(request_id),
})
.map_err(|_| JsErrorBox::generic("Failed to send command"))?;
tracing::trace!("op_create_virtual_buffer_in_split: command sent, waiting for response");
rx
};
let response = receiver
.await
.map_err(|_| JsErrorBox::generic("Response channel closed"))?;
match response {
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
buffer_id,
split_id,
..
} => Ok(CreateVirtualBufferResult {
buffer_id: buffer_id.0 as u32,
split_id: split_id.map(|s| s.0 as u32),
}),
_ => Err(JsErrorBox::generic(
"Unexpected plugin response for virtual buffer creation",
)),
}
}
#[derive(serde::Deserialize)]
struct CreateVirtualBufferInExistingSplitOptions {
name: String,
mode: String,
read_only: bool,
entries: Vec<TsTextPropertyEntry>,
split_id: u32,
show_line_numbers: Option<bool>,
show_cursors: Option<bool>,
editing_disabled: Option<bool>,
}
#[op2(async)]
async fn op_fresh_create_virtual_buffer_in_existing_split(
state: Rc<RefCell<OpState>>,
#[serde] options: CreateVirtualBufferInExistingSplitOptions,
) -> Result<u32, JsErrorBox> {
let receiver = {
let state = state.borrow();
let runtime_state = state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()
.ok_or_else(|| JsErrorBox::generic("Failed to get runtime state"))?;
let runtime_state = runtime_state.borrow();
let request_id = {
let mut id = runtime_state.next_request_id.borrow_mut();
let current = *id;
*id += 1;
current
};
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = runtime_state.pending_responses.lock().unwrap();
pending.insert(request_id, tx);
}
let entries: Vec<crate::primitives::text_property::TextPropertyEntry> = options
.entries
.into_iter()
.map(|e| crate::primitives::text_property::TextPropertyEntry {
text: e.text,
properties: e.properties,
})
.collect();
runtime_state
.command_sender
.send(PluginCommand::CreateVirtualBufferInExistingSplit {
name: options.name,
mode: options.mode,
read_only: options.read_only,
entries,
split_id: crate::model::event::SplitId(options.split_id as usize),
show_line_numbers: options.show_line_numbers.unwrap_or(true),
show_cursors: options.show_cursors.unwrap_or(true),
editing_disabled: options.editing_disabled.unwrap_or(false),
request_id: Some(request_id),
})
.map_err(|_| JsErrorBox::generic("Failed to send command"))?;
rx
};
let response = receiver
.await
.map_err(|_| JsErrorBox::generic("Response channel closed"))?;
match response {
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
buffer_id, ..
} => Ok(buffer_id.0 as u32),
_ => Err(JsErrorBox::generic(
"Unexpected plugin response for virtual buffer creation",
)),
}
}
#[derive(serde::Deserialize)]
struct CreateVirtualBufferInCurrentSplitOptions {
name: String,
mode: String,
read_only: bool,
entries: Vec<TsTextPropertyEntry>,
show_line_numbers: Option<bool>,
show_cursors: Option<bool>,
editing_disabled: Option<bool>,
}
#[op2(async)]
async fn op_fresh_create_virtual_buffer(
state: Rc<RefCell<OpState>>,
#[serde] options: CreateVirtualBufferInCurrentSplitOptions,
) -> Result<u32, JsErrorBox> {
let receiver = {
let state = state.borrow();
let runtime_state = state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()
.ok_or_else(|| JsErrorBox::generic("Failed to get runtime state"))?;
let runtime_state = runtime_state.borrow();
let request_id = {
let mut id = runtime_state.next_request_id.borrow_mut();
let current = *id;
*id += 1;
current
};
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = runtime_state.pending_responses.lock().unwrap();
pending.insert(request_id, tx);
}
let entries: Vec<crate::primitives::text_property::TextPropertyEntry> = options
.entries
.into_iter()
.map(|e| crate::primitives::text_property::TextPropertyEntry {
text: e.text,
properties: e.properties,
})
.collect();
runtime_state
.command_sender
.send(PluginCommand::CreateVirtualBufferWithContent {
name: options.name,
mode: options.mode,
read_only: options.read_only,
entries,
show_line_numbers: options.show_line_numbers.unwrap_or(false),
show_cursors: options.show_cursors.unwrap_or(true),
editing_disabled: options.editing_disabled.unwrap_or(false),
request_id: Some(request_id),
})
.map_err(|_| JsErrorBox::generic("Failed to send command"))?;
rx
};
let response = receiver
.await
.map_err(|_| JsErrorBox::generic("Response channel closed"))?;
match response {
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
buffer_id, ..
} => Ok(buffer_id.0 as u32),
_ => Err(JsErrorBox::generic(
"Unexpected plugin response for virtual buffer creation",
)),
}
}
#[op2(async)]
#[serde]
async fn op_fresh_send_lsp_request(
state: Rc<RefCell<OpState>>,
#[string] language: String,
#[string] method: String,
#[serde] params: Option<serde_json::Value>,
) -> Result<serde_json::Value, JsErrorBox> {
let receiver = {
let state = state.borrow();
let runtime_state = state
.try_borrow::<Rc<RefCell<TsRuntimeState>>>()
.ok_or_else(|| JsErrorBox::generic("Failed to get runtime state"))?;
let runtime_state = runtime_state.borrow();
let request_id = {
let mut id = runtime_state.next_request_id.borrow_mut();
let current = *id;
*id += 1;
current
};
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = runtime_state.pending_responses.lock().unwrap();
pending.insert(request_id, tx);
}
if runtime_state
.command_sender
.send(
crate::services::plugins::api::PluginCommand::SendLspRequest {
language,
method,
params,
request_id,
},
)
.is_err()
{
let mut pending = runtime_state.pending_responses.lock().unwrap();
pending.remove(&request_id);
return Err(JsErrorBox::generic("Failed to send plugin LSP request"));
}
rx
};
let response = receiver
.await
.map_err(|_| JsErrorBox::generic("Plugin LSP request cancelled"))?;
match response {
crate::services::plugins::api::PluginResponse::LspRequest { result, .. } => match result {
Ok(value) => Ok(value),
Err(err) => Err(JsErrorBox::generic(err)),
},
_ => Err(JsErrorBox::generic(
"Unexpected plugin response for LSP request",
)),
}
}
#[op2]
fn op_fresh_define_mode(
state: &mut OpState,
#[string] name: String,
#[string] parent: Option<String>,
#[serde] bindings: Vec<(String, String)>,
read_only: bool,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::DefineMode {
name,
parent,
bindings,
read_only,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_show_buffer(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::ShowBuffer {
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_close_buffer(state: &mut OpState, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::CloseBuffer {
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_focus_split(state: &mut OpState, split_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::FocusSplit {
split_id: crate::model::event::SplitId(split_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_set_split_buffer(state: &mut OpState, split_id: u32, buffer_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetSplitBuffer {
split_id: crate::model::event::SplitId(split_id as usize),
buffer_id: BufferId(buffer_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_close_split(state: &mut OpState, split_id: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::CloseSplit {
split_id: crate::model::event::SplitId(split_id as usize),
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_set_split_ratio(state: &mut OpState, split_id: u32, ratio: f64) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetSplitRatio {
split_id: crate::model::event::SplitId(split_id as usize),
ratio: ratio as f32,
});
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_distribute_splits_evenly(state: &mut OpState) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::DistributeSplitsEvenly {
split_ids: vec![], });
return result.is_ok();
}
false
}
#[op2(fast)]
fn op_fresh_set_buffer_cursor(state: &mut OpState, buffer_id: u32, position: u32) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let result = runtime_state
.command_sender
.send(PluginCommand::SetBufferCursor {
buffer_id: crate::model::event::BufferId(buffer_id as usize),
position: position as usize,
});
return result.is_ok();
}
false
}
#[op2]
#[serde]
fn op_fresh_get_text_properties_at_cursor(
state: &mut OpState,
buffer_id: u32,
) -> Vec<std::collections::HashMap<String, serde_json::Value>> {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
if let Ok(snapshot) = runtime_state.state_snapshot.read() {
let buffer_id_key = BufferId(buffer_id as usize);
let cursor_pos = match snapshot.buffer_cursor_positions.get(&buffer_id_key) {
Some(pos) => *pos,
None => return vec![],
};
let properties = match snapshot.buffer_text_properties.get(&buffer_id_key) {
Some(props) => props,
None => return vec![],
};
return properties
.iter()
.filter(|prop| prop.contains(cursor_pos))
.map(|prop| prop.properties.clone())
.collect();
};
}
vec![]
}
#[op2]
fn op_fresh_set_virtual_buffer_content(
state: &mut OpState,
buffer_id: u32,
#[serde] entries: Vec<TsTextPropertyEntry>,
) -> bool {
if let Some(runtime_state) = state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let rust_entries: Vec<crate::primitives::text_property::TextPropertyEntry> = entries
.into_iter()
.map(|e| crate::primitives::text_property::TextPropertyEntry {
text: e.text,
properties: e.properties,
})
.collect();
let result = runtime_state
.command_sender
.send(PluginCommand::SetVirtualBufferContent {
buffer_id: BufferId(buffer_id as usize),
entries: rust_entries,
});
return result.is_ok();
}
false
}
extension!(
fresh_runtime,
ops = [
op_fresh_set_status,
op_fresh_apply_theme,
op_fresh_reload_config,
op_fresh_get_config,
op_fresh_get_user_config,
op_fresh_debug,
op_fresh_set_clipboard,
op_fresh_get_active_buffer_id,
op_fresh_get_cursor_position,
op_fresh_get_buffer_path,
op_fresh_get_buffer_length,
op_fresh_get_buffer_saved_diff,
op_fresh_is_buffer_modified,
op_fresh_insert_text,
op_fresh_delete_range,
op_fresh_add_overlay,
op_fresh_remove_overlay,
op_fresh_clear_namespace,
op_fresh_clear_overlays_in_range,
op_fresh_set_line_numbers,
op_fresh_clear_all_overlays,
op_fresh_add_virtual_text,
op_fresh_remove_virtual_text,
op_fresh_remove_virtual_texts_by_prefix,
op_fresh_clear_virtual_texts,
op_fresh_add_virtual_line,
op_fresh_clear_virtual_text_namespace,
op_fresh_submit_view_transform,
op_fresh_clear_view_transform,
op_fresh_refresh_lines,
op_fresh_set_line_indicator,
op_fresh_clear_line_indicators,
op_fresh_insert_at_cursor,
op_fresh_register_command,
op_fresh_unregister_command,
op_fresh_set_context,
op_fresh_open_file,
op_fresh_get_active_split_id,
op_fresh_open_file_in_split,
op_fresh_get_cursor_line,
op_fresh_get_all_cursor_positions,
op_fresh_spawn_process,
op_fresh_spawn_background_process,
op_fresh_kill_process,
op_fresh_is_process_running,
op_fresh_get_buffer_info,
op_fresh_list_buffers,
op_fresh_get_all_diagnostics,
op_fresh_get_primary_cursor,
op_fresh_get_all_cursors,
op_fresh_get_viewport,
op_fresh_start_prompt,
op_fresh_start_prompt_with_initial,
op_fresh_set_prompt_suggestions,
op_fresh_read_file,
op_fresh_write_file,
op_fresh_file_exists,
op_fresh_file_stat,
op_fresh_get_env,
op_fresh_get_cwd,
op_fresh_path_join,
op_fresh_path_dirname,
op_fresh_path_basename,
op_fresh_path_extname,
op_fresh_path_is_absolute,
op_fresh_read_dir,
op_fresh_on,
op_fresh_off,
op_fresh_get_handlers,
op_fresh_create_virtual_buffer_in_split,
op_fresh_create_virtual_buffer_in_existing_split,
op_fresh_create_virtual_buffer,
op_fresh_send_lsp_request,
op_fresh_define_mode,
op_fresh_show_buffer,
op_fresh_close_buffer,
op_fresh_focus_split,
op_fresh_set_split_buffer,
op_fresh_close_split,
op_fresh_set_split_ratio,
op_fresh_distribute_splits_evenly,
op_fresh_set_buffer_cursor,
op_fresh_get_text_properties_at_cursor,
op_fresh_set_virtual_buffer_content,
],
);
pub type PendingResponses = Arc<
std::sync::Mutex<
HashMap<u64, tokio::sync::oneshot::Sender<crate::services::plugins::api::PluginResponse>>,
>,
>;
pub struct TypeScriptRuntime {
js_runtime: JsRuntime,
event_handlers: Rc<RefCell<HashMap<String, Vec<String>>>>,
pending_responses: PendingResponses,
}
impl TypeScriptRuntime {
pub fn new() -> Result<Self> {
let (tx, _rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
Self::with_state(state_snapshot, tx)
}
pub fn with_state(
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
) -> Result<Self> {
let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
Self::with_state_and_responses(state_snapshot, command_sender, pending_responses)
}
pub fn with_state_and_responses(
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: std::sync::mpsc::Sender<PluginCommand>,
pending_responses: PendingResponses,
) -> Result<Self> {
tracing::debug!("TypeScriptRuntime::with_state_and_responses: initializing V8 platform");
crate::v8_init::init();
tracing::debug!("TypeScriptRuntime::with_state_and_responses: V8 platform initialized");
tracing::debug!("TypeScriptRuntime::with_state_and_responses: creating runtime state");
let event_handlers = Rc::new(RefCell::new(HashMap::new()));
let runtime_state = Rc::new(RefCell::new(TsRuntimeState {
state_snapshot,
command_sender,
event_handlers: event_handlers.clone(),
pending_responses: Arc::clone(&pending_responses),
next_request_id: Rc::new(RefCell::new(1)),
background_processes: Rc::new(RefCell::new(HashMap::new())),
next_process_id: Rc::new(RefCell::new(1)),
}));
tracing::debug!(
"TypeScriptRuntime::with_state_and_responses: creating JsRuntime with deno_core"
);
let js_runtime_start = std::time::Instant::now();
let mut js_runtime = JsRuntime::new(RuntimeOptions {
module_loader: Some(Rc::new(TypeScriptModuleLoader)),
extensions: vec![fresh_runtime::init()],
..Default::default()
});
tracing::debug!(
"TypeScriptRuntime::with_state_and_responses: JsRuntime created in {:?}",
js_runtime_start.elapsed()
);
js_runtime.op_state().borrow_mut().put(runtime_state);
js_runtime
.execute_script(
"<fresh_bootstrap>",
r#"
const core = Deno.core;
// Create the editor API object
const editor = {
// Status and logging
setStatus(message) {
core.ops.op_fresh_set_status(message);
},
debug(message) {
core.ops.op_fresh_debug(message);
},
// Theme operations
applyTheme(themeName) {
return core.ops.op_fresh_apply_theme(themeName);
},
// Config operations
reloadConfig() {
core.ops.op_fresh_reload_config();
},
getConfig() {
return core.ops.op_fresh_get_config();
},
getUserConfig() {
return core.ops.op_fresh_get_user_config();
},
// Clipboard
copyToClipboard(text) {
core.ops.op_fresh_set_clipboard(text);
},
// Buffer queries
getActiveBufferId() {
return core.ops.op_fresh_get_active_buffer_id();
},
getCursorPosition() {
return core.ops.op_fresh_get_cursor_position();
},
getBufferPath(bufferId) {
return core.ops.op_fresh_get_buffer_path(bufferId);
},
getBufferLength(bufferId) {
return core.ops.op_fresh_get_buffer_length(bufferId);
},
getBufferSavedDiff(bufferId) {
return core.ops.op_fresh_get_buffer_saved_diff(bufferId);
},
isBufferModified(bufferId) {
return core.ops.op_fresh_is_buffer_modified(bufferId);
},
// Buffer mutations
insertText(bufferId, position, text) {
return core.ops.op_fresh_insert_text(bufferId, position, text);
},
deleteRange(bufferId, start, end) {
return core.ops.op_fresh_delete_range(bufferId, start, end);
},
// Overlays
// namespace: group overlays together for efficient batch removal
// Use empty string for no namespace
addOverlay(bufferId, namespace, start, end, r, g, b, underline, bold = false, italic = false) {
return core.ops.op_fresh_add_overlay(bufferId, namespace, start, end, r, g, b, underline, bold, italic);
},
removeOverlay(bufferId, handle) {
return core.ops.op_fresh_remove_overlay(bufferId, handle);
},
clearNamespace(bufferId, namespace) {
return core.ops.op_fresh_clear_namespace(bufferId, namespace);
},
clearOverlaysInRange(bufferId, start, end) {
return core.ops.op_fresh_clear_overlays_in_range(bufferId, start, end);
},
clearAllOverlays(bufferId) {
return core.ops.op_fresh_clear_all_overlays(bufferId);
},
// Line numbers
setLineNumbers(bufferId, enabled) {
return core.ops.op_fresh_set_line_numbers(bufferId, enabled);
},
// Virtual text (inline text that doesn't exist in buffer)
addVirtualText(bufferId, virtualTextId, position, text, r, g, b, before, useBg = false) {
return core.ops.op_fresh_add_virtual_text(bufferId, virtualTextId, position, text, r, g, b, before, useBg);
},
removeVirtualText(bufferId, virtualTextId) {
return core.ops.op_fresh_remove_virtual_text(bufferId, virtualTextId);
},
removeVirtualTextsByPrefix(bufferId, prefix) {
return core.ops.op_fresh_remove_virtual_texts_by_prefix(bufferId, prefix);
},
clearVirtualTexts(bufferId) {
return core.ops.op_fresh_clear_virtual_texts(bufferId);
},
// Virtual lines (full lines above/below source lines - persistent state model)
addVirtualLine(bufferId, position, text, fgR, fgG, fgB, bgR, bgG, bgB, above, namespace, priority = 0) {
return core.ops.op_fresh_add_virtual_line(bufferId, position, text, fgR, fgG, fgB, bgR, bgG, bgB, above, namespace, priority);
},
clearVirtualTextNamespace(bufferId, namespace) {
return core.ops.op_fresh_clear_virtual_text_namespace(bufferId, namespace);
},
// View transforms (for compose mode)
submitViewTransform(bufferId, splitId, start, end, tokens, layoutHints) {
return core.ops.op_fresh_submit_view_transform(bufferId, splitId, start, end, tokens, layoutHints);
},
clearViewTransform(bufferId, splitId = null) {
return core.ops.op_fresh_clear_view_transform(bufferId, splitId);
},
refreshLines(bufferId) {
return core.ops.op_fresh_refresh_lines(bufferId);
},
// Line indicators (gutter column)
setLineIndicator(bufferId, line, namespace, symbol, r, g, b, priority) {
return core.ops.op_fresh_set_line_indicator(bufferId, line, namespace, symbol, r, g, b, priority);
},
clearLineIndicators(bufferId, namespace) {
return core.ops.op_fresh_clear_line_indicators(bufferId, namespace);
},
// Convenience
insertAtCursor(text) {
return core.ops.op_fresh_insert_at_cursor(text);
},
// Command registration
registerCommand(name, description, action, contexts = "") {
// Pass the current plugin source (set by load_module_with_source)
const source = globalThis.__PLUGIN_SOURCE__ || "";
return core.ops.op_fresh_register_command(name, description, action, contexts, source);
},
unregisterCommand(name) {
return core.ops.op_fresh_unregister_command(name);
},
// Context management
setContext(name, active) {
return core.ops.op_fresh_set_context(name, active);
},
// File operations
openFile(path, line = 0, column = 0) {
return core.ops.op_fresh_open_file(path, line, column);
},
// Split operations
getActiveSplitId() {
return core.ops.op_fresh_get_active_split_id();
},
openFileInSplit(splitId, path, line = 0, column = 0) {
return core.ops.op_fresh_open_file_in_split(splitId, path, line, column);
},
// Cursor operations
getCursorLine() {
return core.ops.op_fresh_get_cursor_line();
},
getAllCursorPositions() {
return core.ops.op_fresh_get_all_cursor_positions();
},
// Buffer info queries
getBufferInfo(bufferId) {
return core.ops.op_fresh_get_buffer_info(bufferId);
},
listBuffers() {
return core.ops.op_fresh_list_buffers();
},
getAllDiagnostics() {
return core.ops.op_fresh_get_all_diagnostics();
},
getPrimaryCursor() {
return core.ops.op_fresh_get_primary_cursor();
},
getAllCursors() {
return core.ops.op_fresh_get_all_cursors();
},
getViewport() {
return core.ops.op_fresh_get_viewport();
},
// Prompt operations
startPrompt(label, promptType) {
return core.ops.op_fresh_start_prompt(label, promptType);
},
startPromptWithInitial(label, promptType, initialValue) {
return core.ops.op_fresh_start_prompt_with_initial(label, promptType, initialValue);
},
setPromptSuggestions(suggestions) {
return core.ops.op_fresh_set_prompt_suggestions(suggestions);
},
// Async operations
spawnProcess(command, args = [], cwd = null) {
return core.ops.op_fresh_spawn_process(command, args, cwd);
},
spawnBackgroundProcess(command, args = [], cwd = null) {
return core.ops.op_fresh_spawn_background_process(command, args, cwd);
},
killProcess(processId) {
return core.ops.op_fresh_kill_process(processId);
},
isProcessRunning(processId) {
return core.ops.op_fresh_is_process_running(processId);
},
sendLspRequest(language, method, params = null) {
return core.ops.op_fresh_send_lsp_request(language, method, params);
},
// File system operations
readFile(path) {
return core.ops.op_fresh_read_file(path);
},
writeFile(path, content) {
return core.ops.op_fresh_write_file(path, content);
},
fileExists(path) {
return core.ops.op_fresh_file_exists(path);
},
fileStat(path) {
return core.ops.op_fresh_file_stat(path);
},
// Environment operations
getEnv(name) {
return core.ops.op_fresh_get_env(name);
},
getCwd() {
return core.ops.op_fresh_get_cwd();
},
// Path operations
pathJoin(...parts) {
return core.ops.op_fresh_path_join(parts);
},
pathDirname(path) {
return core.ops.op_fresh_path_dirname(path);
},
pathBasename(path) {
return core.ops.op_fresh_path_basename(path);
},
pathExtname(path) {
return core.ops.op_fresh_path_extname(path);
},
pathIsAbsolute(path) {
return core.ops.op_fresh_path_is_absolute(path);
},
readDir(path) {
return core.ops.op_fresh_read_dir(path);
},
// Event/Hook operations
on(eventName, handlerName) {
return core.ops.op_fresh_on(eventName, handlerName);
},
off(eventName, handlerName) {
return core.ops.op_fresh_off(eventName, handlerName);
},
getHandlers(eventName) {
return core.ops.op_fresh_get_handlers(eventName);
},
// Virtual buffer operations
createVirtualBufferInSplit(options) {
return core.ops.op_fresh_create_virtual_buffer_in_split(options);
},
createVirtualBufferInExistingSplit(options) {
return core.ops.op_fresh_create_virtual_buffer_in_existing_split(options);
},
createVirtualBuffer(options) {
return core.ops.op_fresh_create_virtual_buffer(options);
},
defineMode(name, parent, bindings, readOnly = false) {
return core.ops.op_fresh_define_mode(name, parent, bindings, readOnly);
},
showBuffer(bufferId) {
return core.ops.op_fresh_show_buffer(bufferId);
},
closeBuffer(bufferId) {
return core.ops.op_fresh_close_buffer(bufferId);
},
focusSplit(splitId) {
return core.ops.op_fresh_focus_split(splitId);
},
setSplitBuffer(splitId, bufferId) {
return core.ops.op_fresh_set_split_buffer(splitId, bufferId);
},
closeSplit(splitId) {
return core.ops.op_fresh_close_split(splitId);
},
setSplitRatio(splitId, ratio) {
return core.ops.op_fresh_set_split_ratio(splitId, ratio);
},
distributeSplitsEvenly() {
return core.ops.op_fresh_distribute_splits_evenly();
},
setBufferCursor(bufferId, position) {
return core.ops.op_fresh_set_buffer_cursor(bufferId, position);
},
getTextPropertiesAtCursor(bufferId) {
return core.ops.op_fresh_get_text_properties_at_cursor(bufferId);
},
setVirtualBufferContent(bufferId, entries) {
return core.ops.op_fresh_set_virtual_buffer_content(bufferId, entries);
},
};
// Make editor globally available
globalThis.editor = editor;
// Pre-compiled event dispatcher for performance
// This avoids recompiling JavaScript code for each event emission
globalThis.__eventDispatcher = async function(handlerName, eventData) {
const handler = globalThis[handlerName];
if (typeof handler === 'function') {
const result = handler(eventData);
const finalResult = (result instanceof Promise) ? await result : result;
// Return true by default if handler doesn't return anything
return finalResult !== false;
} else {
console.warn('Event handler "' + handlerName + '" is not defined');
return true;
}
};
"#
.to_string(),
)
.map_err(|e| anyhow!("Failed to initialize editor API: {}", e))?;
tracing::debug!(
"TypeScriptRuntime::with_state_and_responses: bootstrap script executed, runtime ready"
);
Ok(Self {
js_runtime,
event_handlers,
pending_responses,
})
}
pub fn deliver_response(&self, response: crate::services::plugins::api::PluginResponse) {
let request_id = match &response {
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
request_id,
..
} => *request_id,
crate::services::plugins::api::PluginResponse::LspRequest { request_id, .. } => {
*request_id
}
};
let sender = {
let mut pending = self.pending_responses.lock().unwrap();
pending.remove(&request_id)
};
if let Some(tx) = sender {
let _ = tx.send(response);
} else {
tracing::warn!("No pending response sender for request_id {}", request_id);
}
}
pub fn pending_responses(&self) -> &PendingResponses {
&self.pending_responses
}
pub async fn execute_script(&mut self, name: &'static str, code: &str) -> Result<()> {
let code_static: FastString = code.to_string().into();
self.js_runtime
.execute_script(name, code_static)
.map_err(|e| anyhow!("Failed to execute script '{}': {}", name, e))?;
self.js_runtime
.run_event_loop(Default::default())
.await
.map_err(|e| anyhow!("Event loop error: {}", e))?;
Ok(())
}
pub async fn load_module(&mut self, path: &str) -> Result<()> {
self.load_module_with_source(path, "").await
}
pub async fn load_module_with_source(&mut self, path: &str, plugin_source: &str) -> Result<()> {
let set_source: FastString = format!(
"globalThis.__PLUGIN_SOURCE__ = {};",
if plugin_source.is_empty() {
"null".to_string()
} else {
format!("\"{}\"", plugin_source)
}
)
.into();
self.js_runtime
.execute_script("<set_plugin_source>", set_source)
.map_err(|e| anyhow!("Failed to set plugin source: {}", e))?;
let module_specifier = deno_core::resolve_path(
path,
&std::env::current_dir().map_err(|e| anyhow!("Failed to get cwd: {}", e))?,
)
.map_err(|e| anyhow!("Failed to resolve module path '{}': {}", path, e))?;
let mod_id = self
.js_runtime
.load_side_es_module(&module_specifier)
.await
.map_err(|e| anyhow!("Failed to load module '{}': {}", path, e))?;
let result = self.js_runtime.mod_evaluate(mod_id);
self.js_runtime
.run_event_loop(Default::default())
.await
.map_err(|e| anyhow!("Event loop error while loading module: {}", e))?;
result
.await
.map_err(|e| anyhow!("Module evaluation error: {}", e))?;
let clear_source: FastString = "globalThis.__PLUGIN_SOURCE__ = null;".to_string().into();
self.js_runtime
.execute_script("<clear_plugin_source>", clear_source)
.map_err(|e| anyhow!("Failed to clear plugin source: {}", e))?;
Ok(())
}
pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
let code = format!(
r#"
(async () => {{
if (typeof globalThis.{} === 'function') {{
const result = globalThis.{}();
if (result instanceof Promise) {{
await result;
}}
}} else {{
throw new Error('Action "{}" is not defined as a global function');
}}
}})();
"#,
action_name, action_name, action_name
);
self.execute_script("<action>", &code).await
}
pub async fn emit(&mut self, event_name: &str, event_data: &str) -> Result<bool> {
let emit_start = std::time::Instant::now();
let handlers = self.event_handlers.borrow().get(event_name).cloned();
if let Some(handler_names) = handlers {
if handler_names.is_empty() {
return Ok(true);
}
for handler_name in &handler_names {
let call_start = std::time::Instant::now();
let script = format!(
"__eventDispatcher({}, {})",
serde_json::to_string(handler_name).unwrap_or_else(|_| "\"\"".to_string()),
event_data
);
match self.js_runtime.execute_script("<emit>", script) {
Ok(_) => {
let call_elapsed = call_start.elapsed();
let event_loop_start = std::time::Instant::now();
self.js_runtime
.run_event_loop(Default::default())
.await
.map_err(|e| anyhow!("Event loop error in emit: {}", e))?;
let event_loop_elapsed = event_loop_start.elapsed();
tracing::trace!(
event = event_name,
handler = handler_name,
call_us = call_elapsed.as_micros(),
event_loop_us = event_loop_elapsed.as_micros(),
"emit handler timing"
);
}
Err(e) => {
tracing::error!(
"Failed to call event handler '{}' for '{}': {:?}",
handler_name,
event_name,
e
);
}
}
}
}
let emit_elapsed = emit_start.elapsed();
tracing::trace!(
event = event_name,
total_us = emit_elapsed.as_micros(),
"emit total timing"
);
Ok(true)
}
pub fn get_registered_handlers(&self, event_name: &str) -> Vec<String> {
self.event_handlers
.borrow()
.get(event_name)
.cloned()
.unwrap_or_default()
}
pub fn has_handlers(&self, event_name: &str) -> bool {
self.event_handlers
.borrow()
.get(event_name)
.map(|v| !v.is_empty())
.unwrap_or(false)
}
pub fn send_status(&mut self, message: String) {
let op_state = self.js_runtime.op_state();
let op_state = op_state.borrow();
if let Some(runtime_state) = op_state.try_borrow::<Rc<RefCell<TsRuntimeState>>>() {
let runtime_state = runtime_state.borrow();
let _ = runtime_state
.command_sender
.send(PluginCommand::SetStatus { message });
}
}
}
use crate::input::command_registry::CommandRegistry;
use crate::services::plugins::hooks::{hook_args_to_json, HookArgs, HookRegistry};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct TsPluginInfo {
pub name: String,
pub path: PathBuf,
pub enabled: bool,
}
pub struct TypeScriptPluginManager {
runtime: TypeScriptRuntime,
plugins: HashMap<String, TsPluginInfo>,
commands: Arc<RwLock<CommandRegistry>>,
command_receiver: std::sync::mpsc::Receiver<PluginCommand>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
}
impl TypeScriptPluginManager {
pub fn new(
_hooks: Arc<RwLock<HookRegistry>>,
commands: Arc<RwLock<CommandRegistry>>,
) -> Result<Self> {
let (command_sender, command_receiver) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let runtime = TypeScriptRuntime::with_state(Arc::clone(&state_snapshot), command_sender)?;
tracing::info!("TypeScript plugin manager initialized");
Ok(Self {
runtime,
plugins: HashMap::new(),
commands,
command_receiver,
state_snapshot,
})
}
pub async fn load_plugin(&mut self, path: &Path) -> Result<()> {
let plugin_name = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Invalid plugin filename"))?
.to_string();
tracing::info!("Loading TypeScript plugin: {} from {:?}", plugin_name, path);
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("Invalid path encoding"))?;
self.runtime
.load_module_with_source(path_str, &plugin_name)
.await?;
self.plugins.insert(
plugin_name.clone(),
TsPluginInfo {
name: plugin_name,
path: path.to_path_buf(),
enabled: true,
},
);
Ok(())
}
pub fn unload_plugin(&mut self, name: &str) -> Result<()> {
if let Some(_plugin) = self.plugins.remove(name) {
tracing::info!("Unloading TypeScript plugin: {}", name);
let prefix = format!("{}:", name);
self.commands.read().unwrap().unregister_by_prefix(&prefix);
Ok(())
} else {
Err(anyhow!("Plugin '{}' not found", name))
}
}
pub async fn reload_plugin(&mut self, name: &str) -> Result<()> {
let path = self
.plugins
.get(name)
.ok_or_else(|| anyhow!("Plugin '{}' not found", name))?
.path
.clone();
self.unload_plugin(name)?;
self.load_plugin(&path).await?;
Ok(())
}
pub async fn load_plugins_from_dir(&mut self, dir: &Path) -> Vec<String> {
let mut errors = Vec::new();
if !dir.exists() {
tracing::warn!("Plugin directory does not exist: {:?}", dir);
return errors;
}
match std::fs::read_dir(dir) {
Ok(entries) => {
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|s| s.to_str());
if ext == Some("ts") || ext == Some("js") {
if let Err(e) = self.load_plugin(&path).await {
let err = format!("Failed to load {:?}: {}", path, e);
tracing::error!("{}", err);
errors.push(err);
}
}
}
}
Err(e) => {
let err = format!("Failed to read plugin directory: {}", e);
tracing::error!("{}", err);
errors.push(err);
}
}
errors
}
pub fn list_plugins(&self) -> Vec<TsPluginInfo> {
self.plugins.values().cloned().collect()
}
pub fn process_commands(&mut self) -> Vec<PluginCommand> {
let mut commands = Vec::new();
while let Ok(cmd) = self.command_receiver.try_recv() {
commands.push(cmd);
}
commands
}
pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
tracing::info!("Executing TypeScript plugin action: {}", action_name);
self.runtime.execute_action(action_name).await
}
pub async fn run_hook(&mut self, hook_name: &str, args: &HookArgs) -> Result<()> {
let json_data = hook_args_to_json(args)?;
self.runtime.emit(hook_name, &json_data).await?;
Ok(())
}
pub fn state_snapshot_handle(&self) -> Arc<RwLock<EditorStateSnapshot>> {
Arc::clone(&self.state_snapshot)
}
pub fn has_hook_handlers(&self, hook_name: &str) -> bool {
self.runtime.has_handlers(hook_name)
}
#[allow(dead_code)]
pub fn command_registry(&self) -> Arc<RwLock<CommandRegistry>> {
Arc::clone(&self.commands)
}
pub fn load_plugin_blocking(&mut self, path: &Path) -> Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow!("Failed to create runtime: {}", e))?;
rt.block_on(self.load_plugin(path))
}
pub fn load_plugins_from_dir_blocking(&mut self, dir: &Path) -> Vec<String> {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let err = format!("Failed to create runtime: {}", e);
tracing::error!("{}", err);
return vec![err];
}
};
rt.block_on(self.load_plugins_from_dir(dir))
}
pub fn execute_action_blocking(&mut self, action_name: &str) -> Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow!("Failed to create runtime: {}", e))?;
rt.block_on(self.execute_action(action_name))
}
pub fn run_hook_blocking(&mut self, hook_name: &str, args: &HookArgs) -> Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow!("Failed to create runtime: {}", e))?;
rt.block_on(self.run_hook(hook_name, args))
}
pub fn reload_plugin_blocking(&mut self, name: &str) -> Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow!("Failed to create runtime: {}", e))?;
rt.block_on(self.reload_plugin(name))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_typescript_runtime_creation() {
let runtime = TypeScriptRuntime::new();
assert!(runtime.is_ok(), "Failed to create TypeScript runtime");
}
#[tokio::test]
async fn test_execute_simple_script() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script("<test>", "const x = 1 + 1; console.log('Result:', x);")
.await;
assert!(
result.is_ok(),
"Failed to execute simple script: {:?}",
result
);
}
#[tokio::test]
async fn test_call_fresh_ops() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_ops>",
r#"
Deno.core.ops.op_fresh_set_status("Hello from TypeScript!");
Deno.core.ops.op_fresh_debug("Debug message");
const bufferId = Deno.core.ops.op_fresh_get_active_buffer_id();
console.log("Buffer ID:", bufferId);
"#,
)
.await;
assert!(result.is_ok(), "Failed to call Fresh ops: {:?}", result);
}
#[tokio::test]
async fn test_async_await() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_async>",
r#"
async function testAsync() {
const result = await Promise.resolve(42);
console.log("Async result:", result);
return result;
}
testAsync();
"#,
)
.await;
assert!(result.is_ok(), "Failed to execute async code: {:?}", result);
}
#[tokio::test]
async fn test_execute_action() {
let mut runtime = TypeScriptRuntime::new().unwrap();
runtime
.execute_script(
"<define_action>",
r#"
globalThis.my_test_action = function() {
Deno.core.ops.op_fresh_set_status("Action executed!");
};
"#,
)
.await
.unwrap();
let result = runtime.execute_action("my_test_action").await;
assert!(result.is_ok(), "Failed to execute action: {:?}", result);
}
#[tokio::test]
async fn test_execute_async_action() {
let mut runtime = TypeScriptRuntime::new().unwrap();
runtime
.execute_script(
"<define_async_action>",
r#"
globalThis.my_async_action = async function() {
const result = await Promise.resolve("async data");
Deno.core.ops.op_fresh_set_status("Async action completed with: " + result);
};
"#,
)
.await
.unwrap();
let result = runtime.execute_action("my_async_action").await;
assert!(
result.is_ok(),
"Failed to execute async action: {:?}",
result
);
}
#[tokio::test]
async fn test_with_editor_state() {
use crate::services::plugins::api::{BufferInfo, CursorInfo};
use std::path::PathBuf;
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(42);
snapshot.buffers.insert(
BufferId(42),
BufferInfo {
id: BufferId(42),
path: Some(PathBuf::from("/test/file.rs")),
modified: true,
length: 1000,
},
);
snapshot.primary_cursor = Some(CursorInfo {
position: 100,
selection: None,
});
}
let mut runtime = TypeScriptRuntime::with_state(state_snapshot.clone(), tx).unwrap();
let result = runtime
.execute_script(
"<test_state>",
r#"
// Test buffer queries
const bufferId = editor.getActiveBufferId();
if (bufferId !== 42) {
throw new Error(`Expected buffer ID 42, got ${bufferId}`);
}
const path = editor.getBufferPath(bufferId);
if (path !== "/test/file.rs") {
throw new Error(`Expected path /test/file.rs, got ${path}`);
}
const length = editor.getBufferLength(bufferId);
if (length !== 1000) {
throw new Error(`Expected length 1000, got ${length}`);
}
const modified = editor.isBufferModified(bufferId);
if (!modified) {
throw new Error("Expected buffer to be modified");
}
const cursorPos = editor.getCursorPosition();
if (cursorPos !== 100) {
throw new Error(`Expected cursor at 100, got ${cursorPos}`);
}
console.log("All state queries passed!");
"#,
)
.await;
assert!(result.is_ok(), "State query test failed: {:?}", result);
let result = runtime
.execute_script(
"<test_commands>",
r#"
// Test status command
editor.setStatus("Test status from TypeScript");
// Test insert text
const insertSuccess = editor.insertText(42, 50, "Hello, World!");
if (!insertSuccess) {
throw new Error("Insert text failed");
}
// Test delete range
const deleteSuccess = editor.deleteRange(42, 10, 20);
if (!deleteSuccess) {
throw new Error("Delete range failed");
}
// Test overlay
const overlaySuccess = editor.addOverlay(42, "test-overlay", 0, 50, 255, 0, 0, true);
if (!overlaySuccess) {
throw new Error("Add overlay failed");
}
const removeSuccess = editor.removeOverlay(42, "test-overlay");
if (!removeSuccess) {
throw new Error("Remove overlay failed");
}
console.log("All commands sent successfully!");
"#,
)
.await;
assert!(result.is_ok(), "Command test failed: {:?}", result);
let commands: Vec<_> = rx.try_iter().collect();
assert_eq!(commands.len(), 5, "Expected 5 commands");
match &commands[0] {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "Test status from TypeScript");
}
_ => panic!("Expected SetStatus command"),
}
match &commands[1] {
PluginCommand::InsertText {
buffer_id,
position,
text,
} => {
assert_eq!(buffer_id.0, 42);
assert_eq!(*position, 50);
assert_eq!(text, "Hello, World!");
}
_ => panic!("Expected InsertText command"),
}
match &commands[2] {
PluginCommand::DeleteRange { buffer_id, range } => {
assert_eq!(buffer_id.0, 42);
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
}
_ => panic!("Expected DeleteRange command"),
}
match &commands[3] {
PluginCommand::AddOverlay {
buffer_id,
namespace,
range,
color,
underline,
bold,
italic,
} => {
assert_eq!(buffer_id.0, 42);
assert_eq!(namespace.as_ref().map(|n| n.as_str()), Some("test-overlay"));
assert_eq!(range.start, 0);
assert_eq!(range.end, 50);
assert_eq!(*color, (255, 0, 0));
assert!(*underline);
assert!(!*bold);
assert!(!*italic);
}
_ => panic!("Expected AddOverlay command"),
}
match &commands[4] {
PluginCommand::RemoveOverlay { buffer_id, handle } => {
assert_eq!(buffer_id.0, 42);
assert_eq!(handle.as_str(), "test-overlay");
}
_ => panic!("Expected RemoveOverlay command"),
}
}
#[tokio::test]
async fn test_editor_api_accessible() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_api>",
r#"
// Verify all API methods exist
const methods = [
'setStatus', 'debug', 'getActiveBufferId', 'getCursorPosition',
'getBufferPath', 'getBufferLength', 'isBufferModified',
'insertText', 'deleteRange', 'addOverlay', 'removeOverlay'
];
for (const method of methods) {
if (typeof editor[method] !== 'function') {
throw new Error(`editor.${method} is not a function`);
}
}
console.log("All editor API methods are present!");
"#,
)
.await;
assert!(
result.is_ok(),
"API accessibility test failed: {:?}",
result
);
}
#[tokio::test]
async fn test_new_ops() {
use std::path::PathBuf;
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(1);
snapshot.active_split_id = 5;
}
let mut runtime = TypeScriptRuntime::with_state(state_snapshot.clone(), tx).unwrap();
let result = runtime
.execute_script(
"<test_new_ops>",
r#"
// Test getActiveSplitId
const splitId = editor.getActiveSplitId();
if (splitId !== 5) {
throw new Error(`Expected split ID 5, got ${splitId}`);
}
// Test registerCommand
const regSuccess = editor.registerCommand(
"My Plugin Command",
"A test command from TypeScript",
"my_plugin_action",
"normal,prompt"
);
if (!regSuccess) {
throw new Error("Register command failed");
}
// Test openFile
const openSuccess = editor.openFile("/test/file.rs", 42, 10);
if (!openSuccess) {
throw new Error("Open file failed");
}
// Test openFileInSplit
const splitOpenSuccess = editor.openFileInSplit(3, "/test/other.rs", 100, 5);
if (!splitOpenSuccess) {
throw new Error("Open file in split failed");
}
console.log("All new ops work correctly!");
"#,
)
.await;
assert!(result.is_ok(), "New ops test failed: {:?}", result);
let commands: Vec<_> = rx.try_iter().collect();
assert_eq!(commands.len(), 3, "Expected 3 commands");
match &commands[0] {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.name, "My Plugin Command");
assert_eq!(command.description, "A test command from TypeScript");
match &command.action {
crate::input::keybindings::Action::PluginAction(name) => {
assert_eq!(name, "my_plugin_action");
}
_ => panic!("Expected PluginAction"),
}
assert_eq!(command.contexts.len(), 2);
}
_ => panic!("Expected RegisterCommand"),
}
match &commands[1] {
PluginCommand::OpenFileAtLocation { path, line, column } => {
assert_eq!(path, &PathBuf::from("/test/file.rs"));
assert_eq!(*line, Some(42));
assert_eq!(*column, Some(10));
}
_ => panic!("Expected OpenFileAtLocation"),
}
match &commands[2] {
PluginCommand::OpenFileInSplit {
split_id,
path,
line,
column,
} => {
assert_eq!(*split_id, 3);
assert_eq!(path, &PathBuf::from("/test/other.rs"));
assert_eq!(*line, Some(100));
assert_eq!(*column, Some(5));
}
_ => panic!("Expected OpenFileInSplit"),
}
}
#[tokio::test]
async fn test_register_command_empty_contexts() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_empty_contexts>",
r#"
editor.registerCommand("Global Command", "Available everywhere", "global_action", "");
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
assert_eq!(commands.len(), 1);
match &commands[0] {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.name, "Global Command");
assert!(
command.contexts.is_empty(),
"Empty string should result in empty contexts"
);
}
_ => panic!("Expected RegisterCommand"),
}
}
#[tokio::test]
async fn test_register_command_all_contexts() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_all_contexts>",
r#"
editor.registerCommand(
"All Contexts",
"Test all context types",
"test_action",
"global, normal, help, prompt, popup, fileexplorer, menu"
);
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.contexts.len(), 6);
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Global));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Normal));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Prompt));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Popup));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::FileExplorer));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Menu));
}
_ => panic!("Expected RegisterCommand"),
}
}
#[tokio::test]
async fn test_register_command_invalid_contexts_ignored() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_invalid_contexts>",
r#"
editor.registerCommand(
"Partial Contexts",
"Some invalid",
"test_action",
"normal, invalid_context, popup, unknown"
);
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.contexts.len(), 2);
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Normal));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Popup));
}
_ => panic!("Expected RegisterCommand"),
}
}
#[tokio::test]
async fn test_open_file_with_zero_values() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_zero_values>",
r#"
editor.openFile("/test/file.txt", 0, 0);
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::OpenFileAtLocation { path, line, column } => {
assert_eq!(path.to_str().unwrap(), "/test/file.txt");
assert_eq!(*line, None, "0 should translate to None");
assert_eq!(*column, None, "0 should translate to None");
}
_ => panic!("Expected OpenFileAtLocation"),
}
}
#[tokio::test]
async fn test_open_file_with_default_params() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_default_params>",
r#"
// Call with just path (line and column default to 0)
editor.openFile("/test/file.txt");
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::OpenFileAtLocation { path, line, column } => {
assert_eq!(path.to_str().unwrap(), "/test/file.txt");
assert_eq!(*line, None);
assert_eq!(*column, None);
}
_ => panic!("Expected OpenFileAtLocation"),
}
}
#[tokio::test]
async fn test_open_file_with_line_only() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_line_only>",
r#"
editor.openFile("/test/file.txt", 50);
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::OpenFileAtLocation { line, column, .. } => {
assert_eq!(*line, Some(50));
assert_eq!(*column, None, "Column should be None when not specified");
}
_ => panic!("Expected OpenFileAtLocation"),
}
}
#[tokio::test]
async fn test_register_command_case_insensitive_contexts() {
let (tx, rx) = std::sync::mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let mut runtime = TypeScriptRuntime::with_state(state_snapshot, tx).unwrap();
let result = runtime
.execute_script(
"<test_case_insensitive>",
r#"
editor.registerCommand(
"Case Test",
"Test case insensitivity",
"test_action",
"NORMAL, Popup, FileExplorer"
);
"#,
)
.await;
assert!(result.is_ok());
let commands: Vec<_> = rx.try_iter().collect();
match &commands[0] {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.contexts.len(), 3);
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Normal));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::Popup));
assert!(command
.contexts
.contains(&crate::input::keybindings::KeyContext::FileExplorer));
}
_ => panic!("Expected RegisterCommand"),
}
}
#[tokio::test]
async fn test_spawn_process_simple() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_spawn>",
r#"
(async () => {
const result = await editor.spawnProcess("echo", ["hello", "world"]);
if (!result.stdout.includes("hello world")) {
throw new Error(`Expected 'hello world' in stdout, got: ${result.stdout}`);
}
if (result.exit_code !== 0) {
throw new Error(`Expected exit code 0, got: ${result.exit_code}`);
}
console.log("Spawn process test passed!");
})()
"#,
)
.await;
assert!(result.is_ok(), "Spawn process test failed: {:?}", result);
}
#[tokio::test]
async fn test_spawn_process_with_stderr() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_spawn_stderr>",
r#"
(async () => {
const result = await editor.spawnProcess("sh", ["-c", "echo error >&2"]);
if (!result.stderr.includes("error")) {
throw new Error(`Expected 'error' in stderr, got: ${result.stderr}`);
}
console.log("Spawn stderr test passed!");
})()
"#,
)
.await;
assert!(result.is_ok(), "Spawn stderr test failed: {:?}", result);
}
#[tokio::test]
async fn test_spawn_process_nonzero_exit() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_spawn_exit>",
r#"
(async () => {
const result = await editor.spawnProcess("sh", ["-c", "exit 42"]);
if (result.exit_code !== 42) {
throw new Error(`Expected exit code 42, got: ${result.exit_code}`);
}
console.log("Non-zero exit test passed!");
})()
"#,
)
.await;
assert!(result.is_ok(), "Non-zero exit test failed: {:?}", result);
}
#[tokio::test]
async fn test_spawn_process_git_example() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_git>",
r#"
(async () => {
const result = await editor.spawnProcess("git", ["--version"]);
if (!result.stdout.includes("git version")) {
throw new Error(`Expected 'git version' in output, got: ${result.stdout}`);
}
editor.setStatus(`Git version: ${result.stdout.trim()}`);
console.log("Git version test passed!");
})()
"#,
)
.await;
assert!(result.is_ok(), "Git example test failed: {:?}", result);
}
#[tokio::test]
async fn test_file_exists() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_file_exists>",
r#"
// Test existing file
const cargoExists = editor.fileExists("Cargo.toml");
if (!cargoExists) {
throw new Error("Cargo.toml should exist");
}
// Test non-existing file
const fakeExists = editor.fileExists("this_file_does_not_exist_12345.txt");
if (fakeExists) {
throw new Error("Non-existent file should return false");
}
console.log("File exists test passed!");
"#,
)
.await;
assert!(result.is_ok(), "File exists test failed: {:?}", result);
}
#[tokio::test]
async fn test_file_stat() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_file_stat>",
r#"
// Test stat on existing file
const stat = editor.fileStat("Cargo.toml");
if (!stat.exists) {
throw new Error("Cargo.toml should exist");
}
if (!stat.is_file) {
throw new Error("Cargo.toml should be a file");
}
if (stat.is_dir) {
throw new Error("Cargo.toml should not be a directory");
}
if (stat.size === 0) {
throw new Error("Cargo.toml should have non-zero size");
}
// Test stat on non-existing file
const noStat = editor.fileStat("nonexistent_12345.txt");
if (noStat.exists) {
throw new Error("Non-existent file should have exists=false");
}
console.log("File stat test passed!");
"#,
)
.await;
assert!(result.is_ok(), "File stat test failed: {:?}", result);
}
#[tokio::test]
async fn test_read_file() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_read_file>",
r#"
(async () => {
// Read Cargo.toml which should exist
const content = await editor.readFile("Cargo.toml");
if (!content.includes("[package]")) {
throw new Error("Cargo.toml should contain [package] section");
}
if (!content.includes("name")) {
throw new Error("Cargo.toml should contain name field");
}
console.log("Read file test passed!");
})()
"#,
)
.await;
assert!(result.is_ok(), "Read file test failed: {:?}", result);
}
#[tokio::test]
async fn test_path_operations() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_path_ops>",
r#"
// Test pathJoin
const joined = editor.pathJoin("src", "ts_runtime.rs");
if (!joined.includes("src") || !joined.includes("ts_runtime.rs")) {
throw new Error(`pathJoin failed: ${joined}`);
}
// Test pathDirname
const dir = editor.pathDirname("/home/user/file.txt");
if (dir !== "/home/user") {
throw new Error(`pathDirname failed: ${dir}`);
}
// Test pathBasename
const base = editor.pathBasename("/home/user/file.txt");
if (base !== "file.txt") {
throw new Error(`pathBasename failed: ${base}`);
}
// Test pathExtname
const ext = editor.pathExtname("/home/user/file.txt");
if (ext !== ".txt") {
throw new Error(`pathExtname failed: ${ext}`);
}
// Test empty extension
const noExt = editor.pathExtname("/home/user/Makefile");
if (noExt !== "") {
throw new Error(`pathExtname for no extension failed: ${noExt}`);
}
console.log("Path operations test passed!");
"#,
)
.await;
assert!(result.is_ok(), "Path operations test failed: {:?}", result);
}
#[tokio::test]
async fn test_get_env() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_get_env>",
r#"
// PATH should always be set
const path = editor.getEnv("PATH");
if (path === null || path === undefined) {
throw new Error("PATH environment variable should be set");
}
if (path.length === 0) {
throw new Error("PATH should not be empty");
}
// Non-existent env var should return null
const fake = editor.getEnv("THIS_ENV_VAR_DOES_NOT_EXIST_12345");
if (fake !== null && fake !== undefined) {
throw new Error("Non-existent env var should return null/undefined");
}
console.log("Get env test passed!");
"#,
)
.await;
assert!(result.is_ok(), "Get env test failed: {:?}", result);
}
#[tokio::test]
async fn test_get_cwd() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_get_cwd>",
r#"
const cwd = editor.getCwd();
if (!cwd || cwd.length === 0) {
throw new Error("getCwd should return non-empty string");
}
console.log(`Current working directory: ${cwd}`);
"#,
)
.await;
assert!(result.is_ok(), "Get cwd test failed: {:?}", result);
}
#[tokio::test]
async fn test_write_file() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let temp_file = std::env::temp_dir().join("fresh_ts_runtime_test_write.txt");
let temp_file_str = temp_file.to_string_lossy().replace('\\', "/");
let result = runtime
.execute_script(
"<test_write_file>",
&format!(
r#"
(async () => {{
const testFile = "{temp_file_str}";
const testContent = "Hello from TypeScript plugin!\nLine 2\n";
// Write the file
await editor.writeFile(testFile, testContent);
// Verify it was written by reading it back
const readBack = await editor.readFile(testFile);
if (readBack !== testContent) {{
throw new Error(`Write/read mismatch. Expected: ${{testContent}}, Got: ${{readBack}}`);
}}
// Verify file stats
const stat = editor.fileStat(testFile);
if (!stat.exists) {{
throw new Error("Written file should exist");
}}
if (!stat.is_file) {{
throw new Error("Written path should be a file");
}}
if (stat.size !== testContent.length) {{
throw new Error(`File size mismatch. Expected: ${{testContent.length}}, Got: ${{stat.size}}`);
}}
console.log("Write file test passed!");
}})()
"#
),
)
.await;
assert!(result.is_ok(), "Write file test failed: {:?}", result);
let _ = std::fs::remove_file(&temp_file);
}
#[tokio::test]
async fn test_read_dir() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_read_dir>",
r#"
// Read current directory (should have Cargo.toml, src/, etc.)
const entries = editor.readDir(".");
// Should have entries
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error("readDir should return non-empty array");
}
// Look for known files/dirs
const hasCargoToml = entries.some(e => e.name === "Cargo.toml" && e.is_file);
const hasSrc = entries.some(e => e.name === "src" && e.is_dir);
if (!hasCargoToml) {
throw new Error("Should find Cargo.toml in current directory");
}
if (!hasSrc) {
throw new Error("Should find src/ directory");
}
// Verify entry structure
const firstEntry = entries[0];
if (typeof firstEntry.name !== "string") {
throw new Error("Entry should have string name");
}
if (typeof firstEntry.is_file !== "boolean") {
throw new Error("Entry should have boolean is_file");
}
if (typeof firstEntry.is_dir !== "boolean") {
throw new Error("Entry should have boolean is_dir");
}
console.log(`Read directory test passed! Found ${entries.length} entries`);
"#,
)
.await;
assert!(result.is_ok(), "Read directory test failed: {:?}", result);
}
#[tokio::test]
#[cfg(unix)]
async fn test_path_is_absolute() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_path_is_absolute>",
r#"
// Test absolute paths (Unix)
if (!editor.pathIsAbsolute("/home/user")) {
throw new Error("/home/user should be absolute");
}
if (!editor.pathIsAbsolute("/")) {
throw new Error("/ should be absolute");
}
// Test relative paths
if (editor.pathIsAbsolute("src/main.rs")) {
throw new Error("src/main.rs should not be absolute");
}
if (editor.pathIsAbsolute(".")) {
throw new Error(". should not be absolute");
}
if (editor.pathIsAbsolute("..")) {
throw new Error(".. should not be absolute");
}
console.log("Path is absolute test passed!");
"#,
)
.await;
assert!(result.is_ok(), "Path is absolute test failed: {:?}", result);
}
#[tokio::test]
#[cfg(windows)]
async fn test_path_is_absolute() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_path_is_absolute>",
r#"
// Test absolute paths (Windows)
if (!editor.pathIsAbsolute("C:\\Users\\test")) {
throw new Error("C:\\Users\\test should be absolute");
}
if (!editor.pathIsAbsolute("C:/Users/test")) {
throw new Error("C:/Users/test should be absolute");
}
if (!editor.pathIsAbsolute("D:\\")) {
throw new Error("D:\\ should be absolute");
}
// Test relative paths
if (editor.pathIsAbsolute("src\\main.rs")) {
throw new Error("src\\main.rs should not be absolute");
}
if (editor.pathIsAbsolute("src/main.rs")) {
throw new Error("src/main.rs should not be absolute");
}
if (editor.pathIsAbsolute(".")) {
throw new Error(". should not be absolute");
}
if (editor.pathIsAbsolute("..")) {
throw new Error(".. should not be absolute");
}
console.log("Path is absolute test passed!");
"#,
)
.await;
assert!(result.is_ok(), "Path is absolute test failed: {:?}", result);
}
#[tokio::test]
async fn test_hook_registration() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let result = runtime
.execute_script(
"<test_hook_registration>",
r#"
// Register a handler
const registered = editor.on("buffer_save", "onBufferSave");
if (!registered) {
throw new Error("on() should return true");
}
// Check handlers
const handlers = editor.getHandlers("buffer_save");
if (handlers.length !== 1) {
throw new Error(`Expected 1 handler, got ${handlers.length}`);
}
if (handlers[0] !== "onBufferSave") {
throw new Error(`Expected handler 'onBufferSave', got '${handlers[0]}'`);
}
// Register another handler
editor.on("buffer_save", "onBufferSave2");
const handlers2 = editor.getHandlers("buffer_save");
if (handlers2.length !== 2) {
throw new Error(`Expected 2 handlers, got ${handlers2.length}`);
}
// Unregister first handler
const removed = editor.off("buffer_save", "onBufferSave");
if (!removed) {
throw new Error("off() should return true when handler exists");
}
const handlers3 = editor.getHandlers("buffer_save");
if (handlers3.length !== 1) {
throw new Error(`Expected 1 handler after off(), got ${handlers3.length}`);
}
// Try to unregister non-existent handler
const notRemoved = editor.off("buffer_save", "nonexistent");
if (notRemoved) {
throw new Error("off() should return false for non-existent handler");
}
console.log("Hook registration test passed!");
"#,
)
.await;
assert!(
result.is_ok(),
"Hook registration test failed: {:?}",
result
);
}
#[tokio::test]
async fn test_hook_emit() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let setup = runtime
.execute_script(
"<test_hook_emit_setup>",
r#"
globalThis.eventCounter = 0;
globalThis.lastEventData = null;
globalThis.onTestEvent = function(data) {
globalThis.eventCounter++;
globalThis.lastEventData = data;
return true;
};
editor.on("test_event", "onTestEvent");
"#,
)
.await;
assert!(setup.is_ok(), "Setup failed: {:?}", setup);
let emit_result = runtime
.emit("test_event", r#"{"value": 42, "message": "hello"}"#)
.await;
assert!(emit_result.is_ok(), "Emit failed: {:?}", emit_result);
assert!(emit_result.unwrap(), "Emit should return true");
let verify = runtime
.execute_script(
"<test_hook_emit_verify>",
r#"
if (globalThis.eventCounter !== 1) {
throw new Error(`Expected counter=1, got ${globalThis.eventCounter}`);
}
if (globalThis.lastEventData.value !== 42) {
throw new Error(`Expected value=42, got ${globalThis.lastEventData.value}`);
}
if (globalThis.lastEventData.message !== "hello") {
throw new Error(`Expected message='hello', got '${globalThis.lastEventData.message}'`);
}
console.log("Hook emit test passed!");
"#,
)
.await;
assert!(verify.is_ok(), "Verify failed: {:?}", verify);
}
#[tokio::test]
async fn test_hook_emit_cancellation() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let setup = runtime
.execute_script(
"<test_hook_cancel_setup>",
r#"
globalThis.cancelWasCalled = false;
globalThis.onCancelEvent = function(data) {
globalThis.cancelWasCalled = true;
return false; // Cancel the event
};
editor.on("cancel_event", "onCancelEvent");
"#,
)
.await;
assert!(setup.is_ok(), "Setup failed: {:?}", setup);
let emit_result = runtime.emit("cancel_event", "{}").await;
assert!(emit_result.is_ok(), "Emit failed: {:?}", emit_result);
let verify = runtime
.execute_script(
"<test_hook_cancel_verify>",
r#"
if (!globalThis.cancelWasCalled) {
throw new Error("Cancel handler was not called");
}
console.log("Hook cancellation test passed!");
"#,
)
.await;
assert!(verify.is_ok(), "Verify failed: {:?}", verify);
}
#[tokio::test]
async fn test_hook_multiple_handlers() {
let mut runtime = TypeScriptRuntime::new().unwrap();
let setup = runtime
.execute_script(
"<test_hook_multi_setup>",
r#"
globalThis.handler1Called = false;
globalThis.handler2Called = false;
globalThis.handler1 = function(data) {
globalThis.handler1Called = true;
return true;
};
globalThis.handler2 = function(data) {
globalThis.handler2Called = true;
return true;
};
editor.on("multi_event", "handler1");
editor.on("multi_event", "handler2");
"#,
)
.await;
assert!(setup.is_ok(), "Setup failed: {:?}", setup);
let emit_result = runtime.emit("multi_event", "{}").await;
assert!(emit_result.is_ok(), "Emit failed: {:?}", emit_result);
let verify = runtime
.execute_script(
"<test_hook_multi_verify>",
r#"
if (!globalThis.handler1Called) {
throw new Error("handler1 was not called");
}
if (!globalThis.handler2Called) {
throw new Error("handler2 was not called");
}
console.log("Multiple handlers test passed!");
"#,
)
.await;
assert!(verify.is_ok(), "Verify failed: {:?}", verify);
}
#[tokio::test]
async fn test_ts_plugin_manager_creation() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let manager = TypeScriptPluginManager::new(hooks, commands);
assert!(manager.is_ok(), "Failed to create TS plugin manager");
}
#[tokio::test]
async fn test_ts_plugin_manager_state_snapshot() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let snapshot = manager.state_snapshot_handle();
{
let mut state = snapshot.write().unwrap();
state.active_buffer_id = BufferId(42);
}
let state = snapshot.read().unwrap();
assert_eq!(state.active_buffer_id.0, 42);
}
#[tokio::test]
async fn test_ts_plugin_manager_process_commands() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let cmds = manager.process_commands();
assert!(cmds.is_empty());
}
#[tokio::test]
async fn test_ts_plugin_manager_list_plugins_empty() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let plugins = manager.list_plugins();
assert!(plugins.is_empty());
}
#[tokio::test]
async fn test_ts_plugin_manager_hook_args_to_json() {
let args = HookArgs::BufferActivated {
buffer_id: BufferId(5),
};
let json = hook_args_to_json(&args).unwrap();
assert!(json.contains("\"buffer_id\":5"));
let args = HookArgs::CursorMoved {
buffer_id: BufferId(1),
cursor_id: crate::model::event::CursorId(0),
old_position: 10,
new_position: 20,
line: 5,
};
let json = hook_args_to_json(&args).unwrap();
assert!(json.contains("\"buffer_id\":1"));
assert!(json.contains("\"old_position\":10"));
assert!(json.contains("\"new_position\":20"));
assert!(json.contains("\"line\":5"));
let args = HookArgs::EditorInitialized;
let json = hook_args_to_json(&args).unwrap();
assert_eq!(json, "{}");
}
#[tokio::test]
async fn test_ts_plugin_manager_has_no_hook_handlers_initially() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
assert!(!manager.has_hook_handlers("buffer_save"));
assert!(!manager.has_hook_handlers("cursor_moved"));
}
#[tokio::test]
async fn test_ts_plugin_manager_load_inline_plugin() {
use std::io::Write;
use tempfile::NamedTempFile;
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let mut temp_file = NamedTempFile::with_suffix(".js").unwrap();
writeln!(
temp_file,
r#"
// Simple test plugin
editor.setStatus("Test plugin loaded");
// Register a command
editor.registerCommand(
"Test TS Command",
"A test command from TypeScript",
"test_ts_action",
"normal"
);
// Define the action
globalThis.test_ts_action = function() {{
editor.setStatus("TS action executed");
}};
"#
)
.unwrap();
temp_file.flush().unwrap();
let result = manager.load_plugin(temp_file.path()).await;
assert!(result.is_ok(), "Failed to load plugin: {:?}", result);
let plugins = manager.list_plugins();
assert_eq!(plugins.len(), 1);
let cmds = manager.process_commands();
assert!(!cmds.is_empty(), "Expected commands from plugin");
let has_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("Test plugin loaded"))
});
assert!(has_status, "Expected SetStatus command");
}
#[tokio::test]
async fn test_ts_plugin_manager_execute_action() {
use std::io::Write;
use tempfile::NamedTempFile;
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let mut temp_file = NamedTempFile::with_suffix(".js").unwrap();
writeln!(
temp_file,
r#"
globalThis.myAction = function() {{
editor.setStatus("Action executed!");
}};
"#
)
.unwrap();
temp_file.flush().unwrap();
manager.load_plugin(temp_file.path()).await.unwrap();
manager.process_commands();
let result = manager.execute_action("myAction").await;
assert!(result.is_ok(), "Failed to execute action: {:?}", result);
let cmds = manager.process_commands();
let has_action_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("Action executed"))
});
assert!(has_action_status, "Expected SetStatus from action");
}
#[tokio::test]
async fn test_ts_plugin_manager_run_hook() {
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let setup = manager
.runtime
.execute_script(
"<test_hook_setup>",
r#"
globalThis.onBufferActivated = function(data) {
editor.setStatus("Buffer " + data.buffer_id + " activated");
};
editor.on("buffer_activated", "onBufferActivated");
"#,
)
.await;
assert!(setup.is_ok(), "Setup failed: {:?}", setup);
manager.process_commands();
let args = HookArgs::BufferActivated {
buffer_id: BufferId(42),
};
let result = manager.run_hook("buffer_activated", &args).await;
assert!(result.is_ok(), "Failed to run hook: {:?}", result);
let cmds = manager.process_commands();
let has_hook_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("Buffer 42 activated"))
});
assert!(has_hook_status, "Expected SetStatus from hook handler");
}
#[tokio::test]
async fn test_ts_plugin_manager_unload_plugin() {
use std::io::Write;
use tempfile::NamedTempFile;
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let mut temp_file = NamedTempFile::with_suffix(".js").unwrap();
writeln!(temp_file, r#"// Test plugin"#).unwrap();
temp_file.flush().unwrap();
manager.load_plugin(temp_file.path()).await.unwrap();
let plugin_name = temp_file
.path()
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(manager.list_plugins().len(), 1);
let result = manager.unload_plugin(&plugin_name);
assert!(result.is_ok(), "Failed to unload: {:?}", result);
assert_eq!(manager.list_plugins().len(), 0);
}
#[tokio::test]
async fn test_emit_performance() {
use std::time::Instant;
let mut runtime = TypeScriptRuntime::new().unwrap();
runtime
.execute_script(
"<setup_handler>",
r#"
globalThis.onRenderLine = function(data) {
// Simulate TODO highlighter: check if line contains keyword
if (data.content && data.content.includes("TODO")) {
// Would add overlay here
}
return true;
};
editor.on("render_line", "onRenderLine");
"#,
)
.await
.unwrap();
const NUM_LINES: usize = 100;
const NUM_ITERATIONS: usize = 100;
let mut total_duration = std::time::Duration::ZERO;
for _ in 0..NUM_ITERATIONS {
let start = Instant::now();
for line_num in 0..NUM_LINES {
let event_data = format!(
r#"{{"buffer_id": 1, "line_number": {}, "byte_start": {}, "byte_end": {}, "content": " let x = 42; // TODO: optimize this"}}"#,
line_num,
line_num * 50,
(line_num + 1) * 50
);
let result = runtime.emit("render_line", &event_data).await;
assert!(result.is_ok(), "Emit failed: {:?}", result);
}
total_duration += start.elapsed();
}
let avg_duration = total_duration / NUM_ITERATIONS as u32;
let per_line_us = avg_duration.as_micros() / NUM_LINES as u128;
println!("\n=== EMIT PERFORMANCE BENCHMARK ===");
println!("Lines per iteration: {}", NUM_LINES);
println!("Iterations: {}", NUM_ITERATIONS);
println!("Average time per iteration: {:?}", avg_duration);
println!("Average time per line: {} µs", per_line_us);
println!("===================================\n");
assert!(
per_line_us < 1000,
"Emit is too slow: {} µs per line (should be < 1000 µs)",
per_line_us
);
}
#[tokio::test]
async fn test_ts_plugin_manager_load_plugin_with_import_error() {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let plugins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins");
let plugin_path = plugins_dir.join("test_import_plugin.ts");
std::fs::write(
&plugin_path,
r#"
// Import from the actual lib folder
import { PanelManager } from "./lib/index.ts";
// Use the imported value
editor.setStatus("Plugin loaded with PanelManager");
editor.debug("PanelManager type: " + typeof PanelManager);
"#,
)
.unwrap();
let result = manager.load_plugin(&plugin_path).await;
let _ = std::fs::remove_file(&plugin_path);
match result {
Ok(()) => {
let cmds = manager.process_commands();
let has_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("PanelManager"))
});
assert!(has_status, "Expected SetStatus with PanelManager mention");
}
Err(e) => {
eprintln!("Import test failed with error: {}", e);
}
}
}
#[tokio::test]
async fn test_ts_plugin_manager_load_plugin_with_valid_import() {
use tempfile::TempDir;
let hooks = Arc::new(RwLock::new(HookRegistry::new()));
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut manager = TypeScriptPluginManager::new(hooks, commands).unwrap();
let temp_dir = TempDir::new().unwrap();
let lib_path = temp_dir.path().join("lib.ts");
let plugin_path = temp_dir.path().join("test_plugin.ts");
std::fs::write(
&lib_path,
r#"
export const MESSAGE = "Hello from lib";
export function greet(name: string): string {
return `Hello, ${name}!`;
}
"#,
)
.unwrap();
std::fs::write(
&plugin_path,
r#"
import { MESSAGE, greet } from "./lib.ts";
editor.setStatus(MESSAGE);
editor.debug(greet("World"));
"#,
)
.unwrap();
let result = manager.load_plugin(&plugin_path).await;
assert!(
result.is_ok(),
"Failed to load plugin with valid import: {:?}",
result
);
let cmds = manager.process_commands();
let has_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("Hello from lib"))
});
assert!(has_status, "Expected SetStatus with imported MESSAGE");
}
#[test]
fn test_plugin_thread_load_plugin_with_import() {
use crate::services::plugins::thread::PluginThreadHandle;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let plugins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins");
let plugin_path = plugins_dir.join("test_thread_import_plugin.ts");
std::fs::write(
&plugin_path,
r#"
// Import from the actual lib folder
import { PanelManager } from "./lib/index.ts";
// Use the imported value
editor.setStatus("Plugin thread test: PanelManager loaded");
editor.debug("PanelManager type: " + typeof PanelManager);
"#,
)
.unwrap();
let result = handle.load_plugin(&plugin_path);
let _ = std::fs::remove_file(&plugin_path);
match result {
Ok(()) => {
let cmds = handle.process_commands();
let has_status = cmds.iter().any(|cmd| {
matches!(cmd, PluginCommand::SetStatus { message } if message.contains("PanelManager"))
});
assert!(has_status, "Expected SetStatus with PanelManager mention");
}
Err(e) => {
eprintln!("Plugin thread import test failed with error: {}", e);
}
}
handle.shutdown();
}
#[test]
fn test_plugin_thread_load_git_log_plugin() {
use crate::services::plugins::thread::PluginThreadHandle;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let plugins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins");
let plugin_path = plugins_dir.join("git_log.ts");
let result = handle.load_plugin(&plugin_path);
match result {
Ok(()) => {
eprintln!("Git log plugin loaded successfully");
let cmds = handle.process_commands();
eprintln!("Commands after load: {:?}", cmds.len());
}
Err(e) => {
eprintln!("Git log plugin failed with error: {}", e);
}
}
handle.shutdown();
}
#[test]
#[ignore]
fn test_plugin_thread_execute_git_log_action() {
use crate::model::event::BufferId;
use crate::services::plugins::api::PluginCommand;
use crate::services::plugins::thread::PluginThreadHandle;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let plugins_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("plugins");
let plugin_path = plugins_dir.join("git_log.ts");
let result = handle.load_plugin(&plugin_path);
assert!(
result.is_ok(),
"Failed to load git_log plugin: {:?}",
result
);
eprintln!("Git log plugin loaded, now executing show_git_log action...");
let receiver = handle.execute_action_async("show_git_log").unwrap();
let mut completed = false;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(10);
while !completed && start.elapsed() < timeout {
let cmds = handle.process_commands();
for cmd in cmds {
match cmd {
PluginCommand::CreateVirtualBufferInSplit {
request_id: Some(req_id),
..
} => {
eprintln!(
"Received CreateVirtualBufferInSplit with request_id={}",
req_id
);
let response =
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
request_id: req_id,
buffer_id: BufferId(100),
split_id: Some(crate::model::event::SplitId(1)),
};
handle.deliver_response(response);
eprintln!("Delivered response for request_id={}", req_id);
}
PluginCommand::SetStatus { message } => {
eprintln!("Plugin status: {}", message);
}
_ => {}
}
}
match receiver.try_recv() {
Ok(result) => {
completed = true;
match result {
Ok(()) => eprintln!("show_git_log executed successfully!"),
Err(e) => eprintln!("show_git_log failed: {}", e),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("Action receiver disconnected");
}
}
}
if !completed {
panic!("Test timed out waiting for show_git_log to complete");
}
handle.shutdown();
}
#[test]
fn test_plugin_thread_spawn_process_simple() {
use crate::services::plugins::thread::PluginThreadHandle;
use tempfile::TempDir;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let temp_dir = TempDir::new().unwrap();
let plugin_path = temp_dir.path().join("spawn_test.ts");
std::fs::write(
&plugin_path,
r#"
globalThis.test_spawn = async function(): Promise<void> {
editor.setStatus("About to spawn echo...");
const result = await editor.spawnProcess("echo", ["hello", "world"]);
editor.setStatus("Spawn completed: exit=" + result.exit_code);
editor.debug("stdout: " + result.stdout);
};
editor.setStatus("Spawn test plugin loaded");
"#,
)
.unwrap();
let result = handle.load_plugin(&plugin_path);
assert!(
result.is_ok(),
"Failed to load spawn test plugin: {:?}",
result
);
eprintln!("Spawn test plugin loaded, now executing test_spawn action...");
let receiver = handle.execute_action_async("test_spawn").unwrap();
let mut completed = false;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
while !completed && start.elapsed() < timeout {
let _cmds = handle.process_commands();
match receiver.try_recv() {
Ok(result) => {
completed = true;
match result {
Ok(()) => eprintln!("test_spawn executed successfully"),
Err(e) => eprintln!("test_spawn failed: {}", e),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("Action receiver disconnected");
}
}
}
if !completed {
panic!("Test timed out");
}
handle.shutdown();
}
#[test]
fn test_plugin_thread_spawn_git_log() {
use crate::services::plugins::thread::PluginThreadHandle;
use tempfile::TempDir;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let temp_dir = TempDir::new().unwrap();
let plugin_path = temp_dir.path().join("git_test.ts");
std::fs::write(
&plugin_path,
r#"
globalThis.test_git = async function(): Promise<void> {
editor.setStatus("About to run git log...");
const format = "%H%x00%h%x00%an%x00%ae%x00%ai%x00%ar%x00%d%x00%s%x00%b%x1e";
const args = ["log", `--format=${format}`, "-n10"];
const result = await editor.spawnProcess("git", args);
editor.setStatus("Git log completed: exit=" + result.exit_code + ", lines=" + result.stdout.split("\n").length);
};
editor.setStatus("Git test plugin loaded");
"#,
)
.unwrap();
let result = handle.load_plugin(&plugin_path);
assert!(
result.is_ok(),
"Failed to load git test plugin: {:?}",
result
);
eprintln!("Git test plugin loaded, now executing test_git action...");
let receiver = handle.execute_action_async("test_git").unwrap();
let mut completed = false;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
while !completed && start.elapsed() < timeout {
let _cmds = handle.process_commands();
match receiver.try_recv() {
Ok(result) => {
completed = true;
match result {
Ok(()) => eprintln!("test_git executed successfully"),
Err(e) => eprintln!("test_git failed: {}", e),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("Action receiver disconnected");
}
}
}
if !completed {
panic!("Test timed out");
}
handle.shutdown();
}
#[test]
fn test_plugin_thread_create_virtual_buffer_async() {
use crate::model::event::BufferId;
use crate::services::plugins::api::PluginCommand;
use crate::services::plugins::thread::PluginThreadHandle;
use tempfile::TempDir;
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let commands = Arc::new(RwLock::new(CommandRegistry::new()));
let mut handle = PluginThreadHandle::spawn(commands).unwrap();
let temp_dir = TempDir::new().unwrap();
let plugin_path = temp_dir.path().join("vbuf_test.ts");
std::fs::write(
&plugin_path,
r#"
globalThis.test_vbuf = async function(): Promise<void> {
editor.debug("Step 1: About to spawn process...");
const result = await editor.spawnProcess("echo", ["test"]);
editor.debug("Step 2: Spawn completed with exit=" + result.exit_code);
editor.debug("Step 3: About to create virtual buffer...");
// This should hang because no editor is processing responses
const bufferId = await editor.createVirtualBufferInSplit({
name: "*Test*",
mode: "normal",
read_only: true,
entries: [{ text: "test content\n", properties: { type: "test" } }],
ratio: 0.5,
});
editor.debug("Step 4: Virtual buffer created with id=" + bufferId);
};
editor.setStatus("VBuf test plugin loaded");
"#,
)
.unwrap();
let result = handle.load_plugin(&plugin_path);
assert!(
result.is_ok(),
"Failed to load vbuf test plugin: {:?}",
result
);
eprintln!("VBuf test plugin loaded, now executing test_vbuf action...");
eprintln!("Using async pattern to avoid deadlock");
let receiver = handle.execute_action_async("test_vbuf").unwrap();
let mut completed = false;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(5);
while !completed && start.elapsed() < timeout {
let cmds = handle.process_commands();
for cmd in cmds {
match cmd {
PluginCommand::CreateVirtualBufferInSplit {
request_id: Some(req_id),
..
} => {
eprintln!(
"Received CreateVirtualBufferInSplit with request_id={}",
req_id
);
let response =
crate::services::plugins::api::PluginResponse::VirtualBufferCreated {
request_id: req_id,
buffer_id: BufferId(100), split_id: Some(crate::model::event::SplitId(1)),
};
handle.deliver_response(response);
eprintln!("Delivered response for request_id={}", req_id);
}
PluginCommand::SetStatus { message } => {
eprintln!("Plugin status: {}", message);
}
_ => {
}
}
}
match receiver.try_recv() {
Ok(result) => {
completed = true;
match result {
Ok(()) => eprintln!("test_vbuf executed successfully!"),
Err(e) => eprintln!("test_vbuf failed: {}", e),
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("Action receiver disconnected");
}
}
}
if !completed {
panic!("Test timed out waiting for action to complete");
}
handle.shutdown();
}
}