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 path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2040 let mut result_parts: Vec<String> = Vec::new();
2041 let mut leading_slashes: u8 = 0;
2043
2044 for part in &parts.0 {
2045 let normalized = part.replace('\\', "/");
2047
2048 let is_absolute = normalized.starts_with('/')
2050 || (normalized.len() >= 2
2051 && normalized
2052 .chars()
2053 .next()
2054 .map(|c| c.is_ascii_alphabetic())
2055 .unwrap_or(false)
2056 && normalized.chars().nth(1) == Some(':'));
2057
2058 if is_absolute {
2059 result_parts.clear();
2061 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2065 }
2066
2067 for segment in normalized.split('/') {
2069 if !segment.is_empty() && segment != "." {
2070 if segment == ".." {
2071 result_parts.pop();
2072 } else {
2073 result_parts.push(segment.to_string());
2074 }
2075 }
2076 }
2077 }
2078
2079 let joined = result_parts.join("/");
2081 let prefix = match leading_slashes {
2082 0 => "",
2083 1 => "/",
2084 _ => "//",
2085 };
2086
2087 if leading_slashes > 0 {
2088 format!("{}{}", prefix, joined)
2089 } else {
2090 joined
2091 }
2092 }
2093
2094 pub fn path_dirname(&self, path: String) -> String {
2096 Path::new(&path)
2097 .parent()
2098 .map(|p| p.to_string_lossy().to_string())
2099 .unwrap_or_default()
2100 }
2101
2102 pub fn path_basename(&self, path: String) -> String {
2104 Path::new(&path)
2105 .file_name()
2106 .map(|s| s.to_string_lossy().to_string())
2107 .unwrap_or_default()
2108 }
2109
2110 pub fn path_extname(&self, path: String) -> String {
2112 Path::new(&path)
2113 .extension()
2114 .map(|s| format!(".{}", s.to_string_lossy()))
2115 .unwrap_or_default()
2116 }
2117
2118 pub fn path_is_absolute(&self, path: String) -> bool {
2120 Path::new(&path).is_absolute()
2121 }
2122
2123 pub fn file_uri_to_path(&self, uri: String) -> String {
2127 fresh_core::file_uri::file_uri_to_path(&uri)
2128 .map(|p| p.to_string_lossy().to_string())
2129 .unwrap_or_default()
2130 }
2131
2132 pub fn path_to_file_uri(&self, path: String) -> String {
2136 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2137 }
2138
2139 pub fn utf8_byte_length(&self, text: String) -> u32 {
2147 text.len() as u32
2148 }
2149
2150 pub fn file_exists(&self, path: String) -> bool {
2154 Path::new(&path).exists()
2155 }
2156
2157 pub fn read_file(&self, path: String) -> Option<String> {
2159 std::fs::read_to_string(&path).ok()
2160 }
2161
2162 pub fn write_file(&self, path: String, content: String) -> bool {
2164 let p = Path::new(&path);
2165 if let Some(parent) = p.parent() {
2166 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2167 return false;
2168 }
2169 }
2170 std::fs::write(p, content).is_ok()
2171 }
2172
2173 #[plugin_api(ts_return = "DirEntry[]")]
2175 pub fn read_dir<'js>(
2176 &self,
2177 ctx: rquickjs::Ctx<'js>,
2178 path: String,
2179 ) -> rquickjs::Result<Value<'js>> {
2180 use fresh_core::api::DirEntry;
2181
2182 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2183 Ok(entries) => entries
2184 .filter_map(|e| e.ok())
2185 .map(|entry| {
2186 let file_type = entry.file_type().ok();
2187 DirEntry {
2188 name: entry.file_name().to_string_lossy().to_string(),
2189 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2190 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2191 }
2192 })
2193 .collect(),
2194 Err(e) => {
2195 tracing::warn!("readDir failed for '{}': {}", path, e);
2196 Vec::new()
2197 }
2198 };
2199
2200 rquickjs_serde::to_value(ctx, &entries)
2201 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2202 }
2203
2204 pub fn create_dir(&self, path: String) -> bool {
2207 let p = Path::new(&path);
2208 if p.is_dir() {
2209 return true;
2210 }
2211 std::fs::create_dir_all(p).is_ok()
2212 }
2213
2214 pub fn remove_path(&self, path: String) -> bool {
2218 let target = match Path::new(&path).canonicalize() {
2219 Ok(p) => p,
2220 Err(_) => return false, };
2222
2223 let temp_dir = std::env::temp_dir()
2229 .canonicalize()
2230 .unwrap_or_else(|_| std::env::temp_dir());
2231 let config_dir = self
2232 .services
2233 .config_dir()
2234 .canonicalize()
2235 .unwrap_or_else(|_| self.services.config_dir());
2236
2237 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2239 if !allowed {
2240 tracing::warn!(
2241 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2242 target,
2243 temp_dir,
2244 config_dir
2245 );
2246 return false;
2247 }
2248
2249 if target == temp_dir || target == config_dir {
2251 tracing::warn!(
2252 "removePath refused: cannot remove root directory {:?}",
2253 target
2254 );
2255 return false;
2256 }
2257
2258 match trash::delete(&target) {
2259 Ok(()) => true,
2260 Err(e) => {
2261 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2262 false
2263 }
2264 }
2265 }
2266
2267 pub fn rename_path(&self, from: String, to: String) -> bool {
2270 if std::fs::rename(&from, &to).is_ok() {
2272 return true;
2273 }
2274 let from_path = Path::new(&from);
2276 let copied = if from_path.is_dir() {
2277 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2278 } else {
2279 std::fs::copy(&from, &to).is_ok()
2280 };
2281 if copied {
2282 return trash::delete(from_path).is_ok();
2283 }
2284 false
2285 }
2286
2287 pub fn copy_path(&self, from: String, to: String) -> bool {
2290 let from_path = Path::new(&from);
2291 let to_path = Path::new(&to);
2292 if from_path.is_dir() {
2293 copy_dir_recursive(from_path, to_path).is_ok()
2294 } else {
2295 if let Some(parent) = to_path.parent() {
2297 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2298 return false;
2299 }
2300 }
2301 std::fs::copy(from_path, to_path).is_ok()
2302 }
2303 }
2304
2305 pub fn get_temp_dir(&self) -> String {
2307 std::env::temp_dir().to_string_lossy().to_string()
2308 }
2309
2310 #[plugin_api(ts_return = "unknown")]
2321 pub fn parse_jsonc<'js>(
2322 &self,
2323 ctx: rquickjs::Ctx<'js>,
2324 text: String,
2325 ) -> rquickjs::Result<Value<'js>> {
2326 let value: serde_json::Value =
2327 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2328 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2329 })?;
2330 rquickjs_serde::to_value(ctx, &value)
2331 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2332 }
2333
2334 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2343 let config = self
2344 .state_snapshot
2345 .read()
2346 .map(|s| std::sync::Arc::clone(&s.config))
2347 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2348
2349 rquickjs_serde::to_value(ctx, &*config)
2350 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2351 }
2352
2353 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2355 let config = self
2356 .state_snapshot
2357 .read()
2358 .map(|s| std::sync::Arc::clone(&s.user_config))
2359 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2360
2361 rquickjs_serde::to_value(ctx, &*config)
2362 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2363 }
2364
2365 #[plugin_api(ts_return = "boolean")]
2373 pub fn define_config_boolean<'js>(
2374 &self,
2375 ctx: rquickjs::Ctx<'js>,
2376 name: String,
2377 #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2378 options: rquickjs::Object<'js>,
2379 ) -> rquickjs::Result<bool> {
2380 let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2381 validate_allowed_keys(
2382 &ctx,
2383 "defineConfigBoolean",
2384 &name,
2385 &opts,
2386 &["default", "description"],
2387 )?;
2388 let default = match opts.get("default") {
2389 Some(serde_json::Value::Bool(b)) => *b,
2390 _ => {
2391 return Err(throw_js(
2392 &ctx,
2393 &format!(
2394 "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2395 name
2396 ),
2397 ));
2398 }
2399 };
2400 let description = string_opt(&opts, "description");
2401 let mut field = serde_json::Map::new();
2402 field.insert("type".into(), serde_json::json!("boolean"));
2403 field.insert("default".into(), serde_json::json!(default));
2404 if let Some(d) = description {
2405 field.insert("description".into(), serde_json::json!(d));
2406 }
2407 self.send_field_registration(&name, serde_json::Value::Object(field));
2408 Ok(self
2409 .current_field_value(&name)
2410 .and_then(|v| v.as_bool())
2411 .unwrap_or(default))
2412 }
2413
2414 #[plugin_api(ts_return = "number")]
2417 pub fn define_config_integer<'js>(
2418 &self,
2419 ctx: rquickjs::Ctx<'js>,
2420 name: String,
2421 #[plugin_api(
2422 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2423 )]
2424 options: rquickjs::Object<'js>,
2425 ) -> rquickjs::Result<i64> {
2426 let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2427 validate_allowed_keys(
2428 &ctx,
2429 "defineConfigInteger",
2430 &name,
2431 &opts,
2432 &["default", "description", "minimum", "maximum"],
2433 )?;
2434 let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2435 let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2436 let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2437 check_range(
2438 &ctx,
2439 "defineConfigInteger",
2440 &name,
2441 default as f64,
2442 minimum.map(|v| v as f64),
2443 maximum.map(|v| v as f64),
2444 )?;
2445 let description = string_opt(&opts, "description");
2446 let mut field = serde_json::Map::new();
2447 field.insert("type".into(), serde_json::json!("integer"));
2448 field.insert("default".into(), serde_json::json!(default));
2449 if let Some(d) = description {
2450 field.insert("description".into(), serde_json::json!(d));
2451 }
2452 if let Some(v) = minimum {
2453 field.insert("minimum".into(), serde_json::json!(v));
2454 }
2455 if let Some(v) = maximum {
2456 field.insert("maximum".into(), serde_json::json!(v));
2457 }
2458 self.send_field_registration(&name, serde_json::Value::Object(field));
2459 Ok(self
2460 .current_field_value(&name)
2461 .and_then(|v| v.as_i64())
2462 .unwrap_or(default))
2463 }
2464
2465 #[plugin_api(ts_return = "number")]
2468 pub fn define_config_number<'js>(
2469 &self,
2470 ctx: rquickjs::Ctx<'js>,
2471 name: String,
2472 #[plugin_api(
2473 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2474 )]
2475 options: rquickjs::Object<'js>,
2476 ) -> rquickjs::Result<f64> {
2477 let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2478 validate_allowed_keys(
2479 &ctx,
2480 "defineConfigNumber",
2481 &name,
2482 &opts,
2483 &["default", "description", "minimum", "maximum"],
2484 )?;
2485 let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2486 let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2487 let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2488 check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2489 let description = string_opt(&opts, "description");
2490 let mut field = serde_json::Map::new();
2491 field.insert("type".into(), serde_json::json!("number"));
2492 field.insert("default".into(), serde_json::json!(default));
2493 if let Some(d) = description {
2494 field.insert("description".into(), serde_json::json!(d));
2495 }
2496 if let Some(v) = minimum {
2497 field.insert("minimum".into(), serde_json::json!(v));
2498 }
2499 if let Some(v) = maximum {
2500 field.insert("maximum".into(), serde_json::json!(v));
2501 }
2502 self.send_field_registration(&name, serde_json::Value::Object(field));
2503 Ok(self
2504 .current_field_value(&name)
2505 .and_then(|v| v.as_f64())
2506 .unwrap_or(default))
2507 }
2508
2509 #[plugin_api(ts_return = "string")]
2511 pub fn define_config_string<'js>(
2512 &self,
2513 ctx: rquickjs::Ctx<'js>,
2514 name: String,
2515 #[plugin_api(ts_type = "{ default: string; description?: string }")]
2516 options: rquickjs::Object<'js>,
2517 ) -> rquickjs::Result<String> {
2518 let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2519 validate_allowed_keys(
2520 &ctx,
2521 "defineConfigString",
2522 &name,
2523 &opts,
2524 &["default", "description"],
2525 )?;
2526 let default = match opts.get("default") {
2527 Some(serde_json::Value::String(s)) => s.clone(),
2528 _ => {
2529 return Err(throw_js(
2530 &ctx,
2531 &format!(
2532 "defineConfigString(\"{}\"): `default` (string) is required",
2533 name
2534 ),
2535 ));
2536 }
2537 };
2538 let description = string_opt(&opts, "description");
2539 let mut field = serde_json::Map::new();
2540 field.insert("type".into(), serde_json::json!("string"));
2541 field.insert("default".into(), serde_json::json!(default));
2542 if let Some(d) = description {
2543 field.insert("description".into(), serde_json::json!(d));
2544 }
2545 self.send_field_registration(&name, serde_json::Value::Object(field));
2546 Ok(self
2547 .current_field_value(&name)
2548 .and_then(|v| v.as_str().map(|s| s.to_string()))
2549 .unwrap_or(default))
2550 }
2551
2552 #[plugin_api(skip)]
2559 pub fn define_config_enum<'js>(
2560 &self,
2561 ctx: rquickjs::Ctx<'js>,
2562 name: String,
2563 options: rquickjs::Object<'js>,
2564 ) -> rquickjs::Result<String> {
2565 let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2566 validate_allowed_keys(
2567 &ctx,
2568 "defineConfigEnum",
2569 &name,
2570 &opts,
2571 &["default", "description", "values"],
2572 )?;
2573 let values: Vec<String> = match opts.get("values") {
2574 Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
2575 let mut out = Vec::with_capacity(arr.len());
2576 for v in arr {
2577 match v {
2578 serde_json::Value::String(s) => out.push(s.clone()),
2579 _ => {
2580 return Err(throw_js(
2581 &ctx,
2582 &format!(
2583 "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2584 name
2585 ),
2586 ));
2587 }
2588 }
2589 }
2590 out
2591 }
2592 _ => {
2593 return Err(throw_js(
2594 &ctx,
2595 &format!(
2596 "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2597 name
2598 ),
2599 ));
2600 }
2601 };
2602 let default = match opts.get("default") {
2603 Some(serde_json::Value::String(s)) => s.clone(),
2604 _ => {
2605 return Err(throw_js(
2606 &ctx,
2607 &format!(
2608 "defineConfigEnum(\"{}\"): `default` (string) is required",
2609 name
2610 ),
2611 ));
2612 }
2613 };
2614 if !values.contains(&default) {
2615 return Err(throw_js(
2616 &ctx,
2617 &format!(
2618 "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2619 name, values
2620 ),
2621 ));
2622 }
2623 let description = string_opt(&opts, "description");
2624 let mut field = serde_json::Map::new();
2625 field.insert("type".into(), serde_json::json!("string"));
2626 field.insert("enum".into(), serde_json::json!(values));
2627 field.insert("default".into(), serde_json::json!(default));
2628 if let Some(d) = description {
2629 field.insert("description".into(), serde_json::json!(d));
2630 }
2631 self.send_field_registration(&name, serde_json::Value::Object(field));
2632 let current = self
2633 .current_field_value(&name)
2634 .and_then(|v| v.as_str().map(|s| s.to_string()));
2635 Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2639 }
2640
2641 #[plugin_api(ts_return = "string[]")]
2644 pub fn define_config_string_array<'js>(
2645 &self,
2646 ctx: rquickjs::Ctx<'js>,
2647 name: String,
2648 #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2649 options: rquickjs::Object<'js>,
2650 ) -> rquickjs::Result<Vec<String>> {
2651 let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2652 validate_allowed_keys(
2653 &ctx,
2654 "defineConfigStringArray",
2655 &name,
2656 &opts,
2657 &["default", "description"],
2658 )?;
2659 let default: Vec<String> = match opts.get("default") {
2660 Some(serde_json::Value::Array(arr)) => {
2661 let mut out = Vec::with_capacity(arr.len());
2662 for v in arr {
2663 match v {
2664 serde_json::Value::String(s) => out.push(s.clone()),
2665 _ => {
2666 return Err(throw_js(
2667 &ctx,
2668 &format!(
2669 "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2670 name
2671 ),
2672 ));
2673 }
2674 }
2675 }
2676 out
2677 }
2678 _ => {
2679 return Err(throw_js(
2680 &ctx,
2681 &format!(
2682 "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2683 name
2684 ),
2685 ));
2686 }
2687 };
2688 let description = string_opt(&opts, "description");
2689 let mut field = serde_json::Map::new();
2690 field.insert("type".into(), serde_json::json!("array"));
2691 field.insert("items".into(), serde_json::json!({"type": "string"}));
2692 field.insert("default".into(), serde_json::json!(default));
2693 if let Some(d) = description {
2694 field.insert("description".into(), serde_json::json!(d));
2695 }
2696 self.send_field_registration(&name, serde_json::Value::Object(field));
2697 Ok(self
2698 .current_field_value(&name)
2699 .and_then(|v| {
2700 v.as_array().map(|arr| {
2701 arr.iter()
2702 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2703 .collect::<Vec<_>>()
2704 })
2705 })
2706 .unwrap_or(default))
2707 }
2708
2709 pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2717 let config = self
2718 .state_snapshot
2719 .read()
2720 .map(|s| std::sync::Arc::clone(&s.config))
2721 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2722
2723 let settings = config
2724 .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2725 .cloned()
2726 .unwrap_or(serde_json::Value::Null);
2727
2728 rquickjs_serde::to_value(ctx, &settings)
2729 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2730 }
2731
2732 pub fn reload_config(&self) {
2734 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2735 }
2736
2737 pub fn set_setting<'js>(
2750 &self,
2751 _ctx: rquickjs::Ctx<'js>,
2752 path: String,
2753 value: Value<'js>,
2754 ) -> rquickjs::Result<bool> {
2755 let json: serde_json::Value = rquickjs_serde::from_value(value)
2756 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2757 Ok(self
2758 .command_sender
2759 .send(PluginCommand::SetSetting {
2760 plugin_name: self.plugin_name.clone(),
2761 path,
2762 value: json,
2763 })
2764 .is_ok())
2765 }
2766
2767 pub fn reload_themes(&self) {
2770 let _ = self
2771 .command_sender
2772 .send(PluginCommand::ReloadThemes { apply_theme: None });
2773 }
2774
2775 pub fn reload_and_apply_theme(&self, theme_name: String) {
2777 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2778 apply_theme: Some(theme_name),
2779 });
2780 }
2781
2782 pub fn register_grammar<'js>(
2785 &self,
2786 ctx: rquickjs::Ctx<'js>,
2787 language: String,
2788 grammar_path: String,
2789 extensions: Vec<String>,
2790 ) -> rquickjs::Result<bool> {
2791 {
2793 let langs = self.registered_grammar_languages.borrow();
2794 if let Some(existing_plugin) = langs.get(&language) {
2795 if existing_plugin != &self.plugin_name {
2796 let msg = format!(
2797 "Grammar for language '{}' already registered by plugin '{}'",
2798 language, existing_plugin
2799 );
2800 tracing::warn!("registerGrammar collision: {}", msg);
2801 return Err(
2802 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2803 );
2804 }
2805 }
2806 }
2807 self.registered_grammar_languages
2808 .borrow_mut()
2809 .insert(language.clone(), self.plugin_name.clone());
2810
2811 Ok(self
2812 .command_sender
2813 .send(PluginCommand::RegisterGrammar {
2814 language,
2815 grammar_path,
2816 extensions,
2817 })
2818 .is_ok())
2819 }
2820
2821 pub fn register_language_config<'js>(
2823 &self,
2824 ctx: rquickjs::Ctx<'js>,
2825 language: String,
2826 config: LanguagePackConfig,
2827 ) -> rquickjs::Result<bool> {
2828 {
2830 let langs = self.registered_language_configs.borrow();
2831 if let Some(existing_plugin) = langs.get(&language) {
2832 if existing_plugin != &self.plugin_name {
2833 let msg = format!(
2834 "Language config for '{}' already registered by plugin '{}'",
2835 language, existing_plugin
2836 );
2837 tracing::warn!("registerLanguageConfig collision: {}", msg);
2838 return Err(
2839 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2840 );
2841 }
2842 }
2843 }
2844 self.registered_language_configs
2845 .borrow_mut()
2846 .insert(language.clone(), self.plugin_name.clone());
2847
2848 Ok(self
2849 .command_sender
2850 .send(PluginCommand::RegisterLanguageConfig { language, config })
2851 .is_ok())
2852 }
2853
2854 pub fn register_lsp_server<'js>(
2856 &self,
2857 ctx: rquickjs::Ctx<'js>,
2858 language: String,
2859 config: LspServerPackConfig,
2860 ) -> rquickjs::Result<bool> {
2861 {
2863 let langs = self.registered_lsp_servers.borrow();
2864 if let Some(existing_plugin) = langs.get(&language) {
2865 if existing_plugin != &self.plugin_name {
2866 let msg = format!(
2867 "LSP server for language '{}' already registered by plugin '{}'",
2868 language, existing_plugin
2869 );
2870 tracing::warn!("registerLspServer collision: {}", msg);
2871 return Err(
2872 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2873 );
2874 }
2875 }
2876 }
2877 self.registered_lsp_servers
2878 .borrow_mut()
2879 .insert(language.clone(), self.plugin_name.clone());
2880
2881 Ok(self
2882 .command_sender
2883 .send(PluginCommand::RegisterLspServer { language, config })
2884 .is_ok())
2885 }
2886
2887 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2891 #[qjs(rename = "_reloadGrammarsStart")]
2892 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2893 let id = self.alloc_request_id();
2894 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2895 callback_id: fresh_core::api::JsCallbackId::new(id),
2896 });
2897 id
2898 }
2899
2900 pub fn get_plugin_dir(&self) -> String {
2903 self.services
2904 .plugins_dir()
2905 .join("packages")
2906 .join(&self.plugin_name)
2907 .to_string_lossy()
2908 .to_string()
2909 }
2910
2911 pub fn get_config_dir(&self) -> String {
2913 self.services.config_dir().to_string_lossy().to_string()
2914 }
2915
2916 pub fn get_data_dir(&self) -> String {
2920 self.services.data_dir().to_string_lossy().to_string()
2921 }
2922
2923 pub fn get_themes_dir(&self) -> String {
2925 self.services
2926 .config_dir()
2927 .join("themes")
2928 .to_string_lossy()
2929 .to_string()
2930 }
2931
2932 pub fn apply_theme(&self, theme_name: String) -> bool {
2934 self.command_sender
2935 .send(PluginCommand::ApplyTheme { theme_name })
2936 .is_ok()
2937 }
2938
2939 pub fn override_theme_colors<'js>(
2948 &self,
2949 _ctx: rquickjs::Ctx<'js>,
2950 overrides: Value<'js>,
2951 ) -> rquickjs::Result<bool> {
2952 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
2958 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
2959 let Some(obj) = json.as_object() else {
2960 return Err(rquickjs::Error::new_from_js_message(
2961 "type",
2962 "",
2963 "overrideThemeColors expects an object of \"key\": [r, g, b]",
2964 ));
2965 };
2966 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
2967 n.as_i64()
2968 .or_else(|| n.as_f64().map(|f| f as i64))
2969 .map(|v| v.clamp(0, 255) as u8)
2970 };
2971 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
2972 std::collections::HashMap::with_capacity(obj.len());
2973 for (key, value) in obj {
2974 let Some(arr) = value.as_array() else {
2975 continue;
2976 };
2977 if arr.len() != 3 {
2978 continue;
2979 }
2980 let Some(r) = to_u8(&arr[0]) else { continue };
2981 let Some(g) = to_u8(&arr[1]) else { continue };
2982 let Some(b) = to_u8(&arr[2]) else { continue };
2983 clamped.insert(key.clone(), [r, g, b]);
2984 }
2985 Ok(self
2986 .command_sender
2987 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
2988 .is_ok())
2989 }
2990
2991 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2993 let schema = self.services.get_theme_schema();
2994 rquickjs_serde::to_value(ctx, &schema)
2995 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2996 }
2997
2998 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3000 let themes = self.services.get_builtin_themes();
3001 rquickjs_serde::to_value(ctx, &themes)
3002 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3003 }
3004
3005 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3008 let themes = self.services.get_all_themes();
3009 rquickjs_serde::to_value(ctx, &themes)
3010 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3011 }
3012
3013 #[qjs(rename = "_deleteThemeSync")]
3015 pub fn delete_theme_sync(&self, name: String) -> bool {
3016 let themes_dir = self.services.config_dir().join("themes");
3018 let theme_path = themes_dir.join(format!("{}.json", name));
3019
3020 if let Ok(canonical) = theme_path.canonicalize() {
3022 if let Ok(themes_canonical) = themes_dir.canonicalize() {
3023 if canonical.starts_with(&themes_canonical) {
3024 return std::fs::remove_file(&canonical).is_ok();
3025 }
3026 }
3027 }
3028 false
3029 }
3030
3031 pub fn delete_theme(&self, name: String) -> bool {
3033 self.delete_theme_sync(name)
3034 }
3035
3036 pub fn get_theme_data<'js>(
3038 &self,
3039 ctx: rquickjs::Ctx<'js>,
3040 name: String,
3041 ) -> rquickjs::Result<Value<'js>> {
3042 match self.services.get_theme_data(&name) {
3043 Some(data) => rquickjs_serde::to_value(ctx, &data)
3044 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3045 None => Ok(Value::new_null(ctx)),
3046 }
3047 }
3048
3049 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3051 self.services
3052 .save_theme_file(&name, &content)
3053 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3054 }
3055
3056 pub fn theme_file_exists(&self, name: String) -> bool {
3058 self.services.theme_file_exists(&name)
3059 }
3060
3061 pub fn file_stat<'js>(
3065 &self,
3066 ctx: rquickjs::Ctx<'js>,
3067 path: String,
3068 ) -> rquickjs::Result<Value<'js>> {
3069 let metadata = std::fs::metadata(&path).ok();
3070 let stat = metadata.map(|m| {
3071 serde_json::json!({
3072 "isFile": m.is_file(),
3073 "isDir": m.is_dir(),
3074 "size": m.len(),
3075 "readonly": m.permissions().readonly(),
3076 })
3077 });
3078 rquickjs_serde::to_value(ctx, &stat)
3079 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3080 }
3081
3082 pub fn is_process_running(&self, _process_id: u64) -> bool {
3086 false
3089 }
3090
3091 pub fn kill_process(&self, process_id: u64) -> bool {
3093 self.command_sender
3094 .send(PluginCommand::KillBackgroundProcess { process_id })
3095 .is_ok()
3096 }
3097
3098 pub fn plugin_translate<'js>(
3102 &self,
3103 _ctx: rquickjs::Ctx<'js>,
3104 plugin_name: String,
3105 key: String,
3106 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3107 ) -> String {
3108 let args_map: HashMap<String, String> = args
3109 .0
3110 .map(|obj| {
3111 let mut map = HashMap::new();
3112 for (k, v) in obj.props::<String, String>().flatten() {
3113 map.insert(k, v);
3114 }
3115 map
3116 })
3117 .unwrap_or_default();
3118
3119 self.services.translate(&plugin_name, &key, &args_map)
3120 }
3121
3122 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3129 #[qjs(rename = "_createCompositeBufferStart")]
3130 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3131 let id = self.alloc_request_id();
3132
3133 if let Ok(mut owners) = self.async_resource_owners.lock() {
3135 owners.insert(id, self.plugin_name.clone());
3136 }
3137 let _ = self
3138 .command_sender
3139 .send(PluginCommand::CreateCompositeBuffer {
3140 name: opts.name,
3141 mode: opts.mode,
3142 layout: opts.layout,
3143 sources: opts.sources,
3144 hunks: opts.hunks,
3145 initial_focus_hunk: opts.initial_focus_hunk,
3146 request_id: Some(id),
3147 });
3148
3149 id
3150 }
3151
3152 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3156 self.command_sender
3157 .send(PluginCommand::UpdateCompositeAlignment {
3158 buffer_id: BufferId(buffer_id as usize),
3159 hunks,
3160 })
3161 .is_ok()
3162 }
3163
3164 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3166 self.command_sender
3167 .send(PluginCommand::CloseCompositeBuffer {
3168 buffer_id: BufferId(buffer_id as usize),
3169 })
3170 .is_ok()
3171 }
3172
3173 pub fn flush_layout(&self) -> bool {
3177 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3178 }
3179
3180 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3182 self.command_sender
3183 .send(PluginCommand::CompositeNextHunk {
3184 buffer_id: BufferId(buffer_id as usize),
3185 })
3186 .is_ok()
3187 }
3188
3189 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3191 self.command_sender
3192 .send(PluginCommand::CompositePrevHunk {
3193 buffer_id: BufferId(buffer_id as usize),
3194 })
3195 .is_ok()
3196 }
3197
3198 #[plugin_api(
3202 async_promise,
3203 js_name = "getHighlights",
3204 ts_return = "TsHighlightSpan[]"
3205 )]
3206 #[qjs(rename = "_getHighlightsStart")]
3207 pub fn get_highlights_start<'js>(
3208 &self,
3209 _ctx: rquickjs::Ctx<'js>,
3210 buffer_id: u32,
3211 start: u32,
3212 end: u32,
3213 ) -> rquickjs::Result<u64> {
3214 let id = self.alloc_request_id();
3215
3216 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3217 buffer_id: BufferId(buffer_id as usize),
3218 range: (start as usize)..(end as usize),
3219 request_id: id,
3220 });
3221
3222 Ok(id)
3223 }
3224
3225 pub fn add_overlay<'js>(
3247 &self,
3248 _ctx: rquickjs::Ctx<'js>,
3249 buffer_id: u32,
3250 namespace: String,
3251 start: u32,
3252 end: u32,
3253 options: rquickjs::Object<'js>,
3254 ) -> rquickjs::Result<bool> {
3255 use fresh_core::api::OverlayColorSpec;
3256
3257 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3259 if let Ok(theme_key) = obj.get::<_, String>(key) {
3261 if !theme_key.is_empty() {
3262 return Some(OverlayColorSpec::ThemeKey(theme_key));
3263 }
3264 }
3265 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3267 if arr.len() >= 3 {
3268 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3269 }
3270 }
3271 None
3272 }
3273
3274 let fg = parse_color_spec("fg", &options);
3275 let bg = parse_color_spec("bg", &options);
3276 let underline: bool = options.get("underline").unwrap_or(false);
3277 let bold: bool = options.get("bold").unwrap_or(false);
3278 let italic: bool = options.get("italic").unwrap_or(false);
3279 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3280 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3281 let url: Option<String> = options.get("url").ok();
3282
3283 let options = OverlayOptions {
3284 fg,
3285 bg,
3286 underline,
3287 bold,
3288 italic,
3289 strikethrough,
3290 extend_to_line_end,
3291 url,
3292 };
3293
3294 self.plugin_tracked_state
3296 .borrow_mut()
3297 .entry(self.plugin_name.clone())
3298 .or_default()
3299 .overlay_namespaces
3300 .push((BufferId(buffer_id as usize), namespace.clone()));
3301
3302 let _ = self.command_sender.send(PluginCommand::AddOverlay {
3303 buffer_id: BufferId(buffer_id as usize),
3304 namespace: Some(OverlayNamespace::from_string(namespace)),
3305 range: (start as usize)..(end as usize),
3306 options,
3307 });
3308
3309 Ok(true)
3310 }
3311
3312 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3314 self.command_sender
3315 .send(PluginCommand::ClearNamespace {
3316 buffer_id: BufferId(buffer_id as usize),
3317 namespace: OverlayNamespace::from_string(namespace),
3318 })
3319 .is_ok()
3320 }
3321
3322 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3324 self.command_sender
3325 .send(PluginCommand::ClearAllOverlays {
3326 buffer_id: BufferId(buffer_id as usize),
3327 })
3328 .is_ok()
3329 }
3330
3331 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3333 self.command_sender
3334 .send(PluginCommand::ClearOverlaysInRange {
3335 buffer_id: BufferId(buffer_id as usize),
3336 start: start as usize,
3337 end: end as usize,
3338 })
3339 .is_ok()
3340 }
3341
3342 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3344 use fresh_core::overlay::OverlayHandle;
3345 self.command_sender
3346 .send(PluginCommand::RemoveOverlay {
3347 buffer_id: BufferId(buffer_id as usize),
3348 handle: OverlayHandle(handle),
3349 })
3350 .is_ok()
3351 }
3352
3353 pub fn add_conceal(
3357 &self,
3358 buffer_id: u32,
3359 namespace: String,
3360 start: u32,
3361 end: u32,
3362 replacement: Option<String>,
3363 ) -> bool {
3364 self.plugin_tracked_state
3366 .borrow_mut()
3367 .entry(self.plugin_name.clone())
3368 .or_default()
3369 .overlay_namespaces
3370 .push((BufferId(buffer_id as usize), namespace.clone()));
3371
3372 self.command_sender
3373 .send(PluginCommand::AddConceal {
3374 buffer_id: BufferId(buffer_id as usize),
3375 namespace: OverlayNamespace::from_string(namespace),
3376 start: start as usize,
3377 end: end as usize,
3378 replacement,
3379 })
3380 .is_ok()
3381 }
3382
3383 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3385 self.command_sender
3386 .send(PluginCommand::ClearConcealNamespace {
3387 buffer_id: BufferId(buffer_id as usize),
3388 namespace: OverlayNamespace::from_string(namespace),
3389 })
3390 .is_ok()
3391 }
3392
3393 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3395 self.command_sender
3396 .send(PluginCommand::ClearConcealsInRange {
3397 buffer_id: BufferId(buffer_id as usize),
3398 start: start as usize,
3399 end: end as usize,
3400 })
3401 .is_ok()
3402 }
3403
3404 pub fn add_fold(
3411 &self,
3412 buffer_id: u32,
3413 start: u32,
3414 end: u32,
3415 placeholder: rquickjs::function::Opt<String>,
3416 ) -> bool {
3417 self.command_sender
3418 .send(PluginCommand::AddFold {
3419 buffer_id: BufferId(buffer_id as usize),
3420 start: start as usize,
3421 end: end as usize,
3422 placeholder: placeholder.0,
3423 })
3424 .is_ok()
3425 }
3426
3427 pub fn clear_folds(&self, buffer_id: u32) -> bool {
3429 self.command_sender
3430 .send(PluginCommand::ClearFolds {
3431 buffer_id: BufferId(buffer_id as usize),
3432 })
3433 .is_ok()
3434 }
3435
3436 pub fn set_folding_ranges<'js>(
3449 &self,
3450 _ctx: rquickjs::Ctx<'js>,
3451 buffer_id: u32,
3452 ranges_arr: Vec<rquickjs::Object<'js>>,
3453 ) -> rquickjs::Result<bool> {
3454 let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3455 for obj in ranges_arr {
3456 let start_line: u32 = obj.get("startLine").unwrap_or(0);
3457 let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3458 let kind = obj
3459 .get::<_, String>("kind")
3460 .ok()
3461 .and_then(|s| match s.as_str() {
3462 "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3463 "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3464 "region" => Some(lsp_types::FoldingRangeKind::Region),
3465 _ => None,
3466 });
3467 ranges.push(lsp_types::FoldingRange {
3468 start_line,
3469 end_line,
3470 start_character: None,
3471 end_character: None,
3472 kind,
3473 collapsed_text: None,
3474 });
3475 }
3476 Ok(self
3477 .command_sender
3478 .send(PluginCommand::SetFoldingRanges {
3479 buffer_id: BufferId(buffer_id as usize),
3480 ranges,
3481 })
3482 .is_ok())
3483 }
3484
3485 pub fn add_soft_break(
3489 &self,
3490 buffer_id: u32,
3491 namespace: String,
3492 position: u32,
3493 indent: u32,
3494 ) -> bool {
3495 self.plugin_tracked_state
3497 .borrow_mut()
3498 .entry(self.plugin_name.clone())
3499 .or_default()
3500 .overlay_namespaces
3501 .push((BufferId(buffer_id as usize), namespace.clone()));
3502
3503 self.command_sender
3504 .send(PluginCommand::AddSoftBreak {
3505 buffer_id: BufferId(buffer_id as usize),
3506 namespace: OverlayNamespace::from_string(namespace),
3507 position: position as usize,
3508 indent: indent as u16,
3509 })
3510 .is_ok()
3511 }
3512
3513 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3515 self.command_sender
3516 .send(PluginCommand::ClearSoftBreakNamespace {
3517 buffer_id: BufferId(buffer_id as usize),
3518 namespace: OverlayNamespace::from_string(namespace),
3519 })
3520 .is_ok()
3521 }
3522
3523 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3525 self.command_sender
3526 .send(PluginCommand::ClearSoftBreaksInRange {
3527 buffer_id: BufferId(buffer_id as usize),
3528 start: start as usize,
3529 end: end as usize,
3530 })
3531 .is_ok()
3532 }
3533
3534 #[allow(clippy::too_many_arguments)]
3544 pub fn submit_view_transform<'js>(
3545 &self,
3546 _ctx: rquickjs::Ctx<'js>,
3547 buffer_id: u32,
3548 split_id: Option<u32>,
3549 start: u32,
3550 end: u32,
3551 tokens: Vec<rquickjs::Object<'js>>,
3552 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3553 ) -> rquickjs::Result<bool> {
3554 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3555
3556 let tokens: Vec<ViewTokenWire> = tokens
3557 .into_iter()
3558 .enumerate()
3559 .map(|(idx, obj)| {
3560 parse_view_token(&obj, idx)
3562 })
3563 .collect::<rquickjs::Result<Vec<_>>>()?;
3564
3565 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3567 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3568 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3569 Some(LayoutHints {
3570 compose_width,
3571 column_guides,
3572 })
3573 } else {
3574 None
3575 };
3576
3577 let payload = ViewTransformPayload {
3578 range: (start as usize)..(end as usize),
3579 tokens,
3580 layout_hints: parsed_layout_hints,
3581 };
3582
3583 Ok(self
3584 .command_sender
3585 .send(PluginCommand::SubmitViewTransform {
3586 buffer_id: BufferId(buffer_id as usize),
3587 split_id: split_id.map(|id| SplitId(id as usize)),
3588 payload,
3589 })
3590 .is_ok())
3591 }
3592
3593 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3595 self.command_sender
3596 .send(PluginCommand::ClearViewTransform {
3597 buffer_id: BufferId(buffer_id as usize),
3598 split_id: split_id.map(|id| SplitId(id as usize)),
3599 })
3600 .is_ok()
3601 }
3602
3603 pub fn set_layout_hints<'js>(
3606 &self,
3607 buffer_id: u32,
3608 split_id: Option<u32>,
3609 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3610 ) -> rquickjs::Result<bool> {
3611 use fresh_core::api::LayoutHints;
3612
3613 let compose_width: Option<u16> = hints.get("composeWidth").ok();
3614 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3615 let parsed_hints = LayoutHints {
3616 compose_width,
3617 column_guides,
3618 };
3619
3620 Ok(self
3621 .command_sender
3622 .send(PluginCommand::SetLayoutHints {
3623 buffer_id: BufferId(buffer_id as usize),
3624 split_id: split_id.map(|id| SplitId(id as usize)),
3625 range: 0..0,
3626 hints: parsed_hints,
3627 })
3628 .is_ok())
3629 }
3630
3631 pub fn set_file_explorer_decorations<'js>(
3635 &self,
3636 _ctx: rquickjs::Ctx<'js>,
3637 namespace: String,
3638 decorations: Vec<rquickjs::Object<'js>>,
3639 ) -> rquickjs::Result<bool> {
3640 use fresh_core::file_explorer::FileExplorerDecoration;
3641
3642 let decorations: Vec<FileExplorerDecoration> = decorations
3643 .into_iter()
3644 .map(|obj| {
3645 let path: String = obj.get("path")?;
3646 let symbol: String = obj.get("symbol")?;
3647 let priority: i32 = obj.get("priority").unwrap_or(0);
3648
3649 let color_val: rquickjs::Value = obj.get("color")?;
3651 let color = if color_val.is_string() {
3652 let key: String = color_val.get()?;
3653 fresh_core::api::OverlayColorSpec::ThemeKey(key)
3654 } else if color_val.is_array() {
3655 let arr: Vec<u8> = color_val.get()?;
3656 if arr.len() < 3 {
3657 return Err(rquickjs::Error::FromJs {
3658 from: "array",
3659 to: "color",
3660 message: Some(format!(
3661 "color array must have at least 3 elements, got {}",
3662 arr.len()
3663 )),
3664 });
3665 }
3666 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3667 } else {
3668 return Err(rquickjs::Error::FromJs {
3669 from: "value",
3670 to: "color",
3671 message: Some("color must be an RGB array or theme key string".to_string()),
3672 });
3673 };
3674
3675 Ok(FileExplorerDecoration {
3676 path: std::path::PathBuf::from(path),
3677 symbol,
3678 color,
3679 priority,
3680 })
3681 })
3682 .collect::<rquickjs::Result<Vec<_>>>()?;
3683
3684 self.plugin_tracked_state
3686 .borrow_mut()
3687 .entry(self.plugin_name.clone())
3688 .or_default()
3689 .file_explorer_namespaces
3690 .push(namespace.clone());
3691
3692 Ok(self
3693 .command_sender
3694 .send(PluginCommand::SetFileExplorerDecorations {
3695 namespace,
3696 decorations,
3697 })
3698 .is_ok())
3699 }
3700
3701 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3703 self.command_sender
3704 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
3705 .is_ok()
3706 }
3707
3708 #[allow(clippy::too_many_arguments)]
3712 pub fn add_virtual_text(
3713 &self,
3714 buffer_id: u32,
3715 virtual_text_id: String,
3716 position: u32,
3717 text: String,
3718 r: u8,
3719 g: u8,
3720 b: u8,
3721 before: bool,
3722 use_bg: bool,
3723 ) -> bool {
3724 self.plugin_tracked_state
3726 .borrow_mut()
3727 .entry(self.plugin_name.clone())
3728 .or_default()
3729 .virtual_text_ids
3730 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3731
3732 self.command_sender
3733 .send(PluginCommand::AddVirtualText {
3734 buffer_id: BufferId(buffer_id as usize),
3735 virtual_text_id,
3736 position: position as usize,
3737 text,
3738 color: (r, g, b),
3739 use_bg,
3740 before,
3741 })
3742 .is_ok()
3743 }
3744
3745 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
3747 self.command_sender
3748 .send(PluginCommand::RemoveVirtualText {
3749 buffer_id: BufferId(buffer_id as usize),
3750 virtual_text_id,
3751 })
3752 .is_ok()
3753 }
3754
3755 #[allow(clippy::too_many_arguments)]
3761 pub fn add_virtual_text_styled<'js>(
3762 &self,
3763 _ctx: rquickjs::Ctx<'js>,
3764 buffer_id: u32,
3765 virtual_text_id: String,
3766 position: u32,
3767 text: String,
3768 options: rquickjs::Object<'js>,
3769 before: bool,
3770 ) -> rquickjs::Result<bool> {
3771 use fresh_core::api::OverlayColorSpec;
3772
3773 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3776 if let Ok(theme_key) = obj.get::<_, String>(key) {
3777 if !theme_key.is_empty() {
3778 return Some(OverlayColorSpec::ThemeKey(theme_key));
3779 }
3780 }
3781 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3782 if arr.len() >= 3 {
3783 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3784 }
3785 }
3786 None
3787 }
3788
3789 let fg = parse_color_spec("fg", &options);
3790 let bg = parse_color_spec("bg", &options);
3791 let bold: bool = options.get("bold").unwrap_or(false);
3792 let italic: bool = options.get("italic").unwrap_or(false);
3793
3794 self.plugin_tracked_state
3796 .borrow_mut()
3797 .entry(self.plugin_name.clone())
3798 .or_default()
3799 .virtual_text_ids
3800 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3801
3802 let _ = self
3803 .command_sender
3804 .send(PluginCommand::AddVirtualTextStyled {
3805 buffer_id: BufferId(buffer_id as usize),
3806 virtual_text_id,
3807 position: position as usize,
3808 text,
3809 fg,
3810 bg,
3811 bold,
3812 italic,
3813 before,
3814 });
3815 Ok(true)
3816 }
3817
3818 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
3820 self.command_sender
3821 .send(PluginCommand::RemoveVirtualTextsByPrefix {
3822 buffer_id: BufferId(buffer_id as usize),
3823 prefix,
3824 })
3825 .is_ok()
3826 }
3827
3828 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
3830 self.command_sender
3831 .send(PluginCommand::ClearVirtualTexts {
3832 buffer_id: BufferId(buffer_id as usize),
3833 })
3834 .is_ok()
3835 }
3836
3837 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3839 self.command_sender
3840 .send(PluginCommand::ClearVirtualTextNamespace {
3841 buffer_id: BufferId(buffer_id as usize),
3842 namespace,
3843 })
3844 .is_ok()
3845 }
3846
3847 #[allow(clippy::too_many_arguments)]
3862 pub fn add_virtual_line<'js>(
3863 &self,
3864 _ctx: rquickjs::Ctx<'js>,
3865 buffer_id: u32,
3866 position: u32,
3867 text: String,
3868 options: rquickjs::Object<'js>,
3869 above: bool,
3870 namespace: String,
3871 priority: i32,
3872 ) -> rquickjs::Result<bool> {
3873 use fresh_core::api::OverlayColorSpec;
3874
3875 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3878 if let Ok(theme_key) = obj.get::<_, String>(key) {
3879 if !theme_key.is_empty() {
3880 return Some(OverlayColorSpec::ThemeKey(theme_key));
3881 }
3882 }
3883 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3884 if arr.len() >= 3 {
3885 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3886 }
3887 }
3888 None
3889 }
3890
3891 let fg_color = parse_color_spec("fg", &options);
3892 let bg_color = parse_color_spec("bg", &options);
3893 let gutter_glyph = options
3894 .get::<_, String>("gutterGlyph")
3895 .ok()
3896 .filter(|s| !s.is_empty());
3897 let gutter_color = parse_color_spec("gutterColor", &options);
3898
3899 let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
3905 .get::<_, rquickjs::Value<'js>>("textOverlays")
3906 .ok()
3907 .filter(|v| !v.is_undefined() && !v.is_null())
3908 .and_then(|v| rquickjs_serde::from_value(v).ok())
3909 .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
3910 v.into_iter().filter(|o| o.end > o.start).collect()
3911 })
3912 .unwrap_or_default();
3913
3914 self.plugin_tracked_state
3916 .borrow_mut()
3917 .entry(self.plugin_name.clone())
3918 .or_default()
3919 .virtual_line_namespaces
3920 .push((BufferId(buffer_id as usize), namespace.clone()));
3921
3922 Ok(self
3923 .command_sender
3924 .send(PluginCommand::AddVirtualLine {
3925 buffer_id: BufferId(buffer_id as usize),
3926 position: position as usize,
3927 text,
3928 fg_color,
3929 bg_color,
3930 above,
3931 namespace,
3932 priority,
3933 gutter_glyph,
3934 gutter_color,
3935 text_overlays,
3936 })
3937 .is_ok())
3938 }
3939
3940 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
3945 #[qjs(rename = "_promptStart")]
3946 pub fn prompt_start(
3947 &self,
3948 _ctx: rquickjs::Ctx<'_>,
3949 label: String,
3950 initial_value: String,
3951 ) -> u64 {
3952 let id = self.alloc_request_id();
3953
3954 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
3955 label,
3956 initial_value,
3957 callback_id: JsCallbackId::new(id),
3958 });
3959
3960 id
3961 }
3962
3963 pub fn start_prompt(
3974 &self,
3975 label: String,
3976 prompt_type: String,
3977 floating_overlay: rquickjs::function::Opt<bool>,
3978 ) -> bool {
3979 self.command_sender
3980 .send(PluginCommand::StartPrompt {
3981 label,
3982 prompt_type,
3983 floating_overlay: floating_overlay.0.unwrap_or(false),
3984 })
3985 .is_ok()
3986 }
3987
3988 pub fn begin_key_capture(&self) -> bool {
3998 self.command_sender
3999 .send(PluginCommand::SetKeyCaptureActive { active: true })
4000 .is_ok()
4001 }
4002
4003 pub fn end_key_capture(&self) -> bool {
4007 self.command_sender
4008 .send(PluginCommand::SetKeyCaptureActive { active: false })
4009 .is_ok()
4010 }
4011
4012 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4024 #[qjs(rename = "_getNextKeyStart")]
4025 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4026 let id = self.alloc_request_id();
4027 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4028 callback_id: JsCallbackId::new(id),
4029 });
4030 id
4031 }
4032
4033 pub fn start_prompt_with_initial(
4036 &self,
4037 label: String,
4038 prompt_type: String,
4039 initial_value: String,
4040 floating_overlay: rquickjs::function::Opt<bool>,
4041 ) -> bool {
4042 self.command_sender
4043 .send(PluginCommand::StartPromptWithInitial {
4044 label,
4045 prompt_type,
4046 initial_value,
4047 floating_overlay: floating_overlay.0.unwrap_or(false),
4048 })
4049 .is_ok()
4050 }
4051
4052 pub fn set_prompt_suggestions(
4056 &self,
4057 suggestions: Vec<fresh_core::command::Suggestion>,
4058 ) -> bool {
4059 self.command_sender
4060 .send(PluginCommand::SetPromptSuggestions { suggestions })
4061 .is_ok()
4062 }
4063
4064 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4065 self.command_sender
4066 .send(PluginCommand::SetPromptInputSync { sync })
4067 .is_ok()
4068 }
4069
4070 pub fn set_prompt_title(
4080 &self,
4081 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4082 ) -> bool {
4083 self.command_sender
4084 .send(PluginCommand::SetPromptTitle { title })
4085 .is_ok()
4086 }
4087
4088 pub fn set_prompt_footer(
4094 &self,
4095 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4096 ) -> bool {
4097 self.command_sender
4098 .send(PluginCommand::SetPromptFooter { footer })
4099 .is_ok()
4100 }
4101
4102 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4110 self.command_sender
4111 .send(PluginCommand::SetPromptSelectedIndex { index })
4112 .is_ok()
4113 }
4114
4115 pub fn define_mode(
4119 &self,
4120 name: String,
4121 bindings_arr: Vec<Vec<String>>,
4122 read_only: rquickjs::function::Opt<bool>,
4123 allow_text_input: rquickjs::function::Opt<bool>,
4124 inherit_normal_bindings: rquickjs::function::Opt<bool>,
4125 ) -> bool {
4126 let bindings: Vec<(String, String)> = bindings_arr
4127 .into_iter()
4128 .filter_map(|arr| {
4129 if arr.len() >= 2 {
4130 Some((arr[0].clone(), arr[1].clone()))
4131 } else {
4132 None
4133 }
4134 })
4135 .collect();
4136
4137 {
4140 let mut registered = self.registered_actions.borrow_mut();
4141 for (_, cmd_name) in &bindings {
4142 registered.insert(
4143 cmd_name.clone(),
4144 PluginHandler {
4145 plugin_name: self.plugin_name.clone(),
4146 handler_name: cmd_name.clone(),
4147 },
4148 );
4149 }
4150 }
4151
4152 let allow_text = allow_text_input.0.unwrap_or(false);
4155 if allow_text {
4156 let mut registered = self.registered_actions.borrow_mut();
4157 registered.insert(
4158 "mode_text_input".to_string(),
4159 PluginHandler {
4160 plugin_name: self.plugin_name.clone(),
4161 handler_name: "mode_text_input".to_string(),
4162 },
4163 );
4164 }
4165
4166 self.command_sender
4167 .send(PluginCommand::DefineMode {
4168 name,
4169 bindings,
4170 read_only: read_only.0.unwrap_or(false),
4171 allow_text_input: allow_text,
4172 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4173 plugin_name: Some(self.plugin_name.clone()),
4174 })
4175 .is_ok()
4176 }
4177
4178 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4180 self.command_sender
4181 .send(PluginCommand::SetEditorMode { mode })
4182 .is_ok()
4183 }
4184
4185 pub fn get_editor_mode(&self) -> Option<String> {
4187 self.state_snapshot
4188 .read()
4189 .ok()
4190 .and_then(|s| s.editor_mode.clone())
4191 }
4192
4193 pub fn close_split(&self, split_id: u32) -> bool {
4197 self.command_sender
4198 .send(PluginCommand::CloseSplit {
4199 split_id: SplitId(split_id as usize),
4200 })
4201 .is_ok()
4202 }
4203
4204 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4206 self.command_sender
4207 .send(PluginCommand::SetSplitBuffer {
4208 split_id: SplitId(split_id as usize),
4209 buffer_id: BufferId(buffer_id as usize),
4210 })
4211 .is_ok()
4212 }
4213
4214 pub fn focus_split(&self, split_id: u32) -> bool {
4216 self.command_sender
4217 .send(PluginCommand::FocusSplit {
4218 split_id: SplitId(split_id as usize),
4219 })
4220 .is_ok()
4221 }
4222
4223 pub fn create_window(&self, root: String, label: String) -> bool {
4242 self.command_sender
4243 .send(PluginCommand::CreateWindow {
4244 root: std::path::PathBuf::from(root),
4245 label,
4246 })
4247 .is_ok()
4248 }
4249
4250 pub fn set_active_window(&self, id: u64) -> bool {
4255 self.command_sender
4256 .send(PluginCommand::SetActiveWindow {
4257 id: fresh_core::WindowId(id),
4258 })
4259 .is_ok()
4260 }
4261
4262 pub fn close_window(&self, id: u64) -> bool {
4265 self.command_sender
4266 .send(PluginCommand::CloseWindow {
4267 id: fresh_core::WindowId(id),
4268 })
4269 .is_ok()
4270 }
4271
4272 pub fn prewarm_window(&self, id: u64) -> bool {
4276 self.command_sender
4277 .send(PluginCommand::PrewarmWindow {
4278 id: fresh_core::WindowId(id),
4279 })
4280 .is_ok()
4281 }
4282
4283 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4295 #[qjs(rename = "_watchPathStart")]
4296 pub fn watch_path_start(
4297 &self,
4298 _ctx: rquickjs::Ctx<'_>,
4299 path: String,
4300 recursive: rquickjs::function::Opt<bool>,
4301 ) -> rquickjs::Result<u64> {
4302 let id = self.alloc_request_id();
4303 if let Ok(mut owners) = self.async_resource_owners.lock() {
4304 owners.insert(id, self.plugin_name.clone());
4305 }
4306 let _ = self.command_sender.send(PluginCommand::WatchPath {
4307 path: std::path::PathBuf::from(path),
4308 recursive: recursive.0.unwrap_or(false),
4309 request_id: id,
4310 });
4311 Ok(id)
4312 }
4313
4314 pub fn unwatch_path(&self, handle: u64) -> bool {
4317 self.command_sender
4318 .send(PluginCommand::UnwatchPath { handle })
4319 .is_ok()
4320 }
4321
4322 pub fn preview_window_in_rect(&self, id: u64) -> bool {
4333 let sid = if id == 0 {
4334 None
4335 } else {
4336 Some(fresh_core::WindowId(id))
4337 };
4338 self.command_sender
4339 .send(PluginCommand::PreviewWindowInRect { id: sid })
4340 .is_ok()
4341 }
4342
4343 pub fn clear_window_preview(&self) -> bool {
4346 self.command_sender
4347 .send(PluginCommand::PreviewWindowInRect { id: None })
4348 .is_ok()
4349 }
4350
4351 #[plugin_api(ts_return = "WindowInfo[]")]
4354 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4355 let sessions: Vec<fresh_core::api::WindowInfo> = self
4356 .state_snapshot
4357 .read()
4358 .map(|s| s.windows.clone())
4359 .unwrap_or_default();
4360 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4361 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4362 })
4363 }
4364
4365 pub fn active_window(&self) -> u64 {
4368 self.state_snapshot
4369 .read()
4370 .map(|s| s.active_window_id.0)
4371 .unwrap_or(1)
4372 }
4373
4374 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4376 self.command_sender
4377 .send(PluginCommand::SetSplitScroll {
4378 split_id: SplitId(split_id as usize),
4379 top_byte: top_byte as usize,
4380 })
4381 .is_ok()
4382 }
4383
4384 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4386 self.command_sender
4387 .send(PluginCommand::SetSplitRatio {
4388 split_id: SplitId(split_id as usize),
4389 ratio,
4390 })
4391 .is_ok()
4392 }
4393
4394 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4396 self.command_sender
4397 .send(PluginCommand::SetSplitLabel {
4398 split_id: SplitId(split_id as usize),
4399 label,
4400 })
4401 .is_ok()
4402 }
4403
4404 pub fn clear_split_label(&self, split_id: u32) -> bool {
4406 self.command_sender
4407 .send(PluginCommand::ClearSplitLabel {
4408 split_id: SplitId(split_id as usize),
4409 })
4410 .is_ok()
4411 }
4412
4413 #[plugin_api(
4415 async_promise,
4416 js_name = "getSplitByLabel",
4417 ts_return = "number | null"
4418 )]
4419 #[qjs(rename = "_getSplitByLabelStart")]
4420 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4421 let id = self.alloc_request_id();
4422 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4423 label,
4424 request_id: id,
4425 });
4426 id
4427 }
4428
4429 pub fn distribute_splits_evenly(&self) -> bool {
4431 self.command_sender
4433 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4434 .is_ok()
4435 }
4436
4437 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4439 self.command_sender
4440 .send(PluginCommand::SetBufferCursor {
4441 buffer_id: BufferId(buffer_id as usize),
4442 position: position as usize,
4443 })
4444 .is_ok()
4445 }
4446
4447 #[qjs(rename = "setBufferShowCursors")]
4454 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4455 self.command_sender
4456 .send(PluginCommand::SetBufferShowCursors {
4457 buffer_id: BufferId(buffer_id as usize),
4458 show,
4459 })
4460 .is_ok()
4461 }
4462
4463 #[allow(clippy::too_many_arguments)]
4467 pub fn set_line_indicator(
4468 &self,
4469 buffer_id: u32,
4470 line: u32,
4471 namespace: String,
4472 symbol: String,
4473 r: u8,
4474 g: u8,
4475 b: u8,
4476 priority: i32,
4477 ) -> bool {
4478 self.plugin_tracked_state
4480 .borrow_mut()
4481 .entry(self.plugin_name.clone())
4482 .or_default()
4483 .line_indicator_namespaces
4484 .push((BufferId(buffer_id as usize), namespace.clone()));
4485
4486 self.command_sender
4487 .send(PluginCommand::SetLineIndicator {
4488 buffer_id: BufferId(buffer_id as usize),
4489 line: line as usize,
4490 namespace,
4491 symbol,
4492 color: (r, g, b),
4493 priority,
4494 })
4495 .is_ok()
4496 }
4497
4498 #[allow(clippy::too_many_arguments)]
4500 pub fn set_line_indicators(
4501 &self,
4502 buffer_id: u32,
4503 lines: Vec<u32>,
4504 namespace: String,
4505 symbol: String,
4506 r: u8,
4507 g: u8,
4508 b: u8,
4509 priority: i32,
4510 ) -> bool {
4511 self.plugin_tracked_state
4513 .borrow_mut()
4514 .entry(self.plugin_name.clone())
4515 .or_default()
4516 .line_indicator_namespaces
4517 .push((BufferId(buffer_id as usize), namespace.clone()));
4518
4519 self.command_sender
4520 .send(PluginCommand::SetLineIndicators {
4521 buffer_id: BufferId(buffer_id as usize),
4522 lines: lines.into_iter().map(|l| l as usize).collect(),
4523 namespace,
4524 symbol,
4525 color: (r, g, b),
4526 priority,
4527 })
4528 .is_ok()
4529 }
4530
4531 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4533 self.command_sender
4534 .send(PluginCommand::ClearLineIndicators {
4535 buffer_id: BufferId(buffer_id as usize),
4536 namespace,
4537 })
4538 .is_ok()
4539 }
4540
4541 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4543 self.command_sender
4544 .send(PluginCommand::SetLineNumbers {
4545 buffer_id: BufferId(buffer_id as usize),
4546 enabled,
4547 })
4548 .is_ok()
4549 }
4550
4551 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4553 self.command_sender
4554 .send(PluginCommand::SetViewMode {
4555 buffer_id: BufferId(buffer_id as usize),
4556 mode,
4557 })
4558 .is_ok()
4559 }
4560
4561 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4563 self.command_sender
4564 .send(PluginCommand::SetLineWrap {
4565 buffer_id: BufferId(buffer_id as usize),
4566 split_id: split_id.map(|s| SplitId(s as usize)),
4567 enabled,
4568 })
4569 .is_ok()
4570 }
4571
4572 pub fn set_view_state<'js>(
4576 &self,
4577 ctx: rquickjs::Ctx<'js>,
4578 buffer_id: u32,
4579 key: String,
4580 value: Value<'js>,
4581 ) -> bool {
4582 let bid = BufferId(buffer_id as usize);
4583
4584 let json_value = if value.is_undefined() || value.is_null() {
4586 None
4587 } else {
4588 Some(js_to_json(&ctx, value))
4589 };
4590
4591 if let Ok(mut snapshot) = self.state_snapshot.write() {
4593 if let Some(ref json_val) = json_value {
4594 snapshot
4595 .plugin_view_states
4596 .entry(bid)
4597 .or_default()
4598 .insert(key.clone(), json_val.clone());
4599 } else {
4600 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4602 map.remove(&key);
4603 if map.is_empty() {
4604 snapshot.plugin_view_states.remove(&bid);
4605 }
4606 }
4607 }
4608 }
4609
4610 self.command_sender
4612 .send(PluginCommand::SetViewState {
4613 buffer_id: bid,
4614 key,
4615 value: json_value,
4616 })
4617 .is_ok()
4618 }
4619
4620 pub fn get_view_state<'js>(
4622 &self,
4623 ctx: rquickjs::Ctx<'js>,
4624 buffer_id: u32,
4625 key: String,
4626 ) -> rquickjs::Result<Value<'js>> {
4627 let bid = BufferId(buffer_id as usize);
4628 if let Ok(snapshot) = self.state_snapshot.read() {
4629 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4630 if let Some(json_val) = map.get(&key) {
4631 return json_to_js_value(&ctx, json_val);
4632 }
4633 }
4634 }
4635 Ok(Value::new_undefined(ctx.clone()))
4636 }
4637
4638 pub fn set_global_state<'js>(
4644 &self,
4645 ctx: rquickjs::Ctx<'js>,
4646 key: String,
4647 value: Value<'js>,
4648 ) -> bool {
4649 let json_value = if value.is_undefined() || value.is_null() {
4651 None
4652 } else {
4653 Some(js_to_json(&ctx, value))
4654 };
4655
4656 if let Ok(mut snapshot) = self.state_snapshot.write() {
4658 if let Some(ref json_val) = json_value {
4659 snapshot
4660 .plugin_global_states
4661 .entry(self.plugin_name.clone())
4662 .or_default()
4663 .insert(key.clone(), json_val.clone());
4664 } else {
4665 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
4667 map.remove(&key);
4668 if map.is_empty() {
4669 snapshot.plugin_global_states.remove(&self.plugin_name);
4670 }
4671 }
4672 }
4673 }
4674
4675 self.command_sender
4677 .send(PluginCommand::SetGlobalState {
4678 plugin_name: self.plugin_name.clone(),
4679 key,
4680 value: json_value,
4681 })
4682 .is_ok()
4683 }
4684
4685 pub fn get_global_state<'js>(
4689 &self,
4690 ctx: rquickjs::Ctx<'js>,
4691 key: String,
4692 ) -> rquickjs::Result<Value<'js>> {
4693 if let Ok(snapshot) = self.state_snapshot.read() {
4694 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
4695 if let Some(json_val) = map.get(&key) {
4696 return json_to_js_value(&ctx, json_val);
4697 }
4698 }
4699 }
4700 Ok(Value::new_undefined(ctx.clone()))
4701 }
4702
4703 pub fn set_window_state<'js>(
4712 &self,
4713 ctx: rquickjs::Ctx<'js>,
4714 key: String,
4715 value: Value<'js>,
4716 ) -> bool {
4717 let json_value = if value.is_undefined() || value.is_null() {
4718 None
4719 } else {
4720 Some(js_to_json(&ctx, value))
4721 };
4722 if let Ok(mut snapshot) = self.state_snapshot.write() {
4726 match &json_value {
4727 Some(v) => {
4728 snapshot
4729 .active_session_plugin_states
4730 .entry(self.plugin_name.clone())
4731 .or_default()
4732 .insert(key.clone(), v.clone());
4733 }
4734 None => {
4735 if let Some(map) = snapshot
4736 .active_session_plugin_states
4737 .get_mut(&self.plugin_name)
4738 {
4739 map.remove(&key);
4740 if map.is_empty() {
4741 snapshot
4742 .active_session_plugin_states
4743 .remove(&self.plugin_name);
4744 }
4745 }
4746 }
4747 }
4748 }
4749 self.command_sender
4750 .send(PluginCommand::SetWindowState {
4751 plugin_name: self.plugin_name.clone(),
4752 key,
4753 value: json_value,
4754 })
4755 .is_ok()
4756 }
4757
4758 pub fn get_window_state<'js>(
4761 &self,
4762 ctx: rquickjs::Ctx<'js>,
4763 key: String,
4764 ) -> rquickjs::Result<Value<'js>> {
4765 if let Ok(snapshot) = self.state_snapshot.read() {
4766 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
4767 if let Some(json_val) = map.get(&key) {
4768 return json_to_js_value(&ctx, json_val);
4769 }
4770 }
4771 }
4772 Ok(Value::new_undefined(ctx.clone()))
4773 }
4774
4775 pub fn create_scroll_sync_group(
4779 &self,
4780 group_id: u32,
4781 left_split: u32,
4782 right_split: u32,
4783 ) -> bool {
4784 self.plugin_tracked_state
4786 .borrow_mut()
4787 .entry(self.plugin_name.clone())
4788 .or_default()
4789 .scroll_sync_group_ids
4790 .push(group_id);
4791 self.command_sender
4792 .send(PluginCommand::CreateScrollSyncGroup {
4793 group_id,
4794 left_split: SplitId(left_split as usize),
4795 right_split: SplitId(right_split as usize),
4796 })
4797 .is_ok()
4798 }
4799
4800 pub fn set_scroll_sync_anchors<'js>(
4802 &self,
4803 _ctx: rquickjs::Ctx<'js>,
4804 group_id: u32,
4805 anchors: Vec<Vec<u32>>,
4806 ) -> bool {
4807 let anchors: Vec<(usize, usize)> = anchors
4808 .into_iter()
4809 .filter_map(|pair| {
4810 if pair.len() >= 2 {
4811 Some((pair[0] as usize, pair[1] as usize))
4812 } else {
4813 None
4814 }
4815 })
4816 .collect();
4817 self.command_sender
4818 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
4819 .is_ok()
4820 }
4821
4822 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
4824 self.command_sender
4825 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
4826 .is_ok()
4827 }
4828
4829 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
4835 self.command_sender
4836 .send(PluginCommand::ExecuteActions { actions })
4837 .is_ok()
4838 }
4839
4840 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
4844 self.command_sender
4845 .send(PluginCommand::ShowActionPopup {
4846 popup_id: opts.id,
4847 title: opts.title,
4848 message: opts.message,
4849 actions: opts.actions,
4850 })
4851 .is_ok()
4852 }
4853
4854 pub fn set_lsp_menu_contributions(
4858 &self,
4859 plugin_id: String,
4860 language: String,
4861 items: Vec<fresh_core::api::LspMenuItem>,
4862 ) -> bool {
4863 self.command_sender
4864 .send(PluginCommand::SetLspMenuContributions {
4865 plugin_id,
4866 language,
4867 items,
4868 })
4869 .is_ok()
4870 }
4871
4872 pub fn disable_lsp_for_language(&self, language: String) -> bool {
4874 self.command_sender
4875 .send(PluginCommand::DisableLspForLanguage { language })
4876 .is_ok()
4877 }
4878
4879 pub fn restart_lsp_for_language(&self, language: String) -> bool {
4881 self.command_sender
4882 .send(PluginCommand::RestartLspForLanguage { language })
4883 .is_ok()
4884 }
4885
4886 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
4889 self.command_sender
4890 .send(PluginCommand::SetLspRootUri { language, uri })
4891 .is_ok()
4892 }
4893
4894 #[plugin_api(ts_return = "JsDiagnostic[]")]
4896 pub fn get_all_diagnostics<'js>(
4897 &self,
4898 ctx: rquickjs::Ctx<'js>,
4899 ) -> rquickjs::Result<Value<'js>> {
4900 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
4901
4902 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
4903 let mut result: Vec<JsDiagnostic> = Vec::new();
4905 for (uri, diags) in s.diagnostics.iter() {
4906 for diag in diags {
4907 result.push(JsDiagnostic {
4908 uri: uri.clone(),
4909 message: diag.message.clone(),
4910 severity: diag.severity.map(|s| match s {
4911 lsp_types::DiagnosticSeverity::ERROR => 1,
4912 lsp_types::DiagnosticSeverity::WARNING => 2,
4913 lsp_types::DiagnosticSeverity::INFORMATION => 3,
4914 lsp_types::DiagnosticSeverity::HINT => 4,
4915 _ => 0,
4916 }),
4917 range: JsRange {
4918 start: JsPosition {
4919 line: diag.range.start.line,
4920 character: diag.range.start.character,
4921 },
4922 end: JsPosition {
4923 line: diag.range.end.line,
4924 character: diag.range.end.character,
4925 },
4926 },
4927 source: diag.source.clone(),
4928 });
4929 }
4930 }
4931 result
4932 } else {
4933 Vec::new()
4934 };
4935 rquickjs_serde::to_value(ctx, &diagnostics)
4936 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4937 }
4938
4939 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
4941 self.event_handlers
4942 .read()
4943 .expect("event_handlers poisoned")
4944 .get(&event_name)
4945 .cloned()
4946 .unwrap_or_default()
4947 .into_iter()
4948 .map(|h| h.handler_name)
4949 .collect()
4950 }
4951
4952 #[plugin_api(
4956 async_promise,
4957 js_name = "createVirtualBuffer",
4958 ts_return = "VirtualBufferResult"
4959 )]
4960 #[qjs(rename = "_createVirtualBufferStart")]
4961 pub fn create_virtual_buffer_start(
4962 &self,
4963 _ctx: rquickjs::Ctx<'_>,
4964 opts: fresh_core::api::CreateVirtualBufferOptions,
4965 ) -> rquickjs::Result<u64> {
4966 let id = self.alloc_request_id();
4967
4968 let entries: Vec<TextPropertyEntry> = opts
4970 .entries
4971 .unwrap_or_default()
4972 .into_iter()
4973 .map(|e| TextPropertyEntry {
4974 text: e.text,
4975 properties: e.properties.unwrap_or_default(),
4976 style: e.style,
4977 inline_overlays: e.inline_overlays.unwrap_or_default(),
4978 segments: e.segments.unwrap_or_default(),
4979 pad_to_chars: e.pad_to_chars,
4980 truncate_to_chars: e.truncate_to_chars,
4981 })
4982 .collect();
4983
4984 tracing::debug!(
4985 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
4986 id
4987 );
4988 if let Ok(mut owners) = self.async_resource_owners.lock() {
4990 owners.insert(id, self.plugin_name.clone());
4991 }
4992 let _ = self
4993 .command_sender
4994 .send(PluginCommand::CreateVirtualBufferWithContent {
4995 name: opts.name,
4996 mode: opts.mode.unwrap_or_default(),
4997 read_only: opts.read_only.unwrap_or(false),
4998 entries,
4999 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5000 show_cursors: opts.show_cursors.unwrap_or(true),
5001 editing_disabled: opts.editing_disabled.unwrap_or(false),
5002 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5003 request_id: Some(id),
5004 });
5005 Ok(id)
5006 }
5007
5008 #[plugin_api(
5010 async_promise,
5011 js_name = "createVirtualBufferInSplit",
5012 ts_return = "VirtualBufferResult"
5013 )]
5014 #[qjs(rename = "_createVirtualBufferInSplitStart")]
5015 pub fn create_virtual_buffer_in_split_start(
5016 &self,
5017 _ctx: rquickjs::Ctx<'_>,
5018 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5019 ) -> rquickjs::Result<u64> {
5020 let id = self.alloc_request_id();
5021
5022 let entries: Vec<TextPropertyEntry> = opts
5024 .entries
5025 .unwrap_or_default()
5026 .into_iter()
5027 .map(|e| TextPropertyEntry {
5028 text: e.text,
5029 properties: e.properties.unwrap_or_default(),
5030 style: e.style,
5031 inline_overlays: e.inline_overlays.unwrap_or_default(),
5032 segments: e.segments.unwrap_or_default(),
5033 pad_to_chars: e.pad_to_chars,
5034 truncate_to_chars: e.truncate_to_chars,
5035 })
5036 .collect();
5037
5038 if let Ok(mut owners) = self.async_resource_owners.lock() {
5040 owners.insert(id, self.plugin_name.clone());
5041 }
5042 let _ = self
5043 .command_sender
5044 .send(PluginCommand::CreateVirtualBufferInSplit {
5045 name: opts.name,
5046 mode: opts.mode.unwrap_or_default(),
5047 read_only: opts.read_only.unwrap_or(false),
5048 entries,
5049 ratio: opts.ratio.unwrap_or(0.5),
5050 direction: opts.direction,
5051 panel_id: opts.panel_id,
5052 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5053 show_cursors: opts.show_cursors.unwrap_or(true),
5054 editing_disabled: opts.editing_disabled.unwrap_or(false),
5055 line_wrap: opts.line_wrap,
5056 before: opts.before.unwrap_or(false),
5057 role: opts.role,
5058 request_id: Some(id),
5059 });
5060 Ok(id)
5061 }
5062
5063 #[plugin_api(
5065 async_promise,
5066 js_name = "createVirtualBufferInExistingSplit",
5067 ts_return = "VirtualBufferResult"
5068 )]
5069 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5070 pub fn create_virtual_buffer_in_existing_split_start(
5071 &self,
5072 _ctx: rquickjs::Ctx<'_>,
5073 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5074 ) -> rquickjs::Result<u64> {
5075 let id = self.alloc_request_id();
5076
5077 let entries: Vec<TextPropertyEntry> = opts
5079 .entries
5080 .unwrap_or_default()
5081 .into_iter()
5082 .map(|e| TextPropertyEntry {
5083 text: e.text,
5084 properties: e.properties.unwrap_or_default(),
5085 style: e.style,
5086 inline_overlays: e.inline_overlays.unwrap_or_default(),
5087 segments: e.segments.unwrap_or_default(),
5088 pad_to_chars: e.pad_to_chars,
5089 truncate_to_chars: e.truncate_to_chars,
5090 })
5091 .collect();
5092
5093 if let Ok(mut owners) = self.async_resource_owners.lock() {
5095 owners.insert(id, self.plugin_name.clone());
5096 }
5097 let _ = self
5098 .command_sender
5099 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5100 name: opts.name,
5101 mode: opts.mode.unwrap_or_default(),
5102 read_only: opts.read_only.unwrap_or(false),
5103 entries,
5104 split_id: SplitId(opts.split_id),
5105 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5106 show_cursors: opts.show_cursors.unwrap_or(true),
5107 editing_disabled: opts.editing_disabled.unwrap_or(false),
5108 line_wrap: opts.line_wrap,
5109 request_id: Some(id),
5110 });
5111 Ok(id)
5112 }
5113
5114 #[qjs(rename = "_createBufferGroupStart")]
5116 pub fn create_buffer_group_start(
5117 &self,
5118 _ctx: rquickjs::Ctx<'_>,
5119 name: String,
5120 mode: String,
5121 layout_json: String,
5122 ) -> rquickjs::Result<u64> {
5123 let id = self.alloc_request_id();
5124 if let Ok(mut owners) = self.async_resource_owners.lock() {
5125 owners.insert(id, self.plugin_name.clone());
5126 }
5127 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5128 name,
5129 mode,
5130 layout_json,
5131 request_id: Some(id),
5132 });
5133 Ok(id)
5134 }
5135
5136 #[qjs(rename = "setPanelContent")]
5138 pub fn set_panel_content<'js>(
5139 &self,
5140 ctx: rquickjs::Ctx<'js>,
5141 group_id: u32,
5142 panel_name: String,
5143 entries_arr: Vec<rquickjs::Object<'js>>,
5144 ) -> rquickjs::Result<bool> {
5145 let entries: Vec<TextPropertyEntry> = entries_arr
5146 .iter()
5147 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5148 .collect();
5149 Ok(self
5150 .command_sender
5151 .send(PluginCommand::SetPanelContent {
5152 group_id: group_id as usize,
5153 panel_name,
5154 entries,
5155 })
5156 .is_ok())
5157 }
5158
5159 #[qjs(rename = "closeBufferGroup")]
5161 pub fn close_buffer_group(&self, group_id: u32) -> bool {
5162 self.command_sender
5163 .send(PluginCommand::CloseBufferGroup {
5164 group_id: group_id as usize,
5165 })
5166 .is_ok()
5167 }
5168
5169 #[qjs(rename = "focusBufferGroupPanel")]
5171 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5172 self.command_sender
5173 .send(PluginCommand::FocusPanel {
5174 group_id: group_id as usize,
5175 panel_name,
5176 })
5177 .is_ok()
5178 }
5179
5180 #[plugin_api(
5187 async_promise,
5188 js_name = "setBufferGroupPanelBuffer",
5189 ts_return = "boolean"
5190 )]
5191 #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5192 pub fn set_buffer_group_panel_buffer_start(
5193 &self,
5194 _ctx: rquickjs::Ctx<'_>,
5195 group_id: u32,
5196 panel_name: String,
5197 buffer_id: u32,
5198 ) -> u64 {
5199 let id = self.alloc_request_id();
5200 let _ = self
5201 .command_sender
5202 .send(PluginCommand::SetBufferGroupPanelBuffer {
5203 group_id: group_id as usize,
5204 panel_name,
5205 buffer_id: BufferId(buffer_id as usize),
5206 request_id: id,
5207 });
5208 id
5209 }
5210
5211 pub fn set_virtual_buffer_content<'js>(
5215 &self,
5216 ctx: rquickjs::Ctx<'js>,
5217 buffer_id: u32,
5218 entries_arr: Vec<rquickjs::Object<'js>>,
5219 ) -> rquickjs::Result<bool> {
5220 let entries: Vec<TextPropertyEntry> = entries_arr
5221 .iter()
5222 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5223 .collect();
5224 Ok(self
5225 .command_sender
5226 .send(PluginCommand::SetVirtualBufferContent {
5227 buffer_id: BufferId(buffer_id as usize),
5228 entries,
5229 })
5230 .is_ok())
5231 }
5232
5233 pub fn get_text_properties_at_cursor(
5235 &self,
5236 buffer_id: u32,
5237 ) -> fresh_core::api::TextPropertiesAtCursor {
5238 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5239 }
5240
5241 #[qjs(rename = "mountWidgetPanel")]
5251 pub fn mount_widget_panel<'js>(
5252 &self,
5253 ctx: rquickjs::Ctx<'js>,
5254 panel_id: f64,
5255 buffer_id: u32,
5256 spec_obj: rquickjs::Value<'js>,
5257 ) -> rquickjs::Result<bool> {
5258 let json = js_to_json(&ctx, spec_obj);
5259 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5260 Ok(s) => s,
5261 Err(e) => {
5262 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5263 return Ok(false);
5264 }
5265 };
5266 Ok(self
5267 .command_sender
5268 .send(PluginCommand::MountWidgetPanel {
5269 panel_id: panel_id as u64,
5270 buffer_id: BufferId(buffer_id as usize),
5271 spec,
5272 })
5273 .is_ok())
5274 }
5275
5276 #[qjs(rename = "updateWidgetPanel")]
5279 pub fn update_widget_panel<'js>(
5280 &self,
5281 ctx: rquickjs::Ctx<'js>,
5282 panel_id: f64,
5283 spec_obj: rquickjs::Value<'js>,
5284 ) -> rquickjs::Result<bool> {
5285 let json = js_to_json(&ctx, spec_obj);
5286 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5287 Ok(s) => s,
5288 Err(e) => {
5289 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5290 return Ok(false);
5291 }
5292 };
5293 Ok(self
5294 .command_sender
5295 .send(PluginCommand::UpdateWidgetPanel {
5296 panel_id: panel_id as u64,
5297 spec,
5298 })
5299 .is_ok())
5300 }
5301
5302 #[qjs(rename = "unmountWidgetPanel")]
5305 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5306 self.command_sender
5307 .send(PluginCommand::UnmountWidgetPanel {
5308 panel_id: panel_id as u64,
5309 })
5310 .is_ok()
5311 }
5312
5313 #[qjs(rename = "widgetCommand")]
5322 pub fn widget_command<'js>(
5323 &self,
5324 ctx: rquickjs::Ctx<'js>,
5325 panel_id: f64,
5326 action_obj: rquickjs::Value<'js>,
5327 ) -> rquickjs::Result<bool> {
5328 let json = js_to_json(&ctx, action_obj);
5329 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5330 Ok(a) => a,
5331 Err(e) => {
5332 tracing::error!("widgetCommand: invalid action: {}", e);
5333 return Ok(false);
5334 }
5335 };
5336 Ok(self
5337 .command_sender
5338 .send(PluginCommand::WidgetCommand {
5339 panel_id: panel_id as u64,
5340 action,
5341 })
5342 .is_ok())
5343 }
5344
5345 #[qjs(rename = "widgetMutate")]
5351 pub fn widget_mutate<'js>(
5352 &self,
5353 ctx: rquickjs::Ctx<'js>,
5354 panel_id: f64,
5355 mutation_obj: rquickjs::Value<'js>,
5356 ) -> rquickjs::Result<bool> {
5357 let json = js_to_json(&ctx, mutation_obj);
5358 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5359 Ok(m) => m,
5360 Err(e) => {
5361 tracing::error!("widgetMutate: invalid mutation: {}", e);
5362 return Ok(false);
5363 }
5364 };
5365 Ok(self
5366 .command_sender
5367 .send(PluginCommand::WidgetMutate {
5368 panel_id: panel_id as u64,
5369 mutation,
5370 })
5371 .is_ok())
5372 }
5373
5374 #[qjs(rename = "mountFloatingWidget")]
5377 pub fn mount_floating_widget<'js>(
5378 &self,
5379 ctx: rquickjs::Ctx<'js>,
5380 panel_id: f64,
5381 spec_obj: rquickjs::Value<'js>,
5382 width_pct: f64,
5383 height_pct: f64,
5384 ) -> rquickjs::Result<bool> {
5385 let json = js_to_json(&ctx, spec_obj);
5386 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5387 Ok(s) => s,
5388 Err(e) => {
5389 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5390 return Ok(false);
5391 }
5392 };
5393 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5394 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5395 Ok(self
5396 .command_sender
5397 .send(PluginCommand::MountFloatingWidget {
5398 panel_id: panel_id as u64,
5399 spec,
5400 width_pct,
5401 height_pct,
5402 })
5403 .is_ok())
5404 }
5405
5406 #[qjs(rename = "updateFloatingWidget")]
5408 pub fn update_floating_widget<'js>(
5409 &self,
5410 ctx: rquickjs::Ctx<'js>,
5411 panel_id: f64,
5412 spec_obj: rquickjs::Value<'js>,
5413 ) -> rquickjs::Result<bool> {
5414 let json = js_to_json(&ctx, spec_obj);
5415 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5416 Ok(s) => s,
5417 Err(e) => {
5418 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5419 return Ok(false);
5420 }
5421 };
5422 Ok(self
5423 .command_sender
5424 .send(PluginCommand::UpdateFloatingWidget {
5425 panel_id: panel_id as u64,
5426 spec,
5427 })
5428 .is_ok())
5429 }
5430
5431 #[qjs(rename = "unmountFloatingWidget")]
5433 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5434 self.command_sender
5435 .send(PluginCommand::UnmountFloatingWidget {
5436 panel_id: panel_id as u64,
5437 })
5438 .is_ok()
5439 }
5440
5441 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5450 #[qjs(rename = "_spawnProcessStart")]
5451 pub fn spawn_process_start(
5452 &self,
5453 _ctx: rquickjs::Ctx<'_>,
5454 command: String,
5455 args: Vec<String>,
5456 cwd: rquickjs::function::Opt<String>,
5457 stdout_to: rquickjs::function::Opt<String>,
5458 ) -> u64 {
5459 let id = self.alloc_request_id();
5460 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5466 self.state_snapshot
5467 .read()
5468 .ok()
5469 .map(|s| s.working_dir.to_string_lossy().to_string())
5470 });
5471 let stdout_to_path = stdout_to
5472 .0
5473 .filter(|s| !s.is_empty())
5474 .map(std::path::PathBuf::from);
5475 tracing::info!(
5476 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5477 self.plugin_name,
5478 command,
5479 args,
5480 effective_cwd,
5481 stdout_to_path,
5482 id
5483 );
5484 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5485 callback_id: JsCallbackId::new(id),
5486 command,
5487 args,
5488 cwd: effective_cwd,
5489 stdout_to: stdout_to_path,
5490 });
5491 id
5492 }
5493
5494 #[plugin_api(
5501 async_thenable,
5502 js_name = "spawnHostProcess",
5503 ts_return = "SpawnResult"
5504 )]
5505 #[qjs(rename = "_spawnHostProcessStart")]
5506 pub fn spawn_host_process_start(
5507 &self,
5508 _ctx: rquickjs::Ctx<'_>,
5509 command: String,
5510 args: Vec<String>,
5511 cwd: rquickjs::function::Opt<String>,
5512 ) -> u64 {
5513 let id = self.alloc_request_id();
5514 let effective_cwd = cwd.0.or_else(|| {
5515 self.state_snapshot
5516 .read()
5517 .ok()
5518 .map(|s| s.working_dir.to_string_lossy().to_string())
5519 });
5520 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5521 callback_id: JsCallbackId::new(id),
5522 command,
5523 args,
5524 cwd: effective_cwd,
5525 });
5526 id
5527 }
5528
5529 #[plugin_api(js_name = "_killHostProcess")]
5539 pub fn kill_host_process(&self, process_id: u64) -> bool {
5540 self.command_sender
5541 .send(PluginCommand::KillHostProcess { process_id })
5542 .is_ok()
5543 }
5544
5545 #[plugin_api(js_name = "setAuthority")]
5554 pub fn set_authority(
5555 &self,
5556 ctx: rquickjs::Ctx<'_>,
5557 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5558 ) -> bool {
5559 let json = js_to_json(&ctx, payload);
5560 let _ = self
5561 .command_sender
5562 .send(PluginCommand::SetAuthority { payload: json });
5563 true
5564 }
5565
5566 #[plugin_api(js_name = "clearAuthority")]
5569 pub fn clear_authority(&self) {
5570 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5571 }
5572
5573 #[plugin_api(js_name = "setRemoteIndicatorState")]
5591 pub fn set_remote_indicator_state(
5592 &self,
5593 ctx: rquickjs::Ctx<'_>,
5594 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
5595 ) -> bool {
5596 let json = js_to_json(&ctx, state);
5597 let _ = self
5598 .command_sender
5599 .send(PluginCommand::SetRemoteIndicatorState { state: json });
5600 true
5601 }
5602
5603 #[plugin_api(js_name = "clearRemoteIndicatorState")]
5606 pub fn clear_remote_indicator_state(&self) {
5607 let _ = self
5608 .command_sender
5609 .send(PluginCommand::ClearRemoteIndicatorState);
5610 }
5611
5612 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
5614 #[qjs(rename = "_spawnProcessWaitStart")]
5615 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
5616 let id = self.alloc_request_id();
5617 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
5618 process_id,
5619 callback_id: JsCallbackId::new(id),
5620 });
5621 id
5622 }
5623
5624 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
5626 #[qjs(rename = "_getBufferTextStart")]
5627 pub fn get_buffer_text_start(
5628 &self,
5629 _ctx: rquickjs::Ctx<'_>,
5630 buffer_id: u32,
5631 start: u32,
5632 end: u32,
5633 ) -> u64 {
5634 let id = self.alloc_request_id();
5635 let _ = self.command_sender.send(PluginCommand::GetBufferText {
5636 buffer_id: BufferId(buffer_id as usize),
5637 start: start as usize,
5638 end: end as usize,
5639 request_id: id,
5640 });
5641 id
5642 }
5643
5644 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
5646 #[qjs(rename = "_delayStart")]
5647 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
5648 let id = self.alloc_request_id();
5649 let _ = self.command_sender.send(PluginCommand::Delay {
5650 callback_id: JsCallbackId::new(id),
5651 duration_ms,
5652 });
5653 id
5654 }
5655
5656 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
5660 #[qjs(rename = "_grepProjectStart")]
5661 pub fn grep_project_start(
5662 &self,
5663 _ctx: rquickjs::Ctx<'_>,
5664 pattern: String,
5665 fixed_string: Option<bool>,
5666 case_sensitive: Option<bool>,
5667 max_results: Option<u32>,
5668 whole_words: Option<bool>,
5669 ) -> u64 {
5670 let id = self.alloc_request_id();
5671 let _ = self.command_sender.send(PluginCommand::GrepProject {
5672 pattern,
5673 fixed_string: fixed_string.unwrap_or(true),
5674 case_sensitive: case_sensitive.unwrap_or(true),
5675 max_results: max_results.unwrap_or(200) as usize,
5676 whole_words: whole_words.unwrap_or(false),
5677 callback_id: JsCallbackId::new(id),
5678 });
5679 id
5680 }
5681
5682 #[plugin_api(
5687 js_name = "beginSearch",
5688 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }): SearchHandle"
5689 )]
5690 #[qjs(rename = "_beginSearch")]
5691 pub fn begin_search(
5692 &self,
5693 _ctx: rquickjs::Ctx<'_>,
5694 pattern: String,
5695 fixed_string: bool,
5696 case_sensitive: bool,
5697 max_results: u32,
5698 whole_words: bool,
5699 ) -> u64 {
5700 let id = self.alloc_request_id();
5701 let entry = Arc::new(SearchHandleState::new());
5704 if let Ok(mut map) = self.search_handles.lock() {
5705 map.insert(id, entry);
5706 }
5707 let _ = self.command_sender.send(PluginCommand::BeginSearch {
5708 pattern,
5709 fixed_string,
5710 case_sensitive,
5711 max_results: max_results as usize,
5712 whole_words,
5713 handle_id: id,
5714 });
5715 id
5716 }
5717
5718 #[plugin_api(ts_return = "SearchTakeResult")]
5723 #[qjs(rename = "_searchHandleTake")]
5724 pub fn search_handle_take<'js>(
5725 &self,
5726 ctx: rquickjs::Ctx<'js>,
5727 handle_id: u64,
5728 ) -> rquickjs::Result<Value<'js>> {
5729 let entry = self
5730 .search_handles
5731 .lock()
5732 .ok()
5733 .and_then(|m| m.get(&handle_id).cloned());
5734 let result = match entry {
5735 Some(handle) => {
5736 let mut state = match handle.state.lock() {
5738 Ok(s) => s,
5739 Err(poisoned) => poisoned.into_inner(),
5740 };
5741 let matches = std::mem::take(&mut state.pending);
5742 let snapshot = SearchTakeResult {
5743 matches,
5744 done: state.done,
5745 total_seen: state.total_seen,
5746 truncated: state.truncated,
5747 error: state.error.clone(),
5748 };
5749 let done = snapshot.done;
5750 drop(state);
5751 if done {
5752 if let Ok(mut map) = self.search_handles.lock() {
5753 map.remove(&handle_id);
5754 }
5755 }
5756 snapshot
5757 }
5758 None => SearchTakeResult {
5759 matches: Vec::new(),
5760 done: true,
5761 total_seen: 0,
5762 truncated: false,
5763 error: None,
5764 },
5765 };
5766 rquickjs_serde::to_value(ctx, &result)
5767 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5768 }
5769
5770 #[qjs(rename = "_searchHandleCancel")]
5773 pub fn search_handle_cancel(&self, handle_id: u64) {
5774 if let Ok(map) = self.search_handles.lock() {
5775 if let Some(entry) = map.get(&handle_id) {
5776 entry
5777 .cancel
5778 .store(true, std::sync::atomic::Ordering::Relaxed);
5779 }
5780 }
5781 }
5782
5783 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
5787 #[qjs(rename = "_replaceInFileStart")]
5788 pub fn replace_in_file_start(
5789 &self,
5790 _ctx: rquickjs::Ctx<'_>,
5791 file_path: String,
5792 matches: Vec<Vec<u32>>,
5793 replacement: String,
5794 ) -> u64 {
5795 let id = self.alloc_request_id();
5796 let match_pairs: Vec<(usize, usize)> = matches
5798 .iter()
5799 .map(|m| (m[0] as usize, m[1] as usize))
5800 .collect();
5801 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
5802 file_path: PathBuf::from(file_path),
5803 matches: match_pairs,
5804 replacement,
5805 callback_id: JsCallbackId::new(id),
5806 });
5807 id
5808 }
5809
5810 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
5812 #[qjs(rename = "_sendLspRequestStart")]
5813 pub fn send_lsp_request_start<'js>(
5814 &self,
5815 ctx: rquickjs::Ctx<'js>,
5816 language: String,
5817 method: String,
5818 params: Option<rquickjs::Object<'js>>,
5819 ) -> rquickjs::Result<u64> {
5820 let id = self.alloc_request_id();
5821 let params_json: Option<serde_json::Value> = params.map(|obj| {
5823 let val = obj.into_value();
5824 js_to_json(&ctx, val)
5825 });
5826 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
5827 request_id: id,
5828 language,
5829 method,
5830 params: params_json,
5831 });
5832 Ok(id)
5833 }
5834
5835 #[plugin_api(
5837 async_thenable,
5838 js_name = "spawnBackgroundProcess",
5839 ts_return = "BackgroundProcessResult"
5840 )]
5841 #[qjs(rename = "_spawnBackgroundProcessStart")]
5842 pub fn spawn_background_process_start(
5843 &self,
5844 _ctx: rquickjs::Ctx<'_>,
5845 command: String,
5846 args: Vec<String>,
5847 cwd: rquickjs::function::Opt<String>,
5848 ) -> u64 {
5849 let id = self.alloc_request_id();
5850 let process_id = id;
5852 self.plugin_tracked_state
5854 .borrow_mut()
5855 .entry(self.plugin_name.clone())
5856 .or_default()
5857 .background_process_ids
5858 .push(process_id);
5859 let _ = self
5861 .command_sender
5862 .send(PluginCommand::SpawnBackgroundProcess {
5863 process_id,
5864 command,
5865 args,
5866 cwd: cwd.0.filter(|s| !s.is_empty()),
5867 callback_id: JsCallbackId::new(id),
5868 });
5869 id
5870 }
5871
5872 pub fn kill_background_process(&self, process_id: u64) -> bool {
5874 self.command_sender
5875 .send(PluginCommand::KillBackgroundProcess { process_id })
5876 .is_ok()
5877 }
5878
5879 #[plugin_api(
5883 async_promise,
5884 js_name = "createTerminal",
5885 ts_return = "TerminalResult"
5886 )]
5887 #[qjs(rename = "_createTerminalStart")]
5888 pub fn create_terminal_start(
5889 &self,
5890 _ctx: rquickjs::Ctx<'_>,
5891 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
5892 ) -> rquickjs::Result<u64> {
5893 let id = self.alloc_request_id();
5894
5895 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
5896 cwd: None,
5897 direction: None,
5898 ratio: None,
5899 focus: None,
5900 persistent: None,
5901 window_id: None,
5902 command: None,
5903 title: None,
5904 });
5905
5906 if let Ok(mut owners) = self.async_resource_owners.lock() {
5908 owners.insert(id, self.plugin_name.clone());
5909 }
5910 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
5911 cwd: opts.cwd,
5912 direction: opts.direction,
5913 ratio: opts.ratio,
5914 focus: opts.focus,
5915 window_id: opts.window_id,
5916 persistent: opts.persistent.unwrap_or(false),
5920 command: opts.command,
5921 title: opts.title,
5922 request_id: id,
5923 });
5924 Ok(id)
5925 }
5926
5927 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
5929 self.command_sender
5930 .send(PluginCommand::SendTerminalInput {
5931 terminal_id: fresh_core::TerminalId(terminal_id as usize),
5932 data,
5933 })
5934 .is_ok()
5935 }
5936
5937 pub fn close_terminal(&self, terminal_id: u64) -> bool {
5939 self.command_sender
5940 .send(PluginCommand::CloseTerminal {
5941 terminal_id: fresh_core::TerminalId(terminal_id as usize),
5942 })
5943 .is_ok()
5944 }
5945
5946 pub fn signal_window(&self, id: f64, signal: String) -> bool {
5953 self.command_sender
5954 .send(PluginCommand::SignalWindow {
5955 id: fresh_core::WindowId(id as u64),
5956 signal,
5957 })
5958 .is_ok()
5959 }
5960
5961 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
5965 self.command_sender
5966 .send(PluginCommand::RefreshLines {
5967 buffer_id: BufferId(buffer_id as usize),
5968 })
5969 .is_ok()
5970 }
5971
5972 pub fn get_current_locale(&self) -> String {
5974 self.services.current_locale()
5975 }
5976
5977 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
5981 #[qjs(rename = "_loadPluginStart")]
5982 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
5983 let id = self.alloc_request_id();
5984 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
5985 path: std::path::PathBuf::from(path),
5986 callback_id: JsCallbackId::new(id),
5987 });
5988 id
5989 }
5990
5991 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
5993 #[qjs(rename = "_unloadPluginStart")]
5994 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
5995 let id = self.alloc_request_id();
5996 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
5997 name,
5998 callback_id: JsCallbackId::new(id),
5999 });
6000 id
6001 }
6002
6003 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6005 #[qjs(rename = "_reloadPluginStart")]
6006 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6007 let id = self.alloc_request_id();
6008 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6009 name,
6010 callback_id: JsCallbackId::new(id),
6011 });
6012 id
6013 }
6014
6015 #[plugin_api(
6018 async_promise,
6019 js_name = "listPlugins",
6020 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6021 )]
6022 #[qjs(rename = "_listPluginsStart")]
6023 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6024 let id = self.alloc_request_id();
6025 let _ = self.command_sender.send(PluginCommand::ListPlugins {
6026 callback_id: JsCallbackId::new(id),
6027 });
6028 id
6029 }
6030}
6031
6032fn parse_view_token(
6039 obj: &rquickjs::Object<'_>,
6040 idx: usize,
6041) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6042 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6043
6044 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6046 from: "object",
6047 to: "ViewTokenWire",
6048 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6049 })?;
6050
6051 let source_offset: Option<usize> = obj
6053 .get("sourceOffset")
6054 .ok()
6055 .or_else(|| obj.get("source_offset").ok());
6056
6057 let kind = if kind_value.is_string() {
6059 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6062 from: "value",
6063 to: "string",
6064 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6065 })?;
6066
6067 match kind_str.to_lowercase().as_str() {
6068 "text" => {
6069 let text: String = obj.get("text").unwrap_or_default();
6070 ViewTokenWireKind::Text(text)
6071 }
6072 "newline" => ViewTokenWireKind::Newline,
6073 "space" => ViewTokenWireKind::Space,
6074 "break" => ViewTokenWireKind::Break,
6075 _ => {
6076 tracing::warn!(
6078 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6079 idx, kind_str
6080 );
6081 return Err(rquickjs::Error::FromJs {
6082 from: "string",
6083 to: "ViewTokenWireKind",
6084 message: Some(format!(
6085 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6086 idx, kind_str
6087 )),
6088 });
6089 }
6090 }
6091 } else if kind_value.is_object() {
6092 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6094 from: "value",
6095 to: "object",
6096 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6097 })?;
6098
6099 if let Ok(text) = kind_obj.get::<_, String>("Text") {
6100 ViewTokenWireKind::Text(text)
6101 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6102 ViewTokenWireKind::BinaryByte(byte)
6103 } else {
6104 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6106 tracing::warn!(
6107 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6108 idx,
6109 keys
6110 );
6111 return Err(rquickjs::Error::FromJs {
6112 from: "object",
6113 to: "ViewTokenWireKind",
6114 message: Some(format!(
6115 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6116 idx, keys
6117 )),
6118 });
6119 }
6120 } else {
6121 tracing::warn!(
6122 "token[{}]: 'kind' field must be a string or object, got: {:?}",
6123 idx,
6124 kind_value.type_of()
6125 );
6126 return Err(rquickjs::Error::FromJs {
6127 from: "value",
6128 to: "ViewTokenWireKind",
6129 message: Some(format!(
6130 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6131 idx
6132 )),
6133 });
6134 };
6135
6136 let style = parse_view_token_style(obj, idx)?;
6138
6139 Ok(ViewTokenWire {
6140 source_offset,
6141 kind,
6142 style,
6143 })
6144}
6145
6146fn parse_view_token_style(
6148 obj: &rquickjs::Object<'_>,
6149 idx: usize,
6150) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6151 use fresh_core::api::{TokenColor, ViewTokenStyle};
6152
6153 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6154 let Some(s) = style_obj else {
6155 return Ok(None);
6156 };
6157
6158 fn parse_color(
6163 s: &rquickjs::Object<'_>,
6164 field: &str,
6165 idx: usize,
6166 ) -> rquickjs::Result<Option<TokenColor>> {
6167 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6168 if arr.len() < 3 {
6169 tracing::warn!(
6170 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6171 idx,
6172 field,
6173 arr.len()
6174 );
6175 return Ok(None);
6176 }
6177 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6178 }
6179 if let Ok(name) = s.get::<_, String>(field) {
6180 return Ok(Some(TokenColor::Named(name)));
6181 }
6182 Ok(None)
6183 }
6184
6185 Ok(Some(ViewTokenStyle {
6186 fg: parse_color(&s, "fg", idx)?,
6187 bg: parse_color(&s, "bg", idx)?,
6188 bold: s.get("bold").unwrap_or(false),
6189 italic: s.get("italic").unwrap_or(false),
6190 underline: s.get("underline").unwrap_or(false),
6191 }))
6192}
6193
6194pub struct QuickJsBackend {
6196 runtime: Runtime,
6197 main_context: Context,
6199 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6201 event_handlers: EventHandlerRegistry,
6205 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6207 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6209 command_sender: mpsc::Sender<PluginCommand>,
6211 #[allow(dead_code)]
6213 pending_responses: PendingResponses,
6214 next_request_id: Rc<RefCell<u64>>,
6216 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6218 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6220 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6222 async_resource_owners: AsyncResourceOwners,
6225 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6227 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6229 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6231 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6233 plugin_api_exports: PluginApiExports,
6237 search_handles: SearchHandleRegistry,
6239}
6240
6241impl Drop for QuickJsBackend {
6242 fn drop(&mut self) {
6243 self.plugin_api_exports.borrow_mut().clear();
6249 }
6250}
6251
6252impl QuickJsBackend {
6253 pub fn new() -> Result<Self> {
6255 let (tx, _rx) = mpsc::channel();
6256 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6257 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6258 Self::with_state(state_snapshot, tx, services)
6259 }
6260
6261 pub fn with_state(
6263 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6264 command_sender: mpsc::Sender<PluginCommand>,
6265 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6266 ) -> Result<Self> {
6267 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
6268 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
6269 }
6270
6271 pub fn with_state_and_responses(
6273 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6274 command_sender: mpsc::Sender<PluginCommand>,
6275 pending_responses: PendingResponses,
6276 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6277 ) -> Result<Self> {
6278 let async_resource_owners: AsyncResourceOwners =
6279 Arc::new(std::sync::Mutex::new(HashMap::new()));
6280 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
6281 let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
6282 Self::with_state_responses_and_resources(
6283 state_snapshot,
6284 command_sender,
6285 pending_responses,
6286 services,
6287 async_resource_owners,
6288 search_handles,
6289 event_handlers,
6290 )
6291 }
6292
6293 pub fn with_state_responses_and_resources(
6296 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6297 command_sender: mpsc::Sender<PluginCommand>,
6298 pending_responses: PendingResponses,
6299 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6300 async_resource_owners: AsyncResourceOwners,
6301 search_handles: SearchHandleRegistry,
6302 event_handlers: EventHandlerRegistry,
6303 ) -> Result<Self> {
6304 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
6305
6306 let runtime =
6307 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
6308
6309 runtime.set_host_promise_rejection_tracker(Some(Box::new(
6311 |_ctx, _promise, reason, is_handled| {
6312 if !is_handled {
6313 let error_msg = if let Some(exc) = reason.as_exception() {
6315 format!(
6316 "{}: {}",
6317 exc.message().unwrap_or_default(),
6318 exc.stack().unwrap_or_default()
6319 )
6320 } else {
6321 format!("{:?}", reason)
6322 };
6323
6324 tracing::error!("Unhandled Promise rejection: {}", error_msg);
6325
6326 if should_panic_on_js_errors() {
6327 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
6330 set_fatal_js_error(full_msg);
6331 }
6332 }
6333 },
6334 )));
6335
6336 let main_context = Context::full(&runtime)
6337 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
6338
6339 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
6340 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
6341 let next_request_id = Rc::new(RefCell::new(1u64));
6342 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
6343 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
6344 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
6345 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
6346 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
6347 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
6348 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
6349
6350 let backend = Self {
6351 runtime,
6352 main_context,
6353 plugin_contexts,
6354 event_handlers,
6355 registered_actions,
6356 state_snapshot,
6357 command_sender,
6358 pending_responses,
6359 next_request_id,
6360 callback_contexts,
6361 services,
6362 plugin_tracked_state,
6363 async_resource_owners,
6364 registered_command_names,
6365 registered_grammar_languages,
6366 registered_language_configs,
6367 registered_lsp_servers,
6368 plugin_api_exports,
6369 search_handles,
6370 };
6371
6372 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
6374
6375 tracing::debug!("QuickJsBackend::new: runtime created successfully");
6376 Ok(backend)
6377 }
6378
6379 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
6381 let state_snapshot = Arc::clone(&self.state_snapshot);
6382 let command_sender = self.command_sender.clone();
6383 let event_handlers = Arc::clone(&self.event_handlers);
6384 let registered_actions = Rc::clone(&self.registered_actions);
6385 let next_request_id = Rc::clone(&self.next_request_id);
6386 let registered_command_names = Rc::clone(&self.registered_command_names);
6387 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
6388 let registered_language_configs = Rc::clone(&self.registered_language_configs);
6389 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
6390 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
6391
6392 context.with(|ctx| {
6393 let globals = ctx.globals();
6394
6395 globals.set("__pluginName__", plugin_name)?;
6397
6398 let js_api = JsEditorApi {
6401 state_snapshot: Arc::clone(&state_snapshot),
6402 command_sender: command_sender.clone(),
6403 registered_actions: Rc::clone(®istered_actions),
6404 event_handlers: Arc::clone(&event_handlers),
6405 next_request_id: Rc::clone(&next_request_id),
6406 callback_contexts: Rc::clone(&self.callback_contexts),
6407 services: self.services.clone(),
6408 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
6409 async_resource_owners: Arc::clone(&self.async_resource_owners),
6410 registered_command_names: Rc::clone(®istered_command_names),
6411 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
6412 registered_language_configs: Rc::clone(®istered_language_configs),
6413 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
6414 plugin_api_exports: Rc::clone(&plugin_api_exports),
6415 search_handles: Arc::clone(&self.search_handles),
6416 plugin_name: plugin_name.to_string(),
6417 };
6418 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
6419
6420 globals.set("editor", editor)?;
6422
6423 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
6425
6426 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
6428
6429ctx.eval::<(), _>(
6436 r#"
6437 (function() {
6438 const originalOn = editor.on.bind(editor);
6439 const originalOff = editor.off.bind(editor);
6440 let counter = 0;
6441 const anonNames = new WeakMap();
6442 editor.on = function(eventName, handlerOrName) {
6443 if (typeof handlerOrName === 'function') {
6444 const existing = anonNames.get(handlerOrName);
6445 const name = existing || `__anon_on_${++counter}`;
6446 if (!existing) {
6447 anonNames.set(handlerOrName, name);
6448 }
6449 globalThis[name] = handlerOrName;
6450 return originalOn(eventName, name);
6451 }
6452 return originalOn(eventName, handlerOrName);
6453 };
6454 editor.off = function(eventName, handlerOrName) {
6455 if (typeof handlerOrName === 'function') {
6456 const name = anonNames.get(handlerOrName);
6457 if (name === undefined) return false;
6458 return originalOff(eventName, name);
6459 }
6460 return originalOff(eventName, handlerOrName);
6461 };
6462 })();
6463 "#,
6464 )?;
6465
6466 let console = Object::new(ctx.clone())?;
6469 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6470 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6471 tracing::info!("console.log: {}", parts.join(" "));
6472 })?)?;
6473 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6474 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6475 tracing::warn!("console.warn: {}", parts.join(" "));
6476 })?)?;
6477 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6478 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6479 tracing::error!("console.error: {}", parts.join(" "));
6480 })?)?;
6481 globals.set("console", console)?;
6482
6483 ctx.eval::<(), _>(r#"
6485 // Pending promise callbacks: callbackId -> { resolve, reject }
6486 globalThis._pendingCallbacks = new Map();
6487
6488 // Resolve a pending callback (called from Rust)
6489 globalThis._resolveCallback = function(callbackId, result) {
6490 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
6491 const cb = globalThis._pendingCallbacks.get(callbackId);
6492 if (cb) {
6493 console.log('[JS] _resolveCallback: found callback, calling resolve()');
6494 globalThis._pendingCallbacks.delete(callbackId);
6495 cb.resolve(result);
6496 console.log('[JS] _resolveCallback: resolve() called');
6497 } else {
6498 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
6499 }
6500 };
6501
6502 // Reject a pending callback (called from Rust)
6503 globalThis._rejectCallback = function(callbackId, error) {
6504 const cb = globalThis._pendingCallbacks.get(callbackId);
6505 if (cb) {
6506 globalThis._pendingCallbacks.delete(callbackId);
6507 cb.reject(new Error(error));
6508 }
6509 };
6510
6511 // Generic async wrapper decorator
6512 // Wraps a function that returns a callbackId into a promise-returning function
6513 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6514 // NOTE: We pass the method name as a string and call via bracket notation
6515 // to preserve rquickjs's automatic Ctx injection for methods
6516 globalThis._wrapAsync = function(methodName, fnName) {
6517 const startFn = editor[methodName];
6518 if (typeof startFn !== 'function') {
6519 // Return a function that always throws - catches missing implementations
6520 return function(...args) {
6521 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6522 editor.debug(`[ASYNC ERROR] ${error.message}`);
6523 throw error;
6524 };
6525 }
6526 return function(...args) {
6527 // Call via bracket notation to preserve method binding and Ctx injection
6528 const callbackId = editor[methodName](...args);
6529 return new Promise((resolve, reject) => {
6530 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6531 // TODO: Implement setTimeout polyfill using editor.delay() or similar
6532 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6533 });
6534 };
6535 };
6536
6537 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6538 // The returned object has .result promise and is itself thenable
6539 globalThis._wrapAsyncThenable = function(methodName, fnName) {
6540 const startFn = editor[methodName];
6541 if (typeof startFn !== 'function') {
6542 // Return a function that always throws - catches missing implementations
6543 return function(...args) {
6544 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6545 editor.debug(`[ASYNC ERROR] ${error.message}`);
6546 throw error;
6547 };
6548 }
6549 return function(...args) {
6550 // Call via bracket notation to preserve method binding and Ctx injection
6551 const callbackId = editor[methodName](...args);
6552 const resultPromise = new Promise((resolve, reject) => {
6553 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6554 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6555 });
6556 return {
6557 get result() { return resultPromise; },
6558 then(onFulfilled, onRejected) {
6559 return resultPromise.then(onFulfilled, onRejected);
6560 },
6561 catch(onRejected) {
6562 return resultPromise.catch(onRejected);
6563 }
6564 };
6565 };
6566 };
6567
6568 // Apply wrappers to async functions on editor
6569 // spawnProcess accepts either form for the 4th arg:
6570 // editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
6571 // editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
6572 // The first matches the auto-generated TS signature
6573 // (flat positional from the Rust binding's `Opt<String>`
6574 // args); the second is the structured options form
6575 // plugin authors often prefer.
6576 editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
6577 if (typeof editor._spawnProcessStart !== 'function') {
6578 throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
6579 }
6580 // The 3rd arg is either cwd (string) or an options
6581 // object when cwd is omitted; the 4th is either a
6582 // stdoutTo string or an options object.
6583 let cwd = "";
6584 let stdoutTo = "";
6585 if (typeof cwdOrOpts === "string") {
6586 cwd = cwdOrOpts;
6587 } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
6588 if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
6589 }
6590 if (typeof fourth === "string") {
6591 stdoutTo = fourth;
6592 } else if (fourth && typeof fourth === "object") {
6593 if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
6594 }
6595 const callbackId = editor._spawnProcessStart(
6596 command,
6597 argsArr || [],
6598 cwd,
6599 stdoutTo,
6600 );
6601 const resultPromise = new Promise((resolve, reject) => {
6602 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6603 });
6604 return {
6605 get result() { return resultPromise; },
6606 // `kill()` cancels a still-running spawn. The
6607 // dispatcher stores a oneshot keyed by callbackId;
6608 // _killHostProcess fires it and the spawner's
6609 // tokio::select! kills the child. No-op if the
6610 // child already exited (id removed from the map).
6611 kill() {
6612 if (typeof editor._killHostProcess === 'function') {
6613 return editor._killHostProcess(callbackId);
6614 }
6615 return false;
6616 },
6617 then(onFulfilled, onRejected) {
6618 return resultPromise.then(onFulfilled, onRejected);
6619 },
6620 catch(onRejected) {
6621 return resultPromise.catch(onRejected);
6622 }
6623 };
6624 };
6625 // spawnHostProcess gets a bespoke wrapper (instead of
6626 // `_wrapAsyncThenable`) because its `ProcessHandle`
6627 // exposes a real `kill()` that forwards to
6628 // `_killHostProcess`. Generic wrap has no hook for
6629 // that.
6630 editor.spawnHostProcess = function(command, args, cwd) {
6631 if (typeof editor._spawnHostProcessStart !== 'function') {
6632 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
6633 }
6634 // Pass real strings only. Earlier revisions forwarded
6635 // `""` for a missing cwd, which landed verbatim as
6636 // `Command::current_dir("")` in the dispatcher —
6637 // every host-spawn then failed with ENOENT. Use two
6638 // arity forms so the Rust `Opt<String>` stays `None`
6639 // instead of `Some("")`.
6640 let callbackId;
6641 if (typeof cwd === "string" && cwd.length > 0) {
6642 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
6643 } else {
6644 callbackId = editor._spawnHostProcessStart(command, args || []);
6645 }
6646 const resultPromise = new Promise(function(resolve, reject) {
6647 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
6648 });
6649 return {
6650 processId: callbackId,
6651 get result() { return resultPromise; },
6652 then: function(f, r) { return resultPromise.then(f, r); },
6653 catch: function(r) { return resultPromise.catch(r); },
6654 kill: function() {
6655 // Returns true when the kill was enqueued
6656 // (the process may have already exited; in
6657 // that case the dispatcher silently
6658 // drops it). Matches the
6659 // `ProcessHandle.kill(): Promise<boolean>`
6660 // type signature by wrapping the sync
6661 // boolean in a Promise.
6662 return Promise.resolve(editor._killHostProcess(callbackId));
6663 }
6664 };
6665 };
6666 editor.delay = _wrapAsync("_delayStart", "delay");
6667 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
6668 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
6669 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
6670 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
6671 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
6672 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
6673 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
6674 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
6675 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
6676 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
6677 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
6678 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
6679 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
6680 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
6681 editor.prompt = _wrapAsync("_promptStart", "prompt");
6682 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
6683 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
6684 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
6685 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
6686 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
6687 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
6688 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
6689 editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
6690 editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
6691 editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
6692
6693 // Pull-based streaming search. Producers (host searcher tasks)
6694 // write into shared state at full speed; the consumer drains
6695 // it via take() at its own cadence — no per-chunk JS dispatch.
6696 editor.beginSearch = function(pattern, opts) {
6697 opts = opts || {};
6698 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
6699 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
6700 const maxResults = opts.maxResults || 10000;
6701 const wholeWords = opts.wholeWords || false;
6702 const handleId = editor._beginSearch(
6703 pattern, fixedString, caseSensitive, maxResults, wholeWords
6704 );
6705 return {
6706 searchId: handleId,
6707 take: function() { return editor._searchHandleTake(handleId); },
6708 cancel: function() { editor._searchHandleCancel(handleId); }
6709 };
6710 };
6711
6712 // Wrapper for deleteTheme - wraps sync function in Promise
6713 editor.deleteTheme = function(name) {
6714 return new Promise(function(resolve, reject) {
6715 const success = editor._deleteThemeSync(name);
6716 if (success) {
6717 resolve();
6718 } else {
6719 reject(new Error("Failed to delete theme: " + name));
6720 }
6721 });
6722 };
6723 "#.as_bytes())?;
6724
6725 Ok::<_, rquickjs::Error>(())
6726 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
6727
6728 Ok(())
6729 }
6730
6731 pub async fn load_module_with_source(
6733 &mut self,
6734 path: &str,
6735 _plugin_source: &str,
6736 ) -> Result<()> {
6737 let path_buf = PathBuf::from(path);
6738 let source = std::fs::read_to_string(&path_buf)
6739 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
6740
6741 let filename = path_buf
6742 .file_name()
6743 .and_then(|s| s.to_str())
6744 .unwrap_or("plugin.ts");
6745
6746 if has_es_imports(&source) {
6748 match bundle_module(&path_buf) {
6750 Ok(bundled) => {
6751 self.execute_js(&bundled, path)?;
6752 }
6753 Err(e) => {
6754 tracing::warn!(
6755 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
6756 path,
6757 e
6758 );
6759 return Ok(()); }
6761 }
6762 } else if has_es_module_syntax(&source) {
6763 let stripped = strip_imports_and_exports(&source);
6765 let js_code = if filename.ends_with(".ts") {
6766 transpile_typescript(&stripped, filename)?
6767 } else {
6768 stripped
6769 };
6770 self.execute_js(&js_code, path)?;
6771 } else {
6772 let js_code = if filename.ends_with(".ts") {
6774 transpile_typescript(&source, filename)?
6775 } else {
6776 source
6777 };
6778 self.execute_js(&js_code, path)?;
6779 }
6780
6781 Ok(())
6782 }
6783
6784 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
6786 let plugin_name = Path::new(source_name)
6788 .file_stem()
6789 .and_then(|s| s.to_str())
6790 .unwrap_or("unknown");
6791
6792 tracing::debug!(
6793 "execute_js: starting for plugin '{}' from '{}'",
6794 plugin_name,
6795 source_name
6796 );
6797
6798 let context = {
6800 let mut contexts = self.plugin_contexts.borrow_mut();
6801 if let Some(ctx) = contexts.get(plugin_name) {
6802 ctx.clone()
6803 } else {
6804 let ctx = Context::full(&self.runtime).map_err(|e| {
6805 anyhow!(
6806 "Failed to create QuickJS context for plugin {}: {}",
6807 plugin_name,
6808 e
6809 )
6810 })?;
6811 self.setup_context_api(&ctx, plugin_name)?;
6812 contexts.insert(plugin_name.to_string(), ctx.clone());
6813 ctx
6814 }
6815 };
6816
6817 let wrapped_code = format!("(function() {{ {} }})();", code);
6821 let wrapped = wrapped_code.as_str();
6822
6823 context.with(|ctx| {
6824 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
6825
6826 let mut eval_options = rquickjs::context::EvalOptions::default();
6828 eval_options.global = true;
6829 eval_options.filename = Some(source_name.to_string());
6830 let result = ctx
6831 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
6832 .map_err(|e| format_js_error(&ctx, e, source_name));
6833
6834 tracing::debug!(
6835 "execute_js: plugin code execution finished for '{}', result: {:?}",
6836 plugin_name,
6837 result.is_ok()
6838 );
6839
6840 result
6841 })
6842 }
6843
6844 pub fn execute_source(
6850 &mut self,
6851 source: &str,
6852 plugin_name: &str,
6853 is_typescript: bool,
6854 ) -> Result<()> {
6855 use fresh_parser_js::{
6856 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
6857 };
6858
6859 if has_es_imports(source) {
6860 tracing::warn!(
6861 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
6862 plugin_name
6863 );
6864 }
6865
6866 let js_code = if has_es_module_syntax(source) {
6867 let stripped = strip_imports_and_exports(source);
6868 if is_typescript {
6869 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
6870 } else {
6871 stripped
6872 }
6873 } else if is_typescript {
6874 transpile_typescript(source, &format!("{}.ts", plugin_name))?
6875 } else {
6876 source.to_string()
6877 };
6878
6879 let source_name = format!(
6881 "{}.{}",
6882 plugin_name,
6883 if is_typescript { "ts" } else { "js" }
6884 );
6885 self.execute_js(&js_code, &source_name)
6886 }
6887
6888 pub fn cleanup_plugin(&self, plugin_name: &str) {
6894 self.plugin_contexts.borrow_mut().remove(plugin_name);
6896
6897 {
6899 let mut handlers_map = self
6900 .event_handlers
6901 .write()
6902 .expect("event_handlers poisoned");
6903 for handlers in handlers_map.values_mut() {
6904 handlers.retain(|h| h.plugin_name != plugin_name);
6905 }
6906 handlers_map.retain(|_, list| !list.is_empty());
6910 }
6911
6912 self.registered_actions
6914 .borrow_mut()
6915 .retain(|_, h| h.plugin_name != plugin_name);
6916
6917 self.callback_contexts
6919 .borrow_mut()
6920 .retain(|_, pname| pname != plugin_name);
6921
6922 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
6924 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
6926 std::collections::HashSet::new();
6927 for (buf_id, ns) in &tracked.overlay_namespaces {
6928 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
6929 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
6931 buffer_id: *buf_id,
6932 namespace: OverlayNamespace::from_string(ns.clone()),
6933 });
6934 let _ = self
6936 .command_sender
6937 .send(PluginCommand::ClearConcealNamespace {
6938 buffer_id: *buf_id,
6939 namespace: OverlayNamespace::from_string(ns.clone()),
6940 });
6941 let _ = self
6942 .command_sender
6943 .send(PluginCommand::ClearSoftBreakNamespace {
6944 buffer_id: *buf_id,
6945 namespace: OverlayNamespace::from_string(ns.clone()),
6946 });
6947 }
6948 }
6949
6950 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
6956 std::collections::HashSet::new();
6957 for (buf_id, ns) in &tracked.line_indicator_namespaces {
6958 if seen_li_ns.insert((buf_id.0, ns.clone())) {
6959 let _ = self
6960 .command_sender
6961 .send(PluginCommand::ClearLineIndicators {
6962 buffer_id: *buf_id,
6963 namespace: ns.clone(),
6964 });
6965 }
6966 }
6967
6968 let mut seen_vt: std::collections::HashSet<(usize, String)> =
6970 std::collections::HashSet::new();
6971 for (buf_id, vt_id) in &tracked.virtual_text_ids {
6972 if seen_vt.insert((buf_id.0, vt_id.clone())) {
6973 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
6974 buffer_id: *buf_id,
6975 virtual_text_id: vt_id.clone(),
6976 });
6977 }
6978 }
6979
6980 let mut seen_fe_ns: std::collections::HashSet<String> =
6982 std::collections::HashSet::new();
6983 for ns in &tracked.file_explorer_namespaces {
6984 if seen_fe_ns.insert(ns.clone()) {
6985 let _ = self
6986 .command_sender
6987 .send(PluginCommand::ClearFileExplorerDecorations {
6988 namespace: ns.clone(),
6989 });
6990 }
6991 }
6992
6993 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
6995 for ctx_name in &tracked.contexts_set {
6996 if seen_ctx.insert(ctx_name.clone()) {
6997 let _ = self.command_sender.send(PluginCommand::SetContext {
6998 name: ctx_name.clone(),
6999 active: false,
7000 });
7001 }
7002 }
7003
7004 for process_id in &tracked.background_process_ids {
7008 let _ = self
7009 .command_sender
7010 .send(PluginCommand::KillBackgroundProcess {
7011 process_id: *process_id,
7012 });
7013 }
7014
7015 for group_id in &tracked.scroll_sync_group_ids {
7017 let _ = self
7018 .command_sender
7019 .send(PluginCommand::RemoveScrollSyncGroup {
7020 group_id: *group_id,
7021 });
7022 }
7023
7024 for buffer_id in &tracked.virtual_buffer_ids {
7026 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7027 buffer_id: *buffer_id,
7028 });
7029 }
7030
7031 for buffer_id in &tracked.composite_buffer_ids {
7033 let _ = self
7034 .command_sender
7035 .send(PluginCommand::CloseCompositeBuffer {
7036 buffer_id: *buffer_id,
7037 });
7038 }
7039
7040 for terminal_id in &tracked.terminal_ids {
7042 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7043 terminal_id: *terminal_id,
7044 });
7045 }
7046
7047 for handle in &tracked.watch_handles {
7051 let _ = self
7052 .command_sender
7053 .send(PluginCommand::UnwatchPath { handle: *handle });
7054 }
7055 }
7056
7057 if let Ok(mut owners) = self.async_resource_owners.lock() {
7059 owners.retain(|_, name| name != plugin_name);
7060 }
7061
7062 self.plugin_api_exports
7064 .borrow_mut()
7065 .retain(|_, (exporter, _)| exporter != plugin_name);
7066
7067 self.registered_command_names
7069 .borrow_mut()
7070 .retain(|_, pname| pname != plugin_name);
7071 self.registered_grammar_languages
7072 .borrow_mut()
7073 .retain(|_, pname| pname != plugin_name);
7074 self.registered_language_configs
7075 .borrow_mut()
7076 .retain(|_, pname| pname != plugin_name);
7077 self.registered_lsp_servers
7078 .borrow_mut()
7079 .retain(|_, pname| pname != plugin_name);
7080
7081 tracing::debug!(
7082 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7083 plugin_name
7084 );
7085 }
7086
7087 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7089 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7090
7091 self.services
7092 .set_js_execution_state(format!("hook '{}'", event_name));
7093
7094 let handlers = self
7095 .event_handlers
7096 .read()
7097 .expect("event_handlers poisoned")
7098 .get(event_name)
7099 .cloned();
7100 if let Some(handler_pairs) = handlers {
7101 let plugin_contexts = self.plugin_contexts.borrow();
7102 for handler in &handler_pairs {
7103 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7104 continue;
7105 };
7106 context.with(|ctx| {
7107 call_handler(&ctx, &handler.handler_name, event_data);
7108 });
7109 }
7110 }
7111
7112 self.services.clear_js_execution_state();
7113 Ok(true)
7114 }
7115
7116 pub fn has_handlers(&self, event_name: &str) -> bool {
7118 self.event_handlers
7119 .read()
7120 .expect("event_handlers poisoned")
7121 .get(event_name)
7122 .map(|v| !v.is_empty())
7123 .unwrap_or(false)
7124 }
7125
7126 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7130 let (lookup_name, text_input_char) =
7133 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7134 ("mode_text_input", Some(ch.to_string()))
7135 } else {
7136 (action_name, None)
7137 };
7138
7139 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7140 let (plugin_name, function_name) = match pair {
7141 Some(handler) => (handler.plugin_name, handler.handler_name),
7142 None => ("main".to_string(), lookup_name.to_string()),
7143 };
7144
7145 let plugin_contexts = self.plugin_contexts.borrow();
7146 let context = plugin_contexts
7147 .get(&plugin_name)
7148 .unwrap_or(&self.main_context);
7149
7150 self.services
7152 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7153
7154 tracing::info!(
7155 "start_action: BEGIN '{}' -> function '{}'",
7156 action_name,
7157 function_name
7158 );
7159
7160 let call_args = if let Some(ref ch) = text_input_char {
7163 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7164 format!("({{text:\"{}\"}})", escaped)
7165 } else {
7166 "()".to_string()
7167 };
7168
7169 let code = format!(
7170 r#"
7171 (function() {{
7172 console.log('[JS] start_action: calling {fn}');
7173 try {{
7174 if (typeof globalThis.{fn} === 'function') {{
7175 console.log('[JS] start_action: {fn} is a function, invoking...');
7176 globalThis.{fn}{args};
7177 console.log('[JS] start_action: {fn} invoked (may be async)');
7178 }} else {{
7179 console.error('[JS] Action {action} is not defined as a global function');
7180 }}
7181 }} catch (e) {{
7182 console.error('[JS] Action {action} error:', e);
7183 }}
7184 }})();
7185 "#,
7186 fn = function_name,
7187 action = action_name,
7188 args = call_args
7189 );
7190
7191 tracing::info!("start_action: evaluating JS code");
7192 context.with(|ctx| {
7193 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7194 log_js_error(&ctx, e, &format!("action {}", action_name));
7195 }
7196 tracing::info!("start_action: running pending microtasks");
7197 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7199 tracing::info!("start_action: executed {} pending jobs", count);
7200 });
7201
7202 tracing::info!("start_action: END '{}'", action_name);
7203
7204 self.services.clear_js_execution_state();
7206
7207 Ok(())
7208 }
7209
7210 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7212 let pair = self.registered_actions.borrow().get(action_name).cloned();
7214 let (plugin_name, function_name) = match pair {
7215 Some(handler) => (handler.plugin_name, handler.handler_name),
7216 None => ("main".to_string(), action_name.to_string()),
7217 };
7218
7219 let plugin_contexts = self.plugin_contexts.borrow();
7220 let context = plugin_contexts
7221 .get(&plugin_name)
7222 .unwrap_or(&self.main_context);
7223
7224 tracing::debug!(
7225 "execute_action: '{}' -> function '{}'",
7226 action_name,
7227 function_name
7228 );
7229
7230 let code = format!(
7233 r#"
7234 (async function() {{
7235 try {{
7236 if (typeof globalThis.{fn} === 'function') {{
7237 const result = globalThis.{fn}();
7238 // If it's a Promise, await it
7239 if (result && typeof result.then === 'function') {{
7240 await result;
7241 }}
7242 }} else {{
7243 console.error('Action {action} is not defined as a global function');
7244 }}
7245 }} catch (e) {{
7246 console.error('Action {action} error:', e);
7247 }}
7248 }})();
7249 "#,
7250 fn = function_name,
7251 action = action_name
7252 );
7253
7254 context.with(|ctx| {
7255 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7257 Ok(value) => {
7258 if value.is_object() {
7260 if let Some(obj) = value.as_object() {
7261 if obj.get::<_, rquickjs::Function>("then").is_ok() {
7263 run_pending_jobs_checked(
7266 &ctx,
7267 &format!("execute_action {} promise", action_name),
7268 );
7269 }
7270 }
7271 }
7272 }
7273 Err(e) => {
7274 log_js_error(&ctx, e, &format!("action {}", action_name));
7275 }
7276 }
7277 });
7278
7279 Ok(())
7280 }
7281
7282 pub fn poll_event_loop_once(&mut self) -> bool {
7284 let mut had_work = false;
7285
7286 self.main_context.with(|ctx| {
7288 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7289 if count > 0 {
7290 had_work = true;
7291 }
7292 });
7293
7294 let contexts = self.plugin_contexts.borrow().clone();
7296 for (name, context) in contexts {
7297 context.with(|ctx| {
7298 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7299 if count > 0 {
7300 had_work = true;
7301 }
7302 });
7303 }
7304 had_work
7305 }
7306
7307 pub fn send_status(&self, message: String) {
7309 let _ = self
7310 .command_sender
7311 .send(PluginCommand::SetStatus { message });
7312 }
7313
7314 pub fn send_hook_completed(&self, hook_name: String) {
7318 let _ = self
7319 .command_sender
7320 .send(PluginCommand::HookCompleted { hook_name });
7321 }
7322
7323 pub fn resolve_callback(
7328 &mut self,
7329 callback_id: fresh_core::api::JsCallbackId,
7330 result_json: &str,
7331 ) {
7332 let id = callback_id.as_u64();
7333 tracing::debug!("resolve_callback: starting for callback_id={}", id);
7334
7335 let plugin_name = {
7337 let mut contexts = self.callback_contexts.borrow_mut();
7338 contexts.remove(&id)
7339 };
7340
7341 let Some(name) = plugin_name else {
7342 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7343 return;
7344 };
7345
7346 let plugin_contexts = self.plugin_contexts.borrow();
7347 let Some(context) = plugin_contexts.get(&name) else {
7348 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7349 return;
7350 };
7351
7352 context.with(|ctx| {
7353 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7355 Ok(v) => v,
7356 Err(e) => {
7357 tracing::error!(
7358 "resolve_callback: failed to parse JSON for callback_id={}: {}",
7359 id,
7360 e
7361 );
7362 return;
7363 }
7364 };
7365
7366 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7368 Ok(v) => v,
7369 Err(e) => {
7370 tracing::error!(
7371 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7372 id,
7373 e
7374 );
7375 return;
7376 }
7377 };
7378
7379 let globals = ctx.globals();
7381 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7382 Ok(f) => f,
7383 Err(e) => {
7384 tracing::error!(
7385 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7386 id,
7387 e
7388 );
7389 return;
7390 }
7391 };
7392
7393 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7395 log_js_error(&ctx, e, &format!("resolving callback {}", id));
7396 }
7397
7398 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7400 tracing::info!(
7401 "resolve_callback: executed {} pending jobs for callback_id={}",
7402 job_count,
7403 id
7404 );
7405 });
7406 }
7407
7408 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7410 let id = callback_id.as_u64();
7411
7412 let plugin_name = {
7414 let mut contexts = self.callback_contexts.borrow_mut();
7415 contexts.remove(&id)
7416 };
7417
7418 let Some(name) = plugin_name else {
7419 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
7420 return;
7421 };
7422
7423 let plugin_contexts = self.plugin_contexts.borrow();
7424 let Some(context) = plugin_contexts.get(&name) else {
7425 tracing::warn!("reject_callback: Context lost for plugin {}", name);
7426 return;
7427 };
7428
7429 context.with(|ctx| {
7430 let globals = ctx.globals();
7432 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
7433 Ok(f) => f,
7434 Err(e) => {
7435 tracing::error!(
7436 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
7437 id,
7438 e
7439 );
7440 return;
7441 }
7442 };
7443
7444 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
7446 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
7447 }
7448
7449 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
7451 });
7452 }
7453}
7454
7455#[cfg(test)]
7456mod tests {
7457 use super::*;
7458 use fresh_core::api::{BufferInfo, CursorInfo};
7459 use std::sync::mpsc;
7460
7461 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
7463 let (tx, rx) = mpsc::channel();
7464 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7465 let services = Arc::new(TestServiceBridge::new());
7466 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7467 (backend, rx)
7468 }
7469
7470 struct TestServiceBridge {
7471 en_strings: std::sync::Mutex<HashMap<String, String>>,
7472 }
7473
7474 impl TestServiceBridge {
7475 fn new() -> Self {
7476 Self {
7477 en_strings: std::sync::Mutex::new(HashMap::new()),
7478 }
7479 }
7480 }
7481
7482 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
7483 fn as_any(&self) -> &dyn std::any::Any {
7484 self
7485 }
7486 fn translate(
7487 &self,
7488 _plugin_name: &str,
7489 key: &str,
7490 _args: &HashMap<String, String>,
7491 ) -> String {
7492 self.en_strings
7493 .lock()
7494 .unwrap()
7495 .get(key)
7496 .cloned()
7497 .unwrap_or_else(|| key.to_string())
7498 }
7499 fn current_locale(&self) -> String {
7500 "en".to_string()
7501 }
7502 fn set_js_execution_state(&self, _state: String) {}
7503 fn clear_js_execution_state(&self) {}
7504 fn get_theme_schema(&self) -> serde_json::Value {
7505 serde_json::json!({})
7506 }
7507 fn get_builtin_themes(&self) -> serde_json::Value {
7508 serde_json::json!([])
7509 }
7510 fn get_all_themes(&self) -> serde_json::Value {
7511 serde_json::json!({})
7512 }
7513 fn register_command(&self, _command: fresh_core::command::Command) {}
7514 fn unregister_command(&self, _name: &str) {}
7515 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
7516 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
7517 fn plugins_dir(&self) -> std::path::PathBuf {
7518 std::path::PathBuf::from("/tmp/plugins")
7519 }
7520 fn config_dir(&self) -> std::path::PathBuf {
7521 std::path::PathBuf::from("/tmp/config")
7522 }
7523 fn data_dir(&self) -> std::path::PathBuf {
7524 std::path::PathBuf::from("/tmp/data")
7525 }
7526 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
7527 None
7528 }
7529 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7530 Err("not implemented in test".to_string())
7531 }
7532 fn theme_file_exists(&self, _name: &str) -> bool {
7533 false
7534 }
7535 }
7536
7537 #[test]
7538 fn test_quickjs_backend_creation() {
7539 let backend = QuickJsBackend::new();
7540 assert!(backend.is_ok());
7541 }
7542
7543 #[test]
7544 fn test_execute_simple_js() {
7545 let mut backend = QuickJsBackend::new().unwrap();
7546 let result = backend.execute_js("const x = 1 + 2;", "test.js");
7547 assert!(result.is_ok());
7548 }
7549
7550 #[test]
7551 fn test_event_handler_registration() {
7552 let backend = QuickJsBackend::new().unwrap();
7553
7554 assert!(!backend.has_handlers("test_event"));
7556
7557 backend
7559 .event_handlers
7560 .write()
7561 .unwrap()
7562 .entry("test_event".to_string())
7563 .or_default()
7564 .push(PluginHandler {
7565 plugin_name: "test".to_string(),
7566 handler_name: "testHandler".to_string(),
7567 });
7568
7569 assert!(backend.has_handlers("test_event"));
7571 }
7572
7573 #[test]
7576 fn test_api_set_status() {
7577 let (mut backend, rx) = create_test_backend();
7578
7579 backend
7580 .execute_js(
7581 r#"
7582 const editor = getEditor();
7583 editor.setStatus("Hello from test");
7584 "#,
7585 "test.js",
7586 )
7587 .unwrap();
7588
7589 let cmd = rx.try_recv().unwrap();
7590 match cmd {
7591 PluginCommand::SetStatus { message } => {
7592 assert_eq!(message, "Hello from test");
7593 }
7594 _ => panic!("Expected SetStatus command, got {:?}", cmd),
7595 }
7596 }
7597
7598 #[test]
7599 fn test_api_register_command() {
7600 let (mut backend, rx) = create_test_backend();
7601
7602 backend
7603 .execute_js(
7604 r#"
7605 const editor = getEditor();
7606 globalThis.myTestHandler = function() { };
7607 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
7608 "#,
7609 "test_plugin.js",
7610 )
7611 .unwrap();
7612
7613 let cmd = rx.try_recv().unwrap();
7614 match cmd {
7615 PluginCommand::RegisterCommand { command } => {
7616 assert_eq!(command.name, "Test Command");
7617 assert_eq!(command.description, "A test command");
7618 assert_eq!(command.plugin_name, "test_plugin");
7620 }
7621 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
7622 }
7623 }
7624
7625 #[test]
7626 fn test_api_define_mode() {
7627 let (mut backend, rx) = create_test_backend();
7628
7629 backend
7630 .execute_js(
7631 r#"
7632 const editor = getEditor();
7633 editor.defineMode("test-mode", [
7634 ["a", "action_a"],
7635 ["b", "action_b"]
7636 ]);
7637 "#,
7638 "test.js",
7639 )
7640 .unwrap();
7641
7642 let cmd = rx.try_recv().unwrap();
7643 match cmd {
7644 PluginCommand::DefineMode {
7645 name,
7646 bindings,
7647 read_only,
7648 allow_text_input,
7649 inherit_normal_bindings,
7650 plugin_name,
7651 } => {
7652 assert_eq!(name, "test-mode");
7653 assert_eq!(bindings.len(), 2);
7654 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
7655 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
7656 assert!(!read_only);
7657 assert!(!allow_text_input);
7658 assert!(!inherit_normal_bindings);
7659 assert!(plugin_name.is_some());
7660 }
7661 _ => panic!("Expected DefineMode, got {:?}", cmd),
7662 }
7663 }
7664
7665 #[test]
7666 fn test_api_set_editor_mode() {
7667 let (mut backend, rx) = create_test_backend();
7668
7669 backend
7670 .execute_js(
7671 r#"
7672 const editor = getEditor();
7673 editor.setEditorMode("vi-normal");
7674 "#,
7675 "test.js",
7676 )
7677 .unwrap();
7678
7679 let cmd = rx.try_recv().unwrap();
7680 match cmd {
7681 PluginCommand::SetEditorMode { mode } => {
7682 assert_eq!(mode, Some("vi-normal".to_string()));
7683 }
7684 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
7685 }
7686 }
7687
7688 #[test]
7689 fn test_api_clear_editor_mode() {
7690 let (mut backend, rx) = create_test_backend();
7691
7692 backend
7693 .execute_js(
7694 r#"
7695 const editor = getEditor();
7696 editor.setEditorMode(null);
7697 "#,
7698 "test.js",
7699 )
7700 .unwrap();
7701
7702 let cmd = rx.try_recv().unwrap();
7703 match cmd {
7704 PluginCommand::SetEditorMode { mode } => {
7705 assert!(mode.is_none());
7706 }
7707 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
7708 }
7709 }
7710
7711 #[test]
7712 fn test_api_insert_at_cursor() {
7713 let (mut backend, rx) = create_test_backend();
7714
7715 backend
7716 .execute_js(
7717 r#"
7718 const editor = getEditor();
7719 editor.insertAtCursor("Hello, World!");
7720 "#,
7721 "test.js",
7722 )
7723 .unwrap();
7724
7725 let cmd = rx.try_recv().unwrap();
7726 match cmd {
7727 PluginCommand::InsertAtCursor { text } => {
7728 assert_eq!(text, "Hello, World!");
7729 }
7730 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
7731 }
7732 }
7733
7734 #[test]
7735 fn test_api_set_context() {
7736 let (mut backend, rx) = create_test_backend();
7737
7738 backend
7739 .execute_js(
7740 r#"
7741 const editor = getEditor();
7742 editor.setContext("myContext", true);
7743 "#,
7744 "test.js",
7745 )
7746 .unwrap();
7747
7748 let cmd = rx.try_recv().unwrap();
7749 match cmd {
7750 PluginCommand::SetContext { name, active } => {
7751 assert_eq!(name, "myContext");
7752 assert!(active);
7753 }
7754 _ => panic!("Expected SetContext, got {:?}", cmd),
7755 }
7756 }
7757
7758 #[tokio::test]
7759 async fn test_execute_action_sync_function() {
7760 let (mut backend, rx) = create_test_backend();
7761
7762 backend.registered_actions.borrow_mut().insert(
7764 "my_sync_action".to_string(),
7765 PluginHandler {
7766 plugin_name: "test".to_string(),
7767 handler_name: "my_sync_action".to_string(),
7768 },
7769 );
7770
7771 backend
7773 .execute_js(
7774 r#"
7775 const editor = getEditor();
7776 globalThis.my_sync_action = function() {
7777 editor.setStatus("sync action executed");
7778 };
7779 "#,
7780 "test.js",
7781 )
7782 .unwrap();
7783
7784 while rx.try_recv().is_ok() {}
7786
7787 backend.execute_action("my_sync_action").await.unwrap();
7789
7790 let cmd = rx.try_recv().unwrap();
7792 match cmd {
7793 PluginCommand::SetStatus { message } => {
7794 assert_eq!(message, "sync action executed");
7795 }
7796 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
7797 }
7798 }
7799
7800 #[tokio::test]
7801 async fn test_execute_action_async_function() {
7802 let (mut backend, rx) = create_test_backend();
7803
7804 backend.registered_actions.borrow_mut().insert(
7806 "my_async_action".to_string(),
7807 PluginHandler {
7808 plugin_name: "test".to_string(),
7809 handler_name: "my_async_action".to_string(),
7810 },
7811 );
7812
7813 backend
7815 .execute_js(
7816 r#"
7817 const editor = getEditor();
7818 globalThis.my_async_action = async function() {
7819 await Promise.resolve();
7820 editor.setStatus("async action executed");
7821 };
7822 "#,
7823 "test.js",
7824 )
7825 .unwrap();
7826
7827 while rx.try_recv().is_ok() {}
7829
7830 backend.execute_action("my_async_action").await.unwrap();
7832
7833 let cmd = rx.try_recv().unwrap();
7835 match cmd {
7836 PluginCommand::SetStatus { message } => {
7837 assert_eq!(message, "async action executed");
7838 }
7839 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
7840 }
7841 }
7842
7843 #[tokio::test]
7844 async fn test_execute_action_with_registered_handler() {
7845 let (mut backend, rx) = create_test_backend();
7846
7847 backend.registered_actions.borrow_mut().insert(
7849 "my_action".to_string(),
7850 PluginHandler {
7851 plugin_name: "test".to_string(),
7852 handler_name: "actual_handler_function".to_string(),
7853 },
7854 );
7855
7856 backend
7857 .execute_js(
7858 r#"
7859 const editor = getEditor();
7860 globalThis.actual_handler_function = function() {
7861 editor.setStatus("handler executed");
7862 };
7863 "#,
7864 "test.js",
7865 )
7866 .unwrap();
7867
7868 while rx.try_recv().is_ok() {}
7870
7871 backend.execute_action("my_action").await.unwrap();
7873
7874 let cmd = rx.try_recv().unwrap();
7875 match cmd {
7876 PluginCommand::SetStatus { message } => {
7877 assert_eq!(message, "handler executed");
7878 }
7879 _ => panic!("Expected SetStatus, got {:?}", cmd),
7880 }
7881 }
7882
7883 #[test]
7884 fn test_api_on_event_registration() {
7885 let (mut backend, _rx) = create_test_backend();
7886
7887 backend
7888 .execute_js(
7889 r#"
7890 const editor = getEditor();
7891 globalThis.myEventHandler = function() { };
7892 editor.on("bufferSave", "myEventHandler");
7893 "#,
7894 "test.js",
7895 )
7896 .unwrap();
7897
7898 assert!(backend.has_handlers("bufferSave"));
7899 }
7900
7901 #[test]
7902 fn test_api_off_event_unregistration() {
7903 let (mut backend, _rx) = create_test_backend();
7904
7905 backend
7906 .execute_js(
7907 r#"
7908 const editor = getEditor();
7909 globalThis.myEventHandler = function() { };
7910 editor.on("bufferSave", "myEventHandler");
7911 editor.off("bufferSave", "myEventHandler");
7912 "#,
7913 "test.js",
7914 )
7915 .unwrap();
7916
7917 assert!(!backend.has_handlers("bufferSave"));
7919 }
7920
7921 #[tokio::test]
7922 async fn test_emit_event() {
7923 let (mut backend, rx) = create_test_backend();
7924
7925 backend
7926 .execute_js(
7927 r#"
7928 const editor = getEditor();
7929 globalThis.onSaveHandler = function(data) {
7930 editor.setStatus("saved: " + JSON.stringify(data));
7931 };
7932 editor.on("bufferSave", "onSaveHandler");
7933 "#,
7934 "test.js",
7935 )
7936 .unwrap();
7937
7938 while rx.try_recv().is_ok() {}
7940
7941 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
7943 backend.emit("bufferSave", &event_data).await.unwrap();
7944
7945 let cmd = rx.try_recv().unwrap();
7946 match cmd {
7947 PluginCommand::SetStatus { message } => {
7948 assert!(message.contains("/test.txt"));
7949 }
7950 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
7951 }
7952 }
7953
7954 #[test]
7955 fn test_api_copy_to_clipboard() {
7956 let (mut backend, rx) = create_test_backend();
7957
7958 backend
7959 .execute_js(
7960 r#"
7961 const editor = getEditor();
7962 editor.copyToClipboard("clipboard text");
7963 "#,
7964 "test.js",
7965 )
7966 .unwrap();
7967
7968 let cmd = rx.try_recv().unwrap();
7969 match cmd {
7970 PluginCommand::SetClipboard { text } => {
7971 assert_eq!(text, "clipboard text");
7972 }
7973 _ => panic!("Expected SetClipboard, got {:?}", cmd),
7974 }
7975 }
7976
7977 #[test]
7978 fn test_api_open_file() {
7979 let (mut backend, rx) = create_test_backend();
7980
7981 backend
7983 .execute_js(
7984 r#"
7985 const editor = getEditor();
7986 editor.openFile("/path/to/file.txt", null, null);
7987 "#,
7988 "test.js",
7989 )
7990 .unwrap();
7991
7992 let cmd = rx.try_recv().unwrap();
7993 match cmd {
7994 PluginCommand::OpenFileAtLocation { path, line, column } => {
7995 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
7996 assert!(line.is_none());
7997 assert!(column.is_none());
7998 }
7999 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8000 }
8001 }
8002
8003 #[test]
8004 fn test_api_delete_range() {
8005 let (mut backend, rx) = create_test_backend();
8006
8007 backend
8009 .execute_js(
8010 r#"
8011 const editor = getEditor();
8012 editor.deleteRange(0, 10, 20);
8013 "#,
8014 "test.js",
8015 )
8016 .unwrap();
8017
8018 let cmd = rx.try_recv().unwrap();
8019 match cmd {
8020 PluginCommand::DeleteRange { range, .. } => {
8021 assert_eq!(range.start, 10);
8022 assert_eq!(range.end, 20);
8023 }
8024 _ => panic!("Expected DeleteRange, got {:?}", cmd),
8025 }
8026 }
8027
8028 #[test]
8029 fn test_api_insert_text() {
8030 let (mut backend, rx) = create_test_backend();
8031
8032 backend
8034 .execute_js(
8035 r#"
8036 const editor = getEditor();
8037 editor.insertText(0, 5, "inserted");
8038 "#,
8039 "test.js",
8040 )
8041 .unwrap();
8042
8043 let cmd = rx.try_recv().unwrap();
8044 match cmd {
8045 PluginCommand::InsertText { position, text, .. } => {
8046 assert_eq!(position, 5);
8047 assert_eq!(text, "inserted");
8048 }
8049 _ => panic!("Expected InsertText, got {:?}", cmd),
8050 }
8051 }
8052
8053 #[test]
8054 fn test_api_set_buffer_cursor() {
8055 let (mut backend, rx) = create_test_backend();
8056
8057 backend
8059 .execute_js(
8060 r#"
8061 const editor = getEditor();
8062 editor.setBufferCursor(0, 100);
8063 "#,
8064 "test.js",
8065 )
8066 .unwrap();
8067
8068 let cmd = rx.try_recv().unwrap();
8069 match cmd {
8070 PluginCommand::SetBufferCursor { position, .. } => {
8071 assert_eq!(position, 100);
8072 }
8073 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8074 }
8075 }
8076
8077 #[test]
8078 fn test_api_get_cursor_position_from_state() {
8079 let (tx, _rx) = mpsc::channel();
8080 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8081
8082 {
8084 let mut state = state_snapshot.write().unwrap();
8085 state.primary_cursor = Some(CursorInfo {
8086 position: 42,
8087 selection: None,
8088 });
8089 }
8090
8091 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8092 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8093
8094 backend
8096 .execute_js(
8097 r#"
8098 const editor = getEditor();
8099 const pos = editor.getCursorPosition();
8100 globalThis._testResult = pos;
8101 "#,
8102 "test.js",
8103 )
8104 .unwrap();
8105
8106 backend
8108 .plugin_contexts
8109 .borrow()
8110 .get("test")
8111 .unwrap()
8112 .clone()
8113 .with(|ctx| {
8114 let global = ctx.globals();
8115 let result: u32 = global.get("_testResult").unwrap();
8116 assert_eq!(result, 42);
8117 });
8118 }
8119
8120 #[test]
8121 fn test_api_path_functions() {
8122 let (mut backend, _rx) = create_test_backend();
8123
8124 #[cfg(windows)]
8127 let absolute_path = r#"C:\\foo\\bar"#;
8128 #[cfg(not(windows))]
8129 let absolute_path = "/foo/bar";
8130
8131 let js_code = format!(
8133 r#"
8134 const editor = getEditor();
8135 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8136 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8137 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8138 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8139 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8140 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8141 "#,
8142 absolute_path
8143 );
8144 backend.execute_js(&js_code, "test.js").unwrap();
8145
8146 backend
8147 .plugin_contexts
8148 .borrow()
8149 .get("test")
8150 .unwrap()
8151 .clone()
8152 .with(|ctx| {
8153 let global = ctx.globals();
8154 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8155 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8156 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8157 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8158 assert!(!global.get::<_, bool>("_isRelative").unwrap());
8159 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8160 });
8161 }
8162
8163 #[test]
8171 fn test_path_join_preserves_unc_prefix() {
8172 let (mut backend, _rx) = create_test_backend();
8173 backend
8174 .execute_js(
8175 r#"
8176 const editor = getEditor();
8177 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8178 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8179 globalThis._posix = editor.pathJoin("/foo", "bar");
8180 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8181 "#,
8182 "test.js",
8183 )
8184 .unwrap();
8185
8186 backend
8187 .plugin_contexts
8188 .borrow()
8189 .get("test")
8190 .unwrap()
8191 .clone()
8192 .with(|ctx| {
8193 let global = ctx.globals();
8194 assert_eq!(
8195 global.get::<_, String>("_unc").unwrap(),
8196 "//?/C:/workspace/.devcontainer/devcontainer.json",
8197 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
8198 );
8199 assert_eq!(
8200 global.get::<_, String>("_unc_fwd").unwrap(),
8201 "//?/C:/workspace/.devcontainer/devcontainer.json",
8202 "UNC prefix in forward-slash form stays as `//`",
8203 );
8204 assert_eq!(
8205 global.get::<_, String>("_posix").unwrap(),
8206 "/foo/bar",
8207 "POSIX absolute paths keep their single leading slash",
8208 );
8209 assert_eq!(
8210 global.get::<_, String>("_drive").unwrap(),
8211 "C:/foo/bar",
8212 "Windows drive-letter paths have no leading slash",
8213 );
8214 });
8215 }
8216
8217 #[test]
8218 fn test_file_uri_to_path_and_back() {
8219 let (mut backend, _rx) = create_test_backend();
8220
8221 #[cfg(not(windows))]
8223 let js_code = r#"
8224 const editor = getEditor();
8225 // Basic file URI to path
8226 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
8227 // Percent-encoded characters
8228 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
8229 // Invalid URI returns empty string
8230 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8231 // Path to file URI
8232 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
8233 // Round-trip
8234 globalThis._roundtrip = editor.fileUriToPath(
8235 editor.pathToFileUri("/home/user/file.txt")
8236 );
8237 "#;
8238
8239 #[cfg(windows)]
8240 let js_code = r#"
8241 const editor = getEditor();
8242 // Windows URI with encoded colon (the bug from issue #1071)
8243 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
8244 // Windows URI with normal colon
8245 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
8246 // Invalid URI returns empty string
8247 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8248 // Path to file URI
8249 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
8250 // Round-trip
8251 globalThis._roundtrip = editor.fileUriToPath(
8252 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
8253 );
8254 "#;
8255
8256 backend.execute_js(js_code, "test.js").unwrap();
8257
8258 backend
8259 .plugin_contexts
8260 .borrow()
8261 .get("test")
8262 .unwrap()
8263 .clone()
8264 .with(|ctx| {
8265 let global = ctx.globals();
8266
8267 #[cfg(not(windows))]
8268 {
8269 assert_eq!(
8270 global.get::<_, String>("_path1").unwrap(),
8271 "/home/user/file.txt"
8272 );
8273 assert_eq!(
8274 global.get::<_, String>("_path2").unwrap(),
8275 "/home/user/my file.txt"
8276 );
8277 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8278 assert_eq!(
8279 global.get::<_, String>("_uri1").unwrap(),
8280 "file:///home/user/file.txt"
8281 );
8282 assert_eq!(
8283 global.get::<_, String>("_roundtrip").unwrap(),
8284 "/home/user/file.txt"
8285 );
8286 }
8287
8288 #[cfg(windows)]
8289 {
8290 assert_eq!(
8292 global.get::<_, String>("_path1").unwrap(),
8293 "C:\\Users\\admin\\Repos\\file.cs"
8294 );
8295 assert_eq!(
8296 global.get::<_, String>("_path2").unwrap(),
8297 "C:\\Users\\admin\\Repos\\file.cs"
8298 );
8299 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8300 assert_eq!(
8301 global.get::<_, String>("_uri1").unwrap(),
8302 "file:///C:/Users/admin/Repos/file.cs"
8303 );
8304 assert_eq!(
8305 global.get::<_, String>("_roundtrip").unwrap(),
8306 "C:\\Users\\admin\\Repos\\file.cs"
8307 );
8308 }
8309 });
8310 }
8311
8312 #[test]
8313 fn test_typescript_transpilation() {
8314 use fresh_parser_js::transpile_typescript;
8315
8316 let (mut backend, rx) = create_test_backend();
8317
8318 let ts_code = r#"
8320 const editor = getEditor();
8321 function greet(name: string): string {
8322 return "Hello, " + name;
8323 }
8324 editor.setStatus(greet("TypeScript"));
8325 "#;
8326
8327 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
8329
8330 backend.execute_js(&js_code, "test.js").unwrap();
8332
8333 let cmd = rx.try_recv().unwrap();
8334 match cmd {
8335 PluginCommand::SetStatus { message } => {
8336 assert_eq!(message, "Hello, TypeScript");
8337 }
8338 _ => panic!("Expected SetStatus, got {:?}", cmd),
8339 }
8340 }
8341
8342 #[test]
8343 fn test_api_get_buffer_text_sends_command() {
8344 let (mut backend, rx) = create_test_backend();
8345
8346 backend
8348 .execute_js(
8349 r#"
8350 const editor = getEditor();
8351 // Store the promise for later
8352 globalThis._textPromise = editor.getBufferText(0, 10, 20);
8353 "#,
8354 "test.js",
8355 )
8356 .unwrap();
8357
8358 let cmd = rx.try_recv().unwrap();
8360 match cmd {
8361 PluginCommand::GetBufferText {
8362 buffer_id,
8363 start,
8364 end,
8365 request_id,
8366 } => {
8367 assert_eq!(buffer_id.0, 0);
8368 assert_eq!(start, 10);
8369 assert_eq!(end, 20);
8370 assert!(request_id > 0); }
8372 _ => panic!("Expected GetBufferText, got {:?}", cmd),
8373 }
8374 }
8375
8376 #[test]
8377 fn test_api_get_buffer_text_resolves_callback() {
8378 let (mut backend, rx) = create_test_backend();
8379
8380 backend
8382 .execute_js(
8383 r#"
8384 const editor = getEditor();
8385 globalThis._resolvedText = null;
8386 editor.getBufferText(0, 0, 100).then(text => {
8387 globalThis._resolvedText = text;
8388 });
8389 "#,
8390 "test.js",
8391 )
8392 .unwrap();
8393
8394 let request_id = match rx.try_recv().unwrap() {
8396 PluginCommand::GetBufferText { request_id, .. } => request_id,
8397 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
8398 };
8399
8400 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
8402
8403 backend
8405 .plugin_contexts
8406 .borrow()
8407 .get("test")
8408 .unwrap()
8409 .clone()
8410 .with(|ctx| {
8411 run_pending_jobs_checked(&ctx, "test async getText");
8412 });
8413
8414 backend
8416 .plugin_contexts
8417 .borrow()
8418 .get("test")
8419 .unwrap()
8420 .clone()
8421 .with(|ctx| {
8422 let global = ctx.globals();
8423 let result: String = global.get("_resolvedText").unwrap();
8424 assert_eq!(result, "hello world");
8425 });
8426 }
8427
8428 #[test]
8429 fn test_plugin_translation() {
8430 let (mut backend, _rx) = create_test_backend();
8431
8432 backend
8434 .execute_js(
8435 r#"
8436 const editor = getEditor();
8437 globalThis._translated = editor.t("test.key");
8438 "#,
8439 "test.js",
8440 )
8441 .unwrap();
8442
8443 backend
8444 .plugin_contexts
8445 .borrow()
8446 .get("test")
8447 .unwrap()
8448 .clone()
8449 .with(|ctx| {
8450 let global = ctx.globals();
8451 let result: String = global.get("_translated").unwrap();
8453 assert_eq!(result, "test.key");
8454 });
8455 }
8456
8457 #[test]
8458 fn test_plugin_translation_with_registered_strings() {
8459 let (mut backend, _rx) = create_test_backend();
8460
8461 let mut en_strings = std::collections::HashMap::new();
8463 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
8464 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8465
8466 let mut strings = std::collections::HashMap::new();
8467 strings.insert("en".to_string(), en_strings);
8468
8469 if let Some(bridge) = backend
8471 .services
8472 .as_any()
8473 .downcast_ref::<TestServiceBridge>()
8474 {
8475 let mut en = bridge.en_strings.lock().unwrap();
8476 en.insert("greeting".to_string(), "Hello, World!".to_string());
8477 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
8478 }
8479
8480 backend
8482 .execute_js(
8483 r#"
8484 const editor = getEditor();
8485 globalThis._greeting = editor.t("greeting");
8486 globalThis._prompt = editor.t("prompt.find_file");
8487 globalThis._missing = editor.t("nonexistent.key");
8488 "#,
8489 "test.js",
8490 )
8491 .unwrap();
8492
8493 backend
8494 .plugin_contexts
8495 .borrow()
8496 .get("test")
8497 .unwrap()
8498 .clone()
8499 .with(|ctx| {
8500 let global = ctx.globals();
8501 let greeting: String = global.get("_greeting").unwrap();
8502 assert_eq!(greeting, "Hello, World!");
8503
8504 let prompt: String = global.get("_prompt").unwrap();
8505 assert_eq!(prompt, "Find file: ");
8506
8507 let missing: String = global.get("_missing").unwrap();
8509 assert_eq!(missing, "nonexistent.key");
8510 });
8511 }
8512
8513 #[test]
8516 fn test_api_set_line_indicator() {
8517 let (mut backend, rx) = create_test_backend();
8518
8519 backend
8520 .execute_js(
8521 r#"
8522 const editor = getEditor();
8523 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
8524 "#,
8525 "test.js",
8526 )
8527 .unwrap();
8528
8529 let cmd = rx.try_recv().unwrap();
8530 match cmd {
8531 PluginCommand::SetLineIndicator {
8532 buffer_id,
8533 line,
8534 namespace,
8535 symbol,
8536 color,
8537 priority,
8538 } => {
8539 assert_eq!(buffer_id.0, 1);
8540 assert_eq!(line, 5);
8541 assert_eq!(namespace, "test-ns");
8542 assert_eq!(symbol, "●");
8543 assert_eq!(color, (255, 0, 0));
8544 assert_eq!(priority, 10);
8545 }
8546 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
8547 }
8548 }
8549
8550 #[test]
8551 fn test_api_clear_line_indicators() {
8552 let (mut backend, rx) = create_test_backend();
8553
8554 backend
8555 .execute_js(
8556 r#"
8557 const editor = getEditor();
8558 editor.clearLineIndicators(1, "test-ns");
8559 "#,
8560 "test.js",
8561 )
8562 .unwrap();
8563
8564 let cmd = rx.try_recv().unwrap();
8565 match cmd {
8566 PluginCommand::ClearLineIndicators {
8567 buffer_id,
8568 namespace,
8569 } => {
8570 assert_eq!(buffer_id.0, 1);
8571 assert_eq!(namespace, "test-ns");
8572 }
8573 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
8574 }
8575 }
8576
8577 #[test]
8580 fn test_api_create_virtual_buffer_sends_command() {
8581 let (mut backend, rx) = create_test_backend();
8582
8583 backend
8584 .execute_js(
8585 r#"
8586 const editor = getEditor();
8587 editor.createVirtualBuffer({
8588 name: "*Test Buffer*",
8589 mode: "test-mode",
8590 readOnly: true,
8591 entries: [
8592 { text: "Line 1\n", properties: { type: "header" } },
8593 { text: "Line 2\n", properties: { type: "content" } }
8594 ],
8595 showLineNumbers: false,
8596 showCursors: true,
8597 editingDisabled: true
8598 });
8599 "#,
8600 "test.js",
8601 )
8602 .unwrap();
8603
8604 let cmd = rx.try_recv().unwrap();
8605 match cmd {
8606 PluginCommand::CreateVirtualBufferWithContent {
8607 name,
8608 mode,
8609 read_only,
8610 entries,
8611 show_line_numbers,
8612 show_cursors,
8613 editing_disabled,
8614 ..
8615 } => {
8616 assert_eq!(name, "*Test Buffer*");
8617 assert_eq!(mode, "test-mode");
8618 assert!(read_only);
8619 assert_eq!(entries.len(), 2);
8620 assert_eq!(entries[0].text, "Line 1\n");
8621 assert!(!show_line_numbers);
8622 assert!(show_cursors);
8623 assert!(editing_disabled);
8624 }
8625 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
8626 }
8627 }
8628
8629 #[test]
8630 fn test_api_set_virtual_buffer_content() {
8631 let (mut backend, rx) = create_test_backend();
8632
8633 backend
8634 .execute_js(
8635 r#"
8636 const editor = getEditor();
8637 editor.setVirtualBufferContent(5, [
8638 { text: "New content\n", properties: { type: "updated" } }
8639 ]);
8640 "#,
8641 "test.js",
8642 )
8643 .unwrap();
8644
8645 let cmd = rx.try_recv().unwrap();
8646 match cmd {
8647 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
8648 assert_eq!(buffer_id.0, 5);
8649 assert_eq!(entries.len(), 1);
8650 assert_eq!(entries[0].text, "New content\n");
8651 }
8652 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
8653 }
8654 }
8655
8656 #[test]
8659 fn test_api_add_overlay() {
8660 let (mut backend, rx) = create_test_backend();
8661
8662 backend
8663 .execute_js(
8664 r#"
8665 const editor = getEditor();
8666 editor.addOverlay(1, "highlight", 10, 20, {
8667 fg: [255, 128, 0],
8668 bg: [50, 50, 50],
8669 bold: true,
8670 });
8671 "#,
8672 "test.js",
8673 )
8674 .unwrap();
8675
8676 let cmd = rx.try_recv().unwrap();
8677 match cmd {
8678 PluginCommand::AddOverlay {
8679 buffer_id,
8680 namespace,
8681 range,
8682 options,
8683 } => {
8684 use fresh_core::api::OverlayColorSpec;
8685 assert_eq!(buffer_id.0, 1);
8686 assert!(namespace.is_some());
8687 assert_eq!(namespace.unwrap().as_str(), "highlight");
8688 assert_eq!(range, 10..20);
8689 assert!(matches!(
8690 options.fg,
8691 Some(OverlayColorSpec::Rgb(255, 128, 0))
8692 ));
8693 assert!(matches!(
8694 options.bg,
8695 Some(OverlayColorSpec::Rgb(50, 50, 50))
8696 ));
8697 assert!(!options.underline);
8698 assert!(options.bold);
8699 assert!(!options.italic);
8700 assert!(!options.extend_to_line_end);
8701 }
8702 _ => panic!("Expected AddOverlay, got {:?}", cmd),
8703 }
8704 }
8705
8706 #[test]
8707 fn test_api_add_overlay_with_theme_keys() {
8708 let (mut backend, rx) = create_test_backend();
8709
8710 backend
8711 .execute_js(
8712 r#"
8713 const editor = getEditor();
8714 // Test with theme keys for colors
8715 editor.addOverlay(1, "themed", 0, 10, {
8716 fg: "ui.status_bar_fg",
8717 bg: "editor.selection_bg",
8718 });
8719 "#,
8720 "test.js",
8721 )
8722 .unwrap();
8723
8724 let cmd = rx.try_recv().unwrap();
8725 match cmd {
8726 PluginCommand::AddOverlay {
8727 buffer_id,
8728 namespace,
8729 range,
8730 options,
8731 } => {
8732 use fresh_core::api::OverlayColorSpec;
8733 assert_eq!(buffer_id.0, 1);
8734 assert!(namespace.is_some());
8735 assert_eq!(namespace.unwrap().as_str(), "themed");
8736 assert_eq!(range, 0..10);
8737 assert!(matches!(
8738 &options.fg,
8739 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
8740 ));
8741 assert!(matches!(
8742 &options.bg,
8743 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
8744 ));
8745 assert!(!options.underline);
8746 assert!(!options.bold);
8747 assert!(!options.italic);
8748 assert!(!options.extend_to_line_end);
8749 }
8750 _ => panic!("Expected AddOverlay, got {:?}", cmd),
8751 }
8752 }
8753
8754 #[test]
8755 fn test_api_clear_namespace() {
8756 let (mut backend, rx) = create_test_backend();
8757
8758 backend
8759 .execute_js(
8760 r#"
8761 const editor = getEditor();
8762 editor.clearNamespace(1, "highlight");
8763 "#,
8764 "test.js",
8765 )
8766 .unwrap();
8767
8768 let cmd = rx.try_recv().unwrap();
8769 match cmd {
8770 PluginCommand::ClearNamespace {
8771 buffer_id,
8772 namespace,
8773 } => {
8774 assert_eq!(buffer_id.0, 1);
8775 assert_eq!(namespace.as_str(), "highlight");
8776 }
8777 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
8778 }
8779 }
8780
8781 #[test]
8784 fn test_api_get_theme_schema() {
8785 let (mut backend, _rx) = create_test_backend();
8786
8787 backend
8788 .execute_js(
8789 r#"
8790 const editor = getEditor();
8791 const schema = editor.getThemeSchema();
8792 globalThis._isObject = typeof schema === 'object' && schema !== null;
8793 "#,
8794 "test.js",
8795 )
8796 .unwrap();
8797
8798 backend
8799 .plugin_contexts
8800 .borrow()
8801 .get("test")
8802 .unwrap()
8803 .clone()
8804 .with(|ctx| {
8805 let global = ctx.globals();
8806 let is_object: bool = global.get("_isObject").unwrap();
8807 assert!(is_object);
8809 });
8810 }
8811
8812 #[test]
8813 fn test_api_get_builtin_themes() {
8814 let (mut backend, _rx) = create_test_backend();
8815
8816 backend
8817 .execute_js(
8818 r#"
8819 const editor = getEditor();
8820 const themes = editor.getBuiltinThemes();
8821 globalThis._isObject = typeof themes === 'object' && themes !== null;
8822 "#,
8823 "test.js",
8824 )
8825 .unwrap();
8826
8827 backend
8828 .plugin_contexts
8829 .borrow()
8830 .get("test")
8831 .unwrap()
8832 .clone()
8833 .with(|ctx| {
8834 let global = ctx.globals();
8835 let is_object: bool = global.get("_isObject").unwrap();
8836 assert!(is_object);
8838 });
8839 }
8840
8841 #[test]
8842 fn test_api_apply_theme() {
8843 let (mut backend, rx) = create_test_backend();
8844
8845 backend
8846 .execute_js(
8847 r#"
8848 const editor = getEditor();
8849 editor.applyTheme("dark");
8850 "#,
8851 "test.js",
8852 )
8853 .unwrap();
8854
8855 let cmd = rx.try_recv().unwrap();
8856 match cmd {
8857 PluginCommand::ApplyTheme { theme_name } => {
8858 assert_eq!(theme_name, "dark");
8859 }
8860 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
8861 }
8862 }
8863
8864 #[test]
8865 fn test_api_override_theme_colors_round_trip() {
8866 let (mut backend, rx) = create_test_backend();
8869
8870 backend
8871 .execute_js(
8872 r#"
8873 const editor = getEditor();
8874 editor.overrideThemeColors({
8875 "editor.bg": [10, 20, 30],
8876 "editor.fg": [220, 221, 222],
8877 });
8878 "#,
8879 "test.js",
8880 )
8881 .unwrap();
8882
8883 let cmd = rx.try_recv().unwrap();
8884 match cmd {
8885 PluginCommand::OverrideThemeColors { overrides } => {
8886 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
8887 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
8888 assert_eq!(overrides.len(), 2);
8889 }
8890 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
8891 }
8892 }
8893
8894 #[test]
8895 fn test_api_override_theme_colors_clamps_out_of_range() {
8896 let (mut backend, rx) = create_test_backend();
8897
8898 backend
8899 .execute_js(
8900 r#"
8901 const editor = getEditor();
8902 editor.overrideThemeColors({
8903 "editor.bg": [-5, 300, 128],
8904 });
8905 "#,
8906 "test.js",
8907 )
8908 .unwrap();
8909
8910 match rx.try_recv().unwrap() {
8911 PluginCommand::OverrideThemeColors { overrides } => {
8912 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
8913 }
8914 other => panic!("Expected OverrideThemeColors, got {other:?}"),
8915 }
8916 }
8917
8918 #[test]
8919 fn test_api_override_theme_colors_drops_malformed_entries() {
8920 let (mut backend, rx) = create_test_backend();
8923
8924 backend
8925 .execute_js(
8926 r#"
8927 const editor = getEditor();
8928 editor.overrideThemeColors({
8929 "editor.bg": [1, 2, 3],
8930 "not_an_array": "oops",
8931 "wrong_length": [1, 2],
8932 "floats_are_fine": [10.7, 20.2, 30.9],
8933 });
8934 "#,
8935 "test.js",
8936 )
8937 .unwrap();
8938
8939 match rx.try_recv().unwrap() {
8940 PluginCommand::OverrideThemeColors { overrides } => {
8941 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
8942 assert!(!overrides.contains_key("not_an_array"));
8943 assert!(!overrides.contains_key("wrong_length"));
8944 assert_eq!(
8946 overrides.get("floats_are_fine").copied(),
8947 Some([10, 20, 30])
8948 );
8949 }
8950 other => panic!("Expected OverrideThemeColors, got {other:?}"),
8951 }
8952 }
8953
8954 #[test]
8955 fn test_api_get_theme_data_missing() {
8956 let (mut backend, _rx) = create_test_backend();
8957
8958 backend
8959 .execute_js(
8960 r#"
8961 const editor = getEditor();
8962 const data = editor.getThemeData("nonexistent");
8963 globalThis._isNull = data === null;
8964 "#,
8965 "test.js",
8966 )
8967 .unwrap();
8968
8969 backend
8970 .plugin_contexts
8971 .borrow()
8972 .get("test")
8973 .unwrap()
8974 .clone()
8975 .with(|ctx| {
8976 let global = ctx.globals();
8977 let is_null: bool = global.get("_isNull").unwrap();
8978 assert!(is_null);
8980 });
8981 }
8982
8983 #[test]
8984 fn test_api_get_theme_data_present() {
8985 let (tx, _rx) = mpsc::channel();
8987 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8988 let services = Arc::new(ThemeCacheTestBridge {
8989 inner: TestServiceBridge::new(),
8990 });
8991 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8992
8993 backend
8994 .execute_js(
8995 r#"
8996 const editor = getEditor();
8997 const data = editor.getThemeData("test-theme");
8998 globalThis._hasData = data !== null && typeof data === 'object';
8999 globalThis._name = data ? data.name : null;
9000 "#,
9001 "test.js",
9002 )
9003 .unwrap();
9004
9005 backend
9006 .plugin_contexts
9007 .borrow()
9008 .get("test")
9009 .unwrap()
9010 .clone()
9011 .with(|ctx| {
9012 let global = ctx.globals();
9013 let has_data: bool = global.get("_hasData").unwrap();
9014 assert!(has_data, "getThemeData should return theme object");
9015 let name: String = global.get("_name").unwrap();
9016 assert_eq!(name, "test-theme");
9017 });
9018 }
9019
9020 #[test]
9021 fn test_api_theme_file_exists() {
9022 let (mut backend, _rx) = create_test_backend();
9023
9024 backend
9025 .execute_js(
9026 r#"
9027 const editor = getEditor();
9028 globalThis._exists = editor.themeFileExists("anything");
9029 "#,
9030 "test.js",
9031 )
9032 .unwrap();
9033
9034 backend
9035 .plugin_contexts
9036 .borrow()
9037 .get("test")
9038 .unwrap()
9039 .clone()
9040 .with(|ctx| {
9041 let global = ctx.globals();
9042 let exists: bool = global.get("_exists").unwrap();
9043 assert!(!exists);
9045 });
9046 }
9047
9048 #[test]
9049 fn test_api_save_theme_file_error() {
9050 let (mut backend, _rx) = create_test_backend();
9051
9052 backend
9053 .execute_js(
9054 r#"
9055 const editor = getEditor();
9056 let threw = false;
9057 try {
9058 editor.saveThemeFile("test", "{}");
9059 } catch (e) {
9060 threw = true;
9061 }
9062 globalThis._threw = threw;
9063 "#,
9064 "test.js",
9065 )
9066 .unwrap();
9067
9068 backend
9069 .plugin_contexts
9070 .borrow()
9071 .get("test")
9072 .unwrap()
9073 .clone()
9074 .with(|ctx| {
9075 let global = ctx.globals();
9076 let threw: bool = global.get("_threw").unwrap();
9077 assert!(threw);
9079 });
9080 }
9081
9082 struct ThemeCacheTestBridge {
9084 inner: TestServiceBridge,
9085 }
9086
9087 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9088 fn as_any(&self) -> &dyn std::any::Any {
9089 self
9090 }
9091 fn translate(
9092 &self,
9093 plugin_name: &str,
9094 key: &str,
9095 args: &HashMap<String, String>,
9096 ) -> String {
9097 self.inner.translate(plugin_name, key, args)
9098 }
9099 fn current_locale(&self) -> String {
9100 self.inner.current_locale()
9101 }
9102 fn set_js_execution_state(&self, state: String) {
9103 self.inner.set_js_execution_state(state);
9104 }
9105 fn clear_js_execution_state(&self) {
9106 self.inner.clear_js_execution_state();
9107 }
9108 fn get_theme_schema(&self) -> serde_json::Value {
9109 self.inner.get_theme_schema()
9110 }
9111 fn get_builtin_themes(&self) -> serde_json::Value {
9112 self.inner.get_builtin_themes()
9113 }
9114 fn get_all_themes(&self) -> serde_json::Value {
9115 self.inner.get_all_themes()
9116 }
9117 fn register_command(&self, command: fresh_core::command::Command) {
9118 self.inner.register_command(command);
9119 }
9120 fn unregister_command(&self, name: &str) {
9121 self.inner.unregister_command(name);
9122 }
9123 fn unregister_commands_by_prefix(&self, prefix: &str) {
9124 self.inner.unregister_commands_by_prefix(prefix);
9125 }
9126 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9127 self.inner.unregister_commands_by_plugin(plugin_name);
9128 }
9129 fn plugins_dir(&self) -> std::path::PathBuf {
9130 self.inner.plugins_dir()
9131 }
9132 fn config_dir(&self) -> std::path::PathBuf {
9133 self.inner.config_dir()
9134 }
9135 fn data_dir(&self) -> std::path::PathBuf {
9136 self.inner.data_dir()
9137 }
9138 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9139 if name == "test-theme" {
9140 Some(serde_json::json!({
9141 "name": "test-theme",
9142 "editor": {},
9143 "ui": {},
9144 "syntax": {}
9145 }))
9146 } else {
9147 None
9148 }
9149 }
9150 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9151 Err("test bridge does not support save".to_string())
9152 }
9153 fn theme_file_exists(&self, name: &str) -> bool {
9154 name == "test-theme"
9155 }
9156 }
9157
9158 #[test]
9161 fn test_api_close_buffer() {
9162 let (mut backend, rx) = create_test_backend();
9163
9164 backend
9165 .execute_js(
9166 r#"
9167 const editor = getEditor();
9168 editor.closeBuffer(3);
9169 "#,
9170 "test.js",
9171 )
9172 .unwrap();
9173
9174 let cmd = rx.try_recv().unwrap();
9175 match cmd {
9176 PluginCommand::CloseBuffer { buffer_id } => {
9177 assert_eq!(buffer_id.0, 3);
9178 }
9179 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9180 }
9181 }
9182
9183 #[test]
9184 fn test_api_focus_split() {
9185 let (mut backend, rx) = create_test_backend();
9186
9187 backend
9188 .execute_js(
9189 r#"
9190 const editor = getEditor();
9191 editor.focusSplit(2);
9192 "#,
9193 "test.js",
9194 )
9195 .unwrap();
9196
9197 let cmd = rx.try_recv().unwrap();
9198 match cmd {
9199 PluginCommand::FocusSplit { split_id } => {
9200 assert_eq!(split_id.0, 2);
9201 }
9202 _ => panic!("Expected FocusSplit, got {:?}", cmd),
9203 }
9204 }
9205
9206 #[test]
9210 fn test_api_session_lifecycle_dispatches_commands() {
9211 let (mut backend, rx) = create_test_backend();
9212
9213 backend
9214 .execute_js(
9215 r#"
9216 const editor = getEditor();
9217 editor.createWindow("/tmp/wt-feat", "feat");
9218 editor.setActiveWindow(7);
9219 editor.closeWindow(3);
9220 "#,
9221 "test.js",
9222 )
9223 .unwrap();
9224
9225 let create = rx.try_recv().unwrap();
9226 match create {
9227 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
9228 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
9229 assert_eq!(label, "feat");
9230 }
9231 other => panic!("Expected CreateWindow, got {:?}", other),
9232 }
9233
9234 let activate = rx.try_recv().unwrap();
9235 match activate {
9236 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
9237 assert_eq!(id, fresh_core::WindowId(7));
9238 }
9239 other => panic!("Expected SetActiveWindow, got {:?}", other),
9240 }
9241
9242 let close = rx.try_recv().unwrap();
9243 match close {
9244 fresh_core::api::PluginCommand::CloseWindow { id } => {
9245 assert_eq!(id, fresh_core::WindowId(3));
9246 }
9247 other => panic!("Expected CloseWindow, got {:?}", other),
9248 }
9249 }
9250
9251 #[test]
9255 fn test_api_list_sessions_reads_snapshot() {
9256 let (tx, _rx) = mpsc::channel();
9257 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9258
9259 {
9260 let mut state = state_snapshot.write().unwrap();
9261 state.windows = vec![
9262 fresh_core::api::WindowInfo {
9263 id: fresh_core::WindowId(1),
9264 label: "main".into(),
9265 root: std::path::PathBuf::from("/repo"),
9266 project_path: None,
9267 shared_worktree: false,
9268 },
9269 fresh_core::api::WindowInfo {
9270 id: fresh_core::WindowId(2),
9271 label: "feat-auth".into(),
9272 root: std::path::PathBuf::from("/wt/feat-auth"),
9273 project_path: None,
9274 shared_worktree: false,
9275 },
9276 ];
9277 state.active_window_id = fresh_core::WindowId(2);
9278 }
9279
9280 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9281 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9282
9283 backend
9284 .execute_js(
9285 r#"
9286 const editor = getEditor();
9287 const list = editor.listWindows();
9288 globalThis._sessionCount = list.length;
9289 globalThis._secondLabel = list[1].label;
9290 globalThis._secondRoot = list[1].root;
9291 globalThis._activeId = editor.activeWindow();
9292 "#,
9293 "test.js",
9294 )
9295 .unwrap();
9296
9297 backend
9298 .plugin_contexts
9299 .borrow()
9300 .get("test")
9301 .unwrap()
9302 .clone()
9303 .with(|ctx| {
9304 let global = ctx.globals();
9305 let count: u32 = global.get("_sessionCount").unwrap();
9306 let label: String = global.get("_secondLabel").unwrap();
9307 let root: String = global.get("_secondRoot").unwrap();
9308 let active: u32 = global.get("_activeId").unwrap();
9309 assert_eq!(count, 2);
9310 assert_eq!(label, "feat-auth");
9311 assert_eq!(root, "/wt/feat-auth");
9312 assert_eq!(active, 2);
9313 });
9314 }
9315
9316 #[test]
9317 fn test_api_list_buffers() {
9318 let (tx, _rx) = mpsc::channel();
9319 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9320
9321 {
9323 let mut state = state_snapshot.write().unwrap();
9324 state.buffers.insert(
9325 BufferId(0),
9326 BufferInfo {
9327 id: BufferId(0),
9328 path: Some(PathBuf::from("/test1.txt")),
9329 modified: false,
9330 length: 100,
9331 is_virtual: false,
9332 view_mode: "source".to_string(),
9333 is_composing_in_any_split: false,
9334 compose_width: None,
9335 language: "text".to_string(),
9336 is_preview: false,
9337 splits: Vec::new(),
9338 },
9339 );
9340 state.buffers.insert(
9341 BufferId(1),
9342 BufferInfo {
9343 id: BufferId(1),
9344 path: Some(PathBuf::from("/test2.txt")),
9345 modified: true,
9346 length: 200,
9347 is_virtual: false,
9348 view_mode: "source".to_string(),
9349 is_composing_in_any_split: false,
9350 compose_width: None,
9351 language: "text".to_string(),
9352 is_preview: false,
9353 splits: Vec::new(),
9354 },
9355 );
9356 }
9357
9358 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9359 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9360
9361 backend
9362 .execute_js(
9363 r#"
9364 const editor = getEditor();
9365 const buffers = editor.listBuffers();
9366 globalThis._isArray = Array.isArray(buffers);
9367 globalThis._length = buffers.length;
9368 "#,
9369 "test.js",
9370 )
9371 .unwrap();
9372
9373 backend
9374 .plugin_contexts
9375 .borrow()
9376 .get("test")
9377 .unwrap()
9378 .clone()
9379 .with(|ctx| {
9380 let global = ctx.globals();
9381 let is_array: bool = global.get("_isArray").unwrap();
9382 let length: u32 = global.get("_length").unwrap();
9383 assert!(is_array);
9384 assert_eq!(length, 2);
9385 });
9386 }
9387
9388 #[test]
9391 fn test_api_start_prompt() {
9392 let (mut backend, rx) = create_test_backend();
9393
9394 backend
9395 .execute_js(
9396 r#"
9397 const editor = getEditor();
9398 editor.startPrompt("Enter value:", "test-prompt");
9399 "#,
9400 "test.js",
9401 )
9402 .unwrap();
9403
9404 let cmd = rx.try_recv().unwrap();
9405 match cmd {
9406 PluginCommand::StartPrompt {
9407 label,
9408 prompt_type,
9409 floating_overlay,
9410 } => {
9411 assert_eq!(label, "Enter value:");
9412 assert_eq!(prompt_type, "test-prompt");
9413 assert!(!floating_overlay);
9414 }
9415 _ => panic!("Expected StartPrompt, got {:?}", cmd),
9416 }
9417 }
9418
9419 #[test]
9420 fn test_api_start_prompt_with_initial() {
9421 let (mut backend, rx) = create_test_backend();
9422
9423 backend
9424 .execute_js(
9425 r#"
9426 const editor = getEditor();
9427 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
9428 "#,
9429 "test.js",
9430 )
9431 .unwrap();
9432
9433 let cmd = rx.try_recv().unwrap();
9434 match cmd {
9435 PluginCommand::StartPromptWithInitial {
9436 label,
9437 prompt_type,
9438 initial_value,
9439 floating_overlay,
9440 } => {
9441 assert_eq!(label, "Enter value:");
9442 assert_eq!(prompt_type, "test-prompt");
9443 assert_eq!(initial_value, "default");
9444 assert!(!floating_overlay);
9445 }
9446 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
9447 }
9448 }
9449
9450 #[test]
9451 fn test_api_set_prompt_suggestions() {
9452 let (mut backend, rx) = create_test_backend();
9453
9454 backend
9455 .execute_js(
9456 r#"
9457 const editor = getEditor();
9458 editor.setPromptSuggestions([
9459 { text: "Option 1", value: "opt1" },
9460 { text: "Option 2", value: "opt2" }
9461 ]);
9462 "#,
9463 "test.js",
9464 )
9465 .unwrap();
9466
9467 let cmd = rx.try_recv().unwrap();
9468 match cmd {
9469 PluginCommand::SetPromptSuggestions { suggestions } => {
9470 assert_eq!(suggestions.len(), 2);
9471 assert_eq!(suggestions[0].text, "Option 1");
9472 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
9473 }
9474 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
9475 }
9476 }
9477
9478 #[test]
9481 fn test_api_get_active_buffer_id() {
9482 let (tx, _rx) = mpsc::channel();
9483 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9484
9485 {
9486 let mut state = state_snapshot.write().unwrap();
9487 state.active_buffer_id = BufferId(42);
9488 }
9489
9490 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9491 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9492
9493 backend
9494 .execute_js(
9495 r#"
9496 const editor = getEditor();
9497 globalThis._activeId = editor.getActiveBufferId();
9498 "#,
9499 "test.js",
9500 )
9501 .unwrap();
9502
9503 backend
9504 .plugin_contexts
9505 .borrow()
9506 .get("test")
9507 .unwrap()
9508 .clone()
9509 .with(|ctx| {
9510 let global = ctx.globals();
9511 let result: u32 = global.get("_activeId").unwrap();
9512 assert_eq!(result, 42);
9513 });
9514 }
9515
9516 #[test]
9517 fn test_api_get_active_split_id() {
9518 let (tx, _rx) = mpsc::channel();
9519 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9520
9521 {
9522 let mut state = state_snapshot.write().unwrap();
9523 state.active_split_id = 7;
9524 }
9525
9526 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9527 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9528
9529 backend
9530 .execute_js(
9531 r#"
9532 const editor = getEditor();
9533 globalThis._splitId = editor.getActiveSplitId();
9534 "#,
9535 "test.js",
9536 )
9537 .unwrap();
9538
9539 backend
9540 .plugin_contexts
9541 .borrow()
9542 .get("test")
9543 .unwrap()
9544 .clone()
9545 .with(|ctx| {
9546 let global = ctx.globals();
9547 let result: u32 = global.get("_splitId").unwrap();
9548 assert_eq!(result, 7);
9549 });
9550 }
9551
9552 #[test]
9555 fn test_api_file_exists() {
9556 let (mut backend, _rx) = create_test_backend();
9557
9558 backend
9559 .execute_js(
9560 r#"
9561 const editor = getEditor();
9562 // Test with a path that definitely exists
9563 globalThis._exists = editor.fileExists("/");
9564 "#,
9565 "test.js",
9566 )
9567 .unwrap();
9568
9569 backend
9570 .plugin_contexts
9571 .borrow()
9572 .get("test")
9573 .unwrap()
9574 .clone()
9575 .with(|ctx| {
9576 let global = ctx.globals();
9577 let result: bool = global.get("_exists").unwrap();
9578 assert!(result);
9579 });
9580 }
9581
9582 #[test]
9583 fn test_api_parse_jsonc() {
9584 let (mut backend, _rx) = create_test_backend();
9585
9586 backend
9587 .execute_js(
9588 r#"
9589 const editor = getEditor();
9590 // Comments, trailing commas, and nested structures should all parse.
9591 const parsed = editor.parseJsonc(`{
9592 // name of the container
9593 "name": "test",
9594 "features": {
9595 "docker-in-docker": {},
9596 },
9597 /* forwarded port list */
9598 "forwardPorts": [3000, 8080,],
9599 }`);
9600 globalThis._name = parsed.name;
9601 globalThis._featureCount = Object.keys(parsed.features).length;
9602 globalThis._portCount = parsed.forwardPorts.length;
9603
9604 // Invalid JSONC should throw.
9605 try {
9606 editor.parseJsonc("{ broken");
9607 globalThis._threw = false;
9608 } catch (_e) {
9609 globalThis._threw = true;
9610 }
9611 "#,
9612 "test.js",
9613 )
9614 .unwrap();
9615
9616 backend
9617 .plugin_contexts
9618 .borrow()
9619 .get("test")
9620 .unwrap()
9621 .clone()
9622 .with(|ctx| {
9623 let global = ctx.globals();
9624 let name: String = global.get("_name").unwrap();
9625 let feature_count: u32 = global.get("_featureCount").unwrap();
9626 let port_count: u32 = global.get("_portCount").unwrap();
9627 let threw: bool = global.get("_threw").unwrap();
9628 assert_eq!(name, "test");
9629 assert_eq!(feature_count, 1);
9630 assert_eq!(port_count, 2);
9631 assert!(threw, "Invalid JSONC should throw");
9632 });
9633 }
9634
9635 #[test]
9636 fn test_api_get_cwd() {
9637 let (mut backend, _rx) = create_test_backend();
9638
9639 backend
9640 .execute_js(
9641 r#"
9642 const editor = getEditor();
9643 globalThis._cwd = editor.getCwd();
9644 "#,
9645 "test.js",
9646 )
9647 .unwrap();
9648
9649 backend
9650 .plugin_contexts
9651 .borrow()
9652 .get("test")
9653 .unwrap()
9654 .clone()
9655 .with(|ctx| {
9656 let global = ctx.globals();
9657 let result: String = global.get("_cwd").unwrap();
9658 assert!(!result.is_empty());
9660 });
9661 }
9662
9663 #[test]
9664 fn test_api_get_env() {
9665 let (mut backend, _rx) = create_test_backend();
9666
9667 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
9669
9670 backend
9671 .execute_js(
9672 r#"
9673 const editor = getEditor();
9674 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
9675 "#,
9676 "test.js",
9677 )
9678 .unwrap();
9679
9680 backend
9681 .plugin_contexts
9682 .borrow()
9683 .get("test")
9684 .unwrap()
9685 .clone()
9686 .with(|ctx| {
9687 let global = ctx.globals();
9688 let result: Option<String> = global.get("_envVal").unwrap();
9689 assert_eq!(result, Some("test_value".to_string()));
9690 });
9691
9692 std::env::remove_var("TEST_PLUGIN_VAR");
9693 }
9694
9695 #[test]
9696 fn test_api_get_config() {
9697 let (mut backend, _rx) = create_test_backend();
9698
9699 backend
9700 .execute_js(
9701 r#"
9702 const editor = getEditor();
9703 const config = editor.getConfig();
9704 globalThis._isObject = typeof config === 'object';
9705 "#,
9706 "test.js",
9707 )
9708 .unwrap();
9709
9710 backend
9711 .plugin_contexts
9712 .borrow()
9713 .get("test")
9714 .unwrap()
9715 .clone()
9716 .with(|ctx| {
9717 let global = ctx.globals();
9718 let is_object: bool = global.get("_isObject").unwrap();
9719 assert!(is_object);
9721 });
9722 }
9723
9724 #[test]
9725 fn test_api_get_themes_dir() {
9726 let (mut backend, _rx) = create_test_backend();
9727
9728 backend
9729 .execute_js(
9730 r#"
9731 const editor = getEditor();
9732 globalThis._themesDir = editor.getThemesDir();
9733 "#,
9734 "test.js",
9735 )
9736 .unwrap();
9737
9738 backend
9739 .plugin_contexts
9740 .borrow()
9741 .get("test")
9742 .unwrap()
9743 .clone()
9744 .with(|ctx| {
9745 let global = ctx.globals();
9746 let result: String = global.get("_themesDir").unwrap();
9747 assert!(!result.is_empty());
9749 });
9750 }
9751
9752 #[test]
9755 fn test_api_read_dir() {
9756 let (mut backend, _rx) = create_test_backend();
9757
9758 backend
9759 .execute_js(
9760 r#"
9761 const editor = getEditor();
9762 const entries = editor.readDir("/tmp");
9763 globalThis._isArray = Array.isArray(entries);
9764 globalThis._length = entries.length;
9765 "#,
9766 "test.js",
9767 )
9768 .unwrap();
9769
9770 backend
9771 .plugin_contexts
9772 .borrow()
9773 .get("test")
9774 .unwrap()
9775 .clone()
9776 .with(|ctx| {
9777 let global = ctx.globals();
9778 let is_array: bool = global.get("_isArray").unwrap();
9779 let length: u32 = global.get("_length").unwrap();
9780 assert!(is_array);
9782 let _ = length;
9784 });
9785 }
9786
9787 #[test]
9790 fn test_api_execute_action() {
9791 let (mut backend, rx) = create_test_backend();
9792
9793 backend
9794 .execute_js(
9795 r#"
9796 const editor = getEditor();
9797 editor.executeAction("move_cursor_up");
9798 "#,
9799 "test.js",
9800 )
9801 .unwrap();
9802
9803 let cmd = rx.try_recv().unwrap();
9804 match cmd {
9805 PluginCommand::ExecuteAction { action_name } => {
9806 assert_eq!(action_name, "move_cursor_up");
9807 }
9808 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
9809 }
9810 }
9811
9812 #[test]
9815 fn test_api_debug() {
9816 let (mut backend, _rx) = create_test_backend();
9817
9818 backend
9820 .execute_js(
9821 r#"
9822 const editor = getEditor();
9823 editor.debug("Test debug message");
9824 editor.debug("Another message with special chars: <>&\"'");
9825 "#,
9826 "test.js",
9827 )
9828 .unwrap();
9829 }
9831
9832 #[test]
9835 fn test_typescript_preamble_generated() {
9836 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
9838 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
9839 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
9840 println!(
9841 "Generated {} bytes of TypeScript preamble",
9842 JSEDITORAPI_TS_PREAMBLE.len()
9843 );
9844 }
9845
9846 #[test]
9847 fn test_typescript_editor_api_generated() {
9848 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
9850 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
9851 println!(
9852 "Generated {} bytes of EditorAPI interface",
9853 JSEDITORAPI_TS_EDITOR_API.len()
9854 );
9855 }
9856
9857 #[test]
9858 fn test_js_methods_list() {
9859 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
9861 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
9862 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
9864 if i < 20 {
9865 println!(" - {}", method);
9866 }
9867 }
9868 if JSEDITORAPI_JS_METHODS.len() > 20 {
9869 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
9870 }
9871 }
9872
9873 #[test]
9876 fn test_api_load_plugin_sends_command() {
9877 let (mut backend, rx) = create_test_backend();
9878
9879 backend
9881 .execute_js(
9882 r#"
9883 const editor = getEditor();
9884 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
9885 "#,
9886 "test.js",
9887 )
9888 .unwrap();
9889
9890 let cmd = rx.try_recv().unwrap();
9892 match cmd {
9893 PluginCommand::LoadPlugin { path, callback_id } => {
9894 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
9895 assert!(callback_id.0 > 0); }
9897 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
9898 }
9899 }
9900
9901 #[test]
9902 fn test_api_unload_plugin_sends_command() {
9903 let (mut backend, rx) = create_test_backend();
9904
9905 backend
9907 .execute_js(
9908 r#"
9909 const editor = getEditor();
9910 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
9911 "#,
9912 "test.js",
9913 )
9914 .unwrap();
9915
9916 let cmd = rx.try_recv().unwrap();
9918 match cmd {
9919 PluginCommand::UnloadPlugin { name, callback_id } => {
9920 assert_eq!(name, "my-plugin");
9921 assert!(callback_id.0 > 0); }
9923 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
9924 }
9925 }
9926
9927 #[test]
9928 fn test_api_reload_plugin_sends_command() {
9929 let (mut backend, rx) = create_test_backend();
9930
9931 backend
9933 .execute_js(
9934 r#"
9935 const editor = getEditor();
9936 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
9937 "#,
9938 "test.js",
9939 )
9940 .unwrap();
9941
9942 let cmd = rx.try_recv().unwrap();
9944 match cmd {
9945 PluginCommand::ReloadPlugin { name, callback_id } => {
9946 assert_eq!(name, "my-plugin");
9947 assert!(callback_id.0 > 0); }
9949 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
9950 }
9951 }
9952
9953 #[test]
9954 fn test_api_load_plugin_resolves_callback() {
9955 let (mut backend, rx) = create_test_backend();
9956
9957 backend
9959 .execute_js(
9960 r#"
9961 const editor = getEditor();
9962 globalThis._loadResult = null;
9963 editor.loadPlugin("/path/to/plugin.ts").then(result => {
9964 globalThis._loadResult = result;
9965 });
9966 "#,
9967 "test.js",
9968 )
9969 .unwrap();
9970
9971 let callback_id = match rx.try_recv().unwrap() {
9973 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
9974 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
9975 };
9976
9977 backend.resolve_callback(callback_id, "true");
9979
9980 backend
9982 .plugin_contexts
9983 .borrow()
9984 .get("test")
9985 .unwrap()
9986 .clone()
9987 .with(|ctx| {
9988 run_pending_jobs_checked(&ctx, "test async loadPlugin");
9989 });
9990
9991 backend
9993 .plugin_contexts
9994 .borrow()
9995 .get("test")
9996 .unwrap()
9997 .clone()
9998 .with(|ctx| {
9999 let global = ctx.globals();
10000 let result: bool = global.get("_loadResult").unwrap();
10001 assert!(result);
10002 });
10003 }
10004
10005 #[test]
10006 fn test_api_version() {
10007 let (mut backend, _rx) = create_test_backend();
10008
10009 backend
10010 .execute_js(
10011 r#"
10012 const editor = getEditor();
10013 globalThis._apiVersion = editor.apiVersion();
10014 "#,
10015 "test.js",
10016 )
10017 .unwrap();
10018
10019 backend
10020 .plugin_contexts
10021 .borrow()
10022 .get("test")
10023 .unwrap()
10024 .clone()
10025 .with(|ctx| {
10026 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10027 assert_eq!(version, 2);
10028 });
10029 }
10030
10031 #[test]
10032 fn test_api_unload_plugin_rejects_on_error() {
10033 let (mut backend, rx) = create_test_backend();
10034
10035 backend
10037 .execute_js(
10038 r#"
10039 const editor = getEditor();
10040 globalThis._unloadError = null;
10041 editor.unloadPlugin("nonexistent-plugin").catch(err => {
10042 globalThis._unloadError = err.message || String(err);
10043 });
10044 "#,
10045 "test.js",
10046 )
10047 .unwrap();
10048
10049 let callback_id = match rx.try_recv().unwrap() {
10051 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10052 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10053 };
10054
10055 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10057
10058 backend
10060 .plugin_contexts
10061 .borrow()
10062 .get("test")
10063 .unwrap()
10064 .clone()
10065 .with(|ctx| {
10066 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10067 });
10068
10069 backend
10071 .plugin_contexts
10072 .borrow()
10073 .get("test")
10074 .unwrap()
10075 .clone()
10076 .with(|ctx| {
10077 let global = ctx.globals();
10078 let error: String = global.get("_unloadError").unwrap();
10079 assert!(error.contains("nonexistent-plugin"));
10080 });
10081 }
10082
10083 #[test]
10084 fn test_api_set_global_state() {
10085 let (mut backend, rx) = create_test_backend();
10086
10087 backend
10088 .execute_js(
10089 r#"
10090 const editor = getEditor();
10091 editor.setGlobalState("myKey", { enabled: true, count: 42 });
10092 "#,
10093 "test_plugin.js",
10094 )
10095 .unwrap();
10096
10097 let cmd = rx.try_recv().unwrap();
10098 match cmd {
10099 PluginCommand::SetGlobalState {
10100 plugin_name,
10101 key,
10102 value,
10103 } => {
10104 assert_eq!(plugin_name, "test_plugin");
10105 assert_eq!(key, "myKey");
10106 let v = value.unwrap();
10107 assert_eq!(v["enabled"], serde_json::json!(true));
10108 assert_eq!(v["count"], serde_json::json!(42));
10109 }
10110 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10111 }
10112 }
10113
10114 #[test]
10115 fn test_api_set_global_state_delete() {
10116 let (mut backend, rx) = create_test_backend();
10117
10118 backend
10119 .execute_js(
10120 r#"
10121 const editor = getEditor();
10122 editor.setGlobalState("myKey", null);
10123 "#,
10124 "test_plugin.js",
10125 )
10126 .unwrap();
10127
10128 let cmd = rx.try_recv().unwrap();
10129 match cmd {
10130 PluginCommand::SetGlobalState {
10131 plugin_name,
10132 key,
10133 value,
10134 } => {
10135 assert_eq!(plugin_name, "test_plugin");
10136 assert_eq!(key, "myKey");
10137 assert!(value.is_none(), "null should delete the key");
10138 }
10139 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10140 }
10141 }
10142
10143 #[test]
10144 fn test_api_get_global_state_roundtrip() {
10145 let (mut backend, _rx) = create_test_backend();
10146
10147 backend
10149 .execute_js(
10150 r#"
10151 const editor = getEditor();
10152 editor.setGlobalState("flag", true);
10153 globalThis._result = editor.getGlobalState("flag");
10154 "#,
10155 "test_plugin.js",
10156 )
10157 .unwrap();
10158
10159 backend
10160 .plugin_contexts
10161 .borrow()
10162 .get("test_plugin")
10163 .unwrap()
10164 .clone()
10165 .with(|ctx| {
10166 let global = ctx.globals();
10167 let result: bool = global.get("_result").unwrap();
10168 assert!(
10169 result,
10170 "getGlobalState should return the value set by setGlobalState"
10171 );
10172 });
10173 }
10174
10175 #[test]
10180 fn test_api_set_session_state_roundtrip() {
10181 let (mut backend, _rx) = create_test_backend();
10182
10183 backend
10184 .execute_js(
10185 r#"
10186 const editor = getEditor();
10187 editor.setWindowState("draft", { count: 7 });
10188 globalThis._result = editor.getWindowState("draft");
10189 globalThis._missing = editor.getWindowState("absent");
10190 "#,
10191 "test_plugin.js",
10192 )
10193 .unwrap();
10194
10195 backend
10196 .plugin_contexts
10197 .borrow()
10198 .get("test_plugin")
10199 .unwrap()
10200 .clone()
10201 .with(|ctx| {
10202 let global = ctx.globals();
10203 let count: i64 = global
10204 .get::<_, rquickjs::Object>("_result")
10205 .unwrap()
10206 .get("count")
10207 .unwrap();
10208 assert_eq!(
10209 count, 7,
10210 "getWindowState should return the value set by setWindowState"
10211 );
10212 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
10213 assert!(
10214 missing.is_undefined(),
10215 "getWindowState for an unset key must be undefined"
10216 );
10217 });
10218 }
10219
10220 #[test]
10221 fn test_api_get_global_state_missing_key() {
10222 let (mut backend, _rx) = create_test_backend();
10223
10224 backend
10225 .execute_js(
10226 r#"
10227 const editor = getEditor();
10228 globalThis._result = editor.getGlobalState("nonexistent");
10229 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
10230 "#,
10231 "test_plugin.js",
10232 )
10233 .unwrap();
10234
10235 backend
10236 .plugin_contexts
10237 .borrow()
10238 .get("test_plugin")
10239 .unwrap()
10240 .clone()
10241 .with(|ctx| {
10242 let global = ctx.globals();
10243 let is_undefined: bool = global.get("_isUndefined").unwrap();
10244 assert!(
10245 is_undefined,
10246 "getGlobalState for missing key should return undefined"
10247 );
10248 });
10249 }
10250
10251 #[test]
10252 fn test_api_global_state_isolation_between_plugins() {
10253 let (tx, _rx) = mpsc::channel();
10255 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10256 let services = Arc::new(TestServiceBridge::new());
10257
10258 let mut backend_a =
10260 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10261 .unwrap();
10262 backend_a
10263 .execute_js(
10264 r#"
10265 const editor = getEditor();
10266 editor.setGlobalState("flag", "from_plugin_a");
10267 "#,
10268 "plugin_a.js",
10269 )
10270 .unwrap();
10271
10272 let mut backend_b =
10274 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10275 .unwrap();
10276 backend_b
10277 .execute_js(
10278 r#"
10279 const editor = getEditor();
10280 editor.setGlobalState("flag", "from_plugin_b");
10281 "#,
10282 "plugin_b.js",
10283 )
10284 .unwrap();
10285
10286 backend_a
10288 .execute_js(
10289 r#"
10290 const editor = getEditor();
10291 globalThis._aValue = editor.getGlobalState("flag");
10292 "#,
10293 "plugin_a.js",
10294 )
10295 .unwrap();
10296
10297 backend_a
10298 .plugin_contexts
10299 .borrow()
10300 .get("plugin_a")
10301 .unwrap()
10302 .clone()
10303 .with(|ctx| {
10304 let global = ctx.globals();
10305 let a_value: String = global.get("_aValue").unwrap();
10306 assert_eq!(
10307 a_value, "from_plugin_a",
10308 "Plugin A should see its own value, not plugin B's"
10309 );
10310 });
10311
10312 backend_b
10314 .execute_js(
10315 r#"
10316 const editor = getEditor();
10317 globalThis._bValue = editor.getGlobalState("flag");
10318 "#,
10319 "plugin_b.js",
10320 )
10321 .unwrap();
10322
10323 backend_b
10324 .plugin_contexts
10325 .borrow()
10326 .get("plugin_b")
10327 .unwrap()
10328 .clone()
10329 .with(|ctx| {
10330 let global = ctx.globals();
10331 let b_value: String = global.get("_bValue").unwrap();
10332 assert_eq!(
10333 b_value, "from_plugin_b",
10334 "Plugin B should see its own value, not plugin A's"
10335 );
10336 });
10337 }
10338
10339 #[test]
10340 fn test_register_command_collision_different_plugins() {
10341 let (mut backend, _rx) = create_test_backend();
10342
10343 backend
10345 .execute_js(
10346 r#"
10347 const editor = getEditor();
10348 globalThis.handlerA = function() { };
10349 editor.registerCommand("My Command", "From A", "handlerA", null);
10350 "#,
10351 "plugin_a.js",
10352 )
10353 .unwrap();
10354
10355 let result = backend.execute_js(
10357 r#"
10358 const editor = getEditor();
10359 globalThis.handlerB = function() { };
10360 editor.registerCommand("My Command", "From B", "handlerB", null);
10361 "#,
10362 "plugin_b.js",
10363 );
10364
10365 assert!(
10366 result.is_err(),
10367 "Second plugin registering the same command name should fail"
10368 );
10369 let err_msg = result.unwrap_err().to_string();
10370 assert!(
10371 err_msg.contains("already registered"),
10372 "Error should mention collision: {}",
10373 err_msg
10374 );
10375 }
10376
10377 #[test]
10378 fn test_register_command_same_plugin_allowed() {
10379 let (mut backend, _rx) = create_test_backend();
10380
10381 backend
10383 .execute_js(
10384 r#"
10385 const editor = getEditor();
10386 globalThis.handler1 = function() { };
10387 editor.registerCommand("My Command", "Version 1", "handler1", null);
10388 globalThis.handler2 = function() { };
10389 editor.registerCommand("My Command", "Version 2", "handler2", null);
10390 "#,
10391 "plugin_a.js",
10392 )
10393 .unwrap();
10394 }
10395
10396 #[test]
10397 fn test_register_command_after_unregister() {
10398 let (mut backend, _rx) = create_test_backend();
10399
10400 backend
10402 .execute_js(
10403 r#"
10404 const editor = getEditor();
10405 globalThis.handlerA = function() { };
10406 editor.registerCommand("My Command", "From A", "handlerA", null);
10407 editor.unregisterCommand("My Command");
10408 "#,
10409 "plugin_a.js",
10410 )
10411 .unwrap();
10412
10413 backend
10415 .execute_js(
10416 r#"
10417 const editor = getEditor();
10418 globalThis.handlerB = function() { };
10419 editor.registerCommand("My Command", "From B", "handlerB", null);
10420 "#,
10421 "plugin_b.js",
10422 )
10423 .unwrap();
10424 }
10425
10426 #[test]
10427 fn test_register_command_collision_caught_in_try_catch() {
10428 let (mut backend, _rx) = create_test_backend();
10429
10430 backend
10432 .execute_js(
10433 r#"
10434 const editor = getEditor();
10435 globalThis.handlerA = function() { };
10436 editor.registerCommand("My Command", "From A", "handlerA", null);
10437 "#,
10438 "plugin_a.js",
10439 )
10440 .unwrap();
10441
10442 backend
10444 .execute_js(
10445 r#"
10446 const editor = getEditor();
10447 globalThis.handlerB = function() { };
10448 let caught = false;
10449 try {
10450 editor.registerCommand("My Command", "From B", "handlerB", null);
10451 } catch (e) {
10452 caught = true;
10453 }
10454 if (!caught) throw new Error("Expected collision error");
10455 "#,
10456 "plugin_b.js",
10457 )
10458 .unwrap();
10459 }
10460
10461 #[test]
10462 fn test_register_command_i18n_key_no_collision_across_plugins() {
10463 let (mut backend, _rx) = create_test_backend();
10464
10465 backend
10467 .execute_js(
10468 r#"
10469 const editor = getEditor();
10470 globalThis.handlerA = function() { };
10471 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
10472 "#,
10473 "plugin_a.js",
10474 )
10475 .unwrap();
10476
10477 backend
10480 .execute_js(
10481 r#"
10482 const editor = getEditor();
10483 globalThis.handlerB = function() { };
10484 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
10485 "#,
10486 "plugin_b.js",
10487 )
10488 .unwrap();
10489 }
10490
10491 #[test]
10492 fn test_register_command_non_i18n_still_collides() {
10493 let (mut backend, _rx) = create_test_backend();
10494
10495 backend
10497 .execute_js(
10498 r#"
10499 const editor = getEditor();
10500 globalThis.handlerA = function() { };
10501 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
10502 "#,
10503 "plugin_a.js",
10504 )
10505 .unwrap();
10506
10507 let result = backend.execute_js(
10509 r#"
10510 const editor = getEditor();
10511 globalThis.handlerB = function() { };
10512 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
10513 "#,
10514 "plugin_b.js",
10515 );
10516
10517 assert!(
10518 result.is_err(),
10519 "Non-%-prefixed names should still collide across plugins"
10520 );
10521 }
10522}