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 match i32::try_from(i) {
209 Ok(small) => Ok(Value::new_int(ctx.clone(), small)),
210 Err(_) => Ok(Value::new_float(ctx.clone(), i as f64)),
211 }
212 } else if let Some(f) = n.as_f64() {
213 Ok(Value::new_float(ctx.clone(), f))
214 } else {
215 Ok(Value::new_null(ctx.clone()))
216 }
217 }
218 serde_json::Value::String(s) => {
219 let js_str = rquickjs::String::from_str(ctx.clone(), s)?;
220 Ok(js_str.into_value())
221 }
222 serde_json::Value::Array(arr) => {
223 let js_arr = rquickjs::Array::new(ctx.clone())?;
224 for (i, item) in arr.iter().enumerate() {
225 let js_val = json_to_js_value(ctx, item)?;
226 js_arr.set(i, js_val)?;
227 }
228 Ok(js_arr.into_value())
229 }
230 serde_json::Value::Object(map) => {
231 let obj = rquickjs::Object::new(ctx.clone())?;
232 for (key, val) in map {
233 let js_val = json_to_js_value(ctx, val)?;
234 obj.set(key.as_str(), js_val)?;
235 }
236 Ok(obj.into_value())
237 }
238 }
239}
240
241fn call_handler(ctx: &rquickjs::Ctx<'_>, handler_name: &str, event_data: &serde_json::Value) {
244 let js_data = match json_to_js_value(ctx, event_data) {
245 Ok(v) => v,
246 Err(e) => {
247 log_js_error(ctx, e, &format!("handler {} data conversion", handler_name));
248 return;
249 }
250 };
251
252 let globals = ctx.globals();
253 let Ok(func) = globals.get::<_, rquickjs::Function>(handler_name) else {
254 return;
255 };
256
257 match func.call::<_, rquickjs::Value>((js_data,)) {
258 Ok(result) => attach_promise_catch(ctx, &globals, handler_name, result),
259 Err(e) => log_js_error(ctx, e, &format!("handler {}", handler_name)),
260 }
261
262 run_pending_jobs_checked(ctx, &format!("emit handler {}", handler_name));
263}
264
265fn attach_promise_catch<'js>(
267 ctx: &rquickjs::Ctx<'js>,
268 globals: &rquickjs::Object<'js>,
269 handler_name: &str,
270 result: rquickjs::Value<'js>,
271) {
272 let Some(obj) = result.as_object() else {
273 return;
274 };
275 if obj.get::<_, rquickjs::Function>("then").is_err() {
276 return;
277 }
278 let _ = globals.set("__pendingPromise", result);
279 let catch_code = format!(
280 r#"globalThis.__pendingPromise.catch(function(e) {{
281 console.error('Handler {} async error:', e);
282 throw e;
283 }}); delete globalThis.__pendingPromise;"#,
284 handler_name
285 );
286 let _ = ctx.eval::<(), _>(catch_code.as_bytes());
287}
288
289fn get_text_properties_at_cursor_typed(
291 snapshot: &Arc<RwLock<EditorStateSnapshot>>,
292 buffer_id: u32,
293) -> fresh_core::api::TextPropertiesAtCursor {
294 use fresh_core::api::TextPropertiesAtCursor;
295
296 let snap = match snapshot.read() {
297 Ok(s) => s,
298 Err(_) => return TextPropertiesAtCursor(Vec::new()),
299 };
300 let buffer_id_typed = BufferId(buffer_id as usize);
301 let snapshot_pos = snap.buffer_cursor_positions.get(&buffer_id_typed).copied();
302 let fallback_pos = if snap.active_buffer_id == buffer_id_typed {
303 snap.primary_cursor.as_ref().map(|c| c.position)
304 } else {
305 None
306 };
307 let cursor_pos = match snapshot_pos.or(fallback_pos) {
308 Some(pos) => pos,
309 None => {
310 tracing::debug!(
311 "getTextPropertiesAtCursor({:?}): no cursor (snapshot_pos={:?}, active_buffer={:?})",
312 buffer_id_typed,
313 snapshot_pos,
314 snap.active_buffer_id
315 );
316 return TextPropertiesAtCursor(Vec::new());
317 }
318 };
319
320 let properties = match snap.buffer_text_properties.get(&buffer_id_typed) {
321 Some(p) => p,
322 None => {
323 tracing::debug!(
324 "getTextPropertiesAtCursor({:?}): no text_properties in snapshot (cursor_pos={})",
325 buffer_id_typed,
326 cursor_pos
327 );
328 return TextPropertiesAtCursor(Vec::new());
329 }
330 };
331
332 let result: Vec<_> = properties
333 .iter()
334 .filter(|prop| prop.start <= cursor_pos && cursor_pos < prop.end)
335 .map(|prop| prop.properties.clone())
336 .collect();
337
338 tracing::debug!(
339 "getTextPropertiesAtCursor({:?}): cursor_pos={} (snapshot_pos={:?}, fallback_pos={:?}, active_buffer={:?}), total_props={}, matched={}",
340 buffer_id_typed,
341 cursor_pos,
342 snapshot_pos,
343 fallback_pos,
344 snap.active_buffer_id,
345 properties.len(),
346 result.len()
347 );
348
349 TextPropertiesAtCursor(result)
350}
351
352fn js_value_to_string(ctx: &rquickjs::Ctx<'_>, val: &Value<'_>) -> String {
354 use rquickjs::Type;
355 match val.type_of() {
356 Type::Null => "null".to_string(),
357 Type::Undefined => "undefined".to_string(),
358 Type::Bool => val.as_bool().map(|b| b.to_string()).unwrap_or_default(),
359 Type::Int => val.as_int().map(|n| n.to_string()).unwrap_or_default(),
360 Type::Float => val.as_float().map(|f| f.to_string()).unwrap_or_default(),
361 Type::String => val
362 .as_string()
363 .and_then(|s| s.to_string().ok())
364 .unwrap_or_default(),
365 Type::Object | Type::Exception => {
366 if let Some(obj) = val.as_object() {
368 let name: Option<String> = obj.get("name").ok();
370 let message: Option<String> = obj.get("message").ok();
371 let stack: Option<String> = obj.get("stack").ok();
372
373 if message.is_some() || name.is_some() {
374 let name = name.unwrap_or_else(|| "Error".to_string());
376 let message = message.unwrap_or_default();
377 if let Some(stack) = stack {
378 return format!("{}: {}\n{}", name, message, stack);
379 } else {
380 return format!("{}: {}", name, message);
381 }
382 }
383
384 let json = js_to_json(ctx, val.clone());
386 serde_json::to_string(&json).unwrap_or_else(|_| "[object]".to_string())
387 } else {
388 "[object]".to_string()
389 }
390 }
391 Type::Array => {
392 let json = js_to_json(ctx, val.clone());
393 serde_json::to_string(&json).unwrap_or_else(|_| "[array]".to_string())
394 }
395 Type::Function | Type::Constructor => "[function]".to_string(),
396 Type::Symbol => "[symbol]".to_string(),
397 Type::BigInt => val
398 .as_big_int()
399 .and_then(|b| b.clone().to_i64().ok())
400 .map(|n| n.to_string())
401 .unwrap_or_else(|| "[bigint]".to_string()),
402 _ => format!("[{}]", val.type_name()),
403 }
404}
405
406fn format_js_error(
408 ctx: &rquickjs::Ctx<'_>,
409 err: rquickjs::Error,
410 source_name: &str,
411) -> anyhow::Error {
412 if err.is_exception() {
414 let exc = ctx.catch();
416 if !exc.is_undefined() && !exc.is_null() {
417 if let Some(exc_obj) = exc.as_object() {
419 let message: String = exc_obj
420 .get::<_, String>("message")
421 .unwrap_or_else(|_| "Unknown error".to_string());
422 let stack: String = exc_obj.get::<_, String>("stack").unwrap_or_default();
423 let name: String = exc_obj
424 .get::<_, String>("name")
425 .unwrap_or_else(|_| "Error".to_string());
426
427 if !stack.is_empty() {
428 return anyhow::anyhow!(
429 "JS error in {}: {}: {}\nStack trace:\n{}",
430 source_name,
431 name,
432 message,
433 stack
434 );
435 } else {
436 return anyhow::anyhow!("JS error in {}: {}: {}", source_name, name, message);
437 }
438 } else {
439 let exc_str: String = exc
441 .as_string()
442 .and_then(|s: &rquickjs::String| s.to_string().ok())
443 .unwrap_or_else(|| format!("{:?}", exc));
444 return anyhow::anyhow!("JS error in {}: {}", source_name, exc_str);
445 }
446 }
447 }
448
449 anyhow::anyhow!("JS error in {}: {}", source_name, err)
451}
452
453fn log_js_error(ctx: &rquickjs::Ctx<'_>, err: rquickjs::Error, context: &str) {
456 let error = format_js_error(ctx, err, context);
457 tracing::error!("{}", error);
458
459 if should_panic_on_js_errors() {
461 panic!("JavaScript error in {}: {}", context, error);
462 }
463}
464
465static PANIC_ON_JS_ERRORS: std::sync::atomic::AtomicBool =
467 std::sync::atomic::AtomicBool::new(false);
468
469pub fn set_panic_on_js_errors(enabled: bool) {
471 PANIC_ON_JS_ERRORS.store(enabled, std::sync::atomic::Ordering::SeqCst);
472}
473
474fn should_panic_on_js_errors() -> bool {
476 PANIC_ON_JS_ERRORS.load(std::sync::atomic::Ordering::SeqCst)
477}
478
479static FATAL_JS_ERROR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
483
484static FATAL_JS_ERROR_MSG: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
486
487fn set_fatal_js_error(msg: String) {
489 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
490 if guard.is_none() {
491 *guard = Some(msg);
493 }
494 }
495 FATAL_JS_ERROR.store(true, std::sync::atomic::Ordering::SeqCst);
496}
497
498pub fn has_fatal_js_error() -> bool {
500 FATAL_JS_ERROR.load(std::sync::atomic::Ordering::SeqCst)
501}
502
503pub fn take_fatal_js_error() -> Option<String> {
505 if !FATAL_JS_ERROR.swap(false, std::sync::atomic::Ordering::SeqCst) {
506 return None;
507 }
508 if let Ok(mut guard) = FATAL_JS_ERROR_MSG.write() {
509 guard.take()
510 } else {
511 Some("Fatal JS error (message unavailable)".to_string())
512 }
513}
514
515fn run_pending_jobs_checked(ctx: &rquickjs::Ctx<'_>, context: &str) -> usize {
518 let mut count = 0;
519 loop {
520 let exc: rquickjs::Value = ctx.catch();
522 if exc.is_exception() {
524 let error_msg = if let Some(err) = exc.as_exception() {
525 format!(
526 "{}: {}",
527 err.message().unwrap_or_default(),
528 err.stack().unwrap_or_default()
529 )
530 } else {
531 format!("{:?}", exc)
532 };
533 tracing::error!("Unhandled JS exception during {}: {}", context, error_msg);
534 if should_panic_on_js_errors() {
535 panic!("Unhandled JS exception during {}: {}", context, error_msg);
536 }
537 }
538
539 if !ctx.execute_pending_job() {
540 break;
541 }
542 count += 1;
543 }
544
545 let exc: rquickjs::Value = ctx.catch();
547 if exc.is_exception() {
548 let error_msg = if let Some(err) = exc.as_exception() {
549 format!(
550 "{}: {}",
551 err.message().unwrap_or_default(),
552 err.stack().unwrap_or_default()
553 )
554 } else {
555 format!("{:?}", exc)
556 };
557 tracing::error!(
558 "Unhandled JS exception after running jobs in {}: {}",
559 context,
560 error_msg
561 );
562 if should_panic_on_js_errors() {
563 panic!(
564 "Unhandled JS exception after running jobs in {}: {}",
565 context, error_msg
566 );
567 }
568 }
569
570 count
571}
572
573fn parse_text_property_entry(
575 ctx: &rquickjs::Ctx<'_>,
576 obj: &Object<'_>,
577) -> Option<TextPropertyEntry> {
578 let text: String = obj.get("text").ok()?;
579 let properties: HashMap<String, serde_json::Value> = obj
580 .get::<_, Object>("properties")
581 .ok()
582 .map(|props_obj| {
583 let mut map = HashMap::new();
584 for key in props_obj.keys::<String>().flatten() {
585 if let Ok(v) = props_obj.get::<_, Value>(&key) {
586 map.insert(key, js_to_json(ctx, v));
587 }
588 }
589 map
590 })
591 .unwrap_or_default();
592
593 let style: Option<fresh_core::api::OverlayOptions> =
595 obj.get::<_, Object>("style").ok().and_then(|style_obj| {
596 let json_val = js_to_json(ctx, Value::from_object(style_obj));
597 serde_json::from_value(json_val).ok()
598 });
599
600 let inline_overlays: Vec<fresh_core::text_property::InlineOverlay> = obj
602 .get::<_, rquickjs::Array>("inlineOverlays")
603 .ok()
604 .map(|arr| {
605 arr.iter::<Object>()
606 .flatten()
607 .filter_map(|item| {
608 let json_val = js_to_json(ctx, Value::from_object(item));
609 serde_json::from_value(json_val).ok()
610 })
611 .collect()
612 })
613 .unwrap_or_default();
614
615 let pad_to_chars: Option<u32> = obj
616 .get::<_, f64>("padToChars")
617 .ok()
618 .map(|v| v.max(0.0) as u32);
619 let truncate_to_chars: Option<u32> = obj
620 .get::<_, f64>("truncateToChars")
621 .ok()
622 .map(|v| v.max(0.0) as u32);
623
624 let segments: Vec<fresh_core::text_property::StyledSegment> = obj
625 .get::<_, rquickjs::Array>("segments")
626 .ok()
627 .map(|arr| {
628 arr.iter::<Object>()
629 .flatten()
630 .filter_map(|item| {
631 let json_val = js_to_json(ctx, Value::from_object(item));
632 serde_json::from_value(json_val).ok()
633 })
634 .collect()
635 })
636 .unwrap_or_default();
637
638 Some(TextPropertyEntry {
639 text,
640 properties,
641 style,
642 inline_overlays,
643 segments,
644 pad_to_chars,
645 truncate_to_chars,
646 })
647}
648
649pub type PendingResponses =
651 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<PluginResponse>>>>;
652
653#[derive(Debug, Clone)]
655pub struct TsPluginInfo {
656 pub name: String,
657 pub path: PathBuf,
658 pub enabled: bool,
659 pub declarations: Option<String>,
666}
667
668#[derive(Debug, Clone, Default)]
674pub struct PluginTrackedState {
675 pub overlay_namespaces: Vec<(BufferId, String)>,
677 pub virtual_line_namespaces: Vec<(BufferId, String)>,
679 pub line_indicator_namespaces: Vec<(BufferId, String)>,
681 pub virtual_text_ids: Vec<(BufferId, String)>,
683 pub file_explorer_namespaces: Vec<String>,
685 pub contexts_set: Vec<String>,
687 pub background_process_ids: Vec<u64>,
690 pub scroll_sync_group_ids: Vec<u32>,
692 pub virtual_buffer_ids: Vec<BufferId>,
694 pub composite_buffer_ids: Vec<BufferId>,
696 pub terminal_ids: Vec<fresh_core::TerminalId>,
698 pub watch_handles: Vec<u64>,
702}
703
704pub type AsyncResourceOwners = Arc<std::sync::Mutex<HashMap<u64, String>>>;
709
710pub type EventHandlerRegistry = Arc<RwLock<HashMap<String, Vec<PluginHandler>>>>;
718
719#[derive(Debug, Clone)]
720pub struct PluginHandler {
721 pub plugin_name: String,
722 pub handler_name: String,
723}
724
725fn parse_animation_rect(
728 obj: &rquickjs::Object<'_>,
729) -> rquickjs::Result<fresh_core::api::AnimationRect> {
730 Ok(fresh_core::api::AnimationRect {
731 x: obj.get::<_, u16>("x").unwrap_or(0),
732 y: obj.get::<_, u16>("y").unwrap_or(0),
733 width: obj.get::<_, u16>("width").unwrap_or(0),
734 height: obj.get::<_, u16>("height").unwrap_or(0),
735 })
736}
737
738fn parse_animation_kind(
742 obj: &rquickjs::Object<'_>,
743) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
744 use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
745 let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
746 match kind.as_str() {
747 "slideIn" | "" => {
748 let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
749 let from = match from_str.as_str() {
750 "top" => PluginAnimationEdge::Top,
751 "left" => PluginAnimationEdge::Left,
752 "right" => PluginAnimationEdge::Right,
753 _ => PluginAnimationEdge::Bottom,
754 };
755 let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
756 let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
757 Ok(PluginAnimationKind::SlideIn {
758 from,
759 duration_ms,
760 delay_ms,
761 })
762 }
763 other => Err(rquickjs::Error::new_from_js_message(
764 "string",
765 "PluginAnimationKind",
766 format!("unknown animation kind: {}", other),
767 )),
768 }
769}
770
771#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
774#[rquickjs::class]
775pub struct JsEditorApi {
776 #[qjs(skip_trace)]
777 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
778 #[qjs(skip_trace)]
779 command_sender: mpsc::Sender<PluginCommand>,
780 #[qjs(skip_trace)]
781 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
782 #[qjs(skip_trace)]
783 event_handlers: EventHandlerRegistry,
784 #[qjs(skip_trace)]
785 next_request_id: Rc<RefCell<u64>>,
786 #[qjs(skip_trace)]
787 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
788 #[qjs(skip_trace)]
789 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
790 #[qjs(skip_trace)]
791 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
792 #[qjs(skip_trace)]
793 async_resource_owners: AsyncResourceOwners,
794 #[qjs(skip_trace)]
796 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
797 #[qjs(skip_trace)]
799 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
800 #[qjs(skip_trace)]
802 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
803 #[qjs(skip_trace)]
805 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
806 #[qjs(skip_trace)]
810 plugin_api_exports: PluginApiExports,
811 #[qjs(skip_trace)]
815 search_handles: SearchHandleRegistry,
816 pub plugin_name: String,
817}
818
819fn throw_js<'js>(ctx: &rquickjs::Ctx<'js>, msg: &str) -> rquickjs::Error {
824 match rquickjs::String::from_str(ctx.clone(), msg) {
825 Ok(s) => ctx.throw(s.into_value()),
826 Err(e) => e,
827 }
828}
829
830fn parse_options<'js>(
831 ctx: &rquickjs::Ctx<'js>,
832 method: &str,
833 field: &str,
834 options: rquickjs::Object<'js>,
835) -> rquickjs::Result<serde_json::Map<String, serde_json::Value>> {
836 let value: serde_json::Value = rquickjs_serde::from_value(options.into_value())
837 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
838 match value {
839 serde_json::Value::Object(m) => Ok(m),
840 _ => Err(throw_js(
841 ctx,
842 &format!("{}(\"{}\"): options must be an object", method, field),
843 )),
844 }
845}
846
847fn validate_allowed_keys<'js>(
848 ctx: &rquickjs::Ctx<'js>,
849 method: &str,
850 field: &str,
851 opts: &serde_json::Map<String, serde_json::Value>,
852 allowed: &[&str],
853) -> rquickjs::Result<()> {
854 for k in opts.keys() {
855 if !allowed.contains(&k.as_str()) {
856 return Err(throw_js(
857 ctx,
858 &format!(
859 "{}(\"{}\"): unknown option `{}` (allowed: {})",
860 method,
861 field,
862 k,
863 allowed.join(", "),
864 ),
865 ));
866 }
867 }
868 Ok(())
869}
870
871fn string_opt(opts: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
872 opts.get(key)
873 .and_then(|v| v.as_str())
874 .map(|s| s.to_string())
875}
876
877fn require_integer<'js>(
878 ctx: &rquickjs::Ctx<'js>,
879 method: &str,
880 field: &str,
881 opts: &serde_json::Map<String, serde_json::Value>,
882 key: &str,
883) -> rquickjs::Result<i64> {
884 match opts.get(key) {
885 Some(v) => v.as_i64().ok_or_else(|| {
886 throw_js(
887 ctx,
888 &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
889 )
890 }),
891 None => Err(throw_js(
892 ctx,
893 &format!("{}(\"{}\"): `{}` is required", method, field, key),
894 )),
895 }
896}
897
898fn optional_integer<'js>(
899 ctx: &rquickjs::Ctx<'js>,
900 method: &str,
901 field: &str,
902 opts: &serde_json::Map<String, serde_json::Value>,
903 key: &str,
904) -> rquickjs::Result<Option<i64>> {
905 match opts.get(key) {
906 None => Ok(None),
907 Some(v) => v.as_i64().map(Some).ok_or_else(|| {
908 throw_js(
909 ctx,
910 &format!("{}(\"{}\"): `{}` must be an integer", method, field, key),
911 )
912 }),
913 }
914}
915
916fn require_number<'js>(
917 ctx: &rquickjs::Ctx<'js>,
918 method: &str,
919 field: &str,
920 opts: &serde_json::Map<String, serde_json::Value>,
921 key: &str,
922) -> rquickjs::Result<f64> {
923 match opts.get(key) {
924 Some(v) => v.as_f64().ok_or_else(|| {
925 throw_js(
926 ctx,
927 &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
928 )
929 }),
930 None => Err(throw_js(
931 ctx,
932 &format!("{}(\"{}\"): `{}` is required", method, field, key),
933 )),
934 }
935}
936
937fn optional_number<'js>(
938 ctx: &rquickjs::Ctx<'js>,
939 method: &str,
940 field: &str,
941 opts: &serde_json::Map<String, serde_json::Value>,
942 key: &str,
943) -> rquickjs::Result<Option<f64>> {
944 match opts.get(key) {
945 None => Ok(None),
946 Some(v) => v.as_f64().map(Some).ok_or_else(|| {
947 throw_js(
948 ctx,
949 &format!("{}(\"{}\"): `{}` must be a number", method, field, key),
950 )
951 }),
952 }
953}
954
955fn check_range<'js>(
956 ctx: &rquickjs::Ctx<'js>,
957 method: &str,
958 field: &str,
959 default: f64,
960 minimum: Option<f64>,
961 maximum: Option<f64>,
962) -> rquickjs::Result<()> {
963 if let Some(min) = minimum {
964 if default < min {
965 return Err(throw_js(
966 ctx,
967 &format!(
968 "{}(\"{}\"): default ({}) is below minimum ({})",
969 method, field, default, min
970 ),
971 ));
972 }
973 }
974 if let Some(max) = maximum {
975 if default > max {
976 return Err(throw_js(
977 ctx,
978 &format!(
979 "{}(\"{}\"): default ({}) is above maximum ({})",
980 method, field, default, max
981 ),
982 ));
983 }
984 }
985 if let (Some(min), Some(max)) = (minimum, maximum) {
986 if min > max {
987 return Err(throw_js(
988 ctx,
989 &format!(
990 "{}(\"{}\"): minimum ({}) is greater than maximum ({})",
991 method, field, min, max
992 ),
993 ));
994 }
995 }
996 Ok(())
997}
998
999impl JsEditorApi {
1002 fn send_field_registration(&self, field_name: &str, field_schema: serde_json::Value) {
1004 let _ = self
1005 .command_sender
1006 .send(PluginCommand::AddPluginConfigField {
1007 plugin_name: self.plugin_name.clone(),
1008 field_name: field_name.to_string(),
1009 field_schema,
1010 });
1011 }
1012
1013 fn current_field_value(&self, field_name: &str) -> Option<serde_json::Value> {
1016 self.state_snapshot.read().ok().and_then(|s| {
1017 s.config
1018 .pointer(&format!(
1019 "/plugins/{}/settings/{}",
1020 self.plugin_name, field_name
1021 ))
1022 .cloned()
1023 })
1024 }
1025}
1026
1027#[plugin_api_impl]
1028#[rquickjs::methods(rename_all = "camelCase")]
1029impl JsEditorApi {
1030 pub fn api_version(&self) -> u32 {
1035 2
1036 }
1037
1038 pub fn plugin_name(&self) -> String {
1042 self.plugin_name.clone()
1043 }
1044
1045 #[plugin_api(ts_return = "boolean")]
1055 pub fn export_plugin_api<'js>(
1056 &self,
1057 ctx: rquickjs::Ctx<'js>,
1058 name: String,
1059 api: rquickjs::Value<'js>,
1060 ) -> rquickjs::Result<bool> {
1061 if name.is_empty() {
1062 let msg =
1063 rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
1064 return Err(ctx.throw(msg.into_value()));
1065 }
1066 let obj = match api.as_object() {
1067 Some(o) => o.clone(),
1068 None => {
1069 let msg = rquickjs::String::from_str(
1070 ctx.clone(),
1071 "exportPluginApi: api must be an object",
1072 )?;
1073 return Err(ctx.throw(msg.into_value()));
1074 }
1075 };
1076 let persistent = rquickjs::Persistent::save(&ctx, obj);
1077 self.plugin_api_exports
1078 .borrow_mut()
1079 .insert(name, (self.plugin_name.clone(), persistent));
1080 Ok(true)
1081 }
1082
1083 #[plugin_api(ts_return = "unknown | null")]
1087 pub fn get_plugin_api<'js>(
1088 &self,
1089 ctx: rquickjs::Ctx<'js>,
1090 name: String,
1091 ) -> rquickjs::Result<rquickjs::Value<'js>> {
1092 let persistent = self
1093 .plugin_api_exports
1094 .borrow()
1095 .get(&name)
1096 .map(|(_exporter, p)| p.clone());
1097 match persistent {
1098 Some(p) => {
1099 let restored = p.restore(&ctx)?;
1100 Ok(restored.into_value())
1101 }
1102 None => Ok(rquickjs::Value::new_null(ctx)),
1103 }
1104 }
1105
1106 pub fn get_active_buffer_id(&self) -> u32 {
1108 self.state_snapshot
1109 .read()
1110 .map(|s| s.active_buffer_id.0 as u32)
1111 .unwrap_or(0)
1112 }
1113
1114 pub fn get_active_split_id(&self) -> u32 {
1116 self.state_snapshot
1117 .read()
1118 .map(|s| s.active_split_id as u32)
1119 .unwrap_or(0)
1120 }
1121
1122 #[plugin_api]
1125 pub fn has_active_search(&self) -> bool {
1126 self.state_snapshot
1127 .read()
1128 .map(|s| s.has_active_search)
1129 .unwrap_or(false)
1130 }
1131
1132 #[plugin_api(ts_return = "BufferInfo[]")]
1134 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1135 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
1136 s.buffers.values().cloned().collect()
1137 } else {
1138 Vec::new()
1139 };
1140 rquickjs_serde::to_value(ctx, &buffers)
1141 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1142 }
1143
1144 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
1146 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1147 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
1148 s.available_grammars.clone()
1149 } else {
1150 Vec::new()
1151 };
1152 rquickjs_serde::to_value(ctx, &grammars)
1153 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1154 }
1155
1156 pub fn debug(&self, msg: String) {
1159 tracing::debug!("Plugin: {}", msg);
1160 }
1161
1162 pub fn info(&self, msg: String) {
1163 tracing::info!("Plugin: {}", msg);
1164 }
1165
1166 pub fn warn(&self, msg: String) {
1167 tracing::warn!("Plugin: {}", msg);
1168 }
1169
1170 pub fn error(&self, msg: String) {
1171 tracing::error!("Plugin: {}", msg);
1172 }
1173
1174 pub fn set_status(&self, msg: String) {
1177 let _ = self
1178 .command_sender
1179 .send(PluginCommand::SetStatus { message: msg });
1180 }
1181
1182 pub fn copy_to_clipboard(&self, text: String) {
1185 let _ = self
1186 .command_sender
1187 .send(PluginCommand::SetClipboard { text });
1188 }
1189
1190 pub fn set_clipboard(&self, text: String) {
1191 let _ = self
1192 .command_sender
1193 .send(PluginCommand::SetClipboard { text });
1194 }
1195
1196 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
1201 if let Some(mode_name) = mode {
1202 let key = format!("{}\0{}", action, mode_name);
1203 if let Ok(snapshot) = self.state_snapshot.read() {
1204 return snapshot.keybinding_labels.get(&key).cloned();
1205 }
1206 }
1207 None
1208 }
1209
1210 pub fn register_command<'js>(
1221 &self,
1222 ctx: rquickjs::Ctx<'js>,
1223 name: String,
1224 description: String,
1225 handler_name: String,
1226 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
1227 rquickjs::Value<'js>,
1228 >,
1229 #[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
1230 options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1231 ) -> rquickjs::Result<bool> {
1232 let plugin_name = self.plugin_name.clone();
1234 let context_str: Option<String> = context.0.and_then(|v| {
1236 if v.is_null() || v.is_undefined() {
1237 None
1238 } else {
1239 v.as_string().and_then(|s| s.to_string().ok())
1240 }
1241 });
1242
1243 tracing::debug!(
1244 "registerCommand: plugin='{}', name='{}', handler='{}'",
1245 plugin_name,
1246 name,
1247 handler_name
1248 );
1249
1250 let tracking_key = if name.starts_with('%') {
1254 format!("{}:{}", plugin_name, name)
1255 } else {
1256 name.clone()
1257 };
1258 {
1259 let names = self.registered_command_names.borrow();
1260 if let Some(existing_plugin) = names.get(&tracking_key) {
1261 if existing_plugin != &plugin_name {
1262 let msg = format!(
1263 "Command '{}' already registered by plugin '{}'",
1264 name, existing_plugin
1265 );
1266 tracing::warn!("registerCommand collision: {}", msg);
1267 return Err(
1268 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1269 );
1270 }
1271 }
1273 }
1274
1275 self.registered_command_names
1277 .borrow_mut()
1278 .insert(tracking_key, plugin_name.clone());
1279
1280 self.registered_actions.borrow_mut().insert(
1282 handler_name.clone(),
1283 PluginHandler {
1284 plugin_name: self.plugin_name.clone(),
1285 handler_name: handler_name.clone(),
1286 },
1287 );
1288
1289 let terminal_bypass: bool = options
1293 .0
1294 .and_then(|v| {
1295 if v.is_null() || v.is_undefined() {
1296 None
1297 } else {
1298 v.into_object()
1299 .and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
1300 }
1301 })
1302 .unwrap_or(false);
1303
1304 let command = Command {
1306 name: name.clone(),
1307 description,
1308 action_name: handler_name,
1309 plugin_name,
1310 custom_contexts: context_str.into_iter().collect(),
1311 terminal_bypass,
1312 };
1313
1314 Ok(self
1315 .command_sender
1316 .send(PluginCommand::RegisterCommand { command })
1317 .is_ok())
1318 }
1319
1320 pub fn unregister_command(&self, name: String) -> bool {
1322 let tracking_key = if name.starts_with('%') {
1325 format!("{}:{}", self.plugin_name, name)
1326 } else {
1327 name.clone()
1328 };
1329 self.registered_command_names
1330 .borrow_mut()
1331 .remove(&tracking_key);
1332 self.command_sender
1333 .send(PluginCommand::UnregisterCommand { name })
1334 .is_ok()
1335 }
1336
1337 pub fn set_context(&self, name: String, active: bool) -> bool {
1339 if active {
1341 self.plugin_tracked_state
1342 .borrow_mut()
1343 .entry(self.plugin_name.clone())
1344 .or_default()
1345 .contexts_set
1346 .push(name.clone());
1347 }
1348 self.command_sender
1349 .send(PluginCommand::SetContext { name, active })
1350 .is_ok()
1351 }
1352
1353 pub fn execute_action(&self, action_name: String) -> bool {
1355 self.command_sender
1356 .send(PluginCommand::ExecuteAction { action_name })
1357 .is_ok()
1358 }
1359
1360 pub fn cancel_prompt(&self) -> bool {
1365 self.command_sender
1366 .send(PluginCommand::CancelPrompt)
1367 .is_ok()
1368 }
1369
1370 pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
1374 let plugin_name = self.plugin_name.clone();
1375 self.command_sender
1376 .send(PluginCommand::RegisterStatusBarElement {
1377 plugin_name,
1378 token_name,
1379 title,
1380 })
1381 .is_ok()
1382 }
1383
1384 pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
1387 let key = format!("{}:{}", self.plugin_name, token_name);
1388 self.command_sender
1389 .send(PluginCommand::SetStatusBarValue {
1390 buffer_id,
1391 key,
1392 value,
1393 })
1394 .is_ok()
1395 }
1396
1397 pub fn t<'js>(
1402 &self,
1403 _ctx: rquickjs::Ctx<'js>,
1404 key: String,
1405 args: rquickjs::function::Rest<Value<'js>>,
1406 ) -> String {
1407 let plugin_name = self.plugin_name.clone();
1409 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1411 if let Some(obj) = first_arg.as_object() {
1412 let mut map = HashMap::new();
1413 for k in obj.keys::<String>().flatten() {
1414 if let Ok(v) = obj.get::<_, String>(&k) {
1415 map.insert(k, v);
1416 }
1417 }
1418 map
1419 } else {
1420 HashMap::new()
1421 }
1422 } else {
1423 HashMap::new()
1424 };
1425 let res = self.services.translate(&plugin_name, &key, &args_map);
1426
1427 tracing::info!(
1428 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1429 key,
1430 plugin_name,
1431 args_map,
1432 res
1433 );
1434 res
1435 }
1436
1437 pub fn get_cursor_position(&self) -> u32 {
1441 self.state_snapshot
1442 .read()
1443 .ok()
1444 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1445 .unwrap_or(0)
1446 }
1447
1448 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1450 if let Ok(s) = self.state_snapshot.read() {
1451 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1452 if let Some(p) = &b.path {
1453 return p.to_string_lossy().to_string();
1454 }
1455 }
1456 }
1457 String::new()
1458 }
1459
1460 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1462 if let Ok(s) = self.state_snapshot.read() {
1463 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1464 return b.length as u32;
1465 }
1466 }
1467 0
1468 }
1469
1470 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1472 if let Ok(s) = self.state_snapshot.read() {
1473 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1474 return b.modified;
1475 }
1476 }
1477 false
1478 }
1479
1480 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1483 self.command_sender
1484 .send(PluginCommand::SaveBufferToPath {
1485 buffer_id: BufferId(buffer_id as usize),
1486 path: std::path::PathBuf::from(path),
1487 })
1488 .is_ok()
1489 }
1490
1491 #[plugin_api(ts_return = "BufferInfo | null")]
1493 pub fn get_buffer_info<'js>(
1494 &self,
1495 ctx: rquickjs::Ctx<'js>,
1496 buffer_id: u32,
1497 ) -> rquickjs::Result<Value<'js>> {
1498 let info = if let Ok(s) = self.state_snapshot.read() {
1499 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1500 } else {
1501 None
1502 };
1503 rquickjs_serde::to_value(ctx, &info)
1504 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1505 }
1506
1507 #[plugin_api(ts_return = "CursorInfo | null")]
1509 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1510 let cursor = if let Ok(s) = self.state_snapshot.read() {
1511 s.primary_cursor.clone()
1512 } else {
1513 None
1514 };
1515 rquickjs_serde::to_value(ctx, &cursor)
1516 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1517 }
1518
1519 #[plugin_api(ts_return = "CursorInfo[]")]
1521 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1522 let cursors = if let Ok(s) = self.state_snapshot.read() {
1523 s.all_cursors.clone()
1524 } else {
1525 Vec::new()
1526 };
1527 rquickjs_serde::to_value(ctx, &cursors)
1528 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1529 }
1530
1531 #[plugin_api(ts_return = "number[]")]
1533 pub fn get_all_cursor_positions<'js>(
1534 &self,
1535 ctx: rquickjs::Ctx<'js>,
1536 ) -> rquickjs::Result<Value<'js>> {
1537 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1538 s.all_cursors.iter().map(|c| c.position as u32).collect()
1539 } else {
1540 Vec::new()
1541 };
1542 rquickjs_serde::to_value(ctx, &positions)
1543 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1544 }
1545
1546 #[plugin_api(ts_return = "ViewportInfo | null")]
1548 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1549 let viewport = if let Ok(s) = self.state_snapshot.read() {
1550 s.viewport.clone()
1551 } else {
1552 None
1553 };
1554 rquickjs_serde::to_value(ctx, &viewport)
1555 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1556 }
1557
1558 #[plugin_api(ts_return = "ScreenSize")]
1563 pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1564 let size = if let Ok(s) = self.state_snapshot.read() {
1565 fresh_core::api::ScreenSize {
1566 width: s.terminal_width,
1567 height: s.terminal_height,
1568 }
1569 } else {
1570 fresh_core::api::ScreenSize {
1571 width: 0,
1572 height: 0,
1573 }
1574 };
1575 rquickjs_serde::to_value(ctx, &size)
1576 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1577 }
1578
1579 #[plugin_api(ts_return = "SplitSnapshot[]")]
1586 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1587 let splits = if let Ok(s) = self.state_snapshot.read() {
1588 s.splits.clone()
1589 } else {
1590 Vec::new()
1591 };
1592 rquickjs_serde::to_value(ctx, &splits)
1593 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1594 }
1595
1596 pub fn get_cursor_line(&self) -> u32 {
1604 self.state_snapshot
1605 .read()
1606 .ok()
1607 .and_then(|s| s.primary_cursor.as_ref().and_then(|c| c.line))
1608 .unwrap_or(0) as u32
1609 }
1610
1611 #[plugin_api(
1614 async_promise,
1615 js_name = "getLineStartPosition",
1616 ts_return = "number | null"
1617 )]
1618 #[qjs(rename = "_getLineStartPositionStart")]
1619 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1620 let id = self.alloc_request_id();
1621 let _ = self
1623 .command_sender
1624 .send(PluginCommand::GetLineStartPosition {
1625 buffer_id: BufferId(0),
1626 line,
1627 request_id: id,
1628 });
1629 id
1630 }
1631
1632 #[plugin_api(
1636 async_promise,
1637 js_name = "getLineEndPosition",
1638 ts_return = "number | null"
1639 )]
1640 #[qjs(rename = "_getLineEndPositionStart")]
1641 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1642 let id = self.alloc_request_id();
1643 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1645 buffer_id: BufferId(0),
1646 line,
1647 request_id: id,
1648 });
1649 id
1650 }
1651
1652 #[plugin_api(
1655 async_promise,
1656 js_name = "getBufferLineCount",
1657 ts_return = "number | null"
1658 )]
1659 #[qjs(rename = "_getBufferLineCountStart")]
1660 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1661 let id = self.alloc_request_id();
1662 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1664 buffer_id: BufferId(0),
1665 request_id: id,
1666 });
1667 id
1668 }
1669
1670 #[plugin_api(
1678 async_promise,
1679 js_name = "getCompositeCursorInfo",
1680 ts_return = "{ focusedPane: number, paneCount: number, lines: Array<number | null> } | null"
1681 )]
1682 #[qjs(rename = "_getCompositeCursorInfoStart")]
1683 pub fn get_composite_cursor_info_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1684 let id = self.alloc_request_id();
1685 let _ = self
1686 .command_sender
1687 .send(PluginCommand::GetCompositeCursorInfo { request_id: id });
1688 id
1689 }
1690
1691 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1694 self.command_sender
1695 .send(PluginCommand::ScrollToLineCenter {
1696 split_id: SplitId(split_id as usize),
1697 buffer_id: BufferId(buffer_id as usize),
1698 line: line as usize,
1699 })
1700 .is_ok()
1701 }
1702
1703 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1712 self.command_sender
1713 .send(PluginCommand::ScrollBufferToLine {
1714 buffer_id: BufferId(buffer_id as usize),
1715 line: line as usize,
1716 })
1717 .is_ok()
1718 }
1719
1720 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1722 let path_buf = std::path::PathBuf::from(&path);
1723 if let Ok(s) = self.state_snapshot.read() {
1724 for (id, info) in &s.buffers {
1725 if let Some(buf_path) = &info.path {
1726 if buf_path == &path_buf {
1727 return id.0 as u32;
1728 }
1729 }
1730 }
1731 }
1732 0
1733 }
1734
1735 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1737 pub fn get_buffer_saved_diff<'js>(
1738 &self,
1739 ctx: rquickjs::Ctx<'js>,
1740 buffer_id: u32,
1741 ) -> rquickjs::Result<Value<'js>> {
1742 let diff = if let Ok(s) = self.state_snapshot.read() {
1743 s.buffer_saved_diffs
1744 .get(&BufferId(buffer_id as usize))
1745 .cloned()
1746 } else {
1747 None
1748 };
1749 rquickjs_serde::to_value(ctx, &diff)
1750 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1751 }
1752
1753 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1757 self.command_sender
1758 .send(PluginCommand::InsertText {
1759 buffer_id: BufferId(buffer_id as usize),
1760 position: position as usize,
1761 text,
1762 })
1763 .is_ok()
1764 }
1765
1766 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1768 self.command_sender
1769 .send(PluginCommand::DeleteRange {
1770 buffer_id: BufferId(buffer_id as usize),
1771 range: (start as usize)..(end as usize),
1772 })
1773 .is_ok()
1774 }
1775
1776 pub fn insert_at_cursor(&self, text: String) -> bool {
1778 self.command_sender
1779 .send(PluginCommand::InsertAtCursor { text })
1780 .is_ok()
1781 }
1782
1783 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1787 self.command_sender
1788 .send(PluginCommand::OpenFileAtLocation {
1789 path: PathBuf::from(path),
1790 line: line.map(|l| l as usize),
1791 column: column.map(|c| c as usize),
1792 })
1793 .is_ok()
1794 }
1795
1796 pub fn open_file_in_background(
1804 &self,
1805 path: String,
1806 window_id: rquickjs::function::Opt<u64>,
1807 ) -> bool {
1808 self.command_sender
1809 .send(PluginCommand::OpenFileInBackground {
1810 path: PathBuf::from(path),
1811 window_id: window_id.0.map(fresh_core::WindowId),
1812 })
1813 .is_ok()
1814 }
1815
1816 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1818 self.command_sender
1819 .send(PluginCommand::OpenFileInSplit {
1820 split_id: split_id as usize,
1821 path: PathBuf::from(path),
1822 line: Some(line as usize),
1823 column: Some(column as usize),
1824 })
1825 .is_ok()
1826 }
1827
1828 #[plugin_api(
1837 async_promise,
1838 js_name = "openFileStreaming",
1839 ts_return = "number | null"
1840 )]
1841 #[qjs(rename = "_openFileStreamingStart")]
1842 pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
1843 let id = self.alloc_request_id();
1844 let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
1845 path: PathBuf::from(path),
1846 request_id: id,
1847 });
1848 id
1849 }
1850
1851 #[plugin_api(
1859 async_promise,
1860 js_name = "refreshBufferFromDisk",
1861 ts_return = "number | null"
1862 )]
1863 #[qjs(rename = "_refreshBufferFromDiskStart")]
1864 pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
1865 let id = self.alloc_request_id();
1866 let _ = self
1867 .command_sender
1868 .send(PluginCommand::RefreshBufferFromDisk {
1869 buffer_id: BufferId(buffer_id as usize),
1870 request_id: id,
1871 });
1872 id
1873 }
1874
1875 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1877 self.command_sender
1878 .send(PluginCommand::ShowBuffer {
1879 buffer_id: BufferId(buffer_id as usize),
1880 })
1881 .is_ok()
1882 }
1883
1884 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1886 self.command_sender
1887 .send(PluginCommand::CloseBuffer {
1888 buffer_id: BufferId(buffer_id as usize),
1889 })
1890 .is_ok()
1891 }
1892
1893 pub fn close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1895 self.command_sender
1896 .send(PluginCommand::CloseOtherBuffersInSplit {
1897 buffer_id: BufferId(buffer_id as usize),
1898 split_id: SplitId(split_id as usize),
1899 })
1900 .is_ok()
1901 }
1902
1903 pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
1905 self.command_sender
1906 .send(PluginCommand::CloseAllBuffersInSplit {
1907 split_id: SplitId(split_id as usize),
1908 })
1909 .is_ok()
1910 }
1911
1912 pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1914 self.command_sender
1915 .send(PluginCommand::CloseBuffersToRightInSplit {
1916 buffer_id: BufferId(buffer_id as usize),
1917 split_id: SplitId(split_id as usize),
1918 })
1919 .is_ok()
1920 }
1921
1922 pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1924 self.command_sender
1925 .send(PluginCommand::CloseBuffersToLeftInSplit {
1926 buffer_id: BufferId(buffer_id as usize),
1927 split_id: SplitId(split_id as usize),
1928 })
1929 .is_ok()
1930 }
1931
1932 #[plugin_api(ts_return = "boolean")]
1934 pub fn move_tab_to_left(&self) -> bool {
1935 self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
1936 }
1937
1938 #[plugin_api(ts_return = "boolean")]
1940 pub fn move_tab_to_right(&self) -> bool {
1941 self.command_sender
1942 .send(PluginCommand::MoveTabRight)
1943 .is_ok()
1944 }
1945
1946 #[plugin_api(skip)]
1952 #[qjs(skip)]
1953 fn alloc_request_id(&self) -> u64 {
1954 let mut id_ref = self.next_request_id.borrow_mut();
1955 let id = *id_ref;
1956 *id_ref += 1;
1957 self.callback_contexts
1958 .borrow_mut()
1959 .insert(id, self.plugin_name.clone());
1960 id
1961 }
1962
1963 #[plugin_api(skip)]
1967 #[qjs(skip)]
1968 fn alloc_animation_id(&self) -> u64 {
1969 let mut id_ref = self.next_request_id.borrow_mut();
1970 let id = *id_ref;
1971 *id_ref += 1;
1972 id
1973 }
1974
1975 pub fn animate_area<'js>(
1978 &self,
1979 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1980 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1981 ) -> rquickjs::Result<u64> {
1982 let rect = parse_animation_rect(&rect)?;
1983 let kind = parse_animation_kind(&kind)?;
1984 let id = self.alloc_animation_id();
1985 let _ = self
1986 .command_sender
1987 .send(PluginCommand::StartAnimationArea { id, rect, kind });
1988 Ok(id)
1989 }
1990
1991 pub fn animate_virtual_buffer<'js>(
1994 &self,
1995 buffer_id: u32,
1996 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1997 ) -> rquickjs::Result<u64> {
1998 let kind = parse_animation_kind(&kind)?;
1999 let id = self.alloc_animation_id();
2000 let _ = self
2001 .command_sender
2002 .send(PluginCommand::StartAnimationVirtualBuffer {
2003 id,
2004 buffer_id: BufferId(buffer_id as usize),
2005 kind,
2006 });
2007 Ok(id)
2008 }
2009
2010 pub fn cancel_animation(&self, id: u64) -> bool {
2013 self.command_sender
2014 .send(PluginCommand::CancelAnimation { id })
2015 .is_ok()
2016 }
2017
2018 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
2022 if event_name == "lines_changed" {
2026 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
2027 }
2028 self.event_handlers
2029 .write()
2030 .expect("event_handlers poisoned")
2031 .entry(event_name)
2032 .or_default()
2033 .push(PluginHandler {
2034 plugin_name: self.plugin_name.clone(),
2035 handler_name,
2036 });
2037 }
2038
2039 pub fn off(&self, event_name: String, handler_name: String) {
2041 if let Some(list) = self
2042 .event_handlers
2043 .write()
2044 .expect("event_handlers poisoned")
2045 .get_mut(&event_name)
2046 {
2047 list.retain(|h| h.handler_name != handler_name);
2048 }
2049 }
2050
2051 pub fn get_env(&self, name: String) -> Option<String> {
2055 std::env::var(&name).ok()
2056 }
2057
2058 pub fn get_cwd(&self) -> String {
2060 self.state_snapshot
2061 .read()
2062 .map(|s| s.working_dir.to_string_lossy().to_string())
2063 .unwrap_or_else(|_| ".".to_string())
2064 }
2065
2066 pub fn get_authority_label(&self) -> String {
2075 self.state_snapshot
2076 .read()
2077 .map(|s| s.authority_label.clone())
2078 .unwrap_or_default()
2079 }
2080
2081 pub fn workspace_trust_level(&self) -> String {
2086 self.state_snapshot
2087 .read()
2088 .map(|s| s.workspace_trust_level.clone())
2089 .unwrap_or_default()
2090 }
2091
2092 pub fn env_active(&self) -> bool {
2097 self.state_snapshot
2098 .read()
2099 .map(|s| s.env_active)
2100 .unwrap_or(false)
2101 }
2102
2103 pub fn detected_env(&self) -> String {
2108 self.state_snapshot
2109 .read()
2110 .map(|s| s.detected_env.clone())
2111 .unwrap_or_default()
2112 }
2113
2114 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2126 let mut result_parts: Vec<String> = Vec::new();
2127 let mut leading_slashes: u8 = 0;
2129
2130 for part in &parts.0 {
2131 let normalized = part.replace('\\', "/");
2133
2134 let is_absolute = normalized.starts_with('/')
2136 || (normalized.len() >= 2
2137 && normalized
2138 .chars()
2139 .next()
2140 .map(|c| c.is_ascii_alphabetic())
2141 .unwrap_or(false)
2142 && normalized.chars().nth(1) == Some(':'));
2143
2144 if is_absolute {
2145 result_parts.clear();
2147 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2151 }
2152
2153 for segment in normalized.split('/') {
2155 if !segment.is_empty() && segment != "." {
2156 if segment == ".." {
2157 result_parts.pop();
2158 } else {
2159 result_parts.push(segment.to_string());
2160 }
2161 }
2162 }
2163 }
2164
2165 let joined = result_parts.join("/");
2167 let prefix = match leading_slashes {
2168 0 => "",
2169 1 => "/",
2170 _ => "//",
2171 };
2172
2173 if leading_slashes > 0 {
2174 format!("{}{}", prefix, joined)
2175 } else {
2176 joined
2177 }
2178 }
2179
2180 pub fn path_dirname(&self, path: String) -> String {
2182 Path::new(&path)
2183 .parent()
2184 .map(|p| p.to_string_lossy().to_string())
2185 .unwrap_or_default()
2186 }
2187
2188 pub fn path_basename(&self, path: String) -> String {
2190 Path::new(&path)
2191 .file_name()
2192 .map(|s| s.to_string_lossy().to_string())
2193 .unwrap_or_default()
2194 }
2195
2196 pub fn path_extname(&self, path: String) -> String {
2198 Path::new(&path)
2199 .extension()
2200 .map(|s| format!(".{}", s.to_string_lossy()))
2201 .unwrap_or_default()
2202 }
2203
2204 pub fn path_is_absolute(&self, path: String) -> bool {
2206 Path::new(&path).is_absolute()
2207 }
2208
2209 pub fn file_uri_to_path(&self, uri: String) -> String {
2213 fresh_core::file_uri::file_uri_to_path(&uri)
2214 .map(|p| p.to_string_lossy().to_string())
2215 .unwrap_or_default()
2216 }
2217
2218 pub fn path_to_file_uri(&self, path: String) -> String {
2222 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2223 }
2224
2225 pub fn utf8_byte_length(&self, text: String) -> u32 {
2233 text.len() as u32
2234 }
2235
2236 pub fn file_exists(&self, path: String) -> bool {
2240 Path::new(&path).exists()
2241 }
2242
2243 pub fn read_file(&self, path: String) -> Option<String> {
2245 std::fs::read_to_string(&path).ok()
2246 }
2247
2248 pub fn write_file(&self, path: String, content: String) -> bool {
2250 let p = Path::new(&path);
2251 if let Some(parent) = p.parent() {
2252 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2253 return false;
2254 }
2255 }
2256 std::fs::write(p, content).is_ok()
2257 }
2258
2259 #[plugin_api(ts_return = "DirEntry[]")]
2261 pub fn read_dir<'js>(
2262 &self,
2263 ctx: rquickjs::Ctx<'js>,
2264 path: String,
2265 ) -> rquickjs::Result<Value<'js>> {
2266 use fresh_core::api::DirEntry;
2267
2268 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2269 Ok(entries) => entries
2270 .filter_map(|e| e.ok())
2271 .map(|entry| {
2272 let file_type = entry.file_type().ok();
2273 DirEntry {
2274 name: entry.file_name().to_string_lossy().to_string(),
2275 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2276 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2277 }
2278 })
2279 .collect(),
2280 Err(e) => {
2281 tracing::warn!("readDir failed for '{}': {}", path, e);
2282 Vec::new()
2283 }
2284 };
2285
2286 rquickjs_serde::to_value(ctx, &entries)
2287 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2288 }
2289
2290 pub fn create_dir(&self, path: String) -> bool {
2293 let p = Path::new(&path);
2294 if p.is_dir() {
2295 return true;
2296 }
2297 std::fs::create_dir_all(p).is_ok()
2298 }
2299
2300 pub fn remove_path(&self, path: String) -> bool {
2304 let target = match Path::new(&path).canonicalize() {
2305 Ok(p) => p,
2306 Err(_) => return false, };
2308
2309 let temp_dir = std::env::temp_dir()
2315 .canonicalize()
2316 .unwrap_or_else(|_| std::env::temp_dir());
2317 let config_dir = self
2318 .services
2319 .config_dir()
2320 .canonicalize()
2321 .unwrap_or_else(|_| self.services.config_dir());
2322
2323 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2325 if !allowed {
2326 tracing::warn!(
2327 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2328 target,
2329 temp_dir,
2330 config_dir
2331 );
2332 return false;
2333 }
2334
2335 if target == temp_dir || target == config_dir {
2337 tracing::warn!(
2338 "removePath refused: cannot remove root directory {:?}",
2339 target
2340 );
2341 return false;
2342 }
2343
2344 match trash::delete(&target) {
2345 Ok(()) => true,
2346 Err(e) => {
2347 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2348 false
2349 }
2350 }
2351 }
2352
2353 pub fn rename_path(&self, from: String, to: String) -> bool {
2356 if std::fs::rename(&from, &to).is_ok() {
2358 return true;
2359 }
2360 let from_path = Path::new(&from);
2362 let copied = if from_path.is_dir() {
2363 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2364 } else {
2365 std::fs::copy(&from, &to).is_ok()
2366 };
2367 if copied {
2368 return trash::delete(from_path).is_ok();
2369 }
2370 false
2371 }
2372
2373 pub fn copy_path(&self, from: String, to: String) -> bool {
2376 let from_path = Path::new(&from);
2377 let to_path = Path::new(&to);
2378 if from_path.is_dir() {
2379 copy_dir_recursive(from_path, to_path).is_ok()
2380 } else {
2381 if let Some(parent) = to_path.parent() {
2383 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2384 return false;
2385 }
2386 }
2387 std::fs::copy(from_path, to_path).is_ok()
2388 }
2389 }
2390
2391 pub fn get_temp_dir(&self) -> String {
2393 std::env::temp_dir().to_string_lossy().to_string()
2394 }
2395
2396 #[plugin_api(ts_return = "unknown")]
2407 pub fn parse_jsonc<'js>(
2408 &self,
2409 ctx: rquickjs::Ctx<'js>,
2410 text: String,
2411 ) -> rquickjs::Result<Value<'js>> {
2412 let value: serde_json::Value =
2413 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2414 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2415 })?;
2416 rquickjs_serde::to_value(ctx, &value)
2417 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2418 }
2419
2420 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2429 let config = self
2430 .state_snapshot
2431 .read()
2432 .map(|s| std::sync::Arc::clone(&s.config))
2433 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2434
2435 rquickjs_serde::to_value(ctx, &*config)
2436 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2437 }
2438
2439 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2441 let config = self
2442 .state_snapshot
2443 .read()
2444 .map(|s| std::sync::Arc::clone(&s.user_config))
2445 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2446
2447 rquickjs_serde::to_value(ctx, &*config)
2448 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2449 }
2450
2451 #[plugin_api(ts_return = "boolean")]
2459 pub fn define_config_boolean<'js>(
2460 &self,
2461 ctx: rquickjs::Ctx<'js>,
2462 name: String,
2463 #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2464 options: rquickjs::Object<'js>,
2465 ) -> rquickjs::Result<bool> {
2466 let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2467 validate_allowed_keys(
2468 &ctx,
2469 "defineConfigBoolean",
2470 &name,
2471 &opts,
2472 &["default", "description"],
2473 )?;
2474 let default = match opts.get("default") {
2475 Some(serde_json::Value::Bool(b)) => *b,
2476 _ => {
2477 return Err(throw_js(
2478 &ctx,
2479 &format!(
2480 "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2481 name
2482 ),
2483 ));
2484 }
2485 };
2486 let description = string_opt(&opts, "description");
2487 let mut field = serde_json::Map::new();
2488 field.insert("type".into(), serde_json::json!("boolean"));
2489 field.insert("default".into(), serde_json::json!(default));
2490 if let Some(d) = description {
2491 field.insert("description".into(), serde_json::json!(d));
2492 }
2493 self.send_field_registration(&name, serde_json::Value::Object(field));
2494 Ok(self
2495 .current_field_value(&name)
2496 .and_then(|v| v.as_bool())
2497 .unwrap_or(default))
2498 }
2499
2500 #[plugin_api(ts_return = "number")]
2503 pub fn define_config_integer<'js>(
2504 &self,
2505 ctx: rquickjs::Ctx<'js>,
2506 name: String,
2507 #[plugin_api(
2508 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2509 )]
2510 options: rquickjs::Object<'js>,
2511 ) -> rquickjs::Result<i64> {
2512 let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2513 validate_allowed_keys(
2514 &ctx,
2515 "defineConfigInteger",
2516 &name,
2517 &opts,
2518 &["default", "description", "minimum", "maximum"],
2519 )?;
2520 let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2521 let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2522 let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2523 check_range(
2524 &ctx,
2525 "defineConfigInteger",
2526 &name,
2527 default as f64,
2528 minimum.map(|v| v as f64),
2529 maximum.map(|v| v as f64),
2530 )?;
2531 let description = string_opt(&opts, "description");
2532 let mut field = serde_json::Map::new();
2533 field.insert("type".into(), serde_json::json!("integer"));
2534 field.insert("default".into(), serde_json::json!(default));
2535 if let Some(d) = description {
2536 field.insert("description".into(), serde_json::json!(d));
2537 }
2538 if let Some(v) = minimum {
2539 field.insert("minimum".into(), serde_json::json!(v));
2540 }
2541 if let Some(v) = maximum {
2542 field.insert("maximum".into(), serde_json::json!(v));
2543 }
2544 self.send_field_registration(&name, serde_json::Value::Object(field));
2545 Ok(self
2546 .current_field_value(&name)
2547 .and_then(|v| v.as_i64())
2548 .unwrap_or(default))
2549 }
2550
2551 #[plugin_api(ts_return = "number")]
2554 pub fn define_config_number<'js>(
2555 &self,
2556 ctx: rquickjs::Ctx<'js>,
2557 name: String,
2558 #[plugin_api(
2559 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2560 )]
2561 options: rquickjs::Object<'js>,
2562 ) -> rquickjs::Result<f64> {
2563 let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2564 validate_allowed_keys(
2565 &ctx,
2566 "defineConfigNumber",
2567 &name,
2568 &opts,
2569 &["default", "description", "minimum", "maximum"],
2570 )?;
2571 let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2572 let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2573 let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2574 check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2575 let description = string_opt(&opts, "description");
2576 let mut field = serde_json::Map::new();
2577 field.insert("type".into(), serde_json::json!("number"));
2578 field.insert("default".into(), serde_json::json!(default));
2579 if let Some(d) = description {
2580 field.insert("description".into(), serde_json::json!(d));
2581 }
2582 if let Some(v) = minimum {
2583 field.insert("minimum".into(), serde_json::json!(v));
2584 }
2585 if let Some(v) = maximum {
2586 field.insert("maximum".into(), serde_json::json!(v));
2587 }
2588 self.send_field_registration(&name, serde_json::Value::Object(field));
2589 Ok(self
2590 .current_field_value(&name)
2591 .and_then(|v| v.as_f64())
2592 .unwrap_or(default))
2593 }
2594
2595 #[plugin_api(ts_return = "string")]
2597 pub fn define_config_string<'js>(
2598 &self,
2599 ctx: rquickjs::Ctx<'js>,
2600 name: String,
2601 #[plugin_api(ts_type = "{ default: string; description?: string }")]
2602 options: rquickjs::Object<'js>,
2603 ) -> rquickjs::Result<String> {
2604 let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2605 validate_allowed_keys(
2606 &ctx,
2607 "defineConfigString",
2608 &name,
2609 &opts,
2610 &["default", "description"],
2611 )?;
2612 let default = match opts.get("default") {
2613 Some(serde_json::Value::String(s)) => s.clone(),
2614 _ => {
2615 return Err(throw_js(
2616 &ctx,
2617 &format!(
2618 "defineConfigString(\"{}\"): `default` (string) is required",
2619 name
2620 ),
2621 ));
2622 }
2623 };
2624 let description = string_opt(&opts, "description");
2625 let mut field = serde_json::Map::new();
2626 field.insert("type".into(), serde_json::json!("string"));
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 Ok(self
2633 .current_field_value(&name)
2634 .and_then(|v| v.as_str().map(|s| s.to_string()))
2635 .unwrap_or(default))
2636 }
2637
2638 #[plugin_api(skip)]
2645 pub fn define_config_enum<'js>(
2646 &self,
2647 ctx: rquickjs::Ctx<'js>,
2648 name: String,
2649 options: rquickjs::Object<'js>,
2650 ) -> rquickjs::Result<String> {
2651 let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2652 validate_allowed_keys(
2653 &ctx,
2654 "defineConfigEnum",
2655 &name,
2656 &opts,
2657 &["default", "description", "values"],
2658 )?;
2659 let values: Vec<String> = match opts.get("values") {
2660 Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
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 "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2670 name
2671 ),
2672 ));
2673 }
2674 }
2675 }
2676 out
2677 }
2678 _ => {
2679 return Err(throw_js(
2680 &ctx,
2681 &format!(
2682 "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2683 name
2684 ),
2685 ));
2686 }
2687 };
2688 let default = match opts.get("default") {
2689 Some(serde_json::Value::String(s)) => s.clone(),
2690 _ => {
2691 return Err(throw_js(
2692 &ctx,
2693 &format!(
2694 "defineConfigEnum(\"{}\"): `default` (string) is required",
2695 name
2696 ),
2697 ));
2698 }
2699 };
2700 if !values.contains(&default) {
2701 return Err(throw_js(
2702 &ctx,
2703 &format!(
2704 "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2705 name, values
2706 ),
2707 ));
2708 }
2709 let description = string_opt(&opts, "description");
2710 let mut field = serde_json::Map::new();
2711 field.insert("type".into(), serde_json::json!("string"));
2712 field.insert("enum".into(), serde_json::json!(values));
2713 field.insert("default".into(), serde_json::json!(default));
2714 if let Some(d) = description {
2715 field.insert("description".into(), serde_json::json!(d));
2716 }
2717 self.send_field_registration(&name, serde_json::Value::Object(field));
2718 let current = self
2719 .current_field_value(&name)
2720 .and_then(|v| v.as_str().map(|s| s.to_string()));
2721 Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2725 }
2726
2727 #[plugin_api(ts_return = "string[]")]
2730 pub fn define_config_string_array<'js>(
2731 &self,
2732 ctx: rquickjs::Ctx<'js>,
2733 name: String,
2734 #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2735 options: rquickjs::Object<'js>,
2736 ) -> rquickjs::Result<Vec<String>> {
2737 let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2738 validate_allowed_keys(
2739 &ctx,
2740 "defineConfigStringArray",
2741 &name,
2742 &opts,
2743 &["default", "description"],
2744 )?;
2745 let default: Vec<String> = match opts.get("default") {
2746 Some(serde_json::Value::Array(arr)) => {
2747 let mut out = Vec::with_capacity(arr.len());
2748 for v in arr {
2749 match v {
2750 serde_json::Value::String(s) => out.push(s.clone()),
2751 _ => {
2752 return Err(throw_js(
2753 &ctx,
2754 &format!(
2755 "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2756 name
2757 ),
2758 ));
2759 }
2760 }
2761 }
2762 out
2763 }
2764 _ => {
2765 return Err(throw_js(
2766 &ctx,
2767 &format!(
2768 "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2769 name
2770 ),
2771 ));
2772 }
2773 };
2774 let description = string_opt(&opts, "description");
2775 let mut field = serde_json::Map::new();
2776 field.insert("type".into(), serde_json::json!("array"));
2777 field.insert("items".into(), serde_json::json!({"type": "string"}));
2778 field.insert("default".into(), serde_json::json!(default));
2779 if let Some(d) = description {
2780 field.insert("description".into(), serde_json::json!(d));
2781 }
2782 self.send_field_registration(&name, serde_json::Value::Object(field));
2783 Ok(self
2784 .current_field_value(&name)
2785 .and_then(|v| {
2786 v.as_array().map(|arr| {
2787 arr.iter()
2788 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2789 .collect::<Vec<_>>()
2790 })
2791 })
2792 .unwrap_or(default))
2793 }
2794
2795 pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2803 let config = self
2804 .state_snapshot
2805 .read()
2806 .map(|s| std::sync::Arc::clone(&s.config))
2807 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2808
2809 let settings = config
2810 .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2811 .cloned()
2812 .unwrap_or(serde_json::Value::Null);
2813
2814 rquickjs_serde::to_value(ctx, &settings)
2815 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2816 }
2817
2818 pub fn reload_config(&self) {
2820 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2821 }
2822
2823 pub fn set_setting<'js>(
2836 &self,
2837 _ctx: rquickjs::Ctx<'js>,
2838 path: String,
2839 value: Value<'js>,
2840 ) -> rquickjs::Result<bool> {
2841 let json: serde_json::Value = rquickjs_serde::from_value(value)
2842 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2843 Ok(self
2844 .command_sender
2845 .send(PluginCommand::SetSetting {
2846 plugin_name: self.plugin_name.clone(),
2847 path,
2848 value: json,
2849 })
2850 .is_ok())
2851 }
2852
2853 pub fn reload_themes(&self) {
2856 let _ = self
2857 .command_sender
2858 .send(PluginCommand::ReloadThemes { apply_theme: None });
2859 }
2860
2861 pub fn reload_and_apply_theme(&self, theme_name: String) {
2863 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2864 apply_theme: Some(theme_name),
2865 });
2866 }
2867
2868 pub fn register_grammar<'js>(
2871 &self,
2872 ctx: rquickjs::Ctx<'js>,
2873 language: String,
2874 grammar_path: String,
2875 extensions: Vec<String>,
2876 ) -> rquickjs::Result<bool> {
2877 {
2879 let langs = self.registered_grammar_languages.borrow();
2880 if let Some(existing_plugin) = langs.get(&language) {
2881 if existing_plugin != &self.plugin_name {
2882 let msg = format!(
2883 "Grammar for language '{}' already registered by plugin '{}'",
2884 language, existing_plugin
2885 );
2886 tracing::warn!("registerGrammar collision: {}", msg);
2887 return Err(
2888 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2889 );
2890 }
2891 }
2892 }
2893 self.registered_grammar_languages
2894 .borrow_mut()
2895 .insert(language.clone(), self.plugin_name.clone());
2896
2897 Ok(self
2898 .command_sender
2899 .send(PluginCommand::RegisterGrammar {
2900 language,
2901 grammar_path,
2902 extensions,
2903 })
2904 .is_ok())
2905 }
2906
2907 pub fn register_language_config<'js>(
2909 &self,
2910 ctx: rquickjs::Ctx<'js>,
2911 language: String,
2912 config: LanguagePackConfig,
2913 ) -> rquickjs::Result<bool> {
2914 {
2916 let langs = self.registered_language_configs.borrow();
2917 if let Some(existing_plugin) = langs.get(&language) {
2918 if existing_plugin != &self.plugin_name {
2919 let msg = format!(
2920 "Language config for '{}' already registered by plugin '{}'",
2921 language, existing_plugin
2922 );
2923 tracing::warn!("registerLanguageConfig collision: {}", msg);
2924 return Err(
2925 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2926 );
2927 }
2928 }
2929 }
2930 self.registered_language_configs
2931 .borrow_mut()
2932 .insert(language.clone(), self.plugin_name.clone());
2933
2934 Ok(self
2935 .command_sender
2936 .send(PluginCommand::RegisterLanguageConfig { language, config })
2937 .is_ok())
2938 }
2939
2940 pub fn register_lsp_server<'js>(
2942 &self,
2943 ctx: rquickjs::Ctx<'js>,
2944 language: String,
2945 config: LspServerPackConfig,
2946 ) -> rquickjs::Result<bool> {
2947 {
2949 let langs = self.registered_lsp_servers.borrow();
2950 if let Some(existing_plugin) = langs.get(&language) {
2951 if existing_plugin != &self.plugin_name {
2952 let msg = format!(
2953 "LSP server for language '{}' already registered by plugin '{}'",
2954 language, existing_plugin
2955 );
2956 tracing::warn!("registerLspServer collision: {}", msg);
2957 return Err(
2958 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2959 );
2960 }
2961 }
2962 }
2963 self.registered_lsp_servers
2964 .borrow_mut()
2965 .insert(language.clone(), self.plugin_name.clone());
2966
2967 Ok(self
2968 .command_sender
2969 .send(PluginCommand::RegisterLspServer { language, config })
2970 .is_ok())
2971 }
2972
2973 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2977 #[qjs(rename = "_reloadGrammarsStart")]
2978 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2979 let id = self.alloc_request_id();
2980 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2981 callback_id: fresh_core::api::JsCallbackId::new(id),
2982 });
2983 id
2984 }
2985
2986 pub fn get_plugin_dir(&self) -> String {
2989 self.services
2990 .plugins_dir()
2991 .join("packages")
2992 .join(&self.plugin_name)
2993 .to_string_lossy()
2994 .to_string()
2995 }
2996
2997 pub fn get_config_dir(&self) -> String {
2999 self.services.config_dir().to_string_lossy().to_string()
3000 }
3001
3002 pub fn get_data_dir(&self) -> String {
3006 self.services.data_dir().to_string_lossy().to_string()
3007 }
3008
3009 pub fn get_terminal_dir(&self) -> String {
3014 let working_dir = self
3015 .state_snapshot
3016 .read()
3017 .map(|s| s.working_dir.clone())
3018 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3019 self.services
3020 .terminal_dir(&working_dir)
3021 .to_string_lossy()
3022 .to_string()
3023 }
3024
3025 pub fn get_working_data_dir(&self) -> String {
3031 let working_dir = self
3032 .state_snapshot
3033 .read()
3034 .map(|s| s.working_dir.clone())
3035 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3036 self.services
3037 .working_data_dir(&working_dir)
3038 .to_string_lossy()
3039 .to_string()
3040 }
3041
3042 pub fn get_themes_dir(&self) -> String {
3044 self.services
3045 .config_dir()
3046 .join("themes")
3047 .to_string_lossy()
3048 .to_string()
3049 }
3050
3051 pub fn apply_theme(&self, theme_name: String) -> bool {
3053 self.command_sender
3054 .send(PluginCommand::ApplyTheme { theme_name })
3055 .is_ok()
3056 }
3057
3058 pub fn override_theme_colors<'js>(
3067 &self,
3068 _ctx: rquickjs::Ctx<'js>,
3069 overrides: Value<'js>,
3070 ) -> rquickjs::Result<bool> {
3071 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
3077 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
3078 let Some(obj) = json.as_object() else {
3079 return Err(rquickjs::Error::new_from_js_message(
3080 "type",
3081 "",
3082 "overrideThemeColors expects an object of \"key\": [r, g, b]",
3083 ));
3084 };
3085 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
3086 n.as_i64()
3087 .or_else(|| n.as_f64().map(|f| f as i64))
3088 .map(|v| v.clamp(0, 255) as u8)
3089 };
3090 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
3091 std::collections::HashMap::with_capacity(obj.len());
3092 for (key, value) in obj {
3093 let Some(arr) = value.as_array() else {
3094 continue;
3095 };
3096 if arr.len() != 3 {
3097 continue;
3098 }
3099 let Some(r) = to_u8(&arr[0]) else { continue };
3100 let Some(g) = to_u8(&arr[1]) else { continue };
3101 let Some(b) = to_u8(&arr[2]) else { continue };
3102 clamped.insert(key.clone(), [r, g, b]);
3103 }
3104 Ok(self
3105 .command_sender
3106 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
3107 .is_ok())
3108 }
3109
3110 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3112 let schema = self.services.get_theme_schema();
3113 rquickjs_serde::to_value(ctx, &schema)
3114 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3115 }
3116
3117 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3119 let themes = self.services.get_builtin_themes();
3120 rquickjs_serde::to_value(ctx, &themes)
3121 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3122 }
3123
3124 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3127 let themes = self.services.get_all_themes();
3128 rquickjs_serde::to_value(ctx, &themes)
3129 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3130 }
3131
3132 #[qjs(rename = "_deleteThemeSync")]
3134 pub fn delete_theme_sync(&self, name: String) -> bool {
3135 let themes_dir = self.services.config_dir().join("themes");
3137 let theme_path = themes_dir.join(format!("{}.json", name));
3138
3139 if let Ok(canonical) = theme_path.canonicalize() {
3141 if let Ok(themes_canonical) = themes_dir.canonicalize() {
3142 if canonical.starts_with(&themes_canonical) {
3143 return std::fs::remove_file(&canonical).is_ok();
3144 }
3145 }
3146 }
3147 false
3148 }
3149
3150 pub fn delete_theme(&self, name: String) -> bool {
3152 self.delete_theme_sync(name)
3153 }
3154
3155 pub fn get_theme_data<'js>(
3157 &self,
3158 ctx: rquickjs::Ctx<'js>,
3159 name: String,
3160 ) -> rquickjs::Result<Value<'js>> {
3161 match self.services.get_theme_data(&name) {
3162 Some(data) => rquickjs_serde::to_value(ctx, &data)
3163 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3164 None => Ok(Value::new_null(ctx)),
3165 }
3166 }
3167
3168 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3170 self.services
3171 .save_theme_file(&name, &content)
3172 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3173 }
3174
3175 pub fn theme_file_exists(&self, name: String) -> bool {
3177 self.services.theme_file_exists(&name)
3178 }
3179
3180 pub fn file_stat<'js>(
3184 &self,
3185 ctx: rquickjs::Ctx<'js>,
3186 path: String,
3187 ) -> rquickjs::Result<Value<'js>> {
3188 let metadata = std::fs::metadata(&path).ok();
3189 let stat = metadata.map(|m| {
3190 serde_json::json!({
3191 "isFile": m.is_file(),
3192 "isDir": m.is_dir(),
3193 "size": m.len(),
3194 "readonly": m.permissions().readonly(),
3195 })
3196 });
3197 rquickjs_serde::to_value(ctx, &stat)
3198 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3199 }
3200
3201 pub fn is_process_running(&self, _process_id: u64) -> bool {
3205 false
3208 }
3209
3210 pub fn kill_process(&self, process_id: u64) -> bool {
3212 self.command_sender
3213 .send(PluginCommand::KillBackgroundProcess { process_id })
3214 .is_ok()
3215 }
3216
3217 pub fn plugin_translate<'js>(
3221 &self,
3222 _ctx: rquickjs::Ctx<'js>,
3223 plugin_name: String,
3224 key: String,
3225 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3226 ) -> String {
3227 let args_map: HashMap<String, String> = args
3228 .0
3229 .map(|obj| {
3230 let mut map = HashMap::new();
3231 for (k, v) in obj.props::<String, String>().flatten() {
3232 map.insert(k, v);
3233 }
3234 map
3235 })
3236 .unwrap_or_default();
3237
3238 self.services.translate(&plugin_name, &key, &args_map)
3239 }
3240
3241 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3248 #[qjs(rename = "_createCompositeBufferStart")]
3249 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3250 let id = self.alloc_request_id();
3251
3252 if let Ok(mut owners) = self.async_resource_owners.lock() {
3254 owners.insert(id, self.plugin_name.clone());
3255 }
3256 let _ = self
3257 .command_sender
3258 .send(PluginCommand::CreateCompositeBuffer {
3259 name: opts.name,
3260 mode: opts.mode,
3261 layout: opts.layout,
3262 sources: opts.sources,
3263 hunks: opts.hunks,
3264 initial_focus_hunk: opts.initial_focus_hunk,
3265 request_id: Some(id),
3266 });
3267
3268 id
3269 }
3270
3271 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3275 self.command_sender
3276 .send(PluginCommand::UpdateCompositeAlignment {
3277 buffer_id: BufferId(buffer_id as usize),
3278 hunks,
3279 })
3280 .is_ok()
3281 }
3282
3283 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3285 self.command_sender
3286 .send(PluginCommand::CloseCompositeBuffer {
3287 buffer_id: BufferId(buffer_id as usize),
3288 })
3289 .is_ok()
3290 }
3291
3292 pub fn flush_layout(&self) -> bool {
3296 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3297 }
3298
3299 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3301 self.command_sender
3302 .send(PluginCommand::CompositeNextHunk {
3303 buffer_id: BufferId(buffer_id as usize),
3304 })
3305 .is_ok()
3306 }
3307
3308 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3310 self.command_sender
3311 .send(PluginCommand::CompositePrevHunk {
3312 buffer_id: BufferId(buffer_id as usize),
3313 })
3314 .is_ok()
3315 }
3316
3317 #[plugin_api(
3321 async_promise,
3322 js_name = "getHighlights",
3323 ts_return = "TsHighlightSpan[]"
3324 )]
3325 #[qjs(rename = "_getHighlightsStart")]
3326 pub fn get_highlights_start<'js>(
3327 &self,
3328 _ctx: rquickjs::Ctx<'js>,
3329 buffer_id: u32,
3330 start: u32,
3331 end: u32,
3332 ) -> rquickjs::Result<u64> {
3333 let id = self.alloc_request_id();
3334
3335 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3336 buffer_id: BufferId(buffer_id as usize),
3337 range: (start as usize)..(end as usize),
3338 request_id: id,
3339 });
3340
3341 Ok(id)
3342 }
3343
3344 pub fn add_overlay<'js>(
3366 &self,
3367 _ctx: rquickjs::Ctx<'js>,
3368 buffer_id: u32,
3369 namespace: String,
3370 start: u32,
3371 end: u32,
3372 options: rquickjs::Object<'js>,
3373 ) -> rquickjs::Result<bool> {
3374 use fresh_core::api::OverlayColorSpec;
3375
3376 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3378 if let Ok(theme_key) = obj.get::<_, String>(key) {
3380 if !theme_key.is_empty() {
3381 return Some(OverlayColorSpec::ThemeKey(theme_key));
3382 }
3383 }
3384 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3386 if arr.len() >= 3 {
3387 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3388 }
3389 }
3390 None
3391 }
3392
3393 let fg = parse_color_spec("fg", &options);
3394 let bg = parse_color_spec("bg", &options);
3395 let underline: bool = options.get("underline").unwrap_or(false);
3396 let bold: bool = options.get("bold").unwrap_or(false);
3397 let italic: bool = options.get("italic").unwrap_or(false);
3398 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3399 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3400 let fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
3401 let url: Option<String> = options.get("url").ok();
3402
3403 let options = OverlayOptions {
3404 fg,
3405 bg,
3406 underline,
3407 bold,
3408 italic,
3409 strikethrough,
3410 extend_to_line_end,
3411 fg_on_collision_only,
3412 url,
3413 };
3414
3415 self.plugin_tracked_state
3417 .borrow_mut()
3418 .entry(self.plugin_name.clone())
3419 .or_default()
3420 .overlay_namespaces
3421 .push((BufferId(buffer_id as usize), namespace.clone()));
3422
3423 let _ = self.command_sender.send(PluginCommand::AddOverlay {
3424 buffer_id: BufferId(buffer_id as usize),
3425 namespace: Some(OverlayNamespace::from_string(namespace)),
3426 range: (start as usize)..(end as usize),
3427 options,
3428 });
3429
3430 Ok(true)
3431 }
3432
3433 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3435 self.command_sender
3436 .send(PluginCommand::ClearNamespace {
3437 buffer_id: BufferId(buffer_id as usize),
3438 namespace: OverlayNamespace::from_string(namespace),
3439 })
3440 .is_ok()
3441 }
3442
3443 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3445 self.command_sender
3446 .send(PluginCommand::ClearAllOverlays {
3447 buffer_id: BufferId(buffer_id as usize),
3448 })
3449 .is_ok()
3450 }
3451
3452 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3454 self.command_sender
3455 .send(PluginCommand::ClearOverlaysInRange {
3456 buffer_id: BufferId(buffer_id as usize),
3457 start: start as usize,
3458 end: end as usize,
3459 })
3460 .is_ok()
3461 }
3462
3463 pub fn clear_overlays_in_range_for_namespace(
3465 &self,
3466 buffer_id: u32,
3467 namespace: String,
3468 start: u32,
3469 end: u32,
3470 ) -> bool {
3471 self.command_sender
3472 .send(PluginCommand::ClearOverlaysInRangeForNamespace {
3473 buffer_id: BufferId(buffer_id as usize),
3474 namespace: OverlayNamespace::from_string(namespace),
3475 start: start as usize,
3476 end: end as usize,
3477 })
3478 .is_ok()
3479 }
3480
3481 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3483 use fresh_core::overlay::OverlayHandle;
3484 self.command_sender
3485 .send(PluginCommand::RemoveOverlay {
3486 buffer_id: BufferId(buffer_id as usize),
3487 handle: OverlayHandle(handle),
3488 })
3489 .is_ok()
3490 }
3491
3492 pub fn add_conceal(
3496 &self,
3497 buffer_id: u32,
3498 namespace: String,
3499 start: u32,
3500 end: u32,
3501 replacement: Option<String>,
3502 ) -> bool {
3503 self.plugin_tracked_state
3505 .borrow_mut()
3506 .entry(self.plugin_name.clone())
3507 .or_default()
3508 .overlay_namespaces
3509 .push((BufferId(buffer_id as usize), namespace.clone()));
3510
3511 self.command_sender
3512 .send(PluginCommand::AddConceal {
3513 buffer_id: BufferId(buffer_id as usize),
3514 namespace: OverlayNamespace::from_string(namespace),
3515 start: start as usize,
3516 end: end as usize,
3517 replacement,
3518 })
3519 .is_ok()
3520 }
3521
3522 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3524 self.command_sender
3525 .send(PluginCommand::ClearConcealNamespace {
3526 buffer_id: BufferId(buffer_id as usize),
3527 namespace: OverlayNamespace::from_string(namespace),
3528 })
3529 .is_ok()
3530 }
3531
3532 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3534 self.command_sender
3535 .send(PluginCommand::ClearConcealsInRange {
3536 buffer_id: BufferId(buffer_id as usize),
3537 start: start as usize,
3538 end: end as usize,
3539 })
3540 .is_ok()
3541 }
3542
3543 pub fn add_fold(
3550 &self,
3551 buffer_id: u32,
3552 start: u32,
3553 end: u32,
3554 placeholder: rquickjs::function::Opt<String>,
3555 ) -> bool {
3556 self.command_sender
3557 .send(PluginCommand::AddFold {
3558 buffer_id: BufferId(buffer_id as usize),
3559 start: start as usize,
3560 end: end as usize,
3561 placeholder: placeholder.0,
3562 })
3563 .is_ok()
3564 }
3565
3566 pub fn clear_folds(&self, buffer_id: u32) -> bool {
3568 self.command_sender
3569 .send(PluginCommand::ClearFolds {
3570 buffer_id: BufferId(buffer_id as usize),
3571 })
3572 .is_ok()
3573 }
3574
3575 pub fn set_folding_ranges<'js>(
3588 &self,
3589 _ctx: rquickjs::Ctx<'js>,
3590 buffer_id: u32,
3591 ranges_arr: Vec<rquickjs::Object<'js>>,
3592 ) -> rquickjs::Result<bool> {
3593 let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3594 for obj in ranges_arr {
3595 let start_line: u32 = obj.get("startLine").unwrap_or(0);
3596 let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3597 let kind = obj
3598 .get::<_, String>("kind")
3599 .ok()
3600 .and_then(|s| match s.as_str() {
3601 "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3602 "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3603 "region" => Some(lsp_types::FoldingRangeKind::Region),
3604 _ => None,
3605 });
3606 ranges.push(lsp_types::FoldingRange {
3607 start_line,
3608 end_line,
3609 start_character: None,
3610 end_character: None,
3611 kind,
3612 collapsed_text: None,
3613 });
3614 }
3615 Ok(self
3616 .command_sender
3617 .send(PluginCommand::SetFoldingRanges {
3618 buffer_id: BufferId(buffer_id as usize),
3619 ranges,
3620 })
3621 .is_ok())
3622 }
3623
3624 pub fn add_soft_break(
3628 &self,
3629 buffer_id: u32,
3630 namespace: String,
3631 position: u32,
3632 indent: u32,
3633 ) -> bool {
3634 self.plugin_tracked_state
3636 .borrow_mut()
3637 .entry(self.plugin_name.clone())
3638 .or_default()
3639 .overlay_namespaces
3640 .push((BufferId(buffer_id as usize), namespace.clone()));
3641
3642 self.command_sender
3643 .send(PluginCommand::AddSoftBreak {
3644 buffer_id: BufferId(buffer_id as usize),
3645 namespace: OverlayNamespace::from_string(namespace),
3646 position: position as usize,
3647 indent: indent as u16,
3648 })
3649 .is_ok()
3650 }
3651
3652 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3654 self.command_sender
3655 .send(PluginCommand::ClearSoftBreakNamespace {
3656 buffer_id: BufferId(buffer_id as usize),
3657 namespace: OverlayNamespace::from_string(namespace),
3658 })
3659 .is_ok()
3660 }
3661
3662 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3664 self.command_sender
3665 .send(PluginCommand::ClearSoftBreaksInRange {
3666 buffer_id: BufferId(buffer_id as usize),
3667 start: start as usize,
3668 end: end as usize,
3669 })
3670 .is_ok()
3671 }
3672
3673 #[allow(clippy::too_many_arguments)]
3683 pub fn submit_view_transform<'js>(
3684 &self,
3685 _ctx: rquickjs::Ctx<'js>,
3686 buffer_id: u32,
3687 split_id: Option<u32>,
3688 start: u32,
3689 end: u32,
3690 tokens: Vec<rquickjs::Object<'js>>,
3691 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3692 ) -> rquickjs::Result<bool> {
3693 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3694
3695 let tokens: Vec<ViewTokenWire> = tokens
3696 .into_iter()
3697 .enumerate()
3698 .map(|(idx, obj)| {
3699 parse_view_token(&obj, idx)
3701 })
3702 .collect::<rquickjs::Result<Vec<_>>>()?;
3703
3704 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3706 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3707 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3708 Some(LayoutHints {
3709 compose_width,
3710 column_guides,
3711 })
3712 } else {
3713 None
3714 };
3715
3716 let payload = ViewTransformPayload {
3717 range: (start as usize)..(end as usize),
3718 tokens,
3719 layout_hints: parsed_layout_hints,
3720 };
3721
3722 Ok(self
3723 .command_sender
3724 .send(PluginCommand::SubmitViewTransform {
3725 buffer_id: BufferId(buffer_id as usize),
3726 split_id: split_id.map(|id| SplitId(id as usize)),
3727 payload,
3728 })
3729 .is_ok())
3730 }
3731
3732 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3734 self.command_sender
3735 .send(PluginCommand::ClearViewTransform {
3736 buffer_id: BufferId(buffer_id as usize),
3737 split_id: split_id.map(|id| SplitId(id as usize)),
3738 })
3739 .is_ok()
3740 }
3741
3742 pub fn set_layout_hints<'js>(
3745 &self,
3746 buffer_id: u32,
3747 split_id: Option<u32>,
3748 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3749 ) -> rquickjs::Result<bool> {
3750 use fresh_core::api::LayoutHints;
3751
3752 let compose_width: Option<u16> = hints.get("composeWidth").ok();
3753 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3754 let parsed_hints = LayoutHints {
3755 compose_width,
3756 column_guides,
3757 };
3758
3759 Ok(self
3760 .command_sender
3761 .send(PluginCommand::SetLayoutHints {
3762 buffer_id: BufferId(buffer_id as usize),
3763 split_id: split_id.map(|id| SplitId(id as usize)),
3764 range: 0..0,
3765 hints: parsed_hints,
3766 })
3767 .is_ok())
3768 }
3769
3770 pub fn set_file_explorer_decorations<'js>(
3774 &self,
3775 _ctx: rquickjs::Ctx<'js>,
3776 namespace: String,
3777 decorations: Vec<rquickjs::Object<'js>>,
3778 ) -> rquickjs::Result<bool> {
3779 use fresh_core::file_explorer::FileExplorerDecoration;
3780 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3781
3782 let decorations: Vec<FileExplorerDecoration> = decorations
3783 .into_iter()
3784 .map(|obj| {
3785 let path: String = obj.get("path")?;
3786 let symbol: String = obj.get("symbol")?;
3787 let priority: i32 = obj.get("priority").unwrap_or(0);
3788
3789 let color_val: rquickjs::Value = obj.get("color")?;
3791 let color = if color_val.is_string() {
3792 let key: String = color_val.get()?;
3793 fresh_core::api::OverlayColorSpec::ThemeKey(key)
3794 } else if color_val.is_array() {
3795 let arr: Vec<u8> = color_val.get()?;
3796 if arr.len() < 3 {
3797 return Err(rquickjs::Error::FromJs {
3798 from: "array",
3799 to: "color",
3800 message: Some(format!(
3801 "color array must have at least 3 elements, got {}",
3802 arr.len()
3803 )),
3804 });
3805 }
3806 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3807 } else {
3808 return Err(rquickjs::Error::FromJs {
3809 from: "value",
3810 to: "color",
3811 message: Some("color must be an RGB array or theme key string".to_string()),
3812 });
3813 };
3814
3815 Ok(FileExplorerDecoration {
3816 path: std::path::PathBuf::from(path),
3817 symbol,
3818 color,
3819 priority,
3820 })
3821 })
3822 .collect::<rquickjs::Result<Vec<_>>>()?;
3823
3824 self.plugin_tracked_state
3826 .borrow_mut()
3827 .entry(self.plugin_name.clone())
3828 .or_default()
3829 .file_explorer_namespaces
3830 .push(scoped_namespace.clone());
3831
3832 Ok(self
3833 .command_sender
3834 .send(PluginCommand::SetFileExplorerDecorations {
3835 namespace: scoped_namespace,
3836 decorations,
3837 })
3838 .is_ok())
3839 }
3840
3841 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3843 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3844 self.command_sender
3845 .send(PluginCommand::ClearFileExplorerDecorations {
3846 namespace: scoped_namespace,
3847 })
3848 .is_ok()
3849 }
3850
3851 pub fn set_file_explorer_slots<'js>(
3853 &self,
3854 ctx: rquickjs::Ctx<'js>,
3855 namespace: String,
3856 slots: Vec<rquickjs::Object<'js>>,
3857 ) -> rquickjs::Result<bool> {
3858 use fresh_core::file_explorer::FileExplorerSlotEntry;
3859 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3860
3861 let slots: Vec<FileExplorerSlotEntry> = slots
3862 .into_iter()
3863 .map(|obj| <FileExplorerSlotEntry as rquickjs::FromJs>::from_js(&ctx, obj.into()))
3864 .collect::<rquickjs::Result<Vec<_>>>()?;
3865
3866 self.plugin_tracked_state
3867 .borrow_mut()
3868 .entry(self.plugin_name.clone())
3869 .or_default()
3870 .file_explorer_namespaces
3871 .push(scoped_namespace.clone());
3872
3873 Ok(self
3874 .command_sender
3875 .send(PluginCommand::SetFileExplorerSlots {
3876 namespace: scoped_namespace,
3877 slots,
3878 })
3879 .is_ok())
3880 }
3881
3882 pub fn clear_file_explorer_slots(&self, namespace: String) -> bool {
3884 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3885 self.command_sender
3886 .send(PluginCommand::ClearFileExplorerSlots {
3887 namespace: scoped_namespace,
3888 })
3889 .is_ok()
3890 }
3891
3892 #[allow(clippy::too_many_arguments)]
3896 pub fn add_virtual_text(
3897 &self,
3898 buffer_id: u32,
3899 virtual_text_id: String,
3900 position: u32,
3901 text: String,
3902 r: u8,
3903 g: u8,
3904 b: u8,
3905 before: bool,
3906 use_bg: bool,
3907 ) -> bool {
3908 self.plugin_tracked_state
3910 .borrow_mut()
3911 .entry(self.plugin_name.clone())
3912 .or_default()
3913 .virtual_text_ids
3914 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3915
3916 self.command_sender
3917 .send(PluginCommand::AddVirtualText {
3918 buffer_id: BufferId(buffer_id as usize),
3919 virtual_text_id,
3920 position: position as usize,
3921 text,
3922 color: (r, g, b),
3923 use_bg,
3924 before,
3925 })
3926 .is_ok()
3927 }
3928
3929 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
3931 self.command_sender
3932 .send(PluginCommand::RemoveVirtualText {
3933 buffer_id: BufferId(buffer_id as usize),
3934 virtual_text_id,
3935 })
3936 .is_ok()
3937 }
3938
3939 #[allow(clippy::too_many_arguments)]
3945 pub fn add_virtual_text_styled<'js>(
3946 &self,
3947 _ctx: rquickjs::Ctx<'js>,
3948 buffer_id: u32,
3949 virtual_text_id: String,
3950 position: u32,
3951 text: String,
3952 options: rquickjs::Object<'js>,
3953 before: bool,
3954 ) -> rquickjs::Result<bool> {
3955 use fresh_core::api::OverlayColorSpec;
3956
3957 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3960 if let Ok(theme_key) = obj.get::<_, String>(key) {
3961 if !theme_key.is_empty() {
3962 return Some(OverlayColorSpec::ThemeKey(theme_key));
3963 }
3964 }
3965 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3966 if arr.len() >= 3 {
3967 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3968 }
3969 }
3970 None
3971 }
3972
3973 let fg = parse_color_spec("fg", &options);
3974 let bg = parse_color_spec("bg", &options);
3975 let bold: bool = options.get("bold").unwrap_or(false);
3976 let italic: bool = options.get("italic").unwrap_or(false);
3977
3978 self.plugin_tracked_state
3980 .borrow_mut()
3981 .entry(self.plugin_name.clone())
3982 .or_default()
3983 .virtual_text_ids
3984 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3985
3986 let _ = self
3987 .command_sender
3988 .send(PluginCommand::AddVirtualTextStyled {
3989 buffer_id: BufferId(buffer_id as usize),
3990 virtual_text_id,
3991 position: position as usize,
3992 text,
3993 fg,
3994 bg,
3995 bold,
3996 italic,
3997 before,
3998 });
3999 Ok(true)
4000 }
4001
4002 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
4004 self.command_sender
4005 .send(PluginCommand::RemoveVirtualTextsByPrefix {
4006 buffer_id: BufferId(buffer_id as usize),
4007 prefix,
4008 })
4009 .is_ok()
4010 }
4011
4012 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
4014 self.command_sender
4015 .send(PluginCommand::ClearVirtualTexts {
4016 buffer_id: BufferId(buffer_id as usize),
4017 })
4018 .is_ok()
4019 }
4020
4021 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
4023 self.command_sender
4024 .send(PluginCommand::ClearVirtualTextNamespace {
4025 buffer_id: BufferId(buffer_id as usize),
4026 namespace,
4027 })
4028 .is_ok()
4029 }
4030
4031 #[allow(clippy::too_many_arguments)]
4046 pub fn add_virtual_line<'js>(
4047 &self,
4048 _ctx: rquickjs::Ctx<'js>,
4049 buffer_id: u32,
4050 position: u32,
4051 text: String,
4052 options: rquickjs::Object<'js>,
4053 above: bool,
4054 namespace: String,
4055 priority: i32,
4056 ) -> rquickjs::Result<bool> {
4057 use fresh_core::api::OverlayColorSpec;
4058
4059 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
4062 if let Ok(theme_key) = obj.get::<_, String>(key) {
4063 if !theme_key.is_empty() {
4064 return Some(OverlayColorSpec::ThemeKey(theme_key));
4065 }
4066 }
4067 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
4068 if arr.len() >= 3 {
4069 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
4070 }
4071 }
4072 None
4073 }
4074
4075 let fg_color = parse_color_spec("fg", &options);
4076 let bg_color = parse_color_spec("bg", &options);
4077 let gutter_glyph = options
4078 .get::<_, String>("gutterGlyph")
4079 .ok()
4080 .filter(|s| !s.is_empty());
4081 let gutter_color = parse_color_spec("gutterColor", &options);
4082
4083 let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
4089 .get::<_, rquickjs::Value<'js>>("textOverlays")
4090 .ok()
4091 .filter(|v| !v.is_undefined() && !v.is_null())
4092 .and_then(|v| rquickjs_serde::from_value(v).ok())
4093 .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
4094 v.into_iter().filter(|o| o.end > o.start).collect()
4095 })
4096 .unwrap_or_default();
4097
4098 self.plugin_tracked_state
4100 .borrow_mut()
4101 .entry(self.plugin_name.clone())
4102 .or_default()
4103 .virtual_line_namespaces
4104 .push((BufferId(buffer_id as usize), namespace.clone()));
4105
4106 Ok(self
4107 .command_sender
4108 .send(PluginCommand::AddVirtualLine {
4109 buffer_id: BufferId(buffer_id as usize),
4110 position: position as usize,
4111 text,
4112 fg_color,
4113 bg_color,
4114 above,
4115 namespace,
4116 priority,
4117 gutter_glyph,
4118 gutter_color,
4119 text_overlays,
4120 })
4121 .is_ok())
4122 }
4123
4124 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
4129 #[qjs(rename = "_promptStart")]
4130 pub fn prompt_start(
4131 &self,
4132 _ctx: rquickjs::Ctx<'_>,
4133 label: String,
4134 initial_value: String,
4135 ) -> u64 {
4136 let id = self.alloc_request_id();
4137
4138 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
4139 label,
4140 initial_value,
4141 callback_id: JsCallbackId::new(id),
4142 });
4143
4144 id
4145 }
4146
4147 pub fn start_prompt(
4158 &self,
4159 label: String,
4160 prompt_type: String,
4161 floating_overlay: rquickjs::function::Opt<bool>,
4162 ) -> bool {
4163 self.command_sender
4164 .send(PluginCommand::StartPrompt {
4165 label,
4166 prompt_type,
4167 floating_overlay: floating_overlay.0.unwrap_or(false),
4168 })
4169 .is_ok()
4170 }
4171
4172 pub fn begin_key_capture(&self) -> bool {
4182 self.command_sender
4183 .send(PluginCommand::SetKeyCaptureActive { active: true })
4184 .is_ok()
4185 }
4186
4187 pub fn end_key_capture(&self) -> bool {
4191 self.command_sender
4192 .send(PluginCommand::SetKeyCaptureActive { active: false })
4193 .is_ok()
4194 }
4195
4196 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4208 #[qjs(rename = "_getNextKeyStart")]
4209 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4210 let id = self.alloc_request_id();
4211 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4212 callback_id: JsCallbackId::new(id),
4213 });
4214 id
4215 }
4216
4217 pub fn start_prompt_with_initial(
4220 &self,
4221 label: String,
4222 prompt_type: String,
4223 initial_value: String,
4224 floating_overlay: rquickjs::function::Opt<bool>,
4225 ) -> bool {
4226 self.command_sender
4227 .send(PluginCommand::StartPromptWithInitial {
4228 label,
4229 prompt_type,
4230 initial_value,
4231 floating_overlay: floating_overlay.0.unwrap_or(false),
4232 })
4233 .is_ok()
4234 }
4235
4236 pub fn set_prompt_suggestions(
4246 &self,
4247 suggestions: Vec<fresh_core::command::Suggestion>,
4248 selected_index: rquickjs::function::Opt<Option<u32>>,
4249 ) -> bool {
4250 self.command_sender
4251 .send(PluginCommand::SetPromptSuggestions {
4252 suggestions,
4253 selected_index: selected_index.0.flatten(),
4254 })
4255 .is_ok()
4256 }
4257
4258 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4259 self.command_sender
4260 .send(PluginCommand::SetPromptInputSync { sync })
4261 .is_ok()
4262 }
4263
4264 pub fn set_prompt_title(
4274 &self,
4275 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4276 ) -> bool {
4277 self.command_sender
4278 .send(PluginCommand::SetPromptTitle { title })
4279 .is_ok()
4280 }
4281
4282 pub fn set_prompt_footer(
4288 &self,
4289 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4290 ) -> bool {
4291 self.command_sender
4292 .send(PluginCommand::SetPromptFooter { footer })
4293 .is_ok()
4294 }
4295
4296 pub fn set_prompt_status(&self, status: String) -> bool {
4299 self.command_sender
4300 .send(PluginCommand::SetPromptStatus { status })
4301 .is_ok()
4302 }
4303
4304 #[qjs(rename = "setPromptToolbar")]
4308 pub fn set_prompt_toolbar<'js>(
4309 &self,
4310 ctx: rquickjs::Ctx<'js>,
4311 spec_obj: rquickjs::Value<'js>,
4312 ) -> rquickjs::Result<bool> {
4313 let spec = if spec_obj.is_null() || spec_obj.is_undefined() {
4314 None
4315 } else {
4316 let json = js_to_json(&ctx, spec_obj);
4317 match serde_json::from_value::<fresh_core::api::WidgetSpec>(json) {
4318 Ok(s) => Some(s),
4319 Err(e) => {
4320 tracing::error!("setPromptToolbar: invalid spec: {}", e);
4321 return Ok(false);
4322 }
4323 }
4324 };
4325 Ok(self
4326 .command_sender
4327 .send(PluginCommand::SetPromptToolbar { spec })
4328 .is_ok())
4329 }
4330
4331 #[qjs(rename = "toggleOverlayToolbarWidget")]
4336 pub fn toggle_overlay_toolbar_widget(&self, key: String) -> bool {
4337 self.command_sender
4338 .send(PluginCommand::ToggleOverlayToolbarWidget { key })
4339 .is_ok()
4340 }
4341
4342 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4350 self.command_sender
4351 .send(PluginCommand::SetPromptSelectedIndex { index })
4352 .is_ok()
4353 }
4354
4355 pub fn define_mode(
4359 &self,
4360 name: String,
4361 bindings_arr: Vec<Vec<String>>,
4362 read_only: rquickjs::function::Opt<bool>,
4363 allow_text_input: rquickjs::function::Opt<bool>,
4364 inherit_normal_bindings: rquickjs::function::Opt<bool>,
4365 ) -> bool {
4366 let bindings: Vec<(String, String)> = bindings_arr
4367 .into_iter()
4368 .filter_map(|arr| {
4369 if arr.len() >= 2 {
4370 Some((arr[0].clone(), arr[1].clone()))
4371 } else {
4372 None
4373 }
4374 })
4375 .collect();
4376
4377 {
4380 let mut registered = self.registered_actions.borrow_mut();
4381 for (_, cmd_name) in &bindings {
4382 registered.insert(
4383 cmd_name.clone(),
4384 PluginHandler {
4385 plugin_name: self.plugin_name.clone(),
4386 handler_name: cmd_name.clone(),
4387 },
4388 );
4389 }
4390 }
4391
4392 let allow_text = allow_text_input.0.unwrap_or(false);
4395 if allow_text {
4396 let mut registered = self.registered_actions.borrow_mut();
4397 registered.insert(
4398 "mode_text_input".to_string(),
4399 PluginHandler {
4400 plugin_name: self.plugin_name.clone(),
4401 handler_name: "mode_text_input".to_string(),
4402 },
4403 );
4404 }
4405
4406 self.command_sender
4407 .send(PluginCommand::DefineMode {
4408 name,
4409 bindings,
4410 read_only: read_only.0.unwrap_or(false),
4411 allow_text_input: allow_text,
4412 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4413 plugin_name: Some(self.plugin_name.clone()),
4414 })
4415 .is_ok()
4416 }
4417
4418 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4420 self.command_sender
4421 .send(PluginCommand::SetEditorMode { mode })
4422 .is_ok()
4423 }
4424
4425 pub fn get_editor_mode(&self) -> Option<String> {
4427 self.state_snapshot
4428 .read()
4429 .ok()
4430 .and_then(|s| s.editor_mode.clone())
4431 }
4432
4433 pub fn close_split(&self, split_id: u32) -> bool {
4437 self.command_sender
4438 .send(PluginCommand::CloseSplit {
4439 split_id: SplitId(split_id as usize),
4440 })
4441 .is_ok()
4442 }
4443
4444 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4446 self.command_sender
4447 .send(PluginCommand::SetSplitBuffer {
4448 split_id: SplitId(split_id as usize),
4449 buffer_id: BufferId(buffer_id as usize),
4450 })
4451 .is_ok()
4452 }
4453
4454 pub fn focus_split(&self, split_id: u32) -> bool {
4456 self.command_sender
4457 .send(PluginCommand::FocusSplit {
4458 split_id: SplitId(split_id as usize),
4459 })
4460 .is_ok()
4461 }
4462
4463 pub fn create_window(&self, root: String, label: String) -> bool {
4482 self.command_sender
4483 .send(PluginCommand::CreateWindow {
4484 root: std::path::PathBuf::from(root),
4485 label,
4486 })
4487 .is_ok()
4488 }
4489
4490 pub fn set_active_window(&self, id: u64) -> bool {
4495 self.command_sender
4496 .send(PluginCommand::SetActiveWindow {
4497 id: fresh_core::WindowId(id),
4498 })
4499 .is_ok()
4500 }
4501
4502 #[qjs(rename = "setActiveWindowAnimated")]
4506 pub fn set_active_window_animated(&self, id: u64, from_edge: String) -> bool {
4507 self.command_sender
4508 .send(PluginCommand::SetActiveWindowAnimated {
4509 id: fresh_core::WindowId(id),
4510 from_edge,
4511 })
4512 .is_ok()
4513 }
4514
4515 #[qjs(rename = "setWindowCycleOrder")]
4520 pub fn set_window_cycle_order(&self, ids: Vec<i64>) -> bool {
4521 self.command_sender
4522 .send(PluginCommand::SetWindowCycleOrder {
4523 ids: ids
4524 .into_iter()
4525 .filter(|n| *n > 0)
4526 .map(|n| fresh_core::WindowId(n as u64))
4527 .collect(),
4528 })
4529 .is_ok()
4530 }
4531
4532 pub fn close_window(&self, id: u64) -> bool {
4535 self.command_sender
4536 .send(PluginCommand::CloseWindow {
4537 id: fresh_core::WindowId(id),
4538 })
4539 .is_ok()
4540 }
4541
4542 pub fn prewarm_window(&self, id: u64) -> bool {
4546 self.command_sender
4547 .send(PluginCommand::PrewarmWindow {
4548 id: fresh_core::WindowId(id),
4549 })
4550 .is_ok()
4551 }
4552
4553 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4565 #[qjs(rename = "_watchPathStart")]
4566 pub fn watch_path_start(
4567 &self,
4568 _ctx: rquickjs::Ctx<'_>,
4569 path: String,
4570 recursive: rquickjs::function::Opt<bool>,
4571 ) -> rquickjs::Result<u64> {
4572 let id = self.alloc_request_id();
4573 if let Ok(mut owners) = self.async_resource_owners.lock() {
4574 owners.insert(id, self.plugin_name.clone());
4575 }
4576 let _ = self.command_sender.send(PluginCommand::WatchPath {
4577 path: std::path::PathBuf::from(path),
4578 recursive: recursive.0.unwrap_or(false),
4579 request_id: id,
4580 });
4581 Ok(id)
4582 }
4583
4584 pub fn unwatch_path(&self, handle: u64) -> bool {
4587 self.command_sender
4588 .send(PluginCommand::UnwatchPath { handle })
4589 .is_ok()
4590 }
4591
4592 pub fn preview_window_in_rect(&self, id: u64) -> bool {
4603 let sid = if id == 0 {
4604 None
4605 } else {
4606 Some(fresh_core::WindowId(id))
4607 };
4608 self.command_sender
4609 .send(PluginCommand::PreviewWindowInRect { id: sid })
4610 .is_ok()
4611 }
4612
4613 pub fn clear_window_preview(&self) -> bool {
4616 self.command_sender
4617 .send(PluginCommand::PreviewWindowInRect { id: None })
4618 .is_ok()
4619 }
4620
4621 #[plugin_api(ts_return = "WindowInfo[]")]
4624 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4625 let sessions: Vec<fresh_core::api::WindowInfo> = self
4626 .state_snapshot
4627 .read()
4628 .map(|s| s.windows.clone())
4629 .unwrap_or_default();
4630 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4631 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4632 })
4633 }
4634
4635 pub fn active_window(&self) -> u64 {
4638 self.state_snapshot
4639 .read()
4640 .map(|s| s.active_window_id.0)
4641 .unwrap_or(1)
4642 }
4643
4644 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4646 self.command_sender
4647 .send(PluginCommand::SetSplitScroll {
4648 split_id: SplitId(split_id as usize),
4649 top_byte: top_byte as usize,
4650 })
4651 .is_ok()
4652 }
4653
4654 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4656 self.command_sender
4657 .send(PluginCommand::SetSplitRatio {
4658 split_id: SplitId(split_id as usize),
4659 ratio,
4660 })
4661 .is_ok()
4662 }
4663
4664 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4666 self.command_sender
4667 .send(PluginCommand::SetSplitLabel {
4668 split_id: SplitId(split_id as usize),
4669 label,
4670 })
4671 .is_ok()
4672 }
4673
4674 pub fn clear_split_label(&self, split_id: u32) -> bool {
4676 self.command_sender
4677 .send(PluginCommand::ClearSplitLabel {
4678 split_id: SplitId(split_id as usize),
4679 })
4680 .is_ok()
4681 }
4682
4683 #[plugin_api(
4685 async_promise,
4686 js_name = "getSplitByLabel",
4687 ts_return = "number | null"
4688 )]
4689 #[qjs(rename = "_getSplitByLabelStart")]
4690 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4691 let id = self.alloc_request_id();
4692 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4693 label,
4694 request_id: id,
4695 });
4696 id
4697 }
4698
4699 pub fn distribute_splits_evenly(&self) -> bool {
4701 self.command_sender
4703 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4704 .is_ok()
4705 }
4706
4707 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4709 self.command_sender
4710 .send(PluginCommand::SetBufferCursor {
4711 buffer_id: BufferId(buffer_id as usize),
4712 position: position as usize,
4713 })
4714 .is_ok()
4715 }
4716
4717 #[qjs(rename = "setBufferShowCursors")]
4724 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4725 self.command_sender
4726 .send(PluginCommand::SetBufferShowCursors {
4727 buffer_id: BufferId(buffer_id as usize),
4728 show,
4729 })
4730 .is_ok()
4731 }
4732
4733 #[allow(clippy::too_many_arguments)]
4737 pub fn set_line_indicator(
4738 &self,
4739 buffer_id: u32,
4740 line: u32,
4741 namespace: String,
4742 symbol: String,
4743 r: u8,
4744 g: u8,
4745 b: u8,
4746 priority: i32,
4747 ) -> bool {
4748 self.plugin_tracked_state
4750 .borrow_mut()
4751 .entry(self.plugin_name.clone())
4752 .or_default()
4753 .line_indicator_namespaces
4754 .push((BufferId(buffer_id as usize), namespace.clone()));
4755
4756 self.command_sender
4757 .send(PluginCommand::SetLineIndicator {
4758 buffer_id: BufferId(buffer_id as usize),
4759 line: line as usize,
4760 namespace,
4761 symbol,
4762 color: (r, g, b),
4763 priority,
4764 })
4765 .is_ok()
4766 }
4767
4768 #[allow(clippy::too_many_arguments)]
4770 pub fn set_line_indicators(
4771 &self,
4772 buffer_id: u32,
4773 lines: Vec<u32>,
4774 namespace: String,
4775 symbol: String,
4776 r: u8,
4777 g: u8,
4778 b: u8,
4779 priority: i32,
4780 ) -> bool {
4781 self.plugin_tracked_state
4783 .borrow_mut()
4784 .entry(self.plugin_name.clone())
4785 .or_default()
4786 .line_indicator_namespaces
4787 .push((BufferId(buffer_id as usize), namespace.clone()));
4788
4789 self.command_sender
4790 .send(PluginCommand::SetLineIndicators {
4791 buffer_id: BufferId(buffer_id as usize),
4792 lines: lines.into_iter().map(|l| l as usize).collect(),
4793 namespace,
4794 symbol,
4795 color: (r, g, b),
4796 priority,
4797 })
4798 .is_ok()
4799 }
4800
4801 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4803 self.command_sender
4804 .send(PluginCommand::ClearLineIndicators {
4805 buffer_id: BufferId(buffer_id as usize),
4806 namespace,
4807 })
4808 .is_ok()
4809 }
4810
4811 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4813 self.command_sender
4814 .send(PluginCommand::SetLineNumbers {
4815 buffer_id: BufferId(buffer_id as usize),
4816 enabled,
4817 })
4818 .is_ok()
4819 }
4820
4821 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4823 self.command_sender
4824 .send(PluginCommand::SetViewMode {
4825 buffer_id: BufferId(buffer_id as usize),
4826 mode,
4827 })
4828 .is_ok()
4829 }
4830
4831 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4833 self.command_sender
4834 .send(PluginCommand::SetLineWrap {
4835 buffer_id: BufferId(buffer_id as usize),
4836 split_id: split_id.map(|s| SplitId(s as usize)),
4837 enabled,
4838 })
4839 .is_ok()
4840 }
4841
4842 pub fn set_view_state<'js>(
4846 &self,
4847 ctx: rquickjs::Ctx<'js>,
4848 buffer_id: u32,
4849 key: String,
4850 value: Value<'js>,
4851 ) -> bool {
4852 let bid = BufferId(buffer_id as usize);
4853
4854 let json_value = if value.is_undefined() || value.is_null() {
4856 None
4857 } else {
4858 Some(js_to_json(&ctx, value))
4859 };
4860
4861 if let Ok(mut snapshot) = self.state_snapshot.write() {
4863 if let Some(ref json_val) = json_value {
4864 snapshot
4865 .plugin_view_states
4866 .entry(bid)
4867 .or_default()
4868 .insert(key.clone(), json_val.clone());
4869 } else {
4870 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4872 map.remove(&key);
4873 if map.is_empty() {
4874 snapshot.plugin_view_states.remove(&bid);
4875 }
4876 }
4877 }
4878 }
4879
4880 self.command_sender
4882 .send(PluginCommand::SetViewState {
4883 buffer_id: bid,
4884 key,
4885 value: json_value,
4886 })
4887 .is_ok()
4888 }
4889
4890 pub fn get_view_state<'js>(
4892 &self,
4893 ctx: rquickjs::Ctx<'js>,
4894 buffer_id: u32,
4895 key: String,
4896 ) -> rquickjs::Result<Value<'js>> {
4897 let bid = BufferId(buffer_id as usize);
4898 if let Ok(snapshot) = self.state_snapshot.read() {
4899 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4900 if let Some(json_val) = map.get(&key) {
4901 return json_to_js_value(&ctx, json_val);
4902 }
4903 }
4904 }
4905 Ok(Value::new_undefined(ctx.clone()))
4906 }
4907
4908 pub fn set_global_state<'js>(
4914 &self,
4915 ctx: rquickjs::Ctx<'js>,
4916 key: String,
4917 value: Value<'js>,
4918 ) -> bool {
4919 let json_value = if value.is_undefined() || value.is_null() {
4921 None
4922 } else {
4923 Some(js_to_json(&ctx, value))
4924 };
4925
4926 if let Ok(mut snapshot) = self.state_snapshot.write() {
4928 if let Some(ref json_val) = json_value {
4929 snapshot
4930 .plugin_global_states
4931 .entry(self.plugin_name.clone())
4932 .or_default()
4933 .insert(key.clone(), json_val.clone());
4934 } else {
4935 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
4937 map.remove(&key);
4938 if map.is_empty() {
4939 snapshot.plugin_global_states.remove(&self.plugin_name);
4940 }
4941 }
4942 }
4943 }
4944
4945 self.command_sender
4947 .send(PluginCommand::SetGlobalState {
4948 plugin_name: self.plugin_name.clone(),
4949 key,
4950 value: json_value,
4951 })
4952 .is_ok()
4953 }
4954
4955 pub fn get_global_state<'js>(
4959 &self,
4960 ctx: rquickjs::Ctx<'js>,
4961 key: String,
4962 ) -> rquickjs::Result<Value<'js>> {
4963 if let Ok(snapshot) = self.state_snapshot.read() {
4964 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
4965 if let Some(json_val) = map.get(&key) {
4966 return json_to_js_value(&ctx, json_val);
4967 }
4968 }
4969 }
4970 Ok(Value::new_undefined(ctx.clone()))
4971 }
4972
4973 pub fn set_window_state<'js>(
4982 &self,
4983 ctx: rquickjs::Ctx<'js>,
4984 key: String,
4985 value: Value<'js>,
4986 ) -> bool {
4987 let json_value = if value.is_undefined() || value.is_null() {
4988 None
4989 } else {
4990 Some(js_to_json(&ctx, value))
4991 };
4992 if let Ok(mut snapshot) = self.state_snapshot.write() {
4996 match &json_value {
4997 Some(v) => {
4998 snapshot
4999 .active_session_plugin_states
5000 .entry(self.plugin_name.clone())
5001 .or_default()
5002 .insert(key.clone(), v.clone());
5003 }
5004 None => {
5005 if let Some(map) = snapshot
5006 .active_session_plugin_states
5007 .get_mut(&self.plugin_name)
5008 {
5009 map.remove(&key);
5010 if map.is_empty() {
5011 snapshot
5012 .active_session_plugin_states
5013 .remove(&self.plugin_name);
5014 }
5015 }
5016 }
5017 }
5018 }
5019 self.command_sender
5020 .send(PluginCommand::SetWindowState {
5021 plugin_name: self.plugin_name.clone(),
5022 key,
5023 value: json_value,
5024 })
5025 .is_ok()
5026 }
5027
5028 pub fn get_window_state<'js>(
5031 &self,
5032 ctx: rquickjs::Ctx<'js>,
5033 key: String,
5034 ) -> rquickjs::Result<Value<'js>> {
5035 if let Ok(snapshot) = self.state_snapshot.read() {
5036 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
5037 if let Some(json_val) = map.get(&key) {
5038 return json_to_js_value(&ctx, json_val);
5039 }
5040 }
5041 }
5042 Ok(Value::new_undefined(ctx.clone()))
5043 }
5044
5045 pub fn create_scroll_sync_group(
5049 &self,
5050 group_id: u32,
5051 left_split: u32,
5052 right_split: u32,
5053 ) -> bool {
5054 self.plugin_tracked_state
5056 .borrow_mut()
5057 .entry(self.plugin_name.clone())
5058 .or_default()
5059 .scroll_sync_group_ids
5060 .push(group_id);
5061 self.command_sender
5062 .send(PluginCommand::CreateScrollSyncGroup {
5063 group_id,
5064 left_split: SplitId(left_split as usize),
5065 right_split: SplitId(right_split as usize),
5066 })
5067 .is_ok()
5068 }
5069
5070 pub fn set_scroll_sync_anchors<'js>(
5072 &self,
5073 _ctx: rquickjs::Ctx<'js>,
5074 group_id: u32,
5075 anchors: Vec<Vec<u32>>,
5076 ) -> bool {
5077 let anchors: Vec<(usize, usize)> = anchors
5078 .into_iter()
5079 .filter_map(|pair| {
5080 if pair.len() >= 2 {
5081 Some((pair[0] as usize, pair[1] as usize))
5082 } else {
5083 None
5084 }
5085 })
5086 .collect();
5087 self.command_sender
5088 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
5089 .is_ok()
5090 }
5091
5092 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
5094 self.command_sender
5095 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
5096 .is_ok()
5097 }
5098
5099 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
5105 self.command_sender
5106 .send(PluginCommand::ExecuteActions { actions })
5107 .is_ok()
5108 }
5109
5110 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
5114 self.command_sender
5115 .send(PluginCommand::ShowActionPopup {
5116 popup_id: opts.id,
5117 title: opts.title,
5118 message: opts.message,
5119 actions: opts.actions,
5120 })
5121 .is_ok()
5122 }
5123
5124 pub fn set_lsp_menu_contributions(
5128 &self,
5129 plugin_id: String,
5130 language: String,
5131 items: Vec<fresh_core::api::LspMenuItem>,
5132 ) -> bool {
5133 self.command_sender
5134 .send(PluginCommand::SetLspMenuContributions {
5135 plugin_id,
5136 language,
5137 items,
5138 })
5139 .is_ok()
5140 }
5141
5142 pub fn disable_lsp_for_language(&self, language: String) -> bool {
5144 self.command_sender
5145 .send(PluginCommand::DisableLspForLanguage { language })
5146 .is_ok()
5147 }
5148
5149 pub fn restart_lsp_for_language(&self, language: String) -> bool {
5151 self.command_sender
5152 .send(PluginCommand::RestartLspForLanguage { language })
5153 .is_ok()
5154 }
5155
5156 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
5159 self.command_sender
5160 .send(PluginCommand::SetLspRootUri { language, uri })
5161 .is_ok()
5162 }
5163
5164 #[plugin_api(ts_return = "JsDiagnostic[]")]
5166 pub fn get_all_diagnostics<'js>(
5167 &self,
5168 ctx: rquickjs::Ctx<'js>,
5169 ) -> rquickjs::Result<Value<'js>> {
5170 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
5171
5172 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
5173 let mut result: Vec<JsDiagnostic> = Vec::new();
5175 for (uri, diags) in s.diagnostics.iter() {
5176 for diag in diags {
5177 result.push(JsDiagnostic {
5178 uri: uri.clone(),
5179 message: diag.message.clone(),
5180 severity: diag.severity.map(|s| match s {
5181 lsp_types::DiagnosticSeverity::ERROR => 1,
5182 lsp_types::DiagnosticSeverity::WARNING => 2,
5183 lsp_types::DiagnosticSeverity::INFORMATION => 3,
5184 lsp_types::DiagnosticSeverity::HINT => 4,
5185 _ => 0,
5186 }),
5187 range: JsRange {
5188 start: JsPosition {
5189 line: diag.range.start.line,
5190 character: diag.range.start.character,
5191 },
5192 end: JsPosition {
5193 line: diag.range.end.line,
5194 character: diag.range.end.character,
5195 },
5196 },
5197 source: diag.source.clone(),
5198 });
5199 }
5200 }
5201 result
5202 } else {
5203 Vec::new()
5204 };
5205 rquickjs_serde::to_value(ctx, &diagnostics)
5206 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5207 }
5208
5209 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
5211 self.event_handlers
5212 .read()
5213 .expect("event_handlers poisoned")
5214 .get(&event_name)
5215 .cloned()
5216 .unwrap_or_default()
5217 .into_iter()
5218 .map(|h| h.handler_name)
5219 .collect()
5220 }
5221
5222 #[plugin_api(
5226 async_promise,
5227 js_name = "createVirtualBuffer",
5228 ts_return = "VirtualBufferResult"
5229 )]
5230 #[qjs(rename = "_createVirtualBufferStart")]
5231 pub fn create_virtual_buffer_start(
5232 &self,
5233 _ctx: rquickjs::Ctx<'_>,
5234 opts: fresh_core::api::CreateVirtualBufferOptions,
5235 ) -> rquickjs::Result<u64> {
5236 let id = self.alloc_request_id();
5237
5238 let entries: Vec<TextPropertyEntry> = opts
5240 .entries
5241 .unwrap_or_default()
5242 .into_iter()
5243 .map(|e| TextPropertyEntry {
5244 text: e.text,
5245 properties: e.properties.unwrap_or_default(),
5246 style: e.style,
5247 inline_overlays: e.inline_overlays.unwrap_or_default(),
5248 segments: e.segments.unwrap_or_default(),
5249 pad_to_chars: e.pad_to_chars,
5250 truncate_to_chars: e.truncate_to_chars,
5251 })
5252 .collect();
5253
5254 tracing::debug!(
5255 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
5256 id
5257 );
5258 if let Ok(mut owners) = self.async_resource_owners.lock() {
5260 owners.insert(id, self.plugin_name.clone());
5261 }
5262 let _ = self
5263 .command_sender
5264 .send(PluginCommand::CreateVirtualBufferWithContent {
5265 name: opts.name,
5266 mode: opts.mode.unwrap_or_default(),
5267 read_only: opts.read_only.unwrap_or(false),
5268 entries,
5269 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5270 show_cursors: opts.show_cursors.unwrap_or(true),
5271 editing_disabled: opts.editing_disabled.unwrap_or(false),
5272 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5273 request_id: Some(id),
5274 });
5275 Ok(id)
5276 }
5277
5278 #[plugin_api(
5280 async_promise,
5281 js_name = "createVirtualBufferInSplit",
5282 ts_return = "VirtualBufferResult"
5283 )]
5284 #[qjs(rename = "_createVirtualBufferInSplitStart")]
5285 pub fn create_virtual_buffer_in_split_start(
5286 &self,
5287 _ctx: rquickjs::Ctx<'_>,
5288 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5289 ) -> rquickjs::Result<u64> {
5290 let id = self.alloc_request_id();
5291
5292 let entries: Vec<TextPropertyEntry> = opts
5294 .entries
5295 .unwrap_or_default()
5296 .into_iter()
5297 .map(|e| TextPropertyEntry {
5298 text: e.text,
5299 properties: e.properties.unwrap_or_default(),
5300 style: e.style,
5301 inline_overlays: e.inline_overlays.unwrap_or_default(),
5302 segments: e.segments.unwrap_or_default(),
5303 pad_to_chars: e.pad_to_chars,
5304 truncate_to_chars: e.truncate_to_chars,
5305 })
5306 .collect();
5307
5308 if let Ok(mut owners) = self.async_resource_owners.lock() {
5310 owners.insert(id, self.plugin_name.clone());
5311 }
5312 let _ = self
5313 .command_sender
5314 .send(PluginCommand::CreateVirtualBufferInSplit {
5315 name: opts.name,
5316 mode: opts.mode.unwrap_or_default(),
5317 read_only: opts.read_only.unwrap_or(false),
5318 entries,
5319 ratio: opts.ratio.unwrap_or(0.5),
5320 direction: opts.direction,
5321 panel_id: opts.panel_id,
5322 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5323 show_cursors: opts.show_cursors.unwrap_or(true),
5324 editing_disabled: opts.editing_disabled.unwrap_or(false),
5325 line_wrap: opts.line_wrap,
5326 before: opts.before.unwrap_or(false),
5327 role: opts.role,
5328 request_id: Some(id),
5329 });
5330 Ok(id)
5331 }
5332
5333 #[plugin_api(
5335 async_promise,
5336 js_name = "createVirtualBufferInExistingSplit",
5337 ts_return = "VirtualBufferResult"
5338 )]
5339 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5340 pub fn create_virtual_buffer_in_existing_split_start(
5341 &self,
5342 _ctx: rquickjs::Ctx<'_>,
5343 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5344 ) -> rquickjs::Result<u64> {
5345 let id = self.alloc_request_id();
5346
5347 let entries: Vec<TextPropertyEntry> = opts
5349 .entries
5350 .unwrap_or_default()
5351 .into_iter()
5352 .map(|e| TextPropertyEntry {
5353 text: e.text,
5354 properties: e.properties.unwrap_or_default(),
5355 style: e.style,
5356 inline_overlays: e.inline_overlays.unwrap_or_default(),
5357 segments: e.segments.unwrap_or_default(),
5358 pad_to_chars: e.pad_to_chars,
5359 truncate_to_chars: e.truncate_to_chars,
5360 })
5361 .collect();
5362
5363 if let Ok(mut owners) = self.async_resource_owners.lock() {
5365 owners.insert(id, self.plugin_name.clone());
5366 }
5367 let _ = self
5368 .command_sender
5369 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5370 name: opts.name,
5371 mode: opts.mode.unwrap_or_default(),
5372 read_only: opts.read_only.unwrap_or(false),
5373 entries,
5374 split_id: SplitId(opts.split_id),
5375 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5376 show_cursors: opts.show_cursors.unwrap_or(true),
5377 editing_disabled: opts.editing_disabled.unwrap_or(false),
5378 line_wrap: opts.line_wrap,
5379 request_id: Some(id),
5380 });
5381 Ok(id)
5382 }
5383
5384 #[qjs(rename = "_createBufferGroupStart")]
5386 pub fn create_buffer_group_start(
5387 &self,
5388 _ctx: rquickjs::Ctx<'_>,
5389 name: String,
5390 mode: String,
5391 layout_json: String,
5392 ) -> rquickjs::Result<u64> {
5393 let id = self.alloc_request_id();
5394 if let Ok(mut owners) = self.async_resource_owners.lock() {
5395 owners.insert(id, self.plugin_name.clone());
5396 }
5397 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5398 name,
5399 mode,
5400 layout_json,
5401 request_id: Some(id),
5402 });
5403 Ok(id)
5404 }
5405
5406 #[qjs(rename = "setPanelContent")]
5408 pub fn set_panel_content<'js>(
5409 &self,
5410 ctx: rquickjs::Ctx<'js>,
5411 group_id: u32,
5412 panel_name: String,
5413 entries_arr: Vec<rquickjs::Object<'js>>,
5414 ) -> rquickjs::Result<bool> {
5415 let entries: Vec<TextPropertyEntry> = entries_arr
5416 .iter()
5417 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5418 .collect();
5419 Ok(self
5420 .command_sender
5421 .send(PluginCommand::SetPanelContent {
5422 group_id: group_id as usize,
5423 panel_name,
5424 entries,
5425 })
5426 .is_ok())
5427 }
5428
5429 #[qjs(rename = "closeBufferGroup")]
5431 pub fn close_buffer_group(&self, group_id: u32) -> bool {
5432 self.command_sender
5433 .send(PluginCommand::CloseBufferGroup {
5434 group_id: group_id as usize,
5435 })
5436 .is_ok()
5437 }
5438
5439 #[qjs(rename = "focusBufferGroupPanel")]
5441 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5442 self.command_sender
5443 .send(PluginCommand::FocusPanel {
5444 group_id: group_id as usize,
5445 panel_name,
5446 })
5447 .is_ok()
5448 }
5449
5450 #[plugin_api(
5457 async_promise,
5458 js_name = "setBufferGroupPanelBuffer",
5459 ts_return = "boolean"
5460 )]
5461 #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5462 pub fn set_buffer_group_panel_buffer_start(
5463 &self,
5464 _ctx: rquickjs::Ctx<'_>,
5465 group_id: u32,
5466 panel_name: String,
5467 buffer_id: u32,
5468 ) -> u64 {
5469 let id = self.alloc_request_id();
5470 let _ = self
5471 .command_sender
5472 .send(PluginCommand::SetBufferGroupPanelBuffer {
5473 group_id: group_id as usize,
5474 panel_name,
5475 buffer_id: BufferId(buffer_id as usize),
5476 request_id: id,
5477 });
5478 id
5479 }
5480
5481 pub fn set_virtual_buffer_content<'js>(
5485 &self,
5486 ctx: rquickjs::Ctx<'js>,
5487 buffer_id: u32,
5488 entries_arr: Vec<rquickjs::Object<'js>>,
5489 ) -> rquickjs::Result<bool> {
5490 let entries: Vec<TextPropertyEntry> = entries_arr
5491 .iter()
5492 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5493 .collect();
5494 Ok(self
5495 .command_sender
5496 .send(PluginCommand::SetVirtualBufferContent {
5497 buffer_id: BufferId(buffer_id as usize),
5498 entries,
5499 })
5500 .is_ok())
5501 }
5502
5503 pub fn get_text_properties_at_cursor(
5505 &self,
5506 buffer_id: u32,
5507 ) -> fresh_core::api::TextPropertiesAtCursor {
5508 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5509 }
5510
5511 #[qjs(rename = "mountWidgetPanel")]
5521 pub fn mount_widget_panel<'js>(
5522 &self,
5523 ctx: rquickjs::Ctx<'js>,
5524 panel_id: f64,
5525 buffer_id: u32,
5526 spec_obj: rquickjs::Value<'js>,
5527 ) -> rquickjs::Result<bool> {
5528 let json = js_to_json(&ctx, spec_obj);
5529 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5530 Ok(s) => s,
5531 Err(e) => {
5532 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5533 return Ok(false);
5534 }
5535 };
5536 Ok(self
5537 .command_sender
5538 .send(PluginCommand::MountWidgetPanel {
5539 plugin: self.plugin_name.clone(),
5540 panel_id: panel_id as u64,
5541 buffer_id: BufferId(buffer_id as usize),
5542 spec,
5543 })
5544 .is_ok())
5545 }
5546
5547 #[qjs(rename = "updateWidgetPanel")]
5550 pub fn update_widget_panel<'js>(
5551 &self,
5552 ctx: rquickjs::Ctx<'js>,
5553 panel_id: f64,
5554 spec_obj: rquickjs::Value<'js>,
5555 ) -> rquickjs::Result<bool> {
5556 let json = js_to_json(&ctx, spec_obj);
5557 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5558 Ok(s) => s,
5559 Err(e) => {
5560 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5561 return Ok(false);
5562 }
5563 };
5564 Ok(self
5565 .command_sender
5566 .send(PluginCommand::UpdateWidgetPanel {
5567 plugin: self.plugin_name.clone(),
5568 panel_id: panel_id as u64,
5569 spec,
5570 })
5571 .is_ok())
5572 }
5573
5574 #[qjs(rename = "unmountWidgetPanel")]
5577 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5578 self.command_sender
5579 .send(PluginCommand::UnmountWidgetPanel {
5580 plugin: self.plugin_name.clone(),
5581 panel_id: panel_id as u64,
5582 })
5583 .is_ok()
5584 }
5585
5586 #[qjs(rename = "widgetCommand")]
5595 pub fn widget_command<'js>(
5596 &self,
5597 ctx: rquickjs::Ctx<'js>,
5598 panel_id: f64,
5599 action_obj: rquickjs::Value<'js>,
5600 ) -> rquickjs::Result<bool> {
5601 let json = js_to_json(&ctx, action_obj);
5602 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5603 Ok(a) => a,
5604 Err(e) => {
5605 tracing::error!("widgetCommand: invalid action: {}", e);
5606 return Ok(false);
5607 }
5608 };
5609 Ok(self
5610 .command_sender
5611 .send(PluginCommand::WidgetCommand {
5612 plugin: self.plugin_name.clone(),
5613 panel_id: panel_id as u64,
5614 action,
5615 })
5616 .is_ok())
5617 }
5618
5619 #[qjs(rename = "widgetMutate")]
5625 pub fn widget_mutate<'js>(
5626 &self,
5627 ctx: rquickjs::Ctx<'js>,
5628 panel_id: f64,
5629 mutation_obj: rquickjs::Value<'js>,
5630 ) -> rquickjs::Result<bool> {
5631 let json = js_to_json(&ctx, mutation_obj);
5632 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5633 Ok(m) => m,
5634 Err(e) => {
5635 tracing::error!("widgetMutate: invalid mutation: {}", e);
5636 return Ok(false);
5637 }
5638 };
5639 Ok(self
5640 .command_sender
5641 .send(PluginCommand::WidgetMutate {
5642 plugin: self.plugin_name.clone(),
5643 panel_id: panel_id as u64,
5644 mutation,
5645 })
5646 .is_ok())
5647 }
5648
5649 #[qjs(rename = "mountFloatingWidget")]
5652 pub fn mount_floating_widget<'js>(
5653 &self,
5654 ctx: rquickjs::Ctx<'js>,
5655 panel_id: f64,
5656 spec_obj: rquickjs::Value<'js>,
5657 width_pct: f64,
5658 height_pct: f64,
5659 as_dock: rquickjs::function::Opt<bool>,
5660 focus_marker: rquickjs::function::Opt<bool>,
5661 ) -> rquickjs::Result<bool> {
5662 let json = js_to_json(&ctx, spec_obj);
5663 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5664 Ok(s) => s,
5665 Err(e) => {
5666 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5667 return Ok(false);
5668 }
5669 };
5670 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5671 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5672 Ok(self
5673 .command_sender
5674 .send(PluginCommand::MountFloatingWidget {
5675 plugin: self.plugin_name.clone(),
5676 panel_id: panel_id as u64,
5677 spec,
5678 width_pct,
5679 height_pct,
5680 as_dock: as_dock.0.unwrap_or(false),
5681 focus_marker: focus_marker.0.unwrap_or(false),
5682 })
5683 .is_ok())
5684 }
5685
5686 #[qjs(rename = "updateFloatingWidget")]
5688 pub fn update_floating_widget<'js>(
5689 &self,
5690 ctx: rquickjs::Ctx<'js>,
5691 panel_id: f64,
5692 spec_obj: rquickjs::Value<'js>,
5693 ) -> rquickjs::Result<bool> {
5694 let json = js_to_json(&ctx, spec_obj);
5695 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5696 Ok(s) => s,
5697 Err(e) => {
5698 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5699 return Ok(false);
5700 }
5701 };
5702 Ok(self
5703 .command_sender
5704 .send(PluginCommand::UpdateFloatingWidget {
5705 plugin: self.plugin_name.clone(),
5706 panel_id: panel_id as u64,
5707 spec,
5708 })
5709 .is_ok())
5710 }
5711
5712 #[qjs(rename = "unmountFloatingWidget")]
5714 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5715 self.command_sender
5716 .send(PluginCommand::UnmountFloatingWidget {
5717 plugin: self.plugin_name.clone(),
5718 panel_id: panel_id as u64,
5719 })
5720 .is_ok()
5721 }
5722
5723 #[qjs(rename = "floatingPanelControl")]
5729 pub fn floating_panel_control(&self, panel_id: f64, op: String, arg: f64) -> bool {
5730 self.command_sender
5731 .send(PluginCommand::FloatingPanelControl {
5732 plugin: self.plugin_name.clone(),
5733 panel_id: panel_id as u64,
5734 op,
5735 arg,
5736 })
5737 .is_ok()
5738 }
5739
5740 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5749 #[qjs(rename = "_spawnProcessStart")]
5750 pub fn spawn_process_start(
5751 &self,
5752 _ctx: rquickjs::Ctx<'_>,
5753 command: String,
5754 args: Vec<String>,
5755 cwd: rquickjs::function::Opt<String>,
5756 stdout_to: rquickjs::function::Opt<String>,
5757 ) -> u64 {
5758 let id = self.alloc_request_id();
5759 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5765 self.state_snapshot
5766 .read()
5767 .ok()
5768 .map(|s| s.working_dir.to_string_lossy().to_string())
5769 });
5770 let stdout_to_path = stdout_to
5771 .0
5772 .filter(|s| !s.is_empty())
5773 .map(std::path::PathBuf::from);
5774 tracing::info!(
5775 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5776 self.plugin_name,
5777 command,
5778 args,
5779 effective_cwd,
5780 stdout_to_path,
5781 id
5782 );
5783 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5784 callback_id: JsCallbackId::new(id),
5785 command,
5786 args,
5787 cwd: effective_cwd,
5788 stdout_to: stdout_to_path,
5789 });
5790 id
5791 }
5792
5793 #[plugin_api(
5800 async_thenable,
5801 js_name = "spawnHostProcess",
5802 ts_return = "SpawnResult"
5803 )]
5804 #[qjs(rename = "_spawnHostProcessStart")]
5805 pub fn spawn_host_process_start(
5806 &self,
5807 _ctx: rquickjs::Ctx<'_>,
5808 command: String,
5809 args: Vec<String>,
5810 cwd: rquickjs::function::Opt<String>,
5811 ) -> u64 {
5812 let id = self.alloc_request_id();
5813 let effective_cwd = cwd.0.or_else(|| {
5814 self.state_snapshot
5815 .read()
5816 .ok()
5817 .map(|s| s.working_dir.to_string_lossy().to_string())
5818 });
5819 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5820 callback_id: JsCallbackId::new(id),
5821 command,
5822 args,
5823 cwd: effective_cwd,
5824 });
5825 id
5826 }
5827
5828 #[plugin_api(js_name = "_killHostProcess")]
5838 pub fn kill_host_process(&self, process_id: u64) -> bool {
5839 self.command_sender
5840 .send(PluginCommand::KillHostProcess { process_id })
5841 .is_ok()
5842 }
5843
5844 #[plugin_api(js_name = "setAuthority")]
5853 pub fn set_authority(
5854 &self,
5855 ctx: rquickjs::Ctx<'_>,
5856 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5857 ) -> bool {
5858 let json = js_to_json(&ctx, payload);
5859 let _ = self
5860 .command_sender
5861 .send(PluginCommand::SetAuthority { payload: json });
5862 true
5863 }
5864
5865 #[plugin_api(js_name = "clearAuthority")]
5868 pub fn clear_authority(&self) {
5869 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5870 }
5871
5872 #[plugin_api(async_promise, js_name = "attachRemoteAgent", ts_return = "void")]
5888 #[qjs(rename = "_attachRemoteAgentStart")]
5889 pub fn attach_remote_agent(
5890 &self,
5891 ctx: rquickjs::Ctx<'_>,
5892 #[plugin_api(ts_type = "RemoteAgentSpec")] payload: rquickjs::Value<'_>,
5893 ) -> u64 {
5894 let json = js_to_json(&ctx, payload);
5895 let id = self.alloc_request_id();
5896 let _ = self.command_sender.send(PluginCommand::AttachRemoteAgent {
5897 payload: json,
5898 request_id: id,
5899 });
5900 id
5901 }
5902
5903 #[plugin_api(js_name = "cancelRemoteAgent")]
5908 pub fn cancel_remote_agent(&self) {
5909 let _ = self.command_sender.send(PluginCommand::CancelRemoteAttach);
5910 }
5911
5912 #[plugin_api(js_name = "setEnv")]
5916 pub fn set_env(&self, snippet: String, dir: Option<String>) {
5917 let _ = self
5918 .command_sender
5919 .send(PluginCommand::SetEnv { snippet, dir });
5920 }
5921
5922 #[plugin_api(js_name = "clearEnv")]
5924 pub fn clear_env(&self) {
5925 let _ = self.command_sender.send(PluginCommand::ClearEnv);
5926 }
5927
5928 #[plugin_api(js_name = "setRemoteIndicatorState")]
5946 pub fn set_remote_indicator_state(
5947 &self,
5948 ctx: rquickjs::Ctx<'_>,
5949 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
5950 ) -> bool {
5951 let json = js_to_json(&ctx, state);
5952 let _ = self
5953 .command_sender
5954 .send(PluginCommand::SetRemoteIndicatorState { state: json });
5955 true
5956 }
5957
5958 #[plugin_api(js_name = "clearRemoteIndicatorState")]
5961 pub fn clear_remote_indicator_state(&self) {
5962 let _ = self
5963 .command_sender
5964 .send(PluginCommand::ClearRemoteIndicatorState);
5965 }
5966
5967 #[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
5978 #[qjs(rename = "_httpFetchStart")]
5979 pub fn http_fetch_start(
5980 &self,
5981 _ctx: rquickjs::Ctx<'_>,
5982 url: String,
5983 target_path: String,
5984 ) -> u64 {
5985 let id = self.alloc_request_id();
5986 tracing::info!(
5987 "http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
5988 self.plugin_name,
5989 url,
5990 target_path,
5991 id
5992 );
5993 let _ = self.command_sender.send(PluginCommand::HttpFetch {
5994 url,
5995 target_path: std::path::PathBuf::from(target_path),
5996 callback_id: JsCallbackId::new(id),
5997 });
5998 id
5999 }
6000
6001 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
6003 #[qjs(rename = "_spawnProcessWaitStart")]
6004 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
6005 let id = self.alloc_request_id();
6006 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
6007 process_id,
6008 callback_id: JsCallbackId::new(id),
6009 });
6010 id
6011 }
6012
6013 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
6015 #[qjs(rename = "_getBufferTextStart")]
6016 pub fn get_buffer_text_start(
6017 &self,
6018 _ctx: rquickjs::Ctx<'_>,
6019 buffer_id: u32,
6020 start: u32,
6021 end: u32,
6022 ) -> u64 {
6023 let id = self.alloc_request_id();
6024 let _ = self.command_sender.send(PluginCommand::GetBufferText {
6025 buffer_id: BufferId(buffer_id as usize),
6026 start: start as usize,
6027 end: end as usize,
6028 request_id: id,
6029 });
6030 id
6031 }
6032
6033 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
6035 #[qjs(rename = "_delayStart")]
6036 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
6037 let id = self.alloc_request_id();
6038 let _ = self.command_sender.send(PluginCommand::Delay {
6039 callback_id: JsCallbackId::new(id),
6040 duration_ms,
6041 });
6042 id
6043 }
6044
6045 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
6049 #[qjs(rename = "_grepProjectStart")]
6050 pub fn grep_project_start(
6051 &self,
6052 _ctx: rquickjs::Ctx<'_>,
6053 pattern: String,
6054 fixed_string: Option<bool>,
6055 case_sensitive: Option<bool>,
6056 max_results: Option<u32>,
6057 whole_words: Option<bool>,
6058 ) -> u64 {
6059 let id = self.alloc_request_id();
6060 let _ = self.command_sender.send(PluginCommand::GrepProject {
6061 pattern,
6062 fixed_string: fixed_string.unwrap_or(true),
6063 case_sensitive: case_sensitive.unwrap_or(true),
6064 max_results: max_results.unwrap_or(200) as usize,
6065 whole_words: whole_words.unwrap_or(false),
6066 callback_id: JsCallbackId::new(id),
6067 });
6068 id
6069 }
6070
6071 #[plugin_api(
6076 js_name = "beginSearch",
6077 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean; sourceBufferId?: number }): SearchHandle"
6078 )]
6079 #[qjs(rename = "_beginSearch")]
6080 pub fn begin_search(
6081 &self,
6082 _ctx: rquickjs::Ctx<'_>,
6083 pattern: String,
6084 fixed_string: bool,
6085 case_sensitive: bool,
6086 max_results: u32,
6087 whole_words: bool,
6088 source_buffer_id: u32,
6089 ) -> u64 {
6090 let id = self.alloc_request_id();
6091 let entry = Arc::new(SearchHandleState::new());
6094 if let Ok(mut map) = self.search_handles.lock() {
6095 map.insert(id, entry);
6096 }
6097 let _ = self.command_sender.send(PluginCommand::BeginSearch {
6098 pattern,
6099 fixed_string,
6100 case_sensitive,
6101 max_results: max_results as usize,
6102 whole_words,
6103 source_buffer_id: source_buffer_id as usize,
6104 handle_id: id,
6105 });
6106 id
6107 }
6108
6109 #[plugin_api(ts_return = "SearchTakeResult")]
6114 #[qjs(rename = "_searchHandleTake")]
6115 pub fn search_handle_take<'js>(
6116 &self,
6117 ctx: rquickjs::Ctx<'js>,
6118 handle_id: u64,
6119 ) -> rquickjs::Result<Value<'js>> {
6120 let entry = self
6121 .search_handles
6122 .lock()
6123 .ok()
6124 .and_then(|m| m.get(&handle_id).cloned());
6125 let result = match entry {
6126 Some(handle) => {
6127 let mut state = match handle.state.lock() {
6129 Ok(s) => s,
6130 Err(poisoned) => poisoned.into_inner(),
6131 };
6132 let matches = std::mem::take(&mut state.pending);
6133 let snapshot = SearchTakeResult {
6134 matches,
6135 done: state.done,
6136 total_seen: state.total_seen,
6137 truncated: state.truncated,
6138 error: state.error.clone(),
6139 };
6140 let done = snapshot.done;
6141 drop(state);
6142 if done {
6143 if let Ok(mut map) = self.search_handles.lock() {
6144 map.remove(&handle_id);
6145 }
6146 }
6147 snapshot
6148 }
6149 None => SearchTakeResult {
6150 matches: Vec::new(),
6151 done: true,
6152 total_seen: 0,
6153 truncated: false,
6154 error: None,
6155 },
6156 };
6157 rquickjs_serde::to_value(ctx, &result)
6158 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
6159 }
6160
6161 #[qjs(rename = "_searchHandleCancel")]
6164 pub fn search_handle_cancel(&self, handle_id: u64) {
6165 if let Ok(map) = self.search_handles.lock() {
6166 if let Some(entry) = map.get(&handle_id) {
6167 entry
6168 .cancel
6169 .store(true, std::sync::atomic::Ordering::Relaxed);
6170 }
6171 }
6172 }
6173
6174 #[plugin_api(
6178 async_promise,
6179 js_name = "replaceInFile",
6180 ts_raw = "replaceInFile(filePath: string, matches: number[][], replacement: string, bufferId?: number): Promise<ReplaceResult>"
6181 )]
6182 #[qjs(rename = "_replaceInFileStart")]
6183 pub fn replace_in_file_start(
6184 &self,
6185 _ctx: rquickjs::Ctx<'_>,
6186 file_path: String,
6187 matches: Vec<Vec<u32>>,
6188 replacement: String,
6189 buffer_id: rquickjs::function::Opt<u32>,
6190 ) -> u64 {
6191 let id = self.alloc_request_id();
6192 let match_pairs: Vec<(usize, usize)> = matches
6194 .iter()
6195 .map(|m| (m[0] as usize, m[1] as usize))
6196 .collect();
6197 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
6198 file_path: PathBuf::from(file_path),
6199 buffer_id: buffer_id.0.unwrap_or(0) as usize,
6200 matches: match_pairs,
6201 replacement,
6202 callback_id: JsCallbackId::new(id),
6203 });
6204 id
6205 }
6206
6207 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
6209 #[qjs(rename = "_sendLspRequestStart")]
6210 pub fn send_lsp_request_start<'js>(
6211 &self,
6212 ctx: rquickjs::Ctx<'js>,
6213 language: String,
6214 method: String,
6215 params: Option<rquickjs::Object<'js>>,
6216 ) -> rquickjs::Result<u64> {
6217 let id = self.alloc_request_id();
6218 let params_json: Option<serde_json::Value> = params.map(|obj| {
6220 let val = obj.into_value();
6221 js_to_json(&ctx, val)
6222 });
6223 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
6224 request_id: id,
6225 language,
6226 method,
6227 params: params_json,
6228 });
6229 Ok(id)
6230 }
6231
6232 #[plugin_api(
6234 async_thenable,
6235 js_name = "spawnBackgroundProcess",
6236 ts_return = "BackgroundProcessResult"
6237 )]
6238 #[qjs(rename = "_spawnBackgroundProcessStart")]
6239 pub fn spawn_background_process_start(
6240 &self,
6241 _ctx: rquickjs::Ctx<'_>,
6242 command: String,
6243 args: Vec<String>,
6244 cwd: rquickjs::function::Opt<String>,
6245 ) -> u64 {
6246 let id = self.alloc_request_id();
6247 let process_id = id;
6249 self.plugin_tracked_state
6251 .borrow_mut()
6252 .entry(self.plugin_name.clone())
6253 .or_default()
6254 .background_process_ids
6255 .push(process_id);
6256 let _ = self
6258 .command_sender
6259 .send(PluginCommand::SpawnBackgroundProcess {
6260 process_id,
6261 command,
6262 args,
6263 cwd: cwd.0.filter(|s| !s.is_empty()),
6264 callback_id: JsCallbackId::new(id),
6265 });
6266 id
6267 }
6268
6269 pub fn kill_background_process(&self, process_id: u64) -> bool {
6271 self.command_sender
6272 .send(PluginCommand::KillBackgroundProcess { process_id })
6273 .is_ok()
6274 }
6275
6276 #[plugin_api(
6280 async_promise,
6281 js_name = "createTerminal",
6282 ts_return = "TerminalResult"
6283 )]
6284 #[qjs(rename = "_createTerminalStart")]
6285 pub fn create_terminal_start(
6286 &self,
6287 _ctx: rquickjs::Ctx<'_>,
6288 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
6289 ) -> rquickjs::Result<u64> {
6290 let id = self.alloc_request_id();
6291
6292 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
6293 cwd: None,
6294 direction: None,
6295 ratio: None,
6296 focus: None,
6297 persistent: None,
6298 window_id: None,
6299 command: None,
6300 title: None,
6301 });
6302
6303 if let Ok(mut owners) = self.async_resource_owners.lock() {
6305 owners.insert(id, self.plugin_name.clone());
6306 }
6307 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
6308 cwd: opts.cwd,
6309 direction: opts.direction,
6310 ratio: opts.ratio,
6311 focus: opts.focus,
6312 window_id: opts.window_id,
6313 persistent: opts.persistent.unwrap_or(false),
6317 command: opts.command,
6318 title: opts.title,
6319 request_id: id,
6320 });
6321 Ok(id)
6322 }
6323
6324 #[plugin_api(
6330 async_promise,
6331 js_name = "createWindowWithTerminal",
6332 ts_return = "SessionWithTerminalResult"
6333 )]
6334 #[qjs(rename = "_createWindowWithTerminalStart")]
6335 pub fn create_window_with_terminal_start(
6336 &self,
6337 _ctx: rquickjs::Ctx<'_>,
6338 opts: fresh_core::api::CreateWindowWithTerminalOptions,
6339 ) -> rquickjs::Result<u64> {
6340 let id = self.alloc_request_id();
6341 if let Ok(mut owners) = self.async_resource_owners.lock() {
6342 owners.insert(id, self.plugin_name.clone());
6343 }
6344 let _ = self
6345 .command_sender
6346 .send(PluginCommand::CreateWindowWithTerminal {
6347 root: std::path::PathBuf::from(opts.root),
6348 label: opts.label,
6349 cwd: opts.cwd,
6350 command: opts.command,
6351 title: opts.title,
6352 resume: opts.resume,
6353 request_id: id,
6354 });
6355 Ok(id)
6356 }
6357
6358 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
6360 self.command_sender
6361 .send(PluginCommand::SendTerminalInput {
6362 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6363 data,
6364 })
6365 .is_ok()
6366 }
6367
6368 pub fn close_terminal(&self, terminal_id: u64) -> bool {
6370 self.command_sender
6371 .send(PluginCommand::CloseTerminal {
6372 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6373 })
6374 .is_ok()
6375 }
6376
6377 pub fn signal_window(&self, id: f64, signal: String) -> bool {
6384 self.command_sender
6385 .send(PluginCommand::SignalWindow {
6386 id: fresh_core::WindowId(id as u64),
6387 signal,
6388 })
6389 .is_ok()
6390 }
6391
6392 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
6396 self.command_sender
6397 .send(PluginCommand::RefreshLines {
6398 buffer_id: BufferId(buffer_id as usize),
6399 })
6400 .is_ok()
6401 }
6402
6403 pub fn get_current_locale(&self) -> String {
6405 self.services.current_locale()
6406 }
6407
6408 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
6412 #[qjs(rename = "_loadPluginStart")]
6413 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
6414 let id = self.alloc_request_id();
6415 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
6416 path: std::path::PathBuf::from(path),
6417 callback_id: JsCallbackId::new(id),
6418 });
6419 id
6420 }
6421
6422 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
6424 #[qjs(rename = "_unloadPluginStart")]
6425 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6426 let id = self.alloc_request_id();
6427 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
6428 name,
6429 callback_id: JsCallbackId::new(id),
6430 });
6431 id
6432 }
6433
6434 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6436 #[qjs(rename = "_reloadPluginStart")]
6437 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6438 let id = self.alloc_request_id();
6439 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6440 name,
6441 callback_id: JsCallbackId::new(id),
6442 });
6443 id
6444 }
6445
6446 #[plugin_api(
6449 async_promise,
6450 js_name = "listPlugins",
6451 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6452 )]
6453 #[qjs(rename = "_listPluginsStart")]
6454 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6455 let id = self.alloc_request_id();
6456 let _ = self.command_sender.send(PluginCommand::ListPlugins {
6457 callback_id: JsCallbackId::new(id),
6458 });
6459 id
6460 }
6461}
6462
6463fn parse_view_token(
6470 obj: &rquickjs::Object<'_>,
6471 idx: usize,
6472) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6473 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6474
6475 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6477 from: "object",
6478 to: "ViewTokenWire",
6479 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6480 })?;
6481
6482 let source_offset: Option<usize> = obj
6484 .get("sourceOffset")
6485 .ok()
6486 .or_else(|| obj.get("source_offset").ok());
6487
6488 let kind = if kind_value.is_string() {
6490 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6493 from: "value",
6494 to: "string",
6495 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6496 })?;
6497
6498 match kind_str.to_lowercase().as_str() {
6499 "text" => {
6500 let text: String = obj.get("text").unwrap_or_default();
6501 ViewTokenWireKind::Text(text)
6502 }
6503 "newline" => ViewTokenWireKind::Newline,
6504 "space" => ViewTokenWireKind::Space,
6505 "break" => ViewTokenWireKind::Break,
6506 _ => {
6507 tracing::warn!(
6509 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6510 idx, kind_str
6511 );
6512 return Err(rquickjs::Error::FromJs {
6513 from: "string",
6514 to: "ViewTokenWireKind",
6515 message: Some(format!(
6516 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6517 idx, kind_str
6518 )),
6519 });
6520 }
6521 }
6522 } else if kind_value.is_object() {
6523 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6525 from: "value",
6526 to: "object",
6527 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6528 })?;
6529
6530 if let Ok(text) = kind_obj.get::<_, String>("Text") {
6531 ViewTokenWireKind::Text(text)
6532 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6533 ViewTokenWireKind::BinaryByte(byte)
6534 } else {
6535 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6537 tracing::warn!(
6538 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6539 idx,
6540 keys
6541 );
6542 return Err(rquickjs::Error::FromJs {
6543 from: "object",
6544 to: "ViewTokenWireKind",
6545 message: Some(format!(
6546 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6547 idx, keys
6548 )),
6549 });
6550 }
6551 } else {
6552 tracing::warn!(
6553 "token[{}]: 'kind' field must be a string or object, got: {:?}",
6554 idx,
6555 kind_value.type_of()
6556 );
6557 return Err(rquickjs::Error::FromJs {
6558 from: "value",
6559 to: "ViewTokenWireKind",
6560 message: Some(format!(
6561 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6562 idx
6563 )),
6564 });
6565 };
6566
6567 let style = parse_view_token_style(obj, idx)?;
6569
6570 Ok(ViewTokenWire {
6571 source_offset,
6572 kind,
6573 style,
6574 })
6575}
6576
6577fn parse_view_token_style(
6579 obj: &rquickjs::Object<'_>,
6580 idx: usize,
6581) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6582 use fresh_core::api::{TokenColor, ViewTokenStyle};
6583
6584 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6585 let Some(s) = style_obj else {
6586 return Ok(None);
6587 };
6588
6589 fn parse_color(
6594 s: &rquickjs::Object<'_>,
6595 field: &str,
6596 idx: usize,
6597 ) -> rquickjs::Result<Option<TokenColor>> {
6598 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6599 if arr.len() < 3 {
6600 tracing::warn!(
6601 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6602 idx,
6603 field,
6604 arr.len()
6605 );
6606 return Ok(None);
6607 }
6608 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6609 }
6610 if let Ok(name) = s.get::<_, String>(field) {
6611 return Ok(Some(TokenColor::Named(name)));
6612 }
6613 Ok(None)
6614 }
6615
6616 Ok(Some(ViewTokenStyle {
6617 fg: parse_color(&s, "fg", idx)?,
6618 bg: parse_color(&s, "bg", idx)?,
6619 bold: s.get("bold").unwrap_or(false),
6620 italic: s.get("italic").unwrap_or(false),
6621 underline: s.get("underline").unwrap_or(false),
6622 }))
6623}
6624
6625pub struct QuickJsBackend {
6627 runtime: Runtime,
6628 main_context: Context,
6630 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6632 event_handlers: EventHandlerRegistry,
6636 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6638 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6640 command_sender: mpsc::Sender<PluginCommand>,
6642 #[allow(dead_code)]
6644 pending_responses: PendingResponses,
6645 next_request_id: Rc<RefCell<u64>>,
6647 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6649 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6651 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6653 async_resource_owners: AsyncResourceOwners,
6656 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6658 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6660 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6662 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6664 plugin_api_exports: PluginApiExports,
6668 search_handles: SearchHandleRegistry,
6670}
6671
6672impl Drop for QuickJsBackend {
6673 fn drop(&mut self) {
6674 self.plugin_api_exports.borrow_mut().clear();
6680 }
6681}
6682
6683impl QuickJsBackend {
6684 pub fn new() -> Result<Self> {
6686 let (tx, _rx) = mpsc::channel();
6687 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6688 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6689 Self::with_state(state_snapshot, tx, services)
6690 }
6691
6692 pub fn with_state(
6694 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6695 command_sender: mpsc::Sender<PluginCommand>,
6696 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6697 ) -> Result<Self> {
6698 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
6699 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
6700 }
6701
6702 pub fn with_state_and_responses(
6704 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6705 command_sender: mpsc::Sender<PluginCommand>,
6706 pending_responses: PendingResponses,
6707 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6708 ) -> Result<Self> {
6709 let async_resource_owners: AsyncResourceOwners =
6710 Arc::new(std::sync::Mutex::new(HashMap::new()));
6711 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
6712 let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
6713 Self::with_state_responses_and_resources(
6714 state_snapshot,
6715 command_sender,
6716 pending_responses,
6717 services,
6718 async_resource_owners,
6719 search_handles,
6720 event_handlers,
6721 )
6722 }
6723
6724 pub fn with_state_responses_and_resources(
6727 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6728 command_sender: mpsc::Sender<PluginCommand>,
6729 pending_responses: PendingResponses,
6730 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6731 async_resource_owners: AsyncResourceOwners,
6732 search_handles: SearchHandleRegistry,
6733 event_handlers: EventHandlerRegistry,
6734 ) -> Result<Self> {
6735 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
6736
6737 let runtime =
6738 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
6739
6740 runtime.set_host_promise_rejection_tracker(Some(Box::new(
6742 |_ctx, _promise, reason, is_handled| {
6743 if !is_handled {
6744 let error_msg = if let Some(exc) = reason.as_exception() {
6746 format!(
6747 "{}: {}",
6748 exc.message().unwrap_or_default(),
6749 exc.stack().unwrap_or_default()
6750 )
6751 } else {
6752 format!("{:?}", reason)
6753 };
6754
6755 tracing::error!("Unhandled Promise rejection: {}", error_msg);
6756
6757 if should_panic_on_js_errors() {
6758 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
6761 set_fatal_js_error(full_msg);
6762 }
6763 }
6764 },
6765 )));
6766
6767 let main_context = Context::full(&runtime)
6768 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
6769
6770 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
6771 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
6772 let next_request_id = Rc::new(RefCell::new(1u64));
6773 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
6774 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
6775 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
6776 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
6777 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
6778 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
6779 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
6780
6781 let backend = Self {
6782 runtime,
6783 main_context,
6784 plugin_contexts,
6785 event_handlers,
6786 registered_actions,
6787 state_snapshot,
6788 command_sender,
6789 pending_responses,
6790 next_request_id,
6791 callback_contexts,
6792 services,
6793 plugin_tracked_state,
6794 async_resource_owners,
6795 registered_command_names,
6796 registered_grammar_languages,
6797 registered_language_configs,
6798 registered_lsp_servers,
6799 plugin_api_exports,
6800 search_handles,
6801 };
6802
6803 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
6805
6806 tracing::debug!("QuickJsBackend::new: runtime created successfully");
6807 Ok(backend)
6808 }
6809
6810 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
6812 let state_snapshot = Arc::clone(&self.state_snapshot);
6813 let command_sender = self.command_sender.clone();
6814 let event_handlers = Arc::clone(&self.event_handlers);
6815 let registered_actions = Rc::clone(&self.registered_actions);
6816 let next_request_id = Rc::clone(&self.next_request_id);
6817 let registered_command_names = Rc::clone(&self.registered_command_names);
6818 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
6819 let registered_language_configs = Rc::clone(&self.registered_language_configs);
6820 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
6821 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
6822
6823 context.with(|ctx| {
6824 let globals = ctx.globals();
6825
6826 globals.set("__pluginName__", plugin_name)?;
6828
6829 let js_api = JsEditorApi {
6832 state_snapshot: Arc::clone(&state_snapshot),
6833 command_sender: command_sender.clone(),
6834 registered_actions: Rc::clone(®istered_actions),
6835 event_handlers: Arc::clone(&event_handlers),
6836 next_request_id: Rc::clone(&next_request_id),
6837 callback_contexts: Rc::clone(&self.callback_contexts),
6838 services: self.services.clone(),
6839 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
6840 async_resource_owners: Arc::clone(&self.async_resource_owners),
6841 registered_command_names: Rc::clone(®istered_command_names),
6842 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
6843 registered_language_configs: Rc::clone(®istered_language_configs),
6844 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
6845 plugin_api_exports: Rc::clone(&plugin_api_exports),
6846 search_handles: Arc::clone(&self.search_handles),
6847 plugin_name: plugin_name.to_string(),
6848 };
6849 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
6850
6851 globals.set("editor", editor)?;
6853
6854 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
6856
6857 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
6859
6860ctx.eval::<(), _>(
6867 r#"
6868 (function() {
6869 const originalOn = editor.on.bind(editor);
6870 const originalOff = editor.off.bind(editor);
6871 let counter = 0;
6872 const anonNames = new WeakMap();
6873 editor.on = function(eventName, handlerOrName) {
6874 if (typeof handlerOrName === 'function') {
6875 const existing = anonNames.get(handlerOrName);
6876 const name = existing || `__anon_on_${++counter}`;
6877 if (!existing) {
6878 anonNames.set(handlerOrName, name);
6879 }
6880 globalThis[name] = handlerOrName;
6881 return originalOn(eventName, name);
6882 }
6883 return originalOn(eventName, handlerOrName);
6884 };
6885 editor.off = function(eventName, handlerOrName) {
6886 if (typeof handlerOrName === 'function') {
6887 const name = anonNames.get(handlerOrName);
6888 if (name === undefined) return false;
6889 return originalOff(eventName, name);
6890 }
6891 return originalOff(eventName, handlerOrName);
6892 };
6893 })();
6894 "#,
6895 )?;
6896
6897 let console = Object::new(ctx.clone())?;
6900 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6901 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6902 tracing::info!("console.log: {}", parts.join(" "));
6903 })?)?;
6904 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6905 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6906 tracing::warn!("console.warn: {}", parts.join(" "));
6907 })?)?;
6908 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6909 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6910 tracing::error!("console.error: {}", parts.join(" "));
6911 })?)?;
6912 globals.set("console", console)?;
6913
6914 ctx.eval::<(), _>(r#"
6916 // Pending promise callbacks: callbackId -> { resolve, reject }
6917 globalThis._pendingCallbacks = new Map();
6918
6919 // Resolve a pending callback (called from Rust)
6920 globalThis._resolveCallback = function(callbackId, result) {
6921 // No per-resolve logging here: this fires once per async op
6922 // completion (potentially at very high frequency), and
6923 // console.log is captured into the host log, so logging here
6924 // floods the log and can feed a tail-driven feedback loop.
6925 const cb = globalThis._pendingCallbacks.get(callbackId);
6926 if (cb) {
6927 globalThis._pendingCallbacks.delete(callbackId);
6928 cb.resolve(result);
6929 }
6930 };
6931
6932 // Reject a pending callback (called from Rust)
6933 globalThis._rejectCallback = function(callbackId, error) {
6934 const cb = globalThis._pendingCallbacks.get(callbackId);
6935 if (cb) {
6936 globalThis._pendingCallbacks.delete(callbackId);
6937 cb.reject(new Error(error));
6938 }
6939 };
6940
6941 // Generic async wrapper decorator
6942 // Wraps a function that returns a callbackId into a promise-returning function
6943 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6944 // NOTE: We pass the method name as a string and call via bracket notation
6945 // to preserve rquickjs's automatic Ctx injection for methods
6946 globalThis._wrapAsync = function(methodName, fnName) {
6947 const startFn = editor[methodName];
6948 if (typeof startFn !== 'function') {
6949 // Return a function that always throws - catches missing implementations
6950 return function(...args) {
6951 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6952 editor.debug(`[ASYNC ERROR] ${error.message}`);
6953 throw error;
6954 };
6955 }
6956 return function(...args) {
6957 // Call via bracket notation to preserve method binding and Ctx injection
6958 const callbackId = editor[methodName](...args);
6959 return new Promise((resolve, reject) => {
6960 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6961 // TODO: Implement setTimeout polyfill using editor.delay() or similar
6962 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6963 });
6964 };
6965 };
6966
6967 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6968 // The returned object has .result promise and is itself thenable
6969 globalThis._wrapAsyncThenable = function(methodName, fnName) {
6970 const startFn = editor[methodName];
6971 if (typeof startFn !== 'function') {
6972 // Return a function that always throws - catches missing implementations
6973 return function(...args) {
6974 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6975 editor.debug(`[ASYNC ERROR] ${error.message}`);
6976 throw error;
6977 };
6978 }
6979 return function(...args) {
6980 // Call via bracket notation to preserve method binding and Ctx injection
6981 const callbackId = editor[methodName](...args);
6982 const resultPromise = new Promise((resolve, reject) => {
6983 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6984 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6985 });
6986 return {
6987 get result() { return resultPromise; },
6988 then(onFulfilled, onRejected) {
6989 return resultPromise.then(onFulfilled, onRejected);
6990 },
6991 catch(onRejected) {
6992 return resultPromise.catch(onRejected);
6993 }
6994 };
6995 };
6996 };
6997
6998 // Apply wrappers to async functions on editor
6999 // spawnProcess accepts either form for the 4th arg:
7000 // editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
7001 // editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
7002 // The first matches the auto-generated TS signature
7003 // (flat positional from the Rust binding's `Opt<String>`
7004 // args); the second is the structured options form
7005 // plugin authors often prefer.
7006 editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
7007 if (typeof editor._spawnProcessStart !== 'function') {
7008 throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
7009 }
7010 // The 3rd arg is either cwd (string) or an options
7011 // object when cwd is omitted; the 4th is either a
7012 // stdoutTo string or an options object.
7013 let cwd = "";
7014 let stdoutTo = "";
7015 if (typeof cwdOrOpts === "string") {
7016 cwd = cwdOrOpts;
7017 } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
7018 if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
7019 }
7020 if (typeof fourth === "string") {
7021 stdoutTo = fourth;
7022 } else if (fourth && typeof fourth === "object") {
7023 if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
7024 }
7025 const callbackId = editor._spawnProcessStart(
7026 command,
7027 argsArr || [],
7028 cwd,
7029 stdoutTo,
7030 );
7031 const resultPromise = new Promise((resolve, reject) => {
7032 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
7033 });
7034 return {
7035 get result() { return resultPromise; },
7036 // `kill()` cancels a still-running spawn. The
7037 // dispatcher stores a oneshot keyed by callbackId;
7038 // _killHostProcess fires it and the spawner's
7039 // tokio::select! kills the child. No-op if the
7040 // child already exited (id removed from the map).
7041 kill() {
7042 if (typeof editor._killHostProcess === 'function') {
7043 return editor._killHostProcess(callbackId);
7044 }
7045 return false;
7046 },
7047 then(onFulfilled, onRejected) {
7048 return resultPromise.then(onFulfilled, onRejected);
7049 },
7050 catch(onRejected) {
7051 return resultPromise.catch(onRejected);
7052 }
7053 };
7054 };
7055 // spawnHostProcess gets a bespoke wrapper (instead of
7056 // `_wrapAsyncThenable`) because its `ProcessHandle`
7057 // exposes a real `kill()` that forwards to
7058 // `_killHostProcess`. Generic wrap has no hook for
7059 // that.
7060 editor.spawnHostProcess = function(command, args, cwd) {
7061 if (typeof editor._spawnHostProcessStart !== 'function') {
7062 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
7063 }
7064 // Pass real strings only. Earlier revisions forwarded
7065 // `""` for a missing cwd, which landed verbatim as
7066 // `Command::current_dir("")` in the dispatcher —
7067 // every host-spawn then failed with ENOENT. Use two
7068 // arity forms so the Rust `Opt<String>` stays `None`
7069 // instead of `Some("")`.
7070 let callbackId;
7071 if (typeof cwd === "string" && cwd.length > 0) {
7072 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
7073 } else {
7074 callbackId = editor._spawnHostProcessStart(command, args || []);
7075 }
7076 const resultPromise = new Promise(function(resolve, reject) {
7077 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
7078 });
7079 return {
7080 processId: callbackId,
7081 get result() { return resultPromise; },
7082 then: function(f, r) { return resultPromise.then(f, r); },
7083 catch: function(r) { return resultPromise.catch(r); },
7084 kill: function() {
7085 // Returns true when the kill was enqueued
7086 // (the process may have already exited; in
7087 // that case the dispatcher silently
7088 // drops it). Matches the
7089 // `ProcessHandle.kill(): Promise<boolean>`
7090 // type signature by wrapping the sync
7091 // boolean in a Promise.
7092 return Promise.resolve(editor._killHostProcess(callbackId));
7093 }
7094 };
7095 };
7096 editor.delay = _wrapAsync("_delayStart", "delay");
7097 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
7098 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
7099 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
7100 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
7101 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
7102 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
7103 editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
7104 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
7105 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
7106 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
7107 editor.getCompositeCursorInfo = _wrapAsync("_getCompositeCursorInfoStart", "getCompositeCursorInfo");
7108 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
7109 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
7110 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
7111 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
7112 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
7113 editor.prompt = _wrapAsync("_promptStart", "prompt");
7114 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
7115 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
7116 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
7117 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
7118 editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
7119 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
7120 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
7121 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
7122 editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
7123 editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
7124 editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
7125 editor.attachRemoteAgent = _wrapAsync("_attachRemoteAgentStart", "attachRemoteAgent");
7126
7127 // Pull-based streaming search. Producers (host searcher tasks)
7128 // write into shared state at full speed; the consumer drains
7129 // it via take() at its own cadence — no per-chunk JS dispatch.
7130 editor.beginSearch = function(pattern, opts) {
7131 opts = opts || {};
7132 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
7133 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
7134 const maxResults = opts.maxResults || 10000;
7135 const wholeWords = opts.wholeWords || false;
7136 const sourceBufferId = opts.sourceBufferId || 0;
7137 const handleId = editor._beginSearch(
7138 pattern, fixedString, caseSensitive, maxResults, wholeWords, sourceBufferId
7139 );
7140 return {
7141 searchId: handleId,
7142 take: function() { return editor._searchHandleTake(handleId); },
7143 cancel: function() { editor._searchHandleCancel(handleId); }
7144 };
7145 };
7146
7147 // Wrapper for deleteTheme - wraps sync function in Promise
7148 editor.deleteTheme = function(name) {
7149 return new Promise(function(resolve, reject) {
7150 const success = editor._deleteThemeSync(name);
7151 if (success) {
7152 resolve();
7153 } else {
7154 reject(new Error("Failed to delete theme: " + name));
7155 }
7156 });
7157 };
7158 "#.as_bytes())?;
7159
7160 Ok::<_, rquickjs::Error>(())
7161 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
7162
7163 Ok(())
7164 }
7165
7166 pub async fn load_module_with_source(
7168 &mut self,
7169 path: &str,
7170 _plugin_source: &str,
7171 ) -> Result<()> {
7172 let path_buf = PathBuf::from(path);
7173 let source = std::fs::read_to_string(&path_buf)
7174 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
7175
7176 let filename = path_buf
7177 .file_name()
7178 .and_then(|s| s.to_str())
7179 .unwrap_or("plugin.ts");
7180
7181 if has_es_imports(&source) {
7183 match bundle_module(&path_buf) {
7185 Ok(bundled) => {
7186 self.execute_js(&bundled, path)?;
7187 }
7188 Err(e) => {
7189 tracing::warn!(
7190 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
7191 path,
7192 e
7193 );
7194 return Ok(()); }
7196 }
7197 } else if has_es_module_syntax(&source) {
7198 let stripped = strip_imports_and_exports(&source);
7200 let js_code = if filename.ends_with(".ts") {
7201 transpile_typescript(&stripped, filename)?
7202 } else {
7203 stripped
7204 };
7205 self.execute_js(&js_code, path)?;
7206 } else {
7207 let js_code = if filename.ends_with(".ts") {
7209 transpile_typescript(&source, filename)?
7210 } else {
7211 source
7212 };
7213 self.execute_js(&js_code, path)?;
7214 }
7215
7216 Ok(())
7217 }
7218
7219 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
7221 let plugin_name = Path::new(source_name)
7223 .file_stem()
7224 .and_then(|s| s.to_str())
7225 .unwrap_or("unknown");
7226
7227 tracing::debug!(
7228 "execute_js: starting for plugin '{}' from '{}'",
7229 plugin_name,
7230 source_name
7231 );
7232
7233 let context = {
7235 let mut contexts = self.plugin_contexts.borrow_mut();
7236 if let Some(ctx) = contexts.get(plugin_name) {
7237 ctx.clone()
7238 } else {
7239 let ctx = Context::full(&self.runtime).map_err(|e| {
7240 anyhow!(
7241 "Failed to create QuickJS context for plugin {}: {}",
7242 plugin_name,
7243 e
7244 )
7245 })?;
7246 self.setup_context_api(&ctx, plugin_name)?;
7247 contexts.insert(plugin_name.to_string(), ctx.clone());
7248 ctx
7249 }
7250 };
7251
7252 let wrapped_code = format!("(function() {{ {} }})();", code);
7256 let wrapped = wrapped_code.as_str();
7257
7258 context.with(|ctx| {
7259 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
7260
7261 let mut eval_options = rquickjs::context::EvalOptions::default();
7263 eval_options.global = true;
7264 eval_options.filename = Some(source_name.to_string());
7265 let result = ctx
7266 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
7267 .map_err(|e| format_js_error(&ctx, e, source_name));
7268
7269 tracing::debug!(
7270 "execute_js: plugin code execution finished for '{}', result: {:?}",
7271 plugin_name,
7272 result.is_ok()
7273 );
7274
7275 result
7276 })
7277 }
7278
7279 pub fn execute_source(
7285 &mut self,
7286 source: &str,
7287 plugin_name: &str,
7288 is_typescript: bool,
7289 ) -> Result<()> {
7290 use fresh_parser_js::{
7291 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
7292 };
7293
7294 if has_es_imports(source) {
7295 tracing::warn!(
7296 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
7297 plugin_name
7298 );
7299 }
7300
7301 let js_code = if has_es_module_syntax(source) {
7302 let stripped = strip_imports_and_exports(source);
7303 if is_typescript {
7304 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
7305 } else {
7306 stripped
7307 }
7308 } else if is_typescript {
7309 transpile_typescript(source, &format!("{}.ts", plugin_name))?
7310 } else {
7311 source.to_string()
7312 };
7313
7314 let source_name = format!(
7316 "{}.{}",
7317 plugin_name,
7318 if is_typescript { "ts" } else { "js" }
7319 );
7320 self.execute_js(&js_code, &source_name)
7321 }
7322
7323 pub fn cleanup_plugin(&self, plugin_name: &str) {
7329 self.plugin_contexts.borrow_mut().remove(plugin_name);
7331
7332 {
7334 let mut handlers_map = self
7335 .event_handlers
7336 .write()
7337 .expect("event_handlers poisoned");
7338 for handlers in handlers_map.values_mut() {
7339 handlers.retain(|h| h.plugin_name != plugin_name);
7340 }
7341 handlers_map.retain(|_, list| !list.is_empty());
7345 }
7346
7347 self.registered_actions
7349 .borrow_mut()
7350 .retain(|_, h| h.plugin_name != plugin_name);
7351
7352 self.callback_contexts
7354 .borrow_mut()
7355 .retain(|_, pname| pname != plugin_name);
7356
7357 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
7359 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
7361 std::collections::HashSet::new();
7362 for (buf_id, ns) in &tracked.overlay_namespaces {
7363 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
7364 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
7366 buffer_id: *buf_id,
7367 namespace: OverlayNamespace::from_string(ns.clone()),
7368 });
7369 let _ = self
7371 .command_sender
7372 .send(PluginCommand::ClearConcealNamespace {
7373 buffer_id: *buf_id,
7374 namespace: OverlayNamespace::from_string(ns.clone()),
7375 });
7376 let _ = self
7377 .command_sender
7378 .send(PluginCommand::ClearSoftBreakNamespace {
7379 buffer_id: *buf_id,
7380 namespace: OverlayNamespace::from_string(ns.clone()),
7381 });
7382 }
7383 }
7384
7385 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
7391 std::collections::HashSet::new();
7392 for (buf_id, ns) in &tracked.line_indicator_namespaces {
7393 if seen_li_ns.insert((buf_id.0, ns.clone())) {
7394 let _ = self
7395 .command_sender
7396 .send(PluginCommand::ClearLineIndicators {
7397 buffer_id: *buf_id,
7398 namespace: ns.clone(),
7399 });
7400 }
7401 }
7402
7403 let mut seen_vt: std::collections::HashSet<(usize, String)> =
7405 std::collections::HashSet::new();
7406 for (buf_id, vt_id) in &tracked.virtual_text_ids {
7407 if seen_vt.insert((buf_id.0, vt_id.clone())) {
7408 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
7409 buffer_id: *buf_id,
7410 virtual_text_id: vt_id.clone(),
7411 });
7412 }
7413 }
7414
7415 let mut seen_fe_ns: std::collections::HashSet<String> =
7417 std::collections::HashSet::new();
7418 for ns in &tracked.file_explorer_namespaces {
7419 if seen_fe_ns.insert(ns.clone()) {
7420 let _ = self
7421 .command_sender
7422 .send(PluginCommand::ClearFileExplorerDecorations {
7423 namespace: ns.clone(),
7424 });
7425 let _ = self
7426 .command_sender
7427 .send(PluginCommand::ClearFileExplorerSlots {
7428 namespace: ns.clone(),
7429 });
7430 }
7431 }
7432
7433 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
7435 for ctx_name in &tracked.contexts_set {
7436 if seen_ctx.insert(ctx_name.clone()) {
7437 let _ = self.command_sender.send(PluginCommand::SetContext {
7438 name: ctx_name.clone(),
7439 active: false,
7440 });
7441 }
7442 }
7443
7444 for process_id in &tracked.background_process_ids {
7448 let _ = self
7449 .command_sender
7450 .send(PluginCommand::KillBackgroundProcess {
7451 process_id: *process_id,
7452 });
7453 }
7454
7455 for group_id in &tracked.scroll_sync_group_ids {
7457 let _ = self
7458 .command_sender
7459 .send(PluginCommand::RemoveScrollSyncGroup {
7460 group_id: *group_id,
7461 });
7462 }
7463
7464 for buffer_id in &tracked.virtual_buffer_ids {
7466 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7467 buffer_id: *buffer_id,
7468 });
7469 }
7470
7471 for buffer_id in &tracked.composite_buffer_ids {
7473 let _ = self
7474 .command_sender
7475 .send(PluginCommand::CloseCompositeBuffer {
7476 buffer_id: *buffer_id,
7477 });
7478 }
7479
7480 for terminal_id in &tracked.terminal_ids {
7482 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7483 terminal_id: *terminal_id,
7484 });
7485 }
7486
7487 for handle in &tracked.watch_handles {
7491 let _ = self
7492 .command_sender
7493 .send(PluginCommand::UnwatchPath { handle: *handle });
7494 }
7495 }
7496
7497 if let Ok(mut owners) = self.async_resource_owners.lock() {
7499 owners.retain(|_, name| name != plugin_name);
7500 }
7501
7502 self.plugin_api_exports
7504 .borrow_mut()
7505 .retain(|_, (exporter, _)| exporter != plugin_name);
7506
7507 self.registered_command_names
7509 .borrow_mut()
7510 .retain(|_, pname| pname != plugin_name);
7511 self.registered_grammar_languages
7512 .borrow_mut()
7513 .retain(|_, pname| pname != plugin_name);
7514 self.registered_language_configs
7515 .borrow_mut()
7516 .retain(|_, pname| pname != plugin_name);
7517 self.registered_lsp_servers
7518 .borrow_mut()
7519 .retain(|_, pname| pname != plugin_name);
7520
7521 tracing::debug!(
7522 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7523 plugin_name
7524 );
7525 }
7526
7527 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7529 self.emit_to(event_name, event_data, None).await
7530 }
7531
7532 pub async fn emit_to(
7536 &mut self,
7537 event_name: &str,
7538 event_data: &serde_json::Value,
7539 target: Option<&str>,
7540 ) -> Result<bool> {
7541 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7542
7543 self.services
7544 .set_js_execution_state(format!("hook '{}'", event_name));
7545
7546 let handlers = self
7547 .event_handlers
7548 .read()
7549 .expect("event_handlers poisoned")
7550 .get(event_name)
7551 .cloned();
7552 if let Some(handler_pairs) = handlers {
7553 let plugin_contexts = self.plugin_contexts.borrow();
7554 for handler in &handler_pairs {
7555 if target.is_some_and(|t| t != handler.plugin_name) {
7556 continue;
7557 }
7558 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7559 continue;
7560 };
7561 context.with(|ctx| {
7562 call_handler(&ctx, &handler.handler_name, event_data);
7563 });
7564 }
7565 }
7566
7567 self.services.clear_js_execution_state();
7568 Ok(true)
7569 }
7570
7571 pub fn has_handlers(&self, event_name: &str) -> bool {
7573 self.event_handlers
7574 .read()
7575 .expect("event_handlers poisoned")
7576 .get(event_name)
7577 .map(|v| !v.is_empty())
7578 .unwrap_or(false)
7579 }
7580
7581 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7585 let (lookup_name, text_input_char) =
7588 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7589 ("mode_text_input", Some(ch.to_string()))
7590 } else {
7591 (action_name, None)
7592 };
7593
7594 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7595 let (plugin_name, function_name) = match pair {
7596 Some(handler) => (handler.plugin_name, handler.handler_name),
7597 None => ("main".to_string(), lookup_name.to_string()),
7598 };
7599
7600 let plugin_contexts = self.plugin_contexts.borrow();
7601 let context = plugin_contexts
7602 .get(&plugin_name)
7603 .unwrap_or(&self.main_context);
7604
7605 self.services
7607 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7608
7609 tracing::info!(
7610 "start_action: BEGIN '{}' -> function '{}'",
7611 action_name,
7612 function_name
7613 );
7614
7615 let call_args = if let Some(ref ch) = text_input_char {
7618 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7619 format!("({{text:\"{}\"}})", escaped)
7620 } else {
7621 "()".to_string()
7622 };
7623
7624 let code = format!(
7625 r#"
7626 (function() {{
7627 console.log('[JS] start_action: calling {fn}');
7628 try {{
7629 if (typeof globalThis.{fn} === 'function') {{
7630 console.log('[JS] start_action: {fn} is a function, invoking...');
7631 globalThis.{fn}{args};
7632 console.log('[JS] start_action: {fn} invoked (may be async)');
7633 }} else {{
7634 console.error('[JS] Action {action} is not defined as a global function');
7635 }}
7636 }} catch (e) {{
7637 console.error('[JS] Action {action} error:', e);
7638 }}
7639 }})();
7640 "#,
7641 fn = function_name,
7642 action = action_name,
7643 args = call_args
7644 );
7645
7646 tracing::info!("start_action: evaluating JS code");
7647 context.with(|ctx| {
7648 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7649 log_js_error(&ctx, e, &format!("action {}", action_name));
7650 }
7651 tracing::info!("start_action: running pending microtasks");
7652 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7654 tracing::info!("start_action: executed {} pending jobs", count);
7655 });
7656
7657 tracing::info!("start_action: END '{}'", action_name);
7658
7659 self.services.clear_js_execution_state();
7661
7662 Ok(())
7663 }
7664
7665 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7667 let pair = self.registered_actions.borrow().get(action_name).cloned();
7669 let (plugin_name, function_name) = match pair {
7670 Some(handler) => (handler.plugin_name, handler.handler_name),
7671 None => ("main".to_string(), action_name.to_string()),
7672 };
7673
7674 let plugin_contexts = self.plugin_contexts.borrow();
7675 let context = plugin_contexts
7676 .get(&plugin_name)
7677 .unwrap_or(&self.main_context);
7678
7679 tracing::debug!(
7680 "execute_action: '{}' -> function '{}'",
7681 action_name,
7682 function_name
7683 );
7684
7685 let code = format!(
7688 r#"
7689 (async function() {{
7690 try {{
7691 if (typeof globalThis.{fn} === 'function') {{
7692 const result = globalThis.{fn}();
7693 // If it's a Promise, await it
7694 if (result && typeof result.then === 'function') {{
7695 await result;
7696 }}
7697 }} else {{
7698 console.error('Action {action} is not defined as a global function');
7699 }}
7700 }} catch (e) {{
7701 console.error('Action {action} error:', e);
7702 }}
7703 }})();
7704 "#,
7705 fn = function_name,
7706 action = action_name
7707 );
7708
7709 context.with(|ctx| {
7710 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7712 Ok(value) => {
7713 if value.is_object() {
7715 if let Some(obj) = value.as_object() {
7716 if obj.get::<_, rquickjs::Function>("then").is_ok() {
7718 run_pending_jobs_checked(
7721 &ctx,
7722 &format!("execute_action {} promise", action_name),
7723 );
7724 }
7725 }
7726 }
7727 }
7728 Err(e) => {
7729 log_js_error(&ctx, e, &format!("action {}", action_name));
7730 }
7731 }
7732 });
7733
7734 Ok(())
7735 }
7736
7737 pub fn poll_event_loop_once(&mut self) -> bool {
7739 let mut had_work = false;
7740
7741 self.main_context.with(|ctx| {
7743 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7744 if count > 0 {
7745 had_work = true;
7746 }
7747 });
7748
7749 let contexts = self.plugin_contexts.borrow().clone();
7751 for (name, context) in contexts {
7752 context.with(|ctx| {
7753 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7754 if count > 0 {
7755 had_work = true;
7756 }
7757 });
7758 }
7759 had_work
7760 }
7761
7762 pub fn send_status(&self, message: String) {
7764 let _ = self
7765 .command_sender
7766 .send(PluginCommand::SetStatus { message });
7767 }
7768
7769 pub fn send_hook_completed(&self, hook_name: String) {
7773 let _ = self
7774 .command_sender
7775 .send(PluginCommand::HookCompleted { hook_name });
7776 }
7777
7778 pub fn resolve_callback(
7783 &mut self,
7784 callback_id: fresh_core::api::JsCallbackId,
7785 result_json: &str,
7786 ) {
7787 let id = callback_id.as_u64();
7788 tracing::debug!("resolve_callback: starting for callback_id={}", id);
7789
7790 let plugin_name = {
7792 let mut contexts = self.callback_contexts.borrow_mut();
7793 contexts.remove(&id)
7794 };
7795
7796 let Some(name) = plugin_name else {
7797 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7798 return;
7799 };
7800
7801 let plugin_contexts = self.plugin_contexts.borrow();
7802 let Some(context) = plugin_contexts.get(&name) else {
7803 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7804 return;
7805 };
7806
7807 context.with(|ctx| {
7808 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7810 Ok(v) => v,
7811 Err(e) => {
7812 tracing::error!(
7813 "resolve_callback: failed to parse JSON for callback_id={}: {}",
7814 id,
7815 e
7816 );
7817 return;
7818 }
7819 };
7820
7821 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7823 Ok(v) => v,
7824 Err(e) => {
7825 tracing::error!(
7826 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7827 id,
7828 e
7829 );
7830 return;
7831 }
7832 };
7833
7834 let globals = ctx.globals();
7836 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7837 Ok(f) => f,
7838 Err(e) => {
7839 tracing::error!(
7840 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7841 id,
7842 e
7843 );
7844 return;
7845 }
7846 };
7847
7848 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7850 log_js_error(&ctx, e, &format!("resolving callback {}", id));
7851 }
7852
7853 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7855 tracing::info!(
7856 "resolve_callback: executed {} pending jobs for callback_id={}",
7857 job_count,
7858 id
7859 );
7860 });
7861 }
7862
7863 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7865 let id = callback_id.as_u64();
7866
7867 let plugin_name = {
7869 let mut contexts = self.callback_contexts.borrow_mut();
7870 contexts.remove(&id)
7871 };
7872
7873 let Some(name) = plugin_name else {
7874 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
7875 return;
7876 };
7877
7878 let plugin_contexts = self.plugin_contexts.borrow();
7879 let Some(context) = plugin_contexts.get(&name) else {
7880 tracing::warn!("reject_callback: Context lost for plugin {}", name);
7881 return;
7882 };
7883
7884 context.with(|ctx| {
7885 let globals = ctx.globals();
7887 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
7888 Ok(f) => f,
7889 Err(e) => {
7890 tracing::error!(
7891 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
7892 id,
7893 e
7894 );
7895 return;
7896 }
7897 };
7898
7899 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
7901 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
7902 }
7903
7904 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
7906 });
7907 }
7908}
7909
7910#[cfg(test)]
7911mod tests {
7912 use super::*;
7913 use fresh_core::api::{BufferInfo, CursorInfo};
7914 use std::sync::mpsc;
7915
7916 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
7918 let (tx, rx) = mpsc::channel();
7919 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7920 let services = Arc::new(TestServiceBridge::new());
7921 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7922 (backend, rx)
7923 }
7924
7925 struct TestServiceBridge {
7926 en_strings: std::sync::Mutex<HashMap<String, String>>,
7927 }
7928
7929 impl TestServiceBridge {
7930 fn new() -> Self {
7931 Self {
7932 en_strings: std::sync::Mutex::new(HashMap::new()),
7933 }
7934 }
7935 }
7936
7937 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
7938 fn as_any(&self) -> &dyn std::any::Any {
7939 self
7940 }
7941 fn translate(
7942 &self,
7943 _plugin_name: &str,
7944 key: &str,
7945 _args: &HashMap<String, String>,
7946 ) -> String {
7947 self.en_strings
7948 .lock()
7949 .unwrap()
7950 .get(key)
7951 .cloned()
7952 .unwrap_or_else(|| key.to_string())
7953 }
7954 fn current_locale(&self) -> String {
7955 "en".to_string()
7956 }
7957 fn set_js_execution_state(&self, _state: String) {}
7958 fn clear_js_execution_state(&self) {}
7959 fn get_theme_schema(&self) -> serde_json::Value {
7960 serde_json::json!({})
7961 }
7962 fn get_builtin_themes(&self) -> serde_json::Value {
7963 serde_json::json!([])
7964 }
7965 fn get_all_themes(&self) -> serde_json::Value {
7966 serde_json::json!({})
7967 }
7968 fn register_command(&self, _command: fresh_core::command::Command) {}
7969 fn unregister_command(&self, _name: &str) {}
7970 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
7971 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
7972 fn plugins_dir(&self) -> std::path::PathBuf {
7973 std::path::PathBuf::from("/tmp/plugins")
7974 }
7975 fn config_dir(&self) -> std::path::PathBuf {
7976 std::path::PathBuf::from("/tmp/config")
7977 }
7978 fn data_dir(&self) -> std::path::PathBuf {
7979 std::path::PathBuf::from("/tmp/data")
7980 }
7981 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
7982 None
7983 }
7984 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7985 Err("not implemented in test".to_string())
7986 }
7987 fn theme_file_exists(&self, _name: &str) -> bool {
7988 false
7989 }
7990 }
7991
7992 #[test]
7993 fn test_quickjs_backend_creation() {
7994 let backend = QuickJsBackend::new();
7995 assert!(backend.is_ok());
7996 }
7997
7998 #[test]
7999 fn test_execute_simple_js() {
8000 let mut backend = QuickJsBackend::new().unwrap();
8001 let result = backend.execute_js("const x = 1 + 2;", "test.js");
8002 assert!(result.is_ok());
8003 }
8004
8005 #[test]
8006 fn test_event_handler_registration() {
8007 let backend = QuickJsBackend::new().unwrap();
8008
8009 assert!(!backend.has_handlers("test_event"));
8011
8012 backend
8014 .event_handlers
8015 .write()
8016 .unwrap()
8017 .entry("test_event".to_string())
8018 .or_default()
8019 .push(PluginHandler {
8020 plugin_name: "test".to_string(),
8021 handler_name: "testHandler".to_string(),
8022 });
8023
8024 assert!(backend.has_handlers("test_event"));
8026 }
8027
8028 #[test]
8031 fn test_api_set_status() {
8032 let (mut backend, rx) = create_test_backend();
8033
8034 backend
8035 .execute_js(
8036 r#"
8037 const editor = getEditor();
8038 editor.setStatus("Hello from test");
8039 "#,
8040 "test.js",
8041 )
8042 .unwrap();
8043
8044 let cmd = rx.try_recv().unwrap();
8045 match cmd {
8046 PluginCommand::SetStatus { message } => {
8047 assert_eq!(message, "Hello from test");
8048 }
8049 _ => panic!("Expected SetStatus command, got {:?}", cmd),
8050 }
8051 }
8052
8053 #[test]
8054 fn test_api_register_command() {
8055 let (mut backend, rx) = create_test_backend();
8056
8057 backend
8058 .execute_js(
8059 r#"
8060 const editor = getEditor();
8061 globalThis.myTestHandler = function() { };
8062 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
8063 "#,
8064 "test_plugin.js",
8065 )
8066 .unwrap();
8067
8068 let cmd = rx.try_recv().unwrap();
8069 match cmd {
8070 PluginCommand::RegisterCommand { command } => {
8071 assert_eq!(command.name, "Test Command");
8072 assert_eq!(command.description, "A test command");
8073 assert_eq!(command.plugin_name, "test_plugin");
8075 }
8076 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
8077 }
8078 }
8079
8080 #[test]
8081 fn test_api_define_mode() {
8082 let (mut backend, rx) = create_test_backend();
8083
8084 backend
8085 .execute_js(
8086 r#"
8087 const editor = getEditor();
8088 editor.defineMode("test-mode", [
8089 ["a", "action_a"],
8090 ["b", "action_b"]
8091 ]);
8092 "#,
8093 "test.js",
8094 )
8095 .unwrap();
8096
8097 let cmd = rx.try_recv().unwrap();
8098 match cmd {
8099 PluginCommand::DefineMode {
8100 name,
8101 bindings,
8102 read_only,
8103 allow_text_input,
8104 inherit_normal_bindings,
8105 plugin_name,
8106 } => {
8107 assert_eq!(name, "test-mode");
8108 assert_eq!(bindings.len(), 2);
8109 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
8110 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
8111 assert!(!read_only);
8112 assert!(!allow_text_input);
8113 assert!(!inherit_normal_bindings);
8114 assert!(plugin_name.is_some());
8115 }
8116 _ => panic!("Expected DefineMode, got {:?}", cmd),
8117 }
8118 }
8119
8120 #[test]
8121 fn test_api_set_editor_mode() {
8122 let (mut backend, rx) = create_test_backend();
8123
8124 backend
8125 .execute_js(
8126 r#"
8127 const editor = getEditor();
8128 editor.setEditorMode("vi-normal");
8129 "#,
8130 "test.js",
8131 )
8132 .unwrap();
8133
8134 let cmd = rx.try_recv().unwrap();
8135 match cmd {
8136 PluginCommand::SetEditorMode { mode } => {
8137 assert_eq!(mode, Some("vi-normal".to_string()));
8138 }
8139 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
8140 }
8141 }
8142
8143 #[test]
8144 fn test_api_clear_editor_mode() {
8145 let (mut backend, rx) = create_test_backend();
8146
8147 backend
8148 .execute_js(
8149 r#"
8150 const editor = getEditor();
8151 editor.setEditorMode(null);
8152 "#,
8153 "test.js",
8154 )
8155 .unwrap();
8156
8157 let cmd = rx.try_recv().unwrap();
8158 match cmd {
8159 PluginCommand::SetEditorMode { mode } => {
8160 assert!(mode.is_none());
8161 }
8162 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
8163 }
8164 }
8165
8166 #[test]
8167 fn test_api_insert_at_cursor() {
8168 let (mut backend, rx) = create_test_backend();
8169
8170 backend
8171 .execute_js(
8172 r#"
8173 const editor = getEditor();
8174 editor.insertAtCursor("Hello, World!");
8175 "#,
8176 "test.js",
8177 )
8178 .unwrap();
8179
8180 let cmd = rx.try_recv().unwrap();
8181 match cmd {
8182 PluginCommand::InsertAtCursor { text } => {
8183 assert_eq!(text, "Hello, World!");
8184 }
8185 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
8186 }
8187 }
8188
8189 #[test]
8190 fn test_api_set_context() {
8191 let (mut backend, rx) = create_test_backend();
8192
8193 backend
8194 .execute_js(
8195 r#"
8196 const editor = getEditor();
8197 editor.setContext("myContext", true);
8198 "#,
8199 "test.js",
8200 )
8201 .unwrap();
8202
8203 let cmd = rx.try_recv().unwrap();
8204 match cmd {
8205 PluginCommand::SetContext { name, active } => {
8206 assert_eq!(name, "myContext");
8207 assert!(active);
8208 }
8209 _ => panic!("Expected SetContext, got {:?}", cmd),
8210 }
8211 }
8212
8213 #[tokio::test]
8214 async fn test_execute_action_sync_function() {
8215 let (mut backend, rx) = create_test_backend();
8216
8217 backend.registered_actions.borrow_mut().insert(
8219 "my_sync_action".to_string(),
8220 PluginHandler {
8221 plugin_name: "test".to_string(),
8222 handler_name: "my_sync_action".to_string(),
8223 },
8224 );
8225
8226 backend
8228 .execute_js(
8229 r#"
8230 const editor = getEditor();
8231 globalThis.my_sync_action = function() {
8232 editor.setStatus("sync action executed");
8233 };
8234 "#,
8235 "test.js",
8236 )
8237 .unwrap();
8238
8239 while rx.try_recv().is_ok() {}
8241
8242 backend.execute_action("my_sync_action").await.unwrap();
8244
8245 let cmd = rx.try_recv().unwrap();
8247 match cmd {
8248 PluginCommand::SetStatus { message } => {
8249 assert_eq!(message, "sync action executed");
8250 }
8251 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
8252 }
8253 }
8254
8255 #[tokio::test]
8256 async fn test_execute_action_async_function() {
8257 let (mut backend, rx) = create_test_backend();
8258
8259 backend.registered_actions.borrow_mut().insert(
8261 "my_async_action".to_string(),
8262 PluginHandler {
8263 plugin_name: "test".to_string(),
8264 handler_name: "my_async_action".to_string(),
8265 },
8266 );
8267
8268 backend
8270 .execute_js(
8271 r#"
8272 const editor = getEditor();
8273 globalThis.my_async_action = async function() {
8274 await Promise.resolve();
8275 editor.setStatus("async action executed");
8276 };
8277 "#,
8278 "test.js",
8279 )
8280 .unwrap();
8281
8282 while rx.try_recv().is_ok() {}
8284
8285 backend.execute_action("my_async_action").await.unwrap();
8287
8288 let cmd = rx.try_recv().unwrap();
8290 match cmd {
8291 PluginCommand::SetStatus { message } => {
8292 assert_eq!(message, "async action executed");
8293 }
8294 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
8295 }
8296 }
8297
8298 #[tokio::test]
8299 async fn test_execute_action_with_registered_handler() {
8300 let (mut backend, rx) = create_test_backend();
8301
8302 backend.registered_actions.borrow_mut().insert(
8304 "my_action".to_string(),
8305 PluginHandler {
8306 plugin_name: "test".to_string(),
8307 handler_name: "actual_handler_function".to_string(),
8308 },
8309 );
8310
8311 backend
8312 .execute_js(
8313 r#"
8314 const editor = getEditor();
8315 globalThis.actual_handler_function = function() {
8316 editor.setStatus("handler executed");
8317 };
8318 "#,
8319 "test.js",
8320 )
8321 .unwrap();
8322
8323 while rx.try_recv().is_ok() {}
8325
8326 backend.execute_action("my_action").await.unwrap();
8328
8329 let cmd = rx.try_recv().unwrap();
8330 match cmd {
8331 PluginCommand::SetStatus { message } => {
8332 assert_eq!(message, "handler executed");
8333 }
8334 _ => panic!("Expected SetStatus, got {:?}", cmd),
8335 }
8336 }
8337
8338 #[test]
8339 fn test_api_on_event_registration() {
8340 let (mut backend, _rx) = create_test_backend();
8341
8342 backend
8343 .execute_js(
8344 r#"
8345 const editor = getEditor();
8346 globalThis.myEventHandler = function() { };
8347 editor.on("bufferSave", "myEventHandler");
8348 "#,
8349 "test.js",
8350 )
8351 .unwrap();
8352
8353 assert!(backend.has_handlers("bufferSave"));
8354 }
8355
8356 #[test]
8357 fn test_api_off_event_unregistration() {
8358 let (mut backend, _rx) = create_test_backend();
8359
8360 backend
8361 .execute_js(
8362 r#"
8363 const editor = getEditor();
8364 globalThis.myEventHandler = function() { };
8365 editor.on("bufferSave", "myEventHandler");
8366 editor.off("bufferSave", "myEventHandler");
8367 "#,
8368 "test.js",
8369 )
8370 .unwrap();
8371
8372 assert!(!backend.has_handlers("bufferSave"));
8374 }
8375
8376 #[tokio::test]
8377 async fn test_emit_event() {
8378 let (mut backend, rx) = create_test_backend();
8379
8380 backend
8381 .execute_js(
8382 r#"
8383 const editor = getEditor();
8384 globalThis.onSaveHandler = function(data) {
8385 editor.setStatus("saved: " + JSON.stringify(data));
8386 };
8387 editor.on("bufferSave", "onSaveHandler");
8388 "#,
8389 "test.js",
8390 )
8391 .unwrap();
8392
8393 while rx.try_recv().is_ok() {}
8395
8396 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
8398 backend.emit("bufferSave", &event_data).await.unwrap();
8399
8400 let cmd = rx.try_recv().unwrap();
8401 match cmd {
8402 PluginCommand::SetStatus { message } => {
8403 assert!(message.contains("/test.txt"));
8404 }
8405 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8406 }
8407 }
8408
8409 #[tokio::test]
8410 async fn test_emit_to_targets_single_plugin() {
8411 let (mut backend, rx) = create_test_backend();
8415 for name in ["alpha", "beta"] {
8416 backend
8417 .execute_js(
8418 &format!(
8419 r#"
8420 const editor = getEditor();
8421 globalThis.onWidget = function(data) {{
8422 editor.setStatus("{name} got " + data.panel_id);
8423 }};
8424 editor.on("widget_event", "onWidget");
8425 "#
8426 ),
8427 &format!("{name}.js"),
8428 )
8429 .unwrap();
8430 }
8431 while rx.try_recv().is_ok() {}
8432
8433 let event_data: serde_json::Value = serde_json::json!({ "panel_id": 7 });
8434 backend
8435 .emit_to("widget_event", &event_data, Some("beta"))
8436 .await
8437 .unwrap();
8438 match rx.try_recv().unwrap() {
8439 PluginCommand::SetStatus { message } => assert_eq!(message, "beta got 7"),
8440 cmd => panic!("Expected SetStatus, got {:?}", cmd),
8441 }
8442 assert!(
8443 rx.try_recv().is_err(),
8444 "targeted emit must not reach the other plugin"
8445 );
8446
8447 backend
8448 .emit_to("widget_event", &event_data, None)
8449 .await
8450 .unwrap();
8451 let mut got: Vec<String> = Vec::new();
8452 while let Ok(cmd) = rx.try_recv() {
8453 if let PluginCommand::SetStatus { message } = cmd {
8454 got.push(message);
8455 }
8456 }
8457 got.sort();
8458 assert_eq!(got, vec!["alpha got 7", "beta got 7"]);
8459 }
8460
8461 #[tokio::test]
8462 async fn test_emit_event_preserves_integers_beyond_i32() {
8463 let (mut backend, rx) = create_test_backend();
8467
8468 backend
8469 .execute_js(
8470 r#"
8471 const editor = getEditor();
8472 globalThis.onBigHandler = function(data) {
8473 editor.setStatus("big: " + data.big + " neg: " + data.neg + " small: " + data.small);
8474 };
8475 editor.on("bigEvent", "onBigHandler");
8476 "#,
8477 "test.js",
8478 )
8479 .unwrap();
8480 while rx.try_recv().is_ok() {}
8481
8482 let event_data: serde_json::Value = serde_json::json!({
8483 "big": 4_503_599_627_370_001_i64, "neg": -4_503_599_627_370_001_i64,
8485 "small": 42,
8486 });
8487 backend.emit("bigEvent", &event_data).await.unwrap();
8488
8489 let cmd = rx.try_recv().unwrap();
8490 match cmd {
8491 PluginCommand::SetStatus { message } => {
8492 assert_eq!(
8493 message,
8494 "big: 4503599627370001 neg: -4503599627370001 small: 42"
8495 );
8496 }
8497 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8498 }
8499 }
8500
8501 #[test]
8502 fn test_api_copy_to_clipboard() {
8503 let (mut backend, rx) = create_test_backend();
8504
8505 backend
8506 .execute_js(
8507 r#"
8508 const editor = getEditor();
8509 editor.copyToClipboard("clipboard text");
8510 "#,
8511 "test.js",
8512 )
8513 .unwrap();
8514
8515 let cmd = rx.try_recv().unwrap();
8516 match cmd {
8517 PluginCommand::SetClipboard { text } => {
8518 assert_eq!(text, "clipboard text");
8519 }
8520 _ => panic!("Expected SetClipboard, got {:?}", cmd),
8521 }
8522 }
8523
8524 #[test]
8525 fn test_api_open_file() {
8526 let (mut backend, rx) = create_test_backend();
8527
8528 backend
8530 .execute_js(
8531 r#"
8532 const editor = getEditor();
8533 editor.openFile("/path/to/file.txt", null, null);
8534 "#,
8535 "test.js",
8536 )
8537 .unwrap();
8538
8539 let cmd = rx.try_recv().unwrap();
8540 match cmd {
8541 PluginCommand::OpenFileAtLocation { path, line, column } => {
8542 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
8543 assert!(line.is_none());
8544 assert!(column.is_none());
8545 }
8546 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8547 }
8548 }
8549
8550 #[test]
8551 fn test_api_delete_range() {
8552 let (mut backend, rx) = create_test_backend();
8553
8554 backend
8556 .execute_js(
8557 r#"
8558 const editor = getEditor();
8559 editor.deleteRange(0, 10, 20);
8560 "#,
8561 "test.js",
8562 )
8563 .unwrap();
8564
8565 let cmd = rx.try_recv().unwrap();
8566 match cmd {
8567 PluginCommand::DeleteRange { range, .. } => {
8568 assert_eq!(range.start, 10);
8569 assert_eq!(range.end, 20);
8570 }
8571 _ => panic!("Expected DeleteRange, got {:?}", cmd),
8572 }
8573 }
8574
8575 #[test]
8576 fn test_api_insert_text() {
8577 let (mut backend, rx) = create_test_backend();
8578
8579 backend
8581 .execute_js(
8582 r#"
8583 const editor = getEditor();
8584 editor.insertText(0, 5, "inserted");
8585 "#,
8586 "test.js",
8587 )
8588 .unwrap();
8589
8590 let cmd = rx.try_recv().unwrap();
8591 match cmd {
8592 PluginCommand::InsertText { position, text, .. } => {
8593 assert_eq!(position, 5);
8594 assert_eq!(text, "inserted");
8595 }
8596 _ => panic!("Expected InsertText, got {:?}", cmd),
8597 }
8598 }
8599
8600 #[test]
8601 fn test_api_set_buffer_cursor() {
8602 let (mut backend, rx) = create_test_backend();
8603
8604 backend
8606 .execute_js(
8607 r#"
8608 const editor = getEditor();
8609 editor.setBufferCursor(0, 100);
8610 "#,
8611 "test.js",
8612 )
8613 .unwrap();
8614
8615 let cmd = rx.try_recv().unwrap();
8616 match cmd {
8617 PluginCommand::SetBufferCursor { position, .. } => {
8618 assert_eq!(position, 100);
8619 }
8620 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8621 }
8622 }
8623
8624 #[test]
8625 fn test_api_get_cursor_position_from_state() {
8626 let (tx, _rx) = mpsc::channel();
8627 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8628
8629 {
8631 let mut state = state_snapshot.write().unwrap();
8632 state.primary_cursor = Some(CursorInfo {
8633 position: 42,
8634 selection: None,
8635 line: Some(0),
8636 });
8637 }
8638
8639 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8640 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8641
8642 backend
8644 .execute_js(
8645 r#"
8646 const editor = getEditor();
8647 const pos = editor.getCursorPosition();
8648 globalThis._testResult = pos;
8649 "#,
8650 "test.js",
8651 )
8652 .unwrap();
8653
8654 backend
8656 .plugin_contexts
8657 .borrow()
8658 .get("test")
8659 .unwrap()
8660 .clone()
8661 .with(|ctx| {
8662 let global = ctx.globals();
8663 let result: u32 = global.get("_testResult").unwrap();
8664 assert_eq!(result, 42);
8665 });
8666 }
8667
8668 #[test]
8679 fn test_api_get_cursor_line_small_and_large_file() {
8680 let (tx, _rx) = mpsc::channel();
8682 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8683 {
8684 let mut state = state_snapshot.write().unwrap();
8685 state.primary_cursor = Some(CursorInfo {
8686 position: 120,
8687 selection: None,
8688 line: Some(7),
8689 });
8690 state.all_cursors = vec![
8691 CursorInfo {
8692 position: 120,
8693 selection: None,
8694 line: Some(7),
8695 },
8696 CursorInfo {
8697 position: 200,
8698 selection: None,
8699 line: Some(12),
8700 },
8701 ];
8702 }
8703
8704 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8705 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8706
8707 backend
8708 .execute_js(
8709 r#"
8710 const editor = getEditor();
8711 const primary = editor.getPrimaryCursor();
8712 globalThis._primaryLine = primary.line;
8713 globalThis._cursorLine = editor.getCursorLine();
8714 globalThis._allLines = editor.getAllCursors().map(c => c.line);
8715 "#,
8716 "probe_small.js",
8717 )
8718 .unwrap();
8719
8720 backend
8721 .plugin_contexts
8722 .borrow()
8723 .get("probe_small")
8724 .unwrap()
8725 .clone()
8726 .with(|ctx| {
8727 let global = ctx.globals();
8728 let primary_line: i32 = global.get("_primaryLine").unwrap();
8730 assert_eq!(primary_line, 7);
8731 let cursor_line: u32 = global.get("_cursorLine").unwrap();
8733 assert_eq!(cursor_line, 7);
8734 let all_lines: Vec<i32> = global.get("_allLines").unwrap();
8736 assert_eq!(all_lines, vec![7, 12]);
8737 });
8738
8739 let (tx2, _rx2) = mpsc::channel();
8741 let state_snapshot2 = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8742 {
8743 let mut state = state_snapshot2.write().unwrap();
8744 state.primary_cursor = Some(CursorInfo {
8745 position: 5_000_000,
8746 selection: None,
8747 line: None,
8748 });
8749 state.all_cursors = vec![CursorInfo {
8750 position: 5_000_000,
8751 selection: None,
8752 line: None,
8753 }];
8754 }
8755
8756 let services2 = Arc::new(fresh_core::services::NoopServiceBridge);
8757 let mut backend2 = QuickJsBackend::with_state(state_snapshot2, tx2, services2).unwrap();
8758
8759 backend2
8760 .execute_js(
8761 r#"
8762 const editor = getEditor();
8763 const primary = editor.getPrimaryCursor();
8764 // null and undefined both serialize to JS null here; normalize to a
8765 // sentinel so the Rust side can assert "unknown" unambiguously.
8766 globalThis._primaryLineIsNull = (primary.line === null || primary.line === undefined);
8767 globalThis._cursorLineFallback = editor.getCursorLine();
8768 globalThis._allLineIsNull = (editor.getAllCursors()[0].line === null);
8769 "#,
8770 "probe_large.js",
8771 )
8772 .unwrap();
8773
8774 backend2
8775 .plugin_contexts
8776 .borrow()
8777 .get("probe_large")
8778 .unwrap()
8779 .clone()
8780 .with(|ctx| {
8781 let global = ctx.globals();
8782 let primary_null: bool = global.get("_primaryLineIsNull").unwrap();
8784 assert!(
8785 primary_null,
8786 "primary.line should be null in large-file mode"
8787 );
8788 let all_null: bool = global.get("_allLineIsNull").unwrap();
8789 assert!(
8790 all_null,
8791 "getAllCursors()[0].line should be null in large-file mode"
8792 );
8793 let fallback: u32 = global.get("_cursorLineFallback").unwrap();
8795 assert_eq!(fallback, 0);
8796 });
8797 }
8798
8799 #[test]
8800 fn test_api_path_functions() {
8801 let (mut backend, _rx) = create_test_backend();
8802
8803 #[cfg(windows)]
8806 let absolute_path = r#"C:\\foo\\bar"#;
8807 #[cfg(not(windows))]
8808 let absolute_path = "/foo/bar";
8809
8810 let js_code = format!(
8812 r#"
8813 const editor = getEditor();
8814 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8815 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8816 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8817 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8818 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8819 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8820 "#,
8821 absolute_path
8822 );
8823 backend.execute_js(&js_code, "test.js").unwrap();
8824
8825 backend
8826 .plugin_contexts
8827 .borrow()
8828 .get("test")
8829 .unwrap()
8830 .clone()
8831 .with(|ctx| {
8832 let global = ctx.globals();
8833 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8834 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8835 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8836 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8837 assert!(!global.get::<_, bool>("_isRelative").unwrap());
8838 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8839 });
8840 }
8841
8842 #[test]
8850 fn test_path_join_preserves_unc_prefix() {
8851 let (mut backend, _rx) = create_test_backend();
8852 backend
8853 .execute_js(
8854 r#"
8855 const editor = getEditor();
8856 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8857 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8858 globalThis._posix = editor.pathJoin("/foo", "bar");
8859 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8860 "#,
8861 "test.js",
8862 )
8863 .unwrap();
8864
8865 backend
8866 .plugin_contexts
8867 .borrow()
8868 .get("test")
8869 .unwrap()
8870 .clone()
8871 .with(|ctx| {
8872 let global = ctx.globals();
8873 assert_eq!(
8874 global.get::<_, String>("_unc").unwrap(),
8875 "//?/C:/workspace/.devcontainer/devcontainer.json",
8876 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
8877 );
8878 assert_eq!(
8879 global.get::<_, String>("_unc_fwd").unwrap(),
8880 "//?/C:/workspace/.devcontainer/devcontainer.json",
8881 "UNC prefix in forward-slash form stays as `//`",
8882 );
8883 assert_eq!(
8884 global.get::<_, String>("_posix").unwrap(),
8885 "/foo/bar",
8886 "POSIX absolute paths keep their single leading slash",
8887 );
8888 assert_eq!(
8889 global.get::<_, String>("_drive").unwrap(),
8890 "C:/foo/bar",
8891 "Windows drive-letter paths have no leading slash",
8892 );
8893 });
8894 }
8895
8896 #[test]
8897 fn test_file_uri_to_path_and_back() {
8898 let (mut backend, _rx) = create_test_backend();
8899
8900 #[cfg(not(windows))]
8902 let js_code = r#"
8903 const editor = getEditor();
8904 // Basic file URI to path
8905 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
8906 // Percent-encoded characters
8907 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
8908 // Invalid URI returns empty string
8909 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8910 // Path to file URI
8911 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
8912 // Round-trip
8913 globalThis._roundtrip = editor.fileUriToPath(
8914 editor.pathToFileUri("/home/user/file.txt")
8915 );
8916 "#;
8917
8918 #[cfg(windows)]
8919 let js_code = r#"
8920 const editor = getEditor();
8921 // Windows URI with encoded colon (the bug from issue #1071)
8922 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
8923 // Windows URI with normal colon
8924 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
8925 // Invalid URI returns empty string
8926 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8927 // Path to file URI
8928 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
8929 // Round-trip
8930 globalThis._roundtrip = editor.fileUriToPath(
8931 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
8932 );
8933 "#;
8934
8935 backend.execute_js(js_code, "test.js").unwrap();
8936
8937 backend
8938 .plugin_contexts
8939 .borrow()
8940 .get("test")
8941 .unwrap()
8942 .clone()
8943 .with(|ctx| {
8944 let global = ctx.globals();
8945
8946 #[cfg(not(windows))]
8947 {
8948 assert_eq!(
8949 global.get::<_, String>("_path1").unwrap(),
8950 "/home/user/file.txt"
8951 );
8952 assert_eq!(
8953 global.get::<_, String>("_path2").unwrap(),
8954 "/home/user/my file.txt"
8955 );
8956 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8957 assert_eq!(
8958 global.get::<_, String>("_uri1").unwrap(),
8959 "file:///home/user/file.txt"
8960 );
8961 assert_eq!(
8962 global.get::<_, String>("_roundtrip").unwrap(),
8963 "/home/user/file.txt"
8964 );
8965 }
8966
8967 #[cfg(windows)]
8968 {
8969 assert_eq!(
8971 global.get::<_, String>("_path1").unwrap(),
8972 "C:\\Users\\admin\\Repos\\file.cs"
8973 );
8974 assert_eq!(
8975 global.get::<_, String>("_path2").unwrap(),
8976 "C:\\Users\\admin\\Repos\\file.cs"
8977 );
8978 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8979 assert_eq!(
8980 global.get::<_, String>("_uri1").unwrap(),
8981 "file:///C:/Users/admin/Repos/file.cs"
8982 );
8983 assert_eq!(
8984 global.get::<_, String>("_roundtrip").unwrap(),
8985 "C:\\Users\\admin\\Repos\\file.cs"
8986 );
8987 }
8988 });
8989 }
8990
8991 #[test]
8992 fn test_typescript_transpilation() {
8993 use fresh_parser_js::transpile_typescript;
8994
8995 let (mut backend, rx) = create_test_backend();
8996
8997 let ts_code = r#"
8999 const editor = getEditor();
9000 function greet(name: string): string {
9001 return "Hello, " + name;
9002 }
9003 editor.setStatus(greet("TypeScript"));
9004 "#;
9005
9006 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
9008
9009 backend.execute_js(&js_code, "test.js").unwrap();
9011
9012 let cmd = rx.try_recv().unwrap();
9013 match cmd {
9014 PluginCommand::SetStatus { message } => {
9015 assert_eq!(message, "Hello, TypeScript");
9016 }
9017 _ => panic!("Expected SetStatus, got {:?}", cmd),
9018 }
9019 }
9020
9021 #[test]
9022 fn test_api_get_buffer_text_sends_command() {
9023 let (mut backend, rx) = create_test_backend();
9024
9025 backend
9027 .execute_js(
9028 r#"
9029 const editor = getEditor();
9030 // Store the promise for later
9031 globalThis._textPromise = editor.getBufferText(0, 10, 20);
9032 "#,
9033 "test.js",
9034 )
9035 .unwrap();
9036
9037 let cmd = rx.try_recv().unwrap();
9039 match cmd {
9040 PluginCommand::GetBufferText {
9041 buffer_id,
9042 start,
9043 end,
9044 request_id,
9045 } => {
9046 assert_eq!(buffer_id.0, 0);
9047 assert_eq!(start, 10);
9048 assert_eq!(end, 20);
9049 assert!(request_id > 0); }
9051 _ => panic!("Expected GetBufferText, got {:?}", cmd),
9052 }
9053 }
9054
9055 #[test]
9056 fn test_api_get_buffer_text_resolves_callback() {
9057 let (mut backend, rx) = create_test_backend();
9058
9059 backend
9061 .execute_js(
9062 r#"
9063 const editor = getEditor();
9064 globalThis._resolvedText = null;
9065 editor.getBufferText(0, 0, 100).then(text => {
9066 globalThis._resolvedText = text;
9067 });
9068 "#,
9069 "test.js",
9070 )
9071 .unwrap();
9072
9073 let request_id = match rx.try_recv().unwrap() {
9075 PluginCommand::GetBufferText { request_id, .. } => request_id,
9076 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
9077 };
9078
9079 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
9081
9082 backend
9084 .plugin_contexts
9085 .borrow()
9086 .get("test")
9087 .unwrap()
9088 .clone()
9089 .with(|ctx| {
9090 run_pending_jobs_checked(&ctx, "test async getText");
9091 });
9092
9093 backend
9095 .plugin_contexts
9096 .borrow()
9097 .get("test")
9098 .unwrap()
9099 .clone()
9100 .with(|ctx| {
9101 let global = ctx.globals();
9102 let result: String = global.get("_resolvedText").unwrap();
9103 assert_eq!(result, "hello world");
9104 });
9105 }
9106
9107 #[test]
9108 fn test_plugin_translation() {
9109 let (mut backend, _rx) = create_test_backend();
9110
9111 backend
9113 .execute_js(
9114 r#"
9115 const editor = getEditor();
9116 globalThis._translated = editor.t("test.key");
9117 "#,
9118 "test.js",
9119 )
9120 .unwrap();
9121
9122 backend
9123 .plugin_contexts
9124 .borrow()
9125 .get("test")
9126 .unwrap()
9127 .clone()
9128 .with(|ctx| {
9129 let global = ctx.globals();
9130 let result: String = global.get("_translated").unwrap();
9132 assert_eq!(result, "test.key");
9133 });
9134 }
9135
9136 #[test]
9137 fn test_plugin_translation_with_registered_strings() {
9138 let (mut backend, _rx) = create_test_backend();
9139
9140 let mut en_strings = std::collections::HashMap::new();
9142 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
9143 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9144
9145 let mut strings = std::collections::HashMap::new();
9146 strings.insert("en".to_string(), en_strings);
9147
9148 if let Some(bridge) = backend
9150 .services
9151 .as_any()
9152 .downcast_ref::<TestServiceBridge>()
9153 {
9154 let mut en = bridge.en_strings.lock().unwrap();
9155 en.insert("greeting".to_string(), "Hello, World!".to_string());
9156 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9157 }
9158
9159 backend
9161 .execute_js(
9162 r#"
9163 const editor = getEditor();
9164 globalThis._greeting = editor.t("greeting");
9165 globalThis._prompt = editor.t("prompt.find_file");
9166 globalThis._missing = editor.t("nonexistent.key");
9167 "#,
9168 "test.js",
9169 )
9170 .unwrap();
9171
9172 backend
9173 .plugin_contexts
9174 .borrow()
9175 .get("test")
9176 .unwrap()
9177 .clone()
9178 .with(|ctx| {
9179 let global = ctx.globals();
9180 let greeting: String = global.get("_greeting").unwrap();
9181 assert_eq!(greeting, "Hello, World!");
9182
9183 let prompt: String = global.get("_prompt").unwrap();
9184 assert_eq!(prompt, "Find file: ");
9185
9186 let missing: String = global.get("_missing").unwrap();
9188 assert_eq!(missing, "nonexistent.key");
9189 });
9190 }
9191
9192 #[test]
9195 fn test_api_set_line_indicator() {
9196 let (mut backend, rx) = create_test_backend();
9197
9198 backend
9199 .execute_js(
9200 r#"
9201 const editor = getEditor();
9202 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
9203 "#,
9204 "test.js",
9205 )
9206 .unwrap();
9207
9208 let cmd = rx.try_recv().unwrap();
9209 match cmd {
9210 PluginCommand::SetLineIndicator {
9211 buffer_id,
9212 line,
9213 namespace,
9214 symbol,
9215 color,
9216 priority,
9217 } => {
9218 assert_eq!(buffer_id.0, 1);
9219 assert_eq!(line, 5);
9220 assert_eq!(namespace, "test-ns");
9221 assert_eq!(symbol, "●");
9222 assert_eq!(color, (255, 0, 0));
9223 assert_eq!(priority, 10);
9224 }
9225 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
9226 }
9227 }
9228
9229 #[test]
9230 fn test_api_clear_line_indicators() {
9231 let (mut backend, rx) = create_test_backend();
9232
9233 backend
9234 .execute_js(
9235 r#"
9236 const editor = getEditor();
9237 editor.clearLineIndicators(1, "test-ns");
9238 "#,
9239 "test.js",
9240 )
9241 .unwrap();
9242
9243 let cmd = rx.try_recv().unwrap();
9244 match cmd {
9245 PluginCommand::ClearLineIndicators {
9246 buffer_id,
9247 namespace,
9248 } => {
9249 assert_eq!(buffer_id.0, 1);
9250 assert_eq!(namespace, "test-ns");
9251 }
9252 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
9253 }
9254 }
9255
9256 #[test]
9259 fn test_api_create_virtual_buffer_sends_command() {
9260 let (mut backend, rx) = create_test_backend();
9261
9262 backend
9263 .execute_js(
9264 r#"
9265 const editor = getEditor();
9266 editor.createVirtualBuffer({
9267 name: "*Test Buffer*",
9268 mode: "test-mode",
9269 readOnly: true,
9270 entries: [
9271 { text: "Line 1\n", properties: { type: "header" } },
9272 { text: "Line 2\n", properties: { type: "content" } }
9273 ],
9274 showLineNumbers: false,
9275 showCursors: true,
9276 editingDisabled: true
9277 });
9278 "#,
9279 "test.js",
9280 )
9281 .unwrap();
9282
9283 let cmd = rx.try_recv().unwrap();
9284 match cmd {
9285 PluginCommand::CreateVirtualBufferWithContent {
9286 name,
9287 mode,
9288 read_only,
9289 entries,
9290 show_line_numbers,
9291 show_cursors,
9292 editing_disabled,
9293 ..
9294 } => {
9295 assert_eq!(name, "*Test Buffer*");
9296 assert_eq!(mode, "test-mode");
9297 assert!(read_only);
9298 assert_eq!(entries.len(), 2);
9299 assert_eq!(entries[0].text, "Line 1\n");
9300 assert!(!show_line_numbers);
9301 assert!(show_cursors);
9302 assert!(editing_disabled);
9303 }
9304 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
9305 }
9306 }
9307
9308 #[test]
9309 fn test_api_set_virtual_buffer_content() {
9310 let (mut backend, rx) = create_test_backend();
9311
9312 backend
9313 .execute_js(
9314 r#"
9315 const editor = getEditor();
9316 editor.setVirtualBufferContent(5, [
9317 { text: "New content\n", properties: { type: "updated" } }
9318 ]);
9319 "#,
9320 "test.js",
9321 )
9322 .unwrap();
9323
9324 let cmd = rx.try_recv().unwrap();
9325 match cmd {
9326 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
9327 assert_eq!(buffer_id.0, 5);
9328 assert_eq!(entries.len(), 1);
9329 assert_eq!(entries[0].text, "New content\n");
9330 }
9331 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
9332 }
9333 }
9334
9335 #[test]
9338 fn test_api_add_overlay() {
9339 let (mut backend, rx) = create_test_backend();
9340
9341 backend
9342 .execute_js(
9343 r#"
9344 const editor = getEditor();
9345 editor.addOverlay(1, "highlight", 10, 20, {
9346 fg: [255, 128, 0],
9347 bg: [50, 50, 50],
9348 bold: true,
9349 });
9350 "#,
9351 "test.js",
9352 )
9353 .unwrap();
9354
9355 let cmd = rx.try_recv().unwrap();
9356 match cmd {
9357 PluginCommand::AddOverlay {
9358 buffer_id,
9359 namespace,
9360 range,
9361 options,
9362 } => {
9363 use fresh_core::api::OverlayColorSpec;
9364 assert_eq!(buffer_id.0, 1);
9365 assert!(namespace.is_some());
9366 assert_eq!(namespace.unwrap().as_str(), "highlight");
9367 assert_eq!(range, 10..20);
9368 assert!(matches!(
9369 options.fg,
9370 Some(OverlayColorSpec::Rgb(255, 128, 0))
9371 ));
9372 assert!(matches!(
9373 options.bg,
9374 Some(OverlayColorSpec::Rgb(50, 50, 50))
9375 ));
9376 assert!(!options.underline);
9377 assert!(options.bold);
9378 assert!(!options.italic);
9379 assert!(!options.extend_to_line_end);
9380 }
9381 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9382 }
9383 }
9384
9385 #[test]
9386 fn test_api_add_overlay_with_theme_keys() {
9387 let (mut backend, rx) = create_test_backend();
9388
9389 backend
9390 .execute_js(
9391 r#"
9392 const editor = getEditor();
9393 // Test with theme keys for colors
9394 editor.addOverlay(1, "themed", 0, 10, {
9395 fg: "ui.status_bar_fg",
9396 bg: "editor.selection_bg",
9397 });
9398 "#,
9399 "test.js",
9400 )
9401 .unwrap();
9402
9403 let cmd = rx.try_recv().unwrap();
9404 match cmd {
9405 PluginCommand::AddOverlay {
9406 buffer_id,
9407 namespace,
9408 range,
9409 options,
9410 } => {
9411 use fresh_core::api::OverlayColorSpec;
9412 assert_eq!(buffer_id.0, 1);
9413 assert!(namespace.is_some());
9414 assert_eq!(namespace.unwrap().as_str(), "themed");
9415 assert_eq!(range, 0..10);
9416 assert!(matches!(
9417 &options.fg,
9418 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
9419 ));
9420 assert!(matches!(
9421 &options.bg,
9422 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
9423 ));
9424 assert!(!options.underline);
9425 assert!(!options.bold);
9426 assert!(!options.italic);
9427 assert!(!options.extend_to_line_end);
9428 }
9429 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9430 }
9431 }
9432
9433 #[test]
9434 fn test_api_clear_namespace() {
9435 let (mut backend, rx) = create_test_backend();
9436
9437 backend
9438 .execute_js(
9439 r#"
9440 const editor = getEditor();
9441 editor.clearNamespace(1, "highlight");
9442 "#,
9443 "test.js",
9444 )
9445 .unwrap();
9446
9447 let cmd = rx.try_recv().unwrap();
9448 match cmd {
9449 PluginCommand::ClearNamespace {
9450 buffer_id,
9451 namespace,
9452 } => {
9453 assert_eq!(buffer_id.0, 1);
9454 assert_eq!(namespace.as_str(), "highlight");
9455 }
9456 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
9457 }
9458 }
9459
9460 #[test]
9463 fn test_api_get_theme_schema() {
9464 let (mut backend, _rx) = create_test_backend();
9465
9466 backend
9467 .execute_js(
9468 r#"
9469 const editor = getEditor();
9470 const schema = editor.getThemeSchema();
9471 globalThis._isObject = typeof schema === 'object' && schema !== null;
9472 "#,
9473 "test.js",
9474 )
9475 .unwrap();
9476
9477 backend
9478 .plugin_contexts
9479 .borrow()
9480 .get("test")
9481 .unwrap()
9482 .clone()
9483 .with(|ctx| {
9484 let global = ctx.globals();
9485 let is_object: bool = global.get("_isObject").unwrap();
9486 assert!(is_object);
9488 });
9489 }
9490
9491 #[test]
9492 fn test_api_get_builtin_themes() {
9493 let (mut backend, _rx) = create_test_backend();
9494
9495 backend
9496 .execute_js(
9497 r#"
9498 const editor = getEditor();
9499 const themes = editor.getBuiltinThemes();
9500 globalThis._isObject = typeof themes === 'object' && themes !== null;
9501 "#,
9502 "test.js",
9503 )
9504 .unwrap();
9505
9506 backend
9507 .plugin_contexts
9508 .borrow()
9509 .get("test")
9510 .unwrap()
9511 .clone()
9512 .with(|ctx| {
9513 let global = ctx.globals();
9514 let is_object: bool = global.get("_isObject").unwrap();
9515 assert!(is_object);
9517 });
9518 }
9519
9520 #[test]
9521 fn test_api_apply_theme() {
9522 let (mut backend, rx) = create_test_backend();
9523
9524 backend
9525 .execute_js(
9526 r#"
9527 const editor = getEditor();
9528 editor.applyTheme("dark");
9529 "#,
9530 "test.js",
9531 )
9532 .unwrap();
9533
9534 let cmd = rx.try_recv().unwrap();
9535 match cmd {
9536 PluginCommand::ApplyTheme { theme_name } => {
9537 assert_eq!(theme_name, "dark");
9538 }
9539 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
9540 }
9541 }
9542
9543 #[test]
9544 fn test_api_override_theme_colors_round_trip() {
9545 let (mut backend, rx) = create_test_backend();
9548
9549 backend
9550 .execute_js(
9551 r#"
9552 const editor = getEditor();
9553 editor.overrideThemeColors({
9554 "editor.bg": [10, 20, 30],
9555 "editor.fg": [220, 221, 222],
9556 });
9557 "#,
9558 "test.js",
9559 )
9560 .unwrap();
9561
9562 let cmd = rx.try_recv().unwrap();
9563 match cmd {
9564 PluginCommand::OverrideThemeColors { overrides } => {
9565 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
9566 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
9567 assert_eq!(overrides.len(), 2);
9568 }
9569 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
9570 }
9571 }
9572
9573 #[test]
9574 fn test_api_override_theme_colors_clamps_out_of_range() {
9575 let (mut backend, rx) = create_test_backend();
9576
9577 backend
9578 .execute_js(
9579 r#"
9580 const editor = getEditor();
9581 editor.overrideThemeColors({
9582 "editor.bg": [-5, 300, 128],
9583 });
9584 "#,
9585 "test.js",
9586 )
9587 .unwrap();
9588
9589 match rx.try_recv().unwrap() {
9590 PluginCommand::OverrideThemeColors { overrides } => {
9591 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
9592 }
9593 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9594 }
9595 }
9596
9597 #[test]
9598 fn test_api_override_theme_colors_drops_malformed_entries() {
9599 let (mut backend, rx) = create_test_backend();
9602
9603 backend
9604 .execute_js(
9605 r#"
9606 const editor = getEditor();
9607 editor.overrideThemeColors({
9608 "editor.bg": [1, 2, 3],
9609 "not_an_array": "oops",
9610 "wrong_length": [1, 2],
9611 "floats_are_fine": [10.7, 20.2, 30.9],
9612 });
9613 "#,
9614 "test.js",
9615 )
9616 .unwrap();
9617
9618 match rx.try_recv().unwrap() {
9619 PluginCommand::OverrideThemeColors { overrides } => {
9620 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
9621 assert!(!overrides.contains_key("not_an_array"));
9622 assert!(!overrides.contains_key("wrong_length"));
9623 assert_eq!(
9625 overrides.get("floats_are_fine").copied(),
9626 Some([10, 20, 30])
9627 );
9628 }
9629 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9630 }
9631 }
9632
9633 #[test]
9634 fn test_api_get_theme_data_missing() {
9635 let (mut backend, _rx) = create_test_backend();
9636
9637 backend
9638 .execute_js(
9639 r#"
9640 const editor = getEditor();
9641 const data = editor.getThemeData("nonexistent");
9642 globalThis._isNull = data === null;
9643 "#,
9644 "test.js",
9645 )
9646 .unwrap();
9647
9648 backend
9649 .plugin_contexts
9650 .borrow()
9651 .get("test")
9652 .unwrap()
9653 .clone()
9654 .with(|ctx| {
9655 let global = ctx.globals();
9656 let is_null: bool = global.get("_isNull").unwrap();
9657 assert!(is_null);
9659 });
9660 }
9661
9662 #[test]
9663 fn test_api_get_theme_data_present() {
9664 let (tx, _rx) = mpsc::channel();
9666 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9667 let services = Arc::new(ThemeCacheTestBridge {
9668 inner: TestServiceBridge::new(),
9669 });
9670 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9671
9672 backend
9673 .execute_js(
9674 r#"
9675 const editor = getEditor();
9676 const data = editor.getThemeData("test-theme");
9677 globalThis._hasData = data !== null && typeof data === 'object';
9678 globalThis._name = data ? data.name : null;
9679 "#,
9680 "test.js",
9681 )
9682 .unwrap();
9683
9684 backend
9685 .plugin_contexts
9686 .borrow()
9687 .get("test")
9688 .unwrap()
9689 .clone()
9690 .with(|ctx| {
9691 let global = ctx.globals();
9692 let has_data: bool = global.get("_hasData").unwrap();
9693 assert!(has_data, "getThemeData should return theme object");
9694 let name: String = global.get("_name").unwrap();
9695 assert_eq!(name, "test-theme");
9696 });
9697 }
9698
9699 #[test]
9700 fn test_api_theme_file_exists() {
9701 let (mut backend, _rx) = create_test_backend();
9702
9703 backend
9704 .execute_js(
9705 r#"
9706 const editor = getEditor();
9707 globalThis._exists = editor.themeFileExists("anything");
9708 "#,
9709 "test.js",
9710 )
9711 .unwrap();
9712
9713 backend
9714 .plugin_contexts
9715 .borrow()
9716 .get("test")
9717 .unwrap()
9718 .clone()
9719 .with(|ctx| {
9720 let global = ctx.globals();
9721 let exists: bool = global.get("_exists").unwrap();
9722 assert!(!exists);
9724 });
9725 }
9726
9727 #[test]
9728 fn test_api_save_theme_file_error() {
9729 let (mut backend, _rx) = create_test_backend();
9730
9731 backend
9732 .execute_js(
9733 r#"
9734 const editor = getEditor();
9735 let threw = false;
9736 try {
9737 editor.saveThemeFile("test", "{}");
9738 } catch (e) {
9739 threw = true;
9740 }
9741 globalThis._threw = threw;
9742 "#,
9743 "test.js",
9744 )
9745 .unwrap();
9746
9747 backend
9748 .plugin_contexts
9749 .borrow()
9750 .get("test")
9751 .unwrap()
9752 .clone()
9753 .with(|ctx| {
9754 let global = ctx.globals();
9755 let threw: bool = global.get("_threw").unwrap();
9756 assert!(threw);
9758 });
9759 }
9760
9761 struct ThemeCacheTestBridge {
9763 inner: TestServiceBridge,
9764 }
9765
9766 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9767 fn as_any(&self) -> &dyn std::any::Any {
9768 self
9769 }
9770 fn translate(
9771 &self,
9772 plugin_name: &str,
9773 key: &str,
9774 args: &HashMap<String, String>,
9775 ) -> String {
9776 self.inner.translate(plugin_name, key, args)
9777 }
9778 fn current_locale(&self) -> String {
9779 self.inner.current_locale()
9780 }
9781 fn set_js_execution_state(&self, state: String) {
9782 self.inner.set_js_execution_state(state);
9783 }
9784 fn clear_js_execution_state(&self) {
9785 self.inner.clear_js_execution_state();
9786 }
9787 fn get_theme_schema(&self) -> serde_json::Value {
9788 self.inner.get_theme_schema()
9789 }
9790 fn get_builtin_themes(&self) -> serde_json::Value {
9791 self.inner.get_builtin_themes()
9792 }
9793 fn get_all_themes(&self) -> serde_json::Value {
9794 self.inner.get_all_themes()
9795 }
9796 fn register_command(&self, command: fresh_core::command::Command) {
9797 self.inner.register_command(command);
9798 }
9799 fn unregister_command(&self, name: &str) {
9800 self.inner.unregister_command(name);
9801 }
9802 fn unregister_commands_by_prefix(&self, prefix: &str) {
9803 self.inner.unregister_commands_by_prefix(prefix);
9804 }
9805 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9806 self.inner.unregister_commands_by_plugin(plugin_name);
9807 }
9808 fn plugins_dir(&self) -> std::path::PathBuf {
9809 self.inner.plugins_dir()
9810 }
9811 fn config_dir(&self) -> std::path::PathBuf {
9812 self.inner.config_dir()
9813 }
9814 fn data_dir(&self) -> std::path::PathBuf {
9815 self.inner.data_dir()
9816 }
9817 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9818 if name == "test-theme" {
9819 Some(serde_json::json!({
9820 "name": "test-theme",
9821 "editor": {},
9822 "ui": {},
9823 "syntax": {}
9824 }))
9825 } else {
9826 None
9827 }
9828 }
9829 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9830 Err("test bridge does not support save".to_string())
9831 }
9832 fn theme_file_exists(&self, name: &str) -> bool {
9833 name == "test-theme"
9834 }
9835 }
9836
9837 #[test]
9840 fn test_api_close_buffer() {
9841 let (mut backend, rx) = create_test_backend();
9842
9843 backend
9844 .execute_js(
9845 r#"
9846 const editor = getEditor();
9847 editor.closeBuffer(3);
9848 "#,
9849 "test.js",
9850 )
9851 .unwrap();
9852
9853 let cmd = rx.try_recv().unwrap();
9854 match cmd {
9855 PluginCommand::CloseBuffer { buffer_id } => {
9856 assert_eq!(buffer_id.0, 3);
9857 }
9858 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9859 }
9860 }
9861
9862 #[test]
9863 fn test_api_focus_split() {
9864 let (mut backend, rx) = create_test_backend();
9865
9866 backend
9867 .execute_js(
9868 r#"
9869 const editor = getEditor();
9870 editor.focusSplit(2);
9871 "#,
9872 "test.js",
9873 )
9874 .unwrap();
9875
9876 let cmd = rx.try_recv().unwrap();
9877 match cmd {
9878 PluginCommand::FocusSplit { split_id } => {
9879 assert_eq!(split_id.0, 2);
9880 }
9881 _ => panic!("Expected FocusSplit, got {:?}", cmd),
9882 }
9883 }
9884
9885 #[test]
9889 fn test_api_session_lifecycle_dispatches_commands() {
9890 let (mut backend, rx) = create_test_backend();
9891
9892 backend
9893 .execute_js(
9894 r#"
9895 const editor = getEditor();
9896 editor.createWindow("/tmp/wt-feat", "feat");
9897 editor.setActiveWindow(7);
9898 editor.closeWindow(3);
9899 "#,
9900 "test.js",
9901 )
9902 .unwrap();
9903
9904 let create = rx.try_recv().unwrap();
9905 match create {
9906 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
9907 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
9908 assert_eq!(label, "feat");
9909 }
9910 other => panic!("Expected CreateWindow, got {:?}", other),
9911 }
9912
9913 let activate = rx.try_recv().unwrap();
9914 match activate {
9915 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
9916 assert_eq!(id, fresh_core::WindowId(7));
9917 }
9918 other => panic!("Expected SetActiveWindow, got {:?}", other),
9919 }
9920
9921 let close = rx.try_recv().unwrap();
9922 match close {
9923 fresh_core::api::PluginCommand::CloseWindow { id } => {
9924 assert_eq!(id, fresh_core::WindowId(3));
9925 }
9926 other => panic!("Expected CloseWindow, got {:?}", other),
9927 }
9928 }
9929
9930 #[test]
9934 fn test_api_list_sessions_reads_snapshot() {
9935 let (tx, _rx) = mpsc::channel();
9936 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9937
9938 {
9939 let mut state = state_snapshot.write().unwrap();
9940 state.windows = vec![
9941 fresh_core::api::WindowInfo {
9942 id: fresh_core::WindowId(1),
9943 label: "main".into(),
9944 root: std::path::PathBuf::from("/repo"),
9945 project_path: std::path::PathBuf::from("/repo"),
9946 shared_worktree: false,
9947 },
9948 fresh_core::api::WindowInfo {
9949 id: fresh_core::WindowId(2),
9950 label: "feat-auth".into(),
9951 root: std::path::PathBuf::from("/wt/feat-auth"),
9952 project_path: std::path::PathBuf::from("/wt/feat-auth"),
9953 shared_worktree: false,
9954 },
9955 ];
9956 state.active_window_id = fresh_core::WindowId(2);
9957 }
9958
9959 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9960 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9961
9962 backend
9963 .execute_js(
9964 r#"
9965 const editor = getEditor();
9966 const list = editor.listWindows();
9967 globalThis._sessionCount = list.length;
9968 globalThis._secondLabel = list[1].label;
9969 globalThis._secondRoot = list[1].root;
9970 globalThis._activeId = editor.activeWindow();
9971 "#,
9972 "test.js",
9973 )
9974 .unwrap();
9975
9976 backend
9977 .plugin_contexts
9978 .borrow()
9979 .get("test")
9980 .unwrap()
9981 .clone()
9982 .with(|ctx| {
9983 let global = ctx.globals();
9984 let count: u32 = global.get("_sessionCount").unwrap();
9985 let label: String = global.get("_secondLabel").unwrap();
9986 let root: String = global.get("_secondRoot").unwrap();
9987 let active: u32 = global.get("_activeId").unwrap();
9988 assert_eq!(count, 2);
9989 assert_eq!(label, "feat-auth");
9990 assert_eq!(root, "/wt/feat-auth");
9991 assert_eq!(active, 2);
9992 });
9993 }
9994
9995 #[test]
9996 fn test_api_list_buffers() {
9997 let (tx, _rx) = mpsc::channel();
9998 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9999
10000 {
10002 let mut state = state_snapshot.write().unwrap();
10003 state.buffers.insert(
10004 BufferId(0),
10005 BufferInfo {
10006 id: BufferId(0),
10007 path: Some(PathBuf::from("/test1.txt")),
10008 modified: false,
10009 length: 100,
10010 is_virtual: false,
10011 editing_disabled: false,
10012 view_mode: "source".to_string(),
10013 is_composing_in_any_split: false,
10014 compose_width: None,
10015 language: "text".to_string(),
10016 is_preview: false,
10017 splits: Vec::new(),
10018 },
10019 );
10020 state.buffers.insert(
10021 BufferId(1),
10022 BufferInfo {
10023 id: BufferId(1),
10024 path: Some(PathBuf::from("/test2.txt")),
10025 modified: true,
10026 length: 200,
10027 is_virtual: false,
10028 editing_disabled: false,
10029 view_mode: "source".to_string(),
10030 is_composing_in_any_split: false,
10031 compose_width: None,
10032 language: "text".to_string(),
10033 is_preview: false,
10034 splits: Vec::new(),
10035 },
10036 );
10037 }
10038
10039 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10040 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10041
10042 backend
10043 .execute_js(
10044 r#"
10045 const editor = getEditor();
10046 const buffers = editor.listBuffers();
10047 globalThis._isArray = Array.isArray(buffers);
10048 globalThis._length = buffers.length;
10049 "#,
10050 "test.js",
10051 )
10052 .unwrap();
10053
10054 backend
10055 .plugin_contexts
10056 .borrow()
10057 .get("test")
10058 .unwrap()
10059 .clone()
10060 .with(|ctx| {
10061 let global = ctx.globals();
10062 let is_array: bool = global.get("_isArray").unwrap();
10063 let length: u32 = global.get("_length").unwrap();
10064 assert!(is_array);
10065 assert_eq!(length, 2);
10066 });
10067 }
10068
10069 #[test]
10072 fn test_api_start_prompt() {
10073 let (mut backend, rx) = create_test_backend();
10074
10075 backend
10076 .execute_js(
10077 r#"
10078 const editor = getEditor();
10079 editor.startPrompt("Enter value:", "test-prompt");
10080 "#,
10081 "test.js",
10082 )
10083 .unwrap();
10084
10085 let cmd = rx.try_recv().unwrap();
10086 match cmd {
10087 PluginCommand::StartPrompt {
10088 label,
10089 prompt_type,
10090 floating_overlay,
10091 } => {
10092 assert_eq!(label, "Enter value:");
10093 assert_eq!(prompt_type, "test-prompt");
10094 assert!(!floating_overlay);
10095 }
10096 _ => panic!("Expected StartPrompt, got {:?}", cmd),
10097 }
10098 }
10099
10100 #[test]
10101 fn test_api_start_prompt_with_initial() {
10102 let (mut backend, rx) = create_test_backend();
10103
10104 backend
10105 .execute_js(
10106 r#"
10107 const editor = getEditor();
10108 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
10109 "#,
10110 "test.js",
10111 )
10112 .unwrap();
10113
10114 let cmd = rx.try_recv().unwrap();
10115 match cmd {
10116 PluginCommand::StartPromptWithInitial {
10117 label,
10118 prompt_type,
10119 initial_value,
10120 floating_overlay,
10121 } => {
10122 assert_eq!(label, "Enter value:");
10123 assert_eq!(prompt_type, "test-prompt");
10124 assert_eq!(initial_value, "default");
10125 assert!(!floating_overlay);
10126 }
10127 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
10128 }
10129 }
10130
10131 #[test]
10132 fn test_api_set_prompt_suggestions() {
10133 let (mut backend, rx) = create_test_backend();
10134
10135 backend
10136 .execute_js(
10137 r#"
10138 const editor = getEditor();
10139 editor.setPromptSuggestions([
10140 { text: "Option 1", value: "opt1" },
10141 { text: "Option 2", value: "opt2" }
10142 ]);
10143 "#,
10144 "test.js",
10145 )
10146 .unwrap();
10147
10148 let cmd = rx.try_recv().unwrap();
10149 match cmd {
10150 PluginCommand::SetPromptSuggestions { suggestions, .. } => {
10151 assert_eq!(suggestions.len(), 2);
10152 assert_eq!(suggestions[0].text, "Option 1");
10153 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
10154 }
10155 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
10156 }
10157 }
10158
10159 #[test]
10162 fn test_api_get_active_buffer_id() {
10163 let (tx, _rx) = mpsc::channel();
10164 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10165
10166 {
10167 let mut state = state_snapshot.write().unwrap();
10168 state.active_buffer_id = BufferId(42);
10169 }
10170
10171 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10172 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10173
10174 backend
10175 .execute_js(
10176 r#"
10177 const editor = getEditor();
10178 globalThis._activeId = editor.getActiveBufferId();
10179 "#,
10180 "test.js",
10181 )
10182 .unwrap();
10183
10184 backend
10185 .plugin_contexts
10186 .borrow()
10187 .get("test")
10188 .unwrap()
10189 .clone()
10190 .with(|ctx| {
10191 let global = ctx.globals();
10192 let result: u32 = global.get("_activeId").unwrap();
10193 assert_eq!(result, 42);
10194 });
10195 }
10196
10197 #[test]
10198 fn test_api_get_active_split_id() {
10199 let (tx, _rx) = mpsc::channel();
10200 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10201
10202 {
10203 let mut state = state_snapshot.write().unwrap();
10204 state.active_split_id = 7;
10205 }
10206
10207 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10208 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10209
10210 backend
10211 .execute_js(
10212 r#"
10213 const editor = getEditor();
10214 globalThis._splitId = editor.getActiveSplitId();
10215 "#,
10216 "test.js",
10217 )
10218 .unwrap();
10219
10220 backend
10221 .plugin_contexts
10222 .borrow()
10223 .get("test")
10224 .unwrap()
10225 .clone()
10226 .with(|ctx| {
10227 let global = ctx.globals();
10228 let result: u32 = global.get("_splitId").unwrap();
10229 assert_eq!(result, 7);
10230 });
10231 }
10232
10233 #[test]
10236 fn test_api_file_exists() {
10237 let (mut backend, _rx) = create_test_backend();
10238
10239 backend
10240 .execute_js(
10241 r#"
10242 const editor = getEditor();
10243 // Test with a path that definitely exists
10244 globalThis._exists = editor.fileExists("/");
10245 "#,
10246 "test.js",
10247 )
10248 .unwrap();
10249
10250 backend
10251 .plugin_contexts
10252 .borrow()
10253 .get("test")
10254 .unwrap()
10255 .clone()
10256 .with(|ctx| {
10257 let global = ctx.globals();
10258 let result: bool = global.get("_exists").unwrap();
10259 assert!(result);
10260 });
10261 }
10262
10263 #[test]
10264 fn test_api_parse_jsonc() {
10265 let (mut backend, _rx) = create_test_backend();
10266
10267 backend
10268 .execute_js(
10269 r#"
10270 const editor = getEditor();
10271 // Comments, trailing commas, and nested structures should all parse.
10272 const parsed = editor.parseJsonc(`{
10273 // name of the container
10274 "name": "test",
10275 "features": {
10276 "docker-in-docker": {},
10277 },
10278 /* forwarded port list */
10279 "forwardPorts": [3000, 8080,],
10280 }`);
10281 globalThis._name = parsed.name;
10282 globalThis._featureCount = Object.keys(parsed.features).length;
10283 globalThis._portCount = parsed.forwardPorts.length;
10284
10285 // Invalid JSONC should throw.
10286 try {
10287 editor.parseJsonc("{ broken");
10288 globalThis._threw = false;
10289 } catch (_e) {
10290 globalThis._threw = true;
10291 }
10292 "#,
10293 "test.js",
10294 )
10295 .unwrap();
10296
10297 backend
10298 .plugin_contexts
10299 .borrow()
10300 .get("test")
10301 .unwrap()
10302 .clone()
10303 .with(|ctx| {
10304 let global = ctx.globals();
10305 let name: String = global.get("_name").unwrap();
10306 let feature_count: u32 = global.get("_featureCount").unwrap();
10307 let port_count: u32 = global.get("_portCount").unwrap();
10308 let threw: bool = global.get("_threw").unwrap();
10309 assert_eq!(name, "test");
10310 assert_eq!(feature_count, 1);
10311 assert_eq!(port_count, 2);
10312 assert!(threw, "Invalid JSONC should throw");
10313 });
10314 }
10315
10316 #[test]
10317 fn test_api_get_cwd() {
10318 let (mut backend, _rx) = create_test_backend();
10319
10320 backend
10321 .execute_js(
10322 r#"
10323 const editor = getEditor();
10324 globalThis._cwd = editor.getCwd();
10325 "#,
10326 "test.js",
10327 )
10328 .unwrap();
10329
10330 backend
10331 .plugin_contexts
10332 .borrow()
10333 .get("test")
10334 .unwrap()
10335 .clone()
10336 .with(|ctx| {
10337 let global = ctx.globals();
10338 let result: String = global.get("_cwd").unwrap();
10339 assert!(!result.is_empty());
10341 });
10342 }
10343
10344 #[test]
10345 fn test_api_get_env() {
10346 let (mut backend, _rx) = create_test_backend();
10347
10348 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
10350
10351 backend
10352 .execute_js(
10353 r#"
10354 const editor = getEditor();
10355 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
10356 "#,
10357 "test.js",
10358 )
10359 .unwrap();
10360
10361 backend
10362 .plugin_contexts
10363 .borrow()
10364 .get("test")
10365 .unwrap()
10366 .clone()
10367 .with(|ctx| {
10368 let global = ctx.globals();
10369 let result: Option<String> = global.get("_envVal").unwrap();
10370 assert_eq!(result, Some("test_value".to_string()));
10371 });
10372
10373 std::env::remove_var("TEST_PLUGIN_VAR");
10374 }
10375
10376 #[test]
10377 fn test_api_get_config() {
10378 let (mut backend, _rx) = create_test_backend();
10379
10380 backend
10381 .execute_js(
10382 r#"
10383 const editor = getEditor();
10384 const config = editor.getConfig();
10385 globalThis._isObject = typeof config === 'object';
10386 "#,
10387 "test.js",
10388 )
10389 .unwrap();
10390
10391 backend
10392 .plugin_contexts
10393 .borrow()
10394 .get("test")
10395 .unwrap()
10396 .clone()
10397 .with(|ctx| {
10398 let global = ctx.globals();
10399 let is_object: bool = global.get("_isObject").unwrap();
10400 assert!(is_object);
10402 });
10403 }
10404
10405 #[test]
10406 fn test_api_get_themes_dir() {
10407 let (mut backend, _rx) = create_test_backend();
10408
10409 backend
10410 .execute_js(
10411 r#"
10412 const editor = getEditor();
10413 globalThis._themesDir = editor.getThemesDir();
10414 "#,
10415 "test.js",
10416 )
10417 .unwrap();
10418
10419 backend
10420 .plugin_contexts
10421 .borrow()
10422 .get("test")
10423 .unwrap()
10424 .clone()
10425 .with(|ctx| {
10426 let global = ctx.globals();
10427 let result: String = global.get("_themesDir").unwrap();
10428 assert!(!result.is_empty());
10430 });
10431 }
10432
10433 #[test]
10436 fn test_api_read_dir() {
10437 let (mut backend, _rx) = create_test_backend();
10438
10439 backend
10440 .execute_js(
10441 r#"
10442 const editor = getEditor();
10443 const entries = editor.readDir("/tmp");
10444 globalThis._isArray = Array.isArray(entries);
10445 globalThis._length = entries.length;
10446 "#,
10447 "test.js",
10448 )
10449 .unwrap();
10450
10451 backend
10452 .plugin_contexts
10453 .borrow()
10454 .get("test")
10455 .unwrap()
10456 .clone()
10457 .with(|ctx| {
10458 let global = ctx.globals();
10459 let is_array: bool = global.get("_isArray").unwrap();
10460 let length: u32 = global.get("_length").unwrap();
10461 assert!(is_array);
10463 let _ = length;
10465 });
10466 }
10467
10468 #[test]
10471 fn test_api_execute_action() {
10472 let (mut backend, rx) = create_test_backend();
10473
10474 backend
10475 .execute_js(
10476 r#"
10477 const editor = getEditor();
10478 editor.executeAction("move_cursor_up");
10479 "#,
10480 "test.js",
10481 )
10482 .unwrap();
10483
10484 let cmd = rx.try_recv().unwrap();
10485 match cmd {
10486 PluginCommand::ExecuteAction { action_name } => {
10487 assert_eq!(action_name, "move_cursor_up");
10488 }
10489 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
10490 }
10491 }
10492
10493 #[test]
10496 fn test_api_debug() {
10497 let (mut backend, _rx) = create_test_backend();
10498
10499 backend
10501 .execute_js(
10502 r#"
10503 const editor = getEditor();
10504 editor.debug("Test debug message");
10505 editor.debug("Another message with special chars: <>&\"'");
10506 "#,
10507 "test.js",
10508 )
10509 .unwrap();
10510 }
10512
10513 #[test]
10516 fn test_typescript_preamble_generated() {
10517 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
10519 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
10520 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
10521 println!(
10522 "Generated {} bytes of TypeScript preamble",
10523 JSEDITORAPI_TS_PREAMBLE.len()
10524 );
10525 }
10526
10527 #[test]
10528 fn test_typescript_editor_api_generated() {
10529 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
10531 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
10532 println!(
10533 "Generated {} bytes of EditorAPI interface",
10534 JSEDITORAPI_TS_EDITOR_API.len()
10535 );
10536 }
10537
10538 #[test]
10539 fn test_js_methods_list() {
10540 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
10542 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
10543 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
10545 if i < 20 {
10546 println!(" - {}", method);
10547 }
10548 }
10549 if JSEDITORAPI_JS_METHODS.len() > 20 {
10550 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
10551 }
10552 }
10553
10554 #[test]
10557 fn test_api_load_plugin_sends_command() {
10558 let (mut backend, rx) = create_test_backend();
10559
10560 backend
10562 .execute_js(
10563 r#"
10564 const editor = getEditor();
10565 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
10566 "#,
10567 "test.js",
10568 )
10569 .unwrap();
10570
10571 let cmd = rx.try_recv().unwrap();
10573 match cmd {
10574 PluginCommand::LoadPlugin { path, callback_id } => {
10575 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
10576 assert!(callback_id.0 > 0); }
10578 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
10579 }
10580 }
10581
10582 #[test]
10583 fn test_api_unload_plugin_sends_command() {
10584 let (mut backend, rx) = create_test_backend();
10585
10586 backend
10588 .execute_js(
10589 r#"
10590 const editor = getEditor();
10591 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
10592 "#,
10593 "test.js",
10594 )
10595 .unwrap();
10596
10597 let cmd = rx.try_recv().unwrap();
10599 match cmd {
10600 PluginCommand::UnloadPlugin { name, callback_id } => {
10601 assert_eq!(name, "my-plugin");
10602 assert!(callback_id.0 > 0); }
10604 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
10605 }
10606 }
10607
10608 #[test]
10609 fn test_api_reload_plugin_sends_command() {
10610 let (mut backend, rx) = create_test_backend();
10611
10612 backend
10614 .execute_js(
10615 r#"
10616 const editor = getEditor();
10617 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
10618 "#,
10619 "test.js",
10620 )
10621 .unwrap();
10622
10623 let cmd = rx.try_recv().unwrap();
10625 match cmd {
10626 PluginCommand::ReloadPlugin { name, callback_id } => {
10627 assert_eq!(name, "my-plugin");
10628 assert!(callback_id.0 > 0); }
10630 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
10631 }
10632 }
10633
10634 #[test]
10635 fn test_api_load_plugin_resolves_callback() {
10636 let (mut backend, rx) = create_test_backend();
10637
10638 backend
10640 .execute_js(
10641 r#"
10642 const editor = getEditor();
10643 globalThis._loadResult = null;
10644 editor.loadPlugin("/path/to/plugin.ts").then(result => {
10645 globalThis._loadResult = result;
10646 });
10647 "#,
10648 "test.js",
10649 )
10650 .unwrap();
10651
10652 let callback_id = match rx.try_recv().unwrap() {
10654 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
10655 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
10656 };
10657
10658 backend.resolve_callback(callback_id, "true");
10660
10661 backend
10663 .plugin_contexts
10664 .borrow()
10665 .get("test")
10666 .unwrap()
10667 .clone()
10668 .with(|ctx| {
10669 run_pending_jobs_checked(&ctx, "test async loadPlugin");
10670 });
10671
10672 backend
10674 .plugin_contexts
10675 .borrow()
10676 .get("test")
10677 .unwrap()
10678 .clone()
10679 .with(|ctx| {
10680 let global = ctx.globals();
10681 let result: bool = global.get("_loadResult").unwrap();
10682 assert!(result);
10683 });
10684 }
10685
10686 #[test]
10687 fn test_api_version() {
10688 let (mut backend, _rx) = create_test_backend();
10689
10690 backend
10691 .execute_js(
10692 r#"
10693 const editor = getEditor();
10694 globalThis._apiVersion = editor.apiVersion();
10695 "#,
10696 "test.js",
10697 )
10698 .unwrap();
10699
10700 backend
10701 .plugin_contexts
10702 .borrow()
10703 .get("test")
10704 .unwrap()
10705 .clone()
10706 .with(|ctx| {
10707 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10708 assert_eq!(version, 2);
10709 });
10710 }
10711
10712 #[test]
10713 fn test_api_unload_plugin_rejects_on_error() {
10714 let (mut backend, rx) = create_test_backend();
10715
10716 backend
10718 .execute_js(
10719 r#"
10720 const editor = getEditor();
10721 globalThis._unloadError = null;
10722 editor.unloadPlugin("nonexistent-plugin").catch(err => {
10723 globalThis._unloadError = err.message || String(err);
10724 });
10725 "#,
10726 "test.js",
10727 )
10728 .unwrap();
10729
10730 let callback_id = match rx.try_recv().unwrap() {
10732 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10733 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10734 };
10735
10736 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10738
10739 backend
10741 .plugin_contexts
10742 .borrow()
10743 .get("test")
10744 .unwrap()
10745 .clone()
10746 .with(|ctx| {
10747 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10748 });
10749
10750 backend
10752 .plugin_contexts
10753 .borrow()
10754 .get("test")
10755 .unwrap()
10756 .clone()
10757 .with(|ctx| {
10758 let global = ctx.globals();
10759 let error: String = global.get("_unloadError").unwrap();
10760 assert!(error.contains("nonexistent-plugin"));
10761 });
10762 }
10763
10764 #[test]
10765 fn test_api_set_global_state() {
10766 let (mut backend, rx) = create_test_backend();
10767
10768 backend
10769 .execute_js(
10770 r#"
10771 const editor = getEditor();
10772 editor.setGlobalState("myKey", { enabled: true, count: 42 });
10773 "#,
10774 "test_plugin.js",
10775 )
10776 .unwrap();
10777
10778 let cmd = rx.try_recv().unwrap();
10779 match cmd {
10780 PluginCommand::SetGlobalState {
10781 plugin_name,
10782 key,
10783 value,
10784 } => {
10785 assert_eq!(plugin_name, "test_plugin");
10786 assert_eq!(key, "myKey");
10787 let v = value.unwrap();
10788 assert_eq!(v["enabled"], serde_json::json!(true));
10789 assert_eq!(v["count"], serde_json::json!(42));
10790 }
10791 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10792 }
10793 }
10794
10795 #[test]
10796 fn test_api_set_global_state_delete() {
10797 let (mut backend, rx) = create_test_backend();
10798
10799 backend
10800 .execute_js(
10801 r#"
10802 const editor = getEditor();
10803 editor.setGlobalState("myKey", null);
10804 "#,
10805 "test_plugin.js",
10806 )
10807 .unwrap();
10808
10809 let cmd = rx.try_recv().unwrap();
10810 match cmd {
10811 PluginCommand::SetGlobalState {
10812 plugin_name,
10813 key,
10814 value,
10815 } => {
10816 assert_eq!(plugin_name, "test_plugin");
10817 assert_eq!(key, "myKey");
10818 assert!(value.is_none(), "null should delete the key");
10819 }
10820 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10821 }
10822 }
10823
10824 #[test]
10825 fn test_api_get_global_state_roundtrip() {
10826 let (mut backend, _rx) = create_test_backend();
10827
10828 backend
10830 .execute_js(
10831 r#"
10832 const editor = getEditor();
10833 editor.setGlobalState("flag", true);
10834 globalThis._result = editor.getGlobalState("flag");
10835 "#,
10836 "test_plugin.js",
10837 )
10838 .unwrap();
10839
10840 backend
10841 .plugin_contexts
10842 .borrow()
10843 .get("test_plugin")
10844 .unwrap()
10845 .clone()
10846 .with(|ctx| {
10847 let global = ctx.globals();
10848 let result: bool = global.get("_result").unwrap();
10849 assert!(
10850 result,
10851 "getGlobalState should return the value set by setGlobalState"
10852 );
10853 });
10854 }
10855
10856 #[test]
10861 fn test_api_set_session_state_roundtrip() {
10862 let (mut backend, _rx) = create_test_backend();
10863
10864 backend
10865 .execute_js(
10866 r#"
10867 const editor = getEditor();
10868 editor.setWindowState("draft", { count: 7 });
10869 globalThis._result = editor.getWindowState("draft");
10870 globalThis._missing = editor.getWindowState("absent");
10871 "#,
10872 "test_plugin.js",
10873 )
10874 .unwrap();
10875
10876 backend
10877 .plugin_contexts
10878 .borrow()
10879 .get("test_plugin")
10880 .unwrap()
10881 .clone()
10882 .with(|ctx| {
10883 let global = ctx.globals();
10884 let count: i64 = global
10885 .get::<_, rquickjs::Object>("_result")
10886 .unwrap()
10887 .get("count")
10888 .unwrap();
10889 assert_eq!(
10890 count, 7,
10891 "getWindowState should return the value set by setWindowState"
10892 );
10893 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
10894 assert!(
10895 missing.is_undefined(),
10896 "getWindowState for an unset key must be undefined"
10897 );
10898 });
10899 }
10900
10901 #[test]
10902 fn test_api_get_global_state_missing_key() {
10903 let (mut backend, _rx) = create_test_backend();
10904
10905 backend
10906 .execute_js(
10907 r#"
10908 const editor = getEditor();
10909 globalThis._result = editor.getGlobalState("nonexistent");
10910 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
10911 "#,
10912 "test_plugin.js",
10913 )
10914 .unwrap();
10915
10916 backend
10917 .plugin_contexts
10918 .borrow()
10919 .get("test_plugin")
10920 .unwrap()
10921 .clone()
10922 .with(|ctx| {
10923 let global = ctx.globals();
10924 let is_undefined: bool = global.get("_isUndefined").unwrap();
10925 assert!(
10926 is_undefined,
10927 "getGlobalState for missing key should return undefined"
10928 );
10929 });
10930 }
10931
10932 #[test]
10933 fn test_api_global_state_isolation_between_plugins() {
10934 let (tx, _rx) = mpsc::channel();
10936 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10937 let services = Arc::new(TestServiceBridge::new());
10938
10939 let mut backend_a =
10941 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10942 .unwrap();
10943 backend_a
10944 .execute_js(
10945 r#"
10946 const editor = getEditor();
10947 editor.setGlobalState("flag", "from_plugin_a");
10948 "#,
10949 "plugin_a.js",
10950 )
10951 .unwrap();
10952
10953 let mut backend_b =
10955 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10956 .unwrap();
10957 backend_b
10958 .execute_js(
10959 r#"
10960 const editor = getEditor();
10961 editor.setGlobalState("flag", "from_plugin_b");
10962 "#,
10963 "plugin_b.js",
10964 )
10965 .unwrap();
10966
10967 backend_a
10969 .execute_js(
10970 r#"
10971 const editor = getEditor();
10972 globalThis._aValue = editor.getGlobalState("flag");
10973 "#,
10974 "plugin_a.js",
10975 )
10976 .unwrap();
10977
10978 backend_a
10979 .plugin_contexts
10980 .borrow()
10981 .get("plugin_a")
10982 .unwrap()
10983 .clone()
10984 .with(|ctx| {
10985 let global = ctx.globals();
10986 let a_value: String = global.get("_aValue").unwrap();
10987 assert_eq!(
10988 a_value, "from_plugin_a",
10989 "Plugin A should see its own value, not plugin B's"
10990 );
10991 });
10992
10993 backend_b
10995 .execute_js(
10996 r#"
10997 const editor = getEditor();
10998 globalThis._bValue = editor.getGlobalState("flag");
10999 "#,
11000 "plugin_b.js",
11001 )
11002 .unwrap();
11003
11004 backend_b
11005 .plugin_contexts
11006 .borrow()
11007 .get("plugin_b")
11008 .unwrap()
11009 .clone()
11010 .with(|ctx| {
11011 let global = ctx.globals();
11012 let b_value: String = global.get("_bValue").unwrap();
11013 assert_eq!(
11014 b_value, "from_plugin_b",
11015 "Plugin B should see its own value, not plugin A's"
11016 );
11017 });
11018 }
11019
11020 #[test]
11021 fn test_register_command_collision_different_plugins() {
11022 let (mut backend, _rx) = create_test_backend();
11023
11024 backend
11026 .execute_js(
11027 r#"
11028 const editor = getEditor();
11029 globalThis.handlerA = function() { };
11030 editor.registerCommand("My Command", "From A", "handlerA", null);
11031 "#,
11032 "plugin_a.js",
11033 )
11034 .unwrap();
11035
11036 let result = backend.execute_js(
11038 r#"
11039 const editor = getEditor();
11040 globalThis.handlerB = function() { };
11041 editor.registerCommand("My Command", "From B", "handlerB", null);
11042 "#,
11043 "plugin_b.js",
11044 );
11045
11046 assert!(
11047 result.is_err(),
11048 "Second plugin registering the same command name should fail"
11049 );
11050 let err_msg = result.unwrap_err().to_string();
11051 assert!(
11052 err_msg.contains("already registered"),
11053 "Error should mention collision: {}",
11054 err_msg
11055 );
11056 }
11057
11058 #[test]
11059 fn test_register_command_same_plugin_allowed() {
11060 let (mut backend, _rx) = create_test_backend();
11061
11062 backend
11064 .execute_js(
11065 r#"
11066 const editor = getEditor();
11067 globalThis.handler1 = function() { };
11068 editor.registerCommand("My Command", "Version 1", "handler1", null);
11069 globalThis.handler2 = function() { };
11070 editor.registerCommand("My Command", "Version 2", "handler2", null);
11071 "#,
11072 "plugin_a.js",
11073 )
11074 .unwrap();
11075 }
11076
11077 #[test]
11078 fn test_register_command_after_unregister() {
11079 let (mut backend, _rx) = create_test_backend();
11080
11081 backend
11083 .execute_js(
11084 r#"
11085 const editor = getEditor();
11086 globalThis.handlerA = function() { };
11087 editor.registerCommand("My Command", "From A", "handlerA", null);
11088 editor.unregisterCommand("My Command");
11089 "#,
11090 "plugin_a.js",
11091 )
11092 .unwrap();
11093
11094 backend
11096 .execute_js(
11097 r#"
11098 const editor = getEditor();
11099 globalThis.handlerB = function() { };
11100 editor.registerCommand("My Command", "From B", "handlerB", null);
11101 "#,
11102 "plugin_b.js",
11103 )
11104 .unwrap();
11105 }
11106
11107 #[test]
11108 fn test_register_command_collision_caught_in_try_catch() {
11109 let (mut backend, _rx) = create_test_backend();
11110
11111 backend
11113 .execute_js(
11114 r#"
11115 const editor = getEditor();
11116 globalThis.handlerA = function() { };
11117 editor.registerCommand("My Command", "From A", "handlerA", null);
11118 "#,
11119 "plugin_a.js",
11120 )
11121 .unwrap();
11122
11123 backend
11125 .execute_js(
11126 r#"
11127 const editor = getEditor();
11128 globalThis.handlerB = function() { };
11129 let caught = false;
11130 try {
11131 editor.registerCommand("My Command", "From B", "handlerB", null);
11132 } catch (e) {
11133 caught = true;
11134 }
11135 if (!caught) throw new Error("Expected collision error");
11136 "#,
11137 "plugin_b.js",
11138 )
11139 .unwrap();
11140 }
11141
11142 #[test]
11143 fn test_register_command_i18n_key_no_collision_across_plugins() {
11144 let (mut backend, _rx) = create_test_backend();
11145
11146 backend
11148 .execute_js(
11149 r#"
11150 const editor = getEditor();
11151 globalThis.handlerA = function() { };
11152 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
11153 "#,
11154 "plugin_a.js",
11155 )
11156 .unwrap();
11157
11158 backend
11161 .execute_js(
11162 r#"
11163 const editor = getEditor();
11164 globalThis.handlerB = function() { };
11165 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
11166 "#,
11167 "plugin_b.js",
11168 )
11169 .unwrap();
11170 }
11171
11172 #[test]
11173 fn test_register_command_non_i18n_still_collides() {
11174 let (mut backend, _rx) = create_test_backend();
11175
11176 backend
11178 .execute_js(
11179 r#"
11180 const editor = getEditor();
11181 globalThis.handlerA = function() { };
11182 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
11183 "#,
11184 "plugin_a.js",
11185 )
11186 .unwrap();
11187
11188 let result = backend.execute_js(
11190 r#"
11191 const editor = getEditor();
11192 globalThis.handlerB = function() { };
11193 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
11194 "#,
11195 "plugin_b.js",
11196 );
11197
11198 assert!(
11199 result.is_err(),
11200 "Non-%-prefixed names should still collide across plugins"
11201 );
11202 }
11203}