use anyhow::{anyhow, Result};
use fresh_core::api::{
ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
PluginCommand, PluginResponse,
};
use fresh_core::command::Command;
use fresh_core::overlay::OverlayNamespace;
use fresh_core::text_property::TextPropertyEntry;
use fresh_core::{BufferId, SplitId};
use fresh_parser_js::{
bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
transpile_typescript,
};
use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
use rquickjs::{Context, Function, Object, Runtime, Value};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{mpsc, Arc, RwLock};
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
use rquickjs::Type;
match val.type_of() {
Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
Type::Bool => val
.as_bool()
.map(serde_json::Value::Bool)
.unwrap_or(serde_json::Value::Null),
Type::Int => val
.as_int()
.map(|n| serde_json::Value::Number(n.into()))
.unwrap_or(serde_json::Value::Null),
Type::Float => val
.as_float()
.map(|f| {
if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
serde_json::Value::Number((f as i64).into())
} else {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}
})
.unwrap_or(serde_json::Value::Null),
Type::String => val
.as_string()
.and_then(|s| s.to_string().ok())
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null),
Type::Array => {
if let Some(arr) = val.as_array() {
let items: Vec<serde_json::Value> = arr
.iter()
.filter_map(|item| item.ok())
.map(|item| js_to_json(ctx, item))
.collect();
serde_json::Value::Array(items)
} else {
serde_json::Value::Null
}
}
Type::Object | Type::Constructor | Type::Function => {
if let Some(obj) = val.as_object() {
let mut map = serde_json::Map::new();
for key in obj.keys::<String>().flatten() {
if let Ok(v) = obj.get::<_, Value>(&key) {
map.insert(key, js_to_json(ctx, v));
}
}
serde_json::Value::Object(map)
} else {
serde_json::Value::Null
}
}
_ => serde_json::Value::Null,
}
}
fn json_to_js_value<'js>(
ctx: &rquickjs::Ctx<'js>,
val: &serde_json::Value,
) -> rquickjs::Result<Value<'js>> {
match val {
serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::new_int(ctx.clone(), i as i32))
} else if let Some(f) = n.as_f64() {
Ok(Value::new_float(ctx.clone(), f))
} else {
Ok(Value::new_null(ctx.clone()))
}
}
serde_json::Value::String(s) => {
let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
Ok(js_str.into_value())
}
serde_json::Value::Array(arr) => {
let js_arr = rquickjs::Array::new(ctx.clone())?;
for (i, item) in arr.iter().enumerate() {
let js_val = json_to_js_value(ctx, item)?;
js_arr.set(i, js_val)?;
}
Ok(js_arr.into_value())
}
serde_json::Value::Object(map) => {
let obj = rquickjs::Object::new(ctx.clone())?;
for (key, val) in map {
let js_val = json_to_js_value(ctx, val)?;
obj.set(key.as_str(), js_val)?;
}
Ok(obj.into_value())
}
}
}
fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
let js_data = match json_to_js_value(ctx, event_data) {
Ok(v) => v,
Err(e) => {
log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
return;
}
};
let globals = ctx.globals();
let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
return;
};
match func.call::<_, rquickjs::Value>((js_data,)) {
Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
}
run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
}
fn attach_promise_catch<'js>(
ctx: &rquickjs::Ctx<'js>,
globals: &rquickjs::Object<'js>,
handler_name: &str,
result: rquickjs::Value<'js>,
) {
let Some(obj) = result.as_object() else {
return;
};
if obj.get::<_, rquickjs::Function>("then").is_err() {
return;
}
let _ = globals.set("__pendingPromise", result);
let catch_code = format!(
r#"globalThis.__pendingPromise.catch(function(e) {{
console.error('Handler {} async error:', e);
throw e;
}}); delete globalThis.__pendingPromise;"#,
handler_name
);
let _ = ctx.eval::<(), _>(catch_code.as_bytes());
}
fn get_text_properties_at_cursor_typed(
snapshot: &Arc<RwLock<EditorStateSnapshot>>,
buffer_id: u32,
) -> fresh_core::api::TextPropertiesAtCursor {
use fresh_core::api::TextPropertiesAtCursor;
let snap = match snapshot.read() {
Ok(s) => s,
Err(_) => return TextPropertiesAtCursor(Vec::new()),
};
let buffer_id_typed = BufferId(buffer_id as usize);
let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
snap.primary_cursor.as_ref().map(|c| c.position)
} else {
None
};
let cursor_pos = match snapshot_pos.or(fallback_pos) {
Some(pos) => pos,
None => {
tracing::debug!(
"getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
buffer_id_typed,
snapshot_pos,
snap.active_buffer_id
);
return TextPropertiesAtCursor(Vec::new());
}
};
let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
Some(p) => p,
None => {
tracing::debug!(
"getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
buffer_id_typed,
cursor_pos
);
return TextPropertiesAtCursor(Vec::new());
}
};
let result: Vec<_> = properties
.iter()
.filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
.map(|prop| prop.properties.clone())
.collect();
tracing::debug!(
"getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
buffer_id_typed,
cursor_pos,
snapshot_pos,
fallback_pos,
snap.active_buffer_id,
properties.len(),
result.len()
);
TextPropertiesAtCursor(result)
}
fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
use rquickjs::Type;
match val.type_of() {
Type::Null => "null".to_string(),
Type::Undefined => "undefined".to_string(),
Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
Type::String => val
.as_string()
.and_then(|s| s.to_string().ok())
.unwrap_or_default(),
Type::Object | Type::Exception => {
if let Some(obj) = val.as_object() {
let name: Option<String> = obj.get("name").ok();
let message: Option<String> = obj.get("message").ok();
let stack: Option<String> = obj.get("stack").ok();
if message.is_some() || name.is_some() {
let name = name.unwrap_or_else(|| "Error".to_string());
let message = message.unwrap_or_default();
if let Some(stack) = stack {
return format!("{}: {}\n{}", name, message, stack);
} else {
return format!("{}: {}", name, message);
}
}
let json = js_to_json(ctx, val.clone());
serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
} else {
"[object]".to_string()
}
}
Type::Array => {
let json = js_to_json(ctx, val.clone());
serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
}
Type::Function | Type::Constructor => "[function]".to_string(),
Type::Symbol => "[symbol]".to_string(),
Type::BigInt => val
.as_big_int()
.and_then(|b| b.clone().to_i64().ok())
.map(|n| n.to_string())
.unwrap_or_else(|| "[bigint]".to_string()),
_ => format!("[{}]", val.type_name()),
}
}
fn format_js_error(
ctx: &rquickjs::Ctx<'_>,
err: rquickjs::Error,
source_name: &str,
) -> anyhow::Error {
if err.is_exception() {
let exc = ctx.catch();
if !exc.is_undefined() && !exc.is_null() {
if let Some(exc_obj) = exc.as_object() {
let message: String = exc_obj
.get::<_, String>("message")
.unwrap_or_else(|_| "Unknown error".to_string());
let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
let name: String = exc_obj
.get::<_, String>("name")
.unwrap_or_else(|_| "Error".to_string());
if !stack.is_empty() {
return anyhow::anyhow!(
"JS error in {}: {}: {}\nStack trace:\n{}",
source_name,
name,
message,
stack
);
} else {
return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
}
} else {
let exc_str: String = exc
.as_string()
.and_then(|s: &rquickjs::String| s.to_string().ok())
.unwrap_or_else(|| format!("{:?}", exc));
return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
}
}
}
anyhow::anyhow!("JS error in {}: {}", source_name, err)
}
fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
let error = format_js_error(ctx, err, context);
tracing::error!("{}", error);
if should_panic_on_js_errors() {
panic!("JavaScript error in {}: {}", context, error);
}
}
static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn set_panic_on_js_errors(enabled: bool) {
PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
fn should_panic_on_js_errors() -> bool {
PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
}
static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
fn set_fatal_js_error(msg: String) {
if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
if guard.is_none() {
*guard = Some(msg);
}
}
FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
}
pub fn has_fatal_js_error() -> bool {
FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn take_fatal_js_error() -> Option<String> {
if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
return None;
}
if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
guard.take()
} else {
Some("Fatal JS error (message unavailable)".to_string())
}
}
fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
let mut count = 0;
loop {
let exc: rquickjs::Value = ctx.catch();
if exc.is_exception() {
let error_msg = if let Some(err) = exc.as_exception() {
format!(
"{}: {}",
err.message().unwrap_or_default(),
err.stack().unwrap_or_default()
)
} else {
format!("{:?}", exc)
};
tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
if should_panic_on_js_errors() {
panic!("Unhandled JS exception during {}: {}", context, error_msg);
}
}
if !ctx.execute_pending_job() {
break;
}
count += 1;
}
let exc: rquickjs::Value = ctx.catch();
if exc.is_exception() {
let error_msg = if let Some(err) = exc.as_exception() {
format!(
"{}: {}",
err.message().unwrap_or_default(),
err.stack().unwrap_or_default()
)
} else {
format!("{:?}", exc)
};
tracing::error!(
"Unhandled JS exception after running jobs in {}: {}",
context,
error_msg
);
if should_panic_on_js_errors() {
panic!(
"Unhandled JS exception after running jobs in {}: {}",
context, error_msg
);
}
}
count
}
fn parse_text_property_entry(
ctx: &rquickjs::Ctx<'_>,
obj: &Object<'_>,
) -> Option<TextPropertyEntry> {
let text: String = obj.get("text").ok()?;
let properties: HashMap<String, serde_json::Value> = obj
.get::<_, Object>("properties")
.ok()
.map(|props_obj| {
let mut map = HashMap::new();
for key in props_obj.keys::<String>().flatten() {
if let Ok(v) = props_obj.get::<_, Value>(&key) {
map.insert(key, js_to_json(ctx, v));
}
}
map
})
.unwrap_or_default();
let style: Option<fresh_core::api::OverlayOptions> =
obj.get::<_, Object>("style").ok().and_then(|style_obj| {
let json_val = js_to_json(ctx, Value::from_object(style_obj));
serde_json::from_value(json_val).ok()
});
let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
.get::<_, rquickjs::Array>("inlineOverlays")
.ok()
.map(|arr| {
arr.iter::<Object>()
.flatten()
.filter_map(|item| {
let json_val = js_to_json(ctx, Value::from_object(item));
serde_json::from_value(json_val).ok()
})
.collect()
})
.unwrap_or_default();
Some(TextPropertyEntry {
text,
properties,
style,
inline_overlays,
})
}
pub type PendingResponses =
Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
#[derive(Debug, Clone)]
pub struct TsPluginInfo {
pub name: String,
pub path: PathBuf,
pub enabled: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PluginTrackedState {
pub overlay_namespaces: Vec<(BufferId, String)>,
pub virtual_line_namespaces: Vec<(BufferId, String)>,
pub line_indicator_namespaces: Vec<(BufferId, String)>,
pub virtual_text_ids: Vec<(BufferId, String)>,
pub file_explorer_namespaces: Vec<String>,
pub contexts_set: Vec<String>,
pub background_process_ids: Vec<u64>,
pub scroll_sync_group_ids: Vec<u32>,
pub virtual_buffer_ids: Vec<BufferId>,
pub composite_buffer_ids: Vec<BufferId>,
pub terminal_ids: Vec<fresh_core::TerminalId>,
}
pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
#[derive(Debug, Clone)]
pub struct PluginHandler {
pub plugin_name: String,
pub handler_name: String,
}
#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
#[rquickjs::class]
pub struct JsEditorApi {
#[qjs(skip_trace)]
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
#[qjs(skip_trace)]
command_sender: mpsc::Sender<PluginCommand>,
#[qjs(skip_trace)]
registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
#[qjs(skip_trace)]
event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
#[qjs(skip_trace)]
next_request_id: Rc<RefCell<u64>>,
#[qjs(skip_trace)]
callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
#[qjs(skip_trace)]
services: Arc<dyn fresh_core::services::PluginServiceBridge>,
#[qjs(skip_trace)]
plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
#[qjs(skip_trace)]
async_resource_owners: AsyncResourceOwners,
#[qjs(skip_trace)]
registered_command_names: Rc<RefCell<HashMap<String, String>>>,
#[qjs(skip_trace)]
registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
#[qjs(skip_trace)]
registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
#[qjs(skip_trace)]
registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
pub plugin_name: String,
}
#[plugin_api_impl]
#[rquickjs::methods(rename_all = "camelCase")]
impl JsEditorApi {
pub fn api_version(&self) -> u32 {
2
}
pub fn get_active_buffer_id(&self) -> u32 {
self.state_snapshot
.read()
.map(|s| s.active_buffer_id.0 as u32)
.unwrap_or(0)
}
pub fn get_active_split_id(&self) -> u32 {
self.state_snapshot
.read()
.map(|s| s.active_split_id as u32)
.unwrap_or(0)
}
#[plugin_api(ts_return = "BufferInfo[]")]
pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
s.buffers.values().cloned().collect()
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &buffers)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
s.available_grammars.clone()
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &grammars)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn debug(&self, msg: String) {
tracing::trace!("Plugin.debug: {}", msg);
}
pub fn info(&self, msg: String) {
tracing::info!("Plugin: {}", msg);
}
pub fn warn(&self, msg: String) {
tracing::warn!("Plugin: {}", msg);
}
pub fn error(&self, msg: String) {
tracing::error!("Plugin: {}", msg);
}
pub fn set_status(&self, msg: String) {
let _ = self
.command_sender
.send(PluginCommand::SetStatus { message: msg });
}
pub fn copy_to_clipboard(&self, text: String) {
let _ = self
.command_sender
.send(PluginCommand::SetClipboard { text });
}
pub fn set_clipboard(&self, text: String) {
let _ = self
.command_sender
.send(PluginCommand::SetClipboard { text });
}
pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
if let Some(mode_name) = mode {
let key = format!("{}\0{}", action, mode_name);
if let Ok(snapshot) = self.state_snapshot.read() {
return snapshot.keybinding_labels.get(&key).cloned();
}
}
None
}
pub fn register_command<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
description: String,
handler_name: String,
#[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
rquickjs::Value<'js>,
>,
) -> rquickjs::Result<bool> {
let plugin_name = self.plugin_name.clone();
let context_str: Option<String> = context.0.and_then(|v| {
if v.is_null() || v.is_undefined() {
None
} else {
v.as_string().and_then(|s| s.to_string().ok())
}
});
tracing::debug!(
"registerCommand: plugin='{}', name='{}', handler='{}'",
plugin_name,
name,
handler_name
);
let tracking_key = if name.starts_with('%') {
format!("{}:{}", plugin_name, name)
} else {
name.clone()
};
{
let names = self.registered_command_names.borrow();
if let Some(existing_plugin) = names.get(&tracking_key) {
if existing_plugin != &plugin_name {
let msg = format!(
"Command '{}' already registered by plugin '{}'",
name, existing_plugin
);
tracing::warn!("registerCommand collision: {}", msg);
return Err(
ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
);
}
}
}
self.registered_command_names
.borrow_mut()
.insert(tracking_key, plugin_name.clone());
self.registered_actions.borrow_mut().insert(
handler_name.clone(),
PluginHandler {
plugin_name: self.plugin_name.clone(),
handler_name: handler_name.clone(),
},
);
let command = Command {
name: name.clone(),
description,
action_name: handler_name,
plugin_name,
custom_contexts: context_str.into_iter().collect(),
};
Ok(self
.command_sender
.send(PluginCommand::RegisterCommand { command })
.is_ok())
}
pub fn unregister_command(&self, name: String) -> bool {
let tracking_key = if name.starts_with('%') {
format!("{}:{}", self.plugin_name, name)
} else {
name.clone()
};
self.registered_command_names
.borrow_mut()
.remove(&tracking_key);
self.command_sender
.send(PluginCommand::UnregisterCommand { name })
.is_ok()
}
pub fn set_context(&self, name: String, active: bool) -> bool {
if active {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.contexts_set
.push(name.clone());
}
self.command_sender
.send(PluginCommand::SetContext { name, active })
.is_ok()
}
pub fn execute_action(&self, action_name: String) -> bool {
self.command_sender
.send(PluginCommand::ExecuteAction { action_name })
.is_ok()
}
pub fn t<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
key: String,
args: rquickjs::function::Rest<Value<'js>>,
) -> String {
let plugin_name = self.plugin_name.clone();
let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
if let Some(obj) = first_arg.as_object() {
let mut map = HashMap::new();
for k in obj.keys::<String>().flatten() {
if let Ok(v) = obj.get::<_, String>(&k) {
map.insert(k, v);
}
}
map
} else {
HashMap::new()
}
} else {
HashMap::new()
};
let res = self.services.translate(&plugin_name, &key, &args_map);
tracing::info!(
"Translating: key={}, plugin={}, args={:?} => res='{}'",
key,
plugin_name,
args_map,
res
);
res
}
pub fn get_cursor_position(&self) -> u32 {
self.state_snapshot
.read()
.ok()
.and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
.unwrap_or(0)
}
pub fn get_buffer_path(&self, buffer_id: u32) -> String {
if let Ok(s) = self.state_snapshot.read() {
if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
if let Some(p) = &b.path {
return p.to_string_lossy().to_string();
}
}
}
String::new()
}
pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
if let Ok(s) = self.state_snapshot.read() {
if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
return b.length as u32;
}
}
0
}
pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
if let Ok(s) = self.state_snapshot.read() {
if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
return b.modified;
}
}
false
}
pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
self.command_sender
.send(PluginCommand::SaveBufferToPath {
buffer_id: BufferId(buffer_id as usize),
path: std::path::PathBuf::from(path),
})
.is_ok()
}
#[plugin_api(ts_return = "BufferInfo | null")]
pub fn get_buffer_info<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
) -> rquickjs::Result<Value<'js>> {
let info = if let Ok(s) = self.state_snapshot.read() {
s.buffers.get(&BufferId(buffer_id as usize)).cloned()
} else {
None
};
rquickjs_serde::to_value(ctx, &info)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "CursorInfo | null")]
pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let cursor = if let Ok(s) = self.state_snapshot.read() {
s.primary_cursor.clone()
} else {
None
};
rquickjs_serde::to_value(ctx, &cursor)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "CursorInfo[]")]
pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let cursors = if let Ok(s) = self.state_snapshot.read() {
s.all_cursors.clone()
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &cursors)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "number[]")]
pub fn get_all_cursor_positions<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
) -> rquickjs::Result<Value<'js>> {
let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
s.all_cursors.iter().map(|c| c.position as u32).collect()
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &positions)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "ViewportInfo | null")]
pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let viewport = if let Ok(s) = self.state_snapshot.read() {
s.viewport.clone()
} else {
None
};
rquickjs_serde::to_value(ctx, &viewport)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn get_cursor_line(&self) -> u32 {
0
}
#[plugin_api(
async_promise,
js_name = "getLineStartPosition",
ts_return = "number | null"
)]
#[qjs(rename = "_getLineStartPositionStart")]
pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self
.command_sender
.send(PluginCommand::GetLineStartPosition {
buffer_id: BufferId(0),
line,
request_id: id,
});
id
}
#[plugin_api(
async_promise,
js_name = "getLineEndPosition",
ts_return = "number | null"
)]
#[qjs(rename = "_getLineEndPositionStart")]
pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
buffer_id: BufferId(0),
line,
request_id: id,
});
id
}
#[plugin_api(
async_promise,
js_name = "getBufferLineCount",
ts_return = "number | null"
)]
#[qjs(rename = "_getBufferLineCountStart")]
pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
buffer_id: BufferId(0),
request_id: id,
});
id
}
pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
self.command_sender
.send(PluginCommand::ScrollToLineCenter {
split_id: SplitId(split_id as usize),
buffer_id: BufferId(buffer_id as usize),
line: line as usize,
})
.is_ok()
}
pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
self.command_sender
.send(PluginCommand::ScrollBufferToLine {
buffer_id: BufferId(buffer_id as usize),
line: line as usize,
})
.is_ok()
}
pub fn find_buffer_by_path(&self, path: String) -> u32 {
let path_buf = std::path::PathBuf::from(&path);
if let Ok(s) = self.state_snapshot.read() {
for (id, info) in &s.buffers {
if let Some(buf_path) = &info.path {
if buf_path == &path_buf {
return id.0 as u32;
}
}
}
}
0
}
#[plugin_api(ts_return = "BufferSavedDiff | null")]
pub fn get_buffer_saved_diff<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
) -> rquickjs::Result<Value<'js>> {
let diff = if let Ok(s) = self.state_snapshot.read() {
s.buffer_saved_diffs
.get(&BufferId(buffer_id as usize))
.cloned()
} else {
None
};
rquickjs_serde::to_value(ctx, &diff)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
self.command_sender
.send(PluginCommand::InsertText {
buffer_id: BufferId(buffer_id as usize),
position: position as usize,
text,
})
.is_ok()
}
pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
self.command_sender
.send(PluginCommand::DeleteRange {
buffer_id: BufferId(buffer_id as usize),
range: (start as usize)..(end as usize),
})
.is_ok()
}
pub fn insert_at_cursor(&self, text: String) -> bool {
self.command_sender
.send(PluginCommand::InsertAtCursor { text })
.is_ok()
}
pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
self.command_sender
.send(PluginCommand::OpenFileAtLocation {
path: PathBuf::from(path),
line: line.map(|l| l as usize),
column: column.map(|c| c as usize),
})
.is_ok()
}
pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
self.command_sender
.send(PluginCommand::OpenFileInSplit {
split_id: split_id as usize,
path: PathBuf::from(path),
line: Some(line as usize),
column: Some(column as usize),
})
.is_ok()
}
pub fn show_buffer(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::ShowBuffer {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn close_buffer(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseBuffer {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
if event_name == "lines_changed" {
let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
}
self.event_handlers
.borrow_mut()
.entry(event_name)
.or_default()
.push(PluginHandler {
plugin_name: self.plugin_name.clone(),
handler_name,
});
}
pub fn off(&self, event_name: String, handler_name: String) {
if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
list.retain(|h| h.handler_name != handler_name);
}
}
pub fn get_env(&self, name: String) -> Option<String> {
std::env::var(&name).ok()
}
pub fn get_cwd(&self) -> String {
self.state_snapshot
.read()
.map(|s| s.working_dir.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
}
pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
let mut result_parts: Vec<String> = Vec::new();
let mut has_leading_slash = false;
for part in &parts.0 {
let normalized = part.replace('\\', "/");
let is_absolute = normalized.starts_with('/')
|| (normalized.len() >= 2
&& normalized
.chars()
.next()
.map(|c| c.is_ascii_alphabetic())
.unwrap_or(false)
&& normalized.chars().nth(1) == Some(':'));
if is_absolute {
result_parts.clear();
has_leading_slash = normalized.starts_with('/');
}
for segment in normalized.split('/') {
if !segment.is_empty() && segment != "." {
if segment == ".." {
result_parts.pop();
} else {
result_parts.push(segment.to_string());
}
}
}
}
let joined = result_parts.join("/");
if has_leading_slash && !joined.is_empty() {
format!("/{}", joined)
} else {
joined
}
}
pub fn path_dirname(&self, path: String) -> String {
Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
pub fn path_basename(&self, path: String) -> String {
Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
}
pub fn path_extname(&self, path: String) -> String {
Path::new(&path)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default()
}
pub fn path_is_absolute(&self, path: String) -> bool {
Path::new(&path).is_absolute()
}
pub fn file_uri_to_path(&self, uri: String) -> String {
fresh_core::file_uri::file_uri_to_path(&uri)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
}
pub fn path_to_file_uri(&self, path: String) -> String {
fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
}
pub fn utf8_byte_length(&self, text: String) -> u32 {
text.len() as u32
}
pub fn file_exists(&self, path: String) -> bool {
Path::new(&path).exists()
}
pub fn read_file(&self, path: String) -> Option<String> {
std::fs::read_to_string(&path).ok()
}
pub fn write_file(&self, path: String, content: String) -> bool {
let p = Path::new(&path);
if let Some(parent) = p.parent() {
if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
return false;
}
}
std::fs::write(p, content).is_ok()
}
#[plugin_api(ts_return = "DirEntry[]")]
pub fn read_dir<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
path: String,
) -> rquickjs::Result<Value<'js>> {
use fresh_core::api::DirEntry;
let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|entry| {
let file_type = entry.file_type().ok();
DirEntry {
name: entry.file_name().to_string_lossy().to_string(),
is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
}
})
.collect(),
Err(e) => {
tracing::warn!("readDir failed for '{}': {}", path, e);
Vec::new()
}
};
rquickjs_serde::to_value(ctx, &entries)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn create_dir(&self, path: String) -> bool {
let p = Path::new(&path);
if p.is_dir() {
return true;
}
std::fs::create_dir_all(p).is_ok()
}
pub fn remove_path(&self, path: String) -> bool {
let target = match Path::new(&path).canonicalize() {
Ok(p) => p,
Err(_) => return false, };
let temp_dir = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
let config_dir = self
.services
.config_dir()
.canonicalize()
.unwrap_or_else(|_| self.services.config_dir());
let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
if !allowed {
tracing::warn!(
"removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
target,
temp_dir,
config_dir
);
return false;
}
if target == temp_dir || target == config_dir {
tracing::warn!(
"removePath refused: cannot remove root directory {:?}",
target
);
return false;
}
match trash::delete(&target) {
Ok(()) => true,
Err(e) => {
tracing::warn!("removePath trash failed for {:?}: {}", target, e);
false
}
}
}
pub fn rename_path(&self, from: String, to: String) -> bool {
if std::fs::rename(&from, &to).is_ok() {
return true;
}
let from_path = Path::new(&from);
let copied = if from_path.is_dir() {
copy_dir_recursive(from_path, Path::new(&to)).is_ok()
} else {
std::fs::copy(&from, &to).is_ok()
};
if copied {
return trash::delete(from_path).is_ok();
}
false
}
pub fn copy_path(&self, from: String, to: String) -> bool {
let from_path = Path::new(&from);
let to_path = Path::new(&to);
if from_path.is_dir() {
copy_dir_recursive(from_path, to_path).is_ok()
} else {
if let Some(parent) = to_path.parent() {
if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
return false;
}
}
std::fs::copy(from_path, to_path).is_ok()
}
}
pub fn get_temp_dir(&self) -> String {
std::env::temp_dir().to_string_lossy().to_string()
}
pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let config = self
.state_snapshot
.read()
.map(|s| std::sync::Arc::clone(&s.config))
.unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
rquickjs_serde::to_value(ctx, &*config)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let config = self
.state_snapshot
.read()
.map(|s| std::sync::Arc::clone(&s.user_config))
.unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
rquickjs_serde::to_value(ctx, &*config)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn reload_config(&self) {
let _ = self.command_sender.send(PluginCommand::ReloadConfig);
}
pub fn reload_themes(&self) {
let _ = self
.command_sender
.send(PluginCommand::ReloadThemes { apply_theme: None });
}
pub fn reload_and_apply_theme(&self, theme_name: String) {
let _ = self.command_sender.send(PluginCommand::ReloadThemes {
apply_theme: Some(theme_name),
});
}
pub fn register_grammar<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
language: String,
grammar_path: String,
extensions: Vec<String>,
) -> rquickjs::Result<bool> {
{
let langs = self.registered_grammar_languages.borrow();
if let Some(existing_plugin) = langs.get(&language) {
if existing_plugin != &self.plugin_name {
let msg = format!(
"Grammar for language '{}' already registered by plugin '{}'",
language, existing_plugin
);
tracing::warn!("registerGrammar collision: {}", msg);
return Err(
ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
);
}
}
}
self.registered_grammar_languages
.borrow_mut()
.insert(language.clone(), self.plugin_name.clone());
Ok(self
.command_sender
.send(PluginCommand::RegisterGrammar {
language,
grammar_path,
extensions,
})
.is_ok())
}
pub fn register_language_config<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
language: String,
config: LanguagePackConfig,
) -> rquickjs::Result<bool> {
{
let langs = self.registered_language_configs.borrow();
if let Some(existing_plugin) = langs.get(&language) {
if existing_plugin != &self.plugin_name {
let msg = format!(
"Language config for '{}' already registered by plugin '{}'",
language, existing_plugin
);
tracing::warn!("registerLanguageConfig collision: {}", msg);
return Err(
ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
);
}
}
}
self.registered_language_configs
.borrow_mut()
.insert(language.clone(), self.plugin_name.clone());
Ok(self
.command_sender
.send(PluginCommand::RegisterLanguageConfig { language, config })
.is_ok())
}
pub fn register_lsp_server<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
language: String,
config: LspServerPackConfig,
) -> rquickjs::Result<bool> {
{
let langs = self.registered_lsp_servers.borrow();
if let Some(existing_plugin) = langs.get(&language) {
if existing_plugin != &self.plugin_name {
let msg = format!(
"LSP server for language '{}' already registered by plugin '{}'",
language, existing_plugin
);
tracing::warn!("registerLspServer collision: {}", msg);
return Err(
ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
);
}
}
}
self.registered_lsp_servers
.borrow_mut()
.insert(language.clone(), self.plugin_name.clone());
Ok(self
.command_sender
.send(PluginCommand::RegisterLspServer { language, config })
.is_ok())
}
#[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
#[qjs(rename = "_reloadGrammarsStart")]
pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
callback_id: fresh_core::api::JsCallbackId::new(id),
});
id
}
pub fn get_plugin_dir(&self) -> String {
self.services
.plugins_dir()
.join("packages")
.join(&self.plugin_name)
.to_string_lossy()
.to_string()
}
pub fn get_config_dir(&self) -> String {
self.services.config_dir().to_string_lossy().to_string()
}
pub fn get_data_dir(&self) -> String {
self.services.data_dir().to_string_lossy().to_string()
}
pub fn get_themes_dir(&self) -> String {
self.services
.config_dir()
.join("themes")
.to_string_lossy()
.to_string()
}
pub fn apply_theme(&self, theme_name: String) -> bool {
self.command_sender
.send(PluginCommand::ApplyTheme { theme_name })
.is_ok()
}
pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let schema = self.services.get_theme_schema();
rquickjs_serde::to_value(ctx, &schema)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let themes = self.services.get_builtin_themes();
rquickjs_serde::to_value(ctx, &themes)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[qjs(rename = "_deleteThemeSync")]
pub fn delete_theme_sync(&self, name: String) -> bool {
let themes_dir = self.services.config_dir().join("themes");
let theme_path = themes_dir.join(format!("{}.json", name));
if let Ok(canonical) = theme_path.canonicalize() {
if let Ok(themes_canonical) = themes_dir.canonicalize() {
if canonical.starts_with(&themes_canonical) {
return std::fs::remove_file(&canonical).is_ok();
}
}
}
false
}
pub fn delete_theme(&self, name: String) -> bool {
self.delete_theme_sync(name)
}
pub fn get_theme_data<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
) -> rquickjs::Result<Value<'js>> {
match self.services.get_theme_data(&name) {
Some(data) => rquickjs_serde::to_value(ctx, &data)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
None => Ok(Value::new_null(ctx)),
}
}
pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
self.services
.save_theme_file(&name, &content)
.map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
}
pub fn theme_file_exists(&self, name: String) -> bool {
self.services.theme_file_exists(&name)
}
pub fn file_stat<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
path: String,
) -> rquickjs::Result<Value<'js>> {
let metadata = std::fs::metadata(&path).ok();
let stat = metadata.map(|m| {
serde_json::json!({
"isFile": m.is_file(),
"isDir": m.is_dir(),
"size": m.len(),
"readonly": m.permissions().readonly(),
})
});
rquickjs_serde::to_value(ctx, &stat)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn is_process_running(&self, _process_id: u64) -> bool {
false
}
pub fn kill_process(&self, process_id: u64) -> bool {
self.command_sender
.send(PluginCommand::KillBackgroundProcess { process_id })
.is_ok()
}
pub fn plugin_translate<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
plugin_name: String,
key: String,
args: rquickjs::function::Opt<rquickjs::Object<'js>>,
) -> String {
let args_map: HashMap<String, String> = args
.0
.map(|obj| {
let mut map = HashMap::new();
for (k, v) in obj.props::<String, String>().flatten() {
map.insert(k, v);
}
map
})
.unwrap_or_default();
self.services.translate(&plugin_name, &key, &args_map)
}
#[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
#[qjs(rename = "_createCompositeBufferStart")]
pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self
.command_sender
.send(PluginCommand::CreateCompositeBuffer {
name: opts.name,
mode: opts.mode,
layout: opts.layout,
sources: opts.sources,
hunks: opts.hunks,
initial_focus_hunk: opts.initial_focus_hunk,
request_id: Some(id),
});
id
}
pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
self.command_sender
.send(PluginCommand::UpdateCompositeAlignment {
buffer_id: BufferId(buffer_id as usize),
hunks,
})
.is_ok()
}
pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseCompositeBuffer {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn flush_layout(&self) -> bool {
self.command_sender.send(PluginCommand::FlushLayout).is_ok()
}
pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CompositeNextHunk {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CompositePrevHunk {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
#[plugin_api(
async_promise,
js_name = "getHighlights",
ts_return = "TsHighlightSpan[]"
)]
#[qjs(rename = "_getHighlightsStart")]
pub fn get_highlights_start<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
start: u32,
end: u32,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::RequestHighlights {
buffer_id: BufferId(buffer_id as usize),
range: (start as usize)..(end as usize),
request_id: id,
});
Ok(id)
}
pub fn add_overlay<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
namespace: String,
start: u32,
end: u32,
options: rquickjs::Object<'js>,
) -> rquickjs::Result<bool> {
use fresh_core::api::OverlayColorSpec;
fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
if let Ok(theme_key) = obj.get::<_, String>(key) {
if !theme_key.is_empty() {
return Some(OverlayColorSpec::ThemeKey(theme_key));
}
}
if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
if arr.len() >= 3 {
return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
}
}
None
}
let fg = parse_color_spec("fg", &options);
let bg = parse_color_spec("bg", &options);
let underline: bool = options.get("underline").unwrap_or(false);
let bold: bool = options.get("bold").unwrap_or(false);
let italic: bool = options.get("italic").unwrap_or(false);
let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
let url: Option<String> = options.get("url").ok();
let options = OverlayOptions {
fg,
bg,
underline,
bold,
italic,
strikethrough,
extend_to_line_end,
url,
};
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.overlay_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
let _ = self.command_sender.send(PluginCommand::AddOverlay {
buffer_id: BufferId(buffer_id as usize),
namespace: Some(OverlayNamespace::from_string(namespace)),
range: (start as usize)..(end as usize),
options,
});
Ok(true)
}
pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
})
.is_ok()
}
pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearAllOverlays {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearOverlaysInRange {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
})
.is_ok()
}
pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
use fresh_core::overlay::OverlayHandle;
self.command_sender
.send(PluginCommand::RemoveOverlay {
buffer_id: BufferId(buffer_id as usize),
handle: OverlayHandle(handle),
})
.is_ok()
}
pub fn add_conceal(
&self,
buffer_id: u32,
namespace: String,
start: u32,
end: u32,
replacement: Option<String>,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.overlay_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
self.command_sender
.send(PluginCommand::AddConceal {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
start: start as usize,
end: end as usize,
replacement,
})
.is_ok()
}
pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearConcealNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
})
.is_ok()
}
pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearConcealsInRange {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
})
.is_ok()
}
pub fn add_fold(
&self,
buffer_id: u32,
start: u32,
end: u32,
placeholder: rquickjs::function::Opt<String>,
) -> bool {
self.command_sender
.send(PluginCommand::AddFold {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
placeholder: placeholder.0,
})
.is_ok()
}
pub fn clear_folds(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearFolds {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn add_soft_break(
&self,
buffer_id: u32,
namespace: String,
position: u32,
indent: u32,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.overlay_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
self.command_sender
.send(PluginCommand::AddSoftBreak {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
position: position as usize,
indent: indent as u16,
})
.is_ok()
}
pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearSoftBreakNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
})
.is_ok()
}
pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearSoftBreaksInRange {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
})
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn submit_view_transform<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
split_id: Option<u32>,
start: u32,
end: u32,
tokens: Vec<rquickjs::Object<'js>>,
layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
let tokens: Vec<ViewTokenWire> = tokens
.into_iter()
.enumerate()
.map(|(idx, obj)| {
parse_view_token(&obj, idx)
})
.collect::<rquickjs::Result<Vec<_>>>()?;
let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
Some(LayoutHints {
compose_width,
column_guides,
})
} else {
None
};
let payload = ViewTransformPayload {
range: (start as usize)..(end as usize),
tokens,
layout_hints: parsed_layout_hints,
};
Ok(self
.command_sender
.send(PluginCommand::SubmitViewTransform {
buffer_id: BufferId(buffer_id as usize),
split_id: split_id.map(|id| SplitId(id as usize)),
payload,
})
.is_ok())
}
pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
self.command_sender
.send(PluginCommand::ClearViewTransform {
buffer_id: BufferId(buffer_id as usize),
split_id: split_id.map(|id| SplitId(id as usize)),
})
.is_ok()
}
pub fn set_layout_hints<'js>(
&self,
buffer_id: u32,
split_id: Option<u32>,
#[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
) -> rquickjs::Result<bool> {
use fresh_core::api::LayoutHints;
let compose_width: Option<u16> = hints.get("composeWidth").ok();
let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
let parsed_hints = LayoutHints {
compose_width,
column_guides,
};
Ok(self
.command_sender
.send(PluginCommand::SetLayoutHints {
buffer_id: BufferId(buffer_id as usize),
split_id: split_id.map(|id| SplitId(id as usize)),
range: 0..0,
hints: parsed_hints,
})
.is_ok())
}
pub fn set_file_explorer_decorations<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
namespace: String,
decorations: Vec<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
use fresh_core::file_explorer::FileExplorerDecoration;
let decorations: Vec<FileExplorerDecoration> = decorations
.into_iter()
.map(|obj| {
let path: String = obj.get("path")?;
let symbol: String = obj.get("symbol")?;
let priority: i32 = obj.get("priority").unwrap_or(0);
let color_val: rquickjs::Value = obj.get("color")?;
let color = if color_val.is_string() {
let key: String = color_val.get()?;
fresh_core::api::OverlayColorSpec::ThemeKey(key)
} else if color_val.is_array() {
let arr: Vec<u8> = color_val.get()?;
if arr.len() < 3 {
return Err(rquickjs::Error::FromJs {
from: "array",
to: "color",
message: Some(format!(
"color array must have at least 3 elements, got {}",
arr.len()
)),
});
}
fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
} else {
return Err(rquickjs::Error::FromJs {
from: "value",
to: "color",
message: Some("color must be an RGB array or theme key string".to_string()),
});
};
Ok(FileExplorerDecoration {
path: std::path::PathBuf::from(path),
symbol,
color,
priority,
})
})
.collect::<rquickjs::Result<Vec<_>>>()?;
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.file_explorer_namespaces
.push(namespace.clone());
Ok(self
.command_sender
.send(PluginCommand::SetFileExplorerDecorations {
namespace,
decorations,
})
.is_ok())
}
pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearFileExplorerDecorations { namespace })
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn add_virtual_text(
&self,
buffer_id: u32,
virtual_text_id: String,
position: u32,
text: String,
r: u8,
g: u8,
b: u8,
before: bool,
use_bg: bool,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.virtual_text_ids
.push((BufferId(buffer_id as usize), virtual_text_id.clone()));
self.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,
})
.is_ok()
}
pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
self.command_sender
.send(PluginCommand::RemoveVirtualText {
buffer_id: BufferId(buffer_id as usize),
virtual_text_id,
})
.is_ok()
}
pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
self.command_sender
.send(PluginCommand::RemoveVirtualTextsByPrefix {
buffer_id: BufferId(buffer_id as usize),
prefix,
})
.is_ok()
}
pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearVirtualTexts {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearVirtualTextNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace,
})
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn add_virtual_line<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
position: u32,
text: String,
options: rquickjs::Object<'js>,
above: bool,
namespace: String,
priority: i32,
) -> rquickjs::Result<bool> {
use fresh_core::api::OverlayColorSpec;
fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
if let Ok(theme_key) = obj.get::<_, String>(key) {
if !theme_key.is_empty() {
return Some(OverlayColorSpec::ThemeKey(theme_key));
}
}
if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
if arr.len() >= 3 {
return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
}
}
None
}
let fg_color = parse_color_spec("fg", &options);
let bg_color = parse_color_spec("bg", &options);
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.virtual_line_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
Ok(self
.command_sender
.send(PluginCommand::AddVirtualLine {
buffer_id: BufferId(buffer_id as usize),
position: position as usize,
text,
fg_color,
bg_color,
above,
namespace,
priority,
})
.is_ok())
}
#[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
#[qjs(rename = "_promptStart")]
pub fn prompt_start(
&self,
_ctx: rquickjs::Ctx<'_>,
label: String,
initial_value: String,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
label,
initial_value,
callback_id: JsCallbackId::new(id),
});
id
}
pub fn start_prompt(&self, label: String, prompt_type: String) -> bool {
self.command_sender
.send(PluginCommand::StartPrompt { label, prompt_type })
.is_ok()
}
pub fn start_prompt_with_initial(
&self,
label: String,
prompt_type: String,
initial_value: String,
) -> bool {
self.command_sender
.send(PluginCommand::StartPromptWithInitial {
label,
prompt_type,
initial_value,
})
.is_ok()
}
pub fn set_prompt_suggestions(
&self,
suggestions: Vec<fresh_core::command::Suggestion>,
) -> bool {
self.command_sender
.send(PluginCommand::SetPromptSuggestions { suggestions })
.is_ok()
}
pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
self.command_sender
.send(PluginCommand::SetPromptInputSync { sync })
.is_ok()
}
pub fn define_mode(
&self,
name: String,
bindings_arr: Vec<Vec<String>>,
read_only: rquickjs::function::Opt<bool>,
allow_text_input: rquickjs::function::Opt<bool>,
inherit_normal_bindings: rquickjs::function::Opt<bool>,
) -> bool {
let bindings: Vec<(String, String)> = bindings_arr
.into_iter()
.filter_map(|arr| {
if arr.len() >= 2 {
Some((arr[0].clone(), arr[1].clone()))
} else {
None
}
})
.collect();
{
let mut registered = self.registered_actions.borrow_mut();
for (_, cmd_name) in &bindings {
registered.insert(
cmd_name.clone(),
PluginHandler {
plugin_name: self.plugin_name.clone(),
handler_name: cmd_name.clone(),
},
);
}
}
let allow_text = allow_text_input.0.unwrap_or(false);
if allow_text {
let mut registered = self.registered_actions.borrow_mut();
registered.insert(
"mode_text_input".to_string(),
PluginHandler {
plugin_name: self.plugin_name.clone(),
handler_name: "mode_text_input".to_string(),
},
);
}
self.command_sender
.send(PluginCommand::DefineMode {
name,
bindings,
read_only: read_only.0.unwrap_or(false),
allow_text_input: allow_text,
inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
plugin_name: Some(self.plugin_name.clone()),
})
.is_ok()
}
pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
self.command_sender
.send(PluginCommand::SetEditorMode { mode })
.is_ok()
}
pub fn get_editor_mode(&self) -> Option<String> {
self.state_snapshot
.read()
.ok()
.and_then(|s| s.editor_mode.clone())
}
pub fn close_split(&self, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseSplit {
split_id: SplitId(split_id as usize),
})
.is_ok()
}
pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::SetSplitBuffer {
split_id: SplitId(split_id as usize),
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn focus_split(&self, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::FocusSplit {
split_id: SplitId(split_id as usize),
})
.is_ok()
}
pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
self.command_sender
.send(PluginCommand::SetSplitScroll {
split_id: SplitId(split_id as usize),
top_byte: top_byte as usize,
})
.is_ok()
}
pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
self.command_sender
.send(PluginCommand::SetSplitRatio {
split_id: SplitId(split_id as usize),
ratio,
})
.is_ok()
}
pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
self.command_sender
.send(PluginCommand::SetSplitLabel {
split_id: SplitId(split_id as usize),
label,
})
.is_ok()
}
pub fn clear_split_label(&self, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::ClearSplitLabel {
split_id: SplitId(split_id as usize),
})
.is_ok()
}
#[plugin_api(
async_promise,
js_name = "getSplitByLabel",
ts_return = "number | null"
)]
#[qjs(rename = "_getSplitByLabelStart")]
pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
label,
request_id: id,
});
id
}
pub fn distribute_splits_evenly(&self) -> bool {
self.command_sender
.send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
.is_ok()
}
pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
self.command_sender
.send(PluginCommand::SetBufferCursor {
buffer_id: BufferId(buffer_id as usize),
position: position as usize,
})
.is_ok()
}
#[qjs(rename = "setBufferShowCursors")]
pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
self.command_sender
.send(PluginCommand::SetBufferShowCursors {
buffer_id: BufferId(buffer_id as usize),
show,
})
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn set_line_indicator(
&self,
buffer_id: u32,
line: u32,
namespace: String,
symbol: String,
r: u8,
g: u8,
b: u8,
priority: i32,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.line_indicator_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
self.command_sender
.send(PluginCommand::SetLineIndicator {
buffer_id: BufferId(buffer_id as usize),
line: line as usize,
namespace,
symbol,
color: (r, g, b),
priority,
})
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn set_line_indicators(
&self,
buffer_id: u32,
lines: Vec<u32>,
namespace: String,
symbol: String,
r: u8,
g: u8,
b: u8,
priority: i32,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.line_indicator_namespaces
.push((BufferId(buffer_id as usize), namespace.clone()));
self.command_sender
.send(PluginCommand::SetLineIndicators {
buffer_id: BufferId(buffer_id as usize),
lines: lines.into_iter().map(|l| l as usize).collect(),
namespace,
symbol,
color: (r, g, b),
priority,
})
.is_ok()
}
pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
self.command_sender
.send(PluginCommand::ClearLineIndicators {
buffer_id: BufferId(buffer_id as usize),
namespace,
})
.is_ok()
}
pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
self.command_sender
.send(PluginCommand::SetLineNumbers {
buffer_id: BufferId(buffer_id as usize),
enabled,
})
.is_ok()
}
pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
self.command_sender
.send(PluginCommand::SetViewMode {
buffer_id: BufferId(buffer_id as usize),
mode,
})
.is_ok()
}
pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
self.command_sender
.send(PluginCommand::SetLineWrap {
buffer_id: BufferId(buffer_id as usize),
split_id: split_id.map(|s| SplitId(s as usize)),
enabled,
})
.is_ok()
}
pub fn set_view_state<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
key: String,
value: Value<'js>,
) -> bool {
let bid = BufferId(buffer_id as usize);
let json_value = if value.is_undefined() || value.is_null() {
None
} else {
Some(js_to_json(&ctx, value))
};
if let Ok(mut snapshot) = self.state_snapshot.write() {
if let Some(ref json_val) = json_value {
snapshot
.plugin_view_states
.entry(bid)
.or_default()
.insert(key.clone(), json_val.clone());
} else {
if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
map.remove(&key);
if map.is_empty() {
snapshot.plugin_view_states.remove(&bid);
}
}
}
}
self.command_sender
.send(PluginCommand::SetViewState {
buffer_id: bid,
key,
value: json_value,
})
.is_ok()
}
pub fn get_view_state<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
key: String,
) -> rquickjs::Result<Value<'js>> {
let bid = BufferId(buffer_id as usize);
if let Ok(snapshot) = self.state_snapshot.read() {
if let Some(map) = snapshot.plugin_view_states.get(&bid) {
if let Some(json_val) = map.get(&key) {
return json_to_js_value(&ctx, json_val);
}
}
}
Ok(Value::new_undefined(ctx.clone()))
}
pub fn set_global_state<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
key: String,
value: Value<'js>,
) -> bool {
let json_value = if value.is_undefined() || value.is_null() {
None
} else {
Some(js_to_json(&ctx, value))
};
if let Ok(mut snapshot) = self.state_snapshot.write() {
if let Some(ref json_val) = json_value {
snapshot
.plugin_global_states
.entry(self.plugin_name.clone())
.or_default()
.insert(key.clone(), json_val.clone());
} else {
if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
map.remove(&key);
if map.is_empty() {
snapshot.plugin_global_states.remove(&self.plugin_name);
}
}
}
}
self.command_sender
.send(PluginCommand::SetGlobalState {
plugin_name: self.plugin_name.clone(),
key,
value: json_value,
})
.is_ok()
}
pub fn get_global_state<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
key: String,
) -> rquickjs::Result<Value<'js>> {
if let Ok(snapshot) = self.state_snapshot.read() {
if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
if let Some(json_val) = map.get(&key) {
return json_to_js_value(&ctx, json_val);
}
}
}
Ok(Value::new_undefined(ctx.clone()))
}
pub fn create_scroll_sync_group(
&self,
group_id: u32,
left_split: u32,
right_split: u32,
) -> bool {
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.scroll_sync_group_ids
.push(group_id);
self.command_sender
.send(PluginCommand::CreateScrollSyncGroup {
group_id,
left_split: SplitId(left_split as usize),
right_split: SplitId(right_split as usize),
})
.is_ok()
}
pub fn set_scroll_sync_anchors<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
group_id: u32,
anchors: Vec<Vec<u32>>,
) -> bool {
let anchors: Vec<(usize, usize)> = anchors
.into_iter()
.filter_map(|pair| {
if pair.len() >= 2 {
Some((pair[0] as usize, pair[1] as usize))
} else {
None
}
})
.collect();
self.command_sender
.send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
.is_ok()
}
pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
self.command_sender
.send(PluginCommand::RemoveScrollSyncGroup { group_id })
.is_ok()
}
pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
self.command_sender
.send(PluginCommand::ExecuteActions { actions })
.is_ok()
}
pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
self.command_sender
.send(PluginCommand::ShowActionPopup {
popup_id: opts.id,
title: opts.title,
message: opts.message,
actions: opts.actions,
})
.is_ok()
}
pub fn disable_lsp_for_language(&self, language: String) -> bool {
self.command_sender
.send(PluginCommand::DisableLspForLanguage { language })
.is_ok()
}
pub fn restart_lsp_for_language(&self, language: String) -> bool {
self.command_sender
.send(PluginCommand::RestartLspForLanguage { language })
.is_ok()
}
pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
self.command_sender
.send(PluginCommand::SetLspRootUri { language, uri })
.is_ok()
}
#[plugin_api(ts_return = "JsDiagnostic[]")]
pub fn get_all_diagnostics<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
) -> rquickjs::Result<Value<'js>> {
use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
let diagnostics = if let Ok(s) = self.state_snapshot.read() {
let mut result: Vec<JsDiagnostic> = Vec::new();
for (uri, diags) in s.diagnostics.iter() {
for diag in diags {
result.push(JsDiagnostic {
uri: uri.clone(),
message: diag.message.clone(),
severity: diag.severity.map(|s| match s {
lsp_types::DiagnosticSeverity::ERROR => 1,
lsp_types::DiagnosticSeverity::WARNING => 2,
lsp_types::DiagnosticSeverity::INFORMATION => 3,
lsp_types::DiagnosticSeverity::HINT => 4,
_ => 0,
}),
range: JsRange {
start: JsPosition {
line: diag.range.start.line,
character: diag.range.start.character,
},
end: JsPosition {
line: diag.range.end.line,
character: diag.range.end.character,
},
},
source: diag.source.clone(),
});
}
}
result
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &diagnostics)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn get_handlers(&self, event_name: String) -> Vec<String> {
self.event_handlers
.borrow()
.get(&event_name)
.cloned()
.unwrap_or_default()
.into_iter()
.map(|h| h.handler_name)
.collect()
}
#[plugin_api(
async_promise,
js_name = "createVirtualBuffer",
ts_return = "VirtualBufferResult"
)]
#[qjs(rename = "_createVirtualBufferStart")]
pub fn create_virtual_buffer_start(
&self,
_ctx: rquickjs::Ctx<'_>,
opts: fresh_core::api::CreateVirtualBufferOptions,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let entries: Vec<TextPropertyEntry> = opts
.entries
.unwrap_or_default()
.into_iter()
.map(|e| TextPropertyEntry {
text: e.text,
properties: e.properties.unwrap_or_default(),
style: e.style,
inline_overlays: e.inline_overlays.unwrap_or_default(),
})
.collect();
tracing::debug!(
"_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
id
);
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self
.command_sender
.send(PluginCommand::CreateVirtualBufferWithContent {
name: opts.name,
mode: opts.mode.unwrap_or_default(),
read_only: opts.read_only.unwrap_or(false),
entries,
show_line_numbers: opts.show_line_numbers.unwrap_or(false),
show_cursors: opts.show_cursors.unwrap_or(true),
editing_disabled: opts.editing_disabled.unwrap_or(false),
hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
request_id: Some(id),
});
Ok(id)
}
#[plugin_api(
async_promise,
js_name = "createVirtualBufferInSplit",
ts_return = "VirtualBufferResult"
)]
#[qjs(rename = "_createVirtualBufferInSplitStart")]
pub fn create_virtual_buffer_in_split_start(
&self,
_ctx: rquickjs::Ctx<'_>,
opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let entries: Vec<TextPropertyEntry> = opts
.entries
.unwrap_or_default()
.into_iter()
.map(|e| TextPropertyEntry {
text: e.text,
properties: e.properties.unwrap_or_default(),
style: e.style,
inline_overlays: e.inline_overlays.unwrap_or_default(),
})
.collect();
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self
.command_sender
.send(PluginCommand::CreateVirtualBufferInSplit {
name: opts.name,
mode: opts.mode.unwrap_or_default(),
read_only: opts.read_only.unwrap_or(false),
entries,
ratio: opts.ratio.unwrap_or(0.5),
direction: opts.direction,
panel_id: opts.panel_id,
show_line_numbers: opts.show_line_numbers.unwrap_or(true),
show_cursors: opts.show_cursors.unwrap_or(true),
editing_disabled: opts.editing_disabled.unwrap_or(false),
line_wrap: opts.line_wrap,
before: opts.before.unwrap_or(false),
request_id: Some(id),
});
Ok(id)
}
#[plugin_api(
async_promise,
js_name = "createVirtualBufferInExistingSplit",
ts_return = "VirtualBufferResult"
)]
#[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
pub fn create_virtual_buffer_in_existing_split_start(
&self,
_ctx: rquickjs::Ctx<'_>,
opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let entries: Vec<TextPropertyEntry> = opts
.entries
.unwrap_or_default()
.into_iter()
.map(|e| TextPropertyEntry {
text: e.text,
properties: e.properties.unwrap_or_default(),
style: e.style,
inline_overlays: e.inline_overlays.unwrap_or_default(),
})
.collect();
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self
.command_sender
.send(PluginCommand::CreateVirtualBufferInExistingSplit {
name: opts.name,
mode: opts.mode.unwrap_or_default(),
read_only: opts.read_only.unwrap_or(false),
entries,
split_id: SplitId(opts.split_id),
show_line_numbers: opts.show_line_numbers.unwrap_or(true),
show_cursors: opts.show_cursors.unwrap_or(true),
editing_disabled: opts.editing_disabled.unwrap_or(false),
line_wrap: opts.line_wrap,
request_id: Some(id),
});
Ok(id)
}
#[qjs(rename = "_createBufferGroupStart")]
pub fn create_buffer_group_start(
&self,
_ctx: rquickjs::Ctx<'_>,
name: String,
mode: String,
layout_json: String,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
name,
mode,
layout_json,
request_id: Some(id),
});
Ok(id)
}
#[qjs(rename = "setPanelContent")]
pub fn set_panel_content<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
group_id: u32,
panel_name: String,
entries_arr: Vec<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
let entries: Vec<TextPropertyEntry> = entries_arr
.iter()
.filter_map(|obj| parse_text_property_entry(&ctx, obj))
.collect();
Ok(self
.command_sender
.send(PluginCommand::SetPanelContent {
group_id: group_id as usize,
panel_name,
entries,
})
.is_ok())
}
#[qjs(rename = "closeBufferGroup")]
pub fn close_buffer_group(&self, group_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseBufferGroup {
group_id: group_id as usize,
})
.is_ok()
}
#[qjs(rename = "focusBufferGroupPanel")]
pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
self.command_sender
.send(PluginCommand::FocusPanel {
group_id: group_id as usize,
panel_name,
})
.is_ok()
}
pub fn set_virtual_buffer_content<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
entries_arr: Vec<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
let entries: Vec<TextPropertyEntry> = entries_arr
.iter()
.filter_map(|obj| parse_text_property_entry(&ctx, obj))
.collect();
Ok(self
.command_sender
.send(PluginCommand::SetVirtualBufferContent {
buffer_id: BufferId(buffer_id as usize),
entries,
})
.is_ok())
}
pub fn get_text_properties_at_cursor(
&self,
buffer_id: u32,
) -> fresh_core::api::TextPropertiesAtCursor {
get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
}
#[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
#[qjs(rename = "_spawnProcessStart")]
pub fn spawn_process_start(
&self,
_ctx: rquickjs::Ctx<'_>,
command: String,
args: Vec<String>,
cwd: rquickjs::function::Opt<String>,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let effective_cwd = cwd.0.or_else(|| {
self.state_snapshot
.read()
.ok()
.map(|s| s.working_dir.to_string_lossy().to_string())
});
tracing::info!(
"spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
self.plugin_name,
command,
args,
effective_cwd,
id
);
let _ = self.command_sender.send(PluginCommand::SpawnProcess {
callback_id: JsCallbackId::new(id),
command,
args,
cwd: effective_cwd,
});
id
}
#[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
#[qjs(rename = "_spawnProcessWaitStart")]
pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
process_id,
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
#[qjs(rename = "_getBufferTextStart")]
pub fn get_buffer_text_start(
&self,
_ctx: rquickjs::Ctx<'_>,
buffer_id: u32,
start: u32,
end: u32,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::GetBufferText {
buffer_id: BufferId(buffer_id as usize),
start: start as usize,
end: end as usize,
request_id: id,
});
id
}
#[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
#[qjs(rename = "_delayStart")]
pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::Delay {
callback_id: JsCallbackId::new(id),
duration_ms,
});
id
}
#[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
#[qjs(rename = "_grepProjectStart")]
pub fn grep_project_start(
&self,
_ctx: rquickjs::Ctx<'_>,
pattern: String,
fixed_string: Option<bool>,
case_sensitive: Option<bool>,
max_results: Option<u32>,
whole_words: Option<bool>,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::GrepProject {
pattern,
fixed_string: fixed_string.unwrap_or(true),
case_sensitive: case_sensitive.unwrap_or(true),
max_results: max_results.unwrap_or(200) as usize,
whole_words: whole_words.unwrap_or(false),
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(
js_name = "grepProjectStreaming",
ts_raw = "grepProjectStreaming(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }, progressCallback?: (matches: GrepMatch[], done: boolean) => void): PromiseLike<GrepMatch[]> & { searchId: number }"
)]
#[qjs(rename = "_grepProjectStreamingStart")]
pub fn grep_project_streaming_start(
&self,
_ctx: rquickjs::Ctx<'_>,
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: u32,
whole_words: bool,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self
.command_sender
.send(PluginCommand::GrepProjectStreaming {
pattern,
fixed_string,
case_sensitive,
max_results: max_results as usize,
whole_words,
search_id: id,
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
#[qjs(rename = "_replaceInFileStart")]
pub fn replace_in_file_start(
&self,
_ctx: rquickjs::Ctx<'_>,
file_path: String,
matches: Vec<Vec<u32>>,
replacement: String,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let match_pairs: Vec<(usize, usize)> = matches
.iter()
.map(|m| (m[0] as usize, m[1] as usize))
.collect();
let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
file_path: PathBuf::from(file_path),
matches: match_pairs,
replacement,
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
#[qjs(rename = "_sendLspRequestStart")]
pub fn send_lsp_request_start<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
language: String,
method: String,
params: Option<rquickjs::Object<'js>>,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let params_json: Option<serde_json::Value> = params.map(|obj| {
let val = obj.into_value();
js_to_json(&ctx, val)
});
let _ = self.command_sender.send(PluginCommand::SendLspRequest {
request_id: id,
language,
method,
params: params_json,
});
Ok(id)
}
#[plugin_api(
async_thenable,
js_name = "spawnBackgroundProcess",
ts_return = "BackgroundProcessResult"
)]
#[qjs(rename = "_spawnBackgroundProcessStart")]
pub fn spawn_background_process_start(
&self,
_ctx: rquickjs::Ctx<'_>,
command: String,
args: Vec<String>,
cwd: rquickjs::function::Opt<String>,
) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let process_id = id;
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.background_process_ids
.push(process_id);
let _ = self
.command_sender
.send(PluginCommand::SpawnBackgroundProcess {
process_id,
command,
args,
cwd: cwd.0,
callback_id: JsCallbackId::new(id),
});
id
}
pub fn kill_background_process(&self, process_id: u64) -> bool {
self.command_sender
.send(PluginCommand::KillBackgroundProcess { process_id })
.is_ok()
}
#[plugin_api(
async_promise,
js_name = "createTerminal",
ts_return = "TerminalResult"
)]
#[qjs(rename = "_createTerminalStart")]
pub fn create_terminal_start(
&self,
_ctx: rquickjs::Ctx<'_>,
opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
) -> rquickjs::Result<u64> {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
cwd: None,
direction: None,
ratio: None,
focus: None,
});
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self.command_sender.send(PluginCommand::CreateTerminal {
cwd: opts.cwd,
direction: opts.direction,
ratio: opts.ratio,
focus: opts.focus,
request_id: id,
});
Ok(id)
}
pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
self.command_sender
.send(PluginCommand::SendTerminalInput {
terminal_id: fresh_core::TerminalId(terminal_id as usize),
data,
})
.is_ok()
}
pub fn close_terminal(&self, terminal_id: u64) -> bool {
self.command_sender
.send(PluginCommand::CloseTerminal {
terminal_id: fresh_core::TerminalId(terminal_id as usize),
})
.is_ok()
}
pub fn refresh_lines(&self, buffer_id: u32) -> bool {
self.command_sender
.send(PluginCommand::RefreshLines {
buffer_id: BufferId(buffer_id as usize),
})
.is_ok()
}
pub fn get_current_locale(&self) -> String {
self.services.current_locale()
}
#[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
#[qjs(rename = "_loadPluginStart")]
pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::LoadPlugin {
path: std::path::PathBuf::from(path),
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
#[qjs(rename = "_unloadPluginStart")]
pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
name,
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
#[qjs(rename = "_reloadPluginStart")]
pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
name,
callback_id: JsCallbackId::new(id),
});
id
}
#[plugin_api(
async_promise,
js_name = "listPlugins",
ts_return = "Array<{name: string, path: string, enabled: boolean}>"
)]
#[qjs(rename = "_listPluginsStart")]
pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
let id = {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
self.callback_contexts
.borrow_mut()
.insert(id, self.plugin_name.clone());
id
};
let _ = self.command_sender.send(PluginCommand::ListPlugins {
callback_id: JsCallbackId::new(id),
});
id
}
}
fn parse_view_token(
obj: &rquickjs::Object<'_>,
idx: usize,
) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
from: "object",
to: "ViewTokenWire",
message: Some(format!("token[{}]: missing required field 'kind'", idx)),
})?;
let source_offset: Option<usize> = obj
.get("sourceOffset")
.ok()
.or_else(|| obj.get("source_offset").ok());
let kind = if kind_value.is_string() {
let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
from: "value",
to: "string",
message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
})?;
match kind_str.to_lowercase().as_str() {
"text" => {
let text: String = obj.get("text").unwrap_or_default();
ViewTokenWireKind::Text(text)
}
"newline" => ViewTokenWireKind::Newline,
"space" => ViewTokenWireKind::Space,
"break" => ViewTokenWireKind::Break,
_ => {
tracing::warn!(
"token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
idx, kind_str
);
return Err(rquickjs::Error::FromJs {
from: "string",
to: "ViewTokenWireKind",
message: Some(format!(
"token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
idx, kind_str
)),
});
}
}
} else if kind_value.is_object() {
let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
from: "value",
to: "object",
message: Some(format!("token[{}]: 'kind' is not an object", idx)),
})?;
if let Ok(text) = kind_obj.get::<_, String>("Text") {
ViewTokenWireKind::Text(text)
} else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
ViewTokenWireKind::BinaryByte(byte)
} else {
let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
tracing::warn!(
"token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
idx,
keys
);
return Err(rquickjs::Error::FromJs {
from: "object",
to: "ViewTokenWireKind",
message: Some(format!(
"token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
idx, keys
)),
});
}
} else {
tracing::warn!(
"token[{}]: 'kind' field must be a string or object, got: {:?}",
idx,
kind_value.type_of()
);
return Err(rquickjs::Error::FromJs {
from: "value",
to: "ViewTokenWireKind",
message: Some(format!(
"token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
idx
)),
});
};
let style = parse_view_token_style(obj, idx)?;
Ok(ViewTokenWire {
source_offset,
kind,
style,
})
}
fn parse_view_token_style(
obj: &rquickjs::Object<'_>,
idx: usize,
) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
use fresh_core::api::ViewTokenStyle;
let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
let Some(s) = style_obj else {
return Ok(None);
};
let fg: Option<Vec<u8>> = s.get("fg").ok();
let bg: Option<Vec<u8>> = s.get("bg").ok();
let fg_color = if let Some(ref c) = fg {
if c.len() < 3 {
tracing::warn!(
"token[{}]: style.fg has {} elements, expected 3 (RGB)",
idx,
c.len()
);
None
} else {
Some((c[0], c[1], c[2]))
}
} else {
None
};
let bg_color = if let Some(ref c) = bg {
if c.len() < 3 {
tracing::warn!(
"token[{}]: style.bg has {} elements, expected 3 (RGB)",
idx,
c.len()
);
None
} else {
Some((c[0], c[1], c[2]))
}
} else {
None
};
Ok(Some(ViewTokenStyle {
fg: fg_color,
bg: bg_color,
bold: s.get("bold").unwrap_or(false),
italic: s.get("italic").unwrap_or(false),
}))
}
pub struct QuickJsBackend {
runtime: Runtime,
main_context: Context,
plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: mpsc::Sender<PluginCommand>,
#[allow(dead_code)]
pending_responses: PendingResponses,
next_request_id: Rc<RefCell<u64>>,
callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
async_resource_owners: AsyncResourceOwners,
registered_command_names: Rc<RefCell<HashMap<String, String>>>,
registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
}
impl QuickJsBackend {
pub fn new() -> Result<Self> {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let services = Arc::new(fresh_core::services::NoopServiceBridge);
Self::with_state(state_snapshot, tx, services)
}
pub fn with_state(
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: mpsc::Sender<PluginCommand>,
services: Arc<dyn fresh_core::services::PluginServiceBridge>,
) -> 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, services)
}
pub fn with_state_and_responses(
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: mpsc::Sender<PluginCommand>,
pending_responses: PendingResponses,
services: Arc<dyn fresh_core::services::PluginServiceBridge>,
) -> Result<Self> {
let async_resource_owners: AsyncResourceOwners =
Arc::new(std::sync::Mutex::new(HashMap::new()));
Self::with_state_responses_and_resources(
state_snapshot,
command_sender,
pending_responses,
services,
async_resource_owners,
)
}
pub fn with_state_responses_and_resources(
state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
command_sender: mpsc::Sender<PluginCommand>,
pending_responses: PendingResponses,
services: Arc<dyn fresh_core::services::PluginServiceBridge>,
async_resource_owners: AsyncResourceOwners,
) -> Result<Self> {
tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
let runtime =
Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
runtime.set_host_promise_rejection_tracker(Some(Box::new(
|_ctx, _promise, reason, is_handled| {
if !is_handled {
let error_msg = if let Some(exc) = reason.as_exception() {
format!(
"{}: {}",
exc.message().unwrap_or_default(),
exc.stack().unwrap_or_default()
)
} else {
format!("{:?}", reason)
};
tracing::error!("Unhandled Promise rejection: {}", error_msg);
if should_panic_on_js_errors() {
let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
set_fatal_js_error(full_msg);
}
}
},
)));
let main_context = Context::full(&runtime)
.map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
let event_handlers = Rc::new(RefCell::new(HashMap::new()));
let registered_actions = Rc::new(RefCell::new(HashMap::new()));
let next_request_id = Rc::new(RefCell::new(1u64));
let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
let backend = Self {
runtime,
main_context,
plugin_contexts,
event_handlers,
registered_actions,
state_snapshot,
command_sender,
pending_responses,
next_request_id,
callback_contexts,
services,
plugin_tracked_state,
async_resource_owners,
registered_command_names,
registered_grammar_languages,
registered_language_configs,
registered_lsp_servers,
};
backend.setup_context_api(&backend.main_context.clone(), "internal")?;
tracing::debug!("QuickJsBackend::new: runtime created successfully");
Ok(backend)
}
fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
let state_snapshot = Arc::clone(&self.state_snapshot);
let command_sender = self.command_sender.clone();
let event_handlers = Rc::clone(&self.event_handlers);
let registered_actions = Rc::clone(&self.registered_actions);
let next_request_id = Rc::clone(&self.next_request_id);
let registered_command_names = Rc::clone(&self.registered_command_names);
let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
let registered_language_configs = Rc::clone(&self.registered_language_configs);
let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
context.with(|ctx| {
let globals = ctx.globals();
globals.set("__pluginName__", plugin_name)?;
let js_api = JsEditorApi {
state_snapshot: Arc::clone(&state_snapshot),
command_sender: command_sender.clone(),
registered_actions: Rc::clone(®istered_actions),
event_handlers: Rc::clone(&event_handlers),
next_request_id: Rc::clone(&next_request_id),
callback_contexts: Rc::clone(&self.callback_contexts),
services: self.services.clone(),
plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
async_resource_owners: Arc::clone(&self.async_resource_owners),
registered_command_names: Rc::clone(®istered_command_names),
registered_grammar_languages: Rc::clone(®istered_grammar_languages),
registered_language_configs: Rc::clone(®istered_language_configs),
registered_lsp_servers: Rc::clone(®istered_lsp_servers),
plugin_name: plugin_name.to_string(),
};
let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
globals.set("editor", editor)?;
ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
let console = Object::new(ctx.clone())?;
console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
tracing::info!("console.log: {}", parts.join(" "));
})?)?;
console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
tracing::warn!("console.warn: {}", parts.join(" "));
})?)?;
console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
tracing::error!("console.error: {}", parts.join(" "));
})?)?;
globals.set("console", console)?;
ctx.eval::<(), _>(r#"
// Pending promise callbacks: callbackId -> { resolve, reject }
globalThis._pendingCallbacks = new Map();
// Resolve a pending callback (called from Rust)
globalThis._resolveCallback = function(callbackId, result) {
console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
const cb = globalThis._pendingCallbacks.get(callbackId);
if (cb) {
console.log('[JS] _resolveCallback: found callback, calling resolve()');
globalThis._pendingCallbacks.delete(callbackId);
cb.resolve(result);
console.log('[JS] _resolveCallback: resolve() called');
} else {
console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
}
};
// Reject a pending callback (called from Rust)
globalThis._rejectCallback = function(callbackId, error) {
const cb = globalThis._pendingCallbacks.get(callbackId);
if (cb) {
globalThis._pendingCallbacks.delete(callbackId);
cb.reject(new Error(error));
}
};
// Streaming callbacks: called multiple times with partial results
globalThis._streamingCallbacks = new Map();
// Called from Rust with partial data. When done=true, cleans up.
globalThis._callStreamingCallback = function(callbackId, result, done) {
const cb = globalThis._streamingCallbacks.get(callbackId);
if (cb) {
cb(result, done);
if (done) {
globalThis._streamingCallbacks.delete(callbackId);
}
}
};
// Generic async wrapper decorator
// Wraps a function that returns a callbackId into a promise-returning function
// Usage: editor.foo = _wrapAsync("_fooStart", "foo");
// NOTE: We pass the method name as a string and call via bracket notation
// to preserve rquickjs's automatic Ctx injection for methods
globalThis._wrapAsync = function(methodName, fnName) {
const startFn = editor[methodName];
if (typeof startFn !== 'function') {
// Return a function that always throws - catches missing implementations
return function(...args) {
const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
editor.debug(`[ASYNC ERROR] ${error.message}`);
throw error;
};
}
return function(...args) {
// Call via bracket notation to preserve method binding and Ctx injection
const callbackId = editor[methodName](...args);
return new Promise((resolve, reject) => {
// NOTE: setTimeout not available in QuickJS - timeout disabled for now
// TODO: Implement setTimeout polyfill using editor.delay() or similar
globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
});
};
};
// Async wrapper that returns a thenable object (for APIs like spawnProcess)
// The returned object has .result promise and is itself thenable
globalThis._wrapAsyncThenable = function(methodName, fnName) {
const startFn = editor[methodName];
if (typeof startFn !== 'function') {
// Return a function that always throws - catches missing implementations
return function(...args) {
const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
editor.debug(`[ASYNC ERROR] ${error.message}`);
throw error;
};
}
return function(...args) {
// Call via bracket notation to preserve method binding and Ctx injection
const callbackId = editor[methodName](...args);
const resultPromise = new Promise((resolve, reject) => {
// NOTE: setTimeout not available in QuickJS - timeout disabled for now
globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
});
return {
get result() { return resultPromise; },
then(onFulfilled, onRejected) {
return resultPromise.then(onFulfilled, onRejected);
},
catch(onRejected) {
return resultPromise.catch(onRejected);
}
};
};
};
// Apply wrappers to async functions on editor
editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
editor.delay = _wrapAsync("_delayStart", "delay");
editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
editor.prompt = _wrapAsync("_promptStart", "prompt");
editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
// Streaming grep: takes a progress callback, returns a thenable with searchId
editor.grepProjectStreaming = function(pattern, opts, progressCallback) {
opts = opts || {};
const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
const maxResults = opts.maxResults || 10000;
const wholeWords = opts.wholeWords || false;
const searchId = editor._grepProjectStreamingStart(
pattern, fixedString, caseSensitive, maxResults, wholeWords
);
// Register streaming callback
if (progressCallback) {
globalThis._streamingCallbacks.set(searchId, progressCallback);
}
// Create completion promise (resolved via _resolveCallback when search finishes)
const resultPromise = new Promise(function(resolve, reject) {
globalThis._pendingCallbacks.set(searchId, {
resolve: function(result) {
globalThis._streamingCallbacks.delete(searchId);
resolve(result);
},
reject: function(err) {
globalThis._streamingCallbacks.delete(searchId);
reject(err);
}
});
});
return {
searchId: searchId,
get result() { return resultPromise; },
then: function(f, r) { return resultPromise.then(f, r); },
catch: function(r) { return resultPromise.catch(r); }
};
};
// Wrapper for deleteTheme - wraps sync function in Promise
editor.deleteTheme = function(name) {
return new Promise(function(resolve, reject) {
const success = editor._deleteThemeSync(name);
if (success) {
resolve();
} else {
reject(new Error("Failed to delete theme: " + name));
}
});
};
"#.as_bytes())?;
Ok::<_, rquickjs::Error>(())
}).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
Ok(())
}
pub async fn load_module_with_source(
&mut self,
path: &str,
_plugin_source: &str,
) -> Result<()> {
let path_buf = PathBuf::from(path);
let source = std::fs::read_to_string(&path_buf)
.map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
let filename = path_buf
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("plugin.ts");
if has_es_imports(&source) {
match bundle_module(&path_buf) {
Ok(bundled) => {
self.execute_js(&bundled, path)?;
}
Err(e) => {
tracing::warn!(
"Plugin {} uses ES imports but bundling failed: {}. Skipping.",
path,
e
);
return Ok(()); }
}
} else if has_es_module_syntax(&source) {
let stripped = strip_imports_and_exports(&source);
let js_code = if filename.ends_with(".ts") {
transpile_typescript(&stripped, filename)?
} else {
stripped
};
self.execute_js(&js_code, path)?;
} else {
let js_code = if filename.ends_with(".ts") {
transpile_typescript(&source, filename)?
} else {
source
};
self.execute_js(&js_code, path)?;
}
Ok(())
}
pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
let plugin_name = Path::new(source_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
tracing::debug!(
"execute_js: starting for plugin '{}' from '{}'",
plugin_name,
source_name
);
let context = {
let mut contexts = self.plugin_contexts.borrow_mut();
if let Some(ctx) = contexts.get(plugin_name) {
ctx.clone()
} else {
let ctx = Context::full(&self.runtime).map_err(|e| {
anyhow!(
"Failed to create QuickJS context for plugin {}: {}",
plugin_name,
e
)
})?;
self.setup_context_api(&ctx, plugin_name)?;
contexts.insert(plugin_name.to_string(), ctx.clone());
ctx
}
};
let wrapped_code = format!("(function() {{ {} }})();", code);
let wrapped = wrapped_code.as_str();
context.with(|ctx| {
tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
let mut eval_options = rquickjs::context::EvalOptions::default();
eval_options.global = true;
eval_options.filename = Some(source_name.to_string());
let result = ctx
.eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
.map_err(|e| format_js_error(&ctx, e, source_name));
tracing::debug!(
"execute_js: plugin code execution finished for '{}', result: {:?}",
plugin_name,
result.is_ok()
);
result
})
}
pub fn execute_source(
&mut self,
source: &str,
plugin_name: &str,
is_typescript: bool,
) -> Result<()> {
use fresh_parser_js::{
has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
};
if has_es_imports(source) {
tracing::warn!(
"Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
plugin_name
);
}
let js_code = if has_es_module_syntax(source) {
let stripped = strip_imports_and_exports(source);
if is_typescript {
transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
} else {
stripped
}
} else if is_typescript {
transpile_typescript(source, &format!("{}.ts", plugin_name))?
} else {
source.to_string()
};
let source_name = format!(
"{}.{}",
plugin_name,
if is_typescript { "ts" } else { "js" }
);
self.execute_js(&js_code, &source_name)
}
pub fn cleanup_plugin(&self, plugin_name: &str) {
self.plugin_contexts.borrow_mut().remove(plugin_name);
for handlers in self.event_handlers.borrow_mut().values_mut() {
handlers.retain(|h| h.plugin_name != plugin_name);
}
self.registered_actions
.borrow_mut()
.retain(|_, h| h.plugin_name != plugin_name);
self.callback_contexts
.borrow_mut()
.retain(|_, pname| pname != plugin_name);
if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
std::collections::HashSet::new();
for (buf_id, ns) in &tracked.overlay_namespaces {
if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
let _ = self.command_sender.send(PluginCommand::ClearNamespace {
buffer_id: *buf_id,
namespace: OverlayNamespace::from_string(ns.clone()),
});
let _ = self
.command_sender
.send(PluginCommand::ClearConcealNamespace {
buffer_id: *buf_id,
namespace: OverlayNamespace::from_string(ns.clone()),
});
let _ = self
.command_sender
.send(PluginCommand::ClearSoftBreakNamespace {
buffer_id: *buf_id,
namespace: OverlayNamespace::from_string(ns.clone()),
});
}
}
let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
std::collections::HashSet::new();
for (buf_id, ns) in &tracked.line_indicator_namespaces {
if seen_li_ns.insert((buf_id.0, ns.clone())) {
let _ = self
.command_sender
.send(PluginCommand::ClearLineIndicators {
buffer_id: *buf_id,
namespace: ns.clone(),
});
}
}
let mut seen_vt: std::collections::HashSet<(usize, String)> =
std::collections::HashSet::new();
for (buf_id, vt_id) in &tracked.virtual_text_ids {
if seen_vt.insert((buf_id.0, vt_id.clone())) {
let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
buffer_id: *buf_id,
virtual_text_id: vt_id.clone(),
});
}
}
let mut seen_fe_ns: std::collections::HashSet<String> =
std::collections::HashSet::new();
for ns in &tracked.file_explorer_namespaces {
if seen_fe_ns.insert(ns.clone()) {
let _ = self
.command_sender
.send(PluginCommand::ClearFileExplorerDecorations {
namespace: ns.clone(),
});
}
}
let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
for ctx_name in &tracked.contexts_set {
if seen_ctx.insert(ctx_name.clone()) {
let _ = self.command_sender.send(PluginCommand::SetContext {
name: ctx_name.clone(),
active: false,
});
}
}
for process_id in &tracked.background_process_ids {
let _ = self
.command_sender
.send(PluginCommand::KillBackgroundProcess {
process_id: *process_id,
});
}
for group_id in &tracked.scroll_sync_group_ids {
let _ = self
.command_sender
.send(PluginCommand::RemoveScrollSyncGroup {
group_id: *group_id,
});
}
for buffer_id in &tracked.virtual_buffer_ids {
let _ = self.command_sender.send(PluginCommand::CloseBuffer {
buffer_id: *buffer_id,
});
}
for buffer_id in &tracked.composite_buffer_ids {
let _ = self
.command_sender
.send(PluginCommand::CloseCompositeBuffer {
buffer_id: *buffer_id,
});
}
for terminal_id in &tracked.terminal_ids {
let _ = self.command_sender.send(PluginCommand::CloseTerminal {
terminal_id: *terminal_id,
});
}
}
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.retain(|_, name| name != plugin_name);
}
self.registered_command_names
.borrow_mut()
.retain(|_, pname| pname != plugin_name);
self.registered_grammar_languages
.borrow_mut()
.retain(|_, pname| pname != plugin_name);
self.registered_language_configs
.borrow_mut()
.retain(|_, pname| pname != plugin_name);
self.registered_lsp_servers
.borrow_mut()
.retain(|_, pname| pname != plugin_name);
tracing::debug!(
"cleanup_plugin: cleaned up runtime state for plugin '{}'",
plugin_name
);
}
pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
self.services
.set_js_execution_state(format!("hook '{}'", event_name));
let handlers = self.event_handlers.borrow().get(event_name).cloned();
if let Some(handler_pairs) = handlers {
let plugin_contexts = self.plugin_contexts.borrow();
for handler in &handler_pairs {
let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
continue;
};
context.with(|ctx| {
call_handler(&ctx, &handler.handler_name, event_data);
});
}
}
self.services.clear_js_execution_state();
Ok(true)
}
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 start_action(&mut self, action_name: &str) -> Result<()> {
let (lookup_name, text_input_char) =
if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
("mode_text_input", Some(ch.to_string()))
} else {
(action_name, None)
};
let pair = self.registered_actions.borrow().get(lookup_name).cloned();
let (plugin_name, function_name) = match pair {
Some(handler) => (handler.plugin_name, handler.handler_name),
None => ("main".to_string(), lookup_name.to_string()),
};
let plugin_contexts = self.plugin_contexts.borrow();
let context = plugin_contexts
.get(&plugin_name)
.unwrap_or(&self.main_context);
self.services
.set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
tracing::info!(
"start_action: BEGIN '{}' -> function '{}'",
action_name,
function_name
);
let call_args = if let Some(ref ch) = text_input_char {
let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
format!("({{text:\"{}\"}})", escaped)
} else {
"()".to_string()
};
let code = format!(
r#"
(function() {{
console.log('[JS] start_action: calling {fn}');
try {{
if (typeof globalThis.{fn} === 'function') {{
console.log('[JS] start_action: {fn} is a function, invoking...');
globalThis.{fn}{args};
console.log('[JS] start_action: {fn} invoked (may be async)');
}} else {{
console.error('[JS] Action {action} is not defined as a global function');
}}
}} catch (e) {{
console.error('[JS] Action {action} error:', e);
}}
}})();
"#,
fn = function_name,
action = action_name,
args = call_args
);
tracing::info!("start_action: evaluating JS code");
context.with(|ctx| {
if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
log_js_error(&ctx, e, &format!("action {}", action_name));
}
tracing::info!("start_action: running pending microtasks");
let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
tracing::info!("start_action: executed {} pending jobs", count);
});
tracing::info!("start_action: END '{}'", action_name);
self.services.clear_js_execution_state();
Ok(())
}
pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
let pair = self.registered_actions.borrow().get(action_name).cloned();
let (plugin_name, function_name) = match pair {
Some(handler) => (handler.plugin_name, handler.handler_name),
None => ("main".to_string(), action_name.to_string()),
};
let plugin_contexts = self.plugin_contexts.borrow();
let context = plugin_contexts
.get(&plugin_name)
.unwrap_or(&self.main_context);
tracing::debug!(
"execute_action: '{}' -> function '{}'",
action_name,
function_name
);
let code = format!(
r#"
(async function() {{
try {{
if (typeof globalThis.{fn} === 'function') {{
const result = globalThis.{fn}();
// If it's a Promise, await it
if (result && typeof result.then === 'function') {{
await result;
}}
}} else {{
console.error('Action {action} is not defined as a global function');
}}
}} catch (e) {{
console.error('Action {action} error:', e);
}}
}})();
"#,
fn = function_name,
action = action_name
);
context.with(|ctx| {
match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
Ok(value) => {
if value.is_object() {
if let Some(obj) = value.as_object() {
if obj.get::<_, rquickjs::Function>("then").is_ok() {
run_pending_jobs_checked(
&ctx,
&format!("execute_action {} promise", action_name),
);
}
}
}
}
Err(e) => {
log_js_error(&ctx, e, &format!("action {}", action_name));
}
}
});
Ok(())
}
pub fn poll_event_loop_once(&mut self) -> bool {
let mut had_work = false;
self.main_context.with(|ctx| {
let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
if count > 0 {
had_work = true;
}
});
let contexts = self.plugin_contexts.borrow().clone();
for (name, context) in contexts {
context.with(|ctx| {
let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
if count > 0 {
had_work = true;
}
});
}
had_work
}
pub fn send_status(&self, message: String) {
let _ = self
.command_sender
.send(PluginCommand::SetStatus { message });
}
pub fn send_hook_completed(&self, hook_name: String) {
let _ = self
.command_sender
.send(PluginCommand::HookCompleted { hook_name });
}
pub fn resolve_callback(
&mut self,
callback_id: fresh_core::api::JsCallbackId,
result_json: &str,
) {
let id = callback_id.as_u64();
tracing::debug!("resolve_callback: starting for callback_id={}", id);
let plugin_name = {
let mut contexts = self.callback_contexts.borrow_mut();
contexts.remove(&id)
};
let Some(name) = plugin_name else {
tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
return;
};
let plugin_contexts = self.plugin_contexts.borrow();
let Some(context) = plugin_contexts.get(&name) else {
tracing::warn!("resolve_callback: Context lost for plugin {}", name);
return;
};
context.with(|ctx| {
let json_value: serde_json::Value = match serde_json::from_str(result_json) {
Ok(v) => v,
Err(e) => {
tracing::error!(
"resolve_callback: failed to parse JSON for callback_id={}: {}",
id,
e
);
return;
}
};
let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
Ok(v) => v,
Err(e) => {
tracing::error!(
"resolve_callback: failed to convert to JS value for callback_id={}: {}",
id,
e
);
return;
}
};
let globals = ctx.globals();
let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
Ok(f) => f,
Err(e) => {
tracing::error!(
"resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
id,
e
);
return;
}
};
if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
log_js_error(&ctx, e, &format!("resolving callback {}", id));
}
let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
tracing::info!(
"resolve_callback: executed {} pending jobs for callback_id={}",
job_count,
id
);
});
}
pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
let id = callback_id.as_u64();
let plugin_name = {
let mut contexts = self.callback_contexts.borrow_mut();
contexts.remove(&id)
};
let Some(name) = plugin_name else {
tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
return;
};
let plugin_contexts = self.plugin_contexts.borrow();
let Some(context) = plugin_contexts.get(&name) else {
tracing::warn!("reject_callback: Context lost for plugin {}", name);
return;
};
context.with(|ctx| {
let globals = ctx.globals();
let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
Ok(f) => f,
Err(e) => {
tracing::error!(
"reject_callback: _rejectCallback not found for callback_id={}: {:?}",
id,
e
);
return;
}
};
if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
log_js_error(&ctx, e, &format!("rejecting callback {}", id));
}
run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
});
}
pub fn call_streaming_callback(
&mut self,
callback_id: fresh_core::api::JsCallbackId,
result_json: &str,
done: bool,
) {
let id = callback_id.as_u64();
let plugin_name = {
let contexts = self.callback_contexts.borrow();
contexts.get(&id).cloned()
};
let Some(name) = plugin_name else {
tracing::warn!(
"call_streaming_callback: No plugin found for callback_id={}",
id
);
return;
};
if done {
self.callback_contexts.borrow_mut().remove(&id);
}
let plugin_contexts = self.plugin_contexts.borrow();
let Some(context) = plugin_contexts.get(&name) else {
tracing::warn!("call_streaming_callback: Context lost for plugin {}", name);
return;
};
context.with(|ctx| {
let json_value: serde_json::Value = match serde_json::from_str(result_json) {
Ok(v) => v,
Err(e) => {
tracing::error!(
"call_streaming_callback: failed to parse JSON for callback_id={}: {}",
id,
e
);
return;
}
};
let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
Ok(v) => v,
Err(e) => {
tracing::error!(
"call_streaming_callback: failed to convert to JS value for callback_id={}: {}",
id,
e
);
return;
}
};
let globals = ctx.globals();
let call_fn: rquickjs::Function = match globals.get("_callStreamingCallback") {
Ok(f) => f,
Err(e) => {
tracing::error!(
"call_streaming_callback: _callStreamingCallback not found for callback_id={}: {:?}",
id,
e
);
return;
}
};
if let Err(e) = call_fn.call::<_, ()>((id, js_value, done)) {
log_js_error(
&ctx,
e,
&format!("calling streaming callback {}", id),
);
}
run_pending_jobs_checked(&ctx, &format!("call_streaming_callback {}", id));
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use fresh_core::api::{BufferInfo, CursorInfo};
use std::sync::mpsc;
fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
let (tx, rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let services = Arc::new(TestServiceBridge::new());
let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
(backend, rx)
}
struct TestServiceBridge {
en_strings: std::sync::Mutex<HashMap<String, String>>,
}
impl TestServiceBridge {
fn new() -> Self {
Self {
en_strings: std::sync::Mutex::new(HashMap::new()),
}
}
}
impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn translate(
&self,
_plugin_name: &str,
key: &str,
_args: &HashMap<String, String>,
) -> String {
self.en_strings
.lock()
.unwrap()
.get(key)
.cloned()
.unwrap_or_else(|| key.to_string())
}
fn current_locale(&self) -> String {
"en".to_string()
}
fn set_js_execution_state(&self, _state: String) {}
fn clear_js_execution_state(&self) {}
fn get_theme_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn get_builtin_themes(&self) -> serde_json::Value {
serde_json::json!([])
}
fn register_command(&self, _command: fresh_core::command::Command) {}
fn unregister_command(&self, _name: &str) {}
fn unregister_commands_by_prefix(&self, _prefix: &str) {}
fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
fn plugins_dir(&self) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/plugins")
}
fn config_dir(&self) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/config")
}
fn data_dir(&self) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/data")
}
fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
None
}
fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
Err("not implemented in test".to_string())
}
fn theme_file_exists(&self, _name: &str) -> bool {
false
}
}
#[test]
fn test_quickjs_backend_creation() {
let backend = QuickJsBackend::new();
assert!(backend.is_ok());
}
#[test]
fn test_execute_simple_js() {
let mut backend = QuickJsBackend::new().unwrap();
let result = backend.execute_js("const x = 1 + 2;", "test.js");
assert!(result.is_ok());
}
#[test]
fn test_event_handler_registration() {
let backend = QuickJsBackend::new().unwrap();
assert!(!backend.has_handlers("test_event"));
backend
.event_handlers
.borrow_mut()
.entry("test_event".to_string())
.or_default()
.push(PluginHandler {
plugin_name: "test".to_string(),
handler_name: "testHandler".to_string(),
});
assert!(backend.has_handlers("test_event"));
}
#[test]
fn test_api_set_status() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setStatus("Hello from test");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "Hello from test");
}
_ => panic!("Expected SetStatus command, got {:?}", cmd),
}
}
#[test]
fn test_api_register_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.myTestHandler = function() { };
editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
"#,
"test_plugin.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::RegisterCommand { command } => {
assert_eq!(command.name, "Test Command");
assert_eq!(command.description, "A test command");
assert_eq!(command.plugin_name, "test_plugin");
}
_ => panic!("Expected RegisterCommand, got {:?}", cmd),
}
}
#[test]
fn test_api_define_mode() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.defineMode("test-mode", [
["a", "action_a"],
["b", "action_b"]
]);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::DefineMode {
name,
bindings,
read_only,
allow_text_input,
inherit_normal_bindings,
plugin_name,
} => {
assert_eq!(name, "test-mode");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
assert!(!read_only);
assert!(!allow_text_input);
assert!(!inherit_normal_bindings);
assert!(plugin_name.is_some());
}
_ => panic!("Expected DefineMode, got {:?}", cmd),
}
}
#[test]
fn test_api_set_editor_mode() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setEditorMode("vi-normal");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetEditorMode { mode } => {
assert_eq!(mode, Some("vi-normal".to_string()));
}
_ => panic!("Expected SetEditorMode, got {:?}", cmd),
}
}
#[test]
fn test_api_clear_editor_mode() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setEditorMode(null);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetEditorMode { mode } => {
assert!(mode.is_none());
}
_ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
}
}
#[test]
fn test_api_insert_at_cursor() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.insertAtCursor("Hello, World!");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::InsertAtCursor { text } => {
assert_eq!(text, "Hello, World!");
}
_ => panic!("Expected InsertAtCursor, got {:?}", cmd),
}
}
#[test]
fn test_api_set_context() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setContext("myContext", true);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetContext { name, active } => {
assert_eq!(name, "myContext");
assert!(active);
}
_ => panic!("Expected SetContext, got {:?}", cmd),
}
}
#[tokio::test]
async fn test_execute_action_sync_function() {
let (mut backend, rx) = create_test_backend();
backend.registered_actions.borrow_mut().insert(
"my_sync_action".to_string(),
PluginHandler {
plugin_name: "test".to_string(),
handler_name: "my_sync_action".to_string(),
},
);
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.my_sync_action = function() {
editor.setStatus("sync action executed");
};
"#,
"test.js",
)
.unwrap();
while rx.try_recv().is_ok() {}
backend.execute_action("my_sync_action").await.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "sync action executed");
}
_ => panic!("Expected SetStatus from action, got {:?}", cmd),
}
}
#[tokio::test]
async fn test_execute_action_async_function() {
let (mut backend, rx) = create_test_backend();
backend.registered_actions.borrow_mut().insert(
"my_async_action".to_string(),
PluginHandler {
plugin_name: "test".to_string(),
handler_name: "my_async_action".to_string(),
},
);
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.my_async_action = async function() {
await Promise.resolve();
editor.setStatus("async action executed");
};
"#,
"test.js",
)
.unwrap();
while rx.try_recv().is_ok() {}
backend.execute_action("my_async_action").await.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "async action executed");
}
_ => panic!("Expected SetStatus from async action, got {:?}", cmd),
}
}
#[tokio::test]
async fn test_execute_action_with_registered_handler() {
let (mut backend, rx) = create_test_backend();
backend.registered_actions.borrow_mut().insert(
"my_action".to_string(),
PluginHandler {
plugin_name: "test".to_string(),
handler_name: "actual_handler_function".to_string(),
},
);
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.actual_handler_function = function() {
editor.setStatus("handler executed");
};
"#,
"test.js",
)
.unwrap();
while rx.try_recv().is_ok() {}
backend.execute_action("my_action").await.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "handler executed");
}
_ => panic!("Expected SetStatus, got {:?}", cmd),
}
}
#[test]
fn test_api_on_event_registration() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.myEventHandler = function() { };
editor.on("bufferSave", "myEventHandler");
"#,
"test.js",
)
.unwrap();
assert!(backend.has_handlers("bufferSave"));
}
#[test]
fn test_api_off_event_unregistration() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.myEventHandler = function() { };
editor.on("bufferSave", "myEventHandler");
editor.off("bufferSave", "myEventHandler");
"#,
"test.js",
)
.unwrap();
assert!(!backend.has_handlers("bufferSave"));
}
#[tokio::test]
async fn test_emit_event() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.onSaveHandler = function(data) {
editor.setStatus("saved: " + JSON.stringify(data));
};
editor.on("bufferSave", "onSaveHandler");
"#,
"test.js",
)
.unwrap();
while rx.try_recv().is_ok() {}
let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
backend.emit("bufferSave", &event_data).await.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert!(message.contains("/test.txt"));
}
_ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
}
}
#[test]
fn test_api_copy_to_clipboard() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.copyToClipboard("clipboard text");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetClipboard { text } => {
assert_eq!(text, "clipboard text");
}
_ => panic!("Expected SetClipboard, got {:?}", cmd),
}
}
#[test]
fn test_api_open_file() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.openFile("/path/to/file.txt", null, null);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::OpenFileAtLocation { path, line, column } => {
assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
assert!(line.is_none());
assert!(column.is_none());
}
_ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
}
}
#[test]
fn test_api_delete_range() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.deleteRange(0, 10, 20);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::DeleteRange { range, .. } => {
assert_eq!(range.start, 10);
assert_eq!(range.end, 20);
}
_ => panic!("Expected DeleteRange, got {:?}", cmd),
}
}
#[test]
fn test_api_insert_text() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.insertText(0, 5, "inserted");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::InsertText { position, text, .. } => {
assert_eq!(position, 5);
assert_eq!(text, "inserted");
}
_ => panic!("Expected InsertText, got {:?}", cmd),
}
}
#[test]
fn test_api_set_buffer_cursor() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setBufferCursor(0, 100);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetBufferCursor { position, .. } => {
assert_eq!(position, 100);
}
_ => panic!("Expected SetBufferCursor, got {:?}", cmd),
}
}
#[test]
fn test_api_get_cursor_position_from_state() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot.write().unwrap();
state.primary_cursor = Some(CursorInfo {
position: 42,
selection: None,
});
}
let services = Arc::new(fresh_core::services::NoopServiceBridge);
let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
const pos = editor.getCursorPosition();
globalThis._testResult = pos;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: u32 = global.get("_testResult").unwrap();
assert_eq!(result, 42);
});
}
#[test]
fn test_api_path_functions() {
let (mut backend, _rx) = create_test_backend();
#[cfg(windows)]
let absolute_path = r#"C:\\foo\\bar"#;
#[cfg(not(windows))]
let absolute_path = "/foo/bar";
let js_code = format!(
r#"
const editor = getEditor();
globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
globalThis._isAbsolute = editor.pathIsAbsolute("{}");
globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
"#,
absolute_path
);
backend.execute_js(&js_code, "test.js").unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
assert!(global.get::<_, bool>("_isAbsolute").unwrap());
assert!(!global.get::<_, bool>("_isRelative").unwrap());
assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
});
}
#[test]
fn test_file_uri_to_path_and_back() {
let (mut backend, _rx) = create_test_backend();
#[cfg(not(windows))]
let js_code = r#"
const editor = getEditor();
// Basic file URI to path
globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
// Percent-encoded characters
globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
// Invalid URI returns empty string
globalThis._path3 = editor.fileUriToPath("not-a-uri");
// Path to file URI
globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
// Round-trip
globalThis._roundtrip = editor.fileUriToPath(
editor.pathToFileUri("/home/user/file.txt")
);
"#;
#[cfg(windows)]
let js_code = r#"
const editor = getEditor();
// Windows URI with encoded colon (the bug from issue #1071)
globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
// Windows URI with normal colon
globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
// Invalid URI returns empty string
globalThis._path3 = editor.fileUriToPath("not-a-uri");
// Path to file URI
globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
// Round-trip
globalThis._roundtrip = editor.fileUriToPath(
editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
);
"#;
backend.execute_js(js_code, "test.js").unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
#[cfg(not(windows))]
{
assert_eq!(
global.get::<_, String>("_path1").unwrap(),
"/home/user/file.txt"
);
assert_eq!(
global.get::<_, String>("_path2").unwrap(),
"/home/user/my file.txt"
);
assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
assert_eq!(
global.get::<_, String>("_uri1").unwrap(),
"file:///home/user/file.txt"
);
assert_eq!(
global.get::<_, String>("_roundtrip").unwrap(),
"/home/user/file.txt"
);
}
#[cfg(windows)]
{
assert_eq!(
global.get::<_, String>("_path1").unwrap(),
"C:\\Users\\admin\\Repos\\file.cs"
);
assert_eq!(
global.get::<_, String>("_path2").unwrap(),
"C:\\Users\\admin\\Repos\\file.cs"
);
assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
assert_eq!(
global.get::<_, String>("_uri1").unwrap(),
"file:///C:/Users/admin/Repos/file.cs"
);
assert_eq!(
global.get::<_, String>("_roundtrip").unwrap(),
"C:\\Users\\admin\\Repos\\file.cs"
);
}
});
}
#[test]
fn test_typescript_transpilation() {
use fresh_parser_js::transpile_typescript;
let (mut backend, rx) = create_test_backend();
let ts_code = r#"
const editor = getEditor();
function greet(name: string): string {
return "Hello, " + name;
}
editor.setStatus(greet("TypeScript"));
"#;
let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
backend.execute_js(&js_code, "test.js").unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(message, "Hello, TypeScript");
}
_ => panic!("Expected SetStatus, got {:?}", cmd),
}
}
#[test]
fn test_api_get_buffer_text_sends_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
// Store the promise for later
globalThis._textPromise = editor.getBufferText(0, 10, 20);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::GetBufferText {
buffer_id,
start,
end,
request_id,
} => {
assert_eq!(buffer_id.0, 0);
assert_eq!(start, 10);
assert_eq!(end, 20);
assert!(request_id > 0); }
_ => panic!("Expected GetBufferText, got {:?}", cmd),
}
}
#[test]
fn test_api_get_buffer_text_resolves_callback() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._resolvedText = null;
editor.getBufferText(0, 0, 100).then(text => {
globalThis._resolvedText = text;
});
"#,
"test.js",
)
.unwrap();
let request_id = match rx.try_recv().unwrap() {
PluginCommand::GetBufferText { request_id, .. } => request_id,
cmd => panic!("Expected GetBufferText, got {:?}", cmd),
};
backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
run_pending_jobs_checked(&ctx, "test async getText");
});
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: String = global.get("_resolvedText").unwrap();
assert_eq!(result, "hello world");
});
}
#[test]
fn test_plugin_translation() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._translated = editor.t("test.key");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: String = global.get("_translated").unwrap();
assert_eq!(result, "test.key");
});
}
#[test]
fn test_plugin_translation_with_registered_strings() {
let (mut backend, _rx) = create_test_backend();
let mut en_strings = std::collections::HashMap::new();
en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
let mut strings = std::collections::HashMap::new();
strings.insert("en".to_string(), en_strings);
if let Some(bridge) = backend
.services
.as_any()
.downcast_ref::<TestServiceBridge>()
{
let mut en = bridge.en_strings.lock().unwrap();
en.insert("greeting".to_string(), "Hello, World!".to_string());
en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
}
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._greeting = editor.t("greeting");
globalThis._prompt = editor.t("prompt.find_file");
globalThis._missing = editor.t("nonexistent.key");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let greeting: String = global.get("_greeting").unwrap();
assert_eq!(greeting, "Hello, World!");
let prompt: String = global.get("_prompt").unwrap();
assert_eq!(prompt, "Find file: ");
let missing: String = global.get("_missing").unwrap();
assert_eq!(missing, "nonexistent.key");
});
}
#[test]
fn test_api_set_line_indicator() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetLineIndicator {
buffer_id,
line,
namespace,
symbol,
color,
priority,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(line, 5);
assert_eq!(namespace, "test-ns");
assert_eq!(symbol, "●");
assert_eq!(color, (255, 0, 0));
assert_eq!(priority, 10);
}
_ => panic!("Expected SetLineIndicator, got {:?}", cmd),
}
}
#[test]
fn test_api_clear_line_indicators() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.clearLineIndicators(1, "test-ns");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::ClearLineIndicators {
buffer_id,
namespace,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(namespace, "test-ns");
}
_ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
}
}
#[test]
fn test_api_create_virtual_buffer_sends_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.createVirtualBuffer({
name: "*Test Buffer*",
mode: "test-mode",
readOnly: true,
entries: [
{ text: "Line 1\n", properties: { type: "header" } },
{ text: "Line 2\n", properties: { type: "content" } }
],
showLineNumbers: false,
showCursors: true,
editingDisabled: true
});
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::CreateVirtualBufferWithContent {
name,
mode,
read_only,
entries,
show_line_numbers,
show_cursors,
editing_disabled,
..
} => {
assert_eq!(name, "*Test Buffer*");
assert_eq!(mode, "test-mode");
assert!(read_only);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].text, "Line 1\n");
assert!(!show_line_numbers);
assert!(show_cursors);
assert!(editing_disabled);
}
_ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
}
}
#[test]
fn test_api_set_virtual_buffer_content() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setVirtualBufferContent(5, [
{ text: "New content\n", properties: { type: "updated" } }
]);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
assert_eq!(buffer_id.0, 5);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "New content\n");
}
_ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
}
}
#[test]
fn test_api_add_overlay() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.addOverlay(1, "highlight", 10, 20, {
fg: [255, 128, 0],
bg: [50, 50, 50],
bold: true,
});
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::AddOverlay {
buffer_id,
namespace,
range,
options,
} => {
use fresh_core::api::OverlayColorSpec;
assert_eq!(buffer_id.0, 1);
assert!(namespace.is_some());
assert_eq!(namespace.unwrap().as_str(), "highlight");
assert_eq!(range, 10..20);
assert!(matches!(
options.fg,
Some(OverlayColorSpec::Rgb(255, 128, 0))
));
assert!(matches!(
options.bg,
Some(OverlayColorSpec::Rgb(50, 50, 50))
));
assert!(!options.underline);
assert!(options.bold);
assert!(!options.italic);
assert!(!options.extend_to_line_end);
}
_ => panic!("Expected AddOverlay, got {:?}", cmd),
}
}
#[test]
fn test_api_add_overlay_with_theme_keys() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
// Test with theme keys for colors
editor.addOverlay(1, "themed", 0, 10, {
fg: "ui.status_bar_fg",
bg: "editor.selection_bg",
});
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::AddOverlay {
buffer_id,
namespace,
range,
options,
} => {
use fresh_core::api::OverlayColorSpec;
assert_eq!(buffer_id.0, 1);
assert!(namespace.is_some());
assert_eq!(namespace.unwrap().as_str(), "themed");
assert_eq!(range, 0..10);
assert!(matches!(
&options.fg,
Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
));
assert!(matches!(
&options.bg,
Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
));
assert!(!options.underline);
assert!(!options.bold);
assert!(!options.italic);
assert!(!options.extend_to_line_end);
}
_ => panic!("Expected AddOverlay, got {:?}", cmd),
}
}
#[test]
fn test_api_clear_namespace() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.clearNamespace(1, "highlight");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::ClearNamespace {
buffer_id,
namespace,
} => {
assert_eq!(buffer_id.0, 1);
assert_eq!(namespace.as_str(), "highlight");
}
_ => panic!("Expected ClearNamespace, got {:?}", cmd),
}
}
#[test]
fn test_api_get_theme_schema() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
const schema = editor.getThemeSchema();
globalThis._isObject = typeof schema === 'object' && schema !== null;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_object: bool = global.get("_isObject").unwrap();
assert!(is_object);
});
}
#[test]
fn test_api_get_builtin_themes() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
const themes = editor.getBuiltinThemes();
globalThis._isObject = typeof themes === 'object' && themes !== null;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_object: bool = global.get("_isObject").unwrap();
assert!(is_object);
});
}
#[test]
fn test_api_apply_theme() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.applyTheme("dark");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::ApplyTheme { theme_name } => {
assert_eq!(theme_name, "dark");
}
_ => panic!("Expected ApplyTheme, got {:?}", cmd),
}
}
#[test]
fn test_api_get_theme_data_missing() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
const data = editor.getThemeData("nonexistent");
globalThis._isNull = data === null;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_null: bool = global.get("_isNull").unwrap();
assert!(is_null);
});
}
#[test]
fn test_api_get_theme_data_present() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let services = Arc::new(ThemeCacheTestBridge {
inner: TestServiceBridge::new(),
});
let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
const data = editor.getThemeData("test-theme");
globalThis._hasData = data !== null && typeof data === 'object';
globalThis._name = data ? data.name : null;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let has_data: bool = global.get("_hasData").unwrap();
assert!(has_data, "getThemeData should return theme object");
let name: String = global.get("_name").unwrap();
assert_eq!(name, "test-theme");
});
}
#[test]
fn test_api_theme_file_exists() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._exists = editor.themeFileExists("anything");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let exists: bool = global.get("_exists").unwrap();
assert!(!exists);
});
}
#[test]
fn test_api_save_theme_file_error() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
let threw = false;
try {
editor.saveThemeFile("test", "{}");
} catch (e) {
threw = true;
}
globalThis._threw = threw;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let threw: bool = global.get("_threw").unwrap();
assert!(threw);
});
}
struct ThemeCacheTestBridge {
inner: TestServiceBridge,
}
impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn translate(
&self,
plugin_name: &str,
key: &str,
args: &HashMap<String, String>,
) -> String {
self.inner.translate(plugin_name, key, args)
}
fn current_locale(&self) -> String {
self.inner.current_locale()
}
fn set_js_execution_state(&self, state: String) {
self.inner.set_js_execution_state(state);
}
fn clear_js_execution_state(&self) {
self.inner.clear_js_execution_state();
}
fn get_theme_schema(&self) -> serde_json::Value {
self.inner.get_theme_schema()
}
fn get_builtin_themes(&self) -> serde_json::Value {
self.inner.get_builtin_themes()
}
fn register_command(&self, command: fresh_core::command::Command) {
self.inner.register_command(command);
}
fn unregister_command(&self, name: &str) {
self.inner.unregister_command(name);
}
fn unregister_commands_by_prefix(&self, prefix: &str) {
self.inner.unregister_commands_by_prefix(prefix);
}
fn unregister_commands_by_plugin(&self, plugin_name: &str) {
self.inner.unregister_commands_by_plugin(plugin_name);
}
fn plugins_dir(&self) -> std::path::PathBuf {
self.inner.plugins_dir()
}
fn config_dir(&self) -> std::path::PathBuf {
self.inner.config_dir()
}
fn data_dir(&self) -> std::path::PathBuf {
self.inner.data_dir()
}
fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
if name == "test-theme" {
Some(serde_json::json!({
"name": "test-theme",
"editor": {},
"ui": {},
"syntax": {}
}))
} else {
None
}
}
fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
Err("test bridge does not support save".to_string())
}
fn theme_file_exists(&self, name: &str) -> bool {
name == "test-theme"
}
}
#[test]
fn test_api_close_buffer() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.closeBuffer(3);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::CloseBuffer { buffer_id } => {
assert_eq!(buffer_id.0, 3);
}
_ => panic!("Expected CloseBuffer, got {:?}", cmd),
}
}
#[test]
fn test_api_focus_split() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.focusSplit(2);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::FocusSplit { split_id } => {
assert_eq!(split_id.0, 2);
}
_ => panic!("Expected FocusSplit, got {:?}", cmd),
}
}
#[test]
fn test_api_list_buffers() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot.write().unwrap();
state.buffers.insert(
BufferId(0),
BufferInfo {
id: BufferId(0),
path: Some(PathBuf::from("/test1.txt")),
modified: false,
length: 100,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
},
);
state.buffers.insert(
BufferId(1),
BufferInfo {
id: BufferId(1),
path: Some(PathBuf::from("/test2.txt")),
modified: true,
length: 200,
is_virtual: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
},
);
}
let services = Arc::new(fresh_core::services::NoopServiceBridge);
let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
const buffers = editor.listBuffers();
globalThis._isArray = Array.isArray(buffers);
globalThis._length = buffers.length;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_array: bool = global.get("_isArray").unwrap();
let length: u32 = global.get("_length").unwrap();
assert!(is_array);
assert_eq!(length, 2);
});
}
#[test]
fn test_api_start_prompt() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.startPrompt("Enter value:", "test-prompt");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::StartPrompt { label, prompt_type } => {
assert_eq!(label, "Enter value:");
assert_eq!(prompt_type, "test-prompt");
}
_ => panic!("Expected StartPrompt, got {:?}", cmd),
}
}
#[test]
fn test_api_start_prompt_with_initial() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::StartPromptWithInitial {
label,
prompt_type,
initial_value,
} => {
assert_eq!(label, "Enter value:");
assert_eq!(prompt_type, "test-prompt");
assert_eq!(initial_value, "default");
}
_ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
}
}
#[test]
fn test_api_set_prompt_suggestions() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setPromptSuggestions([
{ text: "Option 1", value: "opt1" },
{ text: "Option 2", value: "opt2" }
]);
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetPromptSuggestions { suggestions } => {
assert_eq!(suggestions.len(), 2);
assert_eq!(suggestions[0].text, "Option 1");
assert_eq!(suggestions[0].value, Some("opt1".to_string()));
}
_ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
}
}
#[test]
fn test_api_get_active_buffer_id() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot.write().unwrap();
state.active_buffer_id = BufferId(42);
}
let services = Arc::new(fresh_core::services::NoopServiceBridge);
let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._activeId = editor.getActiveBufferId();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: u32 = global.get("_activeId").unwrap();
assert_eq!(result, 42);
});
}
#[test]
fn test_api_get_active_split_id() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot.write().unwrap();
state.active_split_id = 7;
}
let services = Arc::new(fresh_core::services::NoopServiceBridge);
let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._splitId = editor.getActiveSplitId();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: u32 = global.get("_splitId").unwrap();
assert_eq!(result, 7);
});
}
#[test]
fn test_api_file_exists() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
// Test with a path that definitely exists
globalThis._exists = editor.fileExists("/");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: bool = global.get("_exists").unwrap();
assert!(result);
});
}
#[test]
fn test_api_get_cwd() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._cwd = editor.getCwd();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: String = global.get("_cwd").unwrap();
assert!(!result.is_empty());
});
}
#[test]
fn test_api_get_env() {
let (mut backend, _rx) = create_test_backend();
std::env::set_var("TEST_PLUGIN_VAR", "test_value");
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: Option<String> = global.get("_envVal").unwrap();
assert_eq!(result, Some("test_value".to_string()));
});
std::env::remove_var("TEST_PLUGIN_VAR");
}
#[test]
fn test_api_get_config() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
const config = editor.getConfig();
globalThis._isObject = typeof config === 'object';
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_object: bool = global.get("_isObject").unwrap();
assert!(is_object);
});
}
#[test]
fn test_api_get_themes_dir() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._themesDir = editor.getThemesDir();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: String = global.get("_themesDir").unwrap();
assert!(!result.is_empty());
});
}
#[test]
fn test_api_read_dir() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
const entries = editor.readDir("/tmp");
globalThis._isArray = Array.isArray(entries);
globalThis._length = entries.length;
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_array: bool = global.get("_isArray").unwrap();
let length: u32 = global.get("_length").unwrap();
assert!(is_array);
let _ = length;
});
}
#[test]
fn test_api_execute_action() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.executeAction("move_cursor_up");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::ExecuteAction { action_name } => {
assert_eq!(action_name, "move_cursor_up");
}
_ => panic!("Expected ExecuteAction, got {:?}", cmd),
}
}
#[test]
fn test_api_debug() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.debug("Test debug message");
editor.debug("Another message with special chars: <>&\"'");
"#,
"test.js",
)
.unwrap();
}
#[test]
fn test_typescript_preamble_generated() {
assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
println!(
"Generated {} bytes of TypeScript preamble",
JSEDITORAPI_TS_PREAMBLE.len()
);
}
#[test]
fn test_typescript_editor_api_generated() {
assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
println!(
"Generated {} bytes of EditorAPI interface",
JSEDITORAPI_TS_EDITOR_API.len()
);
}
#[test]
fn test_js_methods_list() {
assert!(!JSEDITORAPI_JS_METHODS.is_empty());
println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
if i < 20 {
println!(" - {}", method);
}
}
if JSEDITORAPI_JS_METHODS.len() > 20 {
println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
}
}
#[test]
fn test_api_load_plugin_sends_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::LoadPlugin { path, callback_id } => {
assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
assert!(callback_id.0 > 0); }
_ => panic!("Expected LoadPlugin, got {:?}", cmd),
}
}
#[test]
fn test_api_unload_plugin_sends_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::UnloadPlugin { name, callback_id } => {
assert_eq!(name, "my-plugin");
assert!(callback_id.0 > 0); }
_ => panic!("Expected UnloadPlugin, got {:?}", cmd),
}
}
#[test]
fn test_api_reload_plugin_sends_command() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::ReloadPlugin { name, callback_id } => {
assert_eq!(name, "my-plugin");
assert!(callback_id.0 > 0); }
_ => panic!("Expected ReloadPlugin, got {:?}", cmd),
}
}
#[test]
fn test_api_load_plugin_resolves_callback() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._loadResult = null;
editor.loadPlugin("/path/to/plugin.ts").then(result => {
globalThis._loadResult = result;
});
"#,
"test.js",
)
.unwrap();
let callback_id = match rx.try_recv().unwrap() {
PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
};
backend.resolve_callback(callback_id, "true");
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
run_pending_jobs_checked(&ctx, "test async loadPlugin");
});
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: bool = global.get("_loadResult").unwrap();
assert!(result);
});
}
#[test]
fn test_api_version() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._apiVersion = editor.apiVersion();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let version: u32 = ctx.globals().get("_apiVersion").unwrap();
assert_eq!(version, 2);
});
}
#[test]
fn test_api_unload_plugin_rejects_on_error() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._unloadError = null;
editor.unloadPlugin("nonexistent-plugin").catch(err => {
globalThis._unloadError = err.message || String(err);
});
"#,
"test.js",
)
.unwrap();
let callback_id = match rx.try_recv().unwrap() {
PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
};
backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
run_pending_jobs_checked(&ctx, "test async unloadPlugin");
});
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let error: String = global.get("_unloadError").unwrap();
assert!(error.contains("nonexistent-plugin"));
});
}
#[test]
fn test_api_set_global_state() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setGlobalState("myKey", { enabled: true, count: 42 });
"#,
"test_plugin.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetGlobalState {
plugin_name,
key,
value,
} => {
assert_eq!(plugin_name, "test_plugin");
assert_eq!(key, "myKey");
let v = value.unwrap();
assert_eq!(v["enabled"], serde_json::json!(true));
assert_eq!(v["count"], serde_json::json!(42));
}
_ => panic!("Expected SetGlobalState command, got {:?}", cmd),
}
}
#[test]
fn test_api_set_global_state_delete() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setGlobalState("myKey", null);
"#,
"test_plugin.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetGlobalState {
plugin_name,
key,
value,
} => {
assert_eq!(plugin_name, "test_plugin");
assert_eq!(key, "myKey");
assert!(value.is_none(), "null should delete the key");
}
_ => panic!("Expected SetGlobalState command, got {:?}", cmd),
}
}
#[test]
fn test_api_get_global_state_roundtrip() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setGlobalState("flag", true);
globalThis._result = editor.getGlobalState("flag");
"#,
"test_plugin.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test_plugin")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let result: bool = global.get("_result").unwrap();
assert!(
result,
"getGlobalState should return the value set by setGlobalState"
);
});
}
#[test]
fn test_api_get_global_state_missing_key() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._result = editor.getGlobalState("nonexistent");
globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
"#,
"test_plugin.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test_plugin")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let is_undefined: bool = global.get("_isUndefined").unwrap();
assert!(
is_undefined,
"getGlobalState for missing key should return undefined"
);
});
}
#[test]
fn test_api_global_state_isolation_between_plugins() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
let services = Arc::new(TestServiceBridge::new());
let mut backend_a =
QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
.unwrap();
backend_a
.execute_js(
r#"
const editor = getEditor();
editor.setGlobalState("flag", "from_plugin_a");
"#,
"plugin_a.js",
)
.unwrap();
let mut backend_b =
QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
.unwrap();
backend_b
.execute_js(
r#"
const editor = getEditor();
editor.setGlobalState("flag", "from_plugin_b");
"#,
"plugin_b.js",
)
.unwrap();
backend_a
.execute_js(
r#"
const editor = getEditor();
globalThis._aValue = editor.getGlobalState("flag");
"#,
"plugin_a.js",
)
.unwrap();
backend_a
.plugin_contexts
.borrow()
.get("plugin_a")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let a_value: String = global.get("_aValue").unwrap();
assert_eq!(
a_value, "from_plugin_a",
"Plugin A should see its own value, not plugin B's"
);
});
backend_b
.execute_js(
r#"
const editor = getEditor();
globalThis._bValue = editor.getGlobalState("flag");
"#,
"plugin_b.js",
)
.unwrap();
backend_b
.plugin_contexts
.borrow()
.get("plugin_b")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let b_value: String = global.get("_bValue").unwrap();
assert_eq!(
b_value, "from_plugin_b",
"Plugin B should see its own value, not plugin A's"
);
});
}
#[test]
fn test_register_command_collision_different_plugins() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerA = function() { };
editor.registerCommand("My Command", "From A", "handlerA", null);
"#,
"plugin_a.js",
)
.unwrap();
let result = backend.execute_js(
r#"
const editor = getEditor();
globalThis.handlerB = function() { };
editor.registerCommand("My Command", "From B", "handlerB", null);
"#,
"plugin_b.js",
);
assert!(
result.is_err(),
"Second plugin registering the same command name should fail"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("already registered"),
"Error should mention collision: {}",
err_msg
);
}
#[test]
fn test_register_command_same_plugin_allowed() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handler1 = function() { };
editor.registerCommand("My Command", "Version 1", "handler1", null);
globalThis.handler2 = function() { };
editor.registerCommand("My Command", "Version 2", "handler2", null);
"#,
"plugin_a.js",
)
.unwrap();
}
#[test]
fn test_register_command_after_unregister() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerA = function() { };
editor.registerCommand("My Command", "From A", "handlerA", null);
editor.unregisterCommand("My Command");
"#,
"plugin_a.js",
)
.unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerB = function() { };
editor.registerCommand("My Command", "From B", "handlerB", null);
"#,
"plugin_b.js",
)
.unwrap();
}
#[test]
fn test_register_command_collision_caught_in_try_catch() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerA = function() { };
editor.registerCommand("My Command", "From A", "handlerA", null);
"#,
"plugin_a.js",
)
.unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerB = function() { };
let caught = false;
try {
editor.registerCommand("My Command", "From B", "handlerB", null);
} catch (e) {
caught = true;
}
if (!caught) throw new Error("Expected collision error");
"#,
"plugin_b.js",
)
.unwrap();
}
#[test]
fn test_register_command_i18n_key_no_collision_across_plugins() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerA = function() { };
editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
"#,
"plugin_a.js",
)
.unwrap();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerB = function() { };
editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
"#,
"plugin_b.js",
)
.unwrap();
}
#[test]
fn test_register_command_non_i18n_still_collides() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.handlerA = function() { };
editor.registerCommand("My Reload", "Reload A", "handlerA", null);
"#,
"plugin_a.js",
)
.unwrap();
let result = backend.execute_js(
r#"
const editor = getEditor();
globalThis.handlerB = function() { };
editor.registerCommand("My Reload", "Reload B", "handlerB", null);
"#,
"plugin_b.js",
);
assert!(
result.is_err(),
"Non-%-prefixed names should still collide across plugins"
);
}
}