1use anyhow::{anyhow, Result};
90use fresh_core::api::{
91 ActionSpec, BufferInfo, CompositeHunk, CreateCompositeBufferOptions, EditorStateSnapshot,
92 GrammarInfoSnapshot, JsCallbackId, LanguagePackConfig, LspServerPackConfig, OverlayOptions,
93 PluginCommand, PluginResponse, SearchHandleRegistry, SearchHandleState, SearchTakeResult,
94};
95use fresh_core::command::Command;
96use fresh_core::overlay::OverlayNamespace;
97use fresh_core::text_property::TextPropertyEntry;
98use fresh_core::{BufferId, SplitId};
99use fresh_parser_js::{
100 bundle_module, has_es_imports, has_es_module_syntax, strip_imports_and_exports,
101 transpile_typescript,
102};
103use fresh_plugin_api_macros::{plugin_api, plugin_api_impl};
104use rquickjs::{Context, Function, Object, Runtime, Value};
105use std::cell::RefCell;
106use std::collections::HashMap;
107use std::path::{Path, PathBuf};
108use std::rc::Rc;
109use std::sync::{mpsc, Arc, RwLock};
110
111type PluginApiExports =
115 Rc<RefCell<HashMap<String, (String, rquickjs::Persistent<rquickjs::Object<'static>>)>>>;
116
117fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
119 std::fs::create_dir_all(dst)?;
120 for entry in std::fs::read_dir(src)? {
121 let entry = entry?;
122 let file_type = entry.file_type()?;
123 let src_path = entry.path();
124 let dst_path = dst.join(entry.file_name());
125 if file_type.is_dir() {
126 copy_dir_recursive(&src_path, &dst_path)?;
127 } else {
128 std::fs::copy(&src_path, &dst_path)?;
129 }
130 }
131 Ok(())
132}
133
134#[allow(clippy::only_used_in_recursion)]
136fn js_to_json(ctx: &rquickjs::Ctx<'_>, val: Value<'_>) -> serde_json::Value {
137 use rquickjs::Type;
138 match val.type_of() {
139 Type::Null | Type::Undefined | Type::Uninitialized => serde_json::Value::Null,
140 Type::Bool => val
141 .as_bool()
142 .map(serde_json::Value::Bool)
143 .unwrap_or(serde_json::Value::Null),
144 Type::Int => val
145 .as_int()
146 .map(|n| serde_json::Value::Number(n.into()))
147 .unwrap_or(serde_json::Value::Null),
148 Type::Float => val
149 .as_float()
150 .map(|f| {
151 if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
154 serde_json::Value::Number((f as i64).into())
155 } else {
156 serde_json::Number::from_f64(f)
157 .map(serde_json::Value::Number)
158 .unwrap_or(serde_json::Value::Null)
159 }
160 })
161 .unwrap_or(serde_json::Value::Null),
162 Type::String => val
163 .as_string()
164 .and_then(|s| s.to_string().ok())
165 .map(serde_json::Value::String)
166 .unwrap_or(serde_json::Value::Null),
167 Type::Array => {
168 if let Some(arr) = val.as_array() {
169 let items: Vec<serde_json::Value> = arr
170 .iter()
171 .filter_map(|item| item.ok())
172 .map(|item| js_to_json(ctx, item))
173 .collect();
174 serde_json::Value::Array(items)
175 } else {
176 serde_json::Value::Null
177 }
178 }
179 Type::Object | Type::Constructor | Type::Function => {
180 if let Some(obj) = val.as_object() {
181 let mut map = serde_json::Map::new();
182 for key in obj.keys::<String>().flatten() {
183 if let Ok(v) = obj.get::<_, Value>(&key) {
184 map.insert(key, js_to_json(ctx, v));
185 }
186 }
187 serde_json::Value::Object(map)
188 } else {
189 serde_json::Value::Null
190 }
191 }
192 _ => serde_json::Value::Null,
193 }
194}
195
196fn json_to_js_value<'js>(
198 ctx: &rquickjs::Ctx<'js>,
199 val: &serde_json::Value,
200) -> rquickjs::Result<Value<'js>> {
201 match val {
202 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
203 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
204 serde_json::Value::Number(n) => {
205 if let Some(i) = n.as_i64() {
206 Ok(Value::new_int(ctx.clone(), i as i32))
207 } else if let Some(f) = n.as_f64() {
208 Ok(Value::new_float(ctx.clone(), f))
209 } else {
210 Ok(Value::new_null(ctx.clone()))
211 }
212 }
213 serde_json::Value::String(s) => {
214 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
215 Ok(js_str.into_value())
216 }
217 serde_json::Value::Array(arr) => {
218 let js_arr = rquickjs::Array::new(ctx.clone())?;
219 for (i, item) in arr.iter().enumerate() {
220 let js_val = json_to_js_value(ctx, item)?;
221 js_arr.set(i, js_val)?;
222 }
223 Ok(js_arr.into_value())
224 }
225 serde_json::Value::Object(map) => {
226 let obj = rquickjs::Object::new(ctx.clone())?;
227 for (key, val) in map {
228 let js_val = json_to_js_value(ctx, val)?;
229 obj.set(key.as_str(), js_val)?;
230 }
231 Ok(obj.into_value())
232 }
233 }
234}
235
236fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
239 let js_data = match json_to_js_value(ctx, event_data) {
240 Ok(v) => v,
241 Err(e) => {
242 log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
243 return;
244 }
245 };
246
247 let globals = ctx.globals();
248 let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
249 return;
250 };
251
252 match func.call::<_, rquickjs::Value>((js_data,)) {
253 Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
254 Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
255 }
256
257 run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
258}
259
260fn attach_promise_catch<'js>(
262 ctx: &rquickjs::Ctx<'js>,
263 globals: &rquickjs::Object<'js>,
264 handler_name: &str,
265 result: rquickjs::Value<'js>,
266) {
267 let Some(obj) = result.as_object() else {
268 return;
269 };
270 if obj.get::<_, rquickjs::Function>("then").is_err() {
271 return;
272 }
273 let _ = globals.set("__pendingPromise", result);
274 let catch_code = format!(
275 r#"globalThis.__pendingPromise.catch(function(e) {{
276 console.error('Handler {} async error:', e);
277 throw e;
278 }}); delete globalThis.__pendingPromise;"#,
279 handler_name
280 );
281 let _ = ctx.eval::<(), _>(catch_code.as_bytes());
282}
283
284fn get_text_properties_at_cursor_typed(
286 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
287 buffer_id: u32,
288) -> fresh_core::api::TextPropertiesAtCursor {
289 use fresh_core::api::TextPropertiesAtCursor;
290
291 let snap = match snapshot.read() {
292 Ok(s) => s,
293 Err(_) => return TextPropertiesAtCursor(Vec::new()),
294 };
295 let buffer_id_typed = BufferId(buffer_id as usize);
296 let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
297 let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
298 snap.primary_cursor.as_ref().map(|c| c.position)
299 } else {
300 None
301 };
302 let cursor_pos = match snapshot_pos.or(fallback_pos) {
303 Some(pos) => pos,
304 None => {
305 tracing::debug!(
306 "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
307 buffer_id_typed,
308 snapshot_pos,
309 snap.active_buffer_id
310 );
311 return TextPropertiesAtCursor(Vec::new());
312 }
313 };
314
315 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
316 Some(p) => p,
317 None => {
318 tracing::debug!(
319 "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
320 buffer_id_typed,
321 cursor_pos
322 );
323 return TextPropertiesAtCursor(Vec::new());
324 }
325 };
326
327 let result: Vec<_> = properties
328 .iter()
329 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
330 .map(|prop| prop.properties.clone())
331 .collect();
332
333 tracing::debug!(
334 "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
335 buffer_id_typed,
336 cursor_pos,
337 snapshot_pos,
338 fallback_pos,
339 snap.active_buffer_id,
340 properties.len(),
341 result.len()
342 );
343
344 TextPropertiesAtCursor(result)
345}
346
347fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
349 use rquickjs::Type;
350 match val.type_of() {
351 Type::Null => "null".to_string(),
352 Type::Undefined => "undefined".to_string(),
353 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
354 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
355 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
356 Type::String => val
357 .as_string()
358 .and_then(|s| s.to_string().ok())
359 .unwrap_or_default(),
360 Type::Object | Type::Exception => {
361 if let Some(obj) = val.as_object() {
363 let name: Option<String> = obj.get("name").ok();
365 let message: Option<String> = obj.get("message").ok();
366 let stack: Option<String> = obj.get("stack").ok();
367
368 if message.is_some() || name.is_some() {
369 let name = name.unwrap_or_else(|| "Error".to_string());
371 let message = message.unwrap_or_default();
372 if let Some(stack) = stack {
373 return format!("{}: {}\n{}", name, message, stack);
374 } else {
375 return format!("{}: {}", name, message);
376 }
377 }
378
379 let json = js_to_json(ctx, val.clone());
381 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
382 } else {
383 "[object]".to_string()
384 }
385 }
386 Type::Array => {
387 let json = js_to_json(ctx, val.clone());
388 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
389 }
390 Type::Function | Type::Constructor => "[function]".to_string(),
391 Type::Symbol => "[symbol]".to_string(),
392 Type::BigInt => val
393 .as_big_int()
394 .and_then(|b| b.clone().to_i64().ok())
395 .map(|n| n.to_string())
396 .unwrap_or_else(|| "[bigint]".to_string()),
397 _ => format!("[{}]", val.type_name()),
398 }
399}
400
401fn format_js_error(
403 ctx: &rquickjs::Ctx<'_>,
404 err: rquickjs::Error,
405 source_name: &str,
406) -> anyhow::Error {
407 if err.is_exception() {
409 let exc = ctx.catch();
411 if !exc.is_undefined() && !exc.is_null() {
412 if let Some(exc_obj) = exc.as_object() {
414 let message: String = exc_obj
415 .get::<_, String>("message")
416 .unwrap_or_else(|_| "Unknown error".to_string());
417 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
418 let name: String = exc_obj
419 .get::<_, String>("name")
420 .unwrap_or_else(|_| "Error".to_string());
421
422 if !stack.is_empty() {
423 return anyhow::anyhow!(
424 "JS error in {}: {}: {}\nStack trace:\n{}",
425 source_name,
426 name,
427 message,
428 stack
429 );
430 } else {
431 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
432 }
433 } else {
434 let exc_str: String = exc
436 .as_string()
437 .and_then(|s: &rquickjs::String| s.to_string().ok())
438 .unwrap_or_else(|| format!("{:?}", exc));
439 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
440 }
441 }
442 }
443
444 anyhow::anyhow!("JS error in {}: {}", source_name, err)
446}
447
448fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
451 let error = format_js_error(ctx, err, context);
452 tracing::error!("{}", error);
453
454 if should_panic_on_js_errors() {
456 panic!("JavaScript error in {}: {}", context, error);
457 }
458}
459
460static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
462 std::sync::atomic::AtomicBool::new(false);
463
464pub fn set_panic_on_js_errors(enabled: bool) {
466 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
467}
468
469fn should_panic_on_js_errors() -> bool {
471 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
472}
473
474static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
478
479static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
481
482fn set_fatal_js_error(msg: String) {
484 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
485 if guard.is_none() {
486 *guard = Some(msg);
488 }
489 }
490 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
491}
492
493pub fn has_fatal_js_error() -> bool {
495 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
496}
497
498pub fn take_fatal_js_error() -> Option<String> {
500 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
501 return None;
502 }
503 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
504 guard.take()
505 } else {
506 Some("Fatal JS error (message unavailable)".to_string())
507 }
508}
509
510fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
513 let mut count = 0;
514 loop {
515 let exc: rquickjs::Value = ctx.catch();
517 if exc.is_exception() {
519 let error_msg = if let Some(err) = exc.as_exception() {
520 format!(
521 "{}: {}",
522 err.message().unwrap_or_default(),
523 err.stack().unwrap_or_default()
524 )
525 } else {
526 format!("{:?}", exc)
527 };
528 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
529 if should_panic_on_js_errors() {
530 panic!("Unhandled JS exception during {}: {}", context, error_msg);
531 }
532 }
533
534 if !ctx.execute_pending_job() {
535 break;
536 }
537 count += 1;
538 }
539
540 let exc: rquickjs::Value = ctx.catch();
542 if exc.is_exception() {
543 let error_msg = if let Some(err) = exc.as_exception() {
544 format!(
545 "{}: {}",
546 err.message().unwrap_or_default(),
547 err.stack().unwrap_or_default()
548 )
549 } else {
550 format!("{:?}", exc)
551 };
552 tracing::error!(
553 "Unhandled JS exception after running jobs in {}: {}",
554 context,
555 error_msg
556 );
557 if should_panic_on_js_errors() {
558 panic!(
559 "Unhandled JS exception after running jobs in {}: {}",
560 context, error_msg
561 );
562 }
563 }
564
565 count
566}
567
568fn parse_text_property_entry(
570 ctx: &rquickjs::Ctx<'_>,
571 obj: &Object<'_>,
572) -> Option<TextPropertyEntry> {
573 let text: String = obj.get("text").ok()?;
574 let properties: HashMap<String, serde_json::Value> = obj
575 .get::<_, Object>("properties")
576 .ok()
577 .map(|props_obj| {
578 let mut map = HashMap::new();
579 for key in props_obj.keys::<String>().flatten() {
580 if let Ok(v) = props_obj.get::<_, Value>(&key) {
581 map.insert(key, js_to_json(ctx, v));
582 }
583 }
584 map
585 })
586 .unwrap_or_default();
587
588 let style: Option<fresh_core::api::OverlayOptions> =
590 obj.get::<_, Object>("style").ok().and_then(|style_obj| {
591 let json_val = js_to_json(ctx, Value::from_object(style_obj));
592 serde_json::from_value(json_val).ok()
593 });
594
595 let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
597 .get::<_, rquickjs::Array>("inlineOverlays")
598 .ok()
599 .map(|arr| {
600 arr.iter::<Object>()
601 .flatten()
602 .filter_map(|item| {
603 let json_val = js_to_json(ctx, Value::from_object(item));
604 serde_json::from_value(json_val).ok()
605 })
606 .collect()
607 })
608 .unwrap_or_default();
609
610 let pad_to_chars: Option<u32> = obj
611 .get::<_, f64>("padToChars")
612 .ok()
613 .map(|v| v.max(0.0) as u32);
614 let truncate_to_chars: Option<u32> = obj
615 .get::<_, f64>("truncateToChars")
616 .ok()
617 .map(|v| v.max(0.0) as u32);
618
619 let segments: Vec<fresh_core::text_property::StyledSegment> = obj
620 .get::<_, rquickjs::Array>("segments")
621 .ok()
622 .map(|arr| {
623 arr.iter::<Object>()
624 .flatten()
625 .filter_map(|item| {
626 let json_val = js_to_json(ctx, Value::from_object(item));
627 serde_json::from_value(json_val).ok()
628 })
629 .collect()
630 })
631 .unwrap_or_default();
632
633 Some(TextPropertyEntry {
634 text,
635 properties,
636 style,
637 inline_overlays,
638 segments,
639 pad_to_chars,
640 truncate_to_chars,
641 })
642}
643
644pub type PendingResponses =
646 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
647
648#[derive(Debug, Clone)]
650pub struct TsPluginInfo {
651 pub name: String,
652 pub path: PathBuf,
653 pub enabled: bool,
654 pub declarations: Option<String>,
661}
662
663#[derive(Debug, Clone, Default)]
669pub struct PluginTrackedState {
670 pub overlay_namespaces: Vec<(BufferId, String)>,
672 pub virtual_line_namespaces: Vec<(BufferId, String)>,
674 pub line_indicator_namespaces: Vec<(BufferId, String)>,
676 pub virtual_text_ids: Vec<(BufferId, String)>,
678 pub file_explorer_namespaces: Vec<String>,
680 pub contexts_set: Vec<String>,
682 pub background_process_ids: Vec<u64>,
685 pub scroll_sync_group_ids: Vec<u32>,
687 pub virtual_buffer_ids: Vec<BufferId>,
689 pub composite_buffer_ids: Vec<BufferId>,
691 pub terminal_ids: Vec<fresh_core::TerminalId>,
693 pub watch_handles: Vec<u64>,
697}
698
699pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
704
705pub type EventHandlerRegistry = Arc<RwLock<HashMap<String, Vec<PluginHandler>>>>;
713
714#[derive(Debug, Clone)]
715pub struct PluginHandler {
716 pub plugin_name: String,
717 pub handler_name: String,
718}
719
720fn parse_animation_rect(
723 obj: &rquickjs::Object<'_>,
724) -> rquickjs::Result<fresh_core::api::AnimationRect> {
725 Ok(fresh_core::api::AnimationRect {
726 x: obj.get::<_, u16>("x").unwrap_or(0),
727 y: obj.get::<_, u16>("y").unwrap_or(0),
728 width: obj.get::<_, u16>("width").unwrap_or(0),
729 height: obj.get::<_, u16>("height").unwrap_or(0),
730 })
731}
732
733fn parse_animation_kind(
737 obj: &rquickjs::Object<'_>,
738) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
739 use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
740 let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
741 match kind.as_str() {
742 "slideIn" | "" => {
743 let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
744 let from = match from_str.as_str() {
745 "top" => PluginAnimationEdge::Top,
746 "left" => PluginAnimationEdge::Left,
747 "right" => PluginAnimationEdge::Right,
748 _ => PluginAnimationEdge::Bottom,
749 };
750 let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
751 let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
752 Ok(PluginAnimationKind::SlideIn {
753 from,
754 duration_ms,
755 delay_ms,
756 })
757 }
758 other => Err(rquickjs::Error::new_from_js_message(
759 "string",
760 "PluginAnimationKind",
761 format!("unknown animation kind: {}", other),
762 )),
763 }
764}
765
766#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
769#[rquickjs::class]
770pub struct JsEditorApi {
771 #[qjs(skip_trace)]
772 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
773 #[qjs(skip_trace)]
774 command_sender: mpsc::Sender<PluginCommand>,
775 #[qjs(skip_trace)]
776 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
777 #[qjs(skip_trace)]
778 event_handlers: EventHandlerRegistry,
779 #[qjs(skip_trace)]
780 next_request_id: Rc<RefCell<u64>>,
781 #[qjs(skip_trace)]
782 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
783 #[qjs(skip_trace)]
784 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
785 #[qjs(skip_trace)]
786 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
787 #[qjs(skip_trace)]
788 async_resource_owners: AsyncResourceOwners,
789 #[qjs(skip_trace)]
791 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
792 #[qjs(skip_trace)]
794 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
795 #[qjs(skip_trace)]
797 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
798 #[qjs(skip_trace)]
800 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
801 #[qjs(skip_trace)]
805 plugin_api_exports: PluginApiExports,
806 #[qjs(skip_trace)]
810 search_handles: SearchHandleRegistry,
811 pub plugin_name: String,
812}
813
814fn throw_js<'js>(ctx: &rquickjs::Ctx<'js>, msg: &str) -> rquickjs::Error {
819 match rquickjs::String::from_str(ctx.clone(), msg) {
820 Ok(s) => ctx.throw(s.into_value()),
821 Err(e) => e,
822 }
823}
824
825fn parse_options<'js>(
826 ctx: &rquickjs::Ctx<'js>,
827 method: &str,
828 field: &str,
829 options: rquickjs::Object<'js>,
830) -> rquickjs::Result<serde_json::Map<String, serde_json::Value>> {
831 let value: serde_json::Value = rquickjs_serde::from_value(options.into_value())
832 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
833 match value {
834 serde_json::Value::Object(m) => Ok(m),
835 _ => Err(throw_js(
836 ctx,
837 &format!("{}(\"{}\"): options must be an object", method, field),
838 )),
839 }
840}
841
842fn validate_allowed_keys<'js>(
843 ctx: &rquickjs::Ctx<'js>,
844 method: &str,
845 field: &str,
846 opts: &serde_json::Map<String, serde_json::Value>,
847 allowed: &[&str],
848) -> rquickjs::Result<()> {
849 for k in opts.keys() {
850 if !allowed.contains(&k.as_str()) {
851 return Err(throw_js(
852 ctx,
853 &format!(
854 "{}(\"{}\"): unknown option `{}` (allowed: {})",
855 method,
856 field,
857 k,
858 allowed.join(", "),
859 ),
860 ));
861 }
862 }
863 Ok(())
864}
865
866fn string_opt(opts: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
867 opts.get(key)
868 .and_then(|v| v.as_str())
869 .map(|s| s.to_string())
870}
871
872fn require_integer<'js>(
873 ctx: &rquickjs::Ctx<'js>,
874 method: &str,
875 field: &str,
876 opts: &serde_json::Map<String, serde_json::Value>,
877 key: &str,
878) -> rquickjs::Result<i64> {
879 match opts.get(key) {
880 Some(v) => v.as_i64().ok_or_else(|| {
881 throw_js(
882 ctx,
883 &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
884 )
885 }),
886 None => Err(throw_js(
887 ctx,
888 &format!("{}(\"{}\"): `{}` is required", method, field, key),
889 )),
890 }
891}
892
893fn optional_integer<'js>(
894 ctx: &rquickjs::Ctx<'js>,
895 method: &str,
896 field: &str,
897 opts: &serde_json::Map<String, serde_json::Value>,
898 key: &str,
899) -> rquickjs::Result<Option<i64>> {
900 match opts.get(key) {
901 None => Ok(None),
902 Some(v) => v.as_i64().map(Some).ok_or_else(|| {
903 throw_js(
904 ctx,
905 &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
906 )
907 }),
908 }
909}
910
911fn require_number<'js>(
912 ctx: &rquickjs::Ctx<'js>,
913 method: &str,
914 field: &str,
915 opts: &serde_json::Map<String, serde_json::Value>,
916 key: &str,
917) -> rquickjs::Result<f64> {
918 match opts.get(key) {
919 Some(v) => v.as_f64().ok_or_else(|| {
920 throw_js(
921 ctx,
922 &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
923 )
924 }),
925 None => Err(throw_js(
926 ctx,
927 &format!("{}(\"{}\"): `{}` is required", method, field, key),
928 )),
929 }
930}
931
932fn optional_number<'js>(
933 ctx: &rquickjs::Ctx<'js>,
934 method: &str,
935 field: &str,
936 opts: &serde_json::Map<String, serde_json::Value>,
937 key: &str,
938) -> rquickjs::Result<Option<f64>> {
939 match opts.get(key) {
940 None => Ok(None),
941 Some(v) => v.as_f64().map(Some).ok_or_else(|| {
942 throw_js(
943 ctx,
944 &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
945 )
946 }),
947 }
948}
949
950fn check_range<'js>(
951 ctx: &rquickjs::Ctx<'js>,
952 method: &str,
953 field: &str,
954 default: f64,
955 minimum: Option<f64>,
956 maximum: Option<f64>,
957) -> rquickjs::Result<()> {
958 if let Some(min) = minimum {
959 if default < min {
960 return Err(throw_js(
961 ctx,
962 &format!(
963 "{}(\"{}\"): default ({}) is below minimum ({})",
964 method, field, default, min
965 ),
966 ));
967 }
968 }
969 if let Some(max) = maximum {
970 if default > max {
971 return Err(throw_js(
972 ctx,
973 &format!(
974 "{}(\"{}\"): default ({}) is above maximum ({})",
975 method, field, default, max
976 ),
977 ));
978 }
979 }
980 if let (Some(min), Some(max)) = (minimum, maximum) {
981 if min > max {
982 return Err(throw_js(
983 ctx,
984 &format!(
985 "{}(\"{}\"): minimum ({}) is greater than maximum ({})",
986 method, field, min, max
987 ),
988 ));
989 }
990 }
991 Ok(())
992}
993
994impl JsEditorApi {
997 fn send_field_registration(&self, field_name: &str, field_schema: serde_json::Value) {
999 let _ = self
1000 .command_sender
1001 .send(PluginCommand::AddPluginConfigField {
1002 plugin_name: self.plugin_name.clone(),
1003 field_name: field_name.to_string(),
1004 field_schema,
1005 });
1006 }
1007
1008 fn current_field_value(&self, field_name: &str) -> Option<serde_json::Value> {
1011 self.state_snapshot.read().ok().and_then(|s| {
1012 s.config
1013 .pointer(&format!(
1014 "/plugins/{}/settings/{}",
1015 self.plugin_name, field_name
1016 ))
1017 .cloned()
1018 })
1019 }
1020}
1021
1022#[plugin_api_impl]
1023#[rquickjs::methods(rename_all = "camelCase")]
1024impl JsEditorApi {
1025 pub fn api_version(&self) -> u32 {
1030 2
1031 }
1032
1033 pub fn plugin_name(&self) -> String {
1037 self.plugin_name.clone()
1038 }
1039
1040 #[plugin_api(ts_return = "boolean")]
1050 pub fn export_plugin_api<'js>(
1051 &self,
1052 ctx: rquickjs::Ctx<'js>,
1053 name: String,
1054 api: rquickjs::Value<'js>,
1055 ) -> rquickjs::Result<bool> {
1056 if name.is_empty() {
1057 let msg =
1058 rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
1059 return Err(ctx.throw(msg.into_value()));
1060 }
1061 let obj = match api.as_object() {
1062 Some(o) => o.clone(),
1063 None => {
1064 let msg = rquickjs::String::from_str(
1065 ctx.clone(),
1066 "exportPluginApi: api must be an object",
1067 )?;
1068 return Err(ctx.throw(msg.into_value()));
1069 }
1070 };
1071 let persistent = rquickjs::Persistent::save(&ctx, obj);
1072 self.plugin_api_exports
1073 .borrow_mut()
1074 .insert(name, (self.plugin_name.clone(), persistent));
1075 Ok(true)
1076 }
1077
1078 #[plugin_api(ts_return = "unknown | null")]
1082 pub fn get_plugin_api<'js>(
1083 &self,
1084 ctx: rquickjs::Ctx<'js>,
1085 name: String,
1086 ) -> rquickjs::Result<rquickjs::Value<'js>> {
1087 let persistent = self
1088 .plugin_api_exports
1089 .borrow()
1090 .get(&name)
1091 .map(|(_exporter, p)| p.clone());
1092 match persistent {
1093 Some(p) => {
1094 let restored = p.restore(&ctx)?;
1095 Ok(restored.into_value())
1096 }
1097 None => Ok(rquickjs::Value::new_null(ctx)),
1098 }
1099 }
1100
1101 pub fn get_active_buffer_id(&self) -> u32 {
1103 self.state_snapshot
1104 .read()
1105 .map(|s| s.active_buffer_id.0 as u32)
1106 .unwrap_or(0)
1107 }
1108
1109 pub fn get_active_split_id(&self) -> u32 {
1111 self.state_snapshot
1112 .read()
1113 .map(|s| s.active_split_id as u32)
1114 .unwrap_or(0)
1115 }
1116
1117 #[plugin_api(ts_return = "BufferInfo[]")]
1119 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1120 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
1121 s.buffers.values().cloned().collect()
1122 } else {
1123 Vec::new()
1124 };
1125 rquickjs_serde::to_value(ctx, &buffers)
1126 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1127 }
1128
1129 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
1131 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1132 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
1133 s.available_grammars.clone()
1134 } else {
1135 Vec::new()
1136 };
1137 rquickjs_serde::to_value(ctx, &grammars)
1138 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1139 }
1140
1141 pub fn debug(&self, msg: String) {
1144 tracing::debug!("Plugin: {}", msg);
1145 }
1146
1147 pub fn info(&self, msg: String) {
1148 tracing::info!("Plugin: {}", msg);
1149 }
1150
1151 pub fn warn(&self, msg: String) {
1152 tracing::warn!("Plugin: {}", msg);
1153 }
1154
1155 pub fn error(&self, msg: String) {
1156 tracing::error!("Plugin: {}", msg);
1157 }
1158
1159 pub fn set_status(&self, msg: String) {
1162 let _ = self
1163 .command_sender
1164 .send(PluginCommand::SetStatus { message: msg });
1165 }
1166
1167 pub fn copy_to_clipboard(&self, text: String) {
1170 let _ = self
1171 .command_sender
1172 .send(PluginCommand::SetClipboard { text });
1173 }
1174
1175 pub fn set_clipboard(&self, text: String) {
1176 let _ = self
1177 .command_sender
1178 .send(PluginCommand::SetClipboard { text });
1179 }
1180
1181 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
1186 if let Some(mode_name) = mode {
1187 let key = format!("{}\0{}", action, mode_name);
1188 if let Ok(snapshot) = self.state_snapshot.read() {
1189 return snapshot.keybinding_labels.get(&key).cloned();
1190 }
1191 }
1192 None
1193 }
1194
1195 pub fn register_command<'js>(
1206 &self,
1207 ctx: rquickjs::Ctx<'js>,
1208 name: String,
1209 description: String,
1210 handler_name: String,
1211 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
1212 rquickjs::Value<'js>,
1213 >,
1214 #[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
1215 options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1216 ) -> rquickjs::Result<bool> {
1217 let plugin_name = self.plugin_name.clone();
1219 let context_str: Option<String> = context.0.and_then(|v| {
1221 if v.is_null() || v.is_undefined() {
1222 None
1223 } else {
1224 v.as_string().and_then(|s| s.to_string().ok())
1225 }
1226 });
1227
1228 tracing::debug!(
1229 "registerCommand: plugin='{}', name='{}', handler='{}'",
1230 plugin_name,
1231 name,
1232 handler_name
1233 );
1234
1235 let tracking_key = if name.starts_with('%') {
1239 format!("{}:{}", plugin_name, name)
1240 } else {
1241 name.clone()
1242 };
1243 {
1244 let names = self.registered_command_names.borrow();
1245 if let Some(existing_plugin) = names.get(&tracking_key) {
1246 if existing_plugin != &plugin_name {
1247 let msg = format!(
1248 "Command '{}' already registered by plugin '{}'",
1249 name, existing_plugin
1250 );
1251 tracing::warn!("registerCommand collision: {}", msg);
1252 return Err(
1253 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1254 );
1255 }
1256 }
1258 }
1259
1260 self.registered_command_names
1262 .borrow_mut()
1263 .insert(tracking_key, plugin_name.clone());
1264
1265 self.registered_actions.borrow_mut().insert(
1267 handler_name.clone(),
1268 PluginHandler {
1269 plugin_name: self.plugin_name.clone(),
1270 handler_name: handler_name.clone(),
1271 },
1272 );
1273
1274 let terminal_bypass: bool = options
1278 .0
1279 .and_then(|v| {
1280 if v.is_null() || v.is_undefined() {
1281 None
1282 } else {
1283 v.into_object()
1284 .and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
1285 }
1286 })
1287 .unwrap_or(false);
1288
1289 let command = Command {
1291 name: name.clone(),
1292 description,
1293 action_name: handler_name,
1294 plugin_name,
1295 custom_contexts: context_str.into_iter().collect(),
1296 terminal_bypass,
1297 };
1298
1299 Ok(self
1300 .command_sender
1301 .send(PluginCommand::RegisterCommand { command })
1302 .is_ok())
1303 }
1304
1305 pub fn unregister_command(&self, name: String) -> bool {
1307 let tracking_key = if name.starts_with('%') {
1310 format!("{}:{}", self.plugin_name, name)
1311 } else {
1312 name.clone()
1313 };
1314 self.registered_command_names
1315 .borrow_mut()
1316 .remove(&tracking_key);
1317 self.command_sender
1318 .send(PluginCommand::UnregisterCommand { name })
1319 .is_ok()
1320 }
1321
1322 pub fn set_context(&self, name: String, active: bool) -> bool {
1324 if active {
1326 self.plugin_tracked_state
1327 .borrow_mut()
1328 .entry(self.plugin_name.clone())
1329 .or_default()
1330 .contexts_set
1331 .push(name.clone());
1332 }
1333 self.command_sender
1334 .send(PluginCommand::SetContext { name, active })
1335 .is_ok()
1336 }
1337
1338 pub fn execute_action(&self, action_name: String) -> bool {
1340 self.command_sender
1341 .send(PluginCommand::ExecuteAction { action_name })
1342 .is_ok()
1343 }
1344
1345 pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
1349 let plugin_name = self.plugin_name.clone();
1350 self.command_sender
1351 .send(PluginCommand::RegisterStatusBarElement {
1352 plugin_name,
1353 token_name,
1354 title,
1355 })
1356 .is_ok()
1357 }
1358
1359 pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
1362 let key = format!("{}:{}", self.plugin_name, token_name);
1363 self.command_sender
1364 .send(PluginCommand::SetStatusBarValue {
1365 buffer_id,
1366 key,
1367 value,
1368 })
1369 .is_ok()
1370 }
1371
1372 pub fn t<'js>(
1377 &self,
1378 _ctx: rquickjs::Ctx<'js>,
1379 key: String,
1380 args: rquickjs::function::Rest<Value<'js>>,
1381 ) -> String {
1382 let plugin_name = self.plugin_name.clone();
1384 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1386 if let Some(obj) = first_arg.as_object() {
1387 let mut map = HashMap::new();
1388 for k in obj.keys::<String>().flatten() {
1389 if let Ok(v) = obj.get::<_, String>(&k) {
1390 map.insert(k, v);
1391 }
1392 }
1393 map
1394 } else {
1395 HashMap::new()
1396 }
1397 } else {
1398 HashMap::new()
1399 };
1400 let res = self.services.translate(&plugin_name, &key, &args_map);
1401
1402 tracing::info!(
1403 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1404 key,
1405 plugin_name,
1406 args_map,
1407 res
1408 );
1409 res
1410 }
1411
1412 pub fn get_cursor_position(&self) -> u32 {
1416 self.state_snapshot
1417 .read()
1418 .ok()
1419 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1420 .unwrap_or(0)
1421 }
1422
1423 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1425 if let Ok(s) = self.state_snapshot.read() {
1426 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1427 if let Some(p) = &b.path {
1428 return p.to_string_lossy().to_string();
1429 }
1430 }
1431 }
1432 String::new()
1433 }
1434
1435 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1437 if let Ok(s) = self.state_snapshot.read() {
1438 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1439 return b.length as u32;
1440 }
1441 }
1442 0
1443 }
1444
1445 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1447 if let Ok(s) = self.state_snapshot.read() {
1448 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1449 return b.modified;
1450 }
1451 }
1452 false
1453 }
1454
1455 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1458 self.command_sender
1459 .send(PluginCommand::SaveBufferToPath {
1460 buffer_id: BufferId(buffer_id as usize),
1461 path: std::path::PathBuf::from(path),
1462 })
1463 .is_ok()
1464 }
1465
1466 #[plugin_api(ts_return = "BufferInfo | null")]
1468 pub fn get_buffer_info<'js>(
1469 &self,
1470 ctx: rquickjs::Ctx<'js>,
1471 buffer_id: u32,
1472 ) -> rquickjs::Result<Value<'js>> {
1473 let info = if let Ok(s) = self.state_snapshot.read() {
1474 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1475 } else {
1476 None
1477 };
1478 rquickjs_serde::to_value(ctx, &info)
1479 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1480 }
1481
1482 #[plugin_api(ts_return = "CursorInfo | null")]
1484 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1485 let cursor = if let Ok(s) = self.state_snapshot.read() {
1486 s.primary_cursor.clone()
1487 } else {
1488 None
1489 };
1490 rquickjs_serde::to_value(ctx, &cursor)
1491 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1492 }
1493
1494 #[plugin_api(ts_return = "CursorInfo[]")]
1496 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1497 let cursors = if let Ok(s) = self.state_snapshot.read() {
1498 s.all_cursors.clone()
1499 } else {
1500 Vec::new()
1501 };
1502 rquickjs_serde::to_value(ctx, &cursors)
1503 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1504 }
1505
1506 #[plugin_api(ts_return = "number[]")]
1508 pub fn get_all_cursor_positions<'js>(
1509 &self,
1510 ctx: rquickjs::Ctx<'js>,
1511 ) -> rquickjs::Result<Value<'js>> {
1512 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1513 s.all_cursors.iter().map(|c| c.position as u32).collect()
1514 } else {
1515 Vec::new()
1516 };
1517 rquickjs_serde::to_value(ctx, &positions)
1518 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1519 }
1520
1521 #[plugin_api(ts_return = "ViewportInfo | null")]
1523 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1524 let viewport = if let Ok(s) = self.state_snapshot.read() {
1525 s.viewport.clone()
1526 } else {
1527 None
1528 };
1529 rquickjs_serde::to_value(ctx, &viewport)
1530 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1531 }
1532
1533 #[plugin_api(ts_return = "ScreenSize")]
1538 pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1539 let size = if let Ok(s) = self.state_snapshot.read() {
1540 fresh_core::api::ScreenSize {
1541 width: s.terminal_width,
1542 height: s.terminal_height,
1543 }
1544 } else {
1545 fresh_core::api::ScreenSize {
1546 width: 0,
1547 height: 0,
1548 }
1549 };
1550 rquickjs_serde::to_value(ctx, &size)
1551 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1552 }
1553
1554 #[plugin_api(ts_return = "SplitSnapshot[]")]
1561 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1562 let splits = if let Ok(s) = self.state_snapshot.read() {
1563 s.splits.clone()
1564 } else {
1565 Vec::new()
1566 };
1567 rquickjs_serde::to_value(ctx, &splits)
1568 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1569 }
1570
1571 pub fn get_cursor_line(&self) -> u32 {
1573 0
1577 }
1578
1579 #[plugin_api(
1582 async_promise,
1583 js_name = "getLineStartPosition",
1584 ts_return = "number | null"
1585 )]
1586 #[qjs(rename = "_getLineStartPositionStart")]
1587 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1588 let id = self.alloc_request_id();
1589 let _ = self
1591 .command_sender
1592 .send(PluginCommand::GetLineStartPosition {
1593 buffer_id: BufferId(0),
1594 line,
1595 request_id: id,
1596 });
1597 id
1598 }
1599
1600 #[plugin_api(
1604 async_promise,
1605 js_name = "getLineEndPosition",
1606 ts_return = "number | null"
1607 )]
1608 #[qjs(rename = "_getLineEndPositionStart")]
1609 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1610 let id = self.alloc_request_id();
1611 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1613 buffer_id: BufferId(0),
1614 line,
1615 request_id: id,
1616 });
1617 id
1618 }
1619
1620 #[plugin_api(
1623 async_promise,
1624 js_name = "getBufferLineCount",
1625 ts_return = "number | null"
1626 )]
1627 #[qjs(rename = "_getBufferLineCountStart")]
1628 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1629 let id = self.alloc_request_id();
1630 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1632 buffer_id: BufferId(0),
1633 request_id: id,
1634 });
1635 id
1636 }
1637
1638 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1641 self.command_sender
1642 .send(PluginCommand::ScrollToLineCenter {
1643 split_id: SplitId(split_id as usize),
1644 buffer_id: BufferId(buffer_id as usize),
1645 line: line as usize,
1646 })
1647 .is_ok()
1648 }
1649
1650 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1659 self.command_sender
1660 .send(PluginCommand::ScrollBufferToLine {
1661 buffer_id: BufferId(buffer_id as usize),
1662 line: line as usize,
1663 })
1664 .is_ok()
1665 }
1666
1667 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1669 let path_buf = std::path::PathBuf::from(&path);
1670 if let Ok(s) = self.state_snapshot.read() {
1671 for (id, info) in &s.buffers {
1672 if let Some(buf_path) = &info.path {
1673 if buf_path == &path_buf {
1674 return id.0 as u32;
1675 }
1676 }
1677 }
1678 }
1679 0
1680 }
1681
1682 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1684 pub fn get_buffer_saved_diff<'js>(
1685 &self,
1686 ctx: rquickjs::Ctx<'js>,
1687 buffer_id: u32,
1688 ) -> rquickjs::Result<Value<'js>> {
1689 let diff = if let Ok(s) = self.state_snapshot.read() {
1690 s.buffer_saved_diffs
1691 .get(&BufferId(buffer_id as usize))
1692 .cloned()
1693 } else {
1694 None
1695 };
1696 rquickjs_serde::to_value(ctx, &diff)
1697 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1698 }
1699
1700 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1704 self.command_sender
1705 .send(PluginCommand::InsertText {
1706 buffer_id: BufferId(buffer_id as usize),
1707 position: position as usize,
1708 text,
1709 })
1710 .is_ok()
1711 }
1712
1713 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1715 self.command_sender
1716 .send(PluginCommand::DeleteRange {
1717 buffer_id: BufferId(buffer_id as usize),
1718 range: (start as usize)..(end as usize),
1719 })
1720 .is_ok()
1721 }
1722
1723 pub fn insert_at_cursor(&self, text: String) -> bool {
1725 self.command_sender
1726 .send(PluginCommand::InsertAtCursor { text })
1727 .is_ok()
1728 }
1729
1730 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1734 self.command_sender
1735 .send(PluginCommand::OpenFileAtLocation {
1736 path: PathBuf::from(path),
1737 line: line.map(|l| l as usize),
1738 column: column.map(|c| c as usize),
1739 })
1740 .is_ok()
1741 }
1742
1743 pub fn open_file_in_background(
1751 &self,
1752 path: String,
1753 window_id: rquickjs::function::Opt<u64>,
1754 ) -> bool {
1755 self.command_sender
1756 .send(PluginCommand::OpenFileInBackground {
1757 path: PathBuf::from(path),
1758 window_id: window_id.0.map(fresh_core::WindowId),
1759 })
1760 .is_ok()
1761 }
1762
1763 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1765 self.command_sender
1766 .send(PluginCommand::OpenFileInSplit {
1767 split_id: split_id as usize,
1768 path: PathBuf::from(path),
1769 line: Some(line as usize),
1770 column: Some(column as usize),
1771 })
1772 .is_ok()
1773 }
1774
1775 #[plugin_api(
1784 async_promise,
1785 js_name = "openFileStreaming",
1786 ts_return = "number | null"
1787 )]
1788 #[qjs(rename = "_openFileStreamingStart")]
1789 pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
1790 let id = self.alloc_request_id();
1791 let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
1792 path: PathBuf::from(path),
1793 request_id: id,
1794 });
1795 id
1796 }
1797
1798 #[plugin_api(
1806 async_promise,
1807 js_name = "refreshBufferFromDisk",
1808 ts_return = "number | null"
1809 )]
1810 #[qjs(rename = "_refreshBufferFromDiskStart")]
1811 pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
1812 let id = self.alloc_request_id();
1813 let _ = self
1814 .command_sender
1815 .send(PluginCommand::RefreshBufferFromDisk {
1816 buffer_id: BufferId(buffer_id as usize),
1817 request_id: id,
1818 });
1819 id
1820 }
1821
1822 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1824 self.command_sender
1825 .send(PluginCommand::ShowBuffer {
1826 buffer_id: BufferId(buffer_id as usize),
1827 })
1828 .is_ok()
1829 }
1830
1831 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1833 self.command_sender
1834 .send(PluginCommand::CloseBuffer {
1835 buffer_id: BufferId(buffer_id as usize),
1836 })
1837 .is_ok()
1838 }
1839
1840 pub fn close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1842 self.command_sender
1843 .send(PluginCommand::CloseOtherBuffersInSplit {
1844 buffer_id: BufferId(buffer_id as usize),
1845 split_id: SplitId(split_id as usize),
1846 })
1847 .is_ok()
1848 }
1849
1850 pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
1852 self.command_sender
1853 .send(PluginCommand::CloseAllBuffersInSplit {
1854 split_id: SplitId(split_id as usize),
1855 })
1856 .is_ok()
1857 }
1858
1859 pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1861 self.command_sender
1862 .send(PluginCommand::CloseBuffersToRightInSplit {
1863 buffer_id: BufferId(buffer_id as usize),
1864 split_id: SplitId(split_id as usize),
1865 })
1866 .is_ok()
1867 }
1868
1869 pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1871 self.command_sender
1872 .send(PluginCommand::CloseBuffersToLeftInSplit {
1873 buffer_id: BufferId(buffer_id as usize),
1874 split_id: SplitId(split_id as usize),
1875 })
1876 .is_ok()
1877 }
1878
1879 #[plugin_api(ts_return = "boolean")]
1881 pub fn move_tab_to_left(&self) -> bool {
1882 self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
1883 }
1884
1885 #[plugin_api(ts_return = "boolean")]
1887 pub fn move_tab_to_right(&self) -> bool {
1888 self.command_sender
1889 .send(PluginCommand::MoveTabRight)
1890 .is_ok()
1891 }
1892
1893 #[plugin_api(skip)]
1899 #[qjs(skip)]
1900 fn alloc_request_id(&self) -> u64 {
1901 let mut id_ref = self.next_request_id.borrow_mut();
1902 let id = *id_ref;
1903 *id_ref += 1;
1904 self.callback_contexts
1905 .borrow_mut()
1906 .insert(id, self.plugin_name.clone());
1907 id
1908 }
1909
1910 #[plugin_api(skip)]
1914 #[qjs(skip)]
1915 fn alloc_animation_id(&self) -> u64 {
1916 let mut id_ref = self.next_request_id.borrow_mut();
1917 let id = *id_ref;
1918 *id_ref += 1;
1919 id
1920 }
1921
1922 pub fn animate_area<'js>(
1925 &self,
1926 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1927 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1928 ) -> rquickjs::Result<u64> {
1929 let rect = parse_animation_rect(&rect)?;
1930 let kind = parse_animation_kind(&kind)?;
1931 let id = self.alloc_animation_id();
1932 let _ = self
1933 .command_sender
1934 .send(PluginCommand::StartAnimationArea { id, rect, kind });
1935 Ok(id)
1936 }
1937
1938 pub fn animate_virtual_buffer<'js>(
1941 &self,
1942 buffer_id: u32,
1943 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1944 ) -> rquickjs::Result<u64> {
1945 let kind = parse_animation_kind(&kind)?;
1946 let id = self.alloc_animation_id();
1947 let _ = self
1948 .command_sender
1949 .send(PluginCommand::StartAnimationVirtualBuffer {
1950 id,
1951 buffer_id: BufferId(buffer_id as usize),
1952 kind,
1953 });
1954 Ok(id)
1955 }
1956
1957 pub fn cancel_animation(&self, id: u64) -> bool {
1960 self.command_sender
1961 .send(PluginCommand::CancelAnimation { id })
1962 .is_ok()
1963 }
1964
1965 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1969 if event_name == "lines_changed" {
1973 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1974 }
1975 self.event_handlers
1976 .write()
1977 .expect("event_handlers poisoned")
1978 .entry(event_name)
1979 .or_default()
1980 .push(PluginHandler {
1981 plugin_name: self.plugin_name.clone(),
1982 handler_name,
1983 });
1984 }
1985
1986 pub fn off(&self, event_name: String, handler_name: String) {
1988 if let Some(list) = self
1989 .event_handlers
1990 .write()
1991 .expect("event_handlers poisoned")
1992 .get_mut(&event_name)
1993 {
1994 list.retain(|h| h.handler_name != handler_name);
1995 }
1996 }
1997
1998 pub fn get_env(&self, name: String) -> Option<String> {
2002 std::env::var(&name).ok()
2003 }
2004
2005 pub fn get_cwd(&self) -> String {
2007 self.state_snapshot
2008 .read()
2009 .map(|s| s.working_dir.to_string_lossy().to_string())
2010 .unwrap_or_else(|_| ".".to_string())
2011 }
2012
2013 pub fn get_authority_label(&self) -> String {
2022 self.state_snapshot
2023 .read()
2024 .map(|s| s.authority_label.clone())
2025 .unwrap_or_default()
2026 }
2027
2028 pub fn workspace_trust_level(&self) -> String {
2033 self.state_snapshot
2034 .read()
2035 .map(|s| s.workspace_trust_level.clone())
2036 .unwrap_or_default()
2037 }
2038
2039 pub fn env_active(&self) -> bool {
2044 self.state_snapshot
2045 .read()
2046 .map(|s| s.env_active)
2047 .unwrap_or(false)
2048 }
2049
2050 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2062 let mut result_parts: Vec<String> = Vec::new();
2063 let mut leading_slashes: u8 = 0;
2065
2066 for part in &parts.0 {
2067 let normalized = part.replace('\\', "/");
2069
2070 let is_absolute = normalized.starts_with('/')
2072 || (normalized.len() >= 2
2073 && normalized
2074 .chars()
2075 .next()
2076 .map(|c| c.is_ascii_alphabetic())
2077 .unwrap_or(false)
2078 && normalized.chars().nth(1) == Some(':'));
2079
2080 if is_absolute {
2081 result_parts.clear();
2083 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2087 }
2088
2089 for segment in normalized.split('/') {
2091 if !segment.is_empty() && segment != "." {
2092 if segment == ".." {
2093 result_parts.pop();
2094 } else {
2095 result_parts.push(segment.to_string());
2096 }
2097 }
2098 }
2099 }
2100
2101 let joined = result_parts.join("/");
2103 let prefix = match leading_slashes {
2104 0 => "",
2105 1 => "/",
2106 _ => "//",
2107 };
2108
2109 if leading_slashes > 0 {
2110 format!("{}{}", prefix, joined)
2111 } else {
2112 joined
2113 }
2114 }
2115
2116 pub fn path_dirname(&self, path: String) -> String {
2118 Path::new(&path)
2119 .parent()
2120 .map(|p| p.to_string_lossy().to_string())
2121 .unwrap_or_default()
2122 }
2123
2124 pub fn path_basename(&self, path: String) -> String {
2126 Path::new(&path)
2127 .file_name()
2128 .map(|s| s.to_string_lossy().to_string())
2129 .unwrap_or_default()
2130 }
2131
2132 pub fn path_extname(&self, path: String) -> String {
2134 Path::new(&path)
2135 .extension()
2136 .map(|s| format!(".{}", s.to_string_lossy()))
2137 .unwrap_or_default()
2138 }
2139
2140 pub fn path_is_absolute(&self, path: String) -> bool {
2142 Path::new(&path).is_absolute()
2143 }
2144
2145 pub fn file_uri_to_path(&self, uri: String) -> String {
2149 fresh_core::file_uri::file_uri_to_path(&uri)
2150 .map(|p| p.to_string_lossy().to_string())
2151 .unwrap_or_default()
2152 }
2153
2154 pub fn path_to_file_uri(&self, path: String) -> String {
2158 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2159 }
2160
2161 pub fn utf8_byte_length(&self, text: String) -> u32 {
2169 text.len() as u32
2170 }
2171
2172 pub fn file_exists(&self, path: String) -> bool {
2176 Path::new(&path).exists()
2177 }
2178
2179 pub fn read_file(&self, path: String) -> Option<String> {
2181 std::fs::read_to_string(&path).ok()
2182 }
2183
2184 pub fn write_file(&self, path: String, content: String) -> bool {
2186 let p = Path::new(&path);
2187 if let Some(parent) = p.parent() {
2188 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2189 return false;
2190 }
2191 }
2192 std::fs::write(p, content).is_ok()
2193 }
2194
2195 #[plugin_api(ts_return = "DirEntry[]")]
2197 pub fn read_dir<'js>(
2198 &self,
2199 ctx: rquickjs::Ctx<'js>,
2200 path: String,
2201 ) -> rquickjs::Result<Value<'js>> {
2202 use fresh_core::api::DirEntry;
2203
2204 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2205 Ok(entries) => entries
2206 .filter_map(|e| e.ok())
2207 .map(|entry| {
2208 let file_type = entry.file_type().ok();
2209 DirEntry {
2210 name: entry.file_name().to_string_lossy().to_string(),
2211 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2212 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2213 }
2214 })
2215 .collect(),
2216 Err(e) => {
2217 tracing::warn!("readDir failed for '{}': {}", path, e);
2218 Vec::new()
2219 }
2220 };
2221
2222 rquickjs_serde::to_value(ctx, &entries)
2223 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2224 }
2225
2226 pub fn create_dir(&self, path: String) -> bool {
2229 let p = Path::new(&path);
2230 if p.is_dir() {
2231 return true;
2232 }
2233 std::fs::create_dir_all(p).is_ok()
2234 }
2235
2236 pub fn remove_path(&self, path: String) -> bool {
2240 let target = match Path::new(&path).canonicalize() {
2241 Ok(p) => p,
2242 Err(_) => return false, };
2244
2245 let temp_dir = std::env::temp_dir()
2251 .canonicalize()
2252 .unwrap_or_else(|_| std::env::temp_dir());
2253 let config_dir = self
2254 .services
2255 .config_dir()
2256 .canonicalize()
2257 .unwrap_or_else(|_| self.services.config_dir());
2258
2259 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2261 if !allowed {
2262 tracing::warn!(
2263 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2264 target,
2265 temp_dir,
2266 config_dir
2267 );
2268 return false;
2269 }
2270
2271 if target == temp_dir || target == config_dir {
2273 tracing::warn!(
2274 "removePath refused: cannot remove root directory {:?}",
2275 target
2276 );
2277 return false;
2278 }
2279
2280 match trash::delete(&target) {
2281 Ok(()) => true,
2282 Err(e) => {
2283 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2284 false
2285 }
2286 }
2287 }
2288
2289 pub fn rename_path(&self, from: String, to: String) -> bool {
2292 if std::fs::rename(&from, &to).is_ok() {
2294 return true;
2295 }
2296 let from_path = Path::new(&from);
2298 let copied = if from_path.is_dir() {
2299 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2300 } else {
2301 std::fs::copy(&from, &to).is_ok()
2302 };
2303 if copied {
2304 return trash::delete(from_path).is_ok();
2305 }
2306 false
2307 }
2308
2309 pub fn copy_path(&self, from: String, to: String) -> bool {
2312 let from_path = Path::new(&from);
2313 let to_path = Path::new(&to);
2314 if from_path.is_dir() {
2315 copy_dir_recursive(from_path, to_path).is_ok()
2316 } else {
2317 if let Some(parent) = to_path.parent() {
2319 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2320 return false;
2321 }
2322 }
2323 std::fs::copy(from_path, to_path).is_ok()
2324 }
2325 }
2326
2327 pub fn get_temp_dir(&self) -> String {
2329 std::env::temp_dir().to_string_lossy().to_string()
2330 }
2331
2332 #[plugin_api(ts_return = "unknown")]
2343 pub fn parse_jsonc<'js>(
2344 &self,
2345 ctx: rquickjs::Ctx<'js>,
2346 text: String,
2347 ) -> rquickjs::Result<Value<'js>> {
2348 let value: serde_json::Value =
2349 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2350 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2351 })?;
2352 rquickjs_serde::to_value(ctx, &value)
2353 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2354 }
2355
2356 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2365 let config = self
2366 .state_snapshot
2367 .read()
2368 .map(|s| std::sync::Arc::clone(&s.config))
2369 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2370
2371 rquickjs_serde::to_value(ctx, &*config)
2372 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2373 }
2374
2375 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2377 let config = self
2378 .state_snapshot
2379 .read()
2380 .map(|s| std::sync::Arc::clone(&s.user_config))
2381 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2382
2383 rquickjs_serde::to_value(ctx, &*config)
2384 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2385 }
2386
2387 #[plugin_api(ts_return = "boolean")]
2395 pub fn define_config_boolean<'js>(
2396 &self,
2397 ctx: rquickjs::Ctx<'js>,
2398 name: String,
2399 #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2400 options: rquickjs::Object<'js>,
2401 ) -> rquickjs::Result<bool> {
2402 let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2403 validate_allowed_keys(
2404 &ctx,
2405 "defineConfigBoolean",
2406 &name,
2407 &opts,
2408 &["default", "description"],
2409 )?;
2410 let default = match opts.get("default") {
2411 Some(serde_json::Value::Bool(b)) => *b,
2412 _ => {
2413 return Err(throw_js(
2414 &ctx,
2415 &format!(
2416 "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2417 name
2418 ),
2419 ));
2420 }
2421 };
2422 let description = string_opt(&opts, "description");
2423 let mut field = serde_json::Map::new();
2424 field.insert("type".into(), serde_json::json!("boolean"));
2425 field.insert("default".into(), serde_json::json!(default));
2426 if let Some(d) = description {
2427 field.insert("description".into(), serde_json::json!(d));
2428 }
2429 self.send_field_registration(&name, serde_json::Value::Object(field));
2430 Ok(self
2431 .current_field_value(&name)
2432 .and_then(|v| v.as_bool())
2433 .unwrap_or(default))
2434 }
2435
2436 #[plugin_api(ts_return = "number")]
2439 pub fn define_config_integer<'js>(
2440 &self,
2441 ctx: rquickjs::Ctx<'js>,
2442 name: String,
2443 #[plugin_api(
2444 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2445 )]
2446 options: rquickjs::Object<'js>,
2447 ) -> rquickjs::Result<i64> {
2448 let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2449 validate_allowed_keys(
2450 &ctx,
2451 "defineConfigInteger",
2452 &name,
2453 &opts,
2454 &["default", "description", "minimum", "maximum"],
2455 )?;
2456 let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2457 let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2458 let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2459 check_range(
2460 &ctx,
2461 "defineConfigInteger",
2462 &name,
2463 default as f64,
2464 minimum.map(|v| v as f64),
2465 maximum.map(|v| v as f64),
2466 )?;
2467 let description = string_opt(&opts, "description");
2468 let mut field = serde_json::Map::new();
2469 field.insert("type".into(), serde_json::json!("integer"));
2470 field.insert("default".into(), serde_json::json!(default));
2471 if let Some(d) = description {
2472 field.insert("description".into(), serde_json::json!(d));
2473 }
2474 if let Some(v) = minimum {
2475 field.insert("minimum".into(), serde_json::json!(v));
2476 }
2477 if let Some(v) = maximum {
2478 field.insert("maximum".into(), serde_json::json!(v));
2479 }
2480 self.send_field_registration(&name, serde_json::Value::Object(field));
2481 Ok(self
2482 .current_field_value(&name)
2483 .and_then(|v| v.as_i64())
2484 .unwrap_or(default))
2485 }
2486
2487 #[plugin_api(ts_return = "number")]
2490 pub fn define_config_number<'js>(
2491 &self,
2492 ctx: rquickjs::Ctx<'js>,
2493 name: String,
2494 #[plugin_api(
2495 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2496 )]
2497 options: rquickjs::Object<'js>,
2498 ) -> rquickjs::Result<f64> {
2499 let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2500 validate_allowed_keys(
2501 &ctx,
2502 "defineConfigNumber",
2503 &name,
2504 &opts,
2505 &["default", "description", "minimum", "maximum"],
2506 )?;
2507 let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2508 let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2509 let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2510 check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2511 let description = string_opt(&opts, "description");
2512 let mut field = serde_json::Map::new();
2513 field.insert("type".into(), serde_json::json!("number"));
2514 field.insert("default".into(), serde_json::json!(default));
2515 if let Some(d) = description {
2516 field.insert("description".into(), serde_json::json!(d));
2517 }
2518 if let Some(v) = minimum {
2519 field.insert("minimum".into(), serde_json::json!(v));
2520 }
2521 if let Some(v) = maximum {
2522 field.insert("maximum".into(), serde_json::json!(v));
2523 }
2524 self.send_field_registration(&name, serde_json::Value::Object(field));
2525 Ok(self
2526 .current_field_value(&name)
2527 .and_then(|v| v.as_f64())
2528 .unwrap_or(default))
2529 }
2530
2531 #[plugin_api(ts_return = "string")]
2533 pub fn define_config_string<'js>(
2534 &self,
2535 ctx: rquickjs::Ctx<'js>,
2536 name: String,
2537 #[plugin_api(ts_type = "{ default: string; description?: string }")]
2538 options: rquickjs::Object<'js>,
2539 ) -> rquickjs::Result<String> {
2540 let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2541 validate_allowed_keys(
2542 &ctx,
2543 "defineConfigString",
2544 &name,
2545 &opts,
2546 &["default", "description"],
2547 )?;
2548 let default = match opts.get("default") {
2549 Some(serde_json::Value::String(s)) => s.clone(),
2550 _ => {
2551 return Err(throw_js(
2552 &ctx,
2553 &format!(
2554 "defineConfigString(\"{}\"): `default` (string) is required",
2555 name
2556 ),
2557 ));
2558 }
2559 };
2560 let description = string_opt(&opts, "description");
2561 let mut field = serde_json::Map::new();
2562 field.insert("type".into(), serde_json::json!("string"));
2563 field.insert("default".into(), serde_json::json!(default));
2564 if let Some(d) = description {
2565 field.insert("description".into(), serde_json::json!(d));
2566 }
2567 self.send_field_registration(&name, serde_json::Value::Object(field));
2568 Ok(self
2569 .current_field_value(&name)
2570 .and_then(|v| v.as_str().map(|s| s.to_string()))
2571 .unwrap_or(default))
2572 }
2573
2574 #[plugin_api(skip)]
2581 pub fn define_config_enum<'js>(
2582 &self,
2583 ctx: rquickjs::Ctx<'js>,
2584 name: String,
2585 options: rquickjs::Object<'js>,
2586 ) -> rquickjs::Result<String> {
2587 let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2588 validate_allowed_keys(
2589 &ctx,
2590 "defineConfigEnum",
2591 &name,
2592 &opts,
2593 &["default", "description", "values"],
2594 )?;
2595 let values: Vec<String> = match opts.get("values") {
2596 Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
2597 let mut out = Vec::with_capacity(arr.len());
2598 for v in arr {
2599 match v {
2600 serde_json::Value::String(s) => out.push(s.clone()),
2601 _ => {
2602 return Err(throw_js(
2603 &ctx,
2604 &format!(
2605 "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2606 name
2607 ),
2608 ));
2609 }
2610 }
2611 }
2612 out
2613 }
2614 _ => {
2615 return Err(throw_js(
2616 &ctx,
2617 &format!(
2618 "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2619 name
2620 ),
2621 ));
2622 }
2623 };
2624 let default = match opts.get("default") {
2625 Some(serde_json::Value::String(s)) => s.clone(),
2626 _ => {
2627 return Err(throw_js(
2628 &ctx,
2629 &format!(
2630 "defineConfigEnum(\"{}\"): `default` (string) is required",
2631 name
2632 ),
2633 ));
2634 }
2635 };
2636 if !values.contains(&default) {
2637 return Err(throw_js(
2638 &ctx,
2639 &format!(
2640 "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2641 name, values
2642 ),
2643 ));
2644 }
2645 let description = string_opt(&opts, "description");
2646 let mut field = serde_json::Map::new();
2647 field.insert("type".into(), serde_json::json!("string"));
2648 field.insert("enum".into(), serde_json::json!(values));
2649 field.insert("default".into(), serde_json::json!(default));
2650 if let Some(d) = description {
2651 field.insert("description".into(), serde_json::json!(d));
2652 }
2653 self.send_field_registration(&name, serde_json::Value::Object(field));
2654 let current = self
2655 .current_field_value(&name)
2656 .and_then(|v| v.as_str().map(|s| s.to_string()));
2657 Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2661 }
2662
2663 #[plugin_api(ts_return = "string[]")]
2666 pub fn define_config_string_array<'js>(
2667 &self,
2668 ctx: rquickjs::Ctx<'js>,
2669 name: String,
2670 #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2671 options: rquickjs::Object<'js>,
2672 ) -> rquickjs::Result<Vec<String>> {
2673 let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2674 validate_allowed_keys(
2675 &ctx,
2676 "defineConfigStringArray",
2677 &name,
2678 &opts,
2679 &["default", "description"],
2680 )?;
2681 let default: Vec<String> = match opts.get("default") {
2682 Some(serde_json::Value::Array(arr)) => {
2683 let mut out = Vec::with_capacity(arr.len());
2684 for v in arr {
2685 match v {
2686 serde_json::Value::String(s) => out.push(s.clone()),
2687 _ => {
2688 return Err(throw_js(
2689 &ctx,
2690 &format!(
2691 "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2692 name
2693 ),
2694 ));
2695 }
2696 }
2697 }
2698 out
2699 }
2700 _ => {
2701 return Err(throw_js(
2702 &ctx,
2703 &format!(
2704 "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2705 name
2706 ),
2707 ));
2708 }
2709 };
2710 let description = string_opt(&opts, "description");
2711 let mut field = serde_json::Map::new();
2712 field.insert("type".into(), serde_json::json!("array"));
2713 field.insert("items".into(), serde_json::json!({"type": "string"}));
2714 field.insert("default".into(), serde_json::json!(default));
2715 if let Some(d) = description {
2716 field.insert("description".into(), serde_json::json!(d));
2717 }
2718 self.send_field_registration(&name, serde_json::Value::Object(field));
2719 Ok(self
2720 .current_field_value(&name)
2721 .and_then(|v| {
2722 v.as_array().map(|arr| {
2723 arr.iter()
2724 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2725 .collect::<Vec<_>>()
2726 })
2727 })
2728 .unwrap_or(default))
2729 }
2730
2731 pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2739 let config = self
2740 .state_snapshot
2741 .read()
2742 .map(|s| std::sync::Arc::clone(&s.config))
2743 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2744
2745 let settings = config
2746 .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2747 .cloned()
2748 .unwrap_or(serde_json::Value::Null);
2749
2750 rquickjs_serde::to_value(ctx, &settings)
2751 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2752 }
2753
2754 pub fn reload_config(&self) {
2756 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2757 }
2758
2759 pub fn set_setting<'js>(
2772 &self,
2773 _ctx: rquickjs::Ctx<'js>,
2774 path: String,
2775 value: Value<'js>,
2776 ) -> rquickjs::Result<bool> {
2777 let json: serde_json::Value = rquickjs_serde::from_value(value)
2778 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2779 Ok(self
2780 .command_sender
2781 .send(PluginCommand::SetSetting {
2782 plugin_name: self.plugin_name.clone(),
2783 path,
2784 value: json,
2785 })
2786 .is_ok())
2787 }
2788
2789 pub fn reload_themes(&self) {
2792 let _ = self
2793 .command_sender
2794 .send(PluginCommand::ReloadThemes { apply_theme: None });
2795 }
2796
2797 pub fn reload_and_apply_theme(&self, theme_name: String) {
2799 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2800 apply_theme: Some(theme_name),
2801 });
2802 }
2803
2804 pub fn register_grammar<'js>(
2807 &self,
2808 ctx: rquickjs::Ctx<'js>,
2809 language: String,
2810 grammar_path: String,
2811 extensions: Vec<String>,
2812 ) -> rquickjs::Result<bool> {
2813 {
2815 let langs = self.registered_grammar_languages.borrow();
2816 if let Some(existing_plugin) = langs.get(&language) {
2817 if existing_plugin != &self.plugin_name {
2818 let msg = format!(
2819 "Grammar for language '{}' already registered by plugin '{}'",
2820 language, existing_plugin
2821 );
2822 tracing::warn!("registerGrammar collision: {}", msg);
2823 return Err(
2824 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2825 );
2826 }
2827 }
2828 }
2829 self.registered_grammar_languages
2830 .borrow_mut()
2831 .insert(language.clone(), self.plugin_name.clone());
2832
2833 Ok(self
2834 .command_sender
2835 .send(PluginCommand::RegisterGrammar {
2836 language,
2837 grammar_path,
2838 extensions,
2839 })
2840 .is_ok())
2841 }
2842
2843 pub fn register_language_config<'js>(
2845 &self,
2846 ctx: rquickjs::Ctx<'js>,
2847 language: String,
2848 config: LanguagePackConfig,
2849 ) -> rquickjs::Result<bool> {
2850 {
2852 let langs = self.registered_language_configs.borrow();
2853 if let Some(existing_plugin) = langs.get(&language) {
2854 if existing_plugin != &self.plugin_name {
2855 let msg = format!(
2856 "Language config for '{}' already registered by plugin '{}'",
2857 language, existing_plugin
2858 );
2859 tracing::warn!("registerLanguageConfig collision: {}", msg);
2860 return Err(
2861 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2862 );
2863 }
2864 }
2865 }
2866 self.registered_language_configs
2867 .borrow_mut()
2868 .insert(language.clone(), self.plugin_name.clone());
2869
2870 Ok(self
2871 .command_sender
2872 .send(PluginCommand::RegisterLanguageConfig { language, config })
2873 .is_ok())
2874 }
2875
2876 pub fn register_lsp_server<'js>(
2878 &self,
2879 ctx: rquickjs::Ctx<'js>,
2880 language: String,
2881 config: LspServerPackConfig,
2882 ) -> rquickjs::Result<bool> {
2883 {
2885 let langs = self.registered_lsp_servers.borrow();
2886 if let Some(existing_plugin) = langs.get(&language) {
2887 if existing_plugin != &self.plugin_name {
2888 let msg = format!(
2889 "LSP server for language '{}' already registered by plugin '{}'",
2890 language, existing_plugin
2891 );
2892 tracing::warn!("registerLspServer collision: {}", msg);
2893 return Err(
2894 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2895 );
2896 }
2897 }
2898 }
2899 self.registered_lsp_servers
2900 .borrow_mut()
2901 .insert(language.clone(), self.plugin_name.clone());
2902
2903 Ok(self
2904 .command_sender
2905 .send(PluginCommand::RegisterLspServer { language, config })
2906 .is_ok())
2907 }
2908
2909 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2913 #[qjs(rename = "_reloadGrammarsStart")]
2914 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2915 let id = self.alloc_request_id();
2916 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2917 callback_id: fresh_core::api::JsCallbackId::new(id),
2918 });
2919 id
2920 }
2921
2922 pub fn get_plugin_dir(&self) -> String {
2925 self.services
2926 .plugins_dir()
2927 .join("packages")
2928 .join(&self.plugin_name)
2929 .to_string_lossy()
2930 .to_string()
2931 }
2932
2933 pub fn get_config_dir(&self) -> String {
2935 self.services.config_dir().to_string_lossy().to_string()
2936 }
2937
2938 pub fn get_data_dir(&self) -> String {
2942 self.services.data_dir().to_string_lossy().to_string()
2943 }
2944
2945 pub fn get_themes_dir(&self) -> String {
2947 self.services
2948 .config_dir()
2949 .join("themes")
2950 .to_string_lossy()
2951 .to_string()
2952 }
2953
2954 pub fn apply_theme(&self, theme_name: String) -> bool {
2956 self.command_sender
2957 .send(PluginCommand::ApplyTheme { theme_name })
2958 .is_ok()
2959 }
2960
2961 pub fn override_theme_colors<'js>(
2970 &self,
2971 _ctx: rquickjs::Ctx<'js>,
2972 overrides: Value<'js>,
2973 ) -> rquickjs::Result<bool> {
2974 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
2980 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
2981 let Some(obj) = json.as_object() else {
2982 return Err(rquickjs::Error::new_from_js_message(
2983 "type",
2984 "",
2985 "overrideThemeColors expects an object of \"key\": [r, g, b]",
2986 ));
2987 };
2988 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
2989 n.as_i64()
2990 .or_else(|| n.as_f64().map(|f| f as i64))
2991 .map(|v| v.clamp(0, 255) as u8)
2992 };
2993 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
2994 std::collections::HashMap::with_capacity(obj.len());
2995 for (key, value) in obj {
2996 let Some(arr) = value.as_array() else {
2997 continue;
2998 };
2999 if arr.len() != 3 {
3000 continue;
3001 }
3002 let Some(r) = to_u8(&arr[0]) else { continue };
3003 let Some(g) = to_u8(&arr[1]) else { continue };
3004 let Some(b) = to_u8(&arr[2]) else { continue };
3005 clamped.insert(key.clone(), [r, g, b]);
3006 }
3007 Ok(self
3008 .command_sender
3009 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
3010 .is_ok())
3011 }
3012
3013 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3015 let schema = self.services.get_theme_schema();
3016 rquickjs_serde::to_value(ctx, &schema)
3017 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3018 }
3019
3020 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3022 let themes = self.services.get_builtin_themes();
3023 rquickjs_serde::to_value(ctx, &themes)
3024 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3025 }
3026
3027 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3030 let themes = self.services.get_all_themes();
3031 rquickjs_serde::to_value(ctx, &themes)
3032 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3033 }
3034
3035 #[qjs(rename = "_deleteThemeSync")]
3037 pub fn delete_theme_sync(&self, name: String) -> bool {
3038 let themes_dir = self.services.config_dir().join("themes");
3040 let theme_path = themes_dir.join(format!("{}.json", name));
3041
3042 if let Ok(canonical) = theme_path.canonicalize() {
3044 if let Ok(themes_canonical) = themes_dir.canonicalize() {
3045 if canonical.starts_with(&themes_canonical) {
3046 return std::fs::remove_file(&canonical).is_ok();
3047 }
3048 }
3049 }
3050 false
3051 }
3052
3053 pub fn delete_theme(&self, name: String) -> bool {
3055 self.delete_theme_sync(name)
3056 }
3057
3058 pub fn get_theme_data<'js>(
3060 &self,
3061 ctx: rquickjs::Ctx<'js>,
3062 name: String,
3063 ) -> rquickjs::Result<Value<'js>> {
3064 match self.services.get_theme_data(&name) {
3065 Some(data) => rquickjs_serde::to_value(ctx, &data)
3066 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3067 None => Ok(Value::new_null(ctx)),
3068 }
3069 }
3070
3071 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3073 self.services
3074 .save_theme_file(&name, &content)
3075 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3076 }
3077
3078 pub fn theme_file_exists(&self, name: String) -> bool {
3080 self.services.theme_file_exists(&name)
3081 }
3082
3083 pub fn file_stat<'js>(
3087 &self,
3088 ctx: rquickjs::Ctx<'js>,
3089 path: String,
3090 ) -> rquickjs::Result<Value<'js>> {
3091 let metadata = std::fs::metadata(&path).ok();
3092 let stat = metadata.map(|m| {
3093 serde_json::json!({
3094 "isFile": m.is_file(),
3095 "isDir": m.is_dir(),
3096 "size": m.len(),
3097 "readonly": m.permissions().readonly(),
3098 })
3099 });
3100 rquickjs_serde::to_value(ctx, &stat)
3101 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3102 }
3103
3104 pub fn is_process_running(&self, _process_id: u64) -> bool {
3108 false
3111 }
3112
3113 pub fn kill_process(&self, process_id: u64) -> bool {
3115 self.command_sender
3116 .send(PluginCommand::KillBackgroundProcess { process_id })
3117 .is_ok()
3118 }
3119
3120 pub fn plugin_translate<'js>(
3124 &self,
3125 _ctx: rquickjs::Ctx<'js>,
3126 plugin_name: String,
3127 key: String,
3128 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3129 ) -> String {
3130 let args_map: HashMap<String, String> = args
3131 .0
3132 .map(|obj| {
3133 let mut map = HashMap::new();
3134 for (k, v) in obj.props::<String, String>().flatten() {
3135 map.insert(k, v);
3136 }
3137 map
3138 })
3139 .unwrap_or_default();
3140
3141 self.services.translate(&plugin_name, &key, &args_map)
3142 }
3143
3144 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3151 #[qjs(rename = "_createCompositeBufferStart")]
3152 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3153 let id = self.alloc_request_id();
3154
3155 if let Ok(mut owners) = self.async_resource_owners.lock() {
3157 owners.insert(id, self.plugin_name.clone());
3158 }
3159 let _ = self
3160 .command_sender
3161 .send(PluginCommand::CreateCompositeBuffer {
3162 name: opts.name,
3163 mode: opts.mode,
3164 layout: opts.layout,
3165 sources: opts.sources,
3166 hunks: opts.hunks,
3167 initial_focus_hunk: opts.initial_focus_hunk,
3168 request_id: Some(id),
3169 });
3170
3171 id
3172 }
3173
3174 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3178 self.command_sender
3179 .send(PluginCommand::UpdateCompositeAlignment {
3180 buffer_id: BufferId(buffer_id as usize),
3181 hunks,
3182 })
3183 .is_ok()
3184 }
3185
3186 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3188 self.command_sender
3189 .send(PluginCommand::CloseCompositeBuffer {
3190 buffer_id: BufferId(buffer_id as usize),
3191 })
3192 .is_ok()
3193 }
3194
3195 pub fn flush_layout(&self) -> bool {
3199 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3200 }
3201
3202 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3204 self.command_sender
3205 .send(PluginCommand::CompositeNextHunk {
3206 buffer_id: BufferId(buffer_id as usize),
3207 })
3208 .is_ok()
3209 }
3210
3211 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3213 self.command_sender
3214 .send(PluginCommand::CompositePrevHunk {
3215 buffer_id: BufferId(buffer_id as usize),
3216 })
3217 .is_ok()
3218 }
3219
3220 #[plugin_api(
3224 async_promise,
3225 js_name = "getHighlights",
3226 ts_return = "TsHighlightSpan[]"
3227 )]
3228 #[qjs(rename = "_getHighlightsStart")]
3229 pub fn get_highlights_start<'js>(
3230 &self,
3231 _ctx: rquickjs::Ctx<'js>,
3232 buffer_id: u32,
3233 start: u32,
3234 end: u32,
3235 ) -> rquickjs::Result<u64> {
3236 let id = self.alloc_request_id();
3237
3238 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3239 buffer_id: BufferId(buffer_id as usize),
3240 range: (start as usize)..(end as usize),
3241 request_id: id,
3242 });
3243
3244 Ok(id)
3245 }
3246
3247 pub fn add_overlay<'js>(
3269 &self,
3270 _ctx: rquickjs::Ctx<'js>,
3271 buffer_id: u32,
3272 namespace: String,
3273 start: u32,
3274 end: u32,
3275 options: rquickjs::Object<'js>,
3276 ) -> rquickjs::Result<bool> {
3277 use fresh_core::api::OverlayColorSpec;
3278
3279 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3281 if let Ok(theme_key) = obj.get::<_, String>(key) {
3283 if !theme_key.is_empty() {
3284 return Some(OverlayColorSpec::ThemeKey(theme_key));
3285 }
3286 }
3287 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3289 if arr.len() >= 3 {
3290 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3291 }
3292 }
3293 None
3294 }
3295
3296 let fg = parse_color_spec("fg", &options);
3297 let bg = parse_color_spec("bg", &options);
3298 let underline: bool = options.get("underline").unwrap_or(false);
3299 let bold: bool = options.get("bold").unwrap_or(false);
3300 let italic: bool = options.get("italic").unwrap_or(false);
3301 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3302 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3303 let fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
3304 let url: Option<String> = options.get("url").ok();
3305
3306 let options = OverlayOptions {
3307 fg,
3308 bg,
3309 underline,
3310 bold,
3311 italic,
3312 strikethrough,
3313 extend_to_line_end,
3314 fg_on_collision_only,
3315 url,
3316 };
3317
3318 self.plugin_tracked_state
3320 .borrow_mut()
3321 .entry(self.plugin_name.clone())
3322 .or_default()
3323 .overlay_namespaces
3324 .push((BufferId(buffer_id as usize), namespace.clone()));
3325
3326 let _ = self.command_sender.send(PluginCommand::AddOverlay {
3327 buffer_id: BufferId(buffer_id as usize),
3328 namespace: Some(OverlayNamespace::from_string(namespace)),
3329 range: (start as usize)..(end as usize),
3330 options,
3331 });
3332
3333 Ok(true)
3334 }
3335
3336 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3338 self.command_sender
3339 .send(PluginCommand::ClearNamespace {
3340 buffer_id: BufferId(buffer_id as usize),
3341 namespace: OverlayNamespace::from_string(namespace),
3342 })
3343 .is_ok()
3344 }
3345
3346 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3348 self.command_sender
3349 .send(PluginCommand::ClearAllOverlays {
3350 buffer_id: BufferId(buffer_id as usize),
3351 })
3352 .is_ok()
3353 }
3354
3355 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3357 self.command_sender
3358 .send(PluginCommand::ClearOverlaysInRange {
3359 buffer_id: BufferId(buffer_id as usize),
3360 start: start as usize,
3361 end: end as usize,
3362 })
3363 .is_ok()
3364 }
3365
3366 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3368 use fresh_core::overlay::OverlayHandle;
3369 self.command_sender
3370 .send(PluginCommand::RemoveOverlay {
3371 buffer_id: BufferId(buffer_id as usize),
3372 handle: OverlayHandle(handle),
3373 })
3374 .is_ok()
3375 }
3376
3377 pub fn add_conceal(
3381 &self,
3382 buffer_id: u32,
3383 namespace: String,
3384 start: u32,
3385 end: u32,
3386 replacement: Option<String>,
3387 ) -> bool {
3388 self.plugin_tracked_state
3390 .borrow_mut()
3391 .entry(self.plugin_name.clone())
3392 .or_default()
3393 .overlay_namespaces
3394 .push((BufferId(buffer_id as usize), namespace.clone()));
3395
3396 self.command_sender
3397 .send(PluginCommand::AddConceal {
3398 buffer_id: BufferId(buffer_id as usize),
3399 namespace: OverlayNamespace::from_string(namespace),
3400 start: start as usize,
3401 end: end as usize,
3402 replacement,
3403 })
3404 .is_ok()
3405 }
3406
3407 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3409 self.command_sender
3410 .send(PluginCommand::ClearConcealNamespace {
3411 buffer_id: BufferId(buffer_id as usize),
3412 namespace: OverlayNamespace::from_string(namespace),
3413 })
3414 .is_ok()
3415 }
3416
3417 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3419 self.command_sender
3420 .send(PluginCommand::ClearConcealsInRange {
3421 buffer_id: BufferId(buffer_id as usize),
3422 start: start as usize,
3423 end: end as usize,
3424 })
3425 .is_ok()
3426 }
3427
3428 pub fn add_fold(
3435 &self,
3436 buffer_id: u32,
3437 start: u32,
3438 end: u32,
3439 placeholder: rquickjs::function::Opt<String>,
3440 ) -> bool {
3441 self.command_sender
3442 .send(PluginCommand::AddFold {
3443 buffer_id: BufferId(buffer_id as usize),
3444 start: start as usize,
3445 end: end as usize,
3446 placeholder: placeholder.0,
3447 })
3448 .is_ok()
3449 }
3450
3451 pub fn clear_folds(&self, buffer_id: u32) -> bool {
3453 self.command_sender
3454 .send(PluginCommand::ClearFolds {
3455 buffer_id: BufferId(buffer_id as usize),
3456 })
3457 .is_ok()
3458 }
3459
3460 pub fn set_folding_ranges<'js>(
3473 &self,
3474 _ctx: rquickjs::Ctx<'js>,
3475 buffer_id: u32,
3476 ranges_arr: Vec<rquickjs::Object<'js>>,
3477 ) -> rquickjs::Result<bool> {
3478 let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3479 for obj in ranges_arr {
3480 let start_line: u32 = obj.get("startLine").unwrap_or(0);
3481 let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3482 let kind = obj
3483 .get::<_, String>("kind")
3484 .ok()
3485 .and_then(|s| match s.as_str() {
3486 "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3487 "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3488 "region" => Some(lsp_types::FoldingRangeKind::Region),
3489 _ => None,
3490 });
3491 ranges.push(lsp_types::FoldingRange {
3492 start_line,
3493 end_line,
3494 start_character: None,
3495 end_character: None,
3496 kind,
3497 collapsed_text: None,
3498 });
3499 }
3500 Ok(self
3501 .command_sender
3502 .send(PluginCommand::SetFoldingRanges {
3503 buffer_id: BufferId(buffer_id as usize),
3504 ranges,
3505 })
3506 .is_ok())
3507 }
3508
3509 pub fn add_soft_break(
3513 &self,
3514 buffer_id: u32,
3515 namespace: String,
3516 position: u32,
3517 indent: u32,
3518 ) -> bool {
3519 self.plugin_tracked_state
3521 .borrow_mut()
3522 .entry(self.plugin_name.clone())
3523 .or_default()
3524 .overlay_namespaces
3525 .push((BufferId(buffer_id as usize), namespace.clone()));
3526
3527 self.command_sender
3528 .send(PluginCommand::AddSoftBreak {
3529 buffer_id: BufferId(buffer_id as usize),
3530 namespace: OverlayNamespace::from_string(namespace),
3531 position: position as usize,
3532 indent: indent as u16,
3533 })
3534 .is_ok()
3535 }
3536
3537 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3539 self.command_sender
3540 .send(PluginCommand::ClearSoftBreakNamespace {
3541 buffer_id: BufferId(buffer_id as usize),
3542 namespace: OverlayNamespace::from_string(namespace),
3543 })
3544 .is_ok()
3545 }
3546
3547 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3549 self.command_sender
3550 .send(PluginCommand::ClearSoftBreaksInRange {
3551 buffer_id: BufferId(buffer_id as usize),
3552 start: start as usize,
3553 end: end as usize,
3554 })
3555 .is_ok()
3556 }
3557
3558 #[allow(clippy::too_many_arguments)]
3568 pub fn submit_view_transform<'js>(
3569 &self,
3570 _ctx: rquickjs::Ctx<'js>,
3571 buffer_id: u32,
3572 split_id: Option<u32>,
3573 start: u32,
3574 end: u32,
3575 tokens: Vec<rquickjs::Object<'js>>,
3576 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3577 ) -> rquickjs::Result<bool> {
3578 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3579
3580 let tokens: Vec<ViewTokenWire> = tokens
3581 .into_iter()
3582 .enumerate()
3583 .map(|(idx, obj)| {
3584 parse_view_token(&obj, idx)
3586 })
3587 .collect::<rquickjs::Result<Vec<_>>>()?;
3588
3589 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3591 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3592 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3593 Some(LayoutHints {
3594 compose_width,
3595 column_guides,
3596 })
3597 } else {
3598 None
3599 };
3600
3601 let payload = ViewTransformPayload {
3602 range: (start as usize)..(end as usize),
3603 tokens,
3604 layout_hints: parsed_layout_hints,
3605 };
3606
3607 Ok(self
3608 .command_sender
3609 .send(PluginCommand::SubmitViewTransform {
3610 buffer_id: BufferId(buffer_id as usize),
3611 split_id: split_id.map(|id| SplitId(id as usize)),
3612 payload,
3613 })
3614 .is_ok())
3615 }
3616
3617 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3619 self.command_sender
3620 .send(PluginCommand::ClearViewTransform {
3621 buffer_id: BufferId(buffer_id as usize),
3622 split_id: split_id.map(|id| SplitId(id as usize)),
3623 })
3624 .is_ok()
3625 }
3626
3627 pub fn set_layout_hints<'js>(
3630 &self,
3631 buffer_id: u32,
3632 split_id: Option<u32>,
3633 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3634 ) -> rquickjs::Result<bool> {
3635 use fresh_core::api::LayoutHints;
3636
3637 let compose_width: Option<u16> = hints.get("composeWidth").ok();
3638 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3639 let parsed_hints = LayoutHints {
3640 compose_width,
3641 column_guides,
3642 };
3643
3644 Ok(self
3645 .command_sender
3646 .send(PluginCommand::SetLayoutHints {
3647 buffer_id: BufferId(buffer_id as usize),
3648 split_id: split_id.map(|id| SplitId(id as usize)),
3649 range: 0..0,
3650 hints: parsed_hints,
3651 })
3652 .is_ok())
3653 }
3654
3655 pub fn set_file_explorer_decorations<'js>(
3659 &self,
3660 _ctx: rquickjs::Ctx<'js>,
3661 namespace: String,
3662 decorations: Vec<rquickjs::Object<'js>>,
3663 ) -> rquickjs::Result<bool> {
3664 use fresh_core::file_explorer::FileExplorerDecoration;
3665
3666 let decorations: Vec<FileExplorerDecoration> = decorations
3667 .into_iter()
3668 .map(|obj| {
3669 let path: String = obj.get("path")?;
3670 let symbol: String = obj.get("symbol")?;
3671 let priority: i32 = obj.get("priority").unwrap_or(0);
3672
3673 let color_val: rquickjs::Value = obj.get("color")?;
3675 let color = if color_val.is_string() {
3676 let key: String = color_val.get()?;
3677 fresh_core::api::OverlayColorSpec::ThemeKey(key)
3678 } else if color_val.is_array() {
3679 let arr: Vec<u8> = color_val.get()?;
3680 if arr.len() < 3 {
3681 return Err(rquickjs::Error::FromJs {
3682 from: "array",
3683 to: "color",
3684 message: Some(format!(
3685 "color array must have at least 3 elements, got {}",
3686 arr.len()
3687 )),
3688 });
3689 }
3690 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3691 } else {
3692 return Err(rquickjs::Error::FromJs {
3693 from: "value",
3694 to: "color",
3695 message: Some("color must be an RGB array or theme key string".to_string()),
3696 });
3697 };
3698
3699 Ok(FileExplorerDecoration {
3700 path: std::path::PathBuf::from(path),
3701 symbol,
3702 color,
3703 priority,
3704 })
3705 })
3706 .collect::<rquickjs::Result<Vec<_>>>()?;
3707
3708 self.plugin_tracked_state
3710 .borrow_mut()
3711 .entry(self.plugin_name.clone())
3712 .or_default()
3713 .file_explorer_namespaces
3714 .push(namespace.clone());
3715
3716 Ok(self
3717 .command_sender
3718 .send(PluginCommand::SetFileExplorerDecorations {
3719 namespace,
3720 decorations,
3721 })
3722 .is_ok())
3723 }
3724
3725 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3727 self.command_sender
3728 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
3729 .is_ok()
3730 }
3731
3732 #[allow(clippy::too_many_arguments)]
3736 pub fn add_virtual_text(
3737 &self,
3738 buffer_id: u32,
3739 virtual_text_id: String,
3740 position: u32,
3741 text: String,
3742 r: u8,
3743 g: u8,
3744 b: u8,
3745 before: bool,
3746 use_bg: bool,
3747 ) -> bool {
3748 self.plugin_tracked_state
3750 .borrow_mut()
3751 .entry(self.plugin_name.clone())
3752 .or_default()
3753 .virtual_text_ids
3754 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3755
3756 self.command_sender
3757 .send(PluginCommand::AddVirtualText {
3758 buffer_id: BufferId(buffer_id as usize),
3759 virtual_text_id,
3760 position: position as usize,
3761 text,
3762 color: (r, g, b),
3763 use_bg,
3764 before,
3765 })
3766 .is_ok()
3767 }
3768
3769 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
3771 self.command_sender
3772 .send(PluginCommand::RemoveVirtualText {
3773 buffer_id: BufferId(buffer_id as usize),
3774 virtual_text_id,
3775 })
3776 .is_ok()
3777 }
3778
3779 #[allow(clippy::too_many_arguments)]
3785 pub fn add_virtual_text_styled<'js>(
3786 &self,
3787 _ctx: rquickjs::Ctx<'js>,
3788 buffer_id: u32,
3789 virtual_text_id: String,
3790 position: u32,
3791 text: String,
3792 options: rquickjs::Object<'js>,
3793 before: bool,
3794 ) -> rquickjs::Result<bool> {
3795 use fresh_core::api::OverlayColorSpec;
3796
3797 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3800 if let Ok(theme_key) = obj.get::<_, String>(key) {
3801 if !theme_key.is_empty() {
3802 return Some(OverlayColorSpec::ThemeKey(theme_key));
3803 }
3804 }
3805 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3806 if arr.len() >= 3 {
3807 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3808 }
3809 }
3810 None
3811 }
3812
3813 let fg = parse_color_spec("fg", &options);
3814 let bg = parse_color_spec("bg", &options);
3815 let bold: bool = options.get("bold").unwrap_or(false);
3816 let italic: bool = options.get("italic").unwrap_or(false);
3817
3818 self.plugin_tracked_state
3820 .borrow_mut()
3821 .entry(self.plugin_name.clone())
3822 .or_default()
3823 .virtual_text_ids
3824 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3825
3826 let _ = self
3827 .command_sender
3828 .send(PluginCommand::AddVirtualTextStyled {
3829 buffer_id: BufferId(buffer_id as usize),
3830 virtual_text_id,
3831 position: position as usize,
3832 text,
3833 fg,
3834 bg,
3835 bold,
3836 italic,
3837 before,
3838 });
3839 Ok(true)
3840 }
3841
3842 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
3844 self.command_sender
3845 .send(PluginCommand::RemoveVirtualTextsByPrefix {
3846 buffer_id: BufferId(buffer_id as usize),
3847 prefix,
3848 })
3849 .is_ok()
3850 }
3851
3852 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
3854 self.command_sender
3855 .send(PluginCommand::ClearVirtualTexts {
3856 buffer_id: BufferId(buffer_id as usize),
3857 })
3858 .is_ok()
3859 }
3860
3861 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3863 self.command_sender
3864 .send(PluginCommand::ClearVirtualTextNamespace {
3865 buffer_id: BufferId(buffer_id as usize),
3866 namespace,
3867 })
3868 .is_ok()
3869 }
3870
3871 #[allow(clippy::too_many_arguments)]
3886 pub fn add_virtual_line<'js>(
3887 &self,
3888 _ctx: rquickjs::Ctx<'js>,
3889 buffer_id: u32,
3890 position: u32,
3891 text: String,
3892 options: rquickjs::Object<'js>,
3893 above: bool,
3894 namespace: String,
3895 priority: i32,
3896 ) -> rquickjs::Result<bool> {
3897 use fresh_core::api::OverlayColorSpec;
3898
3899 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3902 if let Ok(theme_key) = obj.get::<_, String>(key) {
3903 if !theme_key.is_empty() {
3904 return Some(OverlayColorSpec::ThemeKey(theme_key));
3905 }
3906 }
3907 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3908 if arr.len() >= 3 {
3909 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3910 }
3911 }
3912 None
3913 }
3914
3915 let fg_color = parse_color_spec("fg", &options);
3916 let bg_color = parse_color_spec("bg", &options);
3917 let gutter_glyph = options
3918 .get::<_, String>("gutterGlyph")
3919 .ok()
3920 .filter(|s| !s.is_empty());
3921 let gutter_color = parse_color_spec("gutterColor", &options);
3922
3923 let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
3929 .get::<_, rquickjs::Value<'js>>("textOverlays")
3930 .ok()
3931 .filter(|v| !v.is_undefined() && !v.is_null())
3932 .and_then(|v| rquickjs_serde::from_value(v).ok())
3933 .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
3934 v.into_iter().filter(|o| o.end > o.start).collect()
3935 })
3936 .unwrap_or_default();
3937
3938 self.plugin_tracked_state
3940 .borrow_mut()
3941 .entry(self.plugin_name.clone())
3942 .or_default()
3943 .virtual_line_namespaces
3944 .push((BufferId(buffer_id as usize), namespace.clone()));
3945
3946 Ok(self
3947 .command_sender
3948 .send(PluginCommand::AddVirtualLine {
3949 buffer_id: BufferId(buffer_id as usize),
3950 position: position as usize,
3951 text,
3952 fg_color,
3953 bg_color,
3954 above,
3955 namespace,
3956 priority,
3957 gutter_glyph,
3958 gutter_color,
3959 text_overlays,
3960 })
3961 .is_ok())
3962 }
3963
3964 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
3969 #[qjs(rename = "_promptStart")]
3970 pub fn prompt_start(
3971 &self,
3972 _ctx: rquickjs::Ctx<'_>,
3973 label: String,
3974 initial_value: String,
3975 ) -> u64 {
3976 let id = self.alloc_request_id();
3977
3978 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
3979 label,
3980 initial_value,
3981 callback_id: JsCallbackId::new(id),
3982 });
3983
3984 id
3985 }
3986
3987 pub fn start_prompt(
3998 &self,
3999 label: String,
4000 prompt_type: String,
4001 floating_overlay: rquickjs::function::Opt<bool>,
4002 ) -> bool {
4003 self.command_sender
4004 .send(PluginCommand::StartPrompt {
4005 label,
4006 prompt_type,
4007 floating_overlay: floating_overlay.0.unwrap_or(false),
4008 })
4009 .is_ok()
4010 }
4011
4012 pub fn begin_key_capture(&self) -> bool {
4022 self.command_sender
4023 .send(PluginCommand::SetKeyCaptureActive { active: true })
4024 .is_ok()
4025 }
4026
4027 pub fn end_key_capture(&self) -> bool {
4031 self.command_sender
4032 .send(PluginCommand::SetKeyCaptureActive { active: false })
4033 .is_ok()
4034 }
4035
4036 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4048 #[qjs(rename = "_getNextKeyStart")]
4049 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4050 let id = self.alloc_request_id();
4051 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4052 callback_id: JsCallbackId::new(id),
4053 });
4054 id
4055 }
4056
4057 pub fn start_prompt_with_initial(
4060 &self,
4061 label: String,
4062 prompt_type: String,
4063 initial_value: String,
4064 floating_overlay: rquickjs::function::Opt<bool>,
4065 ) -> bool {
4066 self.command_sender
4067 .send(PluginCommand::StartPromptWithInitial {
4068 label,
4069 prompt_type,
4070 initial_value,
4071 floating_overlay: floating_overlay.0.unwrap_or(false),
4072 })
4073 .is_ok()
4074 }
4075
4076 pub fn set_prompt_suggestions(
4080 &self,
4081 suggestions: Vec<fresh_core::command::Suggestion>,
4082 ) -> bool {
4083 self.command_sender
4084 .send(PluginCommand::SetPromptSuggestions { suggestions })
4085 .is_ok()
4086 }
4087
4088 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4089 self.command_sender
4090 .send(PluginCommand::SetPromptInputSync { sync })
4091 .is_ok()
4092 }
4093
4094 pub fn set_prompt_title(
4104 &self,
4105 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4106 ) -> bool {
4107 self.command_sender
4108 .send(PluginCommand::SetPromptTitle { title })
4109 .is_ok()
4110 }
4111
4112 pub fn set_prompt_footer(
4118 &self,
4119 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4120 ) -> bool {
4121 self.command_sender
4122 .send(PluginCommand::SetPromptFooter { footer })
4123 .is_ok()
4124 }
4125
4126 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4134 self.command_sender
4135 .send(PluginCommand::SetPromptSelectedIndex { index })
4136 .is_ok()
4137 }
4138
4139 pub fn define_mode(
4143 &self,
4144 name: String,
4145 bindings_arr: Vec<Vec<String>>,
4146 read_only: rquickjs::function::Opt<bool>,
4147 allow_text_input: rquickjs::function::Opt<bool>,
4148 inherit_normal_bindings: rquickjs::function::Opt<bool>,
4149 ) -> bool {
4150 let bindings: Vec<(String, String)> = bindings_arr
4151 .into_iter()
4152 .filter_map(|arr| {
4153 if arr.len() >= 2 {
4154 Some((arr[0].clone(), arr[1].clone()))
4155 } else {
4156 None
4157 }
4158 })
4159 .collect();
4160
4161 {
4164 let mut registered = self.registered_actions.borrow_mut();
4165 for (_, cmd_name) in &bindings {
4166 registered.insert(
4167 cmd_name.clone(),
4168 PluginHandler {
4169 plugin_name: self.plugin_name.clone(),
4170 handler_name: cmd_name.clone(),
4171 },
4172 );
4173 }
4174 }
4175
4176 let allow_text = allow_text_input.0.unwrap_or(false);
4179 if allow_text {
4180 let mut registered = self.registered_actions.borrow_mut();
4181 registered.insert(
4182 "mode_text_input".to_string(),
4183 PluginHandler {
4184 plugin_name: self.plugin_name.clone(),
4185 handler_name: "mode_text_input".to_string(),
4186 },
4187 );
4188 }
4189
4190 self.command_sender
4191 .send(PluginCommand::DefineMode {
4192 name,
4193 bindings,
4194 read_only: read_only.0.unwrap_or(false),
4195 allow_text_input: allow_text,
4196 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4197 plugin_name: Some(self.plugin_name.clone()),
4198 })
4199 .is_ok()
4200 }
4201
4202 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4204 self.command_sender
4205 .send(PluginCommand::SetEditorMode { mode })
4206 .is_ok()
4207 }
4208
4209 pub fn get_editor_mode(&self) -> Option<String> {
4211 self.state_snapshot
4212 .read()
4213 .ok()
4214 .and_then(|s| s.editor_mode.clone())
4215 }
4216
4217 pub fn close_split(&self, split_id: u32) -> bool {
4221 self.command_sender
4222 .send(PluginCommand::CloseSplit {
4223 split_id: SplitId(split_id as usize),
4224 })
4225 .is_ok()
4226 }
4227
4228 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4230 self.command_sender
4231 .send(PluginCommand::SetSplitBuffer {
4232 split_id: SplitId(split_id as usize),
4233 buffer_id: BufferId(buffer_id as usize),
4234 })
4235 .is_ok()
4236 }
4237
4238 pub fn focus_split(&self, split_id: u32) -> bool {
4240 self.command_sender
4241 .send(PluginCommand::FocusSplit {
4242 split_id: SplitId(split_id as usize),
4243 })
4244 .is_ok()
4245 }
4246
4247 pub fn create_window(&self, root: String, label: String) -> bool {
4266 self.command_sender
4267 .send(PluginCommand::CreateWindow {
4268 root: std::path::PathBuf::from(root),
4269 label,
4270 })
4271 .is_ok()
4272 }
4273
4274 pub fn set_active_window(&self, id: u64) -> bool {
4279 self.command_sender
4280 .send(PluginCommand::SetActiveWindow {
4281 id: fresh_core::WindowId(id),
4282 })
4283 .is_ok()
4284 }
4285
4286 pub fn close_window(&self, id: u64) -> bool {
4289 self.command_sender
4290 .send(PluginCommand::CloseWindow {
4291 id: fresh_core::WindowId(id),
4292 })
4293 .is_ok()
4294 }
4295
4296 pub fn prewarm_window(&self, id: u64) -> bool {
4300 self.command_sender
4301 .send(PluginCommand::PrewarmWindow {
4302 id: fresh_core::WindowId(id),
4303 })
4304 .is_ok()
4305 }
4306
4307 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4319 #[qjs(rename = "_watchPathStart")]
4320 pub fn watch_path_start(
4321 &self,
4322 _ctx: rquickjs::Ctx<'_>,
4323 path: String,
4324 recursive: rquickjs::function::Opt<bool>,
4325 ) -> rquickjs::Result<u64> {
4326 let id = self.alloc_request_id();
4327 if let Ok(mut owners) = self.async_resource_owners.lock() {
4328 owners.insert(id, self.plugin_name.clone());
4329 }
4330 let _ = self.command_sender.send(PluginCommand::WatchPath {
4331 path: std::path::PathBuf::from(path),
4332 recursive: recursive.0.unwrap_or(false),
4333 request_id: id,
4334 });
4335 Ok(id)
4336 }
4337
4338 pub fn unwatch_path(&self, handle: u64) -> bool {
4341 self.command_sender
4342 .send(PluginCommand::UnwatchPath { handle })
4343 .is_ok()
4344 }
4345
4346 pub fn preview_window_in_rect(&self, id: u64) -> bool {
4357 let sid = if id == 0 {
4358 None
4359 } else {
4360 Some(fresh_core::WindowId(id))
4361 };
4362 self.command_sender
4363 .send(PluginCommand::PreviewWindowInRect { id: sid })
4364 .is_ok()
4365 }
4366
4367 pub fn clear_window_preview(&self) -> bool {
4370 self.command_sender
4371 .send(PluginCommand::PreviewWindowInRect { id: None })
4372 .is_ok()
4373 }
4374
4375 #[plugin_api(ts_return = "WindowInfo[]")]
4378 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4379 let sessions: Vec<fresh_core::api::WindowInfo> = self
4380 .state_snapshot
4381 .read()
4382 .map(|s| s.windows.clone())
4383 .unwrap_or_default();
4384 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4385 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4386 })
4387 }
4388
4389 pub fn active_window(&self) -> u64 {
4392 self.state_snapshot
4393 .read()
4394 .map(|s| s.active_window_id.0)
4395 .unwrap_or(1)
4396 }
4397
4398 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4400 self.command_sender
4401 .send(PluginCommand::SetSplitScroll {
4402 split_id: SplitId(split_id as usize),
4403 top_byte: top_byte as usize,
4404 })
4405 .is_ok()
4406 }
4407
4408 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4410 self.command_sender
4411 .send(PluginCommand::SetSplitRatio {
4412 split_id: SplitId(split_id as usize),
4413 ratio,
4414 })
4415 .is_ok()
4416 }
4417
4418 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4420 self.command_sender
4421 .send(PluginCommand::SetSplitLabel {
4422 split_id: SplitId(split_id as usize),
4423 label,
4424 })
4425 .is_ok()
4426 }
4427
4428 pub fn clear_split_label(&self, split_id: u32) -> bool {
4430 self.command_sender
4431 .send(PluginCommand::ClearSplitLabel {
4432 split_id: SplitId(split_id as usize),
4433 })
4434 .is_ok()
4435 }
4436
4437 #[plugin_api(
4439 async_promise,
4440 js_name = "getSplitByLabel",
4441 ts_return = "number | null"
4442 )]
4443 #[qjs(rename = "_getSplitByLabelStart")]
4444 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4445 let id = self.alloc_request_id();
4446 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4447 label,
4448 request_id: id,
4449 });
4450 id
4451 }
4452
4453 pub fn distribute_splits_evenly(&self) -> bool {
4455 self.command_sender
4457 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4458 .is_ok()
4459 }
4460
4461 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4463 self.command_sender
4464 .send(PluginCommand::SetBufferCursor {
4465 buffer_id: BufferId(buffer_id as usize),
4466 position: position as usize,
4467 })
4468 .is_ok()
4469 }
4470
4471 #[qjs(rename = "setBufferShowCursors")]
4478 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4479 self.command_sender
4480 .send(PluginCommand::SetBufferShowCursors {
4481 buffer_id: BufferId(buffer_id as usize),
4482 show,
4483 })
4484 .is_ok()
4485 }
4486
4487 #[allow(clippy::too_many_arguments)]
4491 pub fn set_line_indicator(
4492 &self,
4493 buffer_id: u32,
4494 line: u32,
4495 namespace: String,
4496 symbol: String,
4497 r: u8,
4498 g: u8,
4499 b: u8,
4500 priority: i32,
4501 ) -> bool {
4502 self.plugin_tracked_state
4504 .borrow_mut()
4505 .entry(self.plugin_name.clone())
4506 .or_default()
4507 .line_indicator_namespaces
4508 .push((BufferId(buffer_id as usize), namespace.clone()));
4509
4510 self.command_sender
4511 .send(PluginCommand::SetLineIndicator {
4512 buffer_id: BufferId(buffer_id as usize),
4513 line: line as usize,
4514 namespace,
4515 symbol,
4516 color: (r, g, b),
4517 priority,
4518 })
4519 .is_ok()
4520 }
4521
4522 #[allow(clippy::too_many_arguments)]
4524 pub fn set_line_indicators(
4525 &self,
4526 buffer_id: u32,
4527 lines: Vec<u32>,
4528 namespace: String,
4529 symbol: String,
4530 r: u8,
4531 g: u8,
4532 b: u8,
4533 priority: i32,
4534 ) -> bool {
4535 self.plugin_tracked_state
4537 .borrow_mut()
4538 .entry(self.plugin_name.clone())
4539 .or_default()
4540 .line_indicator_namespaces
4541 .push((BufferId(buffer_id as usize), namespace.clone()));
4542
4543 self.command_sender
4544 .send(PluginCommand::SetLineIndicators {
4545 buffer_id: BufferId(buffer_id as usize),
4546 lines: lines.into_iter().map(|l| l as usize).collect(),
4547 namespace,
4548 symbol,
4549 color: (r, g, b),
4550 priority,
4551 })
4552 .is_ok()
4553 }
4554
4555 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4557 self.command_sender
4558 .send(PluginCommand::ClearLineIndicators {
4559 buffer_id: BufferId(buffer_id as usize),
4560 namespace,
4561 })
4562 .is_ok()
4563 }
4564
4565 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4567 self.command_sender
4568 .send(PluginCommand::SetLineNumbers {
4569 buffer_id: BufferId(buffer_id as usize),
4570 enabled,
4571 })
4572 .is_ok()
4573 }
4574
4575 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4577 self.command_sender
4578 .send(PluginCommand::SetViewMode {
4579 buffer_id: BufferId(buffer_id as usize),
4580 mode,
4581 })
4582 .is_ok()
4583 }
4584
4585 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4587 self.command_sender
4588 .send(PluginCommand::SetLineWrap {
4589 buffer_id: BufferId(buffer_id as usize),
4590 split_id: split_id.map(|s| SplitId(s as usize)),
4591 enabled,
4592 })
4593 .is_ok()
4594 }
4595
4596 pub fn set_view_state<'js>(
4600 &self,
4601 ctx: rquickjs::Ctx<'js>,
4602 buffer_id: u32,
4603 key: String,
4604 value: Value<'js>,
4605 ) -> bool {
4606 let bid = BufferId(buffer_id as usize);
4607
4608 let json_value = if value.is_undefined() || value.is_null() {
4610 None
4611 } else {
4612 Some(js_to_json(&ctx, value))
4613 };
4614
4615 if let Ok(mut snapshot) = self.state_snapshot.write() {
4617 if let Some(ref json_val) = json_value {
4618 snapshot
4619 .plugin_view_states
4620 .entry(bid)
4621 .or_default()
4622 .insert(key.clone(), json_val.clone());
4623 } else {
4624 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4626 map.remove(&key);
4627 if map.is_empty() {
4628 snapshot.plugin_view_states.remove(&bid);
4629 }
4630 }
4631 }
4632 }
4633
4634 self.command_sender
4636 .send(PluginCommand::SetViewState {
4637 buffer_id: bid,
4638 key,
4639 value: json_value,
4640 })
4641 .is_ok()
4642 }
4643
4644 pub fn get_view_state<'js>(
4646 &self,
4647 ctx: rquickjs::Ctx<'js>,
4648 buffer_id: u32,
4649 key: String,
4650 ) -> rquickjs::Result<Value<'js>> {
4651 let bid = BufferId(buffer_id as usize);
4652 if let Ok(snapshot) = self.state_snapshot.read() {
4653 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4654 if let Some(json_val) = map.get(&key) {
4655 return json_to_js_value(&ctx, json_val);
4656 }
4657 }
4658 }
4659 Ok(Value::new_undefined(ctx.clone()))
4660 }
4661
4662 pub fn set_global_state<'js>(
4668 &self,
4669 ctx: rquickjs::Ctx<'js>,
4670 key: String,
4671 value: Value<'js>,
4672 ) -> bool {
4673 let json_value = if value.is_undefined() || value.is_null() {
4675 None
4676 } else {
4677 Some(js_to_json(&ctx, value))
4678 };
4679
4680 if let Ok(mut snapshot) = self.state_snapshot.write() {
4682 if let Some(ref json_val) = json_value {
4683 snapshot
4684 .plugin_global_states
4685 .entry(self.plugin_name.clone())
4686 .or_default()
4687 .insert(key.clone(), json_val.clone());
4688 } else {
4689 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
4691 map.remove(&key);
4692 if map.is_empty() {
4693 snapshot.plugin_global_states.remove(&self.plugin_name);
4694 }
4695 }
4696 }
4697 }
4698
4699 self.command_sender
4701 .send(PluginCommand::SetGlobalState {
4702 plugin_name: self.plugin_name.clone(),
4703 key,
4704 value: json_value,
4705 })
4706 .is_ok()
4707 }
4708
4709 pub fn get_global_state<'js>(
4713 &self,
4714 ctx: rquickjs::Ctx<'js>,
4715 key: String,
4716 ) -> rquickjs::Result<Value<'js>> {
4717 if let Ok(snapshot) = self.state_snapshot.read() {
4718 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
4719 if let Some(json_val) = map.get(&key) {
4720 return json_to_js_value(&ctx, json_val);
4721 }
4722 }
4723 }
4724 Ok(Value::new_undefined(ctx.clone()))
4725 }
4726
4727 pub fn set_window_state<'js>(
4736 &self,
4737 ctx: rquickjs::Ctx<'js>,
4738 key: String,
4739 value: Value<'js>,
4740 ) -> bool {
4741 let json_value = if value.is_undefined() || value.is_null() {
4742 None
4743 } else {
4744 Some(js_to_json(&ctx, value))
4745 };
4746 if let Ok(mut snapshot) = self.state_snapshot.write() {
4750 match &json_value {
4751 Some(v) => {
4752 snapshot
4753 .active_session_plugin_states
4754 .entry(self.plugin_name.clone())
4755 .or_default()
4756 .insert(key.clone(), v.clone());
4757 }
4758 None => {
4759 if let Some(map) = snapshot
4760 .active_session_plugin_states
4761 .get_mut(&self.plugin_name)
4762 {
4763 map.remove(&key);
4764 if map.is_empty() {
4765 snapshot
4766 .active_session_plugin_states
4767 .remove(&self.plugin_name);
4768 }
4769 }
4770 }
4771 }
4772 }
4773 self.command_sender
4774 .send(PluginCommand::SetWindowState {
4775 plugin_name: self.plugin_name.clone(),
4776 key,
4777 value: json_value,
4778 })
4779 .is_ok()
4780 }
4781
4782 pub fn get_window_state<'js>(
4785 &self,
4786 ctx: rquickjs::Ctx<'js>,
4787 key: String,
4788 ) -> rquickjs::Result<Value<'js>> {
4789 if let Ok(snapshot) = self.state_snapshot.read() {
4790 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
4791 if let Some(json_val) = map.get(&key) {
4792 return json_to_js_value(&ctx, json_val);
4793 }
4794 }
4795 }
4796 Ok(Value::new_undefined(ctx.clone()))
4797 }
4798
4799 pub fn create_scroll_sync_group(
4803 &self,
4804 group_id: u32,
4805 left_split: u32,
4806 right_split: u32,
4807 ) -> bool {
4808 self.plugin_tracked_state
4810 .borrow_mut()
4811 .entry(self.plugin_name.clone())
4812 .or_default()
4813 .scroll_sync_group_ids
4814 .push(group_id);
4815 self.command_sender
4816 .send(PluginCommand::CreateScrollSyncGroup {
4817 group_id,
4818 left_split: SplitId(left_split as usize),
4819 right_split: SplitId(right_split as usize),
4820 })
4821 .is_ok()
4822 }
4823
4824 pub fn set_scroll_sync_anchors<'js>(
4826 &self,
4827 _ctx: rquickjs::Ctx<'js>,
4828 group_id: u32,
4829 anchors: Vec<Vec<u32>>,
4830 ) -> bool {
4831 let anchors: Vec<(usize, usize)> = anchors
4832 .into_iter()
4833 .filter_map(|pair| {
4834 if pair.len() >= 2 {
4835 Some((pair[0] as usize, pair[1] as usize))
4836 } else {
4837 None
4838 }
4839 })
4840 .collect();
4841 self.command_sender
4842 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
4843 .is_ok()
4844 }
4845
4846 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
4848 self.command_sender
4849 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
4850 .is_ok()
4851 }
4852
4853 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
4859 self.command_sender
4860 .send(PluginCommand::ExecuteActions { actions })
4861 .is_ok()
4862 }
4863
4864 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
4868 self.command_sender
4869 .send(PluginCommand::ShowActionPopup {
4870 popup_id: opts.id,
4871 title: opts.title,
4872 message: opts.message,
4873 actions: opts.actions,
4874 })
4875 .is_ok()
4876 }
4877
4878 pub fn set_lsp_menu_contributions(
4882 &self,
4883 plugin_id: String,
4884 language: String,
4885 items: Vec<fresh_core::api::LspMenuItem>,
4886 ) -> bool {
4887 self.command_sender
4888 .send(PluginCommand::SetLspMenuContributions {
4889 plugin_id,
4890 language,
4891 items,
4892 })
4893 .is_ok()
4894 }
4895
4896 pub fn disable_lsp_for_language(&self, language: String) -> bool {
4898 self.command_sender
4899 .send(PluginCommand::DisableLspForLanguage { language })
4900 .is_ok()
4901 }
4902
4903 pub fn restart_lsp_for_language(&self, language: String) -> bool {
4905 self.command_sender
4906 .send(PluginCommand::RestartLspForLanguage { language })
4907 .is_ok()
4908 }
4909
4910 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
4913 self.command_sender
4914 .send(PluginCommand::SetLspRootUri { language, uri })
4915 .is_ok()
4916 }
4917
4918 #[plugin_api(ts_return = "JsDiagnostic[]")]
4920 pub fn get_all_diagnostics<'js>(
4921 &self,
4922 ctx: rquickjs::Ctx<'js>,
4923 ) -> rquickjs::Result<Value<'js>> {
4924 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
4925
4926 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
4927 let mut result: Vec<JsDiagnostic> = Vec::new();
4929 for (uri, diags) in s.diagnostics.iter() {
4930 for diag in diags {
4931 result.push(JsDiagnostic {
4932 uri: uri.clone(),
4933 message: diag.message.clone(),
4934 severity: diag.severity.map(|s| match s {
4935 lsp_types::DiagnosticSeverity::ERROR => 1,
4936 lsp_types::DiagnosticSeverity::WARNING => 2,
4937 lsp_types::DiagnosticSeverity::INFORMATION => 3,
4938 lsp_types::DiagnosticSeverity::HINT => 4,
4939 _ => 0,
4940 }),
4941 range: JsRange {
4942 start: JsPosition {
4943 line: diag.range.start.line,
4944 character: diag.range.start.character,
4945 },
4946 end: JsPosition {
4947 line: diag.range.end.line,
4948 character: diag.range.end.character,
4949 },
4950 },
4951 source: diag.source.clone(),
4952 });
4953 }
4954 }
4955 result
4956 } else {
4957 Vec::new()
4958 };
4959 rquickjs_serde::to_value(ctx, &diagnostics)
4960 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4961 }
4962
4963 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
4965 self.event_handlers
4966 .read()
4967 .expect("event_handlers poisoned")
4968 .get(&event_name)
4969 .cloned()
4970 .unwrap_or_default()
4971 .into_iter()
4972 .map(|h| h.handler_name)
4973 .collect()
4974 }
4975
4976 #[plugin_api(
4980 async_promise,
4981 js_name = "createVirtualBuffer",
4982 ts_return = "VirtualBufferResult"
4983 )]
4984 #[qjs(rename = "_createVirtualBufferStart")]
4985 pub fn create_virtual_buffer_start(
4986 &self,
4987 _ctx: rquickjs::Ctx<'_>,
4988 opts: fresh_core::api::CreateVirtualBufferOptions,
4989 ) -> rquickjs::Result<u64> {
4990 let id = self.alloc_request_id();
4991
4992 let entries: Vec<TextPropertyEntry> = opts
4994 .entries
4995 .unwrap_or_default()
4996 .into_iter()
4997 .map(|e| TextPropertyEntry {
4998 text: e.text,
4999 properties: e.properties.unwrap_or_default(),
5000 style: e.style,
5001 inline_overlays: e.inline_overlays.unwrap_or_default(),
5002 segments: e.segments.unwrap_or_default(),
5003 pad_to_chars: e.pad_to_chars,
5004 truncate_to_chars: e.truncate_to_chars,
5005 })
5006 .collect();
5007
5008 tracing::debug!(
5009 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
5010 id
5011 );
5012 if let Ok(mut owners) = self.async_resource_owners.lock() {
5014 owners.insert(id, self.plugin_name.clone());
5015 }
5016 let _ = self
5017 .command_sender
5018 .send(PluginCommand::CreateVirtualBufferWithContent {
5019 name: opts.name,
5020 mode: opts.mode.unwrap_or_default(),
5021 read_only: opts.read_only.unwrap_or(false),
5022 entries,
5023 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5024 show_cursors: opts.show_cursors.unwrap_or(true),
5025 editing_disabled: opts.editing_disabled.unwrap_or(false),
5026 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5027 request_id: Some(id),
5028 });
5029 Ok(id)
5030 }
5031
5032 #[plugin_api(
5034 async_promise,
5035 js_name = "createVirtualBufferInSplit",
5036 ts_return = "VirtualBufferResult"
5037 )]
5038 #[qjs(rename = "_createVirtualBufferInSplitStart")]
5039 pub fn create_virtual_buffer_in_split_start(
5040 &self,
5041 _ctx: rquickjs::Ctx<'_>,
5042 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5043 ) -> rquickjs::Result<u64> {
5044 let id = self.alloc_request_id();
5045
5046 let entries: Vec<TextPropertyEntry> = opts
5048 .entries
5049 .unwrap_or_default()
5050 .into_iter()
5051 .map(|e| TextPropertyEntry {
5052 text: e.text,
5053 properties: e.properties.unwrap_or_default(),
5054 style: e.style,
5055 inline_overlays: e.inline_overlays.unwrap_or_default(),
5056 segments: e.segments.unwrap_or_default(),
5057 pad_to_chars: e.pad_to_chars,
5058 truncate_to_chars: e.truncate_to_chars,
5059 })
5060 .collect();
5061
5062 if let Ok(mut owners) = self.async_resource_owners.lock() {
5064 owners.insert(id, self.plugin_name.clone());
5065 }
5066 let _ = self
5067 .command_sender
5068 .send(PluginCommand::CreateVirtualBufferInSplit {
5069 name: opts.name,
5070 mode: opts.mode.unwrap_or_default(),
5071 read_only: opts.read_only.unwrap_or(false),
5072 entries,
5073 ratio: opts.ratio.unwrap_or(0.5),
5074 direction: opts.direction,
5075 panel_id: opts.panel_id,
5076 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5077 show_cursors: opts.show_cursors.unwrap_or(true),
5078 editing_disabled: opts.editing_disabled.unwrap_or(false),
5079 line_wrap: opts.line_wrap,
5080 before: opts.before.unwrap_or(false),
5081 role: opts.role,
5082 request_id: Some(id),
5083 });
5084 Ok(id)
5085 }
5086
5087 #[plugin_api(
5089 async_promise,
5090 js_name = "createVirtualBufferInExistingSplit",
5091 ts_return = "VirtualBufferResult"
5092 )]
5093 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5094 pub fn create_virtual_buffer_in_existing_split_start(
5095 &self,
5096 _ctx: rquickjs::Ctx<'_>,
5097 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5098 ) -> rquickjs::Result<u64> {
5099 let id = self.alloc_request_id();
5100
5101 let entries: Vec<TextPropertyEntry> = opts
5103 .entries
5104 .unwrap_or_default()
5105 .into_iter()
5106 .map(|e| TextPropertyEntry {
5107 text: e.text,
5108 properties: e.properties.unwrap_or_default(),
5109 style: e.style,
5110 inline_overlays: e.inline_overlays.unwrap_or_default(),
5111 segments: e.segments.unwrap_or_default(),
5112 pad_to_chars: e.pad_to_chars,
5113 truncate_to_chars: e.truncate_to_chars,
5114 })
5115 .collect();
5116
5117 if let Ok(mut owners) = self.async_resource_owners.lock() {
5119 owners.insert(id, self.plugin_name.clone());
5120 }
5121 let _ = self
5122 .command_sender
5123 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5124 name: opts.name,
5125 mode: opts.mode.unwrap_or_default(),
5126 read_only: opts.read_only.unwrap_or(false),
5127 entries,
5128 split_id: SplitId(opts.split_id),
5129 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5130 show_cursors: opts.show_cursors.unwrap_or(true),
5131 editing_disabled: opts.editing_disabled.unwrap_or(false),
5132 line_wrap: opts.line_wrap,
5133 request_id: Some(id),
5134 });
5135 Ok(id)
5136 }
5137
5138 #[qjs(rename = "_createBufferGroupStart")]
5140 pub fn create_buffer_group_start(
5141 &self,
5142 _ctx: rquickjs::Ctx<'_>,
5143 name: String,
5144 mode: String,
5145 layout_json: String,
5146 ) -> rquickjs::Result<u64> {
5147 let id = self.alloc_request_id();
5148 if let Ok(mut owners) = self.async_resource_owners.lock() {
5149 owners.insert(id, self.plugin_name.clone());
5150 }
5151 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5152 name,
5153 mode,
5154 layout_json,
5155 request_id: Some(id),
5156 });
5157 Ok(id)
5158 }
5159
5160 #[qjs(rename = "setPanelContent")]
5162 pub fn set_panel_content<'js>(
5163 &self,
5164 ctx: rquickjs::Ctx<'js>,
5165 group_id: u32,
5166 panel_name: String,
5167 entries_arr: Vec<rquickjs::Object<'js>>,
5168 ) -> rquickjs::Result<bool> {
5169 let entries: Vec<TextPropertyEntry> = entries_arr
5170 .iter()
5171 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5172 .collect();
5173 Ok(self
5174 .command_sender
5175 .send(PluginCommand::SetPanelContent {
5176 group_id: group_id as usize,
5177 panel_name,
5178 entries,
5179 })
5180 .is_ok())
5181 }
5182
5183 #[qjs(rename = "closeBufferGroup")]
5185 pub fn close_buffer_group(&self, group_id: u32) -> bool {
5186 self.command_sender
5187 .send(PluginCommand::CloseBufferGroup {
5188 group_id: group_id as usize,
5189 })
5190 .is_ok()
5191 }
5192
5193 #[qjs(rename = "focusBufferGroupPanel")]
5195 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5196 self.command_sender
5197 .send(PluginCommand::FocusPanel {
5198 group_id: group_id as usize,
5199 panel_name,
5200 })
5201 .is_ok()
5202 }
5203
5204 #[plugin_api(
5211 async_promise,
5212 js_name = "setBufferGroupPanelBuffer",
5213 ts_return = "boolean"
5214 )]
5215 #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5216 pub fn set_buffer_group_panel_buffer_start(
5217 &self,
5218 _ctx: rquickjs::Ctx<'_>,
5219 group_id: u32,
5220 panel_name: String,
5221 buffer_id: u32,
5222 ) -> u64 {
5223 let id = self.alloc_request_id();
5224 let _ = self
5225 .command_sender
5226 .send(PluginCommand::SetBufferGroupPanelBuffer {
5227 group_id: group_id as usize,
5228 panel_name,
5229 buffer_id: BufferId(buffer_id as usize),
5230 request_id: id,
5231 });
5232 id
5233 }
5234
5235 pub fn set_virtual_buffer_content<'js>(
5239 &self,
5240 ctx: rquickjs::Ctx<'js>,
5241 buffer_id: u32,
5242 entries_arr: Vec<rquickjs::Object<'js>>,
5243 ) -> rquickjs::Result<bool> {
5244 let entries: Vec<TextPropertyEntry> = entries_arr
5245 .iter()
5246 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5247 .collect();
5248 Ok(self
5249 .command_sender
5250 .send(PluginCommand::SetVirtualBufferContent {
5251 buffer_id: BufferId(buffer_id as usize),
5252 entries,
5253 })
5254 .is_ok())
5255 }
5256
5257 pub fn get_text_properties_at_cursor(
5259 &self,
5260 buffer_id: u32,
5261 ) -> fresh_core::api::TextPropertiesAtCursor {
5262 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5263 }
5264
5265 #[qjs(rename = "mountWidgetPanel")]
5275 pub fn mount_widget_panel<'js>(
5276 &self,
5277 ctx: rquickjs::Ctx<'js>,
5278 panel_id: f64,
5279 buffer_id: u32,
5280 spec_obj: rquickjs::Value<'js>,
5281 ) -> rquickjs::Result<bool> {
5282 let json = js_to_json(&ctx, spec_obj);
5283 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5284 Ok(s) => s,
5285 Err(e) => {
5286 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5287 return Ok(false);
5288 }
5289 };
5290 Ok(self
5291 .command_sender
5292 .send(PluginCommand::MountWidgetPanel {
5293 panel_id: panel_id as u64,
5294 buffer_id: BufferId(buffer_id as usize),
5295 spec,
5296 })
5297 .is_ok())
5298 }
5299
5300 #[qjs(rename = "updateWidgetPanel")]
5303 pub fn update_widget_panel<'js>(
5304 &self,
5305 ctx: rquickjs::Ctx<'js>,
5306 panel_id: f64,
5307 spec_obj: rquickjs::Value<'js>,
5308 ) -> rquickjs::Result<bool> {
5309 let json = js_to_json(&ctx, spec_obj);
5310 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5311 Ok(s) => s,
5312 Err(e) => {
5313 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5314 return Ok(false);
5315 }
5316 };
5317 Ok(self
5318 .command_sender
5319 .send(PluginCommand::UpdateWidgetPanel {
5320 panel_id: panel_id as u64,
5321 spec,
5322 })
5323 .is_ok())
5324 }
5325
5326 #[qjs(rename = "unmountWidgetPanel")]
5329 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5330 self.command_sender
5331 .send(PluginCommand::UnmountWidgetPanel {
5332 panel_id: panel_id as u64,
5333 })
5334 .is_ok()
5335 }
5336
5337 #[qjs(rename = "widgetCommand")]
5346 pub fn widget_command<'js>(
5347 &self,
5348 ctx: rquickjs::Ctx<'js>,
5349 panel_id: f64,
5350 action_obj: rquickjs::Value<'js>,
5351 ) -> rquickjs::Result<bool> {
5352 let json = js_to_json(&ctx, action_obj);
5353 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5354 Ok(a) => a,
5355 Err(e) => {
5356 tracing::error!("widgetCommand: invalid action: {}", e);
5357 return Ok(false);
5358 }
5359 };
5360 Ok(self
5361 .command_sender
5362 .send(PluginCommand::WidgetCommand {
5363 panel_id: panel_id as u64,
5364 action,
5365 })
5366 .is_ok())
5367 }
5368
5369 #[qjs(rename = "widgetMutate")]
5375 pub fn widget_mutate<'js>(
5376 &self,
5377 ctx: rquickjs::Ctx<'js>,
5378 panel_id: f64,
5379 mutation_obj: rquickjs::Value<'js>,
5380 ) -> rquickjs::Result<bool> {
5381 let json = js_to_json(&ctx, mutation_obj);
5382 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5383 Ok(m) => m,
5384 Err(e) => {
5385 tracing::error!("widgetMutate: invalid mutation: {}", e);
5386 return Ok(false);
5387 }
5388 };
5389 Ok(self
5390 .command_sender
5391 .send(PluginCommand::WidgetMutate {
5392 panel_id: panel_id as u64,
5393 mutation,
5394 })
5395 .is_ok())
5396 }
5397
5398 #[qjs(rename = "mountFloatingWidget")]
5401 pub fn mount_floating_widget<'js>(
5402 &self,
5403 ctx: rquickjs::Ctx<'js>,
5404 panel_id: f64,
5405 spec_obj: rquickjs::Value<'js>,
5406 width_pct: f64,
5407 height_pct: f64,
5408 ) -> rquickjs::Result<bool> {
5409 let json = js_to_json(&ctx, spec_obj);
5410 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5411 Ok(s) => s,
5412 Err(e) => {
5413 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5414 return Ok(false);
5415 }
5416 };
5417 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5418 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5419 Ok(self
5420 .command_sender
5421 .send(PluginCommand::MountFloatingWidget {
5422 panel_id: panel_id as u64,
5423 spec,
5424 width_pct,
5425 height_pct,
5426 })
5427 .is_ok())
5428 }
5429
5430 #[qjs(rename = "updateFloatingWidget")]
5432 pub fn update_floating_widget<'js>(
5433 &self,
5434 ctx: rquickjs::Ctx<'js>,
5435 panel_id: f64,
5436 spec_obj: rquickjs::Value<'js>,
5437 ) -> rquickjs::Result<bool> {
5438 let json = js_to_json(&ctx, spec_obj);
5439 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5440 Ok(s) => s,
5441 Err(e) => {
5442 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5443 return Ok(false);
5444 }
5445 };
5446 Ok(self
5447 .command_sender
5448 .send(PluginCommand::UpdateFloatingWidget {
5449 panel_id: panel_id as u64,
5450 spec,
5451 })
5452 .is_ok())
5453 }
5454
5455 #[qjs(rename = "unmountFloatingWidget")]
5457 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5458 self.command_sender
5459 .send(PluginCommand::UnmountFloatingWidget {
5460 panel_id: panel_id as u64,
5461 })
5462 .is_ok()
5463 }
5464
5465 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5474 #[qjs(rename = "_spawnProcessStart")]
5475 pub fn spawn_process_start(
5476 &self,
5477 _ctx: rquickjs::Ctx<'_>,
5478 command: String,
5479 args: Vec<String>,
5480 cwd: rquickjs::function::Opt<String>,
5481 stdout_to: rquickjs::function::Opt<String>,
5482 ) -> u64 {
5483 let id = self.alloc_request_id();
5484 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5490 self.state_snapshot
5491 .read()
5492 .ok()
5493 .map(|s| s.working_dir.to_string_lossy().to_string())
5494 });
5495 let stdout_to_path = stdout_to
5496 .0
5497 .filter(|s| !s.is_empty())
5498 .map(std::path::PathBuf::from);
5499 tracing::info!(
5500 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5501 self.plugin_name,
5502 command,
5503 args,
5504 effective_cwd,
5505 stdout_to_path,
5506 id
5507 );
5508 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5509 callback_id: JsCallbackId::new(id),
5510 command,
5511 args,
5512 cwd: effective_cwd,
5513 stdout_to: stdout_to_path,
5514 });
5515 id
5516 }
5517
5518 #[plugin_api(
5525 async_thenable,
5526 js_name = "spawnHostProcess",
5527 ts_return = "SpawnResult"
5528 )]
5529 #[qjs(rename = "_spawnHostProcessStart")]
5530 pub fn spawn_host_process_start(
5531 &self,
5532 _ctx: rquickjs::Ctx<'_>,
5533 command: String,
5534 args: Vec<String>,
5535 cwd: rquickjs::function::Opt<String>,
5536 ) -> u64 {
5537 let id = self.alloc_request_id();
5538 let effective_cwd = cwd.0.or_else(|| {
5539 self.state_snapshot
5540 .read()
5541 .ok()
5542 .map(|s| s.working_dir.to_string_lossy().to_string())
5543 });
5544 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5545 callback_id: JsCallbackId::new(id),
5546 command,
5547 args,
5548 cwd: effective_cwd,
5549 });
5550 id
5551 }
5552
5553 #[plugin_api(js_name = "_killHostProcess")]
5563 pub fn kill_host_process(&self, process_id: u64) -> bool {
5564 self.command_sender
5565 .send(PluginCommand::KillHostProcess { process_id })
5566 .is_ok()
5567 }
5568
5569 #[plugin_api(js_name = "setAuthority")]
5578 pub fn set_authority(
5579 &self,
5580 ctx: rquickjs::Ctx<'_>,
5581 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5582 ) -> bool {
5583 let json = js_to_json(&ctx, payload);
5584 let _ = self
5585 .command_sender
5586 .send(PluginCommand::SetAuthority { payload: json });
5587 true
5588 }
5589
5590 #[plugin_api(js_name = "clearAuthority")]
5593 pub fn clear_authority(&self) {
5594 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5595 }
5596
5597 #[plugin_api(js_name = "setEnv")]
5601 pub fn set_env(&self, snippet: String, dir: Option<String>) {
5602 let _ = self
5603 .command_sender
5604 .send(PluginCommand::SetEnv { snippet, dir });
5605 }
5606
5607 #[plugin_api(js_name = "clearEnv")]
5609 pub fn clear_env(&self) {
5610 let _ = self.command_sender.send(PluginCommand::ClearEnv);
5611 }
5612
5613 #[plugin_api(js_name = "setRemoteIndicatorState")]
5631 pub fn set_remote_indicator_state(
5632 &self,
5633 ctx: rquickjs::Ctx<'_>,
5634 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
5635 ) -> bool {
5636 let json = js_to_json(&ctx, state);
5637 let _ = self
5638 .command_sender
5639 .send(PluginCommand::SetRemoteIndicatorState { state: json });
5640 true
5641 }
5642
5643 #[plugin_api(js_name = "clearRemoteIndicatorState")]
5646 pub fn clear_remote_indicator_state(&self) {
5647 let _ = self
5648 .command_sender
5649 .send(PluginCommand::ClearRemoteIndicatorState);
5650 }
5651
5652 #[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
5663 #[qjs(rename = "_httpFetchStart")]
5664 pub fn http_fetch_start(
5665 &self,
5666 _ctx: rquickjs::Ctx<'_>,
5667 url: String,
5668 target_path: String,
5669 ) -> u64 {
5670 let id = self.alloc_request_id();
5671 tracing::info!(
5672 "http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
5673 self.plugin_name,
5674 url,
5675 target_path,
5676 id
5677 );
5678 let _ = self.command_sender.send(PluginCommand::HttpFetch {
5679 url,
5680 target_path: std::path::PathBuf::from(target_path),
5681 callback_id: JsCallbackId::new(id),
5682 });
5683 id
5684 }
5685
5686 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
5688 #[qjs(rename = "_spawnProcessWaitStart")]
5689 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
5690 let id = self.alloc_request_id();
5691 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
5692 process_id,
5693 callback_id: JsCallbackId::new(id),
5694 });
5695 id
5696 }
5697
5698 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
5700 #[qjs(rename = "_getBufferTextStart")]
5701 pub fn get_buffer_text_start(
5702 &self,
5703 _ctx: rquickjs::Ctx<'_>,
5704 buffer_id: u32,
5705 start: u32,
5706 end: u32,
5707 ) -> u64 {
5708 let id = self.alloc_request_id();
5709 let _ = self.command_sender.send(PluginCommand::GetBufferText {
5710 buffer_id: BufferId(buffer_id as usize),
5711 start: start as usize,
5712 end: end as usize,
5713 request_id: id,
5714 });
5715 id
5716 }
5717
5718 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
5720 #[qjs(rename = "_delayStart")]
5721 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
5722 let id = self.alloc_request_id();
5723 let _ = self.command_sender.send(PluginCommand::Delay {
5724 callback_id: JsCallbackId::new(id),
5725 duration_ms,
5726 });
5727 id
5728 }
5729
5730 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
5734 #[qjs(rename = "_grepProjectStart")]
5735 pub fn grep_project_start(
5736 &self,
5737 _ctx: rquickjs::Ctx<'_>,
5738 pattern: String,
5739 fixed_string: Option<bool>,
5740 case_sensitive: Option<bool>,
5741 max_results: Option<u32>,
5742 whole_words: Option<bool>,
5743 ) -> u64 {
5744 let id = self.alloc_request_id();
5745 let _ = self.command_sender.send(PluginCommand::GrepProject {
5746 pattern,
5747 fixed_string: fixed_string.unwrap_or(true),
5748 case_sensitive: case_sensitive.unwrap_or(true),
5749 max_results: max_results.unwrap_or(200) as usize,
5750 whole_words: whole_words.unwrap_or(false),
5751 callback_id: JsCallbackId::new(id),
5752 });
5753 id
5754 }
5755
5756 #[plugin_api(
5761 js_name = "beginSearch",
5762 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }): SearchHandle"
5763 )]
5764 #[qjs(rename = "_beginSearch")]
5765 pub fn begin_search(
5766 &self,
5767 _ctx: rquickjs::Ctx<'_>,
5768 pattern: String,
5769 fixed_string: bool,
5770 case_sensitive: bool,
5771 max_results: u32,
5772 whole_words: bool,
5773 ) -> u64 {
5774 let id = self.alloc_request_id();
5775 let entry = Arc::new(SearchHandleState::new());
5778 if let Ok(mut map) = self.search_handles.lock() {
5779 map.insert(id, entry);
5780 }
5781 let _ = self.command_sender.send(PluginCommand::BeginSearch {
5782 pattern,
5783 fixed_string,
5784 case_sensitive,
5785 max_results: max_results as usize,
5786 whole_words,
5787 handle_id: id,
5788 });
5789 id
5790 }
5791
5792 #[plugin_api(ts_return = "SearchTakeResult")]
5797 #[qjs(rename = "_searchHandleTake")]
5798 pub fn search_handle_take<'js>(
5799 &self,
5800 ctx: rquickjs::Ctx<'js>,
5801 handle_id: u64,
5802 ) -> rquickjs::Result<Value<'js>> {
5803 let entry = self
5804 .search_handles
5805 .lock()
5806 .ok()
5807 .and_then(|m| m.get(&handle_id).cloned());
5808 let result = match entry {
5809 Some(handle) => {
5810 let mut state = match handle.state.lock() {
5812 Ok(s) => s,
5813 Err(poisoned) => poisoned.into_inner(),
5814 };
5815 let matches = std::mem::take(&mut state.pending);
5816 let snapshot = SearchTakeResult {
5817 matches,
5818 done: state.done,
5819 total_seen: state.total_seen,
5820 truncated: state.truncated,
5821 error: state.error.clone(),
5822 };
5823 let done = snapshot.done;
5824 drop(state);
5825 if done {
5826 if let Ok(mut map) = self.search_handles.lock() {
5827 map.remove(&handle_id);
5828 }
5829 }
5830 snapshot
5831 }
5832 None => SearchTakeResult {
5833 matches: Vec::new(),
5834 done: true,
5835 total_seen: 0,
5836 truncated: false,
5837 error: None,
5838 },
5839 };
5840 rquickjs_serde::to_value(ctx, &result)
5841 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5842 }
5843
5844 #[qjs(rename = "_searchHandleCancel")]
5847 pub fn search_handle_cancel(&self, handle_id: u64) {
5848 if let Ok(map) = self.search_handles.lock() {
5849 if let Some(entry) = map.get(&handle_id) {
5850 entry
5851 .cancel
5852 .store(true, std::sync::atomic::Ordering::Relaxed);
5853 }
5854 }
5855 }
5856
5857 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
5861 #[qjs(rename = "_replaceInFileStart")]
5862 pub fn replace_in_file_start(
5863 &self,
5864 _ctx: rquickjs::Ctx<'_>,
5865 file_path: String,
5866 matches: Vec<Vec<u32>>,
5867 replacement: String,
5868 ) -> u64 {
5869 let id = self.alloc_request_id();
5870 let match_pairs: Vec<(usize, usize)> = matches
5872 .iter()
5873 .map(|m| (m[0] as usize, m[1] as usize))
5874 .collect();
5875 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
5876 file_path: PathBuf::from(file_path),
5877 matches: match_pairs,
5878 replacement,
5879 callback_id: JsCallbackId::new(id),
5880 });
5881 id
5882 }
5883
5884 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
5886 #[qjs(rename = "_sendLspRequestStart")]
5887 pub fn send_lsp_request_start<'js>(
5888 &self,
5889 ctx: rquickjs::Ctx<'js>,
5890 language: String,
5891 method: String,
5892 params: Option<rquickjs::Object<'js>>,
5893 ) -> rquickjs::Result<u64> {
5894 let id = self.alloc_request_id();
5895 let params_json: Option<serde_json::Value> = params.map(|obj| {
5897 let val = obj.into_value();
5898 js_to_json(&ctx, val)
5899 });
5900 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
5901 request_id: id,
5902 language,
5903 method,
5904 params: params_json,
5905 });
5906 Ok(id)
5907 }
5908
5909 #[plugin_api(
5911 async_thenable,
5912 js_name = "spawnBackgroundProcess",
5913 ts_return = "BackgroundProcessResult"
5914 )]
5915 #[qjs(rename = "_spawnBackgroundProcessStart")]
5916 pub fn spawn_background_process_start(
5917 &self,
5918 _ctx: rquickjs::Ctx<'_>,
5919 command: String,
5920 args: Vec<String>,
5921 cwd: rquickjs::function::Opt<String>,
5922 ) -> u64 {
5923 let id = self.alloc_request_id();
5924 let process_id = id;
5926 self.plugin_tracked_state
5928 .borrow_mut()
5929 .entry(self.plugin_name.clone())
5930 .or_default()
5931 .background_process_ids
5932 .push(process_id);
5933 let _ = self
5935 .command_sender
5936 .send(PluginCommand::SpawnBackgroundProcess {
5937 process_id,
5938 command,
5939 args,
5940 cwd: cwd.0.filter(|s| !s.is_empty()),
5941 callback_id: JsCallbackId::new(id),
5942 });
5943 id
5944 }
5945
5946 pub fn kill_background_process(&self, process_id: u64) -> bool {
5948 self.command_sender
5949 .send(PluginCommand::KillBackgroundProcess { process_id })
5950 .is_ok()
5951 }
5952
5953 #[plugin_api(
5957 async_promise,
5958 js_name = "createTerminal",
5959 ts_return = "TerminalResult"
5960 )]
5961 #[qjs(rename = "_createTerminalStart")]
5962 pub fn create_terminal_start(
5963 &self,
5964 _ctx: rquickjs::Ctx<'_>,
5965 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
5966 ) -> rquickjs::Result<u64> {
5967 let id = self.alloc_request_id();
5968
5969 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
5970 cwd: None,
5971 direction: None,
5972 ratio: None,
5973 focus: None,
5974 persistent: None,
5975 window_id: None,
5976 command: None,
5977 title: None,
5978 });
5979
5980 if let Ok(mut owners) = self.async_resource_owners.lock() {
5982 owners.insert(id, self.plugin_name.clone());
5983 }
5984 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
5985 cwd: opts.cwd,
5986 direction: opts.direction,
5987 ratio: opts.ratio,
5988 focus: opts.focus,
5989 window_id: opts.window_id,
5990 persistent: opts.persistent.unwrap_or(false),
5994 command: opts.command,
5995 title: opts.title,
5996 request_id: id,
5997 });
5998 Ok(id)
5999 }
6000
6001 #[plugin_api(
6007 async_promise,
6008 js_name = "createWindowWithTerminal",
6009 ts_return = "SessionWithTerminalResult"
6010 )]
6011 #[qjs(rename = "_createWindowWithTerminalStart")]
6012 pub fn create_window_with_terminal_start(
6013 &self,
6014 _ctx: rquickjs::Ctx<'_>,
6015 opts: fresh_core::api::CreateWindowWithTerminalOptions,
6016 ) -> rquickjs::Result<u64> {
6017 let id = self.alloc_request_id();
6018 if let Ok(mut owners) = self.async_resource_owners.lock() {
6019 owners.insert(id, self.plugin_name.clone());
6020 }
6021 let _ = self
6022 .command_sender
6023 .send(PluginCommand::CreateWindowWithTerminal {
6024 root: std::path::PathBuf::from(opts.root),
6025 label: opts.label,
6026 cwd: opts.cwd,
6027 command: opts.command,
6028 title: opts.title,
6029 request_id: id,
6030 });
6031 Ok(id)
6032 }
6033
6034 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
6036 self.command_sender
6037 .send(PluginCommand::SendTerminalInput {
6038 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6039 data,
6040 })
6041 .is_ok()
6042 }
6043
6044 pub fn close_terminal(&self, terminal_id: u64) -> bool {
6046 self.command_sender
6047 .send(PluginCommand::CloseTerminal {
6048 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6049 })
6050 .is_ok()
6051 }
6052
6053 pub fn signal_window(&self, id: f64, signal: String) -> bool {
6060 self.command_sender
6061 .send(PluginCommand::SignalWindow {
6062 id: fresh_core::WindowId(id as u64),
6063 signal,
6064 })
6065 .is_ok()
6066 }
6067
6068 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
6072 self.command_sender
6073 .send(PluginCommand::RefreshLines {
6074 buffer_id: BufferId(buffer_id as usize),
6075 })
6076 .is_ok()
6077 }
6078
6079 pub fn get_current_locale(&self) -> String {
6081 self.services.current_locale()
6082 }
6083
6084 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
6088 #[qjs(rename = "_loadPluginStart")]
6089 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
6090 let id = self.alloc_request_id();
6091 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
6092 path: std::path::PathBuf::from(path),
6093 callback_id: JsCallbackId::new(id),
6094 });
6095 id
6096 }
6097
6098 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
6100 #[qjs(rename = "_unloadPluginStart")]
6101 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6102 let id = self.alloc_request_id();
6103 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
6104 name,
6105 callback_id: JsCallbackId::new(id),
6106 });
6107 id
6108 }
6109
6110 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6112 #[qjs(rename = "_reloadPluginStart")]
6113 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6114 let id = self.alloc_request_id();
6115 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6116 name,
6117 callback_id: JsCallbackId::new(id),
6118 });
6119 id
6120 }
6121
6122 #[plugin_api(
6125 async_promise,
6126 js_name = "listPlugins",
6127 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6128 )]
6129 #[qjs(rename = "_listPluginsStart")]
6130 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6131 let id = self.alloc_request_id();
6132 let _ = self.command_sender.send(PluginCommand::ListPlugins {
6133 callback_id: JsCallbackId::new(id),
6134 });
6135 id
6136 }
6137}
6138
6139fn parse_view_token(
6146 obj: &rquickjs::Object<'_>,
6147 idx: usize,
6148) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6149 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6150
6151 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6153 from: "object",
6154 to: "ViewTokenWire",
6155 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6156 })?;
6157
6158 let source_offset: Option<usize> = obj
6160 .get("sourceOffset")
6161 .ok()
6162 .or_else(|| obj.get("source_offset").ok());
6163
6164 let kind = if kind_value.is_string() {
6166 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6169 from: "value",
6170 to: "string",
6171 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6172 })?;
6173
6174 match kind_str.to_lowercase().as_str() {
6175 "text" => {
6176 let text: String = obj.get("text").unwrap_or_default();
6177 ViewTokenWireKind::Text(text)
6178 }
6179 "newline" => ViewTokenWireKind::Newline,
6180 "space" => ViewTokenWireKind::Space,
6181 "break" => ViewTokenWireKind::Break,
6182 _ => {
6183 tracing::warn!(
6185 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6186 idx, kind_str
6187 );
6188 return Err(rquickjs::Error::FromJs {
6189 from: "string",
6190 to: "ViewTokenWireKind",
6191 message: Some(format!(
6192 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6193 idx, kind_str
6194 )),
6195 });
6196 }
6197 }
6198 } else if kind_value.is_object() {
6199 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6201 from: "value",
6202 to: "object",
6203 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6204 })?;
6205
6206 if let Ok(text) = kind_obj.get::<_, String>("Text") {
6207 ViewTokenWireKind::Text(text)
6208 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6209 ViewTokenWireKind::BinaryByte(byte)
6210 } else {
6211 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6213 tracing::warn!(
6214 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6215 idx,
6216 keys
6217 );
6218 return Err(rquickjs::Error::FromJs {
6219 from: "object",
6220 to: "ViewTokenWireKind",
6221 message: Some(format!(
6222 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6223 idx, keys
6224 )),
6225 });
6226 }
6227 } else {
6228 tracing::warn!(
6229 "token[{}]: 'kind' field must be a string or object, got: {:?}",
6230 idx,
6231 kind_value.type_of()
6232 );
6233 return Err(rquickjs::Error::FromJs {
6234 from: "value",
6235 to: "ViewTokenWireKind",
6236 message: Some(format!(
6237 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6238 idx
6239 )),
6240 });
6241 };
6242
6243 let style = parse_view_token_style(obj, idx)?;
6245
6246 Ok(ViewTokenWire {
6247 source_offset,
6248 kind,
6249 style,
6250 })
6251}
6252
6253fn parse_view_token_style(
6255 obj: &rquickjs::Object<'_>,
6256 idx: usize,
6257) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6258 use fresh_core::api::{TokenColor, ViewTokenStyle};
6259
6260 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6261 let Some(s) = style_obj else {
6262 return Ok(None);
6263 };
6264
6265 fn parse_color(
6270 s: &rquickjs::Object<'_>,
6271 field: &str,
6272 idx: usize,
6273 ) -> rquickjs::Result<Option<TokenColor>> {
6274 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6275 if arr.len() < 3 {
6276 tracing::warn!(
6277 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6278 idx,
6279 field,
6280 arr.len()
6281 );
6282 return Ok(None);
6283 }
6284 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6285 }
6286 if let Ok(name) = s.get::<_, String>(field) {
6287 return Ok(Some(TokenColor::Named(name)));
6288 }
6289 Ok(None)
6290 }
6291
6292 Ok(Some(ViewTokenStyle {
6293 fg: parse_color(&s, "fg", idx)?,
6294 bg: parse_color(&s, "bg", idx)?,
6295 bold: s.get("bold").unwrap_or(false),
6296 italic: s.get("italic").unwrap_or(false),
6297 underline: s.get("underline").unwrap_or(false),
6298 }))
6299}
6300
6301pub struct QuickJsBackend {
6303 runtime: Runtime,
6304 main_context: Context,
6306 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6308 event_handlers: EventHandlerRegistry,
6312 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6314 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6316 command_sender: mpsc::Sender<PluginCommand>,
6318 #[allow(dead_code)]
6320 pending_responses: PendingResponses,
6321 next_request_id: Rc<RefCell<u64>>,
6323 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6325 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6327 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6329 async_resource_owners: AsyncResourceOwners,
6332 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6334 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6336 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6338 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6340 plugin_api_exports: PluginApiExports,
6344 search_handles: SearchHandleRegistry,
6346}
6347
6348impl Drop for QuickJsBackend {
6349 fn drop(&mut self) {
6350 self.plugin_api_exports.borrow_mut().clear();
6356 }
6357}
6358
6359impl QuickJsBackend {
6360 pub fn new() -> Result<Self> {
6362 let (tx, _rx) = mpsc::channel();
6363 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6364 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6365 Self::with_state(state_snapshot, tx, services)
6366 }
6367
6368 pub fn with_state(
6370 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6371 command_sender: mpsc::Sender<PluginCommand>,
6372 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6373 ) -> Result<Self> {
6374 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
6375 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
6376 }
6377
6378 pub fn with_state_and_responses(
6380 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6381 command_sender: mpsc::Sender<PluginCommand>,
6382 pending_responses: PendingResponses,
6383 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6384 ) -> Result<Self> {
6385 let async_resource_owners: AsyncResourceOwners =
6386 Arc::new(std::sync::Mutex::new(HashMap::new()));
6387 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
6388 let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
6389 Self::with_state_responses_and_resources(
6390 state_snapshot,
6391 command_sender,
6392 pending_responses,
6393 services,
6394 async_resource_owners,
6395 search_handles,
6396 event_handlers,
6397 )
6398 }
6399
6400 pub fn with_state_responses_and_resources(
6403 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6404 command_sender: mpsc::Sender<PluginCommand>,
6405 pending_responses: PendingResponses,
6406 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6407 async_resource_owners: AsyncResourceOwners,
6408 search_handles: SearchHandleRegistry,
6409 event_handlers: EventHandlerRegistry,
6410 ) -> Result<Self> {
6411 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
6412
6413 let runtime =
6414 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
6415
6416 runtime.set_host_promise_rejection_tracker(Some(Box::new(
6418 |_ctx, _promise, reason, is_handled| {
6419 if !is_handled {
6420 let error_msg = if let Some(exc) = reason.as_exception() {
6422 format!(
6423 "{}: {}",
6424 exc.message().unwrap_or_default(),
6425 exc.stack().unwrap_or_default()
6426 )
6427 } else {
6428 format!("{:?}", reason)
6429 };
6430
6431 tracing::error!("Unhandled Promise rejection: {}", error_msg);
6432
6433 if should_panic_on_js_errors() {
6434 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
6437 set_fatal_js_error(full_msg);
6438 }
6439 }
6440 },
6441 )));
6442
6443 let main_context = Context::full(&runtime)
6444 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
6445
6446 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
6447 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
6448 let next_request_id = Rc::new(RefCell::new(1u64));
6449 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
6450 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
6451 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
6452 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
6453 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
6454 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
6455 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
6456
6457 let backend = Self {
6458 runtime,
6459 main_context,
6460 plugin_contexts,
6461 event_handlers,
6462 registered_actions,
6463 state_snapshot,
6464 command_sender,
6465 pending_responses,
6466 next_request_id,
6467 callback_contexts,
6468 services,
6469 plugin_tracked_state,
6470 async_resource_owners,
6471 registered_command_names,
6472 registered_grammar_languages,
6473 registered_language_configs,
6474 registered_lsp_servers,
6475 plugin_api_exports,
6476 search_handles,
6477 };
6478
6479 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
6481
6482 tracing::debug!("QuickJsBackend::new: runtime created successfully");
6483 Ok(backend)
6484 }
6485
6486 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
6488 let state_snapshot = Arc::clone(&self.state_snapshot);
6489 let command_sender = self.command_sender.clone();
6490 let event_handlers = Arc::clone(&self.event_handlers);
6491 let registered_actions = Rc::clone(&self.registered_actions);
6492 let next_request_id = Rc::clone(&self.next_request_id);
6493 let registered_command_names = Rc::clone(&self.registered_command_names);
6494 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
6495 let registered_language_configs = Rc::clone(&self.registered_language_configs);
6496 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
6497 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
6498
6499 context.with(|ctx| {
6500 let globals = ctx.globals();
6501
6502 globals.set("__pluginName__", plugin_name)?;
6504
6505 let js_api = JsEditorApi {
6508 state_snapshot: Arc::clone(&state_snapshot),
6509 command_sender: command_sender.clone(),
6510 registered_actions: Rc::clone(®istered_actions),
6511 event_handlers: Arc::clone(&event_handlers),
6512 next_request_id: Rc::clone(&next_request_id),
6513 callback_contexts: Rc::clone(&self.callback_contexts),
6514 services: self.services.clone(),
6515 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
6516 async_resource_owners: Arc::clone(&self.async_resource_owners),
6517 registered_command_names: Rc::clone(®istered_command_names),
6518 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
6519 registered_language_configs: Rc::clone(®istered_language_configs),
6520 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
6521 plugin_api_exports: Rc::clone(&plugin_api_exports),
6522 search_handles: Arc::clone(&self.search_handles),
6523 plugin_name: plugin_name.to_string(),
6524 };
6525 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
6526
6527 globals.set("editor", editor)?;
6529
6530 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
6532
6533 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
6535
6536ctx.eval::<(), _>(
6543 r#"
6544 (function() {
6545 const originalOn = editor.on.bind(editor);
6546 const originalOff = editor.off.bind(editor);
6547 let counter = 0;
6548 const anonNames = new WeakMap();
6549 editor.on = function(eventName, handlerOrName) {
6550 if (typeof handlerOrName === 'function') {
6551 const existing = anonNames.get(handlerOrName);
6552 const name = existing || `__anon_on_${++counter}`;
6553 if (!existing) {
6554 anonNames.set(handlerOrName, name);
6555 }
6556 globalThis[name] = handlerOrName;
6557 return originalOn(eventName, name);
6558 }
6559 return originalOn(eventName, handlerOrName);
6560 };
6561 editor.off = function(eventName, handlerOrName) {
6562 if (typeof handlerOrName === 'function') {
6563 const name = anonNames.get(handlerOrName);
6564 if (name === undefined) return false;
6565 return originalOff(eventName, name);
6566 }
6567 return originalOff(eventName, handlerOrName);
6568 };
6569 })();
6570 "#,
6571 )?;
6572
6573 let console = Object::new(ctx.clone())?;
6576 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6577 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6578 tracing::info!("console.log: {}", parts.join(" "));
6579 })?)?;
6580 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6581 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6582 tracing::warn!("console.warn: {}", parts.join(" "));
6583 })?)?;
6584 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6585 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6586 tracing::error!("console.error: {}", parts.join(" "));
6587 })?)?;
6588 globals.set("console", console)?;
6589
6590 ctx.eval::<(), _>(r#"
6592 // Pending promise callbacks: callbackId -> { resolve, reject }
6593 globalThis._pendingCallbacks = new Map();
6594
6595 // Resolve a pending callback (called from Rust)
6596 globalThis._resolveCallback = function(callbackId, result) {
6597 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
6598 const cb = globalThis._pendingCallbacks.get(callbackId);
6599 if (cb) {
6600 console.log('[JS] _resolveCallback: found callback, calling resolve()');
6601 globalThis._pendingCallbacks.delete(callbackId);
6602 cb.resolve(result);
6603 console.log('[JS] _resolveCallback: resolve() called');
6604 } else {
6605 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
6606 }
6607 };
6608
6609 // Reject a pending callback (called from Rust)
6610 globalThis._rejectCallback = function(callbackId, error) {
6611 const cb = globalThis._pendingCallbacks.get(callbackId);
6612 if (cb) {
6613 globalThis._pendingCallbacks.delete(callbackId);
6614 cb.reject(new Error(error));
6615 }
6616 };
6617
6618 // Generic async wrapper decorator
6619 // Wraps a function that returns a callbackId into a promise-returning function
6620 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6621 // NOTE: We pass the method name as a string and call via bracket notation
6622 // to preserve rquickjs's automatic Ctx injection for methods
6623 globalThis._wrapAsync = function(methodName, fnName) {
6624 const startFn = editor[methodName];
6625 if (typeof startFn !== 'function') {
6626 // Return a function that always throws - catches missing implementations
6627 return function(...args) {
6628 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6629 editor.debug(`[ASYNC ERROR] ${error.message}`);
6630 throw error;
6631 };
6632 }
6633 return function(...args) {
6634 // Call via bracket notation to preserve method binding and Ctx injection
6635 const callbackId = editor[methodName](...args);
6636 return new Promise((resolve, reject) => {
6637 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6638 // TODO: Implement setTimeout polyfill using editor.delay() or similar
6639 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6640 });
6641 };
6642 };
6643
6644 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6645 // The returned object has .result promise and is itself thenable
6646 globalThis._wrapAsyncThenable = function(methodName, fnName) {
6647 const startFn = editor[methodName];
6648 if (typeof startFn !== 'function') {
6649 // Return a function that always throws - catches missing implementations
6650 return function(...args) {
6651 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6652 editor.debug(`[ASYNC ERROR] ${error.message}`);
6653 throw error;
6654 };
6655 }
6656 return function(...args) {
6657 // Call via bracket notation to preserve method binding and Ctx injection
6658 const callbackId = editor[methodName](...args);
6659 const resultPromise = new Promise((resolve, reject) => {
6660 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6661 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6662 });
6663 return {
6664 get result() { return resultPromise; },
6665 then(onFulfilled, onRejected) {
6666 return resultPromise.then(onFulfilled, onRejected);
6667 },
6668 catch(onRejected) {
6669 return resultPromise.catch(onRejected);
6670 }
6671 };
6672 };
6673 };
6674
6675 // Apply wrappers to async functions on editor
6676 // spawnProcess accepts either form for the 4th arg:
6677 // editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
6678 // editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
6679 // The first matches the auto-generated TS signature
6680 // (flat positional from the Rust binding's `Opt<String>`
6681 // args); the second is the structured options form
6682 // plugin authors often prefer.
6683 editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
6684 if (typeof editor._spawnProcessStart !== 'function') {
6685 throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
6686 }
6687 // The 3rd arg is either cwd (string) or an options
6688 // object when cwd is omitted; the 4th is either a
6689 // stdoutTo string or an options object.
6690 let cwd = "";
6691 let stdoutTo = "";
6692 if (typeof cwdOrOpts === "string") {
6693 cwd = cwdOrOpts;
6694 } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
6695 if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
6696 }
6697 if (typeof fourth === "string") {
6698 stdoutTo = fourth;
6699 } else if (fourth && typeof fourth === "object") {
6700 if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
6701 }
6702 const callbackId = editor._spawnProcessStart(
6703 command,
6704 argsArr || [],
6705 cwd,
6706 stdoutTo,
6707 );
6708 const resultPromise = new Promise((resolve, reject) => {
6709 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6710 });
6711 return {
6712 get result() { return resultPromise; },
6713 // `kill()` cancels a still-running spawn. The
6714 // dispatcher stores a oneshot keyed by callbackId;
6715 // _killHostProcess fires it and the spawner's
6716 // tokio::select! kills the child. No-op if the
6717 // child already exited (id removed from the map).
6718 kill() {
6719 if (typeof editor._killHostProcess === 'function') {
6720 return editor._killHostProcess(callbackId);
6721 }
6722 return false;
6723 },
6724 then(onFulfilled, onRejected) {
6725 return resultPromise.then(onFulfilled, onRejected);
6726 },
6727 catch(onRejected) {
6728 return resultPromise.catch(onRejected);
6729 }
6730 };
6731 };
6732 // spawnHostProcess gets a bespoke wrapper (instead of
6733 // `_wrapAsyncThenable`) because its `ProcessHandle`
6734 // exposes a real `kill()` that forwards to
6735 // `_killHostProcess`. Generic wrap has no hook for
6736 // that.
6737 editor.spawnHostProcess = function(command, args, cwd) {
6738 if (typeof editor._spawnHostProcessStart !== 'function') {
6739 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
6740 }
6741 // Pass real strings only. Earlier revisions forwarded
6742 // `""` for a missing cwd, which landed verbatim as
6743 // `Command::current_dir("")` in the dispatcher —
6744 // every host-spawn then failed with ENOENT. Use two
6745 // arity forms so the Rust `Opt<String>` stays `None`
6746 // instead of `Some("")`.
6747 let callbackId;
6748 if (typeof cwd === "string" && cwd.length > 0) {
6749 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
6750 } else {
6751 callbackId = editor._spawnHostProcessStart(command, args || []);
6752 }
6753 const resultPromise = new Promise(function(resolve, reject) {
6754 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
6755 });
6756 return {
6757 processId: callbackId,
6758 get result() { return resultPromise; },
6759 then: function(f, r) { return resultPromise.then(f, r); },
6760 catch: function(r) { return resultPromise.catch(r); },
6761 kill: function() {
6762 // Returns true when the kill was enqueued
6763 // (the process may have already exited; in
6764 // that case the dispatcher silently
6765 // drops it). Matches the
6766 // `ProcessHandle.kill(): Promise<boolean>`
6767 // type signature by wrapping the sync
6768 // boolean in a Promise.
6769 return Promise.resolve(editor._killHostProcess(callbackId));
6770 }
6771 };
6772 };
6773 editor.delay = _wrapAsync("_delayStart", "delay");
6774 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
6775 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
6776 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
6777 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
6778 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
6779 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
6780 editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
6781 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
6782 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
6783 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
6784 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
6785 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
6786 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
6787 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
6788 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
6789 editor.prompt = _wrapAsync("_promptStart", "prompt");
6790 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
6791 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
6792 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
6793 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
6794 editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
6795 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
6796 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
6797 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
6798 editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
6799 editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
6800 editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
6801
6802 // Pull-based streaming search. Producers (host searcher tasks)
6803 // write into shared state at full speed; the consumer drains
6804 // it via take() at its own cadence — no per-chunk JS dispatch.
6805 editor.beginSearch = function(pattern, opts) {
6806 opts = opts || {};
6807 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
6808 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
6809 const maxResults = opts.maxResults || 10000;
6810 const wholeWords = opts.wholeWords || false;
6811 const handleId = editor._beginSearch(
6812 pattern, fixedString, caseSensitive, maxResults, wholeWords
6813 );
6814 return {
6815 searchId: handleId,
6816 take: function() { return editor._searchHandleTake(handleId); },
6817 cancel: function() { editor._searchHandleCancel(handleId); }
6818 };
6819 };
6820
6821 // Wrapper for deleteTheme - wraps sync function in Promise
6822 editor.deleteTheme = function(name) {
6823 return new Promise(function(resolve, reject) {
6824 const success = editor._deleteThemeSync(name);
6825 if (success) {
6826 resolve();
6827 } else {
6828 reject(new Error("Failed to delete theme: " + name));
6829 }
6830 });
6831 };
6832 "#.as_bytes())?;
6833
6834 Ok::<_, rquickjs::Error>(())
6835 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
6836
6837 Ok(())
6838 }
6839
6840 pub async fn load_module_with_source(
6842 &mut self,
6843 path: &str,
6844 _plugin_source: &str,
6845 ) -> Result<()> {
6846 let path_buf = PathBuf::from(path);
6847 let source = std::fs::read_to_string(&path_buf)
6848 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
6849
6850 let filename = path_buf
6851 .file_name()
6852 .and_then(|s| s.to_str())
6853 .unwrap_or("plugin.ts");
6854
6855 if has_es_imports(&source) {
6857 match bundle_module(&path_buf) {
6859 Ok(bundled) => {
6860 self.execute_js(&bundled, path)?;
6861 }
6862 Err(e) => {
6863 tracing::warn!(
6864 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
6865 path,
6866 e
6867 );
6868 return Ok(()); }
6870 }
6871 } else if has_es_module_syntax(&source) {
6872 let stripped = strip_imports_and_exports(&source);
6874 let js_code = if filename.ends_with(".ts") {
6875 transpile_typescript(&stripped, filename)?
6876 } else {
6877 stripped
6878 };
6879 self.execute_js(&js_code, path)?;
6880 } else {
6881 let js_code = if filename.ends_with(".ts") {
6883 transpile_typescript(&source, filename)?
6884 } else {
6885 source
6886 };
6887 self.execute_js(&js_code, path)?;
6888 }
6889
6890 Ok(())
6891 }
6892
6893 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
6895 let plugin_name = Path::new(source_name)
6897 .file_stem()
6898 .and_then(|s| s.to_str())
6899 .unwrap_or("unknown");
6900
6901 tracing::debug!(
6902 "execute_js: starting for plugin '{}' from '{}'",
6903 plugin_name,
6904 source_name
6905 );
6906
6907 let context = {
6909 let mut contexts = self.plugin_contexts.borrow_mut();
6910 if let Some(ctx) = contexts.get(plugin_name) {
6911 ctx.clone()
6912 } else {
6913 let ctx = Context::full(&self.runtime).map_err(|e| {
6914 anyhow!(
6915 "Failed to create QuickJS context for plugin {}: {}",
6916 plugin_name,
6917 e
6918 )
6919 })?;
6920 self.setup_context_api(&ctx, plugin_name)?;
6921 contexts.insert(plugin_name.to_string(), ctx.clone());
6922 ctx
6923 }
6924 };
6925
6926 let wrapped_code = format!("(function() {{ {} }})();", code);
6930 let wrapped = wrapped_code.as_str();
6931
6932 context.with(|ctx| {
6933 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
6934
6935 let mut eval_options = rquickjs::context::EvalOptions::default();
6937 eval_options.global = true;
6938 eval_options.filename = Some(source_name.to_string());
6939 let result = ctx
6940 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
6941 .map_err(|e| format_js_error(&ctx, e, source_name));
6942
6943 tracing::debug!(
6944 "execute_js: plugin code execution finished for '{}', result: {:?}",
6945 plugin_name,
6946 result.is_ok()
6947 );
6948
6949 result
6950 })
6951 }
6952
6953 pub fn execute_source(
6959 &mut self,
6960 source: &str,
6961 plugin_name: &str,
6962 is_typescript: bool,
6963 ) -> Result<()> {
6964 use fresh_parser_js::{
6965 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
6966 };
6967
6968 if has_es_imports(source) {
6969 tracing::warn!(
6970 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
6971 plugin_name
6972 );
6973 }
6974
6975 let js_code = if has_es_module_syntax(source) {
6976 let stripped = strip_imports_and_exports(source);
6977 if is_typescript {
6978 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
6979 } else {
6980 stripped
6981 }
6982 } else if is_typescript {
6983 transpile_typescript(source, &format!("{}.ts", plugin_name))?
6984 } else {
6985 source.to_string()
6986 };
6987
6988 let source_name = format!(
6990 "{}.{}",
6991 plugin_name,
6992 if is_typescript { "ts" } else { "js" }
6993 );
6994 self.execute_js(&js_code, &source_name)
6995 }
6996
6997 pub fn cleanup_plugin(&self, plugin_name: &str) {
7003 self.plugin_contexts.borrow_mut().remove(plugin_name);
7005
7006 {
7008 let mut handlers_map = self
7009 .event_handlers
7010 .write()
7011 .expect("event_handlers poisoned");
7012 for handlers in handlers_map.values_mut() {
7013 handlers.retain(|h| h.plugin_name != plugin_name);
7014 }
7015 handlers_map.retain(|_, list| !list.is_empty());
7019 }
7020
7021 self.registered_actions
7023 .borrow_mut()
7024 .retain(|_, h| h.plugin_name != plugin_name);
7025
7026 self.callback_contexts
7028 .borrow_mut()
7029 .retain(|_, pname| pname != plugin_name);
7030
7031 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
7033 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
7035 std::collections::HashSet::new();
7036 for (buf_id, ns) in &tracked.overlay_namespaces {
7037 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
7038 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
7040 buffer_id: *buf_id,
7041 namespace: OverlayNamespace::from_string(ns.clone()),
7042 });
7043 let _ = self
7045 .command_sender
7046 .send(PluginCommand::ClearConcealNamespace {
7047 buffer_id: *buf_id,
7048 namespace: OverlayNamespace::from_string(ns.clone()),
7049 });
7050 let _ = self
7051 .command_sender
7052 .send(PluginCommand::ClearSoftBreakNamespace {
7053 buffer_id: *buf_id,
7054 namespace: OverlayNamespace::from_string(ns.clone()),
7055 });
7056 }
7057 }
7058
7059 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
7065 std::collections::HashSet::new();
7066 for (buf_id, ns) in &tracked.line_indicator_namespaces {
7067 if seen_li_ns.insert((buf_id.0, ns.clone())) {
7068 let _ = self
7069 .command_sender
7070 .send(PluginCommand::ClearLineIndicators {
7071 buffer_id: *buf_id,
7072 namespace: ns.clone(),
7073 });
7074 }
7075 }
7076
7077 let mut seen_vt: std::collections::HashSet<(usize, String)> =
7079 std::collections::HashSet::new();
7080 for (buf_id, vt_id) in &tracked.virtual_text_ids {
7081 if seen_vt.insert((buf_id.0, vt_id.clone())) {
7082 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
7083 buffer_id: *buf_id,
7084 virtual_text_id: vt_id.clone(),
7085 });
7086 }
7087 }
7088
7089 let mut seen_fe_ns: std::collections::HashSet<String> =
7091 std::collections::HashSet::new();
7092 for ns in &tracked.file_explorer_namespaces {
7093 if seen_fe_ns.insert(ns.clone()) {
7094 let _ = self
7095 .command_sender
7096 .send(PluginCommand::ClearFileExplorerDecorations {
7097 namespace: ns.clone(),
7098 });
7099 }
7100 }
7101
7102 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
7104 for ctx_name in &tracked.contexts_set {
7105 if seen_ctx.insert(ctx_name.clone()) {
7106 let _ = self.command_sender.send(PluginCommand::SetContext {
7107 name: ctx_name.clone(),
7108 active: false,
7109 });
7110 }
7111 }
7112
7113 for process_id in &tracked.background_process_ids {
7117 let _ = self
7118 .command_sender
7119 .send(PluginCommand::KillBackgroundProcess {
7120 process_id: *process_id,
7121 });
7122 }
7123
7124 for group_id in &tracked.scroll_sync_group_ids {
7126 let _ = self
7127 .command_sender
7128 .send(PluginCommand::RemoveScrollSyncGroup {
7129 group_id: *group_id,
7130 });
7131 }
7132
7133 for buffer_id in &tracked.virtual_buffer_ids {
7135 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7136 buffer_id: *buffer_id,
7137 });
7138 }
7139
7140 for buffer_id in &tracked.composite_buffer_ids {
7142 let _ = self
7143 .command_sender
7144 .send(PluginCommand::CloseCompositeBuffer {
7145 buffer_id: *buffer_id,
7146 });
7147 }
7148
7149 for terminal_id in &tracked.terminal_ids {
7151 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7152 terminal_id: *terminal_id,
7153 });
7154 }
7155
7156 for handle in &tracked.watch_handles {
7160 let _ = self
7161 .command_sender
7162 .send(PluginCommand::UnwatchPath { handle: *handle });
7163 }
7164 }
7165
7166 if let Ok(mut owners) = self.async_resource_owners.lock() {
7168 owners.retain(|_, name| name != plugin_name);
7169 }
7170
7171 self.plugin_api_exports
7173 .borrow_mut()
7174 .retain(|_, (exporter, _)| exporter != plugin_name);
7175
7176 self.registered_command_names
7178 .borrow_mut()
7179 .retain(|_, pname| pname != plugin_name);
7180 self.registered_grammar_languages
7181 .borrow_mut()
7182 .retain(|_, pname| pname != plugin_name);
7183 self.registered_language_configs
7184 .borrow_mut()
7185 .retain(|_, pname| pname != plugin_name);
7186 self.registered_lsp_servers
7187 .borrow_mut()
7188 .retain(|_, pname| pname != plugin_name);
7189
7190 tracing::debug!(
7191 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7192 plugin_name
7193 );
7194 }
7195
7196 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7198 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7199
7200 self.services
7201 .set_js_execution_state(format!("hook '{}'", event_name));
7202
7203 let handlers = self
7204 .event_handlers
7205 .read()
7206 .expect("event_handlers poisoned")
7207 .get(event_name)
7208 .cloned();
7209 if let Some(handler_pairs) = handlers {
7210 let plugin_contexts = self.plugin_contexts.borrow();
7211 for handler in &handler_pairs {
7212 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7213 continue;
7214 };
7215 context.with(|ctx| {
7216 call_handler(&ctx, &handler.handler_name, event_data);
7217 });
7218 }
7219 }
7220
7221 self.services.clear_js_execution_state();
7222 Ok(true)
7223 }
7224
7225 pub fn has_handlers(&self, event_name: &str) -> bool {
7227 self.event_handlers
7228 .read()
7229 .expect("event_handlers poisoned")
7230 .get(event_name)
7231 .map(|v| !v.is_empty())
7232 .unwrap_or(false)
7233 }
7234
7235 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7239 let (lookup_name, text_input_char) =
7242 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7243 ("mode_text_input", Some(ch.to_string()))
7244 } else {
7245 (action_name, None)
7246 };
7247
7248 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7249 let (plugin_name, function_name) = match pair {
7250 Some(handler) => (handler.plugin_name, handler.handler_name),
7251 None => ("main".to_string(), lookup_name.to_string()),
7252 };
7253
7254 let plugin_contexts = self.plugin_contexts.borrow();
7255 let context = plugin_contexts
7256 .get(&plugin_name)
7257 .unwrap_or(&self.main_context);
7258
7259 self.services
7261 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7262
7263 tracing::info!(
7264 "start_action: BEGIN '{}' -> function '{}'",
7265 action_name,
7266 function_name
7267 );
7268
7269 let call_args = if let Some(ref ch) = text_input_char {
7272 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7273 format!("({{text:\"{}\"}})", escaped)
7274 } else {
7275 "()".to_string()
7276 };
7277
7278 let code = format!(
7279 r#"
7280 (function() {{
7281 console.log('[JS] start_action: calling {fn}');
7282 try {{
7283 if (typeof globalThis.{fn} === 'function') {{
7284 console.log('[JS] start_action: {fn} is a function, invoking...');
7285 globalThis.{fn}{args};
7286 console.log('[JS] start_action: {fn} invoked (may be async)');
7287 }} else {{
7288 console.error('[JS] Action {action} is not defined as a global function');
7289 }}
7290 }} catch (e) {{
7291 console.error('[JS] Action {action} error:', e);
7292 }}
7293 }})();
7294 "#,
7295 fn = function_name,
7296 action = action_name,
7297 args = call_args
7298 );
7299
7300 tracing::info!("start_action: evaluating JS code");
7301 context.with(|ctx| {
7302 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7303 log_js_error(&ctx, e, &format!("action {}", action_name));
7304 }
7305 tracing::info!("start_action: running pending microtasks");
7306 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7308 tracing::info!("start_action: executed {} pending jobs", count);
7309 });
7310
7311 tracing::info!("start_action: END '{}'", action_name);
7312
7313 self.services.clear_js_execution_state();
7315
7316 Ok(())
7317 }
7318
7319 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7321 let pair = self.registered_actions.borrow().get(action_name).cloned();
7323 let (plugin_name, function_name) = match pair {
7324 Some(handler) => (handler.plugin_name, handler.handler_name),
7325 None => ("main".to_string(), action_name.to_string()),
7326 };
7327
7328 let plugin_contexts = self.plugin_contexts.borrow();
7329 let context = plugin_contexts
7330 .get(&plugin_name)
7331 .unwrap_or(&self.main_context);
7332
7333 tracing::debug!(
7334 "execute_action: '{}' -> function '{}'",
7335 action_name,
7336 function_name
7337 );
7338
7339 let code = format!(
7342 r#"
7343 (async function() {{
7344 try {{
7345 if (typeof globalThis.{fn} === 'function') {{
7346 const result = globalThis.{fn}();
7347 // If it's a Promise, await it
7348 if (result && typeof result.then === 'function') {{
7349 await result;
7350 }}
7351 }} else {{
7352 console.error('Action {action} is not defined as a global function');
7353 }}
7354 }} catch (e) {{
7355 console.error('Action {action} error:', e);
7356 }}
7357 }})();
7358 "#,
7359 fn = function_name,
7360 action = action_name
7361 );
7362
7363 context.with(|ctx| {
7364 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7366 Ok(value) => {
7367 if value.is_object() {
7369 if let Some(obj) = value.as_object() {
7370 if obj.get::<_, rquickjs::Function>("then").is_ok() {
7372 run_pending_jobs_checked(
7375 &ctx,
7376 &format!("execute_action {} promise", action_name),
7377 );
7378 }
7379 }
7380 }
7381 }
7382 Err(e) => {
7383 log_js_error(&ctx, e, &format!("action {}", action_name));
7384 }
7385 }
7386 });
7387
7388 Ok(())
7389 }
7390
7391 pub fn poll_event_loop_once(&mut self) -> bool {
7393 let mut had_work = false;
7394
7395 self.main_context.with(|ctx| {
7397 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7398 if count > 0 {
7399 had_work = true;
7400 }
7401 });
7402
7403 let contexts = self.plugin_contexts.borrow().clone();
7405 for (name, context) in contexts {
7406 context.with(|ctx| {
7407 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7408 if count > 0 {
7409 had_work = true;
7410 }
7411 });
7412 }
7413 had_work
7414 }
7415
7416 pub fn send_status(&self, message: String) {
7418 let _ = self
7419 .command_sender
7420 .send(PluginCommand::SetStatus { message });
7421 }
7422
7423 pub fn send_hook_completed(&self, hook_name: String) {
7427 let _ = self
7428 .command_sender
7429 .send(PluginCommand::HookCompleted { hook_name });
7430 }
7431
7432 pub fn resolve_callback(
7437 &mut self,
7438 callback_id: fresh_core::api::JsCallbackId,
7439 result_json: &str,
7440 ) {
7441 let id = callback_id.as_u64();
7442 tracing::debug!("resolve_callback: starting for callback_id={}", id);
7443
7444 let plugin_name = {
7446 let mut contexts = self.callback_contexts.borrow_mut();
7447 contexts.remove(&id)
7448 };
7449
7450 let Some(name) = plugin_name else {
7451 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7452 return;
7453 };
7454
7455 let plugin_contexts = self.plugin_contexts.borrow();
7456 let Some(context) = plugin_contexts.get(&name) else {
7457 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7458 return;
7459 };
7460
7461 context.with(|ctx| {
7462 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7464 Ok(v) => v,
7465 Err(e) => {
7466 tracing::error!(
7467 "resolve_callback: failed to parse JSON for callback_id={}: {}",
7468 id,
7469 e
7470 );
7471 return;
7472 }
7473 };
7474
7475 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7477 Ok(v) => v,
7478 Err(e) => {
7479 tracing::error!(
7480 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7481 id,
7482 e
7483 );
7484 return;
7485 }
7486 };
7487
7488 let globals = ctx.globals();
7490 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7491 Ok(f) => f,
7492 Err(e) => {
7493 tracing::error!(
7494 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7495 id,
7496 e
7497 );
7498 return;
7499 }
7500 };
7501
7502 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7504 log_js_error(&ctx, e, &format!("resolving callback {}", id));
7505 }
7506
7507 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7509 tracing::info!(
7510 "resolve_callback: executed {} pending jobs for callback_id={}",
7511 job_count,
7512 id
7513 );
7514 });
7515 }
7516
7517 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7519 let id = callback_id.as_u64();
7520
7521 let plugin_name = {
7523 let mut contexts = self.callback_contexts.borrow_mut();
7524 contexts.remove(&id)
7525 };
7526
7527 let Some(name) = plugin_name else {
7528 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
7529 return;
7530 };
7531
7532 let plugin_contexts = self.plugin_contexts.borrow();
7533 let Some(context) = plugin_contexts.get(&name) else {
7534 tracing::warn!("reject_callback: Context lost for plugin {}", name);
7535 return;
7536 };
7537
7538 context.with(|ctx| {
7539 let globals = ctx.globals();
7541 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
7542 Ok(f) => f,
7543 Err(e) => {
7544 tracing::error!(
7545 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
7546 id,
7547 e
7548 );
7549 return;
7550 }
7551 };
7552
7553 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
7555 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
7556 }
7557
7558 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
7560 });
7561 }
7562}
7563
7564#[cfg(test)]
7565mod tests {
7566 use super::*;
7567 use fresh_core::api::{BufferInfo, CursorInfo};
7568 use std::sync::mpsc;
7569
7570 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
7572 let (tx, rx) = mpsc::channel();
7573 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7574 let services = Arc::new(TestServiceBridge::new());
7575 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7576 (backend, rx)
7577 }
7578
7579 struct TestServiceBridge {
7580 en_strings: std::sync::Mutex<HashMap<String, String>>,
7581 }
7582
7583 impl TestServiceBridge {
7584 fn new() -> Self {
7585 Self {
7586 en_strings: std::sync::Mutex::new(HashMap::new()),
7587 }
7588 }
7589 }
7590
7591 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
7592 fn as_any(&self) -> &dyn std::any::Any {
7593 self
7594 }
7595 fn translate(
7596 &self,
7597 _plugin_name: &str,
7598 key: &str,
7599 _args: &HashMap<String, String>,
7600 ) -> String {
7601 self.en_strings
7602 .lock()
7603 .unwrap()
7604 .get(key)
7605 .cloned()
7606 .unwrap_or_else(|| key.to_string())
7607 }
7608 fn current_locale(&self) -> String {
7609 "en".to_string()
7610 }
7611 fn set_js_execution_state(&self, _state: String) {}
7612 fn clear_js_execution_state(&self) {}
7613 fn get_theme_schema(&self) -> serde_json::Value {
7614 serde_json::json!({})
7615 }
7616 fn get_builtin_themes(&self) -> serde_json::Value {
7617 serde_json::json!([])
7618 }
7619 fn get_all_themes(&self) -> serde_json::Value {
7620 serde_json::json!({})
7621 }
7622 fn register_command(&self, _command: fresh_core::command::Command) {}
7623 fn unregister_command(&self, _name: &str) {}
7624 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
7625 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
7626 fn plugins_dir(&self) -> std::path::PathBuf {
7627 std::path::PathBuf::from("/tmp/plugins")
7628 }
7629 fn config_dir(&self) -> std::path::PathBuf {
7630 std::path::PathBuf::from("/tmp/config")
7631 }
7632 fn data_dir(&self) -> std::path::PathBuf {
7633 std::path::PathBuf::from("/tmp/data")
7634 }
7635 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
7636 None
7637 }
7638 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7639 Err("not implemented in test".to_string())
7640 }
7641 fn theme_file_exists(&self, _name: &str) -> bool {
7642 false
7643 }
7644 }
7645
7646 #[test]
7647 fn test_quickjs_backend_creation() {
7648 let backend = QuickJsBackend::new();
7649 assert!(backend.is_ok());
7650 }
7651
7652 #[test]
7653 fn test_execute_simple_js() {
7654 let mut backend = QuickJsBackend::new().unwrap();
7655 let result = backend.execute_js("const x = 1 + 2;", "test.js");
7656 assert!(result.is_ok());
7657 }
7658
7659 #[test]
7660 fn test_event_handler_registration() {
7661 let backend = QuickJsBackend::new().unwrap();
7662
7663 assert!(!backend.has_handlers("test_event"));
7665
7666 backend
7668 .event_handlers
7669 .write()
7670 .unwrap()
7671 .entry("test_event".to_string())
7672 .or_default()
7673 .push(PluginHandler {
7674 plugin_name: "test".to_string(),
7675 handler_name: "testHandler".to_string(),
7676 });
7677
7678 assert!(backend.has_handlers("test_event"));
7680 }
7681
7682 #[test]
7685 fn test_api_set_status() {
7686 let (mut backend, rx) = create_test_backend();
7687
7688 backend
7689 .execute_js(
7690 r#"
7691 const editor = getEditor();
7692 editor.setStatus("Hello from test");
7693 "#,
7694 "test.js",
7695 )
7696 .unwrap();
7697
7698 let cmd = rx.try_recv().unwrap();
7699 match cmd {
7700 PluginCommand::SetStatus { message } => {
7701 assert_eq!(message, "Hello from test");
7702 }
7703 _ => panic!("Expected SetStatus command, got {:?}", cmd),
7704 }
7705 }
7706
7707 #[test]
7708 fn test_api_register_command() {
7709 let (mut backend, rx) = create_test_backend();
7710
7711 backend
7712 .execute_js(
7713 r#"
7714 const editor = getEditor();
7715 globalThis.myTestHandler = function() { };
7716 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
7717 "#,
7718 "test_plugin.js",
7719 )
7720 .unwrap();
7721
7722 let cmd = rx.try_recv().unwrap();
7723 match cmd {
7724 PluginCommand::RegisterCommand { command } => {
7725 assert_eq!(command.name, "Test Command");
7726 assert_eq!(command.description, "A test command");
7727 assert_eq!(command.plugin_name, "test_plugin");
7729 }
7730 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
7731 }
7732 }
7733
7734 #[test]
7735 fn test_api_define_mode() {
7736 let (mut backend, rx) = create_test_backend();
7737
7738 backend
7739 .execute_js(
7740 r#"
7741 const editor = getEditor();
7742 editor.defineMode("test-mode", [
7743 ["a", "action_a"],
7744 ["b", "action_b"]
7745 ]);
7746 "#,
7747 "test.js",
7748 )
7749 .unwrap();
7750
7751 let cmd = rx.try_recv().unwrap();
7752 match cmd {
7753 PluginCommand::DefineMode {
7754 name,
7755 bindings,
7756 read_only,
7757 allow_text_input,
7758 inherit_normal_bindings,
7759 plugin_name,
7760 } => {
7761 assert_eq!(name, "test-mode");
7762 assert_eq!(bindings.len(), 2);
7763 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
7764 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
7765 assert!(!read_only);
7766 assert!(!allow_text_input);
7767 assert!(!inherit_normal_bindings);
7768 assert!(plugin_name.is_some());
7769 }
7770 _ => panic!("Expected DefineMode, got {:?}", cmd),
7771 }
7772 }
7773
7774 #[test]
7775 fn test_api_set_editor_mode() {
7776 let (mut backend, rx) = create_test_backend();
7777
7778 backend
7779 .execute_js(
7780 r#"
7781 const editor = getEditor();
7782 editor.setEditorMode("vi-normal");
7783 "#,
7784 "test.js",
7785 )
7786 .unwrap();
7787
7788 let cmd = rx.try_recv().unwrap();
7789 match cmd {
7790 PluginCommand::SetEditorMode { mode } => {
7791 assert_eq!(mode, Some("vi-normal".to_string()));
7792 }
7793 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
7794 }
7795 }
7796
7797 #[test]
7798 fn test_api_clear_editor_mode() {
7799 let (mut backend, rx) = create_test_backend();
7800
7801 backend
7802 .execute_js(
7803 r#"
7804 const editor = getEditor();
7805 editor.setEditorMode(null);
7806 "#,
7807 "test.js",
7808 )
7809 .unwrap();
7810
7811 let cmd = rx.try_recv().unwrap();
7812 match cmd {
7813 PluginCommand::SetEditorMode { mode } => {
7814 assert!(mode.is_none());
7815 }
7816 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
7817 }
7818 }
7819
7820 #[test]
7821 fn test_api_insert_at_cursor() {
7822 let (mut backend, rx) = create_test_backend();
7823
7824 backend
7825 .execute_js(
7826 r#"
7827 const editor = getEditor();
7828 editor.insertAtCursor("Hello, World!");
7829 "#,
7830 "test.js",
7831 )
7832 .unwrap();
7833
7834 let cmd = rx.try_recv().unwrap();
7835 match cmd {
7836 PluginCommand::InsertAtCursor { text } => {
7837 assert_eq!(text, "Hello, World!");
7838 }
7839 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
7840 }
7841 }
7842
7843 #[test]
7844 fn test_api_set_context() {
7845 let (mut backend, rx) = create_test_backend();
7846
7847 backend
7848 .execute_js(
7849 r#"
7850 const editor = getEditor();
7851 editor.setContext("myContext", true);
7852 "#,
7853 "test.js",
7854 )
7855 .unwrap();
7856
7857 let cmd = rx.try_recv().unwrap();
7858 match cmd {
7859 PluginCommand::SetContext { name, active } => {
7860 assert_eq!(name, "myContext");
7861 assert!(active);
7862 }
7863 _ => panic!("Expected SetContext, got {:?}", cmd),
7864 }
7865 }
7866
7867 #[tokio::test]
7868 async fn test_execute_action_sync_function() {
7869 let (mut backend, rx) = create_test_backend();
7870
7871 backend.registered_actions.borrow_mut().insert(
7873 "my_sync_action".to_string(),
7874 PluginHandler {
7875 plugin_name: "test".to_string(),
7876 handler_name: "my_sync_action".to_string(),
7877 },
7878 );
7879
7880 backend
7882 .execute_js(
7883 r#"
7884 const editor = getEditor();
7885 globalThis.my_sync_action = function() {
7886 editor.setStatus("sync action executed");
7887 };
7888 "#,
7889 "test.js",
7890 )
7891 .unwrap();
7892
7893 while rx.try_recv().is_ok() {}
7895
7896 backend.execute_action("my_sync_action").await.unwrap();
7898
7899 let cmd = rx.try_recv().unwrap();
7901 match cmd {
7902 PluginCommand::SetStatus { message } => {
7903 assert_eq!(message, "sync action executed");
7904 }
7905 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
7906 }
7907 }
7908
7909 #[tokio::test]
7910 async fn test_execute_action_async_function() {
7911 let (mut backend, rx) = create_test_backend();
7912
7913 backend.registered_actions.borrow_mut().insert(
7915 "my_async_action".to_string(),
7916 PluginHandler {
7917 plugin_name: "test".to_string(),
7918 handler_name: "my_async_action".to_string(),
7919 },
7920 );
7921
7922 backend
7924 .execute_js(
7925 r#"
7926 const editor = getEditor();
7927 globalThis.my_async_action = async function() {
7928 await Promise.resolve();
7929 editor.setStatus("async action executed");
7930 };
7931 "#,
7932 "test.js",
7933 )
7934 .unwrap();
7935
7936 while rx.try_recv().is_ok() {}
7938
7939 backend.execute_action("my_async_action").await.unwrap();
7941
7942 let cmd = rx.try_recv().unwrap();
7944 match cmd {
7945 PluginCommand::SetStatus { message } => {
7946 assert_eq!(message, "async action executed");
7947 }
7948 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
7949 }
7950 }
7951
7952 #[tokio::test]
7953 async fn test_execute_action_with_registered_handler() {
7954 let (mut backend, rx) = create_test_backend();
7955
7956 backend.registered_actions.borrow_mut().insert(
7958 "my_action".to_string(),
7959 PluginHandler {
7960 plugin_name: "test".to_string(),
7961 handler_name: "actual_handler_function".to_string(),
7962 },
7963 );
7964
7965 backend
7966 .execute_js(
7967 r#"
7968 const editor = getEditor();
7969 globalThis.actual_handler_function = function() {
7970 editor.setStatus("handler executed");
7971 };
7972 "#,
7973 "test.js",
7974 )
7975 .unwrap();
7976
7977 while rx.try_recv().is_ok() {}
7979
7980 backend.execute_action("my_action").await.unwrap();
7982
7983 let cmd = rx.try_recv().unwrap();
7984 match cmd {
7985 PluginCommand::SetStatus { message } => {
7986 assert_eq!(message, "handler executed");
7987 }
7988 _ => panic!("Expected SetStatus, got {:?}", cmd),
7989 }
7990 }
7991
7992 #[test]
7993 fn test_api_on_event_registration() {
7994 let (mut backend, _rx) = create_test_backend();
7995
7996 backend
7997 .execute_js(
7998 r#"
7999 const editor = getEditor();
8000 globalThis.myEventHandler = function() { };
8001 editor.on("bufferSave", "myEventHandler");
8002 "#,
8003 "test.js",
8004 )
8005 .unwrap();
8006
8007 assert!(backend.has_handlers("bufferSave"));
8008 }
8009
8010 #[test]
8011 fn test_api_off_event_unregistration() {
8012 let (mut backend, _rx) = create_test_backend();
8013
8014 backend
8015 .execute_js(
8016 r#"
8017 const editor = getEditor();
8018 globalThis.myEventHandler = function() { };
8019 editor.on("bufferSave", "myEventHandler");
8020 editor.off("bufferSave", "myEventHandler");
8021 "#,
8022 "test.js",
8023 )
8024 .unwrap();
8025
8026 assert!(!backend.has_handlers("bufferSave"));
8028 }
8029
8030 #[tokio::test]
8031 async fn test_emit_event() {
8032 let (mut backend, rx) = create_test_backend();
8033
8034 backend
8035 .execute_js(
8036 r#"
8037 const editor = getEditor();
8038 globalThis.onSaveHandler = function(data) {
8039 editor.setStatus("saved: " + JSON.stringify(data));
8040 };
8041 editor.on("bufferSave", "onSaveHandler");
8042 "#,
8043 "test.js",
8044 )
8045 .unwrap();
8046
8047 while rx.try_recv().is_ok() {}
8049
8050 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
8052 backend.emit("bufferSave", &event_data).await.unwrap();
8053
8054 let cmd = rx.try_recv().unwrap();
8055 match cmd {
8056 PluginCommand::SetStatus { message } => {
8057 assert!(message.contains("/test.txt"));
8058 }
8059 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8060 }
8061 }
8062
8063 #[test]
8064 fn test_api_copy_to_clipboard() {
8065 let (mut backend, rx) = create_test_backend();
8066
8067 backend
8068 .execute_js(
8069 r#"
8070 const editor = getEditor();
8071 editor.copyToClipboard("clipboard text");
8072 "#,
8073 "test.js",
8074 )
8075 .unwrap();
8076
8077 let cmd = rx.try_recv().unwrap();
8078 match cmd {
8079 PluginCommand::SetClipboard { text } => {
8080 assert_eq!(text, "clipboard text");
8081 }
8082 _ => panic!("Expected SetClipboard, got {:?}", cmd),
8083 }
8084 }
8085
8086 #[test]
8087 fn test_api_open_file() {
8088 let (mut backend, rx) = create_test_backend();
8089
8090 backend
8092 .execute_js(
8093 r#"
8094 const editor = getEditor();
8095 editor.openFile("/path/to/file.txt", null, null);
8096 "#,
8097 "test.js",
8098 )
8099 .unwrap();
8100
8101 let cmd = rx.try_recv().unwrap();
8102 match cmd {
8103 PluginCommand::OpenFileAtLocation { path, line, column } => {
8104 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
8105 assert!(line.is_none());
8106 assert!(column.is_none());
8107 }
8108 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8109 }
8110 }
8111
8112 #[test]
8113 fn test_api_delete_range() {
8114 let (mut backend, rx) = create_test_backend();
8115
8116 backend
8118 .execute_js(
8119 r#"
8120 const editor = getEditor();
8121 editor.deleteRange(0, 10, 20);
8122 "#,
8123 "test.js",
8124 )
8125 .unwrap();
8126
8127 let cmd = rx.try_recv().unwrap();
8128 match cmd {
8129 PluginCommand::DeleteRange { range, .. } => {
8130 assert_eq!(range.start, 10);
8131 assert_eq!(range.end, 20);
8132 }
8133 _ => panic!("Expected DeleteRange, got {:?}", cmd),
8134 }
8135 }
8136
8137 #[test]
8138 fn test_api_insert_text() {
8139 let (mut backend, rx) = create_test_backend();
8140
8141 backend
8143 .execute_js(
8144 r#"
8145 const editor = getEditor();
8146 editor.insertText(0, 5, "inserted");
8147 "#,
8148 "test.js",
8149 )
8150 .unwrap();
8151
8152 let cmd = rx.try_recv().unwrap();
8153 match cmd {
8154 PluginCommand::InsertText { position, text, .. } => {
8155 assert_eq!(position, 5);
8156 assert_eq!(text, "inserted");
8157 }
8158 _ => panic!("Expected InsertText, got {:?}", cmd),
8159 }
8160 }
8161
8162 #[test]
8163 fn test_api_set_buffer_cursor() {
8164 let (mut backend, rx) = create_test_backend();
8165
8166 backend
8168 .execute_js(
8169 r#"
8170 const editor = getEditor();
8171 editor.setBufferCursor(0, 100);
8172 "#,
8173 "test.js",
8174 )
8175 .unwrap();
8176
8177 let cmd = rx.try_recv().unwrap();
8178 match cmd {
8179 PluginCommand::SetBufferCursor { position, .. } => {
8180 assert_eq!(position, 100);
8181 }
8182 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8183 }
8184 }
8185
8186 #[test]
8187 fn test_api_get_cursor_position_from_state() {
8188 let (tx, _rx) = mpsc::channel();
8189 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8190
8191 {
8193 let mut state = state_snapshot.write().unwrap();
8194 state.primary_cursor = Some(CursorInfo {
8195 position: 42,
8196 selection: None,
8197 });
8198 }
8199
8200 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8201 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8202
8203 backend
8205 .execute_js(
8206 r#"
8207 const editor = getEditor();
8208 const pos = editor.getCursorPosition();
8209 globalThis._testResult = pos;
8210 "#,
8211 "test.js",
8212 )
8213 .unwrap();
8214
8215 backend
8217 .plugin_contexts
8218 .borrow()
8219 .get("test")
8220 .unwrap()
8221 .clone()
8222 .with(|ctx| {
8223 let global = ctx.globals();
8224 let result: u32 = global.get("_testResult").unwrap();
8225 assert_eq!(result, 42);
8226 });
8227 }
8228
8229 #[test]
8230 fn test_api_path_functions() {
8231 let (mut backend, _rx) = create_test_backend();
8232
8233 #[cfg(windows)]
8236 let absolute_path = r#"C:\\foo\\bar"#;
8237 #[cfg(not(windows))]
8238 let absolute_path = "/foo/bar";
8239
8240 let js_code = format!(
8242 r#"
8243 const editor = getEditor();
8244 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8245 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8246 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8247 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8248 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8249 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8250 "#,
8251 absolute_path
8252 );
8253 backend.execute_js(&js_code, "test.js").unwrap();
8254
8255 backend
8256 .plugin_contexts
8257 .borrow()
8258 .get("test")
8259 .unwrap()
8260 .clone()
8261 .with(|ctx| {
8262 let global = ctx.globals();
8263 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8264 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8265 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8266 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8267 assert!(!global.get::<_, bool>("_isRelative").unwrap());
8268 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8269 });
8270 }
8271
8272 #[test]
8280 fn test_path_join_preserves_unc_prefix() {
8281 let (mut backend, _rx) = create_test_backend();
8282 backend
8283 .execute_js(
8284 r#"
8285 const editor = getEditor();
8286 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8287 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8288 globalThis._posix = editor.pathJoin("/foo", "bar");
8289 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8290 "#,
8291 "test.js",
8292 )
8293 .unwrap();
8294
8295 backend
8296 .plugin_contexts
8297 .borrow()
8298 .get("test")
8299 .unwrap()
8300 .clone()
8301 .with(|ctx| {
8302 let global = ctx.globals();
8303 assert_eq!(
8304 global.get::<_, String>("_unc").unwrap(),
8305 "//?/C:/workspace/.devcontainer/devcontainer.json",
8306 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
8307 );
8308 assert_eq!(
8309 global.get::<_, String>("_unc_fwd").unwrap(),
8310 "//?/C:/workspace/.devcontainer/devcontainer.json",
8311 "UNC prefix in forward-slash form stays as `//`",
8312 );
8313 assert_eq!(
8314 global.get::<_, String>("_posix").unwrap(),
8315 "/foo/bar",
8316 "POSIX absolute paths keep their single leading slash",
8317 );
8318 assert_eq!(
8319 global.get::<_, String>("_drive").unwrap(),
8320 "C:/foo/bar",
8321 "Windows drive-letter paths have no leading slash",
8322 );
8323 });
8324 }
8325
8326 #[test]
8327 fn test_file_uri_to_path_and_back() {
8328 let (mut backend, _rx) = create_test_backend();
8329
8330 #[cfg(not(windows))]
8332 let js_code = r#"
8333 const editor = getEditor();
8334 // Basic file URI to path
8335 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
8336 // Percent-encoded characters
8337 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
8338 // Invalid URI returns empty string
8339 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8340 // Path to file URI
8341 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
8342 // Round-trip
8343 globalThis._roundtrip = editor.fileUriToPath(
8344 editor.pathToFileUri("/home/user/file.txt")
8345 );
8346 "#;
8347
8348 #[cfg(windows)]
8349 let js_code = r#"
8350 const editor = getEditor();
8351 // Windows URI with encoded colon (the bug from issue #1071)
8352 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
8353 // Windows URI with normal colon
8354 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
8355 // Invalid URI returns empty string
8356 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8357 // Path to file URI
8358 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
8359 // Round-trip
8360 globalThis._roundtrip = editor.fileUriToPath(
8361 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
8362 );
8363 "#;
8364
8365 backend.execute_js(js_code, "test.js").unwrap();
8366
8367 backend
8368 .plugin_contexts
8369 .borrow()
8370 .get("test")
8371 .unwrap()
8372 .clone()
8373 .with(|ctx| {
8374 let global = ctx.globals();
8375
8376 #[cfg(not(windows))]
8377 {
8378 assert_eq!(
8379 global.get::<_, String>("_path1").unwrap(),
8380 "/home/user/file.txt"
8381 );
8382 assert_eq!(
8383 global.get::<_, String>("_path2").unwrap(),
8384 "/home/user/my file.txt"
8385 );
8386 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8387 assert_eq!(
8388 global.get::<_, String>("_uri1").unwrap(),
8389 "file:///home/user/file.txt"
8390 );
8391 assert_eq!(
8392 global.get::<_, String>("_roundtrip").unwrap(),
8393 "/home/user/file.txt"
8394 );
8395 }
8396
8397 #[cfg(windows)]
8398 {
8399 assert_eq!(
8401 global.get::<_, String>("_path1").unwrap(),
8402 "C:\\Users\\admin\\Repos\\file.cs"
8403 );
8404 assert_eq!(
8405 global.get::<_, String>("_path2").unwrap(),
8406 "C:\\Users\\admin\\Repos\\file.cs"
8407 );
8408 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8409 assert_eq!(
8410 global.get::<_, String>("_uri1").unwrap(),
8411 "file:///C:/Users/admin/Repos/file.cs"
8412 );
8413 assert_eq!(
8414 global.get::<_, String>("_roundtrip").unwrap(),
8415 "C:\\Users\\admin\\Repos\\file.cs"
8416 );
8417 }
8418 });
8419 }
8420
8421 #[test]
8422 fn test_typescript_transpilation() {
8423 use fresh_parser_js::transpile_typescript;
8424
8425 let (mut backend, rx) = create_test_backend();
8426
8427 let ts_code = r#"
8429 const editor = getEditor();
8430 function greet(name: string): string {
8431 return "Hello, " + name;
8432 }
8433 editor.setStatus(greet("TypeScript"));
8434 "#;
8435
8436 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
8438
8439 backend.execute_js(&js_code, "test.js").unwrap();
8441
8442 let cmd = rx.try_recv().unwrap();
8443 match cmd {
8444 PluginCommand::SetStatus { message } => {
8445 assert_eq!(message, "Hello, TypeScript");
8446 }
8447 _ => panic!("Expected SetStatus, got {:?}", cmd),
8448 }
8449 }
8450
8451 #[test]
8452 fn test_api_get_buffer_text_sends_command() {
8453 let (mut backend, rx) = create_test_backend();
8454
8455 backend
8457 .execute_js(
8458 r#"
8459 const editor = getEditor();
8460 // Store the promise for later
8461 globalThis._textPromise = editor.getBufferText(0, 10, 20);
8462 "#,
8463 "test.js",
8464 )
8465 .unwrap();
8466
8467 let cmd = rx.try_recv().unwrap();
8469 match cmd {
8470 PluginCommand::GetBufferText {
8471 buffer_id,
8472 start,
8473 end,
8474 request_id,
8475 } => {
8476 assert_eq!(buffer_id.0, 0);
8477 assert_eq!(start, 10);
8478 assert_eq!(end, 20);
8479 assert!(request_id > 0); }
8481 _ => panic!("Expected GetBufferText, got {:?}", cmd),
8482 }
8483 }
8484
8485 #[test]
8486 fn test_api_get_buffer_text_resolves_callback() {
8487 let (mut backend, rx) = create_test_backend();
8488
8489 backend
8491 .execute_js(
8492 r#"
8493 const editor = getEditor();
8494 globalThis._resolvedText = null;
8495 editor.getBufferText(0, 0, 100).then(text => {
8496 globalThis._resolvedText = text;
8497 });
8498 "#,
8499 "test.js",
8500 )
8501 .unwrap();
8502
8503 let request_id = match rx.try_recv().unwrap() {
8505 PluginCommand::GetBufferText { request_id, .. } => request_id,
8506 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
8507 };
8508
8509 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
8511
8512 backend
8514 .plugin_contexts
8515 .borrow()
8516 .get("test")
8517 .unwrap()
8518 .clone()
8519 .with(|ctx| {
8520 run_pending_jobs_checked(&ctx, "test async getText");
8521 });
8522
8523 backend
8525 .plugin_contexts
8526 .borrow()
8527 .get("test")
8528 .unwrap()
8529 .clone()
8530 .with(|ctx| {
8531 let global = ctx.globals();
8532 let result: String = global.get("_resolvedText").unwrap();
8533 assert_eq!(result, "hello world");
8534 });
8535 }
8536
8537 #[test]
8538 fn test_plugin_translation() {
8539 let (mut backend, _rx) = create_test_backend();
8540
8541 backend
8543 .execute_js(
8544 r#"
8545 const editor = getEditor();
8546 globalThis._translated = editor.t("test.key");
8547 "#,
8548 "test.js",
8549 )
8550 .unwrap();
8551
8552 backend
8553 .plugin_contexts
8554 .borrow()
8555 .get("test")
8556 .unwrap()
8557 .clone()
8558 .with(|ctx| {
8559 let global = ctx.globals();
8560 let result: String = global.get("_translated").unwrap();
8562 assert_eq!(result, "test.key");
8563 });
8564 }
8565
8566 #[test]
8567 fn test_plugin_translation_with_registered_strings() {
8568 let (mut backend, _rx) = create_test_backend();
8569
8570 let mut en_strings = std::collections::HashMap::new();
8572 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
8573 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8574
8575 let mut strings = std::collections::HashMap::new();
8576 strings.insert("en".to_string(), en_strings);
8577
8578 if let Some(bridge) = backend
8580 .services
8581 .as_any()
8582 .downcast_ref::<TestServiceBridge>()
8583 {
8584 let mut en = bridge.en_strings.lock().unwrap();
8585 en.insert("greeting".to_string(), "Hello, World!".to_string());
8586 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8587 }
8588
8589 backend
8591 .execute_js(
8592 r#"
8593 const editor = getEditor();
8594 globalThis._greeting = editor.t("greeting");
8595 globalThis._prompt = editor.t("prompt.find_file");
8596 globalThis._missing = editor.t("nonexistent.key");
8597 "#,
8598 "test.js",
8599 )
8600 .unwrap();
8601
8602 backend
8603 .plugin_contexts
8604 .borrow()
8605 .get("test")
8606 .unwrap()
8607 .clone()
8608 .with(|ctx| {
8609 let global = ctx.globals();
8610 let greeting: String = global.get("_greeting").unwrap();
8611 assert_eq!(greeting, "Hello, World!");
8612
8613 let prompt: String = global.get("_prompt").unwrap();
8614 assert_eq!(prompt, "Find file: ");
8615
8616 let missing: String = global.get("_missing").unwrap();
8618 assert_eq!(missing, "nonexistent.key");
8619 });
8620 }
8621
8622 #[test]
8625 fn test_api_set_line_indicator() {
8626 let (mut backend, rx) = create_test_backend();
8627
8628 backend
8629 .execute_js(
8630 r#"
8631 const editor = getEditor();
8632 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
8633 "#,
8634 "test.js",
8635 )
8636 .unwrap();
8637
8638 let cmd = rx.try_recv().unwrap();
8639 match cmd {
8640 PluginCommand::SetLineIndicator {
8641 buffer_id,
8642 line,
8643 namespace,
8644 symbol,
8645 color,
8646 priority,
8647 } => {
8648 assert_eq!(buffer_id.0, 1);
8649 assert_eq!(line, 5);
8650 assert_eq!(namespace, "test-ns");
8651 assert_eq!(symbol, "●");
8652 assert_eq!(color, (255, 0, 0));
8653 assert_eq!(priority, 10);
8654 }
8655 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
8656 }
8657 }
8658
8659 #[test]
8660 fn test_api_clear_line_indicators() {
8661 let (mut backend, rx) = create_test_backend();
8662
8663 backend
8664 .execute_js(
8665 r#"
8666 const editor = getEditor();
8667 editor.clearLineIndicators(1, "test-ns");
8668 "#,
8669 "test.js",
8670 )
8671 .unwrap();
8672
8673 let cmd = rx.try_recv().unwrap();
8674 match cmd {
8675 PluginCommand::ClearLineIndicators {
8676 buffer_id,
8677 namespace,
8678 } => {
8679 assert_eq!(buffer_id.0, 1);
8680 assert_eq!(namespace, "test-ns");
8681 }
8682 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
8683 }
8684 }
8685
8686 #[test]
8689 fn test_api_create_virtual_buffer_sends_command() {
8690 let (mut backend, rx) = create_test_backend();
8691
8692 backend
8693 .execute_js(
8694 r#"
8695 const editor = getEditor();
8696 editor.createVirtualBuffer({
8697 name: "*Test Buffer*",
8698 mode: "test-mode",
8699 readOnly: true,
8700 entries: [
8701 { text: "Line 1\n", properties: { type: "header" } },
8702 { text: "Line 2\n", properties: { type: "content" } }
8703 ],
8704 showLineNumbers: false,
8705 showCursors: true,
8706 editingDisabled: true
8707 });
8708 "#,
8709 "test.js",
8710 )
8711 .unwrap();
8712
8713 let cmd = rx.try_recv().unwrap();
8714 match cmd {
8715 PluginCommand::CreateVirtualBufferWithContent {
8716 name,
8717 mode,
8718 read_only,
8719 entries,
8720 show_line_numbers,
8721 show_cursors,
8722 editing_disabled,
8723 ..
8724 } => {
8725 assert_eq!(name, "*Test Buffer*");
8726 assert_eq!(mode, "test-mode");
8727 assert!(read_only);
8728 assert_eq!(entries.len(), 2);
8729 assert_eq!(entries[0].text, "Line 1\n");
8730 assert!(!show_line_numbers);
8731 assert!(show_cursors);
8732 assert!(editing_disabled);
8733 }
8734 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
8735 }
8736 }
8737
8738 #[test]
8739 fn test_api_set_virtual_buffer_content() {
8740 let (mut backend, rx) = create_test_backend();
8741
8742 backend
8743 .execute_js(
8744 r#"
8745 const editor = getEditor();
8746 editor.setVirtualBufferContent(5, [
8747 { text: "New content\n", properties: { type: "updated" } }
8748 ]);
8749 "#,
8750 "test.js",
8751 )
8752 .unwrap();
8753
8754 let cmd = rx.try_recv().unwrap();
8755 match cmd {
8756 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
8757 assert_eq!(buffer_id.0, 5);
8758 assert_eq!(entries.len(), 1);
8759 assert_eq!(entries[0].text, "New content\n");
8760 }
8761 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
8762 }
8763 }
8764
8765 #[test]
8768 fn test_api_add_overlay() {
8769 let (mut backend, rx) = create_test_backend();
8770
8771 backend
8772 .execute_js(
8773 r#"
8774 const editor = getEditor();
8775 editor.addOverlay(1, "highlight", 10, 20, {
8776 fg: [255, 128, 0],
8777 bg: [50, 50, 50],
8778 bold: true,
8779 });
8780 "#,
8781 "test.js",
8782 )
8783 .unwrap();
8784
8785 let cmd = rx.try_recv().unwrap();
8786 match cmd {
8787 PluginCommand::AddOverlay {
8788 buffer_id,
8789 namespace,
8790 range,
8791 options,
8792 } => {
8793 use fresh_core::api::OverlayColorSpec;
8794 assert_eq!(buffer_id.0, 1);
8795 assert!(namespace.is_some());
8796 assert_eq!(namespace.unwrap().as_str(), "highlight");
8797 assert_eq!(range, 10..20);
8798 assert!(matches!(
8799 options.fg,
8800 Some(OverlayColorSpec::Rgb(255, 128, 0))
8801 ));
8802 assert!(matches!(
8803 options.bg,
8804 Some(OverlayColorSpec::Rgb(50, 50, 50))
8805 ));
8806 assert!(!options.underline);
8807 assert!(options.bold);
8808 assert!(!options.italic);
8809 assert!(!options.extend_to_line_end);
8810 }
8811 _ => panic!("Expected AddOverlay, got {:?}", cmd),
8812 }
8813 }
8814
8815 #[test]
8816 fn test_api_add_overlay_with_theme_keys() {
8817 let (mut backend, rx) = create_test_backend();
8818
8819 backend
8820 .execute_js(
8821 r#"
8822 const editor = getEditor();
8823 // Test with theme keys for colors
8824 editor.addOverlay(1, "themed", 0, 10, {
8825 fg: "ui.status_bar_fg",
8826 bg: "editor.selection_bg",
8827 });
8828 "#,
8829 "test.js",
8830 )
8831 .unwrap();
8832
8833 let cmd = rx.try_recv().unwrap();
8834 match cmd {
8835 PluginCommand::AddOverlay {
8836 buffer_id,
8837 namespace,
8838 range,
8839 options,
8840 } => {
8841 use fresh_core::api::OverlayColorSpec;
8842 assert_eq!(buffer_id.0, 1);
8843 assert!(namespace.is_some());
8844 assert_eq!(namespace.unwrap().as_str(), "themed");
8845 assert_eq!(range, 0..10);
8846 assert!(matches!(
8847 &options.fg,
8848 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
8849 ));
8850 assert!(matches!(
8851 &options.bg,
8852 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
8853 ));
8854 assert!(!options.underline);
8855 assert!(!options.bold);
8856 assert!(!options.italic);
8857 assert!(!options.extend_to_line_end);
8858 }
8859 _ => panic!("Expected AddOverlay, got {:?}", cmd),
8860 }
8861 }
8862
8863 #[test]
8864 fn test_api_clear_namespace() {
8865 let (mut backend, rx) = create_test_backend();
8866
8867 backend
8868 .execute_js(
8869 r#"
8870 const editor = getEditor();
8871 editor.clearNamespace(1, "highlight");
8872 "#,
8873 "test.js",
8874 )
8875 .unwrap();
8876
8877 let cmd = rx.try_recv().unwrap();
8878 match cmd {
8879 PluginCommand::ClearNamespace {
8880 buffer_id,
8881 namespace,
8882 } => {
8883 assert_eq!(buffer_id.0, 1);
8884 assert_eq!(namespace.as_str(), "highlight");
8885 }
8886 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
8887 }
8888 }
8889
8890 #[test]
8893 fn test_api_get_theme_schema() {
8894 let (mut backend, _rx) = create_test_backend();
8895
8896 backend
8897 .execute_js(
8898 r#"
8899 const editor = getEditor();
8900 const schema = editor.getThemeSchema();
8901 globalThis._isObject = typeof schema === 'object' && schema !== null;
8902 "#,
8903 "test.js",
8904 )
8905 .unwrap();
8906
8907 backend
8908 .plugin_contexts
8909 .borrow()
8910 .get("test")
8911 .unwrap()
8912 .clone()
8913 .with(|ctx| {
8914 let global = ctx.globals();
8915 let is_object: bool = global.get("_isObject").unwrap();
8916 assert!(is_object);
8918 });
8919 }
8920
8921 #[test]
8922 fn test_api_get_builtin_themes() {
8923 let (mut backend, _rx) = create_test_backend();
8924
8925 backend
8926 .execute_js(
8927 r#"
8928 const editor = getEditor();
8929 const themes = editor.getBuiltinThemes();
8930 globalThis._isObject = typeof themes === 'object' && themes !== null;
8931 "#,
8932 "test.js",
8933 )
8934 .unwrap();
8935
8936 backend
8937 .plugin_contexts
8938 .borrow()
8939 .get("test")
8940 .unwrap()
8941 .clone()
8942 .with(|ctx| {
8943 let global = ctx.globals();
8944 let is_object: bool = global.get("_isObject").unwrap();
8945 assert!(is_object);
8947 });
8948 }
8949
8950 #[test]
8951 fn test_api_apply_theme() {
8952 let (mut backend, rx) = create_test_backend();
8953
8954 backend
8955 .execute_js(
8956 r#"
8957 const editor = getEditor();
8958 editor.applyTheme("dark");
8959 "#,
8960 "test.js",
8961 )
8962 .unwrap();
8963
8964 let cmd = rx.try_recv().unwrap();
8965 match cmd {
8966 PluginCommand::ApplyTheme { theme_name } => {
8967 assert_eq!(theme_name, "dark");
8968 }
8969 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
8970 }
8971 }
8972
8973 #[test]
8974 fn test_api_override_theme_colors_round_trip() {
8975 let (mut backend, rx) = create_test_backend();
8978
8979 backend
8980 .execute_js(
8981 r#"
8982 const editor = getEditor();
8983 editor.overrideThemeColors({
8984 "editor.bg": [10, 20, 30],
8985 "editor.fg": [220, 221, 222],
8986 });
8987 "#,
8988 "test.js",
8989 )
8990 .unwrap();
8991
8992 let cmd = rx.try_recv().unwrap();
8993 match cmd {
8994 PluginCommand::OverrideThemeColors { overrides } => {
8995 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
8996 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
8997 assert_eq!(overrides.len(), 2);
8998 }
8999 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
9000 }
9001 }
9002
9003 #[test]
9004 fn test_api_override_theme_colors_clamps_out_of_range() {
9005 let (mut backend, rx) = create_test_backend();
9006
9007 backend
9008 .execute_js(
9009 r#"
9010 const editor = getEditor();
9011 editor.overrideThemeColors({
9012 "editor.bg": [-5, 300, 128],
9013 });
9014 "#,
9015 "test.js",
9016 )
9017 .unwrap();
9018
9019 match rx.try_recv().unwrap() {
9020 PluginCommand::OverrideThemeColors { overrides } => {
9021 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
9022 }
9023 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9024 }
9025 }
9026
9027 #[test]
9028 fn test_api_override_theme_colors_drops_malformed_entries() {
9029 let (mut backend, rx) = create_test_backend();
9032
9033 backend
9034 .execute_js(
9035 r#"
9036 const editor = getEditor();
9037 editor.overrideThemeColors({
9038 "editor.bg": [1, 2, 3],
9039 "not_an_array": "oops",
9040 "wrong_length": [1, 2],
9041 "floats_are_fine": [10.7, 20.2, 30.9],
9042 });
9043 "#,
9044 "test.js",
9045 )
9046 .unwrap();
9047
9048 match rx.try_recv().unwrap() {
9049 PluginCommand::OverrideThemeColors { overrides } => {
9050 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
9051 assert!(!overrides.contains_key("not_an_array"));
9052 assert!(!overrides.contains_key("wrong_length"));
9053 assert_eq!(
9055 overrides.get("floats_are_fine").copied(),
9056 Some([10, 20, 30])
9057 );
9058 }
9059 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9060 }
9061 }
9062
9063 #[test]
9064 fn test_api_get_theme_data_missing() {
9065 let (mut backend, _rx) = create_test_backend();
9066
9067 backend
9068 .execute_js(
9069 r#"
9070 const editor = getEditor();
9071 const data = editor.getThemeData("nonexistent");
9072 globalThis._isNull = data === null;
9073 "#,
9074 "test.js",
9075 )
9076 .unwrap();
9077
9078 backend
9079 .plugin_contexts
9080 .borrow()
9081 .get("test")
9082 .unwrap()
9083 .clone()
9084 .with(|ctx| {
9085 let global = ctx.globals();
9086 let is_null: bool = global.get("_isNull").unwrap();
9087 assert!(is_null);
9089 });
9090 }
9091
9092 #[test]
9093 fn test_api_get_theme_data_present() {
9094 let (tx, _rx) = mpsc::channel();
9096 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9097 let services = Arc::new(ThemeCacheTestBridge {
9098 inner: TestServiceBridge::new(),
9099 });
9100 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9101
9102 backend
9103 .execute_js(
9104 r#"
9105 const editor = getEditor();
9106 const data = editor.getThemeData("test-theme");
9107 globalThis._hasData = data !== null && typeof data === 'object';
9108 globalThis._name = data ? data.name : null;
9109 "#,
9110 "test.js",
9111 )
9112 .unwrap();
9113
9114 backend
9115 .plugin_contexts
9116 .borrow()
9117 .get("test")
9118 .unwrap()
9119 .clone()
9120 .with(|ctx| {
9121 let global = ctx.globals();
9122 let has_data: bool = global.get("_hasData").unwrap();
9123 assert!(has_data, "getThemeData should return theme object");
9124 let name: String = global.get("_name").unwrap();
9125 assert_eq!(name, "test-theme");
9126 });
9127 }
9128
9129 #[test]
9130 fn test_api_theme_file_exists() {
9131 let (mut backend, _rx) = create_test_backend();
9132
9133 backend
9134 .execute_js(
9135 r#"
9136 const editor = getEditor();
9137 globalThis._exists = editor.themeFileExists("anything");
9138 "#,
9139 "test.js",
9140 )
9141 .unwrap();
9142
9143 backend
9144 .plugin_contexts
9145 .borrow()
9146 .get("test")
9147 .unwrap()
9148 .clone()
9149 .with(|ctx| {
9150 let global = ctx.globals();
9151 let exists: bool = global.get("_exists").unwrap();
9152 assert!(!exists);
9154 });
9155 }
9156
9157 #[test]
9158 fn test_api_save_theme_file_error() {
9159 let (mut backend, _rx) = create_test_backend();
9160
9161 backend
9162 .execute_js(
9163 r#"
9164 const editor = getEditor();
9165 let threw = false;
9166 try {
9167 editor.saveThemeFile("test", "{}");
9168 } catch (e) {
9169 threw = true;
9170 }
9171 globalThis._threw = threw;
9172 "#,
9173 "test.js",
9174 )
9175 .unwrap();
9176
9177 backend
9178 .plugin_contexts
9179 .borrow()
9180 .get("test")
9181 .unwrap()
9182 .clone()
9183 .with(|ctx| {
9184 let global = ctx.globals();
9185 let threw: bool = global.get("_threw").unwrap();
9186 assert!(threw);
9188 });
9189 }
9190
9191 struct ThemeCacheTestBridge {
9193 inner: TestServiceBridge,
9194 }
9195
9196 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9197 fn as_any(&self) -> &dyn std::any::Any {
9198 self
9199 }
9200 fn translate(
9201 &self,
9202 plugin_name: &str,
9203 key: &str,
9204 args: &HashMap<String, String>,
9205 ) -> String {
9206 self.inner.translate(plugin_name, key, args)
9207 }
9208 fn current_locale(&self) -> String {
9209 self.inner.current_locale()
9210 }
9211 fn set_js_execution_state(&self, state: String) {
9212 self.inner.set_js_execution_state(state);
9213 }
9214 fn clear_js_execution_state(&self) {
9215 self.inner.clear_js_execution_state();
9216 }
9217 fn get_theme_schema(&self) -> serde_json::Value {
9218 self.inner.get_theme_schema()
9219 }
9220 fn get_builtin_themes(&self) -> serde_json::Value {
9221 self.inner.get_builtin_themes()
9222 }
9223 fn get_all_themes(&self) -> serde_json::Value {
9224 self.inner.get_all_themes()
9225 }
9226 fn register_command(&self, command: fresh_core::command::Command) {
9227 self.inner.register_command(command);
9228 }
9229 fn unregister_command(&self, name: &str) {
9230 self.inner.unregister_command(name);
9231 }
9232 fn unregister_commands_by_prefix(&self, prefix: &str) {
9233 self.inner.unregister_commands_by_prefix(prefix);
9234 }
9235 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9236 self.inner.unregister_commands_by_plugin(plugin_name);
9237 }
9238 fn plugins_dir(&self) -> std::path::PathBuf {
9239 self.inner.plugins_dir()
9240 }
9241 fn config_dir(&self) -> std::path::PathBuf {
9242 self.inner.config_dir()
9243 }
9244 fn data_dir(&self) -> std::path::PathBuf {
9245 self.inner.data_dir()
9246 }
9247 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9248 if name == "test-theme" {
9249 Some(serde_json::json!({
9250 "name": "test-theme",
9251 "editor": {},
9252 "ui": {},
9253 "syntax": {}
9254 }))
9255 } else {
9256 None
9257 }
9258 }
9259 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9260 Err("test bridge does not support save".to_string())
9261 }
9262 fn theme_file_exists(&self, name: &str) -> bool {
9263 name == "test-theme"
9264 }
9265 }
9266
9267 #[test]
9270 fn test_api_close_buffer() {
9271 let (mut backend, rx) = create_test_backend();
9272
9273 backend
9274 .execute_js(
9275 r#"
9276 const editor = getEditor();
9277 editor.closeBuffer(3);
9278 "#,
9279 "test.js",
9280 )
9281 .unwrap();
9282
9283 let cmd = rx.try_recv().unwrap();
9284 match cmd {
9285 PluginCommand::CloseBuffer { buffer_id } => {
9286 assert_eq!(buffer_id.0, 3);
9287 }
9288 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9289 }
9290 }
9291
9292 #[test]
9293 fn test_api_focus_split() {
9294 let (mut backend, rx) = create_test_backend();
9295
9296 backend
9297 .execute_js(
9298 r#"
9299 const editor = getEditor();
9300 editor.focusSplit(2);
9301 "#,
9302 "test.js",
9303 )
9304 .unwrap();
9305
9306 let cmd = rx.try_recv().unwrap();
9307 match cmd {
9308 PluginCommand::FocusSplit { split_id } => {
9309 assert_eq!(split_id.0, 2);
9310 }
9311 _ => panic!("Expected FocusSplit, got {:?}", cmd),
9312 }
9313 }
9314
9315 #[test]
9319 fn test_api_session_lifecycle_dispatches_commands() {
9320 let (mut backend, rx) = create_test_backend();
9321
9322 backend
9323 .execute_js(
9324 r#"
9325 const editor = getEditor();
9326 editor.createWindow("/tmp/wt-feat", "feat");
9327 editor.setActiveWindow(7);
9328 editor.closeWindow(3);
9329 "#,
9330 "test.js",
9331 )
9332 .unwrap();
9333
9334 let create = rx.try_recv().unwrap();
9335 match create {
9336 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
9337 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
9338 assert_eq!(label, "feat");
9339 }
9340 other => panic!("Expected CreateWindow, got {:?}", other),
9341 }
9342
9343 let activate = rx.try_recv().unwrap();
9344 match activate {
9345 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
9346 assert_eq!(id, fresh_core::WindowId(7));
9347 }
9348 other => panic!("Expected SetActiveWindow, got {:?}", other),
9349 }
9350
9351 let close = rx.try_recv().unwrap();
9352 match close {
9353 fresh_core::api::PluginCommand::CloseWindow { id } => {
9354 assert_eq!(id, fresh_core::WindowId(3));
9355 }
9356 other => panic!("Expected CloseWindow, got {:?}", other),
9357 }
9358 }
9359
9360 #[test]
9364 fn test_api_list_sessions_reads_snapshot() {
9365 let (tx, _rx) = mpsc::channel();
9366 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9367
9368 {
9369 let mut state = state_snapshot.write().unwrap();
9370 state.windows = vec![
9371 fresh_core::api::WindowInfo {
9372 id: fresh_core::WindowId(1),
9373 label: "main".into(),
9374 root: std::path::PathBuf::from("/repo"),
9375 project_path: None,
9376 shared_worktree: false,
9377 },
9378 fresh_core::api::WindowInfo {
9379 id: fresh_core::WindowId(2),
9380 label: "feat-auth".into(),
9381 root: std::path::PathBuf::from("/wt/feat-auth"),
9382 project_path: None,
9383 shared_worktree: false,
9384 },
9385 ];
9386 state.active_window_id = fresh_core::WindowId(2);
9387 }
9388
9389 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9390 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9391
9392 backend
9393 .execute_js(
9394 r#"
9395 const editor = getEditor();
9396 const list = editor.listWindows();
9397 globalThis._sessionCount = list.length;
9398 globalThis._secondLabel = list[1].label;
9399 globalThis._secondRoot = list[1].root;
9400 globalThis._activeId = editor.activeWindow();
9401 "#,
9402 "test.js",
9403 )
9404 .unwrap();
9405
9406 backend
9407 .plugin_contexts
9408 .borrow()
9409 .get("test")
9410 .unwrap()
9411 .clone()
9412 .with(|ctx| {
9413 let global = ctx.globals();
9414 let count: u32 = global.get("_sessionCount").unwrap();
9415 let label: String = global.get("_secondLabel").unwrap();
9416 let root: String = global.get("_secondRoot").unwrap();
9417 let active: u32 = global.get("_activeId").unwrap();
9418 assert_eq!(count, 2);
9419 assert_eq!(label, "feat-auth");
9420 assert_eq!(root, "/wt/feat-auth");
9421 assert_eq!(active, 2);
9422 });
9423 }
9424
9425 #[test]
9426 fn test_api_list_buffers() {
9427 let (tx, _rx) = mpsc::channel();
9428 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9429
9430 {
9432 let mut state = state_snapshot.write().unwrap();
9433 state.buffers.insert(
9434 BufferId(0),
9435 BufferInfo {
9436 id: BufferId(0),
9437 path: Some(PathBuf::from("/test1.txt")),
9438 modified: false,
9439 length: 100,
9440 is_virtual: false,
9441 view_mode: "source".to_string(),
9442 is_composing_in_any_split: false,
9443 compose_width: None,
9444 language: "text".to_string(),
9445 is_preview: false,
9446 splits: Vec::new(),
9447 },
9448 );
9449 state.buffers.insert(
9450 BufferId(1),
9451 BufferInfo {
9452 id: BufferId(1),
9453 path: Some(PathBuf::from("/test2.txt")),
9454 modified: true,
9455 length: 200,
9456 is_virtual: false,
9457 view_mode: "source".to_string(),
9458 is_composing_in_any_split: false,
9459 compose_width: None,
9460 language: "text".to_string(),
9461 is_preview: false,
9462 splits: Vec::new(),
9463 },
9464 );
9465 }
9466
9467 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9468 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9469
9470 backend
9471 .execute_js(
9472 r#"
9473 const editor = getEditor();
9474 const buffers = editor.listBuffers();
9475 globalThis._isArray = Array.isArray(buffers);
9476 globalThis._length = buffers.length;
9477 "#,
9478 "test.js",
9479 )
9480 .unwrap();
9481
9482 backend
9483 .plugin_contexts
9484 .borrow()
9485 .get("test")
9486 .unwrap()
9487 .clone()
9488 .with(|ctx| {
9489 let global = ctx.globals();
9490 let is_array: bool = global.get("_isArray").unwrap();
9491 let length: u32 = global.get("_length").unwrap();
9492 assert!(is_array);
9493 assert_eq!(length, 2);
9494 });
9495 }
9496
9497 #[test]
9500 fn test_api_start_prompt() {
9501 let (mut backend, rx) = create_test_backend();
9502
9503 backend
9504 .execute_js(
9505 r#"
9506 const editor = getEditor();
9507 editor.startPrompt("Enter value:", "test-prompt");
9508 "#,
9509 "test.js",
9510 )
9511 .unwrap();
9512
9513 let cmd = rx.try_recv().unwrap();
9514 match cmd {
9515 PluginCommand::StartPrompt {
9516 label,
9517 prompt_type,
9518 floating_overlay,
9519 } => {
9520 assert_eq!(label, "Enter value:");
9521 assert_eq!(prompt_type, "test-prompt");
9522 assert!(!floating_overlay);
9523 }
9524 _ => panic!("Expected StartPrompt, got {:?}", cmd),
9525 }
9526 }
9527
9528 #[test]
9529 fn test_api_start_prompt_with_initial() {
9530 let (mut backend, rx) = create_test_backend();
9531
9532 backend
9533 .execute_js(
9534 r#"
9535 const editor = getEditor();
9536 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
9537 "#,
9538 "test.js",
9539 )
9540 .unwrap();
9541
9542 let cmd = rx.try_recv().unwrap();
9543 match cmd {
9544 PluginCommand::StartPromptWithInitial {
9545 label,
9546 prompt_type,
9547 initial_value,
9548 floating_overlay,
9549 } => {
9550 assert_eq!(label, "Enter value:");
9551 assert_eq!(prompt_type, "test-prompt");
9552 assert_eq!(initial_value, "default");
9553 assert!(!floating_overlay);
9554 }
9555 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
9556 }
9557 }
9558
9559 #[test]
9560 fn test_api_set_prompt_suggestions() {
9561 let (mut backend, rx) = create_test_backend();
9562
9563 backend
9564 .execute_js(
9565 r#"
9566 const editor = getEditor();
9567 editor.setPromptSuggestions([
9568 { text: "Option 1", value: "opt1" },
9569 { text: "Option 2", value: "opt2" }
9570 ]);
9571 "#,
9572 "test.js",
9573 )
9574 .unwrap();
9575
9576 let cmd = rx.try_recv().unwrap();
9577 match cmd {
9578 PluginCommand::SetPromptSuggestions { suggestions } => {
9579 assert_eq!(suggestions.len(), 2);
9580 assert_eq!(suggestions[0].text, "Option 1");
9581 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
9582 }
9583 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
9584 }
9585 }
9586
9587 #[test]
9590 fn test_api_get_active_buffer_id() {
9591 let (tx, _rx) = mpsc::channel();
9592 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9593
9594 {
9595 let mut state = state_snapshot.write().unwrap();
9596 state.active_buffer_id = BufferId(42);
9597 }
9598
9599 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9600 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9601
9602 backend
9603 .execute_js(
9604 r#"
9605 const editor = getEditor();
9606 globalThis._activeId = editor.getActiveBufferId();
9607 "#,
9608 "test.js",
9609 )
9610 .unwrap();
9611
9612 backend
9613 .plugin_contexts
9614 .borrow()
9615 .get("test")
9616 .unwrap()
9617 .clone()
9618 .with(|ctx| {
9619 let global = ctx.globals();
9620 let result: u32 = global.get("_activeId").unwrap();
9621 assert_eq!(result, 42);
9622 });
9623 }
9624
9625 #[test]
9626 fn test_api_get_active_split_id() {
9627 let (tx, _rx) = mpsc::channel();
9628 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9629
9630 {
9631 let mut state = state_snapshot.write().unwrap();
9632 state.active_split_id = 7;
9633 }
9634
9635 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9636 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9637
9638 backend
9639 .execute_js(
9640 r#"
9641 const editor = getEditor();
9642 globalThis._splitId = editor.getActiveSplitId();
9643 "#,
9644 "test.js",
9645 )
9646 .unwrap();
9647
9648 backend
9649 .plugin_contexts
9650 .borrow()
9651 .get("test")
9652 .unwrap()
9653 .clone()
9654 .with(|ctx| {
9655 let global = ctx.globals();
9656 let result: u32 = global.get("_splitId").unwrap();
9657 assert_eq!(result, 7);
9658 });
9659 }
9660
9661 #[test]
9664 fn test_api_file_exists() {
9665 let (mut backend, _rx) = create_test_backend();
9666
9667 backend
9668 .execute_js(
9669 r#"
9670 const editor = getEditor();
9671 // Test with a path that definitely exists
9672 globalThis._exists = editor.fileExists("/");
9673 "#,
9674 "test.js",
9675 )
9676 .unwrap();
9677
9678 backend
9679 .plugin_contexts
9680 .borrow()
9681 .get("test")
9682 .unwrap()
9683 .clone()
9684 .with(|ctx| {
9685 let global = ctx.globals();
9686 let result: bool = global.get("_exists").unwrap();
9687 assert!(result);
9688 });
9689 }
9690
9691 #[test]
9692 fn test_api_parse_jsonc() {
9693 let (mut backend, _rx) = create_test_backend();
9694
9695 backend
9696 .execute_js(
9697 r#"
9698 const editor = getEditor();
9699 // Comments, trailing commas, and nested structures should all parse.
9700 const parsed = editor.parseJsonc(`{
9701 // name of the container
9702 "name": "test",
9703 "features": {
9704 "docker-in-docker": {},
9705 },
9706 /* forwarded port list */
9707 "forwardPorts": [3000, 8080,],
9708 }`);
9709 globalThis._name = parsed.name;
9710 globalThis._featureCount = Object.keys(parsed.features).length;
9711 globalThis._portCount = parsed.forwardPorts.length;
9712
9713 // Invalid JSONC should throw.
9714 try {
9715 editor.parseJsonc("{ broken");
9716 globalThis._threw = false;
9717 } catch (_e) {
9718 globalThis._threw = true;
9719 }
9720 "#,
9721 "test.js",
9722 )
9723 .unwrap();
9724
9725 backend
9726 .plugin_contexts
9727 .borrow()
9728 .get("test")
9729 .unwrap()
9730 .clone()
9731 .with(|ctx| {
9732 let global = ctx.globals();
9733 let name: String = global.get("_name").unwrap();
9734 let feature_count: u32 = global.get("_featureCount").unwrap();
9735 let port_count: u32 = global.get("_portCount").unwrap();
9736 let threw: bool = global.get("_threw").unwrap();
9737 assert_eq!(name, "test");
9738 assert_eq!(feature_count, 1);
9739 assert_eq!(port_count, 2);
9740 assert!(threw, "Invalid JSONC should throw");
9741 });
9742 }
9743
9744 #[test]
9745 fn test_api_get_cwd() {
9746 let (mut backend, _rx) = create_test_backend();
9747
9748 backend
9749 .execute_js(
9750 r#"
9751 const editor = getEditor();
9752 globalThis._cwd = editor.getCwd();
9753 "#,
9754 "test.js",
9755 )
9756 .unwrap();
9757
9758 backend
9759 .plugin_contexts
9760 .borrow()
9761 .get("test")
9762 .unwrap()
9763 .clone()
9764 .with(|ctx| {
9765 let global = ctx.globals();
9766 let result: String = global.get("_cwd").unwrap();
9767 assert!(!result.is_empty());
9769 });
9770 }
9771
9772 #[test]
9773 fn test_api_get_env() {
9774 let (mut backend, _rx) = create_test_backend();
9775
9776 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
9778
9779 backend
9780 .execute_js(
9781 r#"
9782 const editor = getEditor();
9783 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
9784 "#,
9785 "test.js",
9786 )
9787 .unwrap();
9788
9789 backend
9790 .plugin_contexts
9791 .borrow()
9792 .get("test")
9793 .unwrap()
9794 .clone()
9795 .with(|ctx| {
9796 let global = ctx.globals();
9797 let result: Option<String> = global.get("_envVal").unwrap();
9798 assert_eq!(result, Some("test_value".to_string()));
9799 });
9800
9801 std::env::remove_var("TEST_PLUGIN_VAR");
9802 }
9803
9804 #[test]
9805 fn test_api_get_config() {
9806 let (mut backend, _rx) = create_test_backend();
9807
9808 backend
9809 .execute_js(
9810 r#"
9811 const editor = getEditor();
9812 const config = editor.getConfig();
9813 globalThis._isObject = typeof config === 'object';
9814 "#,
9815 "test.js",
9816 )
9817 .unwrap();
9818
9819 backend
9820 .plugin_contexts
9821 .borrow()
9822 .get("test")
9823 .unwrap()
9824 .clone()
9825 .with(|ctx| {
9826 let global = ctx.globals();
9827 let is_object: bool = global.get("_isObject").unwrap();
9828 assert!(is_object);
9830 });
9831 }
9832
9833 #[test]
9834 fn test_api_get_themes_dir() {
9835 let (mut backend, _rx) = create_test_backend();
9836
9837 backend
9838 .execute_js(
9839 r#"
9840 const editor = getEditor();
9841 globalThis._themesDir = editor.getThemesDir();
9842 "#,
9843 "test.js",
9844 )
9845 .unwrap();
9846
9847 backend
9848 .plugin_contexts
9849 .borrow()
9850 .get("test")
9851 .unwrap()
9852 .clone()
9853 .with(|ctx| {
9854 let global = ctx.globals();
9855 let result: String = global.get("_themesDir").unwrap();
9856 assert!(!result.is_empty());
9858 });
9859 }
9860
9861 #[test]
9864 fn test_api_read_dir() {
9865 let (mut backend, _rx) = create_test_backend();
9866
9867 backend
9868 .execute_js(
9869 r#"
9870 const editor = getEditor();
9871 const entries = editor.readDir("/tmp");
9872 globalThis._isArray = Array.isArray(entries);
9873 globalThis._length = entries.length;
9874 "#,
9875 "test.js",
9876 )
9877 .unwrap();
9878
9879 backend
9880 .plugin_contexts
9881 .borrow()
9882 .get("test")
9883 .unwrap()
9884 .clone()
9885 .with(|ctx| {
9886 let global = ctx.globals();
9887 let is_array: bool = global.get("_isArray").unwrap();
9888 let length: u32 = global.get("_length").unwrap();
9889 assert!(is_array);
9891 let _ = length;
9893 });
9894 }
9895
9896 #[test]
9899 fn test_api_execute_action() {
9900 let (mut backend, rx) = create_test_backend();
9901
9902 backend
9903 .execute_js(
9904 r#"
9905 const editor = getEditor();
9906 editor.executeAction("move_cursor_up");
9907 "#,
9908 "test.js",
9909 )
9910 .unwrap();
9911
9912 let cmd = rx.try_recv().unwrap();
9913 match cmd {
9914 PluginCommand::ExecuteAction { action_name } => {
9915 assert_eq!(action_name, "move_cursor_up");
9916 }
9917 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
9918 }
9919 }
9920
9921 #[test]
9924 fn test_api_debug() {
9925 let (mut backend, _rx) = create_test_backend();
9926
9927 backend
9929 .execute_js(
9930 r#"
9931 const editor = getEditor();
9932 editor.debug("Test debug message");
9933 editor.debug("Another message with special chars: <>&\"'");
9934 "#,
9935 "test.js",
9936 )
9937 .unwrap();
9938 }
9940
9941 #[test]
9944 fn test_typescript_preamble_generated() {
9945 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
9947 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
9948 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
9949 println!(
9950 "Generated {} bytes of TypeScript preamble",
9951 JSEDITORAPI_TS_PREAMBLE.len()
9952 );
9953 }
9954
9955 #[test]
9956 fn test_typescript_editor_api_generated() {
9957 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
9959 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
9960 println!(
9961 "Generated {} bytes of EditorAPI interface",
9962 JSEDITORAPI_TS_EDITOR_API.len()
9963 );
9964 }
9965
9966 #[test]
9967 fn test_js_methods_list() {
9968 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
9970 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
9971 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
9973 if i < 20 {
9974 println!(" - {}", method);
9975 }
9976 }
9977 if JSEDITORAPI_JS_METHODS.len() > 20 {
9978 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
9979 }
9980 }
9981
9982 #[test]
9985 fn test_api_load_plugin_sends_command() {
9986 let (mut backend, rx) = create_test_backend();
9987
9988 backend
9990 .execute_js(
9991 r#"
9992 const editor = getEditor();
9993 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
9994 "#,
9995 "test.js",
9996 )
9997 .unwrap();
9998
9999 let cmd = rx.try_recv().unwrap();
10001 match cmd {
10002 PluginCommand::LoadPlugin { path, callback_id } => {
10003 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
10004 assert!(callback_id.0 > 0); }
10006 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
10007 }
10008 }
10009
10010 #[test]
10011 fn test_api_unload_plugin_sends_command() {
10012 let (mut backend, rx) = create_test_backend();
10013
10014 backend
10016 .execute_js(
10017 r#"
10018 const editor = getEditor();
10019 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
10020 "#,
10021 "test.js",
10022 )
10023 .unwrap();
10024
10025 let cmd = rx.try_recv().unwrap();
10027 match cmd {
10028 PluginCommand::UnloadPlugin { name, callback_id } => {
10029 assert_eq!(name, "my-plugin");
10030 assert!(callback_id.0 > 0); }
10032 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
10033 }
10034 }
10035
10036 #[test]
10037 fn test_api_reload_plugin_sends_command() {
10038 let (mut backend, rx) = create_test_backend();
10039
10040 backend
10042 .execute_js(
10043 r#"
10044 const editor = getEditor();
10045 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
10046 "#,
10047 "test.js",
10048 )
10049 .unwrap();
10050
10051 let cmd = rx.try_recv().unwrap();
10053 match cmd {
10054 PluginCommand::ReloadPlugin { name, callback_id } => {
10055 assert_eq!(name, "my-plugin");
10056 assert!(callback_id.0 > 0); }
10058 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
10059 }
10060 }
10061
10062 #[test]
10063 fn test_api_load_plugin_resolves_callback() {
10064 let (mut backend, rx) = create_test_backend();
10065
10066 backend
10068 .execute_js(
10069 r#"
10070 const editor = getEditor();
10071 globalThis._loadResult = null;
10072 editor.loadPlugin("/path/to/plugin.ts").then(result => {
10073 globalThis._loadResult = result;
10074 });
10075 "#,
10076 "test.js",
10077 )
10078 .unwrap();
10079
10080 let callback_id = match rx.try_recv().unwrap() {
10082 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
10083 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
10084 };
10085
10086 backend.resolve_callback(callback_id, "true");
10088
10089 backend
10091 .plugin_contexts
10092 .borrow()
10093 .get("test")
10094 .unwrap()
10095 .clone()
10096 .with(|ctx| {
10097 run_pending_jobs_checked(&ctx, "test async loadPlugin");
10098 });
10099
10100 backend
10102 .plugin_contexts
10103 .borrow()
10104 .get("test")
10105 .unwrap()
10106 .clone()
10107 .with(|ctx| {
10108 let global = ctx.globals();
10109 let result: bool = global.get("_loadResult").unwrap();
10110 assert!(result);
10111 });
10112 }
10113
10114 #[test]
10115 fn test_api_version() {
10116 let (mut backend, _rx) = create_test_backend();
10117
10118 backend
10119 .execute_js(
10120 r#"
10121 const editor = getEditor();
10122 globalThis._apiVersion = editor.apiVersion();
10123 "#,
10124 "test.js",
10125 )
10126 .unwrap();
10127
10128 backend
10129 .plugin_contexts
10130 .borrow()
10131 .get("test")
10132 .unwrap()
10133 .clone()
10134 .with(|ctx| {
10135 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10136 assert_eq!(version, 2);
10137 });
10138 }
10139
10140 #[test]
10141 fn test_api_unload_plugin_rejects_on_error() {
10142 let (mut backend, rx) = create_test_backend();
10143
10144 backend
10146 .execute_js(
10147 r#"
10148 const editor = getEditor();
10149 globalThis._unloadError = null;
10150 editor.unloadPlugin("nonexistent-plugin").catch(err => {
10151 globalThis._unloadError = err.message || String(err);
10152 });
10153 "#,
10154 "test.js",
10155 )
10156 .unwrap();
10157
10158 let callback_id = match rx.try_recv().unwrap() {
10160 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10161 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10162 };
10163
10164 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10166
10167 backend
10169 .plugin_contexts
10170 .borrow()
10171 .get("test")
10172 .unwrap()
10173 .clone()
10174 .with(|ctx| {
10175 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10176 });
10177
10178 backend
10180 .plugin_contexts
10181 .borrow()
10182 .get("test")
10183 .unwrap()
10184 .clone()
10185 .with(|ctx| {
10186 let global = ctx.globals();
10187 let error: String = global.get("_unloadError").unwrap();
10188 assert!(error.contains("nonexistent-plugin"));
10189 });
10190 }
10191
10192 #[test]
10193 fn test_api_set_global_state() {
10194 let (mut backend, rx) = create_test_backend();
10195
10196 backend
10197 .execute_js(
10198 r#"
10199 const editor = getEditor();
10200 editor.setGlobalState("myKey", { enabled: true, count: 42 });
10201 "#,
10202 "test_plugin.js",
10203 )
10204 .unwrap();
10205
10206 let cmd = rx.try_recv().unwrap();
10207 match cmd {
10208 PluginCommand::SetGlobalState {
10209 plugin_name,
10210 key,
10211 value,
10212 } => {
10213 assert_eq!(plugin_name, "test_plugin");
10214 assert_eq!(key, "myKey");
10215 let v = value.unwrap();
10216 assert_eq!(v["enabled"], serde_json::json!(true));
10217 assert_eq!(v["count"], serde_json::json!(42));
10218 }
10219 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10220 }
10221 }
10222
10223 #[test]
10224 fn test_api_set_global_state_delete() {
10225 let (mut backend, rx) = create_test_backend();
10226
10227 backend
10228 .execute_js(
10229 r#"
10230 const editor = getEditor();
10231 editor.setGlobalState("myKey", null);
10232 "#,
10233 "test_plugin.js",
10234 )
10235 .unwrap();
10236
10237 let cmd = rx.try_recv().unwrap();
10238 match cmd {
10239 PluginCommand::SetGlobalState {
10240 plugin_name,
10241 key,
10242 value,
10243 } => {
10244 assert_eq!(plugin_name, "test_plugin");
10245 assert_eq!(key, "myKey");
10246 assert!(value.is_none(), "null should delete the key");
10247 }
10248 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10249 }
10250 }
10251
10252 #[test]
10253 fn test_api_get_global_state_roundtrip() {
10254 let (mut backend, _rx) = create_test_backend();
10255
10256 backend
10258 .execute_js(
10259 r#"
10260 const editor = getEditor();
10261 editor.setGlobalState("flag", true);
10262 globalThis._result = editor.getGlobalState("flag");
10263 "#,
10264 "test_plugin.js",
10265 )
10266 .unwrap();
10267
10268 backend
10269 .plugin_contexts
10270 .borrow()
10271 .get("test_plugin")
10272 .unwrap()
10273 .clone()
10274 .with(|ctx| {
10275 let global = ctx.globals();
10276 let result: bool = global.get("_result").unwrap();
10277 assert!(
10278 result,
10279 "getGlobalState should return the value set by setGlobalState"
10280 );
10281 });
10282 }
10283
10284 #[test]
10289 fn test_api_set_session_state_roundtrip() {
10290 let (mut backend, _rx) = create_test_backend();
10291
10292 backend
10293 .execute_js(
10294 r#"
10295 const editor = getEditor();
10296 editor.setWindowState("draft", { count: 7 });
10297 globalThis._result = editor.getWindowState("draft");
10298 globalThis._missing = editor.getWindowState("absent");
10299 "#,
10300 "test_plugin.js",
10301 )
10302 .unwrap();
10303
10304 backend
10305 .plugin_contexts
10306 .borrow()
10307 .get("test_plugin")
10308 .unwrap()
10309 .clone()
10310 .with(|ctx| {
10311 let global = ctx.globals();
10312 let count: i64 = global
10313 .get::<_, rquickjs::Object>("_result")
10314 .unwrap()
10315 .get("count")
10316 .unwrap();
10317 assert_eq!(
10318 count, 7,
10319 "getWindowState should return the value set by setWindowState"
10320 );
10321 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
10322 assert!(
10323 missing.is_undefined(),
10324 "getWindowState for an unset key must be undefined"
10325 );
10326 });
10327 }
10328
10329 #[test]
10330 fn test_api_get_global_state_missing_key() {
10331 let (mut backend, _rx) = create_test_backend();
10332
10333 backend
10334 .execute_js(
10335 r#"
10336 const editor = getEditor();
10337 globalThis._result = editor.getGlobalState("nonexistent");
10338 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
10339 "#,
10340 "test_plugin.js",
10341 )
10342 .unwrap();
10343
10344 backend
10345 .plugin_contexts
10346 .borrow()
10347 .get("test_plugin")
10348 .unwrap()
10349 .clone()
10350 .with(|ctx| {
10351 let global = ctx.globals();
10352 let is_undefined: bool = global.get("_isUndefined").unwrap();
10353 assert!(
10354 is_undefined,
10355 "getGlobalState for missing key should return undefined"
10356 );
10357 });
10358 }
10359
10360 #[test]
10361 fn test_api_global_state_isolation_between_plugins() {
10362 let (tx, _rx) = mpsc::channel();
10364 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10365 let services = Arc::new(TestServiceBridge::new());
10366
10367 let mut backend_a =
10369 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10370 .unwrap();
10371 backend_a
10372 .execute_js(
10373 r#"
10374 const editor = getEditor();
10375 editor.setGlobalState("flag", "from_plugin_a");
10376 "#,
10377 "plugin_a.js",
10378 )
10379 .unwrap();
10380
10381 let mut backend_b =
10383 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10384 .unwrap();
10385 backend_b
10386 .execute_js(
10387 r#"
10388 const editor = getEditor();
10389 editor.setGlobalState("flag", "from_plugin_b");
10390 "#,
10391 "plugin_b.js",
10392 )
10393 .unwrap();
10394
10395 backend_a
10397 .execute_js(
10398 r#"
10399 const editor = getEditor();
10400 globalThis._aValue = editor.getGlobalState("flag");
10401 "#,
10402 "plugin_a.js",
10403 )
10404 .unwrap();
10405
10406 backend_a
10407 .plugin_contexts
10408 .borrow()
10409 .get("plugin_a")
10410 .unwrap()
10411 .clone()
10412 .with(|ctx| {
10413 let global = ctx.globals();
10414 let a_value: String = global.get("_aValue").unwrap();
10415 assert_eq!(
10416 a_value, "from_plugin_a",
10417 "Plugin A should see its own value, not plugin B's"
10418 );
10419 });
10420
10421 backend_b
10423 .execute_js(
10424 r#"
10425 const editor = getEditor();
10426 globalThis._bValue = editor.getGlobalState("flag");
10427 "#,
10428 "plugin_b.js",
10429 )
10430 .unwrap();
10431
10432 backend_b
10433 .plugin_contexts
10434 .borrow()
10435 .get("plugin_b")
10436 .unwrap()
10437 .clone()
10438 .with(|ctx| {
10439 let global = ctx.globals();
10440 let b_value: String = global.get("_bValue").unwrap();
10441 assert_eq!(
10442 b_value, "from_plugin_b",
10443 "Plugin B should see its own value, not plugin A's"
10444 );
10445 });
10446 }
10447
10448 #[test]
10449 fn test_register_command_collision_different_plugins() {
10450 let (mut backend, _rx) = create_test_backend();
10451
10452 backend
10454 .execute_js(
10455 r#"
10456 const editor = getEditor();
10457 globalThis.handlerA = function() { };
10458 editor.registerCommand("My Command", "From A", "handlerA", null);
10459 "#,
10460 "plugin_a.js",
10461 )
10462 .unwrap();
10463
10464 let result = backend.execute_js(
10466 r#"
10467 const editor = getEditor();
10468 globalThis.handlerB = function() { };
10469 editor.registerCommand("My Command", "From B", "handlerB", null);
10470 "#,
10471 "plugin_b.js",
10472 );
10473
10474 assert!(
10475 result.is_err(),
10476 "Second plugin registering the same command name should fail"
10477 );
10478 let err_msg = result.unwrap_err().to_string();
10479 assert!(
10480 err_msg.contains("already registered"),
10481 "Error should mention collision: {}",
10482 err_msg
10483 );
10484 }
10485
10486 #[test]
10487 fn test_register_command_same_plugin_allowed() {
10488 let (mut backend, _rx) = create_test_backend();
10489
10490 backend
10492 .execute_js(
10493 r#"
10494 const editor = getEditor();
10495 globalThis.handler1 = function() { };
10496 editor.registerCommand("My Command", "Version 1", "handler1", null);
10497 globalThis.handler2 = function() { };
10498 editor.registerCommand("My Command", "Version 2", "handler2", null);
10499 "#,
10500 "plugin_a.js",
10501 )
10502 .unwrap();
10503 }
10504
10505 #[test]
10506 fn test_register_command_after_unregister() {
10507 let (mut backend, _rx) = create_test_backend();
10508
10509 backend
10511 .execute_js(
10512 r#"
10513 const editor = getEditor();
10514 globalThis.handlerA = function() { };
10515 editor.registerCommand("My Command", "From A", "handlerA", null);
10516 editor.unregisterCommand("My Command");
10517 "#,
10518 "plugin_a.js",
10519 )
10520 .unwrap();
10521
10522 backend
10524 .execute_js(
10525 r#"
10526 const editor = getEditor();
10527 globalThis.handlerB = function() { };
10528 editor.registerCommand("My Command", "From B", "handlerB", null);
10529 "#,
10530 "plugin_b.js",
10531 )
10532 .unwrap();
10533 }
10534
10535 #[test]
10536 fn test_register_command_collision_caught_in_try_catch() {
10537 let (mut backend, _rx) = create_test_backend();
10538
10539 backend
10541 .execute_js(
10542 r#"
10543 const editor = getEditor();
10544 globalThis.handlerA = function() { };
10545 editor.registerCommand("My Command", "From A", "handlerA", null);
10546 "#,
10547 "plugin_a.js",
10548 )
10549 .unwrap();
10550
10551 backend
10553 .execute_js(
10554 r#"
10555 const editor = getEditor();
10556 globalThis.handlerB = function() { };
10557 let caught = false;
10558 try {
10559 editor.registerCommand("My Command", "From B", "handlerB", null);
10560 } catch (e) {
10561 caught = true;
10562 }
10563 if (!caught) throw new Error("Expected collision error");
10564 "#,
10565 "plugin_b.js",
10566 )
10567 .unwrap();
10568 }
10569
10570 #[test]
10571 fn test_register_command_i18n_key_no_collision_across_plugins() {
10572 let (mut backend, _rx) = create_test_backend();
10573
10574 backend
10576 .execute_js(
10577 r#"
10578 const editor = getEditor();
10579 globalThis.handlerA = function() { };
10580 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
10581 "#,
10582 "plugin_a.js",
10583 )
10584 .unwrap();
10585
10586 backend
10589 .execute_js(
10590 r#"
10591 const editor = getEditor();
10592 globalThis.handlerB = function() { };
10593 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
10594 "#,
10595 "plugin_b.js",
10596 )
10597 .unwrap();
10598 }
10599
10600 #[test]
10601 fn test_register_command_non_i18n_still_collides() {
10602 let (mut backend, _rx) = create_test_backend();
10603
10604 backend
10606 .execute_js(
10607 r#"
10608 const editor = getEditor();
10609 globalThis.handlerA = function() { };
10610 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
10611 "#,
10612 "plugin_a.js",
10613 )
10614 .unwrap();
10615
10616 let result = backend.execute_js(
10618 r#"
10619 const editor = getEditor();
10620 globalThis.handlerB = function() { };
10621 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
10622 "#,
10623 "plugin_b.js",
10624 );
10625
10626 assert!(
10627 result.is_err(),
10628 "Non-%-prefixed names should still collide across plugins"
10629 );
10630 }
10631}