use anyhow::{anyhow, Result};
use fresh_core::api::{
ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
PluginCommand, PluginResponse, SearchHandleRegistry, SearchHandleState, SearchTakeResult,
};
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};
type PluginApiExports =
Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>;
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() {
match i32::try_from(i) {
Ok(small) => Ok(Value::new_int(ctx.clone(), small)),
Err(_) => Ok(Value::new_float(ctx.clone(), i as f64)),
}
} 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();
let pad_to_chars: Option<u32> = obj
.get::<_, f64>("padToChars")
.ok()
.map(|v| v.max(0.0) as u32);
let truncate_to_chars: Option<u32> = obj
.get::<_, f64>("truncateToChars")
.ok()
.map(|v| v.max(0.0) as u32);
let segments: Vec<fresh_core::text_property::StyledSegment> = obj
.get::<_, rquickjs::Array>("segments")
.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,
segments,
pad_to_chars,
truncate_to_chars,
})
}
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,
pub declarations: Option<String>,
}
#[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 watch_handles: Vec<u64>,
}
pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
pub type EventHandlerRegistry = Arc<RwLock<HashMap<String, Vec<PluginHandler>>>>;
#[derive(Debug, Clone)]
pub struct PluginHandler {
pub plugin_name: String,
pub handler_name: String,
}
fn parse_animation_rect(
obj: &rquickjs::Object<'_>,
) -> rquickjs::Result<fresh_core::api::AnimationRect> {
Ok(fresh_core::api::AnimationRect {
x: obj.get::<_, u16>("x").unwrap_or(0),
y: obj.get::<_, u16>("y").unwrap_or(0),
width: obj.get::<_, u16>("width").unwrap_or(0),
height: obj.get::<_, u16>("height").unwrap_or(0),
})
}
fn parse_animation_kind(
obj: &rquickjs::Object<'_>,
) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
match kind.as_str() {
"slideIn" | "" => {
let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
let from = match from_str.as_str() {
"top" => PluginAnimationEdge::Top,
"left" => PluginAnimationEdge::Left,
"right" => PluginAnimationEdge::Right,
_ => PluginAnimationEdge::Bottom,
};
let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
Ok(PluginAnimationKind::SlideIn {
from,
duration_ms,
delay_ms,
})
}
other => Err(rquickjs::Error::new_from_js_message(
"string",
"PluginAnimationKind",
format!("unknown animation kind: {}", other),
)),
}
}
#[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: EventHandlerRegistry,
#[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>>>,
#[qjs(skip_trace)]
plugin_api_exports: PluginApiExports,
#[qjs(skip_trace)]
search_handles: SearchHandleRegistry,
pub plugin_name: String,
}
fn throw_js<'js>(ctx: &rquickjs::Ctx<'js>, msg: &str) -> rquickjs::Error {
match rquickjs::String::from_str(ctx.clone(), msg) {
Ok(s) => ctx.throw(s.into_value()),
Err(e) => e,
}
}
fn parse_options<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
options: rquickjs::Object<'js>,
) -> rquickjs::Result<serde_json::Map<String, serde_json::Value>> {
let value: serde_json::Value = rquickjs_serde::from_value(options.into_value())
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
match value {
serde_json::Value::Object(m) => Ok(m),
_ => Err(throw_js(
ctx,
&format!("{}(\"{}\"): options must be an object", method, field),
)),
}
}
fn validate_allowed_keys<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
opts: &serde_json::Map<String, serde_json::Value>,
allowed: &[&str],
) -> rquickjs::Result<()> {
for k in opts.keys() {
if !allowed.contains(&k.as_str()) {
return Err(throw_js(
ctx,
&format!(
"{}(\"{}\"): unknown option `{}` (allowed: {})",
method,
field,
k,
allowed.join(", "),
),
));
}
}
Ok(())
}
fn string_opt(opts: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
opts.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn require_integer<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
opts: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> rquickjs::Result<i64> {
match opts.get(key) {
Some(v) => v.as_i64().ok_or_else(|| {
throw_js(
ctx,
&format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
)
}),
None => Err(throw_js(
ctx,
&format!("{}(\"{}\"): `{}` is required", method, field, key),
)),
}
}
fn optional_integer<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
opts: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> rquickjs::Result<Option<i64>> {
match opts.get(key) {
None => Ok(None),
Some(v) => v.as_i64().map(Some).ok_or_else(|| {
throw_js(
ctx,
&format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
)
}),
}
}
fn require_number<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
opts: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> rquickjs::Result<f64> {
match opts.get(key) {
Some(v) => v.as_f64().ok_or_else(|| {
throw_js(
ctx,
&format!("{}(\"{}\"): `{}` must be a number", method, field, key),
)
}),
None => Err(throw_js(
ctx,
&format!("{}(\"{}\"): `{}` is required", method, field, key),
)),
}
}
fn optional_number<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
opts: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> rquickjs::Result<Option<f64>> {
match opts.get(key) {
None => Ok(None),
Some(v) => v.as_f64().map(Some).ok_or_else(|| {
throw_js(
ctx,
&format!("{}(\"{}\"): `{}` must be a number", method, field, key),
)
}),
}
}
fn check_range<'js>(
ctx: &rquickjs::Ctx<'js>,
method: &str,
field: &str,
default: f64,
minimum: Option<f64>,
maximum: Option<f64>,
) -> rquickjs::Result<()> {
if let Some(min) = minimum {
if default < min {
return Err(throw_js(
ctx,
&format!(
"{}(\"{}\"): default ({}) is below minimum ({})",
method, field, default, min
),
));
}
}
if let Some(max) = maximum {
if default > max {
return Err(throw_js(
ctx,
&format!(
"{}(\"{}\"): default ({}) is above maximum ({})",
method, field, default, max
),
));
}
}
if let (Some(min), Some(max)) = (minimum, maximum) {
if min > max {
return Err(throw_js(
ctx,
&format!(
"{}(\"{}\"): minimum ({}) is greater than maximum ({})",
method, field, min, max
),
));
}
}
Ok(())
}
impl JsEditorApi {
fn send_field_registration(&self, field_name: &str, field_schema: serde_json::Value) {
let _ = self
.command_sender
.send(PluginCommand::AddPluginConfigField {
plugin_name: self.plugin_name.clone(),
field_name: field_name.to_string(),
field_schema,
});
}
fn current_field_value(&self, field_name: &str) -> Option<serde_json::Value> {
self.state_snapshot.read().ok().and_then(|s| {
s.config
.pointer(&format!(
"/plugins/{}/settings/{}",
self.plugin_name, field_name
))
.cloned()
})
}
}
#[plugin_api_impl]
#[rquickjs::methods(rename_all = "camelCase")]
impl JsEditorApi {
pub fn api_version(&self) -> u32 {
2
}
pub fn plugin_name(&self) -> String {
self.plugin_name.clone()
}
#[plugin_api(ts_return = "boolean")]
pub fn export_plugin_api<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
api: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
if name.is_empty() {
let msg =
rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
return Err(ctx.throw(msg.into_value()));
}
let obj = match api.as_object() {
Some(o) => o.clone(),
None => {
let msg = rquickjs::String::from_str(
ctx.clone(),
"exportPluginApi: api must be an object",
)?;
return Err(ctx.throw(msg.into_value()));
}
};
let persistent = rquickjs::Persistent::save(&ctx, obj);
self.plugin_api_exports
.borrow_mut()
.insert(name, (self.plugin_name.clone(), persistent));
Ok(true)
}
#[plugin_api(ts_return = "unknown | null")]
pub fn get_plugin_api<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
) -> rquickjs::Result<rquickjs::Value<'js>> {
let persistent = self
.plugin_api_exports
.borrow()
.get(&name)
.map(|(_exporter, p)| p.clone());
match persistent {
Some(p) => {
let restored = p.restore(&ctx)?;
Ok(restored.into_value())
}
None => Ok(rquickjs::Value::new_null(ctx)),
}
}
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]
pub fn has_active_search(&self) -> bool {
self.state_snapshot
.read()
.map(|s| s.has_active_search)
.unwrap_or(false)
}
#[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()))
}
#[plugin_api(ts_return = "string[]")]
pub fn list_macros(&self) -> Vec<String> {
if let Ok(s) = self.state_snapshot.read() {
s.macros.iter().map(|m| m.register.clone()).collect()
} else {
Vec::new()
}
}
#[plugin_api(ts_return = "ActionSpec[] | null")]
pub fn get_macro<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
register: String,
) -> rquickjs::Result<Value<'js>> {
let steps = if let Ok(s) = self.state_snapshot.read() {
s.macros
.iter()
.find(|m| m.register == register)
.map(|m| m.steps.clone())
} else {
None
};
match steps {
Some(steps) => rquickjs_serde::to_value(ctx, &steps)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
None => Ok(Value::new_null(ctx)),
}
}
pub fn define_macro(&self, register: String, steps: Vec<ActionSpec>) -> bool {
self.command_sender
.send(PluginCommand::DefineMacro { register, steps })
.is_ok()
}
pub fn play_macro(&self, register: String) -> bool {
self.command_sender
.send(PluginCommand::PlayMacroByRegister { register })
.is_ok()
}
pub fn debug(&self, msg: String) {
tracing::debug!("Plugin: {}", 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>,
>,
#[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
options: 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 terminal_bypass: bool = options
.0
.and_then(|v| {
if v.is_null() || v.is_undefined() {
None
} else {
v.into_object()
.and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
}
})
.unwrap_or(false);
let command = Command {
name: name.clone(),
description,
action_name: handler_name,
plugin_name,
custom_contexts: context_str.into_iter().collect(),
terminal_bypass,
};
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 cancel_prompt(&self) -> bool {
self.command_sender
.send(PluginCommand::CancelPrompt)
.is_ok()
}
pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
let plugin_name = self.plugin_name.clone();
self.command_sender
.send(PluginCommand::RegisterStatusBarElement {
plugin_name,
token_name,
title,
})
.is_ok()
}
pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
let key = format!("{}:{}", self.plugin_name, token_name);
self.command_sender
.send(PluginCommand::SetStatusBarValue {
buffer_id,
key,
value,
})
.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()))
}
#[plugin_api(ts_return = "ScreenSize")]
pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let size = if let Ok(s) = self.state_snapshot.read() {
fresh_core::api::ScreenSize {
width: s.terminal_width,
height: s.terminal_height,
}
} else {
fresh_core::api::ScreenSize {
width: 0,
height: 0,
}
};
rquickjs_serde::to_value(ctx, size)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[plugin_api(ts_return = "SplitSnapshot[]")]
pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let splits = if let Ok(s) = self.state_snapshot.read() {
s.splits.clone()
} else {
Vec::new()
};
rquickjs_serde::to_value(ctx, &splits)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
pub fn get_cursor_line(&self) -> u32 {
self.state_snapshot
.read()
.ok()
.and_then(|s| s.primary_cursor.as_ref().and_then(|c| c.line))
.unwrap_or(0) as u32
}
#[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 = self.alloc_request_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 = self.alloc_request_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 = self.alloc_request_id();
let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
buffer_id: BufferId(0),
request_id: id,
});
id
}
#[plugin_api(
async_promise,
js_name = "getCompositeCursorInfo",
ts_return = "{ focusedPane: number, paneCount: number, lines: Array<number | null> } | null"
)]
#[qjs(rename = "_getCompositeCursorInfoStart")]
pub fn get_composite_cursor_info_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
let id = self.alloc_request_id();
let _ = self
.command_sender
.send(PluginCommand::GetCompositeCursorInfo { 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_background(
&self,
path: String,
window_id: rquickjs::function::Opt<u64>,
) -> bool {
self.command_sender
.send(PluginCommand::OpenFileInBackground {
path: PathBuf::from(path),
window_id: window_id.0.map(fresh_core::WindowId),
})
.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()
}
#[plugin_api(
async_promise,
js_name = "openFileStreaming",
ts_return = "number | null"
)]
#[qjs(rename = "_openFileStreamingStart")]
pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
let id = self.alloc_request_id();
let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
path: PathBuf::from(path),
request_id: id,
});
id
}
#[plugin_api(
async_promise,
js_name = "refreshBufferFromDisk",
ts_return = "number | null"
)]
#[qjs(rename = "_refreshBufferFromDiskStart")]
pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
let id = self.alloc_request_id();
let _ = self
.command_sender
.send(PluginCommand::RefreshBufferFromDisk {
buffer_id: BufferId(buffer_id as usize),
request_id: id,
});
id
}
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 close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseOtherBuffersInSplit {
buffer_id: BufferId(buffer_id as usize),
split_id: SplitId(split_id as usize),
})
.is_ok()
}
pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseAllBuffersInSplit {
split_id: SplitId(split_id as usize),
})
.is_ok()
}
pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseBuffersToRightInSplit {
buffer_id: BufferId(buffer_id as usize),
split_id: SplitId(split_id as usize),
})
.is_ok()
}
pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
self.command_sender
.send(PluginCommand::CloseBuffersToLeftInSplit {
buffer_id: BufferId(buffer_id as usize),
split_id: SplitId(split_id as usize),
})
.is_ok()
}
#[plugin_api(ts_return = "boolean")]
pub fn move_tab_to_left(&self) -> bool {
self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
}
#[plugin_api(ts_return = "boolean")]
pub fn move_tab_to_right(&self) -> bool {
self.command_sender
.send(PluginCommand::MoveTabRight)
.is_ok()
}
#[plugin_api(skip)]
#[qjs(skip)]
fn alloc_request_id(&self) -> u64 {
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
}
#[plugin_api(skip)]
#[qjs(skip)]
fn alloc_animation_id(&self) -> u64 {
let mut id_ref = self.next_request_id.borrow_mut();
let id = *id_ref;
*id_ref += 1;
id
}
pub fn animate_area<'js>(
&self,
#[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
#[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
) -> rquickjs::Result<u64> {
let rect = parse_animation_rect(&rect)?;
let kind = parse_animation_kind(&kind)?;
let id = self.alloc_animation_id();
let _ = self
.command_sender
.send(PluginCommand::StartAnimationArea { id, rect, kind });
Ok(id)
}
pub fn animate_virtual_buffer<'js>(
&self,
buffer_id: u32,
#[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
) -> rquickjs::Result<u64> {
let kind = parse_animation_kind(&kind)?;
let id = self.alloc_animation_id();
let _ = self
.command_sender
.send(PluginCommand::StartAnimationVirtualBuffer {
id,
buffer_id: BufferId(buffer_id as usize),
kind,
});
Ok(id)
}
pub fn cancel_animation(&self, id: u64) -> bool {
self.command_sender
.send(PluginCommand::CancelAnimation { id })
.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
.write()
.expect("event_handlers poisoned")
.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
.write()
.expect("event_handlers poisoned")
.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 get_authority_label(&self) -> String {
self.state_snapshot
.read()
.map(|s| s.authority_label.clone())
.unwrap_or_default()
}
pub fn workspace_trust_level(&self) -> String {
self.state_snapshot
.read()
.map(|s| s.workspace_trust_level.clone())
.unwrap_or_default()
}
pub fn env_active(&self) -> bool {
self.state_snapshot
.read()
.map(|s| s.env_active)
.unwrap_or(false)
}
pub fn detected_env(&self) -> String {
self.state_snapshot
.read()
.map(|s| s.detected_env.clone())
.unwrap_or_default()
}
pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
let mut result_parts: Vec<String> = Vec::new();
let mut leading_slashes: u8 = 0;
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();
leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
}
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("/");
let prefix = match leading_slashes {
0 => "",
1 => "/",
_ => "//",
};
if leading_slashes > 0 {
format!("{}{}", prefix, 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()
}
#[plugin_api(ts_return = "unknown")]
pub fn parse_jsonc<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
text: String,
) -> rquickjs::Result<Value<'js>> {
let value: serde_json::Value =
jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
})?;
rquickjs_serde::to_value(ctx, &value)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.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()))
}
#[plugin_api(ts_return = "boolean")]
pub fn define_config_boolean<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
#[plugin_api(ts_type = "{ default: boolean; description?: string }")]
options: rquickjs::Object<'js>,
) -> rquickjs::Result<bool> {
let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigBoolean",
&name,
&opts,
&["default", "description"],
)?;
let default = match opts.get("default") {
Some(serde_json::Value::Bool(b)) => *b,
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigBoolean(\"{}\"): `default` (boolean) is required",
name
),
));
}
};
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("boolean"));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
Ok(self
.current_field_value(&name)
.and_then(|v| v.as_bool())
.unwrap_or(default))
}
#[plugin_api(ts_return = "number")]
pub fn define_config_integer<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
#[plugin_api(
ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
)]
options: rquickjs::Object<'js>,
) -> rquickjs::Result<i64> {
let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigInteger",
&name,
&opts,
&["default", "description", "minimum", "maximum"],
)?;
let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
check_range(
&ctx,
"defineConfigInteger",
&name,
default as f64,
minimum.map(|v| v as f64),
maximum.map(|v| v as f64),
)?;
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("integer"));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
if let Some(v) = minimum {
field.insert("minimum".into(), serde_json::json!(v));
}
if let Some(v) = maximum {
field.insert("maximum".into(), serde_json::json!(v));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
Ok(self
.current_field_value(&name)
.and_then(|v| v.as_i64())
.unwrap_or(default))
}
#[plugin_api(ts_return = "number")]
pub fn define_config_number<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
#[plugin_api(
ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
)]
options: rquickjs::Object<'js>,
) -> rquickjs::Result<f64> {
let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigNumber",
&name,
&opts,
&["default", "description", "minimum", "maximum"],
)?;
let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("number"));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
if let Some(v) = minimum {
field.insert("minimum".into(), serde_json::json!(v));
}
if let Some(v) = maximum {
field.insert("maximum".into(), serde_json::json!(v));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
Ok(self
.current_field_value(&name)
.and_then(|v| v.as_f64())
.unwrap_or(default))
}
#[plugin_api(ts_return = "string")]
pub fn define_config_string<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
#[plugin_api(ts_type = "{ default: string; description?: string }")]
options: rquickjs::Object<'js>,
) -> rquickjs::Result<String> {
let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigString",
&name,
&opts,
&["default", "description"],
)?;
let default = match opts.get("default") {
Some(serde_json::Value::String(s)) => s.clone(),
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigString(\"{}\"): `default` (string) is required",
name
),
));
}
};
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("string"));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
Ok(self
.current_field_value(&name)
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or(default))
}
#[plugin_api(skip)]
pub fn define_config_enum<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
options: rquickjs::Object<'js>,
) -> rquickjs::Result<String> {
let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigEnum",
&name,
&opts,
&["default", "description", "values"],
)?;
let values: Vec<String> = match opts.get("values") {
Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
match v {
serde_json::Value::String(s) => out.push(s.clone()),
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigEnum(\"{}\"): `values` must be an array of strings",
name
),
));
}
}
}
out
}
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
name
),
));
}
};
let default = match opts.get("default") {
Some(serde_json::Value::String(s)) => s.clone(),
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigEnum(\"{}\"): `default` (string) is required",
name
),
));
}
};
if !values.contains(&default) {
return Err(throw_js(
&ctx,
&format!(
"defineConfigEnum(\"{}\"): `default` must be one of {:?}",
name, values
),
));
}
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("string"));
field.insert("enum".into(), serde_json::json!(values));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
let current = self
.current_field_value(&name)
.and_then(|v| v.as_str().map(|s| s.to_string()));
Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
}
#[plugin_api(ts_return = "string[]")]
pub fn define_config_string_array<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
name: String,
#[plugin_api(ts_type = "{ default: string[]; description?: string }")]
options: rquickjs::Object<'js>,
) -> rquickjs::Result<Vec<String>> {
let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
validate_allowed_keys(
&ctx,
"defineConfigStringArray",
&name,
&opts,
&["default", "description"],
)?;
let default: Vec<String> = match opts.get("default") {
Some(serde_json::Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
match v {
serde_json::Value::String(s) => out.push(s.clone()),
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigStringArray(\"{}\"): `default` entries must all be strings",
name
),
));
}
}
}
out
}
_ => {
return Err(throw_js(
&ctx,
&format!(
"defineConfigStringArray(\"{}\"): `default` (string[]) is required",
name
),
));
}
};
let description = string_opt(&opts, "description");
let mut field = serde_json::Map::new();
field.insert("type".into(), serde_json::json!("array"));
field.insert("items".into(), serde_json::json!({"type": "string"}));
field.insert("default".into(), serde_json::json!(default));
if let Some(d) = description {
field.insert("description".into(), serde_json::json!(d));
}
self.send_field_registration(&name, serde_json::Value::Object(field));
Ok(self
.current_field_value(&name)
.and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
})
.unwrap_or(default))
}
pub fn get_plugin_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!({})));
let settings = config
.pointer(&format!("/plugins/{}/settings", self.plugin_name))
.cloned()
.unwrap_or(serde_json::Value::Null);
rquickjs_serde::to_value(ctx, &settings)
.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 set_setting<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
path: String,
value: Value<'js>,
) -> rquickjs::Result<bool> {
let json: serde_json::Value = rquickjs_serde::from_value(value)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
Ok(self
.command_sender
.send(PluginCommand::SetSetting {
plugin_name: self.plugin_name.clone(),
path,
value: json,
})
.is_ok())
}
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 = self.alloc_request_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_terminal_dir(&self) -> String {
let working_dir = self
.state_snapshot
.read()
.map(|s| s.working_dir.clone())
.unwrap_or_else(|_| std::path::PathBuf::from("."));
self.services
.terminal_dir(&working_dir)
.to_string_lossy()
.to_string()
}
pub fn get_working_data_dir(&self) -> String {
let working_dir = self
.state_snapshot
.read()
.map(|s| s.working_dir.clone())
.unwrap_or_else(|_| std::path::PathBuf::from("."));
self.services
.working_data_dir(&working_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 override_theme_colors<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
overrides: Value<'js>,
) -> rquickjs::Result<bool> {
let json: serde_json::Value = rquickjs_serde::from_value(overrides)
.map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
let Some(obj) = json.as_object() else {
return Err(rquickjs::Error::new_from_js_message(
"type",
"",
"overrideThemeColors expects an object of \"key\": [r, g, b]",
));
};
let to_u8 = |n: &serde_json::Value| -> Option<u8> {
n.as_i64()
.or_else(|| n.as_f64().map(|f| f as i64))
.map(|v| v.clamp(0, 255) as u8)
};
let mut clamped: std::collections::HashMap<String, [u8; 3]> =
std::collections::HashMap::with_capacity(obj.len());
for (key, value) in obj {
let Some(arr) = value.as_array() else {
continue;
};
if arr.len() != 3 {
continue;
}
let Some(r) = to_u8(&arr[0]) else { continue };
let Some(g) = to_u8(&arr[1]) else { continue };
let Some(b) = to_u8(&arr[2]) else { continue };
clamped.insert(key.clone(), [r, g, b]);
}
Ok(self
.command_sender
.send(PluginCommand::OverrideThemeColors { overrides: clamped })
.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()))
}
pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let themes = self.services.get_all_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 = self.alloc_request_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 = self.alloc_request_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 fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
let url: Option<String> = options.get("url").ok();
let options = OverlayOptions {
fg,
bg,
underline,
bold,
italic,
strikethrough,
extend_to_line_end,
fg_on_collision_only,
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 clear_overlays_in_range_for_namespace(
&self,
buffer_id: u32,
namespace: String,
start: u32,
end: u32,
) -> bool {
self.command_sender
.send(PluginCommand::ClearOverlaysInRangeForNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
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 char_width(&self, code_point: u32) -> u32 {
char::from_u32(code_point)
.map(fresh_core::display_width::char_width)
.unwrap_or(0) as u32
}
pub fn string_width(&self, text: String) -> u32 {
fresh_core::display_width::str_width(&text) as u32
}
pub fn clear_conceals_in_range_for_namespace(
&self,
buffer_id: u32,
namespace: String,
start: u32,
end: u32,
) -> bool {
self.command_sender
.send(PluginCommand::ClearConcealsInRangeForNamespace {
buffer_id: BufferId(buffer_id as usize),
namespace: OverlayNamespace::from_string(namespace),
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 set_folding_ranges<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
ranges_arr: Vec<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
for obj in ranges_arr {
let start_line: u32 = obj.get("startLine").unwrap_or(0);
let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
let kind = obj
.get::<_, String>("kind")
.ok()
.and_then(|s| match s.as_str() {
"comment" => Some(lsp_types::FoldingRangeKind::Comment),
"imports" => Some(lsp_types::FoldingRangeKind::Imports),
"region" => Some(lsp_types::FoldingRangeKind::Region),
_ => None,
});
ranges.push(lsp_types::FoldingRange {
start_line,
end_line,
start_character: None,
end_character: None,
kind,
collapsed_text: None,
});
}
Ok(self
.command_sender
.send(PluginCommand::SetFoldingRanges {
buffer_id: BufferId(buffer_id as usize),
ranges,
})
.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 scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
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(scoped_namespace.clone());
Ok(self
.command_sender
.send(PluginCommand::SetFileExplorerDecorations {
namespace: scoped_namespace,
decorations,
})
.is_ok())
}
pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
self.command_sender
.send(PluginCommand::ClearFileExplorerDecorations {
namespace: scoped_namespace,
})
.is_ok()
}
pub fn set_file_explorer_slots<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
namespace: String,
slots: Vec<rquickjs::Object<'js>>,
) -> rquickjs::Result<bool> {
use fresh_core::file_explorer::FileExplorerSlotEntry;
let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
let slots: Vec<FileExplorerSlotEntry> = slots
.into_iter()
.map(|obj| <FileExplorerSlotEntry as rquickjs::FromJs>::from_js(&ctx, obj.into()))
.collect::<rquickjs::Result<Vec<_>>>()?;
self.plugin_tracked_state
.borrow_mut()
.entry(self.plugin_name.clone())
.or_default()
.file_explorer_namespaces
.push(scoped_namespace.clone());
Ok(self
.command_sender
.send(PluginCommand::SetFileExplorerSlots {
namespace: scoped_namespace,
slots,
})
.is_ok())
}
pub fn clear_file_explorer_slots(&self, namespace: String) -> bool {
let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
self.command_sender
.send(PluginCommand::ClearFileExplorerSlots {
namespace: scoped_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()
}
#[allow(clippy::too_many_arguments)]
pub fn add_virtual_text_styled<'js>(
&self,
_ctx: rquickjs::Ctx<'js>,
buffer_id: u32,
virtual_text_id: String,
position: u32,
text: String,
options: rquickjs::Object<'js>,
before: bool,
) -> 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 bold: bool = options.get("bold").unwrap_or(false);
let italic: bool = options.get("italic").unwrap_or(false);
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()));
let _ = self
.command_sender
.send(PluginCommand::AddVirtualTextStyled {
buffer_id: BufferId(buffer_id as usize),
virtual_text_id,
position: position as usize,
text,
fg,
bg,
bold,
italic,
before,
});
Ok(true)
}
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);
let gutter_glyph = options
.get::<_, String>("gutterGlyph")
.ok()
.filter(|s| !s.is_empty());
let gutter_color = parse_color_spec("gutterColor", &options);
let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
.get::<_, rquickjs::Value<'js>>("textOverlays")
.ok()
.filter(|v| !v.is_undefined() && !v.is_null())
.and_then(|v| rquickjs_serde::from_value(v).ok())
.map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
v.into_iter().filter(|o| o.end > o.start).collect()
})
.unwrap_or_default();
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,
gutter_glyph,
gutter_color,
text_overlays,
})
.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 = self.alloc_request_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,
floating_overlay: rquickjs::function::Opt<bool>,
) -> bool {
self.command_sender
.send(PluginCommand::StartPrompt {
label,
prompt_type,
floating_overlay: floating_overlay.0.unwrap_or(false),
})
.is_ok()
}
pub fn begin_key_capture(&self) -> bool {
self.command_sender
.send(PluginCommand::SetKeyCaptureActive { active: true })
.is_ok()
}
pub fn end_key_capture(&self) -> bool {
self.command_sender
.send(PluginCommand::SetKeyCaptureActive { active: false })
.is_ok()
}
#[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
#[qjs(rename = "_getNextKeyStart")]
pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
let id = self.alloc_request_id();
let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
callback_id: JsCallbackId::new(id),
});
id
}
pub fn start_prompt_with_initial(
&self,
label: String,
prompt_type: String,
initial_value: String,
floating_overlay: rquickjs::function::Opt<bool>,
) -> bool {
self.command_sender
.send(PluginCommand::StartPromptWithInitial {
label,
prompt_type,
initial_value,
floating_overlay: floating_overlay.0.unwrap_or(false),
})
.is_ok()
}
pub fn set_prompt_suggestions(
&self,
suggestions: Vec<fresh_core::command::Suggestion>,
selected_index: rquickjs::function::Opt<Option<u32>>,
) -> bool {
self.command_sender
.send(PluginCommand::SetPromptSuggestions {
suggestions,
selected_index: selected_index.0.flatten(),
})
.is_ok()
}
pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
self.command_sender
.send(PluginCommand::SetPromptInputSync { sync })
.is_ok()
}
pub fn set_prompt_title(
&self,
#[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
) -> bool {
self.command_sender
.send(PluginCommand::SetPromptTitle { title })
.is_ok()
}
pub fn set_prompt_footer(
&self,
#[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
) -> bool {
self.command_sender
.send(PluginCommand::SetPromptFooter { footer })
.is_ok()
}
pub fn set_prompt_status(&self, status: String) -> bool {
self.command_sender
.send(PluginCommand::SetPromptStatus { status })
.is_ok()
}
#[qjs(rename = "setPromptToolbar")]
pub fn set_prompt_toolbar<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
spec_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let spec = if spec_obj.is_null() || spec_obj.is_undefined() {
None
} else {
let json = js_to_json(&ctx, spec_obj);
match serde_json::from_value::<fresh_core::api::WidgetSpec>(json) {
Ok(s) => Some(s),
Err(e) => {
tracing::error!("setPromptToolbar: invalid spec: {}", e);
return Ok(false);
}
}
};
Ok(self
.command_sender
.send(PluginCommand::SetPromptToolbar { spec })
.is_ok())
}
#[qjs(rename = "toggleOverlayToolbarWidget")]
pub fn toggle_overlay_toolbar_widget(&self, key: String) -> bool {
self.command_sender
.send(PluginCommand::ToggleOverlayToolbarWidget { key })
.is_ok()
}
pub fn set_prompt_selected_index(&self, index: u32) -> bool {
self.command_sender
.send(PluginCommand::SetPromptSelectedIndex { index })
.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 create_window(&self, root: String, label: String) -> bool {
self.command_sender
.send(PluginCommand::CreateWindow {
root: std::path::PathBuf::from(root),
label,
})
.is_ok()
}
pub fn set_active_window(&self, id: u64) -> bool {
self.command_sender
.send(PluginCommand::SetActiveWindow {
id: fresh_core::WindowId(id),
})
.is_ok()
}
#[qjs(rename = "setActiveWindowAnimated")]
pub fn set_active_window_animated(&self, id: u64, from_edge: String) -> bool {
self.command_sender
.send(PluginCommand::SetActiveWindowAnimated {
id: fresh_core::WindowId(id),
from_edge,
})
.is_ok()
}
#[qjs(rename = "setWindowCycleOrder")]
pub fn set_window_cycle_order(&self, ids: Vec<i64>) -> bool {
self.command_sender
.send(PluginCommand::SetWindowCycleOrder {
ids: ids
.into_iter()
.filter(|n| *n > 0)
.map(|n| fresh_core::WindowId(n as u64))
.collect(),
})
.is_ok()
}
pub fn close_window(&self, id: u64) -> bool {
self.command_sender
.send(PluginCommand::CloseWindow {
id: fresh_core::WindowId(id),
})
.is_ok()
}
pub fn prewarm_window(&self, id: u64) -> bool {
self.command_sender
.send(PluginCommand::PrewarmWindow {
id: fresh_core::WindowId(id),
})
.is_ok()
}
#[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
#[qjs(rename = "_watchPathStart")]
pub fn watch_path_start(
&self,
_ctx: rquickjs::Ctx<'_>,
path: String,
recursive: rquickjs::function::Opt<bool>,
) -> rquickjs::Result<u64> {
let id = self.alloc_request_id();
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self.command_sender.send(PluginCommand::WatchPath {
path: std::path::PathBuf::from(path),
recursive: recursive.0.unwrap_or(false),
request_id: id,
});
Ok(id)
}
pub fn unwatch_path(&self, handle: u64) -> bool {
self.command_sender
.send(PluginCommand::UnwatchPath { handle })
.is_ok()
}
pub fn preview_window_in_rect(&self, id: u64) -> bool {
let sid = if id == 0 {
None
} else {
Some(fresh_core::WindowId(id))
};
self.command_sender
.send(PluginCommand::PreviewWindowInRect { id: sid })
.is_ok()
}
pub fn clear_window_preview(&self) -> bool {
self.command_sender
.send(PluginCommand::PreviewWindowInRect { id: None })
.is_ok()
}
#[plugin_api(ts_return = "WindowInfo[]")]
pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
let sessions: Vec<fresh_core::api::WindowInfo> = self
.state_snapshot
.read()
.map(|s| s.windows.clone())
.unwrap_or_default();
rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
})
}
pub fn active_window(&self) -> u64 {
self.state_snapshot
.read()
.map(|s| s.active_window_id.0)
.unwrap_or(1)
}
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 = self.alloc_request_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 set_window_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() {
match &json_value {
Some(v) => {
snapshot
.active_session_plugin_states
.entry(self.plugin_name.clone())
.or_default()
.insert(key.clone(), v.clone());
}
None => {
if let Some(map) = snapshot
.active_session_plugin_states
.get_mut(&self.plugin_name)
{
map.remove(&key);
if map.is_empty() {
snapshot
.active_session_plugin_states
.remove(&self.plugin_name);
}
}
}
}
}
self.command_sender
.send(PluginCommand::SetWindowState {
plugin_name: self.plugin_name.clone(),
key,
value: json_value,
})
.is_ok()
}
pub fn get_window_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.active_session_plugin_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,
buffer_id: opts.buffer_id,
})
.is_ok()
}
pub fn set_lsp_menu_contributions(
&self,
plugin_id: String,
language: String,
items: Vec<fresh_core::api::LspMenuItem>,
) -> bool {
self.command_sender
.send(PluginCommand::SetLspMenuContributions {
plugin_id,
language,
items,
})
.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
.read()
.expect("event_handlers poisoned")
.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 = self.alloc_request_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(),
segments: e.segments.unwrap_or_default(),
pad_to_chars: e.pad_to_chars,
truncate_to_chars: e.truncate_to_chars,
})
.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),
initial_cursor_line: opts.initial_cursor_line,
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 = self.alloc_request_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(),
segments: e.segments.unwrap_or_default(),
pad_to_chars: e.pad_to_chars,
truncate_to_chars: e.truncate_to_chars,
})
.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),
role: opts.role,
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 = self.alloc_request_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(),
segments: e.segments.unwrap_or_default(),
pad_to_chars: e.pad_to_chars,
truncate_to_chars: e.truncate_to_chars,
})
.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,
initial_cursor_line: opts.initial_cursor_line,
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 = self.alloc_request_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()
}
#[plugin_api(
async_promise,
js_name = "setBufferGroupPanelBuffer",
ts_return = "boolean"
)]
#[qjs(rename = "_setBufferGroupPanelBufferStart")]
pub fn set_buffer_group_panel_buffer_start(
&self,
_ctx: rquickjs::Ctx<'_>,
group_id: u32,
panel_name: String,
buffer_id: u32,
) -> u64 {
let id = self.alloc_request_id();
let _ = self
.command_sender
.send(PluginCommand::SetBufferGroupPanelBuffer {
group_id: group_id as usize,
panel_name,
buffer_id: BufferId(buffer_id as usize),
request_id: id,
});
id
}
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)
}
#[qjs(rename = "mountWidgetPanel")]
pub fn mount_widget_panel<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
buffer_id: u32,
spec_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, spec_obj);
let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
Ok(s) => s,
Err(e) => {
tracing::error!("mountWidgetPanel: invalid spec: {}", e);
return Ok(false);
}
};
Ok(self
.command_sender
.send(PluginCommand::MountWidgetPanel {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
buffer_id: BufferId(buffer_id as usize),
spec,
})
.is_ok())
}
#[qjs(rename = "updateWidgetPanel")]
pub fn update_widget_panel<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
spec_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, spec_obj);
let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
Ok(s) => s,
Err(e) => {
tracing::error!("updateWidgetPanel: invalid spec: {}", e);
return Ok(false);
}
};
Ok(self
.command_sender
.send(PluginCommand::UpdateWidgetPanel {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
spec,
})
.is_ok())
}
#[qjs(rename = "unmountWidgetPanel")]
pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
self.command_sender
.send(PluginCommand::UnmountWidgetPanel {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
})
.is_ok()
}
#[qjs(rename = "widgetCommand")]
pub fn widget_command<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
action_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, action_obj);
let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
Ok(a) => a,
Err(e) => {
tracing::error!("widgetCommand: invalid action: {}", e);
return Ok(false);
}
};
Ok(self
.command_sender
.send(PluginCommand::WidgetCommand {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
action,
})
.is_ok())
}
#[qjs(rename = "widgetMutate")]
pub fn widget_mutate<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
mutation_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, mutation_obj);
let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
Ok(m) => m,
Err(e) => {
tracing::error!("widgetMutate: invalid mutation: {}", e);
return Ok(false);
}
};
Ok(self
.command_sender
.send(PluginCommand::WidgetMutate {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
mutation,
})
.is_ok())
}
#[qjs(rename = "mountFloatingWidget")]
pub fn mount_floating_widget<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
spec_obj: rquickjs::Value<'js>,
width_pct: f64,
height_pct: f64,
as_dock: rquickjs::function::Opt<bool>,
focus_marker: rquickjs::function::Opt<bool>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, spec_obj);
let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
Ok(s) => s,
Err(e) => {
tracing::error!("mountFloatingWidget: invalid spec: {}", e);
return Ok(false);
}
};
let width_pct = width_pct.clamp(1.0, 100.0) as u8;
let height_pct = height_pct.clamp(1.0, 100.0) as u8;
Ok(self
.command_sender
.send(PluginCommand::MountFloatingWidget {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
spec,
width_pct,
height_pct,
as_dock: as_dock.0.unwrap_or(false),
focus_marker: focus_marker.0.unwrap_or(false),
})
.is_ok())
}
#[qjs(rename = "updateFloatingWidget")]
pub fn update_floating_widget<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
panel_id: f64,
spec_obj: rquickjs::Value<'js>,
) -> rquickjs::Result<bool> {
let json = js_to_json(&ctx, spec_obj);
let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
Ok(s) => s,
Err(e) => {
tracing::error!("updateFloatingWidget: invalid spec: {}", e);
return Ok(false);
}
};
Ok(self
.command_sender
.send(PluginCommand::UpdateFloatingWidget {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
spec,
})
.is_ok())
}
#[qjs(rename = "unmountFloatingWidget")]
pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
self.command_sender
.send(PluginCommand::UnmountFloatingWidget {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
})
.is_ok()
}
#[qjs(rename = "floatingPanelControl")]
pub fn floating_panel_control(&self, panel_id: f64, op: String, arg: f64) -> bool {
self.command_sender
.send(PluginCommand::FloatingPanelControl {
plugin: self.plugin_name.clone(),
panel_id: panel_id as u64,
op,
arg,
})
.is_ok()
}
#[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>,
stdout_to: rquickjs::function::Opt<String>,
) -> u64 {
let id = self.alloc_request_id();
let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
self.state_snapshot
.read()
.ok()
.map(|s| s.working_dir.to_string_lossy().to_string())
});
let stdout_to_path = stdout_to
.0
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from);
tracing::info!(
"spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
self.plugin_name,
command,
args,
effective_cwd,
stdout_to_path,
id
);
let _ = self.command_sender.send(PluginCommand::SpawnProcess {
callback_id: JsCallbackId::new(id),
command,
args,
cwd: effective_cwd,
stdout_to: stdout_to_path,
});
id
}
#[plugin_api(
async_thenable,
js_name = "spawnHostProcess",
ts_return = "SpawnResult"
)]
#[qjs(rename = "_spawnHostProcessStart")]
pub fn spawn_host_process_start(
&self,
_ctx: rquickjs::Ctx<'_>,
command: String,
args: Vec<String>,
cwd: rquickjs::function::Opt<String>,
) -> u64 {
let id = self.alloc_request_id();
let effective_cwd = cwd.0.or_else(|| {
self.state_snapshot
.read()
.ok()
.map(|s| s.working_dir.to_string_lossy().to_string())
});
let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
callback_id: JsCallbackId::new(id),
command,
args,
cwd: effective_cwd,
});
id
}
#[plugin_api(js_name = "_killHostProcess")]
pub fn kill_host_process(&self, process_id: u64) -> bool {
self.command_sender
.send(PluginCommand::KillHostProcess { process_id })
.is_ok()
}
#[plugin_api(js_name = "setAuthority")]
pub fn set_authority(
&self,
ctx: rquickjs::Ctx<'_>,
#[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
) -> bool {
let json = js_to_json(&ctx, payload);
let _ = self
.command_sender
.send(PluginCommand::SetAuthority { payload: json });
true
}
#[plugin_api(js_name = "clearAuthority")]
pub fn clear_authority(&self) {
let _ = self.command_sender.send(PluginCommand::ClearAuthority);
}
#[plugin_api(async_promise, js_name = "attachRemoteAgent", ts_return = "void")]
#[qjs(rename = "_attachRemoteAgentStart")]
pub fn attach_remote_agent(
&self,
ctx: rquickjs::Ctx<'_>,
#[plugin_api(ts_type = "RemoteAgentSpec")] payload: rquickjs::Value<'_>,
) -> u64 {
let json = js_to_json(&ctx, payload);
let id = self.alloc_request_id();
let _ = self.command_sender.send(PluginCommand::AttachRemoteAgent {
payload: json,
request_id: id,
});
id
}
#[plugin_api(js_name = "cancelRemoteAgent")]
pub fn cancel_remote_agent(&self) {
let _ = self.command_sender.send(PluginCommand::CancelRemoteAttach);
}
#[plugin_api(js_name = "setEnv")]
pub fn set_env(&self, snippet: String, dir: Option<String>) {
let _ = self
.command_sender
.send(PluginCommand::SetEnv { snippet, dir });
}
#[plugin_api(js_name = "clearEnv")]
pub fn clear_env(&self) {
let _ = self.command_sender.send(PluginCommand::ClearEnv);
}
#[plugin_api(js_name = "setRemoteIndicatorState")]
pub fn set_remote_indicator_state(
&self,
ctx: rquickjs::Ctx<'_>,
#[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
) -> bool {
let json = js_to_json(&ctx, state);
let _ = self
.command_sender
.send(PluginCommand::SetRemoteIndicatorState { state: json });
true
}
#[plugin_api(js_name = "clearRemoteIndicatorState")]
pub fn clear_remote_indicator_state(&self) {
let _ = self
.command_sender
.send(PluginCommand::ClearRemoteIndicatorState);
}
#[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
#[qjs(rename = "_httpFetchStart")]
pub fn http_fetch_start(
&self,
_ctx: rquickjs::Ctx<'_>,
url: String,
target_path: String,
) -> u64 {
let id = self.alloc_request_id();
tracing::info!(
"http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
self.plugin_name,
url,
target_path,
id
);
let _ = self.command_sender.send(PluginCommand::HttpFetch {
url,
target_path: std::path::PathBuf::from(target_path),
callback_id: JsCallbackId::new(id),
});
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 = self.alloc_request_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 = self.alloc_request_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 = self.alloc_request_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 = self.alloc_request_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 = "beginSearch",
ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean; sourceBufferId?: number }): SearchHandle"
)]
#[qjs(rename = "_beginSearch")]
pub fn begin_search(
&self,
_ctx: rquickjs::Ctx<'_>,
pattern: String,
fixed_string: bool,
case_sensitive: bool,
max_results: u32,
whole_words: bool,
source_buffer_id: u32,
) -> u64 {
let id = self.alloc_request_id();
let entry = Arc::new(SearchHandleState::new());
if let Ok(mut map) = self.search_handles.lock() {
map.insert(id, entry);
}
let _ = self.command_sender.send(PluginCommand::BeginSearch {
pattern,
fixed_string,
case_sensitive,
max_results: max_results as usize,
whole_words,
source_buffer_id: source_buffer_id as usize,
handle_id: id,
});
id
}
#[plugin_api(ts_return = "SearchTakeResult")]
#[qjs(rename = "_searchHandleTake")]
pub fn search_handle_take<'js>(
&self,
ctx: rquickjs::Ctx<'js>,
handle_id: u64,
) -> rquickjs::Result<Value<'js>> {
let entry = self
.search_handles
.lock()
.ok()
.and_then(|m| m.get(&handle_id).cloned());
let result = match entry {
Some(handle) => {
let mut state = match handle.state.lock() {
Ok(s) => s,
Err(poisoned) => poisoned.into_inner(),
};
let matches = std::mem::take(&mut state.pending);
let snapshot = SearchTakeResult {
matches,
done: state.done,
total_seen: state.total_seen,
truncated: state.truncated,
error: state.error.clone(),
};
let done = snapshot.done;
drop(state);
if done {
if let Ok(mut map) = self.search_handles.lock() {
map.remove(&handle_id);
}
}
snapshot
}
None => SearchTakeResult {
matches: Vec::new(),
done: true,
total_seen: 0,
truncated: false,
error: None,
},
};
rquickjs_serde::to_value(ctx, &result)
.map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
}
#[qjs(rename = "_searchHandleCancel")]
pub fn search_handle_cancel(&self, handle_id: u64) {
if let Ok(map) = self.search_handles.lock() {
if let Some(entry) = map.get(&handle_id) {
entry
.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
#[plugin_api(
async_promise,
js_name = "replaceInFile",
ts_raw = "replaceInFile(filePath: string, matches: number[][], replacement: string, bufferId?: number): Promise<ReplaceResult>"
)]
#[qjs(rename = "_replaceInFileStart")]
pub fn replace_in_file_start(
&self,
_ctx: rquickjs::Ctx<'_>,
file_path: String,
matches: Vec<Vec<u32>>,
replacement: String,
buffer_id: rquickjs::function::Opt<u32>,
) -> u64 {
let id = self.alloc_request_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),
buffer_id: buffer_id.0.unwrap_or(0) as usize,
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 = self.alloc_request_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 = self.alloc_request_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.filter(|s| !s.is_empty()),
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 = self.alloc_request_id();
let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
cwd: None,
direction: None,
ratio: None,
focus: None,
persistent: None,
window_id: None,
command: None,
title: 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,
window_id: opts.window_id,
persistent: opts.persistent.unwrap_or(false),
command: opts.command,
title: opts.title,
request_id: id,
});
Ok(id)
}
#[plugin_api(
async_promise,
js_name = "createWindowWithTerminal",
ts_return = "SessionWithTerminalResult"
)]
#[qjs(rename = "_createWindowWithTerminalStart")]
pub fn create_window_with_terminal_start(
&self,
_ctx: rquickjs::Ctx<'_>,
opts: fresh_core::api::CreateWindowWithTerminalOptions,
) -> rquickjs::Result<u64> {
let id = self.alloc_request_id();
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.insert(id, self.plugin_name.clone());
}
let _ = self
.command_sender
.send(PluginCommand::CreateWindowWithTerminal {
root: std::path::PathBuf::from(opts.root),
label: opts.label,
cwd: opts.cwd,
command: opts.command,
title: opts.title,
resume: opts.resume,
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 signal_window(&self, id: f64, signal: String) -> bool {
self.command_sender
.send(PluginCommand::SignalWindow {
id: fresh_core::WindowId(id as u64),
signal,
})
.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 = self.alloc_request_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 = self.alloc_request_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 = self.alloc_request_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 = self.alloc_request_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::{TokenColor, ViewTokenStyle};
let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
let Some(s) = style_obj else {
return Ok(None);
};
fn parse_color(
s: &rquickjs::Object<'_>,
field: &str,
idx: usize,
) -> rquickjs::Result<Option<TokenColor>> {
if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
if arr.len() < 3 {
tracing::warn!(
"token[{}]: style.{} has {} elements, expected 3 (RGB)",
idx,
field,
arr.len()
);
return Ok(None);
}
return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
}
if let Ok(name) = s.get::<_, String>(field) {
return Ok(Some(TokenColor::Named(name)));
}
Ok(None)
}
Ok(Some(ViewTokenStyle {
fg: parse_color(&s, "fg", idx)?,
bg: parse_color(&s, "bg", idx)?,
bold: s.get("bold").unwrap_or(false),
italic: s.get("italic").unwrap_or(false),
underline: s.get("underline").unwrap_or(false),
}))
}
pub struct QuickJsBackend {
runtime: Runtime,
main_context: Context,
plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
event_handlers: EventHandlerRegistry,
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>>>,
plugin_api_exports: PluginApiExports,
search_handles: SearchHandleRegistry,
}
impl Drop for QuickJsBackend {
fn drop(&mut self) {
self.plugin_api_exports.borrow_mut().clear();
}
}
const EDITOR_GLOBALS_BOOTSTRAP: &str = "globalThis.getEditor = function() { return editor; };\nglobalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };";
const EDITOR_ON_OFF_SHIM: &str = r#"
(function() {
const originalOn = editor.on.bind(editor);
const originalOff = editor.off.bind(editor);
let counter = 0;
const anonNames = new WeakMap();
editor.on = function(eventName, handlerOrName) {
if (typeof handlerOrName === 'function') {
const existing = anonNames.get(handlerOrName);
const name = existing || `__anon_on_${++counter}`;
if (!existing) {
anonNames.set(handlerOrName, name);
}
globalThis[name] = handlerOrName;
return originalOn(eventName, name);
}
return originalOn(eventName, handlerOrName);
};
editor.off = function(eventName, handlerOrName) {
if (typeof handlerOrName === 'function') {
const name = anonNames.get(handlerOrName);
if (name === undefined) return false;
return originalOff(eventName, name);
}
return originalOff(eventName, handlerOrName);
};
})();
"#;
const EDITOR_PROMISE_BOOTSTRAP: &str = r#"
// Pending promise callbacks: callbackId -> { resolve, reject }
globalThis._pendingCallbacks = new Map();
// Resolve a pending callback (called from Rust)
globalThis._resolveCallback = function(callbackId, result) {
// No per-resolve logging here: this fires once per async op
// completion (potentially at very high frequency), and
// console.log is captured into the host log, so logging here
// floods the log and can feed a tail-driven feedback loop.
const cb = globalThis._pendingCallbacks.get(callbackId);
if (cb) {
globalThis._pendingCallbacks.delete(callbackId);
cb.resolve(result);
}
};
// 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));
}
};
// 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
// spawnProcess accepts either form for the 4th arg:
// editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
// editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
// The first matches the auto-generated TS signature
// (flat positional from the Rust binding's `Opt<String>`
// args); the second is the structured options form
// plugin authors often prefer.
editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
if (typeof editor._spawnProcessStart !== 'function') {
throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
}
// The 3rd arg is either cwd (string) or an options
// object when cwd is omitted; the 4th is either a
// stdoutTo string or an options object.
let cwd = "";
let stdoutTo = "";
if (typeof cwdOrOpts === "string") {
cwd = cwdOrOpts;
} else if (cwdOrOpts && typeof cwdOrOpts === "object") {
if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
}
if (typeof fourth === "string") {
stdoutTo = fourth;
} else if (fourth && typeof fourth === "object") {
if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
}
const callbackId = editor._spawnProcessStart(
command,
argsArr || [],
cwd,
stdoutTo,
);
const resultPromise = new Promise((resolve, reject) => {
globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
});
return {
get result() { return resultPromise; },
// `kill()` cancels a still-running spawn. The
// dispatcher stores a oneshot keyed by callbackId;
// _killHostProcess fires it and the spawner's
// tokio::select! kills the child. No-op if the
// child already exited (id removed from the map).
kill() {
if (typeof editor._killHostProcess === 'function') {
return editor._killHostProcess(callbackId);
}
return false;
},
then(onFulfilled, onRejected) {
return resultPromise.then(onFulfilled, onRejected);
},
catch(onRejected) {
return resultPromise.catch(onRejected);
}
};
};
// spawnHostProcess gets a bespoke wrapper (instead of
// `_wrapAsyncThenable`) because its `ProcessHandle`
// exposes a real `kill()` that forwards to
// `_killHostProcess`. Generic wrap has no hook for
// that.
editor.spawnHostProcess = function(command, args, cwd) {
if (typeof editor._spawnHostProcessStart !== 'function') {
throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
}
// Pass real strings only. Earlier revisions forwarded
// `""` for a missing cwd, which landed verbatim as
// `Command::current_dir("")` in the dispatcher —
// every host-spawn then failed with ENOENT. Use two
// arity forms so the Rust `Opt<String>` stays `None`
// instead of `Some("")`.
let callbackId;
if (typeof cwd === "string" && cwd.length > 0) {
callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
} else {
callbackId = editor._spawnHostProcessStart(command, args || []);
}
const resultPromise = new Promise(function(resolve, reject) {
globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
});
return {
processId: callbackId,
get result() { return resultPromise; },
then: function(f, r) { return resultPromise.then(f, r); },
catch: function(r) { return resultPromise.catch(r); },
kill: function() {
// Returns true when the kill was enqueued
// (the process may have already exited; in
// that case the dispatcher silently
// drops it). Matches the
// `ProcessHandle.kill(): Promise<boolean>`
// type signature by wrapping the sync
// boolean in a Promise.
return Promise.resolve(editor._killHostProcess(callbackId));
}
};
};
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.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
editor.getCompositeCursorInfo = _wrapAsync("_getCompositeCursorInfoStart", "getCompositeCursorInfo");
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.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
editor.attachRemoteAgent = _wrapAsync("_attachRemoteAgentStart", "attachRemoteAgent");
// Pull-based streaming search. Producers (host searcher tasks)
// write into shared state at full speed; the consumer drains
// it via take() at its own cadence — no per-chunk JS dispatch.
editor.beginSearch = function(pattern, opts) {
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 sourceBufferId = opts.sourceBufferId || 0;
const handleId = editor._beginSearch(
pattern, fixedString, caseSensitive, maxResults, wholeWords, sourceBufferId
);
return {
searchId: handleId,
take: function() { return editor._searchHandleTake(handleId); },
cancel: function() { editor._searchHandleCancel(handleId); }
};
};
// 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));
}
});
};
"#;
fn install_console<'js>(
ctx: &rquickjs::Ctx<'js>,
globals: &rquickjs::Object<'js>,
) -> rquickjs::Result<()> {
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)?;
Ok(())
}
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()));
let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
Self::with_state_responses_and_resources(
state_snapshot,
command_sender,
pending_responses,
services,
async_resource_owners,
search_handles,
event_handlers,
)
}
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,
search_handles: SearchHandleRegistry,
event_handlers: EventHandlerRegistry,
) -> 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 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 plugin_api_exports = 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,
plugin_api_exports,
search_handles,
};
backend.setup_context_api(&backend.main_context.clone(), "internal")?;
tracing::debug!("QuickJsBackend::new: runtime created successfully");
Ok(backend)
}
fn build_editor_api(&self, plugin_name: &str) -> JsEditorApi {
JsEditorApi {
state_snapshot: Arc::clone(&self.state_snapshot),
command_sender: self.command_sender.clone(),
registered_actions: Rc::clone(&self.registered_actions),
event_handlers: Arc::clone(&self.event_handlers),
next_request_id: Rc::clone(&self.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(&self.registered_command_names),
registered_grammar_languages: Rc::clone(&self.registered_grammar_languages),
registered_language_configs: Rc::clone(&self.registered_language_configs),
registered_lsp_servers: Rc::clone(&self.registered_lsp_servers),
plugin_api_exports: Rc::clone(&self.plugin_api_exports),
search_handles: Arc::clone(&self.search_handles),
plugin_name: plugin_name.to_string(),
}
}
fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
context
.with(|ctx| {
let globals = ctx.globals();
globals.set("__pluginName__", plugin_name)?;
let editor = rquickjs::Class::<JsEditorApi>::instance(
ctx.clone(),
self.build_editor_api(plugin_name),
)?;
globals.set("editor", editor)?;
ctx.eval::<(), _>(EDITOR_GLOBALS_BOOTSTRAP)?;
ctx.eval::<(), _>(EDITOR_ON_OFF_SHIM)?;
install_console(&ctx, &globals)?;
ctx.eval::<(), _>(EDITOR_PROMISE_BOOTSTRAP.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);
{
let mut handlers_map = self
.event_handlers
.write()
.expect("event_handlers poisoned");
for handlers in handlers_map.values_mut() {
handlers.retain(|h| h.plugin_name != plugin_name);
}
handlers_map.retain(|_, list| !list.is_empty());
}
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 _ = self
.command_sender
.send(PluginCommand::ClearFileExplorerSlots {
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,
});
}
for handle in &tracked.watch_handles {
let _ = self
.command_sender
.send(PluginCommand::UnwatchPath { handle: *handle });
}
}
if let Ok(mut owners) = self.async_resource_owners.lock() {
owners.retain(|_, name| name != plugin_name);
}
self.plugin_api_exports
.borrow_mut()
.retain(|_, (exporter, _)| exporter != 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> {
self.emit_to(event_name, event_data, None).await
}
pub async fn emit_to(
&mut self,
event_name: &str,
event_data: &serde_json::Value,
target: Option<&str>,
) -> 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
.read()
.expect("event_handlers poisoned")
.get(event_name)
.cloned();
if let Some(handler_pairs) = handlers {
let plugin_contexts = self.plugin_contexts.borrow();
for handler in &handler_pairs {
if target.is_some_and(|t| t != handler.plugin_name) {
continue;
}
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
.read()
.expect("event_handlers poisoned")
.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));
});
}
}
#[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 get_all_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
.write()
.unwrap()
.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),
}
}
#[tokio::test]
async fn test_emit_to_targets_single_plugin() {
let (mut backend, rx) = create_test_backend();
for name in ["alpha", "beta"] {
backend
.execute_js(
&format!(
r#"
const editor = getEditor();
globalThis.onWidget = function(data) {{
editor.setStatus("{name} got " + data.panel_id);
}};
editor.on("widget_event", "onWidget");
"#
),
&format!("{name}.js"),
)
.unwrap();
}
while rx.try_recv().is_ok() {}
let event_data: serde_json::Value = serde_json::json!({ "panel_id": 7 });
backend
.emit_to("widget_event", &event_data, Some("beta"))
.await
.unwrap();
match rx.try_recv().unwrap() {
PluginCommand::SetStatus { message } => assert_eq!(message, "beta got 7"),
cmd => panic!("Expected SetStatus, got {:?}", cmd),
}
assert!(
rx.try_recv().is_err(),
"targeted emit must not reach the other plugin"
);
backend
.emit_to("widget_event", &event_data, None)
.await
.unwrap();
let mut got: Vec<String> = Vec::new();
while let Ok(cmd) = rx.try_recv() {
if let PluginCommand::SetStatus { message } = cmd {
got.push(message);
}
}
got.sort();
assert_eq!(got, vec!["alpha got 7", "beta got 7"]);
}
#[tokio::test]
async fn test_emit_event_preserves_integers_beyond_i32() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis.onBigHandler = function(data) {
editor.setStatus("big: " + data.big + " neg: " + data.neg + " small: " + data.small);
};
editor.on("bigEvent", "onBigHandler");
"#,
"test.js",
)
.unwrap();
while rx.try_recv().is_ok() {}
let event_data: serde_json::Value = serde_json::json!({
"big": 4_503_599_627_370_001_i64, "neg": -4_503_599_627_370_001_i64,
"small": 42,
});
backend.emit("bigEvent", &event_data).await.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::SetStatus { message } => {
assert_eq!(
message,
"big: 4503599627370001 neg: -4503599627370001 small: 42"
);
}
_ => 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,
line: Some(0),
});
}
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_get_cursor_line_small_and_large_file() {
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: 120,
selection: None,
line: Some(7),
});
state.all_cursors = vec![
CursorInfo {
position: 120,
selection: None,
line: Some(7),
},
CursorInfo {
position: 200,
selection: None,
line: Some(12),
},
];
}
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 primary = editor.getPrimaryCursor();
globalThis._primaryLine = primary.line;
globalThis._cursorLine = editor.getCursorLine();
globalThis._allLines = editor.getAllCursors().map(c => c.line);
"#,
"probe_small.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("probe_small")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let primary_line: i32 = global.get("_primaryLine").unwrap();
assert_eq!(primary_line, 7);
let cursor_line: u32 = global.get("_cursorLine").unwrap();
assert_eq!(cursor_line, 7);
let all_lines: Vec<i32> = global.get("_allLines").unwrap();
assert_eq!(all_lines, vec![7, 12]);
});
let (tx2, _rx2) = mpsc::channel();
let state_snapshot2 = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot2.write().unwrap();
state.primary_cursor = Some(CursorInfo {
position: 5_000_000,
selection: None,
line: None,
});
state.all_cursors = vec![CursorInfo {
position: 5_000_000,
selection: None,
line: None,
}];
}
let services2 = Arc::new(fresh_core::services::NoopServiceBridge);
let mut backend2 = QuickJsBackend::with_state(state_snapshot2, tx2, services2).unwrap();
backend2
.execute_js(
r#"
const editor = getEditor();
const primary = editor.getPrimaryCursor();
// null and undefined both serialize to JS null here; normalize to a
// sentinel so the Rust side can assert "unknown" unambiguously.
globalThis._primaryLineIsNull = (primary.line === null || primary.line === undefined);
globalThis._cursorLineFallback = editor.getCursorLine();
globalThis._allLineIsNull = (editor.getAllCursors()[0].line === null);
"#,
"probe_large.js",
)
.unwrap();
backend2
.plugin_contexts
.borrow()
.get("probe_large")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let primary_null: bool = global.get("_primaryLineIsNull").unwrap();
assert!(
primary_null,
"primary.line should be null in large-file mode"
);
let all_null: bool = global.get("_allLineIsNull").unwrap();
assert!(
all_null,
"getAllCursors()[0].line should be null in large-file mode"
);
let fallback: u32 = global.get("_cursorLineFallback").unwrap();
assert_eq!(fallback, 0);
});
}
#[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_path_join_preserves_unc_prefix() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
globalThis._posix = editor.pathJoin("/foo", "bar");
globalThis._drive = editor.pathJoin("C:\\foo", "bar");
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
assert_eq!(
global.get::<_, String>("_unc").unwrap(),
"//?/C:/workspace/.devcontainer/devcontainer.json",
"UNC prefix `\\\\?\\` must survive pathJoin normalization",
);
assert_eq!(
global.get::<_, String>("_unc_fwd").unwrap(),
"//?/C:/workspace/.devcontainer/devcontainer.json",
"UNC prefix in forward-slash form stays as `//`",
);
assert_eq!(
global.get::<_, String>("_posix").unwrap(),
"/foo/bar",
"POSIX absolute paths keep their single leading slash",
);
assert_eq!(
global.get::<_, String>("_drive").unwrap(),
"C:/foo/bar",
"Windows drive-letter paths have no leading slash",
);
});
}
#[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_override_theme_colors_round_trip() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.overrideThemeColors({
"editor.bg": [10, 20, 30],
"editor.fg": [220, 221, 222],
});
"#,
"test.js",
)
.unwrap();
let cmd = rx.try_recv().unwrap();
match cmd {
PluginCommand::OverrideThemeColors { overrides } => {
assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
assert_eq!(overrides.len(), 2);
}
_ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
}
}
#[test]
fn test_api_override_theme_colors_clamps_out_of_range() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.overrideThemeColors({
"editor.bg": [-5, 300, 128],
});
"#,
"test.js",
)
.unwrap();
match rx.try_recv().unwrap() {
PluginCommand::OverrideThemeColors { overrides } => {
assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
}
other => panic!("Expected OverrideThemeColors, got {other:?}"),
}
}
#[test]
fn test_api_override_theme_colors_drops_malformed_entries() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.overrideThemeColors({
"editor.bg": [1, 2, 3],
"not_an_array": "oops",
"wrong_length": [1, 2],
"floats_are_fine": [10.7, 20.2, 30.9],
});
"#,
"test.js",
)
.unwrap();
match rx.try_recv().unwrap() {
PluginCommand::OverrideThemeColors { overrides } => {
assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
assert!(!overrides.contains_key("not_an_array"));
assert!(!overrides.contains_key("wrong_length"));
assert_eq!(
overrides.get("floats_are_fine").copied(),
Some([10, 20, 30])
);
}
other => panic!("Expected OverrideThemeColors, got {other:?}"),
}
}
#[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 get_all_themes(&self) -> serde_json::Value {
self.inner.get_all_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_session_lifecycle_dispatches_commands() {
let (mut backend, rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.createWindow("/tmp/wt-feat", "feat");
editor.setActiveWindow(7);
editor.closeWindow(3);
"#,
"test.js",
)
.unwrap();
let create = rx.try_recv().unwrap();
match create {
fresh_core::api::PluginCommand::CreateWindow { root, label } => {
assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
assert_eq!(label, "feat");
}
other => panic!("Expected CreateWindow, got {:?}", other),
}
let activate = rx.try_recv().unwrap();
match activate {
fresh_core::api::PluginCommand::SetActiveWindow { id } => {
assert_eq!(id, fresh_core::WindowId(7));
}
other => panic!("Expected SetActiveWindow, got {:?}", other),
}
let close = rx.try_recv().unwrap();
match close {
fresh_core::api::PluginCommand::CloseWindow { id } => {
assert_eq!(id, fresh_core::WindowId(3));
}
other => panic!("Expected CloseWindow, got {:?}", other),
}
}
#[test]
fn test_api_list_sessions_reads_snapshot() {
let (tx, _rx) = mpsc::channel();
let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
{
let mut state = state_snapshot.write().unwrap();
state.windows = vec![
fresh_core::api::WindowInfo {
id: fresh_core::WindowId(1),
label: "main".into(),
root: std::path::PathBuf::from("/repo"),
project_path: std::path::PathBuf::from("/repo"),
shared_worktree: false,
},
fresh_core::api::WindowInfo {
id: fresh_core::WindowId(2),
label: "feat-auth".into(),
root: std::path::PathBuf::from("/wt/feat-auth"),
project_path: std::path::PathBuf::from("/wt/feat-auth"),
shared_worktree: false,
},
];
state.active_window_id = fresh_core::WindowId(2);
}
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 list = editor.listWindows();
globalThis._sessionCount = list.length;
globalThis._secondLabel = list[1].label;
globalThis._secondRoot = list[1].root;
globalThis._activeId = editor.activeWindow();
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let count: u32 = global.get("_sessionCount").unwrap();
let label: String = global.get("_secondLabel").unwrap();
let root: String = global.get("_secondRoot").unwrap();
let active: u32 = global.get("_activeId").unwrap();
assert_eq!(count, 2);
assert_eq!(label, "feat-auth");
assert_eq!(root, "/wt/feat-auth");
assert_eq!(active, 2);
});
}
#[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,
editing_disabled: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
state.buffers.insert(
BufferId(1),
BufferInfo {
id: BufferId(1),
path: Some(PathBuf::from("/test2.txt")),
modified: true,
length: 200,
is_virtual: false,
editing_disabled: false,
view_mode: "source".to_string(),
is_composing_in_any_split: false,
compose_width: None,
language: "text".to_string(),
is_preview: false,
splits: Vec::new(),
},
);
}
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,
floating_overlay,
} => {
assert_eq!(label, "Enter value:");
assert_eq!(prompt_type, "test-prompt");
assert!(!floating_overlay);
}
_ => 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,
floating_overlay,
} => {
assert_eq!(label, "Enter value:");
assert_eq!(prompt_type, "test-prompt");
assert_eq!(initial_value, "default");
assert!(!floating_overlay);
}
_ => 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_parse_jsonc() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
// Comments, trailing commas, and nested structures should all parse.
const parsed = editor.parseJsonc(`{
// name of the container
"name": "test",
"features": {
"docker-in-docker": {},
},
/* forwarded port list */
"forwardPorts": [3000, 8080,],
}`);
globalThis._name = parsed.name;
globalThis._featureCount = Object.keys(parsed.features).length;
globalThis._portCount = parsed.forwardPorts.length;
// Invalid JSONC should throw.
try {
editor.parseJsonc("{ broken");
globalThis._threw = false;
} catch (_e) {
globalThis._threw = true;
}
"#,
"test.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let name: String = global.get("_name").unwrap();
let feature_count: u32 = global.get("_featureCount").unwrap();
let port_count: u32 = global.get("_portCount").unwrap();
let threw: bool = global.get("_threw").unwrap();
assert_eq!(name, "test");
assert_eq!(feature_count, 1);
assert_eq!(port_count, 2);
assert!(threw, "Invalid JSONC should throw");
});
}
#[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_set_session_state_roundtrip() {
let (mut backend, _rx) = create_test_backend();
backend
.execute_js(
r#"
const editor = getEditor();
editor.setWindowState("draft", { count: 7 });
globalThis._result = editor.getWindowState("draft");
globalThis._missing = editor.getWindowState("absent");
"#,
"test_plugin.js",
)
.unwrap();
backend
.plugin_contexts
.borrow()
.get("test_plugin")
.unwrap()
.clone()
.with(|ctx| {
let global = ctx.globals();
let count: i64 = global
.get::<_, rquickjs::Object>("_result")
.unwrap()
.get("count")
.unwrap();
assert_eq!(
count, 7,
"getWindowState should return the value set by setWindowState"
);
let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
assert!(
missing.is_undefined(),
"getWindowState for an unset key must be undefined"
);
});
}
#[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"
);
}
}