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 #[plugin_api(ts_return = "string[]")]
1161 pub fn list_macros(&self) -> Vec<String> {
1162 if let Ok(s) = self.state_snapshot.read() {
1163 s.macros.iter().map(|m| m.register.clone()).collect()
1164 } else {
1165 Vec::new()
1166 }
1167 }
1168
1169 #[plugin_api(ts_return = "ActionSpec[] | null")]
1175 pub fn get_macro<'js>(
1176 &self,
1177 ctx: rquickjs::Ctx<'js>,
1178 register: String,
1179 ) -> rquickjs::Result<Value<'js>> {
1180 let steps = if let Ok(s) = self.state_snapshot.read() {
1181 s.macros
1182 .iter()
1183 .find(|m| m.register == register)
1184 .map(|m| m.steps.clone())
1185 } else {
1186 None
1187 };
1188 match steps {
1189 Some(steps) => rquickjs_serde::to_value(ctx, &steps)
1190 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
1191 None => Ok(Value::new_null(ctx)),
1192 }
1193 }
1194
1195 pub fn define_macro(&self, register: String, steps: Vec<ActionSpec>) -> bool {
1199 self.command_sender
1200 .send(PluginCommand::DefineMacro { register, steps })
1201 .is_ok()
1202 }
1203
1204 pub fn play_macro(&self, register: String) -> bool {
1207 self.command_sender
1208 .send(PluginCommand::PlayMacroByRegister { register })
1209 .is_ok()
1210 }
1211
1212 pub fn debug(&self, msg: String) {
1215 tracing::debug!("Plugin: {}", msg);
1216 }
1217
1218 pub fn info(&self, msg: String) {
1219 tracing::info!("Plugin: {}", msg);
1220 }
1221
1222 pub fn warn(&self, msg: String) {
1223 tracing::warn!("Plugin: {}", msg);
1224 }
1225
1226 pub fn error(&self, msg: String) {
1227 tracing::error!("Plugin: {}", msg);
1228 }
1229
1230 pub fn set_status(&self, msg: String) {
1233 let _ = self
1234 .command_sender
1235 .send(PluginCommand::SetStatus { message: msg });
1236 }
1237
1238 pub fn copy_to_clipboard(&self, text: String) {
1241 let _ = self
1242 .command_sender
1243 .send(PluginCommand::SetClipboard { text });
1244 }
1245
1246 pub fn set_clipboard(&self, text: String) {
1247 let _ = self
1248 .command_sender
1249 .send(PluginCommand::SetClipboard { text });
1250 }
1251
1252 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
1257 if let Some(mode_name) = mode {
1258 let key = format!("{}\0{}", action, mode_name);
1259 if let Ok(snapshot) = self.state_snapshot.read() {
1260 return snapshot.keybinding_labels.get(&key).cloned();
1261 }
1262 }
1263 None
1264 }
1265
1266 pub fn register_command<'js>(
1277 &self,
1278 ctx: rquickjs::Ctx<'js>,
1279 name: String,
1280 description: String,
1281 handler_name: String,
1282 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
1283 rquickjs::Value<'js>,
1284 >,
1285 #[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
1286 options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1287 ) -> rquickjs::Result<bool> {
1288 let plugin_name = self.plugin_name.clone();
1290 let context_str: Option<String> = context.0.and_then(|v| {
1292 if v.is_null() || v.is_undefined() {
1293 None
1294 } else {
1295 v.as_string().and_then(|s| s.to_string().ok())
1296 }
1297 });
1298
1299 tracing::debug!(
1300 "registerCommand: plugin='{}', name='{}', handler='{}'",
1301 plugin_name,
1302 name,
1303 handler_name
1304 );
1305
1306 let tracking_key = if name.starts_with('%') {
1310 format!("{}:{}", plugin_name, name)
1311 } else {
1312 name.clone()
1313 };
1314 {
1315 let names = self.registered_command_names.borrow();
1316 if let Some(existing_plugin) = names.get(&tracking_key) {
1317 if existing_plugin != &plugin_name {
1318 let msg = format!(
1319 "Command '{}' already registered by plugin '{}'",
1320 name, existing_plugin
1321 );
1322 tracing::warn!("registerCommand collision: {}", msg);
1323 return Err(
1324 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1325 );
1326 }
1327 }
1329 }
1330
1331 self.registered_command_names
1333 .borrow_mut()
1334 .insert(tracking_key, plugin_name.clone());
1335
1336 self.registered_actions.borrow_mut().insert(
1338 handler_name.clone(),
1339 PluginHandler {
1340 plugin_name: self.plugin_name.clone(),
1341 handler_name: handler_name.clone(),
1342 },
1343 );
1344
1345 let terminal_bypass: bool = options
1349 .0
1350 .and_then(|v| {
1351 if v.is_null() || v.is_undefined() {
1352 None
1353 } else {
1354 v.into_object()
1355 .and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
1356 }
1357 })
1358 .unwrap_or(false);
1359
1360 let command = Command {
1362 name: name.clone(),
1363 description,
1364 action_name: handler_name,
1365 plugin_name,
1366 custom_contexts: context_str.into_iter().collect(),
1367 terminal_bypass,
1368 };
1369
1370 Ok(self
1371 .command_sender
1372 .send(PluginCommand::RegisterCommand { command })
1373 .is_ok())
1374 }
1375
1376 pub fn unregister_command(&self, name: String) -> bool {
1378 let tracking_key = if name.starts_with('%') {
1381 format!("{}:{}", self.plugin_name, name)
1382 } else {
1383 name.clone()
1384 };
1385 self.registered_command_names
1386 .borrow_mut()
1387 .remove(&tracking_key);
1388 self.command_sender
1389 .send(PluginCommand::UnregisterCommand { name })
1390 .is_ok()
1391 }
1392
1393 pub fn set_context(&self, name: String, active: bool) -> bool {
1395 if active {
1397 self.plugin_tracked_state
1398 .borrow_mut()
1399 .entry(self.plugin_name.clone())
1400 .or_default()
1401 .contexts_set
1402 .push(name.clone());
1403 }
1404 self.command_sender
1405 .send(PluginCommand::SetContext { name, active })
1406 .is_ok()
1407 }
1408
1409 pub fn execute_action(&self, action_name: String) -> bool {
1411 self.command_sender
1412 .send(PluginCommand::ExecuteAction { action_name })
1413 .is_ok()
1414 }
1415
1416 pub fn cancel_prompt(&self) -> bool {
1421 self.command_sender
1422 .send(PluginCommand::CancelPrompt)
1423 .is_ok()
1424 }
1425
1426 pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
1430 let plugin_name = self.plugin_name.clone();
1431 self.command_sender
1432 .send(PluginCommand::RegisterStatusBarElement {
1433 plugin_name,
1434 token_name,
1435 title,
1436 })
1437 .is_ok()
1438 }
1439
1440 pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
1443 let key = format!("{}:{}", self.plugin_name, token_name);
1444 self.command_sender
1445 .send(PluginCommand::SetStatusBarValue {
1446 buffer_id,
1447 key,
1448 value,
1449 })
1450 .is_ok()
1451 }
1452
1453 pub fn t<'js>(
1458 &self,
1459 _ctx: rquickjs::Ctx<'js>,
1460 key: String,
1461 args: rquickjs::function::Rest<Value<'js>>,
1462 ) -> String {
1463 let plugin_name = self.plugin_name.clone();
1465 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1467 if let Some(obj) = first_arg.as_object() {
1468 let mut map = HashMap::new();
1469 for k in obj.keys::<String>().flatten() {
1470 if let Ok(v) = obj.get::<_, String>(&k) {
1471 map.insert(k, v);
1472 }
1473 }
1474 map
1475 } else {
1476 HashMap::new()
1477 }
1478 } else {
1479 HashMap::new()
1480 };
1481 let res = self.services.translate(&plugin_name, &key, &args_map);
1482
1483 tracing::info!(
1484 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1485 key,
1486 plugin_name,
1487 args_map,
1488 res
1489 );
1490 res
1491 }
1492
1493 pub fn get_cursor_position(&self) -> u32 {
1497 self.state_snapshot
1498 .read()
1499 .ok()
1500 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1501 .unwrap_or(0)
1502 }
1503
1504 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1506 if let Ok(s) = self.state_snapshot.read() {
1507 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1508 if let Some(p) = &b.path {
1509 return p.to_string_lossy().to_string();
1510 }
1511 }
1512 }
1513 String::new()
1514 }
1515
1516 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1518 if let Ok(s) = self.state_snapshot.read() {
1519 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1520 return b.length as u32;
1521 }
1522 }
1523 0
1524 }
1525
1526 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1528 if let Ok(s) = self.state_snapshot.read() {
1529 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1530 return b.modified;
1531 }
1532 }
1533 false
1534 }
1535
1536 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1539 self.command_sender
1540 .send(PluginCommand::SaveBufferToPath {
1541 buffer_id: BufferId(buffer_id as usize),
1542 path: std::path::PathBuf::from(path),
1543 })
1544 .is_ok()
1545 }
1546
1547 #[plugin_api(ts_return = "BufferInfo | null")]
1549 pub fn get_buffer_info<'js>(
1550 &self,
1551 ctx: rquickjs::Ctx<'js>,
1552 buffer_id: u32,
1553 ) -> rquickjs::Result<Value<'js>> {
1554 let info = if let Ok(s) = self.state_snapshot.read() {
1555 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1556 } else {
1557 None
1558 };
1559 rquickjs_serde::to_value(ctx, &info)
1560 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1561 }
1562
1563 #[plugin_api(ts_return = "CursorInfo | null")]
1565 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1566 let cursor = if let Ok(s) = self.state_snapshot.read() {
1567 s.primary_cursor.clone()
1568 } else {
1569 None
1570 };
1571 rquickjs_serde::to_value(ctx, &cursor)
1572 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1573 }
1574
1575 #[plugin_api(ts_return = "CursorInfo[]")]
1577 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1578 let cursors = if let Ok(s) = self.state_snapshot.read() {
1579 s.all_cursors.clone()
1580 } else {
1581 Vec::new()
1582 };
1583 rquickjs_serde::to_value(ctx, &cursors)
1584 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1585 }
1586
1587 #[plugin_api(ts_return = "number[]")]
1589 pub fn get_all_cursor_positions<'js>(
1590 &self,
1591 ctx: rquickjs::Ctx<'js>,
1592 ) -> rquickjs::Result<Value<'js>> {
1593 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1594 s.all_cursors.iter().map(|c| c.position as u32).collect()
1595 } else {
1596 Vec::new()
1597 };
1598 rquickjs_serde::to_value(ctx, &positions)
1599 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1600 }
1601
1602 #[plugin_api(ts_return = "ViewportInfo | null")]
1604 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1605 let viewport = if let Ok(s) = self.state_snapshot.read() {
1606 s.viewport.clone()
1607 } else {
1608 None
1609 };
1610 rquickjs_serde::to_value(ctx, &viewport)
1611 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1612 }
1613
1614 #[plugin_api(ts_return = "ScreenSize")]
1619 pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1620 let size = if let Ok(s) = self.state_snapshot.read() {
1621 fresh_core::api::ScreenSize {
1622 width: s.terminal_width,
1623 height: s.terminal_height,
1624 }
1625 } else {
1626 fresh_core::api::ScreenSize {
1627 width: 0,
1628 height: 0,
1629 }
1630 };
1631 rquickjs_serde::to_value(ctx, size)
1632 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1633 }
1634
1635 #[plugin_api(ts_return = "SplitSnapshot[]")]
1642 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1643 let splits = if let Ok(s) = self.state_snapshot.read() {
1644 s.splits.clone()
1645 } else {
1646 Vec::new()
1647 };
1648 rquickjs_serde::to_value(ctx, &splits)
1649 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1650 }
1651
1652 pub fn get_cursor_line(&self) -> u32 {
1660 self.state_snapshot
1661 .read()
1662 .ok()
1663 .and_then(|s| s.primary_cursor.as_ref().and_then(|c| c.line))
1664 .unwrap_or(0) as u32
1665 }
1666
1667 #[plugin_api(
1670 async_promise,
1671 js_name = "getLineStartPosition",
1672 ts_return = "number | null"
1673 )]
1674 #[qjs(rename = "_getLineStartPositionStart")]
1675 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1676 let id = self.alloc_request_id();
1677 let _ = self
1679 .command_sender
1680 .send(PluginCommand::GetLineStartPosition {
1681 buffer_id: BufferId(0),
1682 line,
1683 request_id: id,
1684 });
1685 id
1686 }
1687
1688 #[plugin_api(
1692 async_promise,
1693 js_name = "getLineEndPosition",
1694 ts_return = "number | null"
1695 )]
1696 #[qjs(rename = "_getLineEndPositionStart")]
1697 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1698 let id = self.alloc_request_id();
1699 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1701 buffer_id: BufferId(0),
1702 line,
1703 request_id: id,
1704 });
1705 id
1706 }
1707
1708 #[plugin_api(
1711 async_promise,
1712 js_name = "getBufferLineCount",
1713 ts_return = "number | null"
1714 )]
1715 #[qjs(rename = "_getBufferLineCountStart")]
1716 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1717 let id = self.alloc_request_id();
1718 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1720 buffer_id: BufferId(0),
1721 request_id: id,
1722 });
1723 id
1724 }
1725
1726 #[plugin_api(
1734 async_promise,
1735 js_name = "getCompositeCursorInfo",
1736 ts_return = "{ focusedPane: number, paneCount: number, lines: Array<number | null> } | null"
1737 )]
1738 #[qjs(rename = "_getCompositeCursorInfoStart")]
1739 pub fn get_composite_cursor_info_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1740 let id = self.alloc_request_id();
1741 let _ = self
1742 .command_sender
1743 .send(PluginCommand::GetCompositeCursorInfo { request_id: id });
1744 id
1745 }
1746
1747 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1750 self.command_sender
1751 .send(PluginCommand::ScrollToLineCenter {
1752 split_id: SplitId(split_id as usize),
1753 buffer_id: BufferId(buffer_id as usize),
1754 line: line as usize,
1755 })
1756 .is_ok()
1757 }
1758
1759 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1768 self.command_sender
1769 .send(PluginCommand::ScrollBufferToLine {
1770 buffer_id: BufferId(buffer_id as usize),
1771 line: line as usize,
1772 })
1773 .is_ok()
1774 }
1775
1776 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1778 let path_buf = std::path::PathBuf::from(&path);
1779 if let Ok(s) = self.state_snapshot.read() {
1780 for (id, info) in &s.buffers {
1781 if let Some(buf_path) = &info.path {
1782 if buf_path == &path_buf {
1783 return id.0 as u32;
1784 }
1785 }
1786 }
1787 }
1788 0
1789 }
1790
1791 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1793 pub fn get_buffer_saved_diff<'js>(
1794 &self,
1795 ctx: rquickjs::Ctx<'js>,
1796 buffer_id: u32,
1797 ) -> rquickjs::Result<Value<'js>> {
1798 let diff = if let Ok(s) = self.state_snapshot.read() {
1799 s.buffer_saved_diffs
1800 .get(&BufferId(buffer_id as usize))
1801 .cloned()
1802 } else {
1803 None
1804 };
1805 rquickjs_serde::to_value(ctx, &diff)
1806 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1807 }
1808
1809 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1813 self.command_sender
1814 .send(PluginCommand::InsertText {
1815 buffer_id: BufferId(buffer_id as usize),
1816 position: position as usize,
1817 text,
1818 })
1819 .is_ok()
1820 }
1821
1822 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1824 self.command_sender
1825 .send(PluginCommand::DeleteRange {
1826 buffer_id: BufferId(buffer_id as usize),
1827 range: (start as usize)..(end as usize),
1828 })
1829 .is_ok()
1830 }
1831
1832 pub fn insert_at_cursor(&self, text: String) -> bool {
1834 self.command_sender
1835 .send(PluginCommand::InsertAtCursor { text })
1836 .is_ok()
1837 }
1838
1839 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1843 self.command_sender
1844 .send(PluginCommand::OpenFileAtLocation {
1845 path: PathBuf::from(path),
1846 line: line.map(|l| l as usize),
1847 column: column.map(|c| c as usize),
1848 })
1849 .is_ok()
1850 }
1851
1852 pub fn open_file_in_background(
1860 &self,
1861 path: String,
1862 window_id: rquickjs::function::Opt<u64>,
1863 ) -> bool {
1864 self.command_sender
1865 .send(PluginCommand::OpenFileInBackground {
1866 path: PathBuf::from(path),
1867 window_id: window_id.0.map(fresh_core::WindowId),
1868 })
1869 .is_ok()
1870 }
1871
1872 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1874 self.command_sender
1875 .send(PluginCommand::OpenFileInSplit {
1876 split_id: split_id as usize,
1877 path: PathBuf::from(path),
1878 line: Some(line as usize),
1879 column: Some(column as usize),
1880 })
1881 .is_ok()
1882 }
1883
1884 #[plugin_api(
1893 async_promise,
1894 js_name = "openFileStreaming",
1895 ts_return = "number | null"
1896 )]
1897 #[qjs(rename = "_openFileStreamingStart")]
1898 pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
1899 let id = self.alloc_request_id();
1900 let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
1901 path: PathBuf::from(path),
1902 request_id: id,
1903 });
1904 id
1905 }
1906
1907 #[plugin_api(
1915 async_promise,
1916 js_name = "refreshBufferFromDisk",
1917 ts_return = "number | null"
1918 )]
1919 #[qjs(rename = "_refreshBufferFromDiskStart")]
1920 pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
1921 let id = self.alloc_request_id();
1922 let _ = self
1923 .command_sender
1924 .send(PluginCommand::RefreshBufferFromDisk {
1925 buffer_id: BufferId(buffer_id as usize),
1926 request_id: id,
1927 });
1928 id
1929 }
1930
1931 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1933 self.command_sender
1934 .send(PluginCommand::ShowBuffer {
1935 buffer_id: BufferId(buffer_id as usize),
1936 })
1937 .is_ok()
1938 }
1939
1940 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1942 self.command_sender
1943 .send(PluginCommand::CloseBuffer {
1944 buffer_id: BufferId(buffer_id as usize),
1945 })
1946 .is_ok()
1947 }
1948
1949 pub fn close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1951 self.command_sender
1952 .send(PluginCommand::CloseOtherBuffersInSplit {
1953 buffer_id: BufferId(buffer_id as usize),
1954 split_id: SplitId(split_id as usize),
1955 })
1956 .is_ok()
1957 }
1958
1959 pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
1961 self.command_sender
1962 .send(PluginCommand::CloseAllBuffersInSplit {
1963 split_id: SplitId(split_id as usize),
1964 })
1965 .is_ok()
1966 }
1967
1968 pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1970 self.command_sender
1971 .send(PluginCommand::CloseBuffersToRightInSplit {
1972 buffer_id: BufferId(buffer_id as usize),
1973 split_id: SplitId(split_id as usize),
1974 })
1975 .is_ok()
1976 }
1977
1978 pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1980 self.command_sender
1981 .send(PluginCommand::CloseBuffersToLeftInSplit {
1982 buffer_id: BufferId(buffer_id as usize),
1983 split_id: SplitId(split_id as usize),
1984 })
1985 .is_ok()
1986 }
1987
1988 #[plugin_api(ts_return = "boolean")]
1990 pub fn move_tab_to_left(&self) -> bool {
1991 self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
1992 }
1993
1994 #[plugin_api(ts_return = "boolean")]
1996 pub fn move_tab_to_right(&self) -> bool {
1997 self.command_sender
1998 .send(PluginCommand::MoveTabRight)
1999 .is_ok()
2000 }
2001
2002 #[plugin_api(skip)]
2008 #[qjs(skip)]
2009 fn alloc_request_id(&self) -> u64 {
2010 let mut id_ref = self.next_request_id.borrow_mut();
2011 let id = *id_ref;
2012 *id_ref += 1;
2013 self.callback_contexts
2014 .borrow_mut()
2015 .insert(id, self.plugin_name.clone());
2016 id
2017 }
2018
2019 #[plugin_api(skip)]
2023 #[qjs(skip)]
2024 fn alloc_animation_id(&self) -> u64 {
2025 let mut id_ref = self.next_request_id.borrow_mut();
2026 let id = *id_ref;
2027 *id_ref += 1;
2028 id
2029 }
2030
2031 pub fn animate_area<'js>(
2034 &self,
2035 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
2036 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
2037 ) -> rquickjs::Result<u64> {
2038 let rect = parse_animation_rect(&rect)?;
2039 let kind = parse_animation_kind(&kind)?;
2040 let id = self.alloc_animation_id();
2041 let _ = self
2042 .command_sender
2043 .send(PluginCommand::StartAnimationArea { id, rect, kind });
2044 Ok(id)
2045 }
2046
2047 pub fn animate_virtual_buffer<'js>(
2050 &self,
2051 buffer_id: u32,
2052 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
2053 ) -> rquickjs::Result<u64> {
2054 let kind = parse_animation_kind(&kind)?;
2055 let id = self.alloc_animation_id();
2056 let _ = self
2057 .command_sender
2058 .send(PluginCommand::StartAnimationVirtualBuffer {
2059 id,
2060 buffer_id: BufferId(buffer_id as usize),
2061 kind,
2062 });
2063 Ok(id)
2064 }
2065
2066 pub fn cancel_animation(&self, id: u64) -> bool {
2069 self.command_sender
2070 .send(PluginCommand::CancelAnimation { id })
2071 .is_ok()
2072 }
2073
2074 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
2078 if event_name == "lines_changed" {
2082 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
2083 }
2084 self.event_handlers
2085 .write()
2086 .expect("event_handlers poisoned")
2087 .entry(event_name)
2088 .or_default()
2089 .push(PluginHandler {
2090 plugin_name: self.plugin_name.clone(),
2091 handler_name,
2092 });
2093 }
2094
2095 pub fn off(&self, event_name: String, handler_name: String) {
2097 if let Some(list) = self
2098 .event_handlers
2099 .write()
2100 .expect("event_handlers poisoned")
2101 .get_mut(&event_name)
2102 {
2103 list.retain(|h| h.handler_name != handler_name);
2104 }
2105 }
2106
2107 pub fn get_env(&self, name: String) -> Option<String> {
2111 std::env::var(&name).ok()
2112 }
2113
2114 pub fn get_cwd(&self) -> String {
2116 self.state_snapshot
2117 .read()
2118 .map(|s| s.working_dir.to_string_lossy().to_string())
2119 .unwrap_or_else(|_| ".".to_string())
2120 }
2121
2122 pub fn get_authority_label(&self) -> String {
2131 self.state_snapshot
2132 .read()
2133 .map(|s| s.authority_label.clone())
2134 .unwrap_or_default()
2135 }
2136
2137 pub fn workspace_trust_level(&self) -> String {
2142 self.state_snapshot
2143 .read()
2144 .map(|s| s.workspace_trust_level.clone())
2145 .unwrap_or_default()
2146 }
2147
2148 pub fn env_active(&self) -> bool {
2153 self.state_snapshot
2154 .read()
2155 .map(|s| s.env_active)
2156 .unwrap_or(false)
2157 }
2158
2159 pub fn detected_env(&self) -> String {
2164 self.state_snapshot
2165 .read()
2166 .map(|s| s.detected_env.clone())
2167 .unwrap_or_default()
2168 }
2169
2170 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2182 let mut result_parts: Vec<String> = Vec::new();
2183 let mut leading_slashes: u8 = 0;
2185
2186 for part in &parts.0 {
2187 let normalized = part.replace('\\', "/");
2189
2190 let is_absolute = normalized.starts_with('/')
2192 || (normalized.len() >= 2
2193 && normalized
2194 .chars()
2195 .next()
2196 .map(|c| c.is_ascii_alphabetic())
2197 .unwrap_or(false)
2198 && normalized.chars().nth(1) == Some(':'));
2199
2200 if is_absolute {
2201 result_parts.clear();
2203 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2207 }
2208
2209 for segment in normalized.split('/') {
2211 if !segment.is_empty() && segment != "." {
2212 if segment == ".." {
2213 result_parts.pop();
2214 } else {
2215 result_parts.push(segment.to_string());
2216 }
2217 }
2218 }
2219 }
2220
2221 let joined = result_parts.join("/");
2223 let prefix = match leading_slashes {
2224 0 => "",
2225 1 => "/",
2226 _ => "//",
2227 };
2228
2229 if leading_slashes > 0 {
2230 format!("{}{}", prefix, joined)
2231 } else {
2232 joined
2233 }
2234 }
2235
2236 pub fn path_dirname(&self, path: String) -> String {
2238 Path::new(&path)
2239 .parent()
2240 .map(|p| p.to_string_lossy().to_string())
2241 .unwrap_or_default()
2242 }
2243
2244 pub fn path_basename(&self, path: String) -> String {
2246 Path::new(&path)
2247 .file_name()
2248 .map(|s| s.to_string_lossy().to_string())
2249 .unwrap_or_default()
2250 }
2251
2252 pub fn path_extname(&self, path: String) -> String {
2254 Path::new(&path)
2255 .extension()
2256 .map(|s| format!(".{}", s.to_string_lossy()))
2257 .unwrap_or_default()
2258 }
2259
2260 pub fn path_is_absolute(&self, path: String) -> bool {
2262 Path::new(&path).is_absolute()
2263 }
2264
2265 pub fn file_uri_to_path(&self, uri: String) -> String {
2269 fresh_core::file_uri::file_uri_to_path(&uri)
2270 .map(|p| p.to_string_lossy().to_string())
2271 .unwrap_or_default()
2272 }
2273
2274 pub fn path_to_file_uri(&self, path: String) -> String {
2278 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2279 }
2280
2281 pub fn utf8_byte_length(&self, text: String) -> u32 {
2289 text.len() as u32
2290 }
2291
2292 pub fn file_exists(&self, path: String) -> bool {
2296 Path::new(&path).exists()
2297 }
2298
2299 pub fn read_file(&self, path: String) -> Option<String> {
2301 std::fs::read_to_string(&path).ok()
2302 }
2303
2304 pub fn write_file(&self, path: String, content: String) -> bool {
2306 let p = Path::new(&path);
2307 if let Some(parent) = p.parent() {
2308 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2309 return false;
2310 }
2311 }
2312 std::fs::write(p, content).is_ok()
2313 }
2314
2315 #[plugin_api(ts_return = "DirEntry[]")]
2317 pub fn read_dir<'js>(
2318 &self,
2319 ctx: rquickjs::Ctx<'js>,
2320 path: String,
2321 ) -> rquickjs::Result<Value<'js>> {
2322 use fresh_core::api::DirEntry;
2323
2324 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2325 Ok(entries) => entries
2326 .filter_map(|e| e.ok())
2327 .map(|entry| {
2328 let file_type = entry.file_type().ok();
2329 DirEntry {
2330 name: entry.file_name().to_string_lossy().to_string(),
2331 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2332 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2333 }
2334 })
2335 .collect(),
2336 Err(e) => {
2337 tracing::warn!("readDir failed for '{}': {}", path, e);
2338 Vec::new()
2339 }
2340 };
2341
2342 rquickjs_serde::to_value(ctx, &entries)
2343 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2344 }
2345
2346 pub fn create_dir(&self, path: String) -> bool {
2349 let p = Path::new(&path);
2350 if p.is_dir() {
2351 return true;
2352 }
2353 std::fs::create_dir_all(p).is_ok()
2354 }
2355
2356 pub fn remove_path(&self, path: String) -> bool {
2360 let target = match Path::new(&path).canonicalize() {
2361 Ok(p) => p,
2362 Err(_) => return false, };
2364
2365 let temp_dir = std::env::temp_dir()
2371 .canonicalize()
2372 .unwrap_or_else(|_| std::env::temp_dir());
2373 let config_dir = self
2374 .services
2375 .config_dir()
2376 .canonicalize()
2377 .unwrap_or_else(|_| self.services.config_dir());
2378
2379 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2381 if !allowed {
2382 tracing::warn!(
2383 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2384 target,
2385 temp_dir,
2386 config_dir
2387 );
2388 return false;
2389 }
2390
2391 if target == temp_dir || target == config_dir {
2393 tracing::warn!(
2394 "removePath refused: cannot remove root directory {:?}",
2395 target
2396 );
2397 return false;
2398 }
2399
2400 match trash::delete(&target) {
2401 Ok(()) => true,
2402 Err(e) => {
2403 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2404 false
2405 }
2406 }
2407 }
2408
2409 pub fn rename_path(&self, from: String, to: String) -> bool {
2412 if std::fs::rename(&from, &to).is_ok() {
2414 return true;
2415 }
2416 let from_path = Path::new(&from);
2418 let copied = if from_path.is_dir() {
2419 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2420 } else {
2421 std::fs::copy(&from, &to).is_ok()
2422 };
2423 if copied {
2424 return trash::delete(from_path).is_ok();
2425 }
2426 false
2427 }
2428
2429 pub fn copy_path(&self, from: String, to: String) -> bool {
2432 let from_path = Path::new(&from);
2433 let to_path = Path::new(&to);
2434 if from_path.is_dir() {
2435 copy_dir_recursive(from_path, to_path).is_ok()
2436 } else {
2437 if let Some(parent) = to_path.parent() {
2439 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2440 return false;
2441 }
2442 }
2443 std::fs::copy(from_path, to_path).is_ok()
2444 }
2445 }
2446
2447 pub fn get_temp_dir(&self) -> String {
2449 std::env::temp_dir().to_string_lossy().to_string()
2450 }
2451
2452 #[plugin_api(ts_return = "unknown")]
2463 pub fn parse_jsonc<'js>(
2464 &self,
2465 ctx: rquickjs::Ctx<'js>,
2466 text: String,
2467 ) -> rquickjs::Result<Value<'js>> {
2468 let value: serde_json::Value =
2469 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2470 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2471 })?;
2472 rquickjs_serde::to_value(ctx, &value)
2473 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2474 }
2475
2476 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2485 let config = self
2486 .state_snapshot
2487 .read()
2488 .map(|s| std::sync::Arc::clone(&s.config))
2489 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2490
2491 rquickjs_serde::to_value(ctx, &*config)
2492 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2493 }
2494
2495 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2497 let config = self
2498 .state_snapshot
2499 .read()
2500 .map(|s| std::sync::Arc::clone(&s.user_config))
2501 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2502
2503 rquickjs_serde::to_value(ctx, &*config)
2504 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2505 }
2506
2507 #[plugin_api(ts_return = "boolean")]
2515 pub fn define_config_boolean<'js>(
2516 &self,
2517 ctx: rquickjs::Ctx<'js>,
2518 name: String,
2519 #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2520 options: rquickjs::Object<'js>,
2521 ) -> rquickjs::Result<bool> {
2522 let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2523 validate_allowed_keys(
2524 &ctx,
2525 "defineConfigBoolean",
2526 &name,
2527 &opts,
2528 &["default", "description"],
2529 )?;
2530 let default = match opts.get("default") {
2531 Some(serde_json::Value::Bool(b)) => *b,
2532 _ => {
2533 return Err(throw_js(
2534 &ctx,
2535 &format!(
2536 "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2537 name
2538 ),
2539 ));
2540 }
2541 };
2542 let description = string_opt(&opts, "description");
2543 let mut field = serde_json::Map::new();
2544 field.insert("type".into(), serde_json::json!("boolean"));
2545 field.insert("default".into(), serde_json::json!(default));
2546 if let Some(d) = description {
2547 field.insert("description".into(), serde_json::json!(d));
2548 }
2549 self.send_field_registration(&name, serde_json::Value::Object(field));
2550 Ok(self
2551 .current_field_value(&name)
2552 .and_then(|v| v.as_bool())
2553 .unwrap_or(default))
2554 }
2555
2556 #[plugin_api(ts_return = "number")]
2559 pub fn define_config_integer<'js>(
2560 &self,
2561 ctx: rquickjs::Ctx<'js>,
2562 name: String,
2563 #[plugin_api(
2564 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2565 )]
2566 options: rquickjs::Object<'js>,
2567 ) -> rquickjs::Result<i64> {
2568 let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2569 validate_allowed_keys(
2570 &ctx,
2571 "defineConfigInteger",
2572 &name,
2573 &opts,
2574 &["default", "description", "minimum", "maximum"],
2575 )?;
2576 let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2577 let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2578 let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2579 check_range(
2580 &ctx,
2581 "defineConfigInteger",
2582 &name,
2583 default as f64,
2584 minimum.map(|v| v as f64),
2585 maximum.map(|v| v as f64),
2586 )?;
2587 let description = string_opt(&opts, "description");
2588 let mut field = serde_json::Map::new();
2589 field.insert("type".into(), serde_json::json!("integer"));
2590 field.insert("default".into(), serde_json::json!(default));
2591 if let Some(d) = description {
2592 field.insert("description".into(), serde_json::json!(d));
2593 }
2594 if let Some(v) = minimum {
2595 field.insert("minimum".into(), serde_json::json!(v));
2596 }
2597 if let Some(v) = maximum {
2598 field.insert("maximum".into(), serde_json::json!(v));
2599 }
2600 self.send_field_registration(&name, serde_json::Value::Object(field));
2601 Ok(self
2602 .current_field_value(&name)
2603 .and_then(|v| v.as_i64())
2604 .unwrap_or(default))
2605 }
2606
2607 #[plugin_api(ts_return = "number")]
2610 pub fn define_config_number<'js>(
2611 &self,
2612 ctx: rquickjs::Ctx<'js>,
2613 name: String,
2614 #[plugin_api(
2615 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2616 )]
2617 options: rquickjs::Object<'js>,
2618 ) -> rquickjs::Result<f64> {
2619 let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2620 validate_allowed_keys(
2621 &ctx,
2622 "defineConfigNumber",
2623 &name,
2624 &opts,
2625 &["default", "description", "minimum", "maximum"],
2626 )?;
2627 let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2628 let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2629 let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2630 check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2631 let description = string_opt(&opts, "description");
2632 let mut field = serde_json::Map::new();
2633 field.insert("type".into(), serde_json::json!("number"));
2634 field.insert("default".into(), serde_json::json!(default));
2635 if let Some(d) = description {
2636 field.insert("description".into(), serde_json::json!(d));
2637 }
2638 if let Some(v) = minimum {
2639 field.insert("minimum".into(), serde_json::json!(v));
2640 }
2641 if let Some(v) = maximum {
2642 field.insert("maximum".into(), serde_json::json!(v));
2643 }
2644 self.send_field_registration(&name, serde_json::Value::Object(field));
2645 Ok(self
2646 .current_field_value(&name)
2647 .and_then(|v| v.as_f64())
2648 .unwrap_or(default))
2649 }
2650
2651 #[plugin_api(ts_return = "string")]
2653 pub fn define_config_string<'js>(
2654 &self,
2655 ctx: rquickjs::Ctx<'js>,
2656 name: String,
2657 #[plugin_api(ts_type = "{ default: string; description?: string }")]
2658 options: rquickjs::Object<'js>,
2659 ) -> rquickjs::Result<String> {
2660 let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2661 validate_allowed_keys(
2662 &ctx,
2663 "defineConfigString",
2664 &name,
2665 &opts,
2666 &["default", "description"],
2667 )?;
2668 let default = match opts.get("default") {
2669 Some(serde_json::Value::String(s)) => s.clone(),
2670 _ => {
2671 return Err(throw_js(
2672 &ctx,
2673 &format!(
2674 "defineConfigString(\"{}\"): `default` (string) is required",
2675 name
2676 ),
2677 ));
2678 }
2679 };
2680 let description = string_opt(&opts, "description");
2681 let mut field = serde_json::Map::new();
2682 field.insert("type".into(), serde_json::json!("string"));
2683 field.insert("default".into(), serde_json::json!(default));
2684 if let Some(d) = description {
2685 field.insert("description".into(), serde_json::json!(d));
2686 }
2687 self.send_field_registration(&name, serde_json::Value::Object(field));
2688 Ok(self
2689 .current_field_value(&name)
2690 .and_then(|v| v.as_str().map(|s| s.to_string()))
2691 .unwrap_or(default))
2692 }
2693
2694 #[plugin_api(skip)]
2701 pub fn define_config_enum<'js>(
2702 &self,
2703 ctx: rquickjs::Ctx<'js>,
2704 name: String,
2705 options: rquickjs::Object<'js>,
2706 ) -> rquickjs::Result<String> {
2707 let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2708 validate_allowed_keys(
2709 &ctx,
2710 "defineConfigEnum",
2711 &name,
2712 &opts,
2713 &["default", "description", "values"],
2714 )?;
2715 let values: Vec<String> = match opts.get("values") {
2716 Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
2717 let mut out = Vec::with_capacity(arr.len());
2718 for v in arr {
2719 match v {
2720 serde_json::Value::String(s) => out.push(s.clone()),
2721 _ => {
2722 return Err(throw_js(
2723 &ctx,
2724 &format!(
2725 "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2726 name
2727 ),
2728 ));
2729 }
2730 }
2731 }
2732 out
2733 }
2734 _ => {
2735 return Err(throw_js(
2736 &ctx,
2737 &format!(
2738 "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2739 name
2740 ),
2741 ));
2742 }
2743 };
2744 let default = match opts.get("default") {
2745 Some(serde_json::Value::String(s)) => s.clone(),
2746 _ => {
2747 return Err(throw_js(
2748 &ctx,
2749 &format!(
2750 "defineConfigEnum(\"{}\"): `default` (string) is required",
2751 name
2752 ),
2753 ));
2754 }
2755 };
2756 if !values.contains(&default) {
2757 return Err(throw_js(
2758 &ctx,
2759 &format!(
2760 "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2761 name, values
2762 ),
2763 ));
2764 }
2765 let description = string_opt(&opts, "description");
2766 let mut field = serde_json::Map::new();
2767 field.insert("type".into(), serde_json::json!("string"));
2768 field.insert("enum".into(), serde_json::json!(values));
2769 field.insert("default".into(), serde_json::json!(default));
2770 if let Some(d) = description {
2771 field.insert("description".into(), serde_json::json!(d));
2772 }
2773 self.send_field_registration(&name, serde_json::Value::Object(field));
2774 let current = self
2775 .current_field_value(&name)
2776 .and_then(|v| v.as_str().map(|s| s.to_string()));
2777 Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2781 }
2782
2783 #[plugin_api(ts_return = "string[]")]
2786 pub fn define_config_string_array<'js>(
2787 &self,
2788 ctx: rquickjs::Ctx<'js>,
2789 name: String,
2790 #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2791 options: rquickjs::Object<'js>,
2792 ) -> rquickjs::Result<Vec<String>> {
2793 let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2794 validate_allowed_keys(
2795 &ctx,
2796 "defineConfigStringArray",
2797 &name,
2798 &opts,
2799 &["default", "description"],
2800 )?;
2801 let default: Vec<String> = match opts.get("default") {
2802 Some(serde_json::Value::Array(arr)) => {
2803 let mut out = Vec::with_capacity(arr.len());
2804 for v in arr {
2805 match v {
2806 serde_json::Value::String(s) => out.push(s.clone()),
2807 _ => {
2808 return Err(throw_js(
2809 &ctx,
2810 &format!(
2811 "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2812 name
2813 ),
2814 ));
2815 }
2816 }
2817 }
2818 out
2819 }
2820 _ => {
2821 return Err(throw_js(
2822 &ctx,
2823 &format!(
2824 "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2825 name
2826 ),
2827 ));
2828 }
2829 };
2830 let description = string_opt(&opts, "description");
2831 let mut field = serde_json::Map::new();
2832 field.insert("type".into(), serde_json::json!("array"));
2833 field.insert("items".into(), serde_json::json!({"type": "string"}));
2834 field.insert("default".into(), serde_json::json!(default));
2835 if let Some(d) = description {
2836 field.insert("description".into(), serde_json::json!(d));
2837 }
2838 self.send_field_registration(&name, serde_json::Value::Object(field));
2839 Ok(self
2840 .current_field_value(&name)
2841 .and_then(|v| {
2842 v.as_array().map(|arr| {
2843 arr.iter()
2844 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2845 .collect::<Vec<_>>()
2846 })
2847 })
2848 .unwrap_or(default))
2849 }
2850
2851 pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2859 let config = self
2860 .state_snapshot
2861 .read()
2862 .map(|s| std::sync::Arc::clone(&s.config))
2863 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2864
2865 let settings = config
2866 .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2867 .cloned()
2868 .unwrap_or(serde_json::Value::Null);
2869
2870 rquickjs_serde::to_value(ctx, &settings)
2871 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2872 }
2873
2874 pub fn reload_config(&self) {
2876 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2877 }
2878
2879 pub fn set_setting<'js>(
2892 &self,
2893 _ctx: rquickjs::Ctx<'js>,
2894 path: String,
2895 value: Value<'js>,
2896 ) -> rquickjs::Result<bool> {
2897 let json: serde_json::Value = rquickjs_serde::from_value(value)
2898 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2899 Ok(self
2900 .command_sender
2901 .send(PluginCommand::SetSetting {
2902 plugin_name: self.plugin_name.clone(),
2903 path,
2904 value: json,
2905 })
2906 .is_ok())
2907 }
2908
2909 pub fn reload_themes(&self) {
2912 let _ = self
2913 .command_sender
2914 .send(PluginCommand::ReloadThemes { apply_theme: None });
2915 }
2916
2917 pub fn reload_and_apply_theme(&self, theme_name: String) {
2919 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2920 apply_theme: Some(theme_name),
2921 });
2922 }
2923
2924 pub fn register_grammar<'js>(
2927 &self,
2928 ctx: rquickjs::Ctx<'js>,
2929 language: String,
2930 grammar_path: String,
2931 extensions: Vec<String>,
2932 ) -> rquickjs::Result<bool> {
2933 {
2935 let langs = self.registered_grammar_languages.borrow();
2936 if let Some(existing_plugin) = langs.get(&language) {
2937 if existing_plugin != &self.plugin_name {
2938 let msg = format!(
2939 "Grammar for language '{}' already registered by plugin '{}'",
2940 language, existing_plugin
2941 );
2942 tracing::warn!("registerGrammar collision: {}", msg);
2943 return Err(
2944 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2945 );
2946 }
2947 }
2948 }
2949 self.registered_grammar_languages
2950 .borrow_mut()
2951 .insert(language.clone(), self.plugin_name.clone());
2952
2953 Ok(self
2954 .command_sender
2955 .send(PluginCommand::RegisterGrammar {
2956 language,
2957 grammar_path,
2958 extensions,
2959 })
2960 .is_ok())
2961 }
2962
2963 pub fn register_language_config<'js>(
2965 &self,
2966 ctx: rquickjs::Ctx<'js>,
2967 language: String,
2968 config: LanguagePackConfig,
2969 ) -> rquickjs::Result<bool> {
2970 {
2972 let langs = self.registered_language_configs.borrow();
2973 if let Some(existing_plugin) = langs.get(&language) {
2974 if existing_plugin != &self.plugin_name {
2975 let msg = format!(
2976 "Language config for '{}' already registered by plugin '{}'",
2977 language, existing_plugin
2978 );
2979 tracing::warn!("registerLanguageConfig collision: {}", msg);
2980 return Err(
2981 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2982 );
2983 }
2984 }
2985 }
2986 self.registered_language_configs
2987 .borrow_mut()
2988 .insert(language.clone(), self.plugin_name.clone());
2989
2990 Ok(self
2991 .command_sender
2992 .send(PluginCommand::RegisterLanguageConfig { language, config })
2993 .is_ok())
2994 }
2995
2996 pub fn register_lsp_server<'js>(
2998 &self,
2999 ctx: rquickjs::Ctx<'js>,
3000 language: String,
3001 config: LspServerPackConfig,
3002 ) -> rquickjs::Result<bool> {
3003 {
3005 let langs = self.registered_lsp_servers.borrow();
3006 if let Some(existing_plugin) = langs.get(&language) {
3007 if existing_plugin != &self.plugin_name {
3008 let msg = format!(
3009 "LSP server for language '{}' already registered by plugin '{}'",
3010 language, existing_plugin
3011 );
3012 tracing::warn!("registerLspServer collision: {}", msg);
3013 return Err(
3014 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
3015 );
3016 }
3017 }
3018 }
3019 self.registered_lsp_servers
3020 .borrow_mut()
3021 .insert(language.clone(), self.plugin_name.clone());
3022
3023 Ok(self
3024 .command_sender
3025 .send(PluginCommand::RegisterLspServer { language, config })
3026 .is_ok())
3027 }
3028
3029 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
3033 #[qjs(rename = "_reloadGrammarsStart")]
3034 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3035 let id = self.alloc_request_id();
3036 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
3037 callback_id: fresh_core::api::JsCallbackId::new(id),
3038 });
3039 id
3040 }
3041
3042 pub fn get_plugin_dir(&self) -> String {
3045 self.services
3046 .plugins_dir()
3047 .join("packages")
3048 .join(&self.plugin_name)
3049 .to_string_lossy()
3050 .to_string()
3051 }
3052
3053 pub fn get_config_dir(&self) -> String {
3055 self.services.config_dir().to_string_lossy().to_string()
3056 }
3057
3058 pub fn get_data_dir(&self) -> String {
3062 self.services.data_dir().to_string_lossy().to_string()
3063 }
3064
3065 pub fn get_terminal_dir(&self) -> String {
3070 let working_dir = self
3071 .state_snapshot
3072 .read()
3073 .map(|s| s.working_dir.clone())
3074 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3075 self.services
3076 .terminal_dir(&working_dir)
3077 .to_string_lossy()
3078 .to_string()
3079 }
3080
3081 pub fn get_working_data_dir(&self) -> String {
3087 let working_dir = self
3088 .state_snapshot
3089 .read()
3090 .map(|s| s.working_dir.clone())
3091 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3092 self.services
3093 .working_data_dir(&working_dir)
3094 .to_string_lossy()
3095 .to_string()
3096 }
3097
3098 pub fn get_themes_dir(&self) -> String {
3100 self.services
3101 .config_dir()
3102 .join("themes")
3103 .to_string_lossy()
3104 .to_string()
3105 }
3106
3107 pub fn apply_theme(&self, theme_name: String) -> bool {
3109 self.command_sender
3110 .send(PluginCommand::ApplyTheme { theme_name })
3111 .is_ok()
3112 }
3113
3114 pub fn override_theme_colors<'js>(
3123 &self,
3124 _ctx: rquickjs::Ctx<'js>,
3125 overrides: Value<'js>,
3126 ) -> rquickjs::Result<bool> {
3127 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
3133 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
3134 let Some(obj) = json.as_object() else {
3135 return Err(rquickjs::Error::new_from_js_message(
3136 "type",
3137 "",
3138 "overrideThemeColors expects an object of \"key\": [r, g, b]",
3139 ));
3140 };
3141 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
3142 n.as_i64()
3143 .or_else(|| n.as_f64().map(|f| f as i64))
3144 .map(|v| v.clamp(0, 255) as u8)
3145 };
3146 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
3147 std::collections::HashMap::with_capacity(obj.len());
3148 for (key, value) in obj {
3149 let Some(arr) = value.as_array() else {
3150 continue;
3151 };
3152 if arr.len() != 3 {
3153 continue;
3154 }
3155 let Some(r) = to_u8(&arr[0]) else { continue };
3156 let Some(g) = to_u8(&arr[1]) else { continue };
3157 let Some(b) = to_u8(&arr[2]) else { continue };
3158 clamped.insert(key.clone(), [r, g, b]);
3159 }
3160 Ok(self
3161 .command_sender
3162 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
3163 .is_ok())
3164 }
3165
3166 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3168 let schema = self.services.get_theme_schema();
3169 rquickjs_serde::to_value(ctx, &schema)
3170 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3171 }
3172
3173 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3175 let themes = self.services.get_builtin_themes();
3176 rquickjs_serde::to_value(ctx, &themes)
3177 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3178 }
3179
3180 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3183 let themes = self.services.get_all_themes();
3184 rquickjs_serde::to_value(ctx, &themes)
3185 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3186 }
3187
3188 #[qjs(rename = "_deleteThemeSync")]
3190 pub fn delete_theme_sync(&self, name: String) -> bool {
3191 let themes_dir = self.services.config_dir().join("themes");
3193 let theme_path = themes_dir.join(format!("{}.json", name));
3194
3195 if let Ok(canonical) = theme_path.canonicalize() {
3197 if let Ok(themes_canonical) = themes_dir.canonicalize() {
3198 if canonical.starts_with(&themes_canonical) {
3199 return std::fs::remove_file(&canonical).is_ok();
3200 }
3201 }
3202 }
3203 false
3204 }
3205
3206 pub fn delete_theme(&self, name: String) -> bool {
3208 self.delete_theme_sync(name)
3209 }
3210
3211 pub fn get_theme_data<'js>(
3213 &self,
3214 ctx: rquickjs::Ctx<'js>,
3215 name: String,
3216 ) -> rquickjs::Result<Value<'js>> {
3217 match self.services.get_theme_data(&name) {
3218 Some(data) => rquickjs_serde::to_value(ctx, &data)
3219 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3220 None => Ok(Value::new_null(ctx)),
3221 }
3222 }
3223
3224 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3226 self.services
3227 .save_theme_file(&name, &content)
3228 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3229 }
3230
3231 pub fn theme_file_exists(&self, name: String) -> bool {
3233 self.services.theme_file_exists(&name)
3234 }
3235
3236 pub fn file_stat<'js>(
3240 &self,
3241 ctx: rquickjs::Ctx<'js>,
3242 path: String,
3243 ) -> rquickjs::Result<Value<'js>> {
3244 let metadata = std::fs::metadata(&path).ok();
3245 let stat = metadata.map(|m| {
3246 serde_json::json!({
3247 "isFile": m.is_file(),
3248 "isDir": m.is_dir(),
3249 "size": m.len(),
3250 "readonly": m.permissions().readonly(),
3251 })
3252 });
3253 rquickjs_serde::to_value(ctx, &stat)
3254 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3255 }
3256
3257 pub fn is_process_running(&self, _process_id: u64) -> bool {
3261 false
3264 }
3265
3266 pub fn kill_process(&self, process_id: u64) -> bool {
3268 self.command_sender
3269 .send(PluginCommand::KillBackgroundProcess { process_id })
3270 .is_ok()
3271 }
3272
3273 pub fn plugin_translate<'js>(
3277 &self,
3278 _ctx: rquickjs::Ctx<'js>,
3279 plugin_name: String,
3280 key: String,
3281 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3282 ) -> String {
3283 let args_map: HashMap<String, String> = args
3284 .0
3285 .map(|obj| {
3286 let mut map = HashMap::new();
3287 for (k, v) in obj.props::<String, String>().flatten() {
3288 map.insert(k, v);
3289 }
3290 map
3291 })
3292 .unwrap_or_default();
3293
3294 self.services.translate(&plugin_name, &key, &args_map)
3295 }
3296
3297 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3304 #[qjs(rename = "_createCompositeBufferStart")]
3305 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3306 let id = self.alloc_request_id();
3307
3308 if let Ok(mut owners) = self.async_resource_owners.lock() {
3310 owners.insert(id, self.plugin_name.clone());
3311 }
3312 let _ = self
3313 .command_sender
3314 .send(PluginCommand::CreateCompositeBuffer {
3315 name: opts.name,
3316 mode: opts.mode,
3317 layout: opts.layout,
3318 sources: opts.sources,
3319 hunks: opts.hunks,
3320 initial_focus_hunk: opts.initial_focus_hunk,
3321 request_id: Some(id),
3322 });
3323
3324 id
3325 }
3326
3327 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3331 self.command_sender
3332 .send(PluginCommand::UpdateCompositeAlignment {
3333 buffer_id: BufferId(buffer_id as usize),
3334 hunks,
3335 })
3336 .is_ok()
3337 }
3338
3339 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3341 self.command_sender
3342 .send(PluginCommand::CloseCompositeBuffer {
3343 buffer_id: BufferId(buffer_id as usize),
3344 })
3345 .is_ok()
3346 }
3347
3348 pub fn flush_layout(&self) -> bool {
3352 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3353 }
3354
3355 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3357 self.command_sender
3358 .send(PluginCommand::CompositeNextHunk {
3359 buffer_id: BufferId(buffer_id as usize),
3360 })
3361 .is_ok()
3362 }
3363
3364 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3366 self.command_sender
3367 .send(PluginCommand::CompositePrevHunk {
3368 buffer_id: BufferId(buffer_id as usize),
3369 })
3370 .is_ok()
3371 }
3372
3373 #[plugin_api(
3377 async_promise,
3378 js_name = "getHighlights",
3379 ts_return = "TsHighlightSpan[]"
3380 )]
3381 #[qjs(rename = "_getHighlightsStart")]
3382 pub fn get_highlights_start<'js>(
3383 &self,
3384 _ctx: rquickjs::Ctx<'js>,
3385 buffer_id: u32,
3386 start: u32,
3387 end: u32,
3388 ) -> rquickjs::Result<u64> {
3389 let id = self.alloc_request_id();
3390
3391 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3392 buffer_id: BufferId(buffer_id as usize),
3393 range: (start as usize)..(end as usize),
3394 request_id: id,
3395 });
3396
3397 Ok(id)
3398 }
3399
3400 pub fn add_overlay<'js>(
3422 &self,
3423 _ctx: rquickjs::Ctx<'js>,
3424 buffer_id: u32,
3425 namespace: String,
3426 start: u32,
3427 end: u32,
3428 options: rquickjs::Object<'js>,
3429 ) -> rquickjs::Result<bool> {
3430 use fresh_core::api::OverlayColorSpec;
3431
3432 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3434 if let Ok(theme_key) = obj.get::<_, String>(key) {
3436 if !theme_key.is_empty() {
3437 return Some(OverlayColorSpec::ThemeKey(theme_key));
3438 }
3439 }
3440 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3442 if arr.len() >= 3 {
3443 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3444 }
3445 }
3446 None
3447 }
3448
3449 let fg = parse_color_spec("fg", &options);
3450 let bg = parse_color_spec("bg", &options);
3451 let underline: bool = options.get("underline").unwrap_or(false);
3452 let bold: bool = options.get("bold").unwrap_or(false);
3453 let italic: bool = options.get("italic").unwrap_or(false);
3454 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3455 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3456 let fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
3457 let url: Option<String> = options.get("url").ok();
3458
3459 let options = OverlayOptions {
3460 fg,
3461 bg,
3462 underline,
3463 bold,
3464 italic,
3465 strikethrough,
3466 extend_to_line_end,
3467 fg_on_collision_only,
3468 url,
3469 };
3470
3471 self.plugin_tracked_state
3473 .borrow_mut()
3474 .entry(self.plugin_name.clone())
3475 .or_default()
3476 .overlay_namespaces
3477 .push((BufferId(buffer_id as usize), namespace.clone()));
3478
3479 let _ = self.command_sender.send(PluginCommand::AddOverlay {
3480 buffer_id: BufferId(buffer_id as usize),
3481 namespace: Some(OverlayNamespace::from_string(namespace)),
3482 range: (start as usize)..(end as usize),
3483 options,
3484 });
3485
3486 Ok(true)
3487 }
3488
3489 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3491 self.command_sender
3492 .send(PluginCommand::ClearNamespace {
3493 buffer_id: BufferId(buffer_id as usize),
3494 namespace: OverlayNamespace::from_string(namespace),
3495 })
3496 .is_ok()
3497 }
3498
3499 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3501 self.command_sender
3502 .send(PluginCommand::ClearAllOverlays {
3503 buffer_id: BufferId(buffer_id as usize),
3504 })
3505 .is_ok()
3506 }
3507
3508 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3510 self.command_sender
3511 .send(PluginCommand::ClearOverlaysInRange {
3512 buffer_id: BufferId(buffer_id as usize),
3513 start: start as usize,
3514 end: end as usize,
3515 })
3516 .is_ok()
3517 }
3518
3519 pub fn clear_overlays_in_range_for_namespace(
3521 &self,
3522 buffer_id: u32,
3523 namespace: String,
3524 start: u32,
3525 end: u32,
3526 ) -> bool {
3527 self.command_sender
3528 .send(PluginCommand::ClearOverlaysInRangeForNamespace {
3529 buffer_id: BufferId(buffer_id as usize),
3530 namespace: OverlayNamespace::from_string(namespace),
3531 start: start as usize,
3532 end: end as usize,
3533 })
3534 .is_ok()
3535 }
3536
3537 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3539 use fresh_core::overlay::OverlayHandle;
3540 self.command_sender
3541 .send(PluginCommand::RemoveOverlay {
3542 buffer_id: BufferId(buffer_id as usize),
3543 handle: OverlayHandle(handle),
3544 })
3545 .is_ok()
3546 }
3547
3548 pub fn add_conceal(
3552 &self,
3553 buffer_id: u32,
3554 namespace: String,
3555 start: u32,
3556 end: u32,
3557 replacement: Option<String>,
3558 ) -> bool {
3559 self.plugin_tracked_state
3561 .borrow_mut()
3562 .entry(self.plugin_name.clone())
3563 .or_default()
3564 .overlay_namespaces
3565 .push((BufferId(buffer_id as usize), namespace.clone()));
3566
3567 self.command_sender
3568 .send(PluginCommand::AddConceal {
3569 buffer_id: BufferId(buffer_id as usize),
3570 namespace: OverlayNamespace::from_string(namespace),
3571 start: start as usize,
3572 end: end as usize,
3573 replacement,
3574 })
3575 .is_ok()
3576 }
3577
3578 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3580 self.command_sender
3581 .send(PluginCommand::ClearConcealNamespace {
3582 buffer_id: BufferId(buffer_id as usize),
3583 namespace: OverlayNamespace::from_string(namespace),
3584 })
3585 .is_ok()
3586 }
3587
3588 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3590 self.command_sender
3591 .send(PluginCommand::ClearConcealsInRange {
3592 buffer_id: BufferId(buffer_id as usize),
3593 start: start as usize,
3594 end: end as usize,
3595 })
3596 .is_ok()
3597 }
3598
3599 pub fn char_width(&self, code_point: u32) -> u32 {
3608 char::from_u32(code_point)
3609 .map(fresh_core::display_width::char_width)
3610 .unwrap_or(0) as u32
3611 }
3612
3613 pub fn string_width(&self, text: String) -> u32 {
3617 fresh_core::display_width::str_width(&text) as u32
3618 }
3619
3620 pub fn clear_conceals_in_range_for_namespace(
3623 &self,
3624 buffer_id: u32,
3625 namespace: String,
3626 start: u32,
3627 end: u32,
3628 ) -> bool {
3629 self.command_sender
3630 .send(PluginCommand::ClearConcealsInRangeForNamespace {
3631 buffer_id: BufferId(buffer_id as usize),
3632 namespace: OverlayNamespace::from_string(namespace),
3633 start: start as usize,
3634 end: end as usize,
3635 })
3636 .is_ok()
3637 }
3638
3639 pub fn add_fold(
3646 &self,
3647 buffer_id: u32,
3648 start: u32,
3649 end: u32,
3650 placeholder: rquickjs::function::Opt<String>,
3651 ) -> bool {
3652 self.command_sender
3653 .send(PluginCommand::AddFold {
3654 buffer_id: BufferId(buffer_id as usize),
3655 start: start as usize,
3656 end: end as usize,
3657 placeholder: placeholder.0,
3658 })
3659 .is_ok()
3660 }
3661
3662 pub fn clear_folds(&self, buffer_id: u32) -> bool {
3664 self.command_sender
3665 .send(PluginCommand::ClearFolds {
3666 buffer_id: BufferId(buffer_id as usize),
3667 })
3668 .is_ok()
3669 }
3670
3671 pub fn set_folding_ranges<'js>(
3684 &self,
3685 _ctx: rquickjs::Ctx<'js>,
3686 buffer_id: u32,
3687 ranges_arr: Vec<rquickjs::Object<'js>>,
3688 ) -> rquickjs::Result<bool> {
3689 let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3690 for obj in ranges_arr {
3691 let start_line: u32 = obj.get("startLine").unwrap_or(0);
3692 let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3693 let kind = obj
3694 .get::<_, String>("kind")
3695 .ok()
3696 .and_then(|s| match s.as_str() {
3697 "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3698 "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3699 "region" => Some(lsp_types::FoldingRangeKind::Region),
3700 _ => None,
3701 });
3702 ranges.push(lsp_types::FoldingRange {
3703 start_line,
3704 end_line,
3705 start_character: None,
3706 end_character: None,
3707 kind,
3708 collapsed_text: None,
3709 });
3710 }
3711 Ok(self
3712 .command_sender
3713 .send(PluginCommand::SetFoldingRanges {
3714 buffer_id: BufferId(buffer_id as usize),
3715 ranges,
3716 })
3717 .is_ok())
3718 }
3719
3720 pub fn add_soft_break(
3724 &self,
3725 buffer_id: u32,
3726 namespace: String,
3727 position: u32,
3728 indent: u32,
3729 ) -> bool {
3730 self.plugin_tracked_state
3732 .borrow_mut()
3733 .entry(self.plugin_name.clone())
3734 .or_default()
3735 .overlay_namespaces
3736 .push((BufferId(buffer_id as usize), namespace.clone()));
3737
3738 self.command_sender
3739 .send(PluginCommand::AddSoftBreak {
3740 buffer_id: BufferId(buffer_id as usize),
3741 namespace: OverlayNamespace::from_string(namespace),
3742 position: position as usize,
3743 indent: indent as u16,
3744 })
3745 .is_ok()
3746 }
3747
3748 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3750 self.command_sender
3751 .send(PluginCommand::ClearSoftBreakNamespace {
3752 buffer_id: BufferId(buffer_id as usize),
3753 namespace: OverlayNamespace::from_string(namespace),
3754 })
3755 .is_ok()
3756 }
3757
3758 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3760 self.command_sender
3761 .send(PluginCommand::ClearSoftBreaksInRange {
3762 buffer_id: BufferId(buffer_id as usize),
3763 start: start as usize,
3764 end: end as usize,
3765 })
3766 .is_ok()
3767 }
3768
3769 #[allow(clippy::too_many_arguments)]
3779 pub fn submit_view_transform<'js>(
3780 &self,
3781 _ctx: rquickjs::Ctx<'js>,
3782 buffer_id: u32,
3783 split_id: Option<u32>,
3784 start: u32,
3785 end: u32,
3786 tokens: Vec<rquickjs::Object<'js>>,
3787 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3788 ) -> rquickjs::Result<bool> {
3789 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3790
3791 let tokens: Vec<ViewTokenWire> = tokens
3792 .into_iter()
3793 .enumerate()
3794 .map(|(idx, obj)| {
3795 parse_view_token(&obj, idx)
3797 })
3798 .collect::<rquickjs::Result<Vec<_>>>()?;
3799
3800 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3802 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3803 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3804 Some(LayoutHints {
3805 compose_width,
3806 column_guides,
3807 })
3808 } else {
3809 None
3810 };
3811
3812 let payload = ViewTransformPayload {
3813 range: (start as usize)..(end as usize),
3814 tokens,
3815 layout_hints: parsed_layout_hints,
3816 };
3817
3818 Ok(self
3819 .command_sender
3820 .send(PluginCommand::SubmitViewTransform {
3821 buffer_id: BufferId(buffer_id as usize),
3822 split_id: split_id.map(|id| SplitId(id as usize)),
3823 payload,
3824 })
3825 .is_ok())
3826 }
3827
3828 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3830 self.command_sender
3831 .send(PluginCommand::ClearViewTransform {
3832 buffer_id: BufferId(buffer_id as usize),
3833 split_id: split_id.map(|id| SplitId(id as usize)),
3834 })
3835 .is_ok()
3836 }
3837
3838 pub fn set_layout_hints<'js>(
3841 &self,
3842 buffer_id: u32,
3843 split_id: Option<u32>,
3844 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3845 ) -> rquickjs::Result<bool> {
3846 use fresh_core::api::LayoutHints;
3847
3848 let compose_width: Option<u16> = hints.get("composeWidth").ok();
3849 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3850 let parsed_hints = LayoutHints {
3851 compose_width,
3852 column_guides,
3853 };
3854
3855 Ok(self
3856 .command_sender
3857 .send(PluginCommand::SetLayoutHints {
3858 buffer_id: BufferId(buffer_id as usize),
3859 split_id: split_id.map(|id| SplitId(id as usize)),
3860 range: 0..0,
3861 hints: parsed_hints,
3862 })
3863 .is_ok())
3864 }
3865
3866 pub fn set_file_explorer_decorations<'js>(
3870 &self,
3871 _ctx: rquickjs::Ctx<'js>,
3872 namespace: String,
3873 decorations: Vec<rquickjs::Object<'js>>,
3874 ) -> rquickjs::Result<bool> {
3875 use fresh_core::file_explorer::FileExplorerDecoration;
3876 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3877
3878 let decorations: Vec<FileExplorerDecoration> = decorations
3879 .into_iter()
3880 .map(|obj| {
3881 let path: String = obj.get("path")?;
3882 let symbol: String = obj.get("symbol")?;
3883 let priority: i32 = obj.get("priority").unwrap_or(0);
3884
3885 let color_val: rquickjs::Value = obj.get("color")?;
3887 let color = if color_val.is_string() {
3888 let key: String = color_val.get()?;
3889 fresh_core::api::OverlayColorSpec::ThemeKey(key)
3890 } else if color_val.is_array() {
3891 let arr: Vec<u8> = color_val.get()?;
3892 if arr.len() < 3 {
3893 return Err(rquickjs::Error::FromJs {
3894 from: "array",
3895 to: "color",
3896 message: Some(format!(
3897 "color array must have at least 3 elements, got {}",
3898 arr.len()
3899 )),
3900 });
3901 }
3902 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3903 } else {
3904 return Err(rquickjs::Error::FromJs {
3905 from: "value",
3906 to: "color",
3907 message: Some("color must be an RGB array or theme key string".to_string()),
3908 });
3909 };
3910
3911 Ok(FileExplorerDecoration {
3912 path: std::path::PathBuf::from(path),
3913 symbol,
3914 color,
3915 priority,
3916 })
3917 })
3918 .collect::<rquickjs::Result<Vec<_>>>()?;
3919
3920 self.plugin_tracked_state
3922 .borrow_mut()
3923 .entry(self.plugin_name.clone())
3924 .or_default()
3925 .file_explorer_namespaces
3926 .push(scoped_namespace.clone());
3927
3928 Ok(self
3929 .command_sender
3930 .send(PluginCommand::SetFileExplorerDecorations {
3931 namespace: scoped_namespace,
3932 decorations,
3933 })
3934 .is_ok())
3935 }
3936
3937 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3939 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3940 self.command_sender
3941 .send(PluginCommand::ClearFileExplorerDecorations {
3942 namespace: scoped_namespace,
3943 })
3944 .is_ok()
3945 }
3946
3947 pub fn set_file_explorer_slots<'js>(
3949 &self,
3950 ctx: rquickjs::Ctx<'js>,
3951 namespace: String,
3952 slots: Vec<rquickjs::Object<'js>>,
3953 ) -> rquickjs::Result<bool> {
3954 use fresh_core::file_explorer::FileExplorerSlotEntry;
3955 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3956
3957 let slots: Vec<FileExplorerSlotEntry> = slots
3958 .into_iter()
3959 .map(|obj| <FileExplorerSlotEntry as rquickjs::FromJs>::from_js(&ctx, obj.into()))
3960 .collect::<rquickjs::Result<Vec<_>>>()?;
3961
3962 self.plugin_tracked_state
3963 .borrow_mut()
3964 .entry(self.plugin_name.clone())
3965 .or_default()
3966 .file_explorer_namespaces
3967 .push(scoped_namespace.clone());
3968
3969 Ok(self
3970 .command_sender
3971 .send(PluginCommand::SetFileExplorerSlots {
3972 namespace: scoped_namespace,
3973 slots,
3974 })
3975 .is_ok())
3976 }
3977
3978 pub fn clear_file_explorer_slots(&self, namespace: String) -> bool {
3980 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3981 self.command_sender
3982 .send(PluginCommand::ClearFileExplorerSlots {
3983 namespace: scoped_namespace,
3984 })
3985 .is_ok()
3986 }
3987
3988 #[allow(clippy::too_many_arguments)]
3992 pub fn add_virtual_text(
3993 &self,
3994 buffer_id: u32,
3995 virtual_text_id: String,
3996 position: u32,
3997 text: String,
3998 r: u8,
3999 g: u8,
4000 b: u8,
4001 before: bool,
4002 use_bg: bool,
4003 ) -> bool {
4004 self.plugin_tracked_state
4006 .borrow_mut()
4007 .entry(self.plugin_name.clone())
4008 .or_default()
4009 .virtual_text_ids
4010 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
4011
4012 self.command_sender
4013 .send(PluginCommand::AddVirtualText {
4014 buffer_id: BufferId(buffer_id as usize),
4015 virtual_text_id,
4016 position: position as usize,
4017 text,
4018 color: (r, g, b),
4019 use_bg,
4020 before,
4021 })
4022 .is_ok()
4023 }
4024
4025 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
4027 self.command_sender
4028 .send(PluginCommand::RemoveVirtualText {
4029 buffer_id: BufferId(buffer_id as usize),
4030 virtual_text_id,
4031 })
4032 .is_ok()
4033 }
4034
4035 #[allow(clippy::too_many_arguments)]
4041 pub fn add_virtual_text_styled<'js>(
4042 &self,
4043 _ctx: rquickjs::Ctx<'js>,
4044 buffer_id: u32,
4045 virtual_text_id: String,
4046 position: u32,
4047 text: String,
4048 options: rquickjs::Object<'js>,
4049 before: bool,
4050 ) -> rquickjs::Result<bool> {
4051 use fresh_core::api::OverlayColorSpec;
4052
4053 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
4056 if let Ok(theme_key) = obj.get::<_, String>(key) {
4057 if !theme_key.is_empty() {
4058 return Some(OverlayColorSpec::ThemeKey(theme_key));
4059 }
4060 }
4061 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
4062 if arr.len() >= 3 {
4063 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
4064 }
4065 }
4066 None
4067 }
4068
4069 let fg = parse_color_spec("fg", &options);
4070 let bg = parse_color_spec("bg", &options);
4071 let bold: bool = options.get("bold").unwrap_or(false);
4072 let italic: bool = options.get("italic").unwrap_or(false);
4073
4074 self.plugin_tracked_state
4076 .borrow_mut()
4077 .entry(self.plugin_name.clone())
4078 .or_default()
4079 .virtual_text_ids
4080 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
4081
4082 let _ = self
4083 .command_sender
4084 .send(PluginCommand::AddVirtualTextStyled {
4085 buffer_id: BufferId(buffer_id as usize),
4086 virtual_text_id,
4087 position: position as usize,
4088 text,
4089 fg,
4090 bg,
4091 bold,
4092 italic,
4093 before,
4094 });
4095 Ok(true)
4096 }
4097
4098 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
4100 self.command_sender
4101 .send(PluginCommand::RemoveVirtualTextsByPrefix {
4102 buffer_id: BufferId(buffer_id as usize),
4103 prefix,
4104 })
4105 .is_ok()
4106 }
4107
4108 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
4110 self.command_sender
4111 .send(PluginCommand::ClearVirtualTexts {
4112 buffer_id: BufferId(buffer_id as usize),
4113 })
4114 .is_ok()
4115 }
4116
4117 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
4119 self.command_sender
4120 .send(PluginCommand::ClearVirtualTextNamespace {
4121 buffer_id: BufferId(buffer_id as usize),
4122 namespace,
4123 })
4124 .is_ok()
4125 }
4126
4127 #[allow(clippy::too_many_arguments)]
4142 pub fn add_virtual_line<'js>(
4143 &self,
4144 _ctx: rquickjs::Ctx<'js>,
4145 buffer_id: u32,
4146 position: u32,
4147 text: String,
4148 options: rquickjs::Object<'js>,
4149 above: bool,
4150 namespace: String,
4151 priority: i32,
4152 ) -> rquickjs::Result<bool> {
4153 use fresh_core::api::OverlayColorSpec;
4154
4155 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
4158 if let Ok(theme_key) = obj.get::<_, String>(key) {
4159 if !theme_key.is_empty() {
4160 return Some(OverlayColorSpec::ThemeKey(theme_key));
4161 }
4162 }
4163 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
4164 if arr.len() >= 3 {
4165 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
4166 }
4167 }
4168 None
4169 }
4170
4171 let fg_color = parse_color_spec("fg", &options);
4172 let bg_color = parse_color_spec("bg", &options);
4173 let gutter_glyph = options
4174 .get::<_, String>("gutterGlyph")
4175 .ok()
4176 .filter(|s| !s.is_empty());
4177 let gutter_color = parse_color_spec("gutterColor", &options);
4178
4179 let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
4185 .get::<_, rquickjs::Value<'js>>("textOverlays")
4186 .ok()
4187 .filter(|v| !v.is_undefined() && !v.is_null())
4188 .and_then(|v| rquickjs_serde::from_value(v).ok())
4189 .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
4190 v.into_iter().filter(|o| o.end > o.start).collect()
4191 })
4192 .unwrap_or_default();
4193
4194 self.plugin_tracked_state
4196 .borrow_mut()
4197 .entry(self.plugin_name.clone())
4198 .or_default()
4199 .virtual_line_namespaces
4200 .push((BufferId(buffer_id as usize), namespace.clone()));
4201
4202 Ok(self
4203 .command_sender
4204 .send(PluginCommand::AddVirtualLine {
4205 buffer_id: BufferId(buffer_id as usize),
4206 position: position as usize,
4207 text,
4208 fg_color,
4209 bg_color,
4210 above,
4211 namespace,
4212 priority,
4213 gutter_glyph,
4214 gutter_color,
4215 text_overlays,
4216 })
4217 .is_ok())
4218 }
4219
4220 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
4225 #[qjs(rename = "_promptStart")]
4226 pub fn prompt_start(
4227 &self,
4228 _ctx: rquickjs::Ctx<'_>,
4229 label: String,
4230 initial_value: String,
4231 ) -> u64 {
4232 let id = self.alloc_request_id();
4233
4234 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
4235 label,
4236 initial_value,
4237 callback_id: JsCallbackId::new(id),
4238 });
4239
4240 id
4241 }
4242
4243 pub fn start_prompt(
4254 &self,
4255 label: String,
4256 prompt_type: String,
4257 floating_overlay: rquickjs::function::Opt<bool>,
4258 ) -> bool {
4259 self.command_sender
4260 .send(PluginCommand::StartPrompt {
4261 label,
4262 prompt_type,
4263 floating_overlay: floating_overlay.0.unwrap_or(false),
4264 })
4265 .is_ok()
4266 }
4267
4268 pub fn begin_key_capture(&self) -> bool {
4278 self.command_sender
4279 .send(PluginCommand::SetKeyCaptureActive { active: true })
4280 .is_ok()
4281 }
4282
4283 pub fn end_key_capture(&self) -> bool {
4287 self.command_sender
4288 .send(PluginCommand::SetKeyCaptureActive { active: false })
4289 .is_ok()
4290 }
4291
4292 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4304 #[qjs(rename = "_getNextKeyStart")]
4305 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4306 let id = self.alloc_request_id();
4307 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4308 callback_id: JsCallbackId::new(id),
4309 });
4310 id
4311 }
4312
4313 pub fn start_prompt_with_initial(
4316 &self,
4317 label: String,
4318 prompt_type: String,
4319 initial_value: String,
4320 floating_overlay: rquickjs::function::Opt<bool>,
4321 ) -> bool {
4322 self.command_sender
4323 .send(PluginCommand::StartPromptWithInitial {
4324 label,
4325 prompt_type,
4326 initial_value,
4327 floating_overlay: floating_overlay.0.unwrap_or(false),
4328 })
4329 .is_ok()
4330 }
4331
4332 pub fn set_prompt_suggestions(
4342 &self,
4343 suggestions: Vec<fresh_core::command::Suggestion>,
4344 selected_index: rquickjs::function::Opt<Option<u32>>,
4345 ) -> bool {
4346 self.command_sender
4347 .send(PluginCommand::SetPromptSuggestions {
4348 suggestions,
4349 selected_index: selected_index.0.flatten(),
4350 })
4351 .is_ok()
4352 }
4353
4354 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4355 self.command_sender
4356 .send(PluginCommand::SetPromptInputSync { sync })
4357 .is_ok()
4358 }
4359
4360 pub fn set_prompt_title(
4370 &self,
4371 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4372 ) -> bool {
4373 self.command_sender
4374 .send(PluginCommand::SetPromptTitle { title })
4375 .is_ok()
4376 }
4377
4378 pub fn set_prompt_footer(
4384 &self,
4385 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4386 ) -> bool {
4387 self.command_sender
4388 .send(PluginCommand::SetPromptFooter { footer })
4389 .is_ok()
4390 }
4391
4392 pub fn set_prompt_status(&self, status: String) -> bool {
4395 self.command_sender
4396 .send(PluginCommand::SetPromptStatus { status })
4397 .is_ok()
4398 }
4399
4400 #[qjs(rename = "setPromptToolbar")]
4404 pub fn set_prompt_toolbar<'js>(
4405 &self,
4406 ctx: rquickjs::Ctx<'js>,
4407 spec_obj: rquickjs::Value<'js>,
4408 ) -> rquickjs::Result<bool> {
4409 let spec = if spec_obj.is_null() || spec_obj.is_undefined() {
4410 None
4411 } else {
4412 let json = js_to_json(&ctx, spec_obj);
4413 match serde_json::from_value::<fresh_core::api::WidgetSpec>(json) {
4414 Ok(s) => Some(s),
4415 Err(e) => {
4416 tracing::error!("setPromptToolbar: invalid spec: {}", e);
4417 return Ok(false);
4418 }
4419 }
4420 };
4421 Ok(self
4422 .command_sender
4423 .send(PluginCommand::SetPromptToolbar { spec })
4424 .is_ok())
4425 }
4426
4427 #[qjs(rename = "toggleOverlayToolbarWidget")]
4432 pub fn toggle_overlay_toolbar_widget(&self, key: String) -> bool {
4433 self.command_sender
4434 .send(PluginCommand::ToggleOverlayToolbarWidget { key })
4435 .is_ok()
4436 }
4437
4438 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4446 self.command_sender
4447 .send(PluginCommand::SetPromptSelectedIndex { index })
4448 .is_ok()
4449 }
4450
4451 pub fn define_mode(
4455 &self,
4456 name: String,
4457 bindings_arr: Vec<Vec<String>>,
4458 read_only: rquickjs::function::Opt<bool>,
4459 allow_text_input: rquickjs::function::Opt<bool>,
4460 inherit_normal_bindings: rquickjs::function::Opt<bool>,
4461 ) -> bool {
4462 let bindings: Vec<(String, String)> = bindings_arr
4463 .into_iter()
4464 .filter_map(|arr| {
4465 if arr.len() >= 2 {
4466 Some((arr[0].clone(), arr[1].clone()))
4467 } else {
4468 None
4469 }
4470 })
4471 .collect();
4472
4473 {
4476 let mut registered = self.registered_actions.borrow_mut();
4477 for (_, cmd_name) in &bindings {
4478 registered.insert(
4479 cmd_name.clone(),
4480 PluginHandler {
4481 plugin_name: self.plugin_name.clone(),
4482 handler_name: cmd_name.clone(),
4483 },
4484 );
4485 }
4486 }
4487
4488 let allow_text = allow_text_input.0.unwrap_or(false);
4491 if allow_text {
4492 let mut registered = self.registered_actions.borrow_mut();
4493 registered.insert(
4494 "mode_text_input".to_string(),
4495 PluginHandler {
4496 plugin_name: self.plugin_name.clone(),
4497 handler_name: "mode_text_input".to_string(),
4498 },
4499 );
4500 }
4501
4502 self.command_sender
4503 .send(PluginCommand::DefineMode {
4504 name,
4505 bindings,
4506 read_only: read_only.0.unwrap_or(false),
4507 allow_text_input: allow_text,
4508 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4509 plugin_name: Some(self.plugin_name.clone()),
4510 })
4511 .is_ok()
4512 }
4513
4514 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4516 self.command_sender
4517 .send(PluginCommand::SetEditorMode { mode })
4518 .is_ok()
4519 }
4520
4521 pub fn get_editor_mode(&self) -> Option<String> {
4523 self.state_snapshot
4524 .read()
4525 .ok()
4526 .and_then(|s| s.editor_mode.clone())
4527 }
4528
4529 pub fn close_split(&self, split_id: u32) -> bool {
4533 self.command_sender
4534 .send(PluginCommand::CloseSplit {
4535 split_id: SplitId(split_id as usize),
4536 })
4537 .is_ok()
4538 }
4539
4540 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4542 self.command_sender
4543 .send(PluginCommand::SetSplitBuffer {
4544 split_id: SplitId(split_id as usize),
4545 buffer_id: BufferId(buffer_id as usize),
4546 })
4547 .is_ok()
4548 }
4549
4550 pub fn focus_split(&self, split_id: u32) -> bool {
4552 self.command_sender
4553 .send(PluginCommand::FocusSplit {
4554 split_id: SplitId(split_id as usize),
4555 })
4556 .is_ok()
4557 }
4558
4559 pub fn create_window(&self, root: String, label: String) -> bool {
4578 self.command_sender
4579 .send(PluginCommand::CreateWindow {
4580 root: std::path::PathBuf::from(root),
4581 label,
4582 })
4583 .is_ok()
4584 }
4585
4586 pub fn set_active_window(&self, id: u64) -> bool {
4591 self.command_sender
4592 .send(PluginCommand::SetActiveWindow {
4593 id: fresh_core::WindowId(id),
4594 })
4595 .is_ok()
4596 }
4597
4598 #[qjs(rename = "setActiveWindowAnimated")]
4602 pub fn set_active_window_animated(&self, id: u64, from_edge: String) -> bool {
4603 self.command_sender
4604 .send(PluginCommand::SetActiveWindowAnimated {
4605 id: fresh_core::WindowId(id),
4606 from_edge,
4607 })
4608 .is_ok()
4609 }
4610
4611 #[qjs(rename = "setWindowCycleOrder")]
4616 pub fn set_window_cycle_order(&self, ids: Vec<i64>) -> bool {
4617 self.command_sender
4618 .send(PluginCommand::SetWindowCycleOrder {
4619 ids: ids
4620 .into_iter()
4621 .filter(|n| *n > 0)
4622 .map(|n| fresh_core::WindowId(n as u64))
4623 .collect(),
4624 })
4625 .is_ok()
4626 }
4627
4628 pub fn close_window(&self, id: u64) -> bool {
4631 self.command_sender
4632 .send(PluginCommand::CloseWindow {
4633 id: fresh_core::WindowId(id),
4634 })
4635 .is_ok()
4636 }
4637
4638 pub fn prewarm_window(&self, id: u64) -> bool {
4642 self.command_sender
4643 .send(PluginCommand::PrewarmWindow {
4644 id: fresh_core::WindowId(id),
4645 })
4646 .is_ok()
4647 }
4648
4649 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4661 #[qjs(rename = "_watchPathStart")]
4662 pub fn watch_path_start(
4663 &self,
4664 _ctx: rquickjs::Ctx<'_>,
4665 path: String,
4666 recursive: rquickjs::function::Opt<bool>,
4667 ) -> rquickjs::Result<u64> {
4668 let id = self.alloc_request_id();
4669 if let Ok(mut owners) = self.async_resource_owners.lock() {
4670 owners.insert(id, self.plugin_name.clone());
4671 }
4672 let _ = self.command_sender.send(PluginCommand::WatchPath {
4673 path: std::path::PathBuf::from(path),
4674 recursive: recursive.0.unwrap_or(false),
4675 request_id: id,
4676 });
4677 Ok(id)
4678 }
4679
4680 pub fn unwatch_path(&self, handle: u64) -> bool {
4683 self.command_sender
4684 .send(PluginCommand::UnwatchPath { handle })
4685 .is_ok()
4686 }
4687
4688 pub fn preview_window_in_rect(&self, id: u64) -> bool {
4699 let sid = if id == 0 {
4700 None
4701 } else {
4702 Some(fresh_core::WindowId(id))
4703 };
4704 self.command_sender
4705 .send(PluginCommand::PreviewWindowInRect { id: sid })
4706 .is_ok()
4707 }
4708
4709 pub fn clear_window_preview(&self) -> bool {
4712 self.command_sender
4713 .send(PluginCommand::PreviewWindowInRect { id: None })
4714 .is_ok()
4715 }
4716
4717 #[plugin_api(ts_return = "WindowInfo[]")]
4720 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4721 let sessions: Vec<fresh_core::api::WindowInfo> = self
4722 .state_snapshot
4723 .read()
4724 .map(|s| s.windows.clone())
4725 .unwrap_or_default();
4726 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4727 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4728 })
4729 }
4730
4731 pub fn active_window(&self) -> u64 {
4734 self.state_snapshot
4735 .read()
4736 .map(|s| s.active_window_id.0)
4737 .unwrap_or(1)
4738 }
4739
4740 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4742 self.command_sender
4743 .send(PluginCommand::SetSplitScroll {
4744 split_id: SplitId(split_id as usize),
4745 top_byte: top_byte as usize,
4746 })
4747 .is_ok()
4748 }
4749
4750 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4752 self.command_sender
4753 .send(PluginCommand::SetSplitRatio {
4754 split_id: SplitId(split_id as usize),
4755 ratio,
4756 })
4757 .is_ok()
4758 }
4759
4760 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4762 self.command_sender
4763 .send(PluginCommand::SetSplitLabel {
4764 split_id: SplitId(split_id as usize),
4765 label,
4766 })
4767 .is_ok()
4768 }
4769
4770 pub fn clear_split_label(&self, split_id: u32) -> bool {
4772 self.command_sender
4773 .send(PluginCommand::ClearSplitLabel {
4774 split_id: SplitId(split_id as usize),
4775 })
4776 .is_ok()
4777 }
4778
4779 #[plugin_api(
4781 async_promise,
4782 js_name = "getSplitByLabel",
4783 ts_return = "number | null"
4784 )]
4785 #[qjs(rename = "_getSplitByLabelStart")]
4786 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4787 let id = self.alloc_request_id();
4788 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4789 label,
4790 request_id: id,
4791 });
4792 id
4793 }
4794
4795 pub fn distribute_splits_evenly(&self) -> bool {
4797 self.command_sender
4799 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4800 .is_ok()
4801 }
4802
4803 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4805 self.command_sender
4806 .send(PluginCommand::SetBufferCursor {
4807 buffer_id: BufferId(buffer_id as usize),
4808 position: position as usize,
4809 })
4810 .is_ok()
4811 }
4812
4813 #[qjs(rename = "setBufferShowCursors")]
4820 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4821 self.command_sender
4822 .send(PluginCommand::SetBufferShowCursors {
4823 buffer_id: BufferId(buffer_id as usize),
4824 show,
4825 })
4826 .is_ok()
4827 }
4828
4829 #[allow(clippy::too_many_arguments)]
4833 pub fn set_line_indicator(
4834 &self,
4835 buffer_id: u32,
4836 line: u32,
4837 namespace: String,
4838 symbol: String,
4839 r: u8,
4840 g: u8,
4841 b: u8,
4842 priority: i32,
4843 ) -> bool {
4844 self.plugin_tracked_state
4846 .borrow_mut()
4847 .entry(self.plugin_name.clone())
4848 .or_default()
4849 .line_indicator_namespaces
4850 .push((BufferId(buffer_id as usize), namespace.clone()));
4851
4852 self.command_sender
4853 .send(PluginCommand::SetLineIndicator {
4854 buffer_id: BufferId(buffer_id as usize),
4855 line: line as usize,
4856 namespace,
4857 symbol,
4858 color: (r, g, b),
4859 priority,
4860 })
4861 .is_ok()
4862 }
4863
4864 #[allow(clippy::too_many_arguments)]
4866 pub fn set_line_indicators(
4867 &self,
4868 buffer_id: u32,
4869 lines: Vec<u32>,
4870 namespace: String,
4871 symbol: String,
4872 r: u8,
4873 g: u8,
4874 b: u8,
4875 priority: i32,
4876 ) -> bool {
4877 self.plugin_tracked_state
4879 .borrow_mut()
4880 .entry(self.plugin_name.clone())
4881 .or_default()
4882 .line_indicator_namespaces
4883 .push((BufferId(buffer_id as usize), namespace.clone()));
4884
4885 self.command_sender
4886 .send(PluginCommand::SetLineIndicators {
4887 buffer_id: BufferId(buffer_id as usize),
4888 lines: lines.into_iter().map(|l| l as usize).collect(),
4889 namespace,
4890 symbol,
4891 color: (r, g, b),
4892 priority,
4893 })
4894 .is_ok()
4895 }
4896
4897 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4899 self.command_sender
4900 .send(PluginCommand::ClearLineIndicators {
4901 buffer_id: BufferId(buffer_id as usize),
4902 namespace,
4903 })
4904 .is_ok()
4905 }
4906
4907 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4909 self.command_sender
4910 .send(PluginCommand::SetLineNumbers {
4911 buffer_id: BufferId(buffer_id as usize),
4912 enabled,
4913 })
4914 .is_ok()
4915 }
4916
4917 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4919 self.command_sender
4920 .send(PluginCommand::SetViewMode {
4921 buffer_id: BufferId(buffer_id as usize),
4922 mode,
4923 })
4924 .is_ok()
4925 }
4926
4927 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4929 self.command_sender
4930 .send(PluginCommand::SetLineWrap {
4931 buffer_id: BufferId(buffer_id as usize),
4932 split_id: split_id.map(|s| SplitId(s as usize)),
4933 enabled,
4934 })
4935 .is_ok()
4936 }
4937
4938 pub fn set_view_state<'js>(
4942 &self,
4943 ctx: rquickjs::Ctx<'js>,
4944 buffer_id: u32,
4945 key: String,
4946 value: Value<'js>,
4947 ) -> bool {
4948 let bid = BufferId(buffer_id as usize);
4949
4950 let json_value = if value.is_undefined() || value.is_null() {
4952 None
4953 } else {
4954 Some(js_to_json(&ctx, value))
4955 };
4956
4957 if let Ok(mut snapshot) = self.state_snapshot.write() {
4959 if let Some(ref json_val) = json_value {
4960 snapshot
4961 .plugin_view_states
4962 .entry(bid)
4963 .or_default()
4964 .insert(key.clone(), json_val.clone());
4965 } else {
4966 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4968 map.remove(&key);
4969 if map.is_empty() {
4970 snapshot.plugin_view_states.remove(&bid);
4971 }
4972 }
4973 }
4974 }
4975
4976 self.command_sender
4978 .send(PluginCommand::SetViewState {
4979 buffer_id: bid,
4980 key,
4981 value: json_value,
4982 })
4983 .is_ok()
4984 }
4985
4986 pub fn get_view_state<'js>(
4988 &self,
4989 ctx: rquickjs::Ctx<'js>,
4990 buffer_id: u32,
4991 key: String,
4992 ) -> rquickjs::Result<Value<'js>> {
4993 let bid = BufferId(buffer_id as usize);
4994 if let Ok(snapshot) = self.state_snapshot.read() {
4995 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4996 if let Some(json_val) = map.get(&key) {
4997 return json_to_js_value(&ctx, json_val);
4998 }
4999 }
5000 }
5001 Ok(Value::new_undefined(ctx.clone()))
5002 }
5003
5004 pub fn set_global_state<'js>(
5010 &self,
5011 ctx: rquickjs::Ctx<'js>,
5012 key: String,
5013 value: Value<'js>,
5014 ) -> bool {
5015 let json_value = if value.is_undefined() || value.is_null() {
5017 None
5018 } else {
5019 Some(js_to_json(&ctx, value))
5020 };
5021
5022 if let Ok(mut snapshot) = self.state_snapshot.write() {
5024 if let Some(ref json_val) = json_value {
5025 snapshot
5026 .plugin_global_states
5027 .entry(self.plugin_name.clone())
5028 .or_default()
5029 .insert(key.clone(), json_val.clone());
5030 } else {
5031 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
5033 map.remove(&key);
5034 if map.is_empty() {
5035 snapshot.plugin_global_states.remove(&self.plugin_name);
5036 }
5037 }
5038 }
5039 }
5040
5041 self.command_sender
5043 .send(PluginCommand::SetGlobalState {
5044 plugin_name: self.plugin_name.clone(),
5045 key,
5046 value: json_value,
5047 })
5048 .is_ok()
5049 }
5050
5051 pub fn get_global_state<'js>(
5055 &self,
5056 ctx: rquickjs::Ctx<'js>,
5057 key: String,
5058 ) -> rquickjs::Result<Value<'js>> {
5059 if let Ok(snapshot) = self.state_snapshot.read() {
5060 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
5061 if let Some(json_val) = map.get(&key) {
5062 return json_to_js_value(&ctx, json_val);
5063 }
5064 }
5065 }
5066 Ok(Value::new_undefined(ctx.clone()))
5067 }
5068
5069 pub fn set_window_state<'js>(
5078 &self,
5079 ctx: rquickjs::Ctx<'js>,
5080 key: String,
5081 value: Value<'js>,
5082 ) -> bool {
5083 let json_value = if value.is_undefined() || value.is_null() {
5084 None
5085 } else {
5086 Some(js_to_json(&ctx, value))
5087 };
5088 if let Ok(mut snapshot) = self.state_snapshot.write() {
5092 match &json_value {
5093 Some(v) => {
5094 snapshot
5095 .active_session_plugin_states
5096 .entry(self.plugin_name.clone())
5097 .or_default()
5098 .insert(key.clone(), v.clone());
5099 }
5100 None => {
5101 if let Some(map) = snapshot
5102 .active_session_plugin_states
5103 .get_mut(&self.plugin_name)
5104 {
5105 map.remove(&key);
5106 if map.is_empty() {
5107 snapshot
5108 .active_session_plugin_states
5109 .remove(&self.plugin_name);
5110 }
5111 }
5112 }
5113 }
5114 }
5115 self.command_sender
5116 .send(PluginCommand::SetWindowState {
5117 plugin_name: self.plugin_name.clone(),
5118 key,
5119 value: json_value,
5120 })
5121 .is_ok()
5122 }
5123
5124 pub fn get_window_state<'js>(
5127 &self,
5128 ctx: rquickjs::Ctx<'js>,
5129 key: String,
5130 ) -> rquickjs::Result<Value<'js>> {
5131 if let Ok(snapshot) = self.state_snapshot.read() {
5132 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
5133 if let Some(json_val) = map.get(&key) {
5134 return json_to_js_value(&ctx, json_val);
5135 }
5136 }
5137 }
5138 Ok(Value::new_undefined(ctx.clone()))
5139 }
5140
5141 pub fn create_scroll_sync_group(
5145 &self,
5146 group_id: u32,
5147 left_split: u32,
5148 right_split: u32,
5149 ) -> bool {
5150 self.plugin_tracked_state
5152 .borrow_mut()
5153 .entry(self.plugin_name.clone())
5154 .or_default()
5155 .scroll_sync_group_ids
5156 .push(group_id);
5157 self.command_sender
5158 .send(PluginCommand::CreateScrollSyncGroup {
5159 group_id,
5160 left_split: SplitId(left_split as usize),
5161 right_split: SplitId(right_split as usize),
5162 })
5163 .is_ok()
5164 }
5165
5166 pub fn set_scroll_sync_anchors<'js>(
5168 &self,
5169 _ctx: rquickjs::Ctx<'js>,
5170 group_id: u32,
5171 anchors: Vec<Vec<u32>>,
5172 ) -> bool {
5173 let anchors: Vec<(usize, usize)> = anchors
5174 .into_iter()
5175 .filter_map(|pair| {
5176 if pair.len() >= 2 {
5177 Some((pair[0] as usize, pair[1] as usize))
5178 } else {
5179 None
5180 }
5181 })
5182 .collect();
5183 self.command_sender
5184 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
5185 .is_ok()
5186 }
5187
5188 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
5190 self.command_sender
5191 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
5192 .is_ok()
5193 }
5194
5195 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
5201 self.command_sender
5202 .send(PluginCommand::ExecuteActions { actions })
5203 .is_ok()
5204 }
5205
5206 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
5210 self.command_sender
5211 .send(PluginCommand::ShowActionPopup {
5212 popup_id: opts.id,
5213 title: opts.title,
5214 message: opts.message,
5215 actions: opts.actions,
5216 buffer_id: opts.buffer_id,
5217 })
5218 .is_ok()
5219 }
5220
5221 pub fn set_lsp_menu_contributions(
5225 &self,
5226 plugin_id: String,
5227 language: String,
5228 items: Vec<fresh_core::api::LspMenuItem>,
5229 ) -> bool {
5230 self.command_sender
5231 .send(PluginCommand::SetLspMenuContributions {
5232 plugin_id,
5233 language,
5234 items,
5235 })
5236 .is_ok()
5237 }
5238
5239 pub fn disable_lsp_for_language(&self, language: String) -> bool {
5241 self.command_sender
5242 .send(PluginCommand::DisableLspForLanguage { language })
5243 .is_ok()
5244 }
5245
5246 pub fn restart_lsp_for_language(&self, language: String) -> bool {
5248 self.command_sender
5249 .send(PluginCommand::RestartLspForLanguage { language })
5250 .is_ok()
5251 }
5252
5253 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
5256 self.command_sender
5257 .send(PluginCommand::SetLspRootUri { language, uri })
5258 .is_ok()
5259 }
5260
5261 #[plugin_api(ts_return = "JsDiagnostic[]")]
5263 pub fn get_all_diagnostics<'js>(
5264 &self,
5265 ctx: rquickjs::Ctx<'js>,
5266 ) -> rquickjs::Result<Value<'js>> {
5267 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
5268
5269 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
5270 let mut result: Vec<JsDiagnostic> = Vec::new();
5272 for (uri, diags) in s.diagnostics.iter() {
5273 for diag in diags {
5274 result.push(JsDiagnostic {
5275 uri: uri.clone(),
5276 message: diag.message.clone(),
5277 severity: diag.severity.map(|s| match s {
5278 lsp_types::DiagnosticSeverity::ERROR => 1,
5279 lsp_types::DiagnosticSeverity::WARNING => 2,
5280 lsp_types::DiagnosticSeverity::INFORMATION => 3,
5281 lsp_types::DiagnosticSeverity::HINT => 4,
5282 _ => 0,
5283 }),
5284 range: JsRange {
5285 start: JsPosition {
5286 line: diag.range.start.line,
5287 character: diag.range.start.character,
5288 },
5289 end: JsPosition {
5290 line: diag.range.end.line,
5291 character: diag.range.end.character,
5292 },
5293 },
5294 source: diag.source.clone(),
5295 });
5296 }
5297 }
5298 result
5299 } else {
5300 Vec::new()
5301 };
5302 rquickjs_serde::to_value(ctx, &diagnostics)
5303 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5304 }
5305
5306 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
5308 self.event_handlers
5309 .read()
5310 .expect("event_handlers poisoned")
5311 .get(&event_name)
5312 .cloned()
5313 .unwrap_or_default()
5314 .into_iter()
5315 .map(|h| h.handler_name)
5316 .collect()
5317 }
5318
5319 #[plugin_api(
5323 async_promise,
5324 js_name = "createVirtualBuffer",
5325 ts_return = "VirtualBufferResult"
5326 )]
5327 #[qjs(rename = "_createVirtualBufferStart")]
5328 pub fn create_virtual_buffer_start(
5329 &self,
5330 _ctx: rquickjs::Ctx<'_>,
5331 opts: fresh_core::api::CreateVirtualBufferOptions,
5332 ) -> rquickjs::Result<u64> {
5333 let id = self.alloc_request_id();
5334
5335 let entries: Vec<TextPropertyEntry> = opts
5337 .entries
5338 .unwrap_or_default()
5339 .into_iter()
5340 .map(|e| TextPropertyEntry {
5341 text: e.text,
5342 properties: e.properties.unwrap_or_default(),
5343 style: e.style,
5344 inline_overlays: e.inline_overlays.unwrap_or_default(),
5345 segments: e.segments.unwrap_or_default(),
5346 pad_to_chars: e.pad_to_chars,
5347 truncate_to_chars: e.truncate_to_chars,
5348 })
5349 .collect();
5350
5351 tracing::debug!(
5352 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
5353 id
5354 );
5355 if let Ok(mut owners) = self.async_resource_owners.lock() {
5357 owners.insert(id, self.plugin_name.clone());
5358 }
5359 let _ = self
5360 .command_sender
5361 .send(PluginCommand::CreateVirtualBufferWithContent {
5362 name: opts.name,
5363 mode: opts.mode.unwrap_or_default(),
5364 read_only: opts.read_only.unwrap_or(false),
5365 entries,
5366 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5367 show_cursors: opts.show_cursors.unwrap_or(true),
5368 editing_disabled: opts.editing_disabled.unwrap_or(false),
5369 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5370 initial_cursor_line: opts.initial_cursor_line,
5371 request_id: Some(id),
5372 });
5373 Ok(id)
5374 }
5375
5376 #[plugin_api(
5378 async_promise,
5379 js_name = "createVirtualBufferInSplit",
5380 ts_return = "VirtualBufferResult"
5381 )]
5382 #[qjs(rename = "_createVirtualBufferInSplitStart")]
5383 pub fn create_virtual_buffer_in_split_start(
5384 &self,
5385 _ctx: rquickjs::Ctx<'_>,
5386 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5387 ) -> rquickjs::Result<u64> {
5388 let id = self.alloc_request_id();
5389
5390 let entries: Vec<TextPropertyEntry> = opts
5392 .entries
5393 .unwrap_or_default()
5394 .into_iter()
5395 .map(|e| TextPropertyEntry {
5396 text: e.text,
5397 properties: e.properties.unwrap_or_default(),
5398 style: e.style,
5399 inline_overlays: e.inline_overlays.unwrap_or_default(),
5400 segments: e.segments.unwrap_or_default(),
5401 pad_to_chars: e.pad_to_chars,
5402 truncate_to_chars: e.truncate_to_chars,
5403 })
5404 .collect();
5405
5406 if let Ok(mut owners) = self.async_resource_owners.lock() {
5408 owners.insert(id, self.plugin_name.clone());
5409 }
5410 let _ = self
5411 .command_sender
5412 .send(PluginCommand::CreateVirtualBufferInSplit {
5413 name: opts.name,
5414 mode: opts.mode.unwrap_or_default(),
5415 read_only: opts.read_only.unwrap_or(false),
5416 entries,
5417 ratio: opts.ratio.unwrap_or(0.5),
5418 direction: opts.direction,
5419 panel_id: opts.panel_id,
5420 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5421 show_cursors: opts.show_cursors.unwrap_or(true),
5422 editing_disabled: opts.editing_disabled.unwrap_or(false),
5423 line_wrap: opts.line_wrap,
5424 before: opts.before.unwrap_or(false),
5425 role: opts.role,
5426 request_id: Some(id),
5427 });
5428 Ok(id)
5429 }
5430
5431 #[plugin_api(
5433 async_promise,
5434 js_name = "createVirtualBufferInExistingSplit",
5435 ts_return = "VirtualBufferResult"
5436 )]
5437 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5438 pub fn create_virtual_buffer_in_existing_split_start(
5439 &self,
5440 _ctx: rquickjs::Ctx<'_>,
5441 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5442 ) -> rquickjs::Result<u64> {
5443 let id = self.alloc_request_id();
5444
5445 let entries: Vec<TextPropertyEntry> = opts
5447 .entries
5448 .unwrap_or_default()
5449 .into_iter()
5450 .map(|e| TextPropertyEntry {
5451 text: e.text,
5452 properties: e.properties.unwrap_or_default(),
5453 style: e.style,
5454 inline_overlays: e.inline_overlays.unwrap_or_default(),
5455 segments: e.segments.unwrap_or_default(),
5456 pad_to_chars: e.pad_to_chars,
5457 truncate_to_chars: e.truncate_to_chars,
5458 })
5459 .collect();
5460
5461 if let Ok(mut owners) = self.async_resource_owners.lock() {
5463 owners.insert(id, self.plugin_name.clone());
5464 }
5465 let _ = self
5466 .command_sender
5467 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5468 name: opts.name,
5469 mode: opts.mode.unwrap_or_default(),
5470 read_only: opts.read_only.unwrap_or(false),
5471 entries,
5472 split_id: SplitId(opts.split_id),
5473 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5474 show_cursors: opts.show_cursors.unwrap_or(true),
5475 editing_disabled: opts.editing_disabled.unwrap_or(false),
5476 line_wrap: opts.line_wrap,
5477 initial_cursor_line: opts.initial_cursor_line,
5478 request_id: Some(id),
5479 });
5480 Ok(id)
5481 }
5482
5483 #[qjs(rename = "_createBufferGroupStart")]
5485 pub fn create_buffer_group_start(
5486 &self,
5487 _ctx: rquickjs::Ctx<'_>,
5488 name: String,
5489 mode: String,
5490 layout_json: String,
5491 ) -> rquickjs::Result<u64> {
5492 let id = self.alloc_request_id();
5493 if let Ok(mut owners) = self.async_resource_owners.lock() {
5494 owners.insert(id, self.plugin_name.clone());
5495 }
5496 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5497 name,
5498 mode,
5499 layout_json,
5500 request_id: Some(id),
5501 });
5502 Ok(id)
5503 }
5504
5505 #[qjs(rename = "setPanelContent")]
5507 pub fn set_panel_content<'js>(
5508 &self,
5509 ctx: rquickjs::Ctx<'js>,
5510 group_id: u32,
5511 panel_name: String,
5512 entries_arr: Vec<rquickjs::Object<'js>>,
5513 ) -> rquickjs::Result<bool> {
5514 let entries: Vec<TextPropertyEntry> = entries_arr
5515 .iter()
5516 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5517 .collect();
5518 Ok(self
5519 .command_sender
5520 .send(PluginCommand::SetPanelContent {
5521 group_id: group_id as usize,
5522 panel_name,
5523 entries,
5524 })
5525 .is_ok())
5526 }
5527
5528 #[qjs(rename = "closeBufferGroup")]
5530 pub fn close_buffer_group(&self, group_id: u32) -> bool {
5531 self.command_sender
5532 .send(PluginCommand::CloseBufferGroup {
5533 group_id: group_id as usize,
5534 })
5535 .is_ok()
5536 }
5537
5538 #[qjs(rename = "focusBufferGroupPanel")]
5540 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5541 self.command_sender
5542 .send(PluginCommand::FocusPanel {
5543 group_id: group_id as usize,
5544 panel_name,
5545 })
5546 .is_ok()
5547 }
5548
5549 #[plugin_api(
5556 async_promise,
5557 js_name = "setBufferGroupPanelBuffer",
5558 ts_return = "boolean"
5559 )]
5560 #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5561 pub fn set_buffer_group_panel_buffer_start(
5562 &self,
5563 _ctx: rquickjs::Ctx<'_>,
5564 group_id: u32,
5565 panel_name: String,
5566 buffer_id: u32,
5567 ) -> u64 {
5568 let id = self.alloc_request_id();
5569 let _ = self
5570 .command_sender
5571 .send(PluginCommand::SetBufferGroupPanelBuffer {
5572 group_id: group_id as usize,
5573 panel_name,
5574 buffer_id: BufferId(buffer_id as usize),
5575 request_id: id,
5576 });
5577 id
5578 }
5579
5580 pub fn set_virtual_buffer_content<'js>(
5584 &self,
5585 ctx: rquickjs::Ctx<'js>,
5586 buffer_id: u32,
5587 entries_arr: Vec<rquickjs::Object<'js>>,
5588 ) -> rquickjs::Result<bool> {
5589 let entries: Vec<TextPropertyEntry> = entries_arr
5590 .iter()
5591 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5592 .collect();
5593 Ok(self
5594 .command_sender
5595 .send(PluginCommand::SetVirtualBufferContent {
5596 buffer_id: BufferId(buffer_id as usize),
5597 entries,
5598 })
5599 .is_ok())
5600 }
5601
5602 pub fn get_text_properties_at_cursor(
5604 &self,
5605 buffer_id: u32,
5606 ) -> fresh_core::api::TextPropertiesAtCursor {
5607 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5608 }
5609
5610 #[qjs(rename = "mountWidgetPanel")]
5620 pub fn mount_widget_panel<'js>(
5621 &self,
5622 ctx: rquickjs::Ctx<'js>,
5623 panel_id: f64,
5624 buffer_id: u32,
5625 spec_obj: rquickjs::Value<'js>,
5626 ) -> rquickjs::Result<bool> {
5627 let json = js_to_json(&ctx, spec_obj);
5628 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5629 Ok(s) => s,
5630 Err(e) => {
5631 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5632 return Ok(false);
5633 }
5634 };
5635 Ok(self
5636 .command_sender
5637 .send(PluginCommand::MountWidgetPanel {
5638 plugin: self.plugin_name.clone(),
5639 panel_id: panel_id as u64,
5640 buffer_id: BufferId(buffer_id as usize),
5641 spec,
5642 })
5643 .is_ok())
5644 }
5645
5646 #[qjs(rename = "updateWidgetPanel")]
5649 pub fn update_widget_panel<'js>(
5650 &self,
5651 ctx: rquickjs::Ctx<'js>,
5652 panel_id: f64,
5653 spec_obj: rquickjs::Value<'js>,
5654 ) -> rquickjs::Result<bool> {
5655 let json = js_to_json(&ctx, spec_obj);
5656 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5657 Ok(s) => s,
5658 Err(e) => {
5659 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5660 return Ok(false);
5661 }
5662 };
5663 Ok(self
5664 .command_sender
5665 .send(PluginCommand::UpdateWidgetPanel {
5666 plugin: self.plugin_name.clone(),
5667 panel_id: panel_id as u64,
5668 spec,
5669 })
5670 .is_ok())
5671 }
5672
5673 #[qjs(rename = "unmountWidgetPanel")]
5676 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5677 self.command_sender
5678 .send(PluginCommand::UnmountWidgetPanel {
5679 plugin: self.plugin_name.clone(),
5680 panel_id: panel_id as u64,
5681 })
5682 .is_ok()
5683 }
5684
5685 #[qjs(rename = "widgetCommand")]
5694 pub fn widget_command<'js>(
5695 &self,
5696 ctx: rquickjs::Ctx<'js>,
5697 panel_id: f64,
5698 action_obj: rquickjs::Value<'js>,
5699 ) -> rquickjs::Result<bool> {
5700 let json = js_to_json(&ctx, action_obj);
5701 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5702 Ok(a) => a,
5703 Err(e) => {
5704 tracing::error!("widgetCommand: invalid action: {}", e);
5705 return Ok(false);
5706 }
5707 };
5708 Ok(self
5709 .command_sender
5710 .send(PluginCommand::WidgetCommand {
5711 plugin: self.plugin_name.clone(),
5712 panel_id: panel_id as u64,
5713 action,
5714 })
5715 .is_ok())
5716 }
5717
5718 #[qjs(rename = "widgetMutate")]
5724 pub fn widget_mutate<'js>(
5725 &self,
5726 ctx: rquickjs::Ctx<'js>,
5727 panel_id: f64,
5728 mutation_obj: rquickjs::Value<'js>,
5729 ) -> rquickjs::Result<bool> {
5730 let json = js_to_json(&ctx, mutation_obj);
5731 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5732 Ok(m) => m,
5733 Err(e) => {
5734 tracing::error!("widgetMutate: invalid mutation: {}", e);
5735 return Ok(false);
5736 }
5737 };
5738 Ok(self
5739 .command_sender
5740 .send(PluginCommand::WidgetMutate {
5741 plugin: self.plugin_name.clone(),
5742 panel_id: panel_id as u64,
5743 mutation,
5744 })
5745 .is_ok())
5746 }
5747
5748 #[qjs(rename = "mountFloatingWidget")]
5751 pub fn mount_floating_widget<'js>(
5752 &self,
5753 ctx: rquickjs::Ctx<'js>,
5754 panel_id: f64,
5755 spec_obj: rquickjs::Value<'js>,
5756 width_pct: f64,
5757 height_pct: f64,
5758 as_dock: rquickjs::function::Opt<bool>,
5759 focus_marker: rquickjs::function::Opt<bool>,
5760 ) -> rquickjs::Result<bool> {
5761 let json = js_to_json(&ctx, spec_obj);
5762 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5763 Ok(s) => s,
5764 Err(e) => {
5765 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5766 return Ok(false);
5767 }
5768 };
5769 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5770 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5771 Ok(self
5772 .command_sender
5773 .send(PluginCommand::MountFloatingWidget {
5774 plugin: self.plugin_name.clone(),
5775 panel_id: panel_id as u64,
5776 spec,
5777 width_pct,
5778 height_pct,
5779 as_dock: as_dock.0.unwrap_or(false),
5780 focus_marker: focus_marker.0.unwrap_or(false),
5781 })
5782 .is_ok())
5783 }
5784
5785 #[qjs(rename = "updateFloatingWidget")]
5787 pub fn update_floating_widget<'js>(
5788 &self,
5789 ctx: rquickjs::Ctx<'js>,
5790 panel_id: f64,
5791 spec_obj: rquickjs::Value<'js>,
5792 ) -> rquickjs::Result<bool> {
5793 let json = js_to_json(&ctx, spec_obj);
5794 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5795 Ok(s) => s,
5796 Err(e) => {
5797 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5798 return Ok(false);
5799 }
5800 };
5801 Ok(self
5802 .command_sender
5803 .send(PluginCommand::UpdateFloatingWidget {
5804 plugin: self.plugin_name.clone(),
5805 panel_id: panel_id as u64,
5806 spec,
5807 })
5808 .is_ok())
5809 }
5810
5811 #[qjs(rename = "unmountFloatingWidget")]
5813 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5814 self.command_sender
5815 .send(PluginCommand::UnmountFloatingWidget {
5816 plugin: self.plugin_name.clone(),
5817 panel_id: panel_id as u64,
5818 })
5819 .is_ok()
5820 }
5821
5822 #[qjs(rename = "floatingPanelControl")]
5828 pub fn floating_panel_control(&self, panel_id: f64, op: String, arg: f64) -> bool {
5829 self.command_sender
5830 .send(PluginCommand::FloatingPanelControl {
5831 plugin: self.plugin_name.clone(),
5832 panel_id: panel_id as u64,
5833 op,
5834 arg,
5835 })
5836 .is_ok()
5837 }
5838
5839 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5848 #[qjs(rename = "_spawnProcessStart")]
5849 pub fn spawn_process_start(
5850 &self,
5851 _ctx: rquickjs::Ctx<'_>,
5852 command: String,
5853 args: Vec<String>,
5854 cwd: rquickjs::function::Opt<String>,
5855 stdout_to: rquickjs::function::Opt<String>,
5856 ) -> u64 {
5857 let id = self.alloc_request_id();
5858 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5864 self.state_snapshot
5865 .read()
5866 .ok()
5867 .map(|s| s.working_dir.to_string_lossy().to_string())
5868 });
5869 let stdout_to_path = stdout_to
5870 .0
5871 .filter(|s| !s.is_empty())
5872 .map(std::path::PathBuf::from);
5873 tracing::info!(
5874 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5875 self.plugin_name,
5876 command,
5877 args,
5878 effective_cwd,
5879 stdout_to_path,
5880 id
5881 );
5882 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5883 callback_id: JsCallbackId::new(id),
5884 command,
5885 args,
5886 cwd: effective_cwd,
5887 stdout_to: stdout_to_path,
5888 });
5889 id
5890 }
5891
5892 #[plugin_api(
5899 async_thenable,
5900 js_name = "spawnHostProcess",
5901 ts_return = "SpawnResult"
5902 )]
5903 #[qjs(rename = "_spawnHostProcessStart")]
5904 pub fn spawn_host_process_start(
5905 &self,
5906 _ctx: rquickjs::Ctx<'_>,
5907 command: String,
5908 args: Vec<String>,
5909 cwd: rquickjs::function::Opt<String>,
5910 ) -> u64 {
5911 let id = self.alloc_request_id();
5912 let effective_cwd = cwd.0.or_else(|| {
5913 self.state_snapshot
5914 .read()
5915 .ok()
5916 .map(|s| s.working_dir.to_string_lossy().to_string())
5917 });
5918 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5919 callback_id: JsCallbackId::new(id),
5920 command,
5921 args,
5922 cwd: effective_cwd,
5923 });
5924 id
5925 }
5926
5927 #[plugin_api(js_name = "_killHostProcess")]
5937 pub fn kill_host_process(&self, process_id: u64) -> bool {
5938 self.command_sender
5939 .send(PluginCommand::KillHostProcess { process_id })
5940 .is_ok()
5941 }
5942
5943 #[plugin_api(js_name = "setAuthority")]
5952 pub fn set_authority(
5953 &self,
5954 ctx: rquickjs::Ctx<'_>,
5955 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5956 ) -> bool {
5957 let json = js_to_json(&ctx, payload);
5958 let _ = self
5959 .command_sender
5960 .send(PluginCommand::SetAuthority { payload: json });
5961 true
5962 }
5963
5964 #[plugin_api(js_name = "clearAuthority")]
5967 pub fn clear_authority(&self) {
5968 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5969 }
5970
5971 #[plugin_api(async_promise, js_name = "attachRemoteAgent", ts_return = "void")]
5987 #[qjs(rename = "_attachRemoteAgentStart")]
5988 pub fn attach_remote_agent(
5989 &self,
5990 ctx: rquickjs::Ctx<'_>,
5991 #[plugin_api(ts_type = "RemoteAgentSpec")] payload: rquickjs::Value<'_>,
5992 ) -> u64 {
5993 let json = js_to_json(&ctx, payload);
5994 let id = self.alloc_request_id();
5995 let _ = self.command_sender.send(PluginCommand::AttachRemoteAgent {
5996 payload: json,
5997 request_id: id,
5998 });
5999 id
6000 }
6001
6002 #[plugin_api(js_name = "cancelRemoteAgent")]
6007 pub fn cancel_remote_agent(&self) {
6008 let _ = self.command_sender.send(PluginCommand::CancelRemoteAttach);
6009 }
6010
6011 #[plugin_api(js_name = "setEnv")]
6015 pub fn set_env(&self, snippet: String, dir: Option<String>) {
6016 let _ = self
6017 .command_sender
6018 .send(PluginCommand::SetEnv { snippet, dir });
6019 }
6020
6021 #[plugin_api(js_name = "clearEnv")]
6023 pub fn clear_env(&self) {
6024 let _ = self.command_sender.send(PluginCommand::ClearEnv);
6025 }
6026
6027 #[plugin_api(js_name = "setRemoteIndicatorState")]
6045 pub fn set_remote_indicator_state(
6046 &self,
6047 ctx: rquickjs::Ctx<'_>,
6048 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
6049 ) -> bool {
6050 let json = js_to_json(&ctx, state);
6051 let _ = self
6052 .command_sender
6053 .send(PluginCommand::SetRemoteIndicatorState { state: json });
6054 true
6055 }
6056
6057 #[plugin_api(js_name = "clearRemoteIndicatorState")]
6060 pub fn clear_remote_indicator_state(&self) {
6061 let _ = self
6062 .command_sender
6063 .send(PluginCommand::ClearRemoteIndicatorState);
6064 }
6065
6066 #[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
6077 #[qjs(rename = "_httpFetchStart")]
6078 pub fn http_fetch_start(
6079 &self,
6080 _ctx: rquickjs::Ctx<'_>,
6081 url: String,
6082 target_path: String,
6083 ) -> u64 {
6084 let id = self.alloc_request_id();
6085 tracing::info!(
6086 "http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
6087 self.plugin_name,
6088 url,
6089 target_path,
6090 id
6091 );
6092 let _ = self.command_sender.send(PluginCommand::HttpFetch {
6093 url,
6094 target_path: std::path::PathBuf::from(target_path),
6095 callback_id: JsCallbackId::new(id),
6096 });
6097 id
6098 }
6099
6100 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
6102 #[qjs(rename = "_spawnProcessWaitStart")]
6103 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
6104 let id = self.alloc_request_id();
6105 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
6106 process_id,
6107 callback_id: JsCallbackId::new(id),
6108 });
6109 id
6110 }
6111
6112 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
6114 #[qjs(rename = "_getBufferTextStart")]
6115 pub fn get_buffer_text_start(
6116 &self,
6117 _ctx: rquickjs::Ctx<'_>,
6118 buffer_id: u32,
6119 start: u32,
6120 end: u32,
6121 ) -> u64 {
6122 let id = self.alloc_request_id();
6123 let _ = self.command_sender.send(PluginCommand::GetBufferText {
6124 buffer_id: BufferId(buffer_id as usize),
6125 start: start as usize,
6126 end: end as usize,
6127 request_id: id,
6128 });
6129 id
6130 }
6131
6132 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
6134 #[qjs(rename = "_delayStart")]
6135 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
6136 let id = self.alloc_request_id();
6137 let _ = self.command_sender.send(PluginCommand::Delay {
6138 callback_id: JsCallbackId::new(id),
6139 duration_ms,
6140 });
6141 id
6142 }
6143
6144 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
6148 #[qjs(rename = "_grepProjectStart")]
6149 pub fn grep_project_start(
6150 &self,
6151 _ctx: rquickjs::Ctx<'_>,
6152 pattern: String,
6153 fixed_string: Option<bool>,
6154 case_sensitive: Option<bool>,
6155 max_results: Option<u32>,
6156 whole_words: Option<bool>,
6157 ) -> u64 {
6158 let id = self.alloc_request_id();
6159 let _ = self.command_sender.send(PluginCommand::GrepProject {
6160 pattern,
6161 fixed_string: fixed_string.unwrap_or(true),
6162 case_sensitive: case_sensitive.unwrap_or(true),
6163 max_results: max_results.unwrap_or(200) as usize,
6164 whole_words: whole_words.unwrap_or(false),
6165 callback_id: JsCallbackId::new(id),
6166 });
6167 id
6168 }
6169
6170 #[plugin_api(
6175 js_name = "beginSearch",
6176 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean; sourceBufferId?: number }): SearchHandle"
6177 )]
6178 #[qjs(rename = "_beginSearch")]
6179 pub fn begin_search(
6180 &self,
6181 _ctx: rquickjs::Ctx<'_>,
6182 pattern: String,
6183 fixed_string: bool,
6184 case_sensitive: bool,
6185 max_results: u32,
6186 whole_words: bool,
6187 source_buffer_id: u32,
6188 ) -> u64 {
6189 let id = self.alloc_request_id();
6190 let entry = Arc::new(SearchHandleState::new());
6193 if let Ok(mut map) = self.search_handles.lock() {
6194 map.insert(id, entry);
6195 }
6196 let _ = self.command_sender.send(PluginCommand::BeginSearch {
6197 pattern,
6198 fixed_string,
6199 case_sensitive,
6200 max_results: max_results as usize,
6201 whole_words,
6202 source_buffer_id: source_buffer_id as usize,
6203 handle_id: id,
6204 });
6205 id
6206 }
6207
6208 #[plugin_api(ts_return = "SearchTakeResult")]
6213 #[qjs(rename = "_searchHandleTake")]
6214 pub fn search_handle_take<'js>(
6215 &self,
6216 ctx: rquickjs::Ctx<'js>,
6217 handle_id: u64,
6218 ) -> rquickjs::Result<Value<'js>> {
6219 let entry = self
6220 .search_handles
6221 .lock()
6222 .ok()
6223 .and_then(|m| m.get(&handle_id).cloned());
6224 let result = match entry {
6225 Some(handle) => {
6226 let mut state = match handle.state.lock() {
6228 Ok(s) => s,
6229 Err(poisoned) => poisoned.into_inner(),
6230 };
6231 let matches = std::mem::take(&mut state.pending);
6232 let snapshot = SearchTakeResult {
6233 matches,
6234 done: state.done,
6235 total_seen: state.total_seen,
6236 truncated: state.truncated,
6237 error: state.error.clone(),
6238 };
6239 let done = snapshot.done;
6240 drop(state);
6241 if done {
6242 if let Ok(mut map) = self.search_handles.lock() {
6243 map.remove(&handle_id);
6244 }
6245 }
6246 snapshot
6247 }
6248 None => SearchTakeResult {
6249 matches: Vec::new(),
6250 done: true,
6251 total_seen: 0,
6252 truncated: false,
6253 error: None,
6254 },
6255 };
6256 rquickjs_serde::to_value(ctx, &result)
6257 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
6258 }
6259
6260 #[qjs(rename = "_searchHandleCancel")]
6263 pub fn search_handle_cancel(&self, handle_id: u64) {
6264 if let Ok(map) = self.search_handles.lock() {
6265 if let Some(entry) = map.get(&handle_id) {
6266 entry
6267 .cancel
6268 .store(true, std::sync::atomic::Ordering::Relaxed);
6269 }
6270 }
6271 }
6272
6273 #[plugin_api(
6277 async_promise,
6278 js_name = "replaceInFile",
6279 ts_raw = "replaceInFile(filePath: string, matches: number[][], replacement: string, bufferId?: number): Promise<ReplaceResult>"
6280 )]
6281 #[qjs(rename = "_replaceInFileStart")]
6282 pub fn replace_in_file_start(
6283 &self,
6284 _ctx: rquickjs::Ctx<'_>,
6285 file_path: String,
6286 matches: Vec<Vec<u32>>,
6287 replacement: String,
6288 buffer_id: rquickjs::function::Opt<u32>,
6289 ) -> u64 {
6290 let id = self.alloc_request_id();
6291 let match_pairs: Vec<(usize, usize)> = matches
6293 .iter()
6294 .map(|m| (m[0] as usize, m[1] as usize))
6295 .collect();
6296 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
6297 file_path: PathBuf::from(file_path),
6298 buffer_id: buffer_id.0.unwrap_or(0) as usize,
6299 matches: match_pairs,
6300 replacement,
6301 callback_id: JsCallbackId::new(id),
6302 });
6303 id
6304 }
6305
6306 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
6308 #[qjs(rename = "_sendLspRequestStart")]
6309 pub fn send_lsp_request_start<'js>(
6310 &self,
6311 ctx: rquickjs::Ctx<'js>,
6312 language: String,
6313 method: String,
6314 params: Option<rquickjs::Object<'js>>,
6315 ) -> rquickjs::Result<u64> {
6316 let id = self.alloc_request_id();
6317 let params_json: Option<serde_json::Value> = params.map(|obj| {
6319 let val = obj.into_value();
6320 js_to_json(&ctx, val)
6321 });
6322 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
6323 request_id: id,
6324 language,
6325 method,
6326 params: params_json,
6327 });
6328 Ok(id)
6329 }
6330
6331 #[plugin_api(
6333 async_thenable,
6334 js_name = "spawnBackgroundProcess",
6335 ts_return = "BackgroundProcessResult"
6336 )]
6337 #[qjs(rename = "_spawnBackgroundProcessStart")]
6338 pub fn spawn_background_process_start(
6339 &self,
6340 _ctx: rquickjs::Ctx<'_>,
6341 command: String,
6342 args: Vec<String>,
6343 cwd: rquickjs::function::Opt<String>,
6344 ) -> u64 {
6345 let id = self.alloc_request_id();
6346 let process_id = id;
6348 self.plugin_tracked_state
6350 .borrow_mut()
6351 .entry(self.plugin_name.clone())
6352 .or_default()
6353 .background_process_ids
6354 .push(process_id);
6355 let _ = self
6357 .command_sender
6358 .send(PluginCommand::SpawnBackgroundProcess {
6359 process_id,
6360 command,
6361 args,
6362 cwd: cwd.0.filter(|s| !s.is_empty()),
6363 callback_id: JsCallbackId::new(id),
6364 });
6365 id
6366 }
6367
6368 pub fn kill_background_process(&self, process_id: u64) -> bool {
6370 self.command_sender
6371 .send(PluginCommand::KillBackgroundProcess { process_id })
6372 .is_ok()
6373 }
6374
6375 #[plugin_api(
6379 async_promise,
6380 js_name = "createTerminal",
6381 ts_return = "TerminalResult"
6382 )]
6383 #[qjs(rename = "_createTerminalStart")]
6384 pub fn create_terminal_start(
6385 &self,
6386 _ctx: rquickjs::Ctx<'_>,
6387 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
6388 ) -> rquickjs::Result<u64> {
6389 let id = self.alloc_request_id();
6390
6391 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
6392 cwd: None,
6393 direction: None,
6394 ratio: None,
6395 focus: None,
6396 persistent: None,
6397 window_id: None,
6398 command: None,
6399 title: None,
6400 });
6401
6402 if let Ok(mut owners) = self.async_resource_owners.lock() {
6404 owners.insert(id, self.plugin_name.clone());
6405 }
6406 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
6407 cwd: opts.cwd,
6408 direction: opts.direction,
6409 ratio: opts.ratio,
6410 focus: opts.focus,
6411 window_id: opts.window_id,
6412 persistent: opts.persistent.unwrap_or(false),
6416 command: opts.command,
6417 title: opts.title,
6418 request_id: id,
6419 });
6420 Ok(id)
6421 }
6422
6423 #[plugin_api(
6429 async_promise,
6430 js_name = "createWindowWithTerminal",
6431 ts_return = "SessionWithTerminalResult"
6432 )]
6433 #[qjs(rename = "_createWindowWithTerminalStart")]
6434 pub fn create_window_with_terminal_start(
6435 &self,
6436 _ctx: rquickjs::Ctx<'_>,
6437 opts: fresh_core::api::CreateWindowWithTerminalOptions,
6438 ) -> rquickjs::Result<u64> {
6439 let id = self.alloc_request_id();
6440 if let Ok(mut owners) = self.async_resource_owners.lock() {
6441 owners.insert(id, self.plugin_name.clone());
6442 }
6443 let _ = self
6444 .command_sender
6445 .send(PluginCommand::CreateWindowWithTerminal {
6446 root: std::path::PathBuf::from(opts.root),
6447 label: opts.label,
6448 cwd: opts.cwd,
6449 command: opts.command,
6450 title: opts.title,
6451 resume: opts.resume,
6452 request_id: id,
6453 });
6454 Ok(id)
6455 }
6456
6457 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
6459 self.command_sender
6460 .send(PluginCommand::SendTerminalInput {
6461 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6462 data,
6463 })
6464 .is_ok()
6465 }
6466
6467 pub fn close_terminal(&self, terminal_id: u64) -> bool {
6469 self.command_sender
6470 .send(PluginCommand::CloseTerminal {
6471 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6472 })
6473 .is_ok()
6474 }
6475
6476 pub fn signal_window(&self, id: f64, signal: String) -> bool {
6483 self.command_sender
6484 .send(PluginCommand::SignalWindow {
6485 id: fresh_core::WindowId(id as u64),
6486 signal,
6487 })
6488 .is_ok()
6489 }
6490
6491 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
6495 self.command_sender
6496 .send(PluginCommand::RefreshLines {
6497 buffer_id: BufferId(buffer_id as usize),
6498 })
6499 .is_ok()
6500 }
6501
6502 pub fn get_current_locale(&self) -> String {
6504 self.services.current_locale()
6505 }
6506
6507 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
6511 #[qjs(rename = "_loadPluginStart")]
6512 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
6513 let id = self.alloc_request_id();
6514 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
6515 path: std::path::PathBuf::from(path),
6516 callback_id: JsCallbackId::new(id),
6517 });
6518 id
6519 }
6520
6521 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
6523 #[qjs(rename = "_unloadPluginStart")]
6524 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6525 let id = self.alloc_request_id();
6526 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
6527 name,
6528 callback_id: JsCallbackId::new(id),
6529 });
6530 id
6531 }
6532
6533 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6535 #[qjs(rename = "_reloadPluginStart")]
6536 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6537 let id = self.alloc_request_id();
6538 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6539 name,
6540 callback_id: JsCallbackId::new(id),
6541 });
6542 id
6543 }
6544
6545 #[plugin_api(
6548 async_promise,
6549 js_name = "listPlugins",
6550 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6551 )]
6552 #[qjs(rename = "_listPluginsStart")]
6553 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6554 let id = self.alloc_request_id();
6555 let _ = self.command_sender.send(PluginCommand::ListPlugins {
6556 callback_id: JsCallbackId::new(id),
6557 });
6558 id
6559 }
6560}
6561
6562fn parse_view_token(
6569 obj: &rquickjs::Object<'_>,
6570 idx: usize,
6571) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6572 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6573
6574 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6576 from: "object",
6577 to: "ViewTokenWire",
6578 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6579 })?;
6580
6581 let source_offset: Option<usize> = obj
6583 .get("sourceOffset")
6584 .ok()
6585 .or_else(|| obj.get("source_offset").ok());
6586
6587 let kind = if kind_value.is_string() {
6589 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6592 from: "value",
6593 to: "string",
6594 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6595 })?;
6596
6597 match kind_str.to_lowercase().as_str() {
6598 "text" => {
6599 let text: String = obj.get("text").unwrap_or_default();
6600 ViewTokenWireKind::Text(text)
6601 }
6602 "newline" => ViewTokenWireKind::Newline,
6603 "space" => ViewTokenWireKind::Space,
6604 "break" => ViewTokenWireKind::Break,
6605 _ => {
6606 tracing::warn!(
6608 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6609 idx, kind_str
6610 );
6611 return Err(rquickjs::Error::FromJs {
6612 from: "string",
6613 to: "ViewTokenWireKind",
6614 message: Some(format!(
6615 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6616 idx, kind_str
6617 )),
6618 });
6619 }
6620 }
6621 } else if kind_value.is_object() {
6622 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6624 from: "value",
6625 to: "object",
6626 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6627 })?;
6628
6629 if let Ok(text) = kind_obj.get::<_, String>("Text") {
6630 ViewTokenWireKind::Text(text)
6631 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6632 ViewTokenWireKind::BinaryByte(byte)
6633 } else {
6634 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6636 tracing::warn!(
6637 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6638 idx,
6639 keys
6640 );
6641 return Err(rquickjs::Error::FromJs {
6642 from: "object",
6643 to: "ViewTokenWireKind",
6644 message: Some(format!(
6645 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6646 idx, keys
6647 )),
6648 });
6649 }
6650 } else {
6651 tracing::warn!(
6652 "token[{}]: 'kind' field must be a string or object, got: {:?}",
6653 idx,
6654 kind_value.type_of()
6655 );
6656 return Err(rquickjs::Error::FromJs {
6657 from: "value",
6658 to: "ViewTokenWireKind",
6659 message: Some(format!(
6660 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6661 idx
6662 )),
6663 });
6664 };
6665
6666 let style = parse_view_token_style(obj, idx)?;
6668
6669 Ok(ViewTokenWire {
6670 source_offset,
6671 kind,
6672 style,
6673 })
6674}
6675
6676fn parse_view_token_style(
6678 obj: &rquickjs::Object<'_>,
6679 idx: usize,
6680) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6681 use fresh_core::api::{TokenColor, ViewTokenStyle};
6682
6683 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6684 let Some(s) = style_obj else {
6685 return Ok(None);
6686 };
6687
6688 fn parse_color(
6693 s: &rquickjs::Object<'_>,
6694 field: &str,
6695 idx: usize,
6696 ) -> rquickjs::Result<Option<TokenColor>> {
6697 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6698 if arr.len() < 3 {
6699 tracing::warn!(
6700 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6701 idx,
6702 field,
6703 arr.len()
6704 );
6705 return Ok(None);
6706 }
6707 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6708 }
6709 if let Ok(name) = s.get::<_, String>(field) {
6710 return Ok(Some(TokenColor::Named(name)));
6711 }
6712 Ok(None)
6713 }
6714
6715 Ok(Some(ViewTokenStyle {
6716 fg: parse_color(&s, "fg", idx)?,
6717 bg: parse_color(&s, "bg", idx)?,
6718 bold: s.get("bold").unwrap_or(false),
6719 italic: s.get("italic").unwrap_or(false),
6720 underline: s.get("underline").unwrap_or(false),
6721 }))
6722}
6723
6724pub struct QuickJsBackend {
6726 runtime: Runtime,
6727 main_context: Context,
6729 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6731 event_handlers: EventHandlerRegistry,
6735 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6737 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6739 command_sender: mpsc::Sender<PluginCommand>,
6741 #[allow(dead_code)]
6743 pending_responses: PendingResponses,
6744 next_request_id: Rc<RefCell<u64>>,
6746 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6748 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6750 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6752 async_resource_owners: AsyncResourceOwners,
6755 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6757 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6759 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6761 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6763 plugin_api_exports: PluginApiExports,
6767 search_handles: SearchHandleRegistry,
6769}
6770
6771impl Drop for QuickJsBackend {
6772 fn drop(&mut self) {
6773 self.plugin_api_exports.borrow_mut().clear();
6779 }
6780}
6781
6782const EDITOR_GLOBALS_BOOTSTRAP: &str = "globalThis.getEditor = function() { return editor; };\nglobalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };";
6785
6786const EDITOR_ON_OFF_SHIM: &str = r#"
6792 (function() {
6793 const originalOn = editor.on.bind(editor);
6794 const originalOff = editor.off.bind(editor);
6795 let counter = 0;
6796 const anonNames = new WeakMap();
6797 editor.on = function(eventName, handlerOrName) {
6798 if (typeof handlerOrName === 'function') {
6799 const existing = anonNames.get(handlerOrName);
6800 const name = existing || `__anon_on_${++counter}`;
6801 if (!existing) {
6802 anonNames.set(handlerOrName, name);
6803 }
6804 globalThis[name] = handlerOrName;
6805 return originalOn(eventName, name);
6806 }
6807 return originalOn(eventName, handlerOrName);
6808 };
6809 editor.off = function(eventName, handlerOrName) {
6810 if (typeof handlerOrName === 'function') {
6811 const name = anonNames.get(handlerOrName);
6812 if (name === undefined) return false;
6813 return originalOff(eventName, name);
6814 }
6815 return originalOff(eventName, handlerOrName);
6816 };
6817 })();
6818 "#;
6819
6820const EDITOR_PROMISE_BOOTSTRAP: &str = r#"
6824 // Pending promise callbacks: callbackId -> { resolve, reject }
6825 globalThis._pendingCallbacks = new Map();
6826
6827 // Resolve a pending callback (called from Rust)
6828 globalThis._resolveCallback = function(callbackId, result) {
6829 // No per-resolve logging here: this fires once per async op
6830 // completion (potentially at very high frequency), and
6831 // console.log is captured into the host log, so logging here
6832 // floods the log and can feed a tail-driven feedback loop.
6833 const cb = globalThis._pendingCallbacks.get(callbackId);
6834 if (cb) {
6835 globalThis._pendingCallbacks.delete(callbackId);
6836 cb.resolve(result);
6837 }
6838 };
6839
6840 // Reject a pending callback (called from Rust)
6841 globalThis._rejectCallback = function(callbackId, error) {
6842 const cb = globalThis._pendingCallbacks.get(callbackId);
6843 if (cb) {
6844 globalThis._pendingCallbacks.delete(callbackId);
6845 cb.reject(new Error(error));
6846 }
6847 };
6848
6849 // Generic async wrapper decorator
6850 // Wraps a function that returns a callbackId into a promise-returning function
6851 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6852 // NOTE: We pass the method name as a string and call via bracket notation
6853 // to preserve rquickjs's automatic Ctx injection for methods
6854 globalThis._wrapAsync = function(methodName, fnName) {
6855 const startFn = editor[methodName];
6856 if (typeof startFn !== 'function') {
6857 // Return a function that always throws - catches missing implementations
6858 return function(...args) {
6859 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6860 editor.debug(`[ASYNC ERROR] ${error.message}`);
6861 throw error;
6862 };
6863 }
6864 return function(...args) {
6865 // Call via bracket notation to preserve method binding and Ctx injection
6866 const callbackId = editor[methodName](...args);
6867 return new Promise((resolve, reject) => {
6868 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6869 // TODO: Implement setTimeout polyfill using editor.delay() or similar
6870 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6871 });
6872 };
6873 };
6874
6875 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6876 // The returned object has .result promise and is itself thenable
6877 globalThis._wrapAsyncThenable = function(methodName, fnName) {
6878 const startFn = editor[methodName];
6879 if (typeof startFn !== 'function') {
6880 // Return a function that always throws - catches missing implementations
6881 return function(...args) {
6882 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6883 editor.debug(`[ASYNC ERROR] ${error.message}`);
6884 throw error;
6885 };
6886 }
6887 return function(...args) {
6888 // Call via bracket notation to preserve method binding and Ctx injection
6889 const callbackId = editor[methodName](...args);
6890 const resultPromise = new Promise((resolve, reject) => {
6891 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6892 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6893 });
6894 return {
6895 get result() { return resultPromise; },
6896 then(onFulfilled, onRejected) {
6897 return resultPromise.then(onFulfilled, onRejected);
6898 },
6899 catch(onRejected) {
6900 return resultPromise.catch(onRejected);
6901 }
6902 };
6903 };
6904 };
6905
6906 // Apply wrappers to async functions on editor
6907 // spawnProcess accepts either form for the 4th arg:
6908 // editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
6909 // editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
6910 // The first matches the auto-generated TS signature
6911 // (flat positional from the Rust binding's `Opt<String>`
6912 // args); the second is the structured options form
6913 // plugin authors often prefer.
6914 editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
6915 if (typeof editor._spawnProcessStart !== 'function') {
6916 throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
6917 }
6918 // The 3rd arg is either cwd (string) or an options
6919 // object when cwd is omitted; the 4th is either a
6920 // stdoutTo string or an options object.
6921 let cwd = "";
6922 let stdoutTo = "";
6923 if (typeof cwdOrOpts === "string") {
6924 cwd = cwdOrOpts;
6925 } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
6926 if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
6927 }
6928 if (typeof fourth === "string") {
6929 stdoutTo = fourth;
6930 } else if (fourth && typeof fourth === "object") {
6931 if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
6932 }
6933 const callbackId = editor._spawnProcessStart(
6934 command,
6935 argsArr || [],
6936 cwd,
6937 stdoutTo,
6938 );
6939 const resultPromise = new Promise((resolve, reject) => {
6940 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6941 });
6942 return {
6943 get result() { return resultPromise; },
6944 // `kill()` cancels a still-running spawn. The
6945 // dispatcher stores a oneshot keyed by callbackId;
6946 // _killHostProcess fires it and the spawner's
6947 // tokio::select! kills the child. No-op if the
6948 // child already exited (id removed from the map).
6949 kill() {
6950 if (typeof editor._killHostProcess === 'function') {
6951 return editor._killHostProcess(callbackId);
6952 }
6953 return false;
6954 },
6955 then(onFulfilled, onRejected) {
6956 return resultPromise.then(onFulfilled, onRejected);
6957 },
6958 catch(onRejected) {
6959 return resultPromise.catch(onRejected);
6960 }
6961 };
6962 };
6963 // spawnHostProcess gets a bespoke wrapper (instead of
6964 // `_wrapAsyncThenable`) because its `ProcessHandle`
6965 // exposes a real `kill()` that forwards to
6966 // `_killHostProcess`. Generic wrap has no hook for
6967 // that.
6968 editor.spawnHostProcess = function(command, args, cwd) {
6969 if (typeof editor._spawnHostProcessStart !== 'function') {
6970 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
6971 }
6972 // Pass real strings only. Earlier revisions forwarded
6973 // `""` for a missing cwd, which landed verbatim as
6974 // `Command::current_dir("")` in the dispatcher —
6975 // every host-spawn then failed with ENOENT. Use two
6976 // arity forms so the Rust `Opt<String>` stays `None`
6977 // instead of `Some("")`.
6978 let callbackId;
6979 if (typeof cwd === "string" && cwd.length > 0) {
6980 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
6981 } else {
6982 callbackId = editor._spawnHostProcessStart(command, args || []);
6983 }
6984 const resultPromise = new Promise(function(resolve, reject) {
6985 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
6986 });
6987 return {
6988 processId: callbackId,
6989 get result() { return resultPromise; },
6990 then: function(f, r) { return resultPromise.then(f, r); },
6991 catch: function(r) { return resultPromise.catch(r); },
6992 kill: function() {
6993 // Returns true when the kill was enqueued
6994 // (the process may have already exited; in
6995 // that case the dispatcher silently
6996 // drops it). Matches the
6997 // `ProcessHandle.kill(): Promise<boolean>`
6998 // type signature by wrapping the sync
6999 // boolean in a Promise.
7000 return Promise.resolve(editor._killHostProcess(callbackId));
7001 }
7002 };
7003 };
7004 editor.delay = _wrapAsync("_delayStart", "delay");
7005 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
7006 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
7007 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
7008 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
7009 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
7010 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
7011 editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
7012 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
7013 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
7014 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
7015 editor.getCompositeCursorInfo = _wrapAsync("_getCompositeCursorInfoStart", "getCompositeCursorInfo");
7016 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
7017 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
7018 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
7019 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
7020 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
7021 editor.prompt = _wrapAsync("_promptStart", "prompt");
7022 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
7023 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
7024 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
7025 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
7026 editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
7027 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
7028 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
7029 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
7030 editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
7031 editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
7032 editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
7033 editor.attachRemoteAgent = _wrapAsync("_attachRemoteAgentStart", "attachRemoteAgent");
7034
7035 // Pull-based streaming search. Producers (host searcher tasks)
7036 // write into shared state at full speed; the consumer drains
7037 // it via take() at its own cadence — no per-chunk JS dispatch.
7038 editor.beginSearch = function(pattern, opts) {
7039 opts = opts || {};
7040 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
7041 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
7042 const maxResults = opts.maxResults || 10000;
7043 const wholeWords = opts.wholeWords || false;
7044 const sourceBufferId = opts.sourceBufferId || 0;
7045 const handleId = editor._beginSearch(
7046 pattern, fixedString, caseSensitive, maxResults, wholeWords, sourceBufferId
7047 );
7048 return {
7049 searchId: handleId,
7050 take: function() { return editor._searchHandleTake(handleId); },
7051 cancel: function() { editor._searchHandleCancel(handleId); }
7052 };
7053 };
7054
7055 // Wrapper for deleteTheme - wraps sync function in Promise
7056 editor.deleteTheme = function(name) {
7057 return new Promise(function(resolve, reject) {
7058 const success = editor._deleteThemeSync(name);
7059 if (success) {
7060 resolve();
7061 } else {
7062 reject(new Error("Failed to delete theme: " + name));
7063 }
7064 });
7065 };
7066 "#;
7067
7068fn install_console<'js>(
7071 ctx: &rquickjs::Ctx<'js>,
7072 globals: &rquickjs::Object<'js>,
7073) -> rquickjs::Result<()> {
7074 let console = Object::new(ctx.clone())?;
7075 console.set(
7076 "log",
7077 Function::new(
7078 ctx.clone(),
7079 |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
7080 let parts: Vec<String> =
7081 args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
7082 tracing::info!("console.log: {}", parts.join(" "));
7083 },
7084 )?,
7085 )?;
7086 console.set(
7087 "warn",
7088 Function::new(
7089 ctx.clone(),
7090 |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
7091 let parts: Vec<String> =
7092 args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
7093 tracing::warn!("console.warn: {}", parts.join(" "));
7094 },
7095 )?,
7096 )?;
7097 console.set(
7098 "error",
7099 Function::new(
7100 ctx.clone(),
7101 |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
7102 let parts: Vec<String> =
7103 args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
7104 tracing::error!("console.error: {}", parts.join(" "));
7105 },
7106 )?,
7107 )?;
7108 globals.set("console", console)?;
7109 Ok(())
7110}
7111
7112impl QuickJsBackend {
7113 pub fn new() -> Result<Self> {
7115 let (tx, _rx) = mpsc::channel();
7116 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7117 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7118 Self::with_state(state_snapshot, tx, services)
7119 }
7120
7121 pub fn with_state(
7123 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
7124 command_sender: mpsc::Sender<PluginCommand>,
7125 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
7126 ) -> Result<Self> {
7127 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
7128 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
7129 }
7130
7131 pub fn with_state_and_responses(
7133 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
7134 command_sender: mpsc::Sender<PluginCommand>,
7135 pending_responses: PendingResponses,
7136 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
7137 ) -> Result<Self> {
7138 let async_resource_owners: AsyncResourceOwners =
7139 Arc::new(std::sync::Mutex::new(HashMap::new()));
7140 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
7141 let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
7142 Self::with_state_responses_and_resources(
7143 state_snapshot,
7144 command_sender,
7145 pending_responses,
7146 services,
7147 async_resource_owners,
7148 search_handles,
7149 event_handlers,
7150 )
7151 }
7152
7153 pub fn with_state_responses_and_resources(
7156 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
7157 command_sender: mpsc::Sender<PluginCommand>,
7158 pending_responses: PendingResponses,
7159 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
7160 async_resource_owners: AsyncResourceOwners,
7161 search_handles: SearchHandleRegistry,
7162 event_handlers: EventHandlerRegistry,
7163 ) -> Result<Self> {
7164 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
7165
7166 let runtime =
7167 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
7168
7169 runtime.set_host_promise_rejection_tracker(Some(Box::new(
7171 |_ctx, _promise, reason, is_handled| {
7172 if !is_handled {
7173 let error_msg = if let Some(exc) = reason.as_exception() {
7175 format!(
7176 "{}: {}",
7177 exc.message().unwrap_or_default(),
7178 exc.stack().unwrap_or_default()
7179 )
7180 } else {
7181 format!("{:?}", reason)
7182 };
7183
7184 tracing::error!("Unhandled Promise rejection: {}", error_msg);
7185
7186 if should_panic_on_js_errors() {
7187 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
7190 set_fatal_js_error(full_msg);
7191 }
7192 }
7193 },
7194 )));
7195
7196 let main_context = Context::full(&runtime)
7197 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
7198
7199 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
7200 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
7201 let next_request_id = Rc::new(RefCell::new(1u64));
7202 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
7203 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
7204 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
7205 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
7206 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
7207 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
7208 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
7209
7210 let backend = Self {
7211 runtime,
7212 main_context,
7213 plugin_contexts,
7214 event_handlers,
7215 registered_actions,
7216 state_snapshot,
7217 command_sender,
7218 pending_responses,
7219 next_request_id,
7220 callback_contexts,
7221 services,
7222 plugin_tracked_state,
7223 async_resource_owners,
7224 registered_command_names,
7225 registered_grammar_languages,
7226 registered_language_configs,
7227 registered_lsp_servers,
7228 plugin_api_exports,
7229 search_handles,
7230 };
7231
7232 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
7234
7235 tracing::debug!("QuickJsBackend::new: runtime created successfully");
7236 Ok(backend)
7237 }
7238
7239 fn build_editor_api(&self, plugin_name: &str) -> JsEditorApi {
7243 JsEditorApi {
7244 state_snapshot: Arc::clone(&self.state_snapshot),
7245 command_sender: self.command_sender.clone(),
7246 registered_actions: Rc::clone(&self.registered_actions),
7247 event_handlers: Arc::clone(&self.event_handlers),
7248 next_request_id: Rc::clone(&self.next_request_id),
7249 callback_contexts: Rc::clone(&self.callback_contexts),
7250 services: self.services.clone(),
7251 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
7252 async_resource_owners: Arc::clone(&self.async_resource_owners),
7253 registered_command_names: Rc::clone(&self.registered_command_names),
7254 registered_grammar_languages: Rc::clone(&self.registered_grammar_languages),
7255 registered_language_configs: Rc::clone(&self.registered_language_configs),
7256 registered_lsp_servers: Rc::clone(&self.registered_lsp_servers),
7257 plugin_api_exports: Rc::clone(&self.plugin_api_exports),
7258 search_handles: Arc::clone(&self.search_handles),
7259 plugin_name: plugin_name.to_string(),
7260 }
7261 }
7262
7263 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
7264 context
7265 .with(|ctx| {
7266 let globals = ctx.globals();
7267
7268 globals.set("__pluginName__", plugin_name)?;
7270
7271 let editor = rquickjs::Class::<JsEditorApi>::instance(
7275 ctx.clone(),
7276 self.build_editor_api(plugin_name),
7277 )?;
7278 globals.set("editor", editor)?;
7279
7280 ctx.eval::<(), _>(EDITOR_GLOBALS_BOOTSTRAP)?;
7285 ctx.eval::<(), _>(EDITOR_ON_OFF_SHIM)?;
7286 install_console(&ctx, &globals)?;
7287 ctx.eval::<(), _>(EDITOR_PROMISE_BOOTSTRAP.as_bytes())?;
7288
7289 Ok::<_, rquickjs::Error>(())
7290 })
7291 .map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
7292
7293 Ok(())
7294 }
7295
7296 pub async fn load_module_with_source(
7298 &mut self,
7299 path: &str,
7300 _plugin_source: &str,
7301 ) -> Result<()> {
7302 let path_buf = PathBuf::from(path);
7303 let source = std::fs::read_to_string(&path_buf)
7304 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
7305
7306 let filename = path_buf
7307 .file_name()
7308 .and_then(|s| s.to_str())
7309 .unwrap_or("plugin.ts");
7310
7311 if has_es_imports(&source) {
7313 match bundle_module(&path_buf) {
7315 Ok(bundled) => {
7316 self.execute_js(&bundled, path)?;
7317 }
7318 Err(e) => {
7319 tracing::warn!(
7320 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
7321 path,
7322 e
7323 );
7324 return Ok(()); }
7326 }
7327 } else if has_es_module_syntax(&source) {
7328 let stripped = strip_imports_and_exports(&source);
7330 let js_code = if filename.ends_with(".ts") {
7331 transpile_typescript(&stripped, filename)?
7332 } else {
7333 stripped
7334 };
7335 self.execute_js(&js_code, path)?;
7336 } else {
7337 let js_code = if filename.ends_with(".ts") {
7339 transpile_typescript(&source, filename)?
7340 } else {
7341 source
7342 };
7343 self.execute_js(&js_code, path)?;
7344 }
7345
7346 Ok(())
7347 }
7348
7349 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
7351 let plugin_name = Path::new(source_name)
7353 .file_stem()
7354 .and_then(|s| s.to_str())
7355 .unwrap_or("unknown");
7356
7357 tracing::debug!(
7358 "execute_js: starting for plugin '{}' from '{}'",
7359 plugin_name,
7360 source_name
7361 );
7362
7363 let context = {
7365 let mut contexts = self.plugin_contexts.borrow_mut();
7366 if let Some(ctx) = contexts.get(plugin_name) {
7367 ctx.clone()
7368 } else {
7369 let ctx = Context::full(&self.runtime).map_err(|e| {
7370 anyhow!(
7371 "Failed to create QuickJS context for plugin {}: {}",
7372 plugin_name,
7373 e
7374 )
7375 })?;
7376 self.setup_context_api(&ctx, plugin_name)?;
7377 contexts.insert(plugin_name.to_string(), ctx.clone());
7378 ctx
7379 }
7380 };
7381
7382 let wrapped_code = format!("(function() {{ {} }})();", code);
7386 let wrapped = wrapped_code.as_str();
7387
7388 context.with(|ctx| {
7389 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
7390
7391 let mut eval_options = rquickjs::context::EvalOptions::default();
7393 eval_options.global = true;
7394 eval_options.filename = Some(source_name.to_string());
7395 let result = ctx
7396 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
7397 .map_err(|e| format_js_error(&ctx, e, source_name));
7398
7399 tracing::debug!(
7400 "execute_js: plugin code execution finished for '{}', result: {:?}",
7401 plugin_name,
7402 result.is_ok()
7403 );
7404
7405 result
7406 })
7407 }
7408
7409 pub fn execute_source(
7415 &mut self,
7416 source: &str,
7417 plugin_name: &str,
7418 is_typescript: bool,
7419 ) -> Result<()> {
7420 use fresh_parser_js::{
7421 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
7422 };
7423
7424 if has_es_imports(source) {
7425 tracing::warn!(
7426 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
7427 plugin_name
7428 );
7429 }
7430
7431 let js_code = if has_es_module_syntax(source) {
7432 let stripped = strip_imports_and_exports(source);
7433 if is_typescript {
7434 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
7435 } else {
7436 stripped
7437 }
7438 } else if is_typescript {
7439 transpile_typescript(source, &format!("{}.ts", plugin_name))?
7440 } else {
7441 source.to_string()
7442 };
7443
7444 let source_name = format!(
7446 "{}.{}",
7447 plugin_name,
7448 if is_typescript { "ts" } else { "js" }
7449 );
7450 self.execute_js(&js_code, &source_name)
7451 }
7452
7453 pub fn cleanup_plugin(&self, plugin_name: &str) {
7459 self.plugin_contexts.borrow_mut().remove(plugin_name);
7461
7462 {
7464 let mut handlers_map = self
7465 .event_handlers
7466 .write()
7467 .expect("event_handlers poisoned");
7468 for handlers in handlers_map.values_mut() {
7469 handlers.retain(|h| h.plugin_name != plugin_name);
7470 }
7471 handlers_map.retain(|_, list| !list.is_empty());
7475 }
7476
7477 self.registered_actions
7479 .borrow_mut()
7480 .retain(|_, h| h.plugin_name != plugin_name);
7481
7482 self.callback_contexts
7484 .borrow_mut()
7485 .retain(|_, pname| pname != plugin_name);
7486
7487 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
7489 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
7491 std::collections::HashSet::new();
7492 for (buf_id, ns) in &tracked.overlay_namespaces {
7493 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
7494 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
7496 buffer_id: *buf_id,
7497 namespace: OverlayNamespace::from_string(ns.clone()),
7498 });
7499 let _ = self
7501 .command_sender
7502 .send(PluginCommand::ClearConcealNamespace {
7503 buffer_id: *buf_id,
7504 namespace: OverlayNamespace::from_string(ns.clone()),
7505 });
7506 let _ = self
7507 .command_sender
7508 .send(PluginCommand::ClearSoftBreakNamespace {
7509 buffer_id: *buf_id,
7510 namespace: OverlayNamespace::from_string(ns.clone()),
7511 });
7512 }
7513 }
7514
7515 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
7521 std::collections::HashSet::new();
7522 for (buf_id, ns) in &tracked.line_indicator_namespaces {
7523 if seen_li_ns.insert((buf_id.0, ns.clone())) {
7524 let _ = self
7525 .command_sender
7526 .send(PluginCommand::ClearLineIndicators {
7527 buffer_id: *buf_id,
7528 namespace: ns.clone(),
7529 });
7530 }
7531 }
7532
7533 let mut seen_vt: std::collections::HashSet<(usize, String)> =
7535 std::collections::HashSet::new();
7536 for (buf_id, vt_id) in &tracked.virtual_text_ids {
7537 if seen_vt.insert((buf_id.0, vt_id.clone())) {
7538 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
7539 buffer_id: *buf_id,
7540 virtual_text_id: vt_id.clone(),
7541 });
7542 }
7543 }
7544
7545 let mut seen_fe_ns: std::collections::HashSet<String> =
7547 std::collections::HashSet::new();
7548 for ns in &tracked.file_explorer_namespaces {
7549 if seen_fe_ns.insert(ns.clone()) {
7550 let _ = self
7551 .command_sender
7552 .send(PluginCommand::ClearFileExplorerDecorations {
7553 namespace: ns.clone(),
7554 });
7555 let _ = self
7556 .command_sender
7557 .send(PluginCommand::ClearFileExplorerSlots {
7558 namespace: ns.clone(),
7559 });
7560 }
7561 }
7562
7563 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
7565 for ctx_name in &tracked.contexts_set {
7566 if seen_ctx.insert(ctx_name.clone()) {
7567 let _ = self.command_sender.send(PluginCommand::SetContext {
7568 name: ctx_name.clone(),
7569 active: false,
7570 });
7571 }
7572 }
7573
7574 for process_id in &tracked.background_process_ids {
7578 let _ = self
7579 .command_sender
7580 .send(PluginCommand::KillBackgroundProcess {
7581 process_id: *process_id,
7582 });
7583 }
7584
7585 for group_id in &tracked.scroll_sync_group_ids {
7587 let _ = self
7588 .command_sender
7589 .send(PluginCommand::RemoveScrollSyncGroup {
7590 group_id: *group_id,
7591 });
7592 }
7593
7594 for buffer_id in &tracked.virtual_buffer_ids {
7596 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7597 buffer_id: *buffer_id,
7598 });
7599 }
7600
7601 for buffer_id in &tracked.composite_buffer_ids {
7603 let _ = self
7604 .command_sender
7605 .send(PluginCommand::CloseCompositeBuffer {
7606 buffer_id: *buffer_id,
7607 });
7608 }
7609
7610 for terminal_id in &tracked.terminal_ids {
7612 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7613 terminal_id: *terminal_id,
7614 });
7615 }
7616
7617 for handle in &tracked.watch_handles {
7621 let _ = self
7622 .command_sender
7623 .send(PluginCommand::UnwatchPath { handle: *handle });
7624 }
7625 }
7626
7627 if let Ok(mut owners) = self.async_resource_owners.lock() {
7629 owners.retain(|_, name| name != plugin_name);
7630 }
7631
7632 self.plugin_api_exports
7634 .borrow_mut()
7635 .retain(|_, (exporter, _)| exporter != plugin_name);
7636
7637 self.registered_command_names
7639 .borrow_mut()
7640 .retain(|_, pname| pname != plugin_name);
7641 self.registered_grammar_languages
7642 .borrow_mut()
7643 .retain(|_, pname| pname != plugin_name);
7644 self.registered_language_configs
7645 .borrow_mut()
7646 .retain(|_, pname| pname != plugin_name);
7647 self.registered_lsp_servers
7648 .borrow_mut()
7649 .retain(|_, pname| pname != plugin_name);
7650
7651 tracing::debug!(
7652 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7653 plugin_name
7654 );
7655 }
7656
7657 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7659 self.emit_to(event_name, event_data, None).await
7660 }
7661
7662 pub async fn emit_to(
7666 &mut self,
7667 event_name: &str,
7668 event_data: &serde_json::Value,
7669 target: Option<&str>,
7670 ) -> Result<bool> {
7671 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7672
7673 self.services
7674 .set_js_execution_state(format!("hook '{}'", event_name));
7675
7676 let handlers = self
7677 .event_handlers
7678 .read()
7679 .expect("event_handlers poisoned")
7680 .get(event_name)
7681 .cloned();
7682 if let Some(handler_pairs) = handlers {
7683 let plugin_contexts = self.plugin_contexts.borrow();
7684 for handler in &handler_pairs {
7685 if target.is_some_and(|t| t != handler.plugin_name) {
7686 continue;
7687 }
7688 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7689 continue;
7690 };
7691 context.with(|ctx| {
7692 call_handler(&ctx, &handler.handler_name, event_data);
7693 });
7694 }
7695 }
7696
7697 self.services.clear_js_execution_state();
7698 Ok(true)
7699 }
7700
7701 pub fn has_handlers(&self, event_name: &str) -> bool {
7703 self.event_handlers
7704 .read()
7705 .expect("event_handlers poisoned")
7706 .get(event_name)
7707 .map(|v| !v.is_empty())
7708 .unwrap_or(false)
7709 }
7710
7711 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7715 let (lookup_name, text_input_char) =
7718 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7719 ("mode_text_input", Some(ch.to_string()))
7720 } else {
7721 (action_name, None)
7722 };
7723
7724 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7725 let (plugin_name, function_name) = match pair {
7726 Some(handler) => (handler.plugin_name, handler.handler_name),
7727 None => ("main".to_string(), lookup_name.to_string()),
7728 };
7729
7730 let plugin_contexts = self.plugin_contexts.borrow();
7731 let context = plugin_contexts
7732 .get(&plugin_name)
7733 .unwrap_or(&self.main_context);
7734
7735 self.services
7737 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7738
7739 tracing::info!(
7740 "start_action: BEGIN '{}' -> function '{}'",
7741 action_name,
7742 function_name
7743 );
7744
7745 let call_args = if let Some(ref ch) = text_input_char {
7748 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7749 format!("({{text:\"{}\"}})", escaped)
7750 } else {
7751 "()".to_string()
7752 };
7753
7754 let code = format!(
7755 r#"
7756 (function() {{
7757 console.log('[JS] start_action: calling {fn}');
7758 try {{
7759 if (typeof globalThis.{fn} === 'function') {{
7760 console.log('[JS] start_action: {fn} is a function, invoking...');
7761 globalThis.{fn}{args};
7762 console.log('[JS] start_action: {fn} invoked (may be async)');
7763 }} else {{
7764 console.error('[JS] Action {action} is not defined as a global function');
7765 }}
7766 }} catch (e) {{
7767 console.error('[JS] Action {action} error:', e);
7768 }}
7769 }})();
7770 "#,
7771 fn = function_name,
7772 action = action_name,
7773 args = call_args
7774 );
7775
7776 tracing::info!("start_action: evaluating JS code");
7777 context.with(|ctx| {
7778 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7779 log_js_error(&ctx, e, &format!("action {}", action_name));
7780 }
7781 tracing::info!("start_action: running pending microtasks");
7782 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7784 tracing::info!("start_action: executed {} pending jobs", count);
7785 });
7786
7787 tracing::info!("start_action: END '{}'", action_name);
7788
7789 self.services.clear_js_execution_state();
7791
7792 Ok(())
7793 }
7794
7795 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7797 let pair = self.registered_actions.borrow().get(action_name).cloned();
7799 let (plugin_name, function_name) = match pair {
7800 Some(handler) => (handler.plugin_name, handler.handler_name),
7801 None => ("main".to_string(), action_name.to_string()),
7802 };
7803
7804 let plugin_contexts = self.plugin_contexts.borrow();
7805 let context = plugin_contexts
7806 .get(&plugin_name)
7807 .unwrap_or(&self.main_context);
7808
7809 tracing::debug!(
7810 "execute_action: '{}' -> function '{}'",
7811 action_name,
7812 function_name
7813 );
7814
7815 let code = format!(
7818 r#"
7819 (async function() {{
7820 try {{
7821 if (typeof globalThis.{fn} === 'function') {{
7822 const result = globalThis.{fn}();
7823 // If it's a Promise, await it
7824 if (result && typeof result.then === 'function') {{
7825 await result;
7826 }}
7827 }} else {{
7828 console.error('Action {action} is not defined as a global function');
7829 }}
7830 }} catch (e) {{
7831 console.error('Action {action} error:', e);
7832 }}
7833 }})();
7834 "#,
7835 fn = function_name,
7836 action = action_name
7837 );
7838
7839 context.with(|ctx| {
7840 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7842 Ok(value) => {
7843 if value.is_object() {
7845 if let Some(obj) = value.as_object() {
7846 if obj.get::<_, rquickjs::Function>("then").is_ok() {
7848 run_pending_jobs_checked(
7851 &ctx,
7852 &format!("execute_action {} promise", action_name),
7853 );
7854 }
7855 }
7856 }
7857 }
7858 Err(e) => {
7859 log_js_error(&ctx, e, &format!("action {}", action_name));
7860 }
7861 }
7862 });
7863
7864 Ok(())
7865 }
7866
7867 pub fn poll_event_loop_once(&mut self) -> bool {
7869 let mut had_work = false;
7870
7871 self.main_context.with(|ctx| {
7873 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7874 if count > 0 {
7875 had_work = true;
7876 }
7877 });
7878
7879 let contexts = self.plugin_contexts.borrow().clone();
7881 for (name, context) in contexts {
7882 context.with(|ctx| {
7883 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7884 if count > 0 {
7885 had_work = true;
7886 }
7887 });
7888 }
7889 had_work
7890 }
7891
7892 pub fn send_status(&self, message: String) {
7894 let _ = self
7895 .command_sender
7896 .send(PluginCommand::SetStatus { message });
7897 }
7898
7899 pub fn send_hook_completed(&self, hook_name: String) {
7903 let _ = self
7904 .command_sender
7905 .send(PluginCommand::HookCompleted { hook_name });
7906 }
7907
7908 pub fn resolve_callback(
7913 &mut self,
7914 callback_id: fresh_core::api::JsCallbackId,
7915 result_json: &str,
7916 ) {
7917 let id = callback_id.as_u64();
7918 tracing::debug!("resolve_callback: starting for callback_id={}", id);
7919
7920 let plugin_name = {
7922 let mut contexts = self.callback_contexts.borrow_mut();
7923 contexts.remove(&id)
7924 };
7925
7926 let Some(name) = plugin_name else {
7927 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7928 return;
7929 };
7930
7931 let plugin_contexts = self.plugin_contexts.borrow();
7932 let Some(context) = plugin_contexts.get(&name) else {
7933 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7934 return;
7935 };
7936
7937 context.with(|ctx| {
7938 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7940 Ok(v) => v,
7941 Err(e) => {
7942 tracing::error!(
7943 "resolve_callback: failed to parse JSON for callback_id={}: {}",
7944 id,
7945 e
7946 );
7947 return;
7948 }
7949 };
7950
7951 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7953 Ok(v) => v,
7954 Err(e) => {
7955 tracing::error!(
7956 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7957 id,
7958 e
7959 );
7960 return;
7961 }
7962 };
7963
7964 let globals = ctx.globals();
7966 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7967 Ok(f) => f,
7968 Err(e) => {
7969 tracing::error!(
7970 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7971 id,
7972 e
7973 );
7974 return;
7975 }
7976 };
7977
7978 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7980 log_js_error(&ctx, e, &format!("resolving callback {}", id));
7981 }
7982
7983 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7985 tracing::info!(
7986 "resolve_callback: executed {} pending jobs for callback_id={}",
7987 job_count,
7988 id
7989 );
7990 });
7991 }
7992
7993 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7995 let id = callback_id.as_u64();
7996
7997 let plugin_name = {
7999 let mut contexts = self.callback_contexts.borrow_mut();
8000 contexts.remove(&id)
8001 };
8002
8003 let Some(name) = plugin_name else {
8004 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
8005 return;
8006 };
8007
8008 let plugin_contexts = self.plugin_contexts.borrow();
8009 let Some(context) = plugin_contexts.get(&name) else {
8010 tracing::warn!("reject_callback: Context lost for plugin {}", name);
8011 return;
8012 };
8013
8014 context.with(|ctx| {
8015 let globals = ctx.globals();
8017 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
8018 Ok(f) => f,
8019 Err(e) => {
8020 tracing::error!(
8021 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
8022 id,
8023 e
8024 );
8025 return;
8026 }
8027 };
8028
8029 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
8031 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
8032 }
8033
8034 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
8036 });
8037 }
8038}
8039
8040#[cfg(test)]
8041mod tests {
8042 use super::*;
8043 use fresh_core::api::{BufferInfo, CursorInfo};
8044 use std::sync::mpsc;
8045
8046 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
8048 let (tx, rx) = mpsc::channel();
8049 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8050 let services = Arc::new(TestServiceBridge::new());
8051 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8052 (backend, rx)
8053 }
8054
8055 struct TestServiceBridge {
8056 en_strings: std::sync::Mutex<HashMap<String, String>>,
8057 }
8058
8059 impl TestServiceBridge {
8060 fn new() -> Self {
8061 Self {
8062 en_strings: std::sync::Mutex::new(HashMap::new()),
8063 }
8064 }
8065 }
8066
8067 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
8068 fn as_any(&self) -> &dyn std::any::Any {
8069 self
8070 }
8071 fn translate(
8072 &self,
8073 _plugin_name: &str,
8074 key: &str,
8075 _args: &HashMap<String, String>,
8076 ) -> String {
8077 self.en_strings
8078 .lock()
8079 .unwrap()
8080 .get(key)
8081 .cloned()
8082 .unwrap_or_else(|| key.to_string())
8083 }
8084 fn current_locale(&self) -> String {
8085 "en".to_string()
8086 }
8087 fn set_js_execution_state(&self, _state: String) {}
8088 fn clear_js_execution_state(&self) {}
8089 fn get_theme_schema(&self) -> serde_json::Value {
8090 serde_json::json!({})
8091 }
8092 fn get_builtin_themes(&self) -> serde_json::Value {
8093 serde_json::json!([])
8094 }
8095 fn get_all_themes(&self) -> serde_json::Value {
8096 serde_json::json!({})
8097 }
8098 fn register_command(&self, _command: fresh_core::command::Command) {}
8099 fn unregister_command(&self, _name: &str) {}
8100 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
8101 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
8102 fn plugins_dir(&self) -> std::path::PathBuf {
8103 std::path::PathBuf::from("/tmp/plugins")
8104 }
8105 fn config_dir(&self) -> std::path::PathBuf {
8106 std::path::PathBuf::from("/tmp/config")
8107 }
8108 fn data_dir(&self) -> std::path::PathBuf {
8109 std::path::PathBuf::from("/tmp/data")
8110 }
8111 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
8112 None
8113 }
8114 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
8115 Err("not implemented in test".to_string())
8116 }
8117 fn theme_file_exists(&self, _name: &str) -> bool {
8118 false
8119 }
8120 }
8121
8122 #[test]
8123 fn test_quickjs_backend_creation() {
8124 let backend = QuickJsBackend::new();
8125 assert!(backend.is_ok());
8126 }
8127
8128 #[test]
8129 fn test_execute_simple_js() {
8130 let mut backend = QuickJsBackend::new().unwrap();
8131 let result = backend.execute_js("const x = 1 + 2;", "test.js");
8132 assert!(result.is_ok());
8133 }
8134
8135 #[test]
8136 fn test_event_handler_registration() {
8137 let backend = QuickJsBackend::new().unwrap();
8138
8139 assert!(!backend.has_handlers("test_event"));
8141
8142 backend
8144 .event_handlers
8145 .write()
8146 .unwrap()
8147 .entry("test_event".to_string())
8148 .or_default()
8149 .push(PluginHandler {
8150 plugin_name: "test".to_string(),
8151 handler_name: "testHandler".to_string(),
8152 });
8153
8154 assert!(backend.has_handlers("test_event"));
8156 }
8157
8158 #[test]
8161 fn test_api_set_status() {
8162 let (mut backend, rx) = create_test_backend();
8163
8164 backend
8165 .execute_js(
8166 r#"
8167 const editor = getEditor();
8168 editor.setStatus("Hello from test");
8169 "#,
8170 "test.js",
8171 )
8172 .unwrap();
8173
8174 let cmd = rx.try_recv().unwrap();
8175 match cmd {
8176 PluginCommand::SetStatus { message } => {
8177 assert_eq!(message, "Hello from test");
8178 }
8179 _ => panic!("Expected SetStatus command, got {:?}", cmd),
8180 }
8181 }
8182
8183 #[test]
8184 fn test_api_register_command() {
8185 let (mut backend, rx) = create_test_backend();
8186
8187 backend
8188 .execute_js(
8189 r#"
8190 const editor = getEditor();
8191 globalThis.myTestHandler = function() { };
8192 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
8193 "#,
8194 "test_plugin.js",
8195 )
8196 .unwrap();
8197
8198 let cmd = rx.try_recv().unwrap();
8199 match cmd {
8200 PluginCommand::RegisterCommand { command } => {
8201 assert_eq!(command.name, "Test Command");
8202 assert_eq!(command.description, "A test command");
8203 assert_eq!(command.plugin_name, "test_plugin");
8205 }
8206 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
8207 }
8208 }
8209
8210 #[test]
8211 fn test_api_define_mode() {
8212 let (mut backend, rx) = create_test_backend();
8213
8214 backend
8215 .execute_js(
8216 r#"
8217 const editor = getEditor();
8218 editor.defineMode("test-mode", [
8219 ["a", "action_a"],
8220 ["b", "action_b"]
8221 ]);
8222 "#,
8223 "test.js",
8224 )
8225 .unwrap();
8226
8227 let cmd = rx.try_recv().unwrap();
8228 match cmd {
8229 PluginCommand::DefineMode {
8230 name,
8231 bindings,
8232 read_only,
8233 allow_text_input,
8234 inherit_normal_bindings,
8235 plugin_name,
8236 } => {
8237 assert_eq!(name, "test-mode");
8238 assert_eq!(bindings.len(), 2);
8239 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
8240 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
8241 assert!(!read_only);
8242 assert!(!allow_text_input);
8243 assert!(!inherit_normal_bindings);
8244 assert!(plugin_name.is_some());
8245 }
8246 _ => panic!("Expected DefineMode, got {:?}", cmd),
8247 }
8248 }
8249
8250 #[test]
8251 fn test_api_set_editor_mode() {
8252 let (mut backend, rx) = create_test_backend();
8253
8254 backend
8255 .execute_js(
8256 r#"
8257 const editor = getEditor();
8258 editor.setEditorMode("vi-normal");
8259 "#,
8260 "test.js",
8261 )
8262 .unwrap();
8263
8264 let cmd = rx.try_recv().unwrap();
8265 match cmd {
8266 PluginCommand::SetEditorMode { mode } => {
8267 assert_eq!(mode, Some("vi-normal".to_string()));
8268 }
8269 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
8270 }
8271 }
8272
8273 #[test]
8274 fn test_api_clear_editor_mode() {
8275 let (mut backend, rx) = create_test_backend();
8276
8277 backend
8278 .execute_js(
8279 r#"
8280 const editor = getEditor();
8281 editor.setEditorMode(null);
8282 "#,
8283 "test.js",
8284 )
8285 .unwrap();
8286
8287 let cmd = rx.try_recv().unwrap();
8288 match cmd {
8289 PluginCommand::SetEditorMode { mode } => {
8290 assert!(mode.is_none());
8291 }
8292 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
8293 }
8294 }
8295
8296 #[test]
8297 fn test_api_insert_at_cursor() {
8298 let (mut backend, rx) = create_test_backend();
8299
8300 backend
8301 .execute_js(
8302 r#"
8303 const editor = getEditor();
8304 editor.insertAtCursor("Hello, World!");
8305 "#,
8306 "test.js",
8307 )
8308 .unwrap();
8309
8310 let cmd = rx.try_recv().unwrap();
8311 match cmd {
8312 PluginCommand::InsertAtCursor { text } => {
8313 assert_eq!(text, "Hello, World!");
8314 }
8315 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
8316 }
8317 }
8318
8319 #[test]
8320 fn test_api_set_context() {
8321 let (mut backend, rx) = create_test_backend();
8322
8323 backend
8324 .execute_js(
8325 r#"
8326 const editor = getEditor();
8327 editor.setContext("myContext", true);
8328 "#,
8329 "test.js",
8330 )
8331 .unwrap();
8332
8333 let cmd = rx.try_recv().unwrap();
8334 match cmd {
8335 PluginCommand::SetContext { name, active } => {
8336 assert_eq!(name, "myContext");
8337 assert!(active);
8338 }
8339 _ => panic!("Expected SetContext, got {:?}", cmd),
8340 }
8341 }
8342
8343 #[tokio::test]
8344 async fn test_execute_action_sync_function() {
8345 let (mut backend, rx) = create_test_backend();
8346
8347 backend.registered_actions.borrow_mut().insert(
8349 "my_sync_action".to_string(),
8350 PluginHandler {
8351 plugin_name: "test".to_string(),
8352 handler_name: "my_sync_action".to_string(),
8353 },
8354 );
8355
8356 backend
8358 .execute_js(
8359 r#"
8360 const editor = getEditor();
8361 globalThis.my_sync_action = function() {
8362 editor.setStatus("sync action executed");
8363 };
8364 "#,
8365 "test.js",
8366 )
8367 .unwrap();
8368
8369 while rx.try_recv().is_ok() {}
8371
8372 backend.execute_action("my_sync_action").await.unwrap();
8374
8375 let cmd = rx.try_recv().unwrap();
8377 match cmd {
8378 PluginCommand::SetStatus { message } => {
8379 assert_eq!(message, "sync action executed");
8380 }
8381 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
8382 }
8383 }
8384
8385 #[tokio::test]
8386 async fn test_execute_action_async_function() {
8387 let (mut backend, rx) = create_test_backend();
8388
8389 backend.registered_actions.borrow_mut().insert(
8391 "my_async_action".to_string(),
8392 PluginHandler {
8393 plugin_name: "test".to_string(),
8394 handler_name: "my_async_action".to_string(),
8395 },
8396 );
8397
8398 backend
8400 .execute_js(
8401 r#"
8402 const editor = getEditor();
8403 globalThis.my_async_action = async function() {
8404 await Promise.resolve();
8405 editor.setStatus("async action executed");
8406 };
8407 "#,
8408 "test.js",
8409 )
8410 .unwrap();
8411
8412 while rx.try_recv().is_ok() {}
8414
8415 backend.execute_action("my_async_action").await.unwrap();
8417
8418 let cmd = rx.try_recv().unwrap();
8420 match cmd {
8421 PluginCommand::SetStatus { message } => {
8422 assert_eq!(message, "async action executed");
8423 }
8424 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
8425 }
8426 }
8427
8428 #[tokio::test]
8429 async fn test_execute_action_with_registered_handler() {
8430 let (mut backend, rx) = create_test_backend();
8431
8432 backend.registered_actions.borrow_mut().insert(
8434 "my_action".to_string(),
8435 PluginHandler {
8436 plugin_name: "test".to_string(),
8437 handler_name: "actual_handler_function".to_string(),
8438 },
8439 );
8440
8441 backend
8442 .execute_js(
8443 r#"
8444 const editor = getEditor();
8445 globalThis.actual_handler_function = function() {
8446 editor.setStatus("handler executed");
8447 };
8448 "#,
8449 "test.js",
8450 )
8451 .unwrap();
8452
8453 while rx.try_recv().is_ok() {}
8455
8456 backend.execute_action("my_action").await.unwrap();
8458
8459 let cmd = rx.try_recv().unwrap();
8460 match cmd {
8461 PluginCommand::SetStatus { message } => {
8462 assert_eq!(message, "handler executed");
8463 }
8464 _ => panic!("Expected SetStatus, got {:?}", cmd),
8465 }
8466 }
8467
8468 #[test]
8469 fn test_api_on_event_registration() {
8470 let (mut backend, _rx) = create_test_backend();
8471
8472 backend
8473 .execute_js(
8474 r#"
8475 const editor = getEditor();
8476 globalThis.myEventHandler = function() { };
8477 editor.on("bufferSave", "myEventHandler");
8478 "#,
8479 "test.js",
8480 )
8481 .unwrap();
8482
8483 assert!(backend.has_handlers("bufferSave"));
8484 }
8485
8486 #[test]
8487 fn test_api_off_event_unregistration() {
8488 let (mut backend, _rx) = create_test_backend();
8489
8490 backend
8491 .execute_js(
8492 r#"
8493 const editor = getEditor();
8494 globalThis.myEventHandler = function() { };
8495 editor.on("bufferSave", "myEventHandler");
8496 editor.off("bufferSave", "myEventHandler");
8497 "#,
8498 "test.js",
8499 )
8500 .unwrap();
8501
8502 assert!(!backend.has_handlers("bufferSave"));
8504 }
8505
8506 #[tokio::test]
8507 async fn test_emit_event() {
8508 let (mut backend, rx) = create_test_backend();
8509
8510 backend
8511 .execute_js(
8512 r#"
8513 const editor = getEditor();
8514 globalThis.onSaveHandler = function(data) {
8515 editor.setStatus("saved: " + JSON.stringify(data));
8516 };
8517 editor.on("bufferSave", "onSaveHandler");
8518 "#,
8519 "test.js",
8520 )
8521 .unwrap();
8522
8523 while rx.try_recv().is_ok() {}
8525
8526 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
8528 backend.emit("bufferSave", &event_data).await.unwrap();
8529
8530 let cmd = rx.try_recv().unwrap();
8531 match cmd {
8532 PluginCommand::SetStatus { message } => {
8533 assert!(message.contains("/test.txt"));
8534 }
8535 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8536 }
8537 }
8538
8539 #[tokio::test]
8540 async fn test_emit_to_targets_single_plugin() {
8541 let (mut backend, rx) = create_test_backend();
8545 for name in ["alpha", "beta"] {
8546 backend
8547 .execute_js(
8548 &format!(
8549 r#"
8550 const editor = getEditor();
8551 globalThis.onWidget = function(data) {{
8552 editor.setStatus("{name} got " + data.panel_id);
8553 }};
8554 editor.on("widget_event", "onWidget");
8555 "#
8556 ),
8557 &format!("{name}.js"),
8558 )
8559 .unwrap();
8560 }
8561 while rx.try_recv().is_ok() {}
8562
8563 let event_data: serde_json::Value = serde_json::json!({ "panel_id": 7 });
8564 backend
8565 .emit_to("widget_event", &event_data, Some("beta"))
8566 .await
8567 .unwrap();
8568 match rx.try_recv().unwrap() {
8569 PluginCommand::SetStatus { message } => assert_eq!(message, "beta got 7"),
8570 cmd => panic!("Expected SetStatus, got {:?}", cmd),
8571 }
8572 assert!(
8573 rx.try_recv().is_err(),
8574 "targeted emit must not reach the other plugin"
8575 );
8576
8577 backend
8578 .emit_to("widget_event", &event_data, None)
8579 .await
8580 .unwrap();
8581 let mut got: Vec<String> = Vec::new();
8582 while let Ok(cmd) = rx.try_recv() {
8583 if let PluginCommand::SetStatus { message } = cmd {
8584 got.push(message);
8585 }
8586 }
8587 got.sort();
8588 assert_eq!(got, vec!["alpha got 7", "beta got 7"]);
8589 }
8590
8591 #[tokio::test]
8592 async fn test_emit_event_preserves_integers_beyond_i32() {
8593 let (mut backend, rx) = create_test_backend();
8597
8598 backend
8599 .execute_js(
8600 r#"
8601 const editor = getEditor();
8602 globalThis.onBigHandler = function(data) {
8603 editor.setStatus("big: " + data.big + " neg: " + data.neg + " small: " + data.small);
8604 };
8605 editor.on("bigEvent", "onBigHandler");
8606 "#,
8607 "test.js",
8608 )
8609 .unwrap();
8610 while rx.try_recv().is_ok() {}
8611
8612 let event_data: serde_json::Value = serde_json::json!({
8613 "big": 4_503_599_627_370_001_i64, "neg": -4_503_599_627_370_001_i64,
8615 "small": 42,
8616 });
8617 backend.emit("bigEvent", &event_data).await.unwrap();
8618
8619 let cmd = rx.try_recv().unwrap();
8620 match cmd {
8621 PluginCommand::SetStatus { message } => {
8622 assert_eq!(
8623 message,
8624 "big: 4503599627370001 neg: -4503599627370001 small: 42"
8625 );
8626 }
8627 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8628 }
8629 }
8630
8631 #[test]
8632 fn test_api_copy_to_clipboard() {
8633 let (mut backend, rx) = create_test_backend();
8634
8635 backend
8636 .execute_js(
8637 r#"
8638 const editor = getEditor();
8639 editor.copyToClipboard("clipboard text");
8640 "#,
8641 "test.js",
8642 )
8643 .unwrap();
8644
8645 let cmd = rx.try_recv().unwrap();
8646 match cmd {
8647 PluginCommand::SetClipboard { text } => {
8648 assert_eq!(text, "clipboard text");
8649 }
8650 _ => panic!("Expected SetClipboard, got {:?}", cmd),
8651 }
8652 }
8653
8654 #[test]
8655 fn test_api_open_file() {
8656 let (mut backend, rx) = create_test_backend();
8657
8658 backend
8660 .execute_js(
8661 r#"
8662 const editor = getEditor();
8663 editor.openFile("/path/to/file.txt", null, null);
8664 "#,
8665 "test.js",
8666 )
8667 .unwrap();
8668
8669 let cmd = rx.try_recv().unwrap();
8670 match cmd {
8671 PluginCommand::OpenFileAtLocation { path, line, column } => {
8672 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
8673 assert!(line.is_none());
8674 assert!(column.is_none());
8675 }
8676 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8677 }
8678 }
8679
8680 #[test]
8681 fn test_api_delete_range() {
8682 let (mut backend, rx) = create_test_backend();
8683
8684 backend
8686 .execute_js(
8687 r#"
8688 const editor = getEditor();
8689 editor.deleteRange(0, 10, 20);
8690 "#,
8691 "test.js",
8692 )
8693 .unwrap();
8694
8695 let cmd = rx.try_recv().unwrap();
8696 match cmd {
8697 PluginCommand::DeleteRange { range, .. } => {
8698 assert_eq!(range.start, 10);
8699 assert_eq!(range.end, 20);
8700 }
8701 _ => panic!("Expected DeleteRange, got {:?}", cmd),
8702 }
8703 }
8704
8705 #[test]
8706 fn test_api_insert_text() {
8707 let (mut backend, rx) = create_test_backend();
8708
8709 backend
8711 .execute_js(
8712 r#"
8713 const editor = getEditor();
8714 editor.insertText(0, 5, "inserted");
8715 "#,
8716 "test.js",
8717 )
8718 .unwrap();
8719
8720 let cmd = rx.try_recv().unwrap();
8721 match cmd {
8722 PluginCommand::InsertText { position, text, .. } => {
8723 assert_eq!(position, 5);
8724 assert_eq!(text, "inserted");
8725 }
8726 _ => panic!("Expected InsertText, got {:?}", cmd),
8727 }
8728 }
8729
8730 #[test]
8731 fn test_api_set_buffer_cursor() {
8732 let (mut backend, rx) = create_test_backend();
8733
8734 backend
8736 .execute_js(
8737 r#"
8738 const editor = getEditor();
8739 editor.setBufferCursor(0, 100);
8740 "#,
8741 "test.js",
8742 )
8743 .unwrap();
8744
8745 let cmd = rx.try_recv().unwrap();
8746 match cmd {
8747 PluginCommand::SetBufferCursor { position, .. } => {
8748 assert_eq!(position, 100);
8749 }
8750 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8751 }
8752 }
8753
8754 #[test]
8755 fn test_api_get_cursor_position_from_state() {
8756 let (tx, _rx) = mpsc::channel();
8757 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8758
8759 {
8761 let mut state = state_snapshot.write().unwrap();
8762 state.primary_cursor = Some(CursorInfo {
8763 position: 42,
8764 selection: None,
8765 line: Some(0),
8766 });
8767 }
8768
8769 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8770 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8771
8772 backend
8774 .execute_js(
8775 r#"
8776 const editor = getEditor();
8777 const pos = editor.getCursorPosition();
8778 globalThis._testResult = pos;
8779 "#,
8780 "test.js",
8781 )
8782 .unwrap();
8783
8784 backend
8786 .plugin_contexts
8787 .borrow()
8788 .get("test")
8789 .unwrap()
8790 .clone()
8791 .with(|ctx| {
8792 let global = ctx.globals();
8793 let result: u32 = global.get("_testResult").unwrap();
8794 assert_eq!(result, 42);
8795 });
8796 }
8797
8798 #[test]
8809 fn test_api_get_cursor_line_small_and_large_file() {
8810 let (tx, _rx) = mpsc::channel();
8812 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8813 {
8814 let mut state = state_snapshot.write().unwrap();
8815 state.primary_cursor = Some(CursorInfo {
8816 position: 120,
8817 selection: None,
8818 line: Some(7),
8819 });
8820 state.all_cursors = vec![
8821 CursorInfo {
8822 position: 120,
8823 selection: None,
8824 line: Some(7),
8825 },
8826 CursorInfo {
8827 position: 200,
8828 selection: None,
8829 line: Some(12),
8830 },
8831 ];
8832 }
8833
8834 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8835 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8836
8837 backend
8838 .execute_js(
8839 r#"
8840 const editor = getEditor();
8841 const primary = editor.getPrimaryCursor();
8842 globalThis._primaryLine = primary.line;
8843 globalThis._cursorLine = editor.getCursorLine();
8844 globalThis._allLines = editor.getAllCursors().map(c => c.line);
8845 "#,
8846 "probe_small.js",
8847 )
8848 .unwrap();
8849
8850 backend
8851 .plugin_contexts
8852 .borrow()
8853 .get("probe_small")
8854 .unwrap()
8855 .clone()
8856 .with(|ctx| {
8857 let global = ctx.globals();
8858 let primary_line: i32 = global.get("_primaryLine").unwrap();
8860 assert_eq!(primary_line, 7);
8861 let cursor_line: u32 = global.get("_cursorLine").unwrap();
8863 assert_eq!(cursor_line, 7);
8864 let all_lines: Vec<i32> = global.get("_allLines").unwrap();
8866 assert_eq!(all_lines, vec![7, 12]);
8867 });
8868
8869 let (tx2, _rx2) = mpsc::channel();
8871 let state_snapshot2 = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8872 {
8873 let mut state = state_snapshot2.write().unwrap();
8874 state.primary_cursor = Some(CursorInfo {
8875 position: 5_000_000,
8876 selection: None,
8877 line: None,
8878 });
8879 state.all_cursors = vec![CursorInfo {
8880 position: 5_000_000,
8881 selection: None,
8882 line: None,
8883 }];
8884 }
8885
8886 let services2 = Arc::new(fresh_core::services::NoopServiceBridge);
8887 let mut backend2 = QuickJsBackend::with_state(state_snapshot2, tx2, services2).unwrap();
8888
8889 backend2
8890 .execute_js(
8891 r#"
8892 const editor = getEditor();
8893 const primary = editor.getPrimaryCursor();
8894 // null and undefined both serialize to JS null here; normalize to a
8895 // sentinel so the Rust side can assert "unknown" unambiguously.
8896 globalThis._primaryLineIsNull = (primary.line === null || primary.line === undefined);
8897 globalThis._cursorLineFallback = editor.getCursorLine();
8898 globalThis._allLineIsNull = (editor.getAllCursors()[0].line === null);
8899 "#,
8900 "probe_large.js",
8901 )
8902 .unwrap();
8903
8904 backend2
8905 .plugin_contexts
8906 .borrow()
8907 .get("probe_large")
8908 .unwrap()
8909 .clone()
8910 .with(|ctx| {
8911 let global = ctx.globals();
8912 let primary_null: bool = global.get("_primaryLineIsNull").unwrap();
8914 assert!(
8915 primary_null,
8916 "primary.line should be null in large-file mode"
8917 );
8918 let all_null: bool = global.get("_allLineIsNull").unwrap();
8919 assert!(
8920 all_null,
8921 "getAllCursors()[0].line should be null in large-file mode"
8922 );
8923 let fallback: u32 = global.get("_cursorLineFallback").unwrap();
8925 assert_eq!(fallback, 0);
8926 });
8927 }
8928
8929 #[test]
8930 fn test_api_path_functions() {
8931 let (mut backend, _rx) = create_test_backend();
8932
8933 #[cfg(windows)]
8936 let absolute_path = r#"C:\\foo\\bar"#;
8937 #[cfg(not(windows))]
8938 let absolute_path = "/foo/bar";
8939
8940 let js_code = format!(
8942 r#"
8943 const editor = getEditor();
8944 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8945 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8946 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8947 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8948 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8949 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8950 "#,
8951 absolute_path
8952 );
8953 backend.execute_js(&js_code, "test.js").unwrap();
8954
8955 backend
8956 .plugin_contexts
8957 .borrow()
8958 .get("test")
8959 .unwrap()
8960 .clone()
8961 .with(|ctx| {
8962 let global = ctx.globals();
8963 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8964 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8965 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8966 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8967 assert!(!global.get::<_, bool>("_isRelative").unwrap());
8968 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8969 });
8970 }
8971
8972 #[test]
8980 fn test_path_join_preserves_unc_prefix() {
8981 let (mut backend, _rx) = create_test_backend();
8982 backend
8983 .execute_js(
8984 r#"
8985 const editor = getEditor();
8986 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8987 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8988 globalThis._posix = editor.pathJoin("/foo", "bar");
8989 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8990 "#,
8991 "test.js",
8992 )
8993 .unwrap();
8994
8995 backend
8996 .plugin_contexts
8997 .borrow()
8998 .get("test")
8999 .unwrap()
9000 .clone()
9001 .with(|ctx| {
9002 let global = ctx.globals();
9003 assert_eq!(
9004 global.get::<_, String>("_unc").unwrap(),
9005 "//?/C:/workspace/.devcontainer/devcontainer.json",
9006 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
9007 );
9008 assert_eq!(
9009 global.get::<_, String>("_unc_fwd").unwrap(),
9010 "//?/C:/workspace/.devcontainer/devcontainer.json",
9011 "UNC prefix in forward-slash form stays as `//`",
9012 );
9013 assert_eq!(
9014 global.get::<_, String>("_posix").unwrap(),
9015 "/foo/bar",
9016 "POSIX absolute paths keep their single leading slash",
9017 );
9018 assert_eq!(
9019 global.get::<_, String>("_drive").unwrap(),
9020 "C:/foo/bar",
9021 "Windows drive-letter paths have no leading slash",
9022 );
9023 });
9024 }
9025
9026 #[test]
9027 fn test_file_uri_to_path_and_back() {
9028 let (mut backend, _rx) = create_test_backend();
9029
9030 #[cfg(not(windows))]
9032 let js_code = r#"
9033 const editor = getEditor();
9034 // Basic file URI to path
9035 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
9036 // Percent-encoded characters
9037 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
9038 // Invalid URI returns empty string
9039 globalThis._path3 = editor.fileUriToPath("not-a-uri");
9040 // Path to file URI
9041 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
9042 // Round-trip
9043 globalThis._roundtrip = editor.fileUriToPath(
9044 editor.pathToFileUri("/home/user/file.txt")
9045 );
9046 "#;
9047
9048 #[cfg(windows)]
9049 let js_code = r#"
9050 const editor = getEditor();
9051 // Windows URI with encoded colon (the bug from issue #1071)
9052 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
9053 // Windows URI with normal colon
9054 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
9055 // Invalid URI returns empty string
9056 globalThis._path3 = editor.fileUriToPath("not-a-uri");
9057 // Path to file URI
9058 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
9059 // Round-trip
9060 globalThis._roundtrip = editor.fileUriToPath(
9061 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
9062 );
9063 "#;
9064
9065 backend.execute_js(js_code, "test.js").unwrap();
9066
9067 backend
9068 .plugin_contexts
9069 .borrow()
9070 .get("test")
9071 .unwrap()
9072 .clone()
9073 .with(|ctx| {
9074 let global = ctx.globals();
9075
9076 #[cfg(not(windows))]
9077 {
9078 assert_eq!(
9079 global.get::<_, String>("_path1").unwrap(),
9080 "/home/user/file.txt"
9081 );
9082 assert_eq!(
9083 global.get::<_, String>("_path2").unwrap(),
9084 "/home/user/my file.txt"
9085 );
9086 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
9087 assert_eq!(
9088 global.get::<_, String>("_uri1").unwrap(),
9089 "file:///home/user/file.txt"
9090 );
9091 assert_eq!(
9092 global.get::<_, String>("_roundtrip").unwrap(),
9093 "/home/user/file.txt"
9094 );
9095 }
9096
9097 #[cfg(windows)]
9098 {
9099 assert_eq!(
9101 global.get::<_, String>("_path1").unwrap(),
9102 "C:\\Users\\admin\\Repos\\file.cs"
9103 );
9104 assert_eq!(
9105 global.get::<_, String>("_path2").unwrap(),
9106 "C:\\Users\\admin\\Repos\\file.cs"
9107 );
9108 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
9109 assert_eq!(
9110 global.get::<_, String>("_uri1").unwrap(),
9111 "file:///C:/Users/admin/Repos/file.cs"
9112 );
9113 assert_eq!(
9114 global.get::<_, String>("_roundtrip").unwrap(),
9115 "C:\\Users\\admin\\Repos\\file.cs"
9116 );
9117 }
9118 });
9119 }
9120
9121 #[test]
9122 fn test_typescript_transpilation() {
9123 use fresh_parser_js::transpile_typescript;
9124
9125 let (mut backend, rx) = create_test_backend();
9126
9127 let ts_code = r#"
9129 const editor = getEditor();
9130 function greet(name: string): string {
9131 return "Hello, " + name;
9132 }
9133 editor.setStatus(greet("TypeScript"));
9134 "#;
9135
9136 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
9138
9139 backend.execute_js(&js_code, "test.js").unwrap();
9141
9142 let cmd = rx.try_recv().unwrap();
9143 match cmd {
9144 PluginCommand::SetStatus { message } => {
9145 assert_eq!(message, "Hello, TypeScript");
9146 }
9147 _ => panic!("Expected SetStatus, got {:?}", cmd),
9148 }
9149 }
9150
9151 #[test]
9152 fn test_api_get_buffer_text_sends_command() {
9153 let (mut backend, rx) = create_test_backend();
9154
9155 backend
9157 .execute_js(
9158 r#"
9159 const editor = getEditor();
9160 // Store the promise for later
9161 globalThis._textPromise = editor.getBufferText(0, 10, 20);
9162 "#,
9163 "test.js",
9164 )
9165 .unwrap();
9166
9167 let cmd = rx.try_recv().unwrap();
9169 match cmd {
9170 PluginCommand::GetBufferText {
9171 buffer_id,
9172 start,
9173 end,
9174 request_id,
9175 } => {
9176 assert_eq!(buffer_id.0, 0);
9177 assert_eq!(start, 10);
9178 assert_eq!(end, 20);
9179 assert!(request_id > 0); }
9181 _ => panic!("Expected GetBufferText, got {:?}", cmd),
9182 }
9183 }
9184
9185 #[test]
9186 fn test_api_get_buffer_text_resolves_callback() {
9187 let (mut backend, rx) = create_test_backend();
9188
9189 backend
9191 .execute_js(
9192 r#"
9193 const editor = getEditor();
9194 globalThis._resolvedText = null;
9195 editor.getBufferText(0, 0, 100).then(text => {
9196 globalThis._resolvedText = text;
9197 });
9198 "#,
9199 "test.js",
9200 )
9201 .unwrap();
9202
9203 let request_id = match rx.try_recv().unwrap() {
9205 PluginCommand::GetBufferText { request_id, .. } => request_id,
9206 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
9207 };
9208
9209 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
9211
9212 backend
9214 .plugin_contexts
9215 .borrow()
9216 .get("test")
9217 .unwrap()
9218 .clone()
9219 .with(|ctx| {
9220 run_pending_jobs_checked(&ctx, "test async getText");
9221 });
9222
9223 backend
9225 .plugin_contexts
9226 .borrow()
9227 .get("test")
9228 .unwrap()
9229 .clone()
9230 .with(|ctx| {
9231 let global = ctx.globals();
9232 let result: String = global.get("_resolvedText").unwrap();
9233 assert_eq!(result, "hello world");
9234 });
9235 }
9236
9237 #[test]
9238 fn test_plugin_translation() {
9239 let (mut backend, _rx) = create_test_backend();
9240
9241 backend
9243 .execute_js(
9244 r#"
9245 const editor = getEditor();
9246 globalThis._translated = editor.t("test.key");
9247 "#,
9248 "test.js",
9249 )
9250 .unwrap();
9251
9252 backend
9253 .plugin_contexts
9254 .borrow()
9255 .get("test")
9256 .unwrap()
9257 .clone()
9258 .with(|ctx| {
9259 let global = ctx.globals();
9260 let result: String = global.get("_translated").unwrap();
9262 assert_eq!(result, "test.key");
9263 });
9264 }
9265
9266 #[test]
9267 fn test_plugin_translation_with_registered_strings() {
9268 let (mut backend, _rx) = create_test_backend();
9269
9270 let mut en_strings = std::collections::HashMap::new();
9272 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
9273 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9274
9275 let mut strings = std::collections::HashMap::new();
9276 strings.insert("en".to_string(), en_strings);
9277
9278 if let Some(bridge) = backend
9280 .services
9281 .as_any()
9282 .downcast_ref::<TestServiceBridge>()
9283 {
9284 let mut en = bridge.en_strings.lock().unwrap();
9285 en.insert("greeting".to_string(), "Hello, World!".to_string());
9286 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9287 }
9288
9289 backend
9291 .execute_js(
9292 r#"
9293 const editor = getEditor();
9294 globalThis._greeting = editor.t("greeting");
9295 globalThis._prompt = editor.t("prompt.find_file");
9296 globalThis._missing = editor.t("nonexistent.key");
9297 "#,
9298 "test.js",
9299 )
9300 .unwrap();
9301
9302 backend
9303 .plugin_contexts
9304 .borrow()
9305 .get("test")
9306 .unwrap()
9307 .clone()
9308 .with(|ctx| {
9309 let global = ctx.globals();
9310 let greeting: String = global.get("_greeting").unwrap();
9311 assert_eq!(greeting, "Hello, World!");
9312
9313 let prompt: String = global.get("_prompt").unwrap();
9314 assert_eq!(prompt, "Find file: ");
9315
9316 let missing: String = global.get("_missing").unwrap();
9318 assert_eq!(missing, "nonexistent.key");
9319 });
9320 }
9321
9322 #[test]
9325 fn test_api_set_line_indicator() {
9326 let (mut backend, rx) = create_test_backend();
9327
9328 backend
9329 .execute_js(
9330 r#"
9331 const editor = getEditor();
9332 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
9333 "#,
9334 "test.js",
9335 )
9336 .unwrap();
9337
9338 let cmd = rx.try_recv().unwrap();
9339 match cmd {
9340 PluginCommand::SetLineIndicator {
9341 buffer_id,
9342 line,
9343 namespace,
9344 symbol,
9345 color,
9346 priority,
9347 } => {
9348 assert_eq!(buffer_id.0, 1);
9349 assert_eq!(line, 5);
9350 assert_eq!(namespace, "test-ns");
9351 assert_eq!(symbol, "●");
9352 assert_eq!(color, (255, 0, 0));
9353 assert_eq!(priority, 10);
9354 }
9355 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
9356 }
9357 }
9358
9359 #[test]
9360 fn test_api_clear_line_indicators() {
9361 let (mut backend, rx) = create_test_backend();
9362
9363 backend
9364 .execute_js(
9365 r#"
9366 const editor = getEditor();
9367 editor.clearLineIndicators(1, "test-ns");
9368 "#,
9369 "test.js",
9370 )
9371 .unwrap();
9372
9373 let cmd = rx.try_recv().unwrap();
9374 match cmd {
9375 PluginCommand::ClearLineIndicators {
9376 buffer_id,
9377 namespace,
9378 } => {
9379 assert_eq!(buffer_id.0, 1);
9380 assert_eq!(namespace, "test-ns");
9381 }
9382 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
9383 }
9384 }
9385
9386 #[test]
9389 fn test_api_create_virtual_buffer_sends_command() {
9390 let (mut backend, rx) = create_test_backend();
9391
9392 backend
9393 .execute_js(
9394 r#"
9395 const editor = getEditor();
9396 editor.createVirtualBuffer({
9397 name: "*Test Buffer*",
9398 mode: "test-mode",
9399 readOnly: true,
9400 entries: [
9401 { text: "Line 1\n", properties: { type: "header" } },
9402 { text: "Line 2\n", properties: { type: "content" } }
9403 ],
9404 showLineNumbers: false,
9405 showCursors: true,
9406 editingDisabled: true
9407 });
9408 "#,
9409 "test.js",
9410 )
9411 .unwrap();
9412
9413 let cmd = rx.try_recv().unwrap();
9414 match cmd {
9415 PluginCommand::CreateVirtualBufferWithContent {
9416 name,
9417 mode,
9418 read_only,
9419 entries,
9420 show_line_numbers,
9421 show_cursors,
9422 editing_disabled,
9423 ..
9424 } => {
9425 assert_eq!(name, "*Test Buffer*");
9426 assert_eq!(mode, "test-mode");
9427 assert!(read_only);
9428 assert_eq!(entries.len(), 2);
9429 assert_eq!(entries[0].text, "Line 1\n");
9430 assert!(!show_line_numbers);
9431 assert!(show_cursors);
9432 assert!(editing_disabled);
9433 }
9434 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
9435 }
9436 }
9437
9438 #[test]
9439 fn test_api_set_virtual_buffer_content() {
9440 let (mut backend, rx) = create_test_backend();
9441
9442 backend
9443 .execute_js(
9444 r#"
9445 const editor = getEditor();
9446 editor.setVirtualBufferContent(5, [
9447 { text: "New content\n", properties: { type: "updated" } }
9448 ]);
9449 "#,
9450 "test.js",
9451 )
9452 .unwrap();
9453
9454 let cmd = rx.try_recv().unwrap();
9455 match cmd {
9456 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
9457 assert_eq!(buffer_id.0, 5);
9458 assert_eq!(entries.len(), 1);
9459 assert_eq!(entries[0].text, "New content\n");
9460 }
9461 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
9462 }
9463 }
9464
9465 #[test]
9468 fn test_api_add_overlay() {
9469 let (mut backend, rx) = create_test_backend();
9470
9471 backend
9472 .execute_js(
9473 r#"
9474 const editor = getEditor();
9475 editor.addOverlay(1, "highlight", 10, 20, {
9476 fg: [255, 128, 0],
9477 bg: [50, 50, 50],
9478 bold: true,
9479 });
9480 "#,
9481 "test.js",
9482 )
9483 .unwrap();
9484
9485 let cmd = rx.try_recv().unwrap();
9486 match cmd {
9487 PluginCommand::AddOverlay {
9488 buffer_id,
9489 namespace,
9490 range,
9491 options,
9492 } => {
9493 use fresh_core::api::OverlayColorSpec;
9494 assert_eq!(buffer_id.0, 1);
9495 assert!(namespace.is_some());
9496 assert_eq!(namespace.unwrap().as_str(), "highlight");
9497 assert_eq!(range, 10..20);
9498 assert!(matches!(
9499 options.fg,
9500 Some(OverlayColorSpec::Rgb(255, 128, 0))
9501 ));
9502 assert!(matches!(
9503 options.bg,
9504 Some(OverlayColorSpec::Rgb(50, 50, 50))
9505 ));
9506 assert!(!options.underline);
9507 assert!(options.bold);
9508 assert!(!options.italic);
9509 assert!(!options.extend_to_line_end);
9510 }
9511 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9512 }
9513 }
9514
9515 #[test]
9516 fn test_api_add_overlay_with_theme_keys() {
9517 let (mut backend, rx) = create_test_backend();
9518
9519 backend
9520 .execute_js(
9521 r#"
9522 const editor = getEditor();
9523 // Test with theme keys for colors
9524 editor.addOverlay(1, "themed", 0, 10, {
9525 fg: "ui.status_bar_fg",
9526 bg: "editor.selection_bg",
9527 });
9528 "#,
9529 "test.js",
9530 )
9531 .unwrap();
9532
9533 let cmd = rx.try_recv().unwrap();
9534 match cmd {
9535 PluginCommand::AddOverlay {
9536 buffer_id,
9537 namespace,
9538 range,
9539 options,
9540 } => {
9541 use fresh_core::api::OverlayColorSpec;
9542 assert_eq!(buffer_id.0, 1);
9543 assert!(namespace.is_some());
9544 assert_eq!(namespace.unwrap().as_str(), "themed");
9545 assert_eq!(range, 0..10);
9546 assert!(matches!(
9547 &options.fg,
9548 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
9549 ));
9550 assert!(matches!(
9551 &options.bg,
9552 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
9553 ));
9554 assert!(!options.underline);
9555 assert!(!options.bold);
9556 assert!(!options.italic);
9557 assert!(!options.extend_to_line_end);
9558 }
9559 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9560 }
9561 }
9562
9563 #[test]
9564 fn test_api_clear_namespace() {
9565 let (mut backend, rx) = create_test_backend();
9566
9567 backend
9568 .execute_js(
9569 r#"
9570 const editor = getEditor();
9571 editor.clearNamespace(1, "highlight");
9572 "#,
9573 "test.js",
9574 )
9575 .unwrap();
9576
9577 let cmd = rx.try_recv().unwrap();
9578 match cmd {
9579 PluginCommand::ClearNamespace {
9580 buffer_id,
9581 namespace,
9582 } => {
9583 assert_eq!(buffer_id.0, 1);
9584 assert_eq!(namespace.as_str(), "highlight");
9585 }
9586 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
9587 }
9588 }
9589
9590 #[test]
9593 fn test_api_get_theme_schema() {
9594 let (mut backend, _rx) = create_test_backend();
9595
9596 backend
9597 .execute_js(
9598 r#"
9599 const editor = getEditor();
9600 const schema = editor.getThemeSchema();
9601 globalThis._isObject = typeof schema === 'object' && schema !== null;
9602 "#,
9603 "test.js",
9604 )
9605 .unwrap();
9606
9607 backend
9608 .plugin_contexts
9609 .borrow()
9610 .get("test")
9611 .unwrap()
9612 .clone()
9613 .with(|ctx| {
9614 let global = ctx.globals();
9615 let is_object: bool = global.get("_isObject").unwrap();
9616 assert!(is_object);
9618 });
9619 }
9620
9621 #[test]
9622 fn test_api_get_builtin_themes() {
9623 let (mut backend, _rx) = create_test_backend();
9624
9625 backend
9626 .execute_js(
9627 r#"
9628 const editor = getEditor();
9629 const themes = editor.getBuiltinThemes();
9630 globalThis._isObject = typeof themes === 'object' && themes !== null;
9631 "#,
9632 "test.js",
9633 )
9634 .unwrap();
9635
9636 backend
9637 .plugin_contexts
9638 .borrow()
9639 .get("test")
9640 .unwrap()
9641 .clone()
9642 .with(|ctx| {
9643 let global = ctx.globals();
9644 let is_object: bool = global.get("_isObject").unwrap();
9645 assert!(is_object);
9647 });
9648 }
9649
9650 #[test]
9651 fn test_api_apply_theme() {
9652 let (mut backend, rx) = create_test_backend();
9653
9654 backend
9655 .execute_js(
9656 r#"
9657 const editor = getEditor();
9658 editor.applyTheme("dark");
9659 "#,
9660 "test.js",
9661 )
9662 .unwrap();
9663
9664 let cmd = rx.try_recv().unwrap();
9665 match cmd {
9666 PluginCommand::ApplyTheme { theme_name } => {
9667 assert_eq!(theme_name, "dark");
9668 }
9669 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
9670 }
9671 }
9672
9673 #[test]
9674 fn test_api_override_theme_colors_round_trip() {
9675 let (mut backend, rx) = create_test_backend();
9678
9679 backend
9680 .execute_js(
9681 r#"
9682 const editor = getEditor();
9683 editor.overrideThemeColors({
9684 "editor.bg": [10, 20, 30],
9685 "editor.fg": [220, 221, 222],
9686 });
9687 "#,
9688 "test.js",
9689 )
9690 .unwrap();
9691
9692 let cmd = rx.try_recv().unwrap();
9693 match cmd {
9694 PluginCommand::OverrideThemeColors { overrides } => {
9695 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
9696 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
9697 assert_eq!(overrides.len(), 2);
9698 }
9699 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
9700 }
9701 }
9702
9703 #[test]
9704 fn test_api_override_theme_colors_clamps_out_of_range() {
9705 let (mut backend, rx) = create_test_backend();
9706
9707 backend
9708 .execute_js(
9709 r#"
9710 const editor = getEditor();
9711 editor.overrideThemeColors({
9712 "editor.bg": [-5, 300, 128],
9713 });
9714 "#,
9715 "test.js",
9716 )
9717 .unwrap();
9718
9719 match rx.try_recv().unwrap() {
9720 PluginCommand::OverrideThemeColors { overrides } => {
9721 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
9722 }
9723 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9724 }
9725 }
9726
9727 #[test]
9728 fn test_api_override_theme_colors_drops_malformed_entries() {
9729 let (mut backend, rx) = create_test_backend();
9732
9733 backend
9734 .execute_js(
9735 r#"
9736 const editor = getEditor();
9737 editor.overrideThemeColors({
9738 "editor.bg": [1, 2, 3],
9739 "not_an_array": "oops",
9740 "wrong_length": [1, 2],
9741 "floats_are_fine": [10.7, 20.2, 30.9],
9742 });
9743 "#,
9744 "test.js",
9745 )
9746 .unwrap();
9747
9748 match rx.try_recv().unwrap() {
9749 PluginCommand::OverrideThemeColors { overrides } => {
9750 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
9751 assert!(!overrides.contains_key("not_an_array"));
9752 assert!(!overrides.contains_key("wrong_length"));
9753 assert_eq!(
9755 overrides.get("floats_are_fine").copied(),
9756 Some([10, 20, 30])
9757 );
9758 }
9759 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9760 }
9761 }
9762
9763 #[test]
9764 fn test_api_get_theme_data_missing() {
9765 let (mut backend, _rx) = create_test_backend();
9766
9767 backend
9768 .execute_js(
9769 r#"
9770 const editor = getEditor();
9771 const data = editor.getThemeData("nonexistent");
9772 globalThis._isNull = data === null;
9773 "#,
9774 "test.js",
9775 )
9776 .unwrap();
9777
9778 backend
9779 .plugin_contexts
9780 .borrow()
9781 .get("test")
9782 .unwrap()
9783 .clone()
9784 .with(|ctx| {
9785 let global = ctx.globals();
9786 let is_null: bool = global.get("_isNull").unwrap();
9787 assert!(is_null);
9789 });
9790 }
9791
9792 #[test]
9793 fn test_api_get_theme_data_present() {
9794 let (tx, _rx) = mpsc::channel();
9796 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9797 let services = Arc::new(ThemeCacheTestBridge {
9798 inner: TestServiceBridge::new(),
9799 });
9800 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9801
9802 backend
9803 .execute_js(
9804 r#"
9805 const editor = getEditor();
9806 const data = editor.getThemeData("test-theme");
9807 globalThis._hasData = data !== null && typeof data === 'object';
9808 globalThis._name = data ? data.name : null;
9809 "#,
9810 "test.js",
9811 )
9812 .unwrap();
9813
9814 backend
9815 .plugin_contexts
9816 .borrow()
9817 .get("test")
9818 .unwrap()
9819 .clone()
9820 .with(|ctx| {
9821 let global = ctx.globals();
9822 let has_data: bool = global.get("_hasData").unwrap();
9823 assert!(has_data, "getThemeData should return theme object");
9824 let name: String = global.get("_name").unwrap();
9825 assert_eq!(name, "test-theme");
9826 });
9827 }
9828
9829 #[test]
9830 fn test_api_theme_file_exists() {
9831 let (mut backend, _rx) = create_test_backend();
9832
9833 backend
9834 .execute_js(
9835 r#"
9836 const editor = getEditor();
9837 globalThis._exists = editor.themeFileExists("anything");
9838 "#,
9839 "test.js",
9840 )
9841 .unwrap();
9842
9843 backend
9844 .plugin_contexts
9845 .borrow()
9846 .get("test")
9847 .unwrap()
9848 .clone()
9849 .with(|ctx| {
9850 let global = ctx.globals();
9851 let exists: bool = global.get("_exists").unwrap();
9852 assert!(!exists);
9854 });
9855 }
9856
9857 #[test]
9858 fn test_api_save_theme_file_error() {
9859 let (mut backend, _rx) = create_test_backend();
9860
9861 backend
9862 .execute_js(
9863 r#"
9864 const editor = getEditor();
9865 let threw = false;
9866 try {
9867 editor.saveThemeFile("test", "{}");
9868 } catch (e) {
9869 threw = true;
9870 }
9871 globalThis._threw = threw;
9872 "#,
9873 "test.js",
9874 )
9875 .unwrap();
9876
9877 backend
9878 .plugin_contexts
9879 .borrow()
9880 .get("test")
9881 .unwrap()
9882 .clone()
9883 .with(|ctx| {
9884 let global = ctx.globals();
9885 let threw: bool = global.get("_threw").unwrap();
9886 assert!(threw);
9888 });
9889 }
9890
9891 struct ThemeCacheTestBridge {
9893 inner: TestServiceBridge,
9894 }
9895
9896 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9897 fn as_any(&self) -> &dyn std::any::Any {
9898 self
9899 }
9900 fn translate(
9901 &self,
9902 plugin_name: &str,
9903 key: &str,
9904 args: &HashMap<String, String>,
9905 ) -> String {
9906 self.inner.translate(plugin_name, key, args)
9907 }
9908 fn current_locale(&self) -> String {
9909 self.inner.current_locale()
9910 }
9911 fn set_js_execution_state(&self, state: String) {
9912 self.inner.set_js_execution_state(state);
9913 }
9914 fn clear_js_execution_state(&self) {
9915 self.inner.clear_js_execution_state();
9916 }
9917 fn get_theme_schema(&self) -> serde_json::Value {
9918 self.inner.get_theme_schema()
9919 }
9920 fn get_builtin_themes(&self) -> serde_json::Value {
9921 self.inner.get_builtin_themes()
9922 }
9923 fn get_all_themes(&self) -> serde_json::Value {
9924 self.inner.get_all_themes()
9925 }
9926 fn register_command(&self, command: fresh_core::command::Command) {
9927 self.inner.register_command(command);
9928 }
9929 fn unregister_command(&self, name: &str) {
9930 self.inner.unregister_command(name);
9931 }
9932 fn unregister_commands_by_prefix(&self, prefix: &str) {
9933 self.inner.unregister_commands_by_prefix(prefix);
9934 }
9935 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9936 self.inner.unregister_commands_by_plugin(plugin_name);
9937 }
9938 fn plugins_dir(&self) -> std::path::PathBuf {
9939 self.inner.plugins_dir()
9940 }
9941 fn config_dir(&self) -> std::path::PathBuf {
9942 self.inner.config_dir()
9943 }
9944 fn data_dir(&self) -> std::path::PathBuf {
9945 self.inner.data_dir()
9946 }
9947 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9948 if name == "test-theme" {
9949 Some(serde_json::json!({
9950 "name": "test-theme",
9951 "editor": {},
9952 "ui": {},
9953 "syntax": {}
9954 }))
9955 } else {
9956 None
9957 }
9958 }
9959 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9960 Err("test bridge does not support save".to_string())
9961 }
9962 fn theme_file_exists(&self, name: &str) -> bool {
9963 name == "test-theme"
9964 }
9965 }
9966
9967 #[test]
9970 fn test_api_close_buffer() {
9971 let (mut backend, rx) = create_test_backend();
9972
9973 backend
9974 .execute_js(
9975 r#"
9976 const editor = getEditor();
9977 editor.closeBuffer(3);
9978 "#,
9979 "test.js",
9980 )
9981 .unwrap();
9982
9983 let cmd = rx.try_recv().unwrap();
9984 match cmd {
9985 PluginCommand::CloseBuffer { buffer_id } => {
9986 assert_eq!(buffer_id.0, 3);
9987 }
9988 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9989 }
9990 }
9991
9992 #[test]
9993 fn test_api_focus_split() {
9994 let (mut backend, rx) = create_test_backend();
9995
9996 backend
9997 .execute_js(
9998 r#"
9999 const editor = getEditor();
10000 editor.focusSplit(2);
10001 "#,
10002 "test.js",
10003 )
10004 .unwrap();
10005
10006 let cmd = rx.try_recv().unwrap();
10007 match cmd {
10008 PluginCommand::FocusSplit { split_id } => {
10009 assert_eq!(split_id.0, 2);
10010 }
10011 _ => panic!("Expected FocusSplit, got {:?}", cmd),
10012 }
10013 }
10014
10015 #[test]
10019 fn test_api_session_lifecycle_dispatches_commands() {
10020 let (mut backend, rx) = create_test_backend();
10021
10022 backend
10023 .execute_js(
10024 r#"
10025 const editor = getEditor();
10026 editor.createWindow("/tmp/wt-feat", "feat");
10027 editor.setActiveWindow(7);
10028 editor.closeWindow(3);
10029 "#,
10030 "test.js",
10031 )
10032 .unwrap();
10033
10034 let create = rx.try_recv().unwrap();
10035 match create {
10036 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
10037 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
10038 assert_eq!(label, "feat");
10039 }
10040 other => panic!("Expected CreateWindow, got {:?}", other),
10041 }
10042
10043 let activate = rx.try_recv().unwrap();
10044 match activate {
10045 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
10046 assert_eq!(id, fresh_core::WindowId(7));
10047 }
10048 other => panic!("Expected SetActiveWindow, got {:?}", other),
10049 }
10050
10051 let close = rx.try_recv().unwrap();
10052 match close {
10053 fresh_core::api::PluginCommand::CloseWindow { id } => {
10054 assert_eq!(id, fresh_core::WindowId(3));
10055 }
10056 other => panic!("Expected CloseWindow, got {:?}", other),
10057 }
10058 }
10059
10060 #[test]
10064 fn test_api_list_sessions_reads_snapshot() {
10065 let (tx, _rx) = mpsc::channel();
10066 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10067
10068 {
10069 let mut state = state_snapshot.write().unwrap();
10070 state.windows = vec![
10071 fresh_core::api::WindowInfo {
10072 id: fresh_core::WindowId(1),
10073 label: "main".into(),
10074 root: std::path::PathBuf::from("/repo"),
10075 project_path: std::path::PathBuf::from("/repo"),
10076 shared_worktree: false,
10077 },
10078 fresh_core::api::WindowInfo {
10079 id: fresh_core::WindowId(2),
10080 label: "feat-auth".into(),
10081 root: std::path::PathBuf::from("/wt/feat-auth"),
10082 project_path: std::path::PathBuf::from("/wt/feat-auth"),
10083 shared_worktree: false,
10084 },
10085 ];
10086 state.active_window_id = fresh_core::WindowId(2);
10087 }
10088
10089 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10090 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10091
10092 backend
10093 .execute_js(
10094 r#"
10095 const editor = getEditor();
10096 const list = editor.listWindows();
10097 globalThis._sessionCount = list.length;
10098 globalThis._secondLabel = list[1].label;
10099 globalThis._secondRoot = list[1].root;
10100 globalThis._activeId = editor.activeWindow();
10101 "#,
10102 "test.js",
10103 )
10104 .unwrap();
10105
10106 backend
10107 .plugin_contexts
10108 .borrow()
10109 .get("test")
10110 .unwrap()
10111 .clone()
10112 .with(|ctx| {
10113 let global = ctx.globals();
10114 let count: u32 = global.get("_sessionCount").unwrap();
10115 let label: String = global.get("_secondLabel").unwrap();
10116 let root: String = global.get("_secondRoot").unwrap();
10117 let active: u32 = global.get("_activeId").unwrap();
10118 assert_eq!(count, 2);
10119 assert_eq!(label, "feat-auth");
10120 assert_eq!(root, "/wt/feat-auth");
10121 assert_eq!(active, 2);
10122 });
10123 }
10124
10125 #[test]
10126 fn test_api_list_buffers() {
10127 let (tx, _rx) = mpsc::channel();
10128 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10129
10130 {
10132 let mut state = state_snapshot.write().unwrap();
10133 state.buffers.insert(
10134 BufferId(0),
10135 BufferInfo {
10136 id: BufferId(0),
10137 path: Some(PathBuf::from("/test1.txt")),
10138 modified: false,
10139 length: 100,
10140 is_virtual: false,
10141 editing_disabled: false,
10142 view_mode: "source".to_string(),
10143 is_composing_in_any_split: false,
10144 compose_width: None,
10145 language: "text".to_string(),
10146 is_preview: false,
10147 splits: Vec::new(),
10148 },
10149 );
10150 state.buffers.insert(
10151 BufferId(1),
10152 BufferInfo {
10153 id: BufferId(1),
10154 path: Some(PathBuf::from("/test2.txt")),
10155 modified: true,
10156 length: 200,
10157 is_virtual: false,
10158 editing_disabled: false,
10159 view_mode: "source".to_string(),
10160 is_composing_in_any_split: false,
10161 compose_width: None,
10162 language: "text".to_string(),
10163 is_preview: false,
10164 splits: Vec::new(),
10165 },
10166 );
10167 }
10168
10169 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10170 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10171
10172 backend
10173 .execute_js(
10174 r#"
10175 const editor = getEditor();
10176 const buffers = editor.listBuffers();
10177 globalThis._isArray = Array.isArray(buffers);
10178 globalThis._length = buffers.length;
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 is_array: bool = global.get("_isArray").unwrap();
10193 let length: u32 = global.get("_length").unwrap();
10194 assert!(is_array);
10195 assert_eq!(length, 2);
10196 });
10197 }
10198
10199 #[test]
10202 fn test_api_start_prompt() {
10203 let (mut backend, rx) = create_test_backend();
10204
10205 backend
10206 .execute_js(
10207 r#"
10208 const editor = getEditor();
10209 editor.startPrompt("Enter value:", "test-prompt");
10210 "#,
10211 "test.js",
10212 )
10213 .unwrap();
10214
10215 let cmd = rx.try_recv().unwrap();
10216 match cmd {
10217 PluginCommand::StartPrompt {
10218 label,
10219 prompt_type,
10220 floating_overlay,
10221 } => {
10222 assert_eq!(label, "Enter value:");
10223 assert_eq!(prompt_type, "test-prompt");
10224 assert!(!floating_overlay);
10225 }
10226 _ => panic!("Expected StartPrompt, got {:?}", cmd),
10227 }
10228 }
10229
10230 #[test]
10231 fn test_api_start_prompt_with_initial() {
10232 let (mut backend, rx) = create_test_backend();
10233
10234 backend
10235 .execute_js(
10236 r#"
10237 const editor = getEditor();
10238 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
10239 "#,
10240 "test.js",
10241 )
10242 .unwrap();
10243
10244 let cmd = rx.try_recv().unwrap();
10245 match cmd {
10246 PluginCommand::StartPromptWithInitial {
10247 label,
10248 prompt_type,
10249 initial_value,
10250 floating_overlay,
10251 } => {
10252 assert_eq!(label, "Enter value:");
10253 assert_eq!(prompt_type, "test-prompt");
10254 assert_eq!(initial_value, "default");
10255 assert!(!floating_overlay);
10256 }
10257 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
10258 }
10259 }
10260
10261 #[test]
10262 fn test_api_set_prompt_suggestions() {
10263 let (mut backend, rx) = create_test_backend();
10264
10265 backend
10266 .execute_js(
10267 r#"
10268 const editor = getEditor();
10269 editor.setPromptSuggestions([
10270 { text: "Option 1", value: "opt1" },
10271 { text: "Option 2", value: "opt2" }
10272 ]);
10273 "#,
10274 "test.js",
10275 )
10276 .unwrap();
10277
10278 let cmd = rx.try_recv().unwrap();
10279 match cmd {
10280 PluginCommand::SetPromptSuggestions { suggestions, .. } => {
10281 assert_eq!(suggestions.len(), 2);
10282 assert_eq!(suggestions[0].text, "Option 1");
10283 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
10284 }
10285 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
10286 }
10287 }
10288
10289 #[test]
10292 fn test_api_get_active_buffer_id() {
10293 let (tx, _rx) = mpsc::channel();
10294 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10295
10296 {
10297 let mut state = state_snapshot.write().unwrap();
10298 state.active_buffer_id = BufferId(42);
10299 }
10300
10301 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10302 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10303
10304 backend
10305 .execute_js(
10306 r#"
10307 const editor = getEditor();
10308 globalThis._activeId = editor.getActiveBufferId();
10309 "#,
10310 "test.js",
10311 )
10312 .unwrap();
10313
10314 backend
10315 .plugin_contexts
10316 .borrow()
10317 .get("test")
10318 .unwrap()
10319 .clone()
10320 .with(|ctx| {
10321 let global = ctx.globals();
10322 let result: u32 = global.get("_activeId").unwrap();
10323 assert_eq!(result, 42);
10324 });
10325 }
10326
10327 #[test]
10328 fn test_api_get_active_split_id() {
10329 let (tx, _rx) = mpsc::channel();
10330 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10331
10332 {
10333 let mut state = state_snapshot.write().unwrap();
10334 state.active_split_id = 7;
10335 }
10336
10337 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10338 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10339
10340 backend
10341 .execute_js(
10342 r#"
10343 const editor = getEditor();
10344 globalThis._splitId = editor.getActiveSplitId();
10345 "#,
10346 "test.js",
10347 )
10348 .unwrap();
10349
10350 backend
10351 .plugin_contexts
10352 .borrow()
10353 .get("test")
10354 .unwrap()
10355 .clone()
10356 .with(|ctx| {
10357 let global = ctx.globals();
10358 let result: u32 = global.get("_splitId").unwrap();
10359 assert_eq!(result, 7);
10360 });
10361 }
10362
10363 #[test]
10366 fn test_api_file_exists() {
10367 let (mut backend, _rx) = create_test_backend();
10368
10369 backend
10370 .execute_js(
10371 r#"
10372 const editor = getEditor();
10373 // Test with a path that definitely exists
10374 globalThis._exists = editor.fileExists("/");
10375 "#,
10376 "test.js",
10377 )
10378 .unwrap();
10379
10380 backend
10381 .plugin_contexts
10382 .borrow()
10383 .get("test")
10384 .unwrap()
10385 .clone()
10386 .with(|ctx| {
10387 let global = ctx.globals();
10388 let result: bool = global.get("_exists").unwrap();
10389 assert!(result);
10390 });
10391 }
10392
10393 #[test]
10394 fn test_api_parse_jsonc() {
10395 let (mut backend, _rx) = create_test_backend();
10396
10397 backend
10398 .execute_js(
10399 r#"
10400 const editor = getEditor();
10401 // Comments, trailing commas, and nested structures should all parse.
10402 const parsed = editor.parseJsonc(`{
10403 // name of the container
10404 "name": "test",
10405 "features": {
10406 "docker-in-docker": {},
10407 },
10408 /* forwarded port list */
10409 "forwardPorts": [3000, 8080,],
10410 }`);
10411 globalThis._name = parsed.name;
10412 globalThis._featureCount = Object.keys(parsed.features).length;
10413 globalThis._portCount = parsed.forwardPorts.length;
10414
10415 // Invalid JSONC should throw.
10416 try {
10417 editor.parseJsonc("{ broken");
10418 globalThis._threw = false;
10419 } catch (_e) {
10420 globalThis._threw = true;
10421 }
10422 "#,
10423 "test.js",
10424 )
10425 .unwrap();
10426
10427 backend
10428 .plugin_contexts
10429 .borrow()
10430 .get("test")
10431 .unwrap()
10432 .clone()
10433 .with(|ctx| {
10434 let global = ctx.globals();
10435 let name: String = global.get("_name").unwrap();
10436 let feature_count: u32 = global.get("_featureCount").unwrap();
10437 let port_count: u32 = global.get("_portCount").unwrap();
10438 let threw: bool = global.get("_threw").unwrap();
10439 assert_eq!(name, "test");
10440 assert_eq!(feature_count, 1);
10441 assert_eq!(port_count, 2);
10442 assert!(threw, "Invalid JSONC should throw");
10443 });
10444 }
10445
10446 #[test]
10447 fn test_api_get_cwd() {
10448 let (mut backend, _rx) = create_test_backend();
10449
10450 backend
10451 .execute_js(
10452 r#"
10453 const editor = getEditor();
10454 globalThis._cwd = editor.getCwd();
10455 "#,
10456 "test.js",
10457 )
10458 .unwrap();
10459
10460 backend
10461 .plugin_contexts
10462 .borrow()
10463 .get("test")
10464 .unwrap()
10465 .clone()
10466 .with(|ctx| {
10467 let global = ctx.globals();
10468 let result: String = global.get("_cwd").unwrap();
10469 assert!(!result.is_empty());
10471 });
10472 }
10473
10474 #[test]
10475 fn test_api_get_env() {
10476 let (mut backend, _rx) = create_test_backend();
10477
10478 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
10480
10481 backend
10482 .execute_js(
10483 r#"
10484 const editor = getEditor();
10485 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
10486 "#,
10487 "test.js",
10488 )
10489 .unwrap();
10490
10491 backend
10492 .plugin_contexts
10493 .borrow()
10494 .get("test")
10495 .unwrap()
10496 .clone()
10497 .with(|ctx| {
10498 let global = ctx.globals();
10499 let result: Option<String> = global.get("_envVal").unwrap();
10500 assert_eq!(result, Some("test_value".to_string()));
10501 });
10502
10503 std::env::remove_var("TEST_PLUGIN_VAR");
10504 }
10505
10506 #[test]
10507 fn test_api_get_config() {
10508 let (mut backend, _rx) = create_test_backend();
10509
10510 backend
10511 .execute_js(
10512 r#"
10513 const editor = getEditor();
10514 const config = editor.getConfig();
10515 globalThis._isObject = typeof config === 'object';
10516 "#,
10517 "test.js",
10518 )
10519 .unwrap();
10520
10521 backend
10522 .plugin_contexts
10523 .borrow()
10524 .get("test")
10525 .unwrap()
10526 .clone()
10527 .with(|ctx| {
10528 let global = ctx.globals();
10529 let is_object: bool = global.get("_isObject").unwrap();
10530 assert!(is_object);
10532 });
10533 }
10534
10535 #[test]
10536 fn test_api_get_themes_dir() {
10537 let (mut backend, _rx) = create_test_backend();
10538
10539 backend
10540 .execute_js(
10541 r#"
10542 const editor = getEditor();
10543 globalThis._themesDir = editor.getThemesDir();
10544 "#,
10545 "test.js",
10546 )
10547 .unwrap();
10548
10549 backend
10550 .plugin_contexts
10551 .borrow()
10552 .get("test")
10553 .unwrap()
10554 .clone()
10555 .with(|ctx| {
10556 let global = ctx.globals();
10557 let result: String = global.get("_themesDir").unwrap();
10558 assert!(!result.is_empty());
10560 });
10561 }
10562
10563 #[test]
10566 fn test_api_read_dir() {
10567 let (mut backend, _rx) = create_test_backend();
10568
10569 backend
10570 .execute_js(
10571 r#"
10572 const editor = getEditor();
10573 const entries = editor.readDir("/tmp");
10574 globalThis._isArray = Array.isArray(entries);
10575 globalThis._length = entries.length;
10576 "#,
10577 "test.js",
10578 )
10579 .unwrap();
10580
10581 backend
10582 .plugin_contexts
10583 .borrow()
10584 .get("test")
10585 .unwrap()
10586 .clone()
10587 .with(|ctx| {
10588 let global = ctx.globals();
10589 let is_array: bool = global.get("_isArray").unwrap();
10590 let length: u32 = global.get("_length").unwrap();
10591 assert!(is_array);
10593 let _ = length;
10595 });
10596 }
10597
10598 #[test]
10601 fn test_api_execute_action() {
10602 let (mut backend, rx) = create_test_backend();
10603
10604 backend
10605 .execute_js(
10606 r#"
10607 const editor = getEditor();
10608 editor.executeAction("move_cursor_up");
10609 "#,
10610 "test.js",
10611 )
10612 .unwrap();
10613
10614 let cmd = rx.try_recv().unwrap();
10615 match cmd {
10616 PluginCommand::ExecuteAction { action_name } => {
10617 assert_eq!(action_name, "move_cursor_up");
10618 }
10619 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
10620 }
10621 }
10622
10623 #[test]
10626 fn test_api_debug() {
10627 let (mut backend, _rx) = create_test_backend();
10628
10629 backend
10631 .execute_js(
10632 r#"
10633 const editor = getEditor();
10634 editor.debug("Test debug message");
10635 editor.debug("Another message with special chars: <>&\"'");
10636 "#,
10637 "test.js",
10638 )
10639 .unwrap();
10640 }
10642
10643 #[test]
10646 fn test_typescript_preamble_generated() {
10647 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
10649 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
10650 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
10651 println!(
10652 "Generated {} bytes of TypeScript preamble",
10653 JSEDITORAPI_TS_PREAMBLE.len()
10654 );
10655 }
10656
10657 #[test]
10658 fn test_typescript_editor_api_generated() {
10659 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
10661 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
10662 println!(
10663 "Generated {} bytes of EditorAPI interface",
10664 JSEDITORAPI_TS_EDITOR_API.len()
10665 );
10666 }
10667
10668 #[test]
10669 fn test_js_methods_list() {
10670 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
10672 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
10673 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
10675 if i < 20 {
10676 println!(" - {}", method);
10677 }
10678 }
10679 if JSEDITORAPI_JS_METHODS.len() > 20 {
10680 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
10681 }
10682 }
10683
10684 #[test]
10687 fn test_api_load_plugin_sends_command() {
10688 let (mut backend, rx) = create_test_backend();
10689
10690 backend
10692 .execute_js(
10693 r#"
10694 const editor = getEditor();
10695 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
10696 "#,
10697 "test.js",
10698 )
10699 .unwrap();
10700
10701 let cmd = rx.try_recv().unwrap();
10703 match cmd {
10704 PluginCommand::LoadPlugin { path, callback_id } => {
10705 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
10706 assert!(callback_id.0 > 0); }
10708 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
10709 }
10710 }
10711
10712 #[test]
10713 fn test_api_unload_plugin_sends_command() {
10714 let (mut backend, rx) = create_test_backend();
10715
10716 backend
10718 .execute_js(
10719 r#"
10720 const editor = getEditor();
10721 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
10722 "#,
10723 "test.js",
10724 )
10725 .unwrap();
10726
10727 let cmd = rx.try_recv().unwrap();
10729 match cmd {
10730 PluginCommand::UnloadPlugin { name, callback_id } => {
10731 assert_eq!(name, "my-plugin");
10732 assert!(callback_id.0 > 0); }
10734 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
10735 }
10736 }
10737
10738 #[test]
10739 fn test_api_reload_plugin_sends_command() {
10740 let (mut backend, rx) = create_test_backend();
10741
10742 backend
10744 .execute_js(
10745 r#"
10746 const editor = getEditor();
10747 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
10748 "#,
10749 "test.js",
10750 )
10751 .unwrap();
10752
10753 let cmd = rx.try_recv().unwrap();
10755 match cmd {
10756 PluginCommand::ReloadPlugin { name, callback_id } => {
10757 assert_eq!(name, "my-plugin");
10758 assert!(callback_id.0 > 0); }
10760 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
10761 }
10762 }
10763
10764 #[test]
10765 fn test_api_load_plugin_resolves_callback() {
10766 let (mut backend, rx) = create_test_backend();
10767
10768 backend
10770 .execute_js(
10771 r#"
10772 const editor = getEditor();
10773 globalThis._loadResult = null;
10774 editor.loadPlugin("/path/to/plugin.ts").then(result => {
10775 globalThis._loadResult = result;
10776 });
10777 "#,
10778 "test.js",
10779 )
10780 .unwrap();
10781
10782 let callback_id = match rx.try_recv().unwrap() {
10784 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
10785 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
10786 };
10787
10788 backend.resolve_callback(callback_id, "true");
10790
10791 backend
10793 .plugin_contexts
10794 .borrow()
10795 .get("test")
10796 .unwrap()
10797 .clone()
10798 .with(|ctx| {
10799 run_pending_jobs_checked(&ctx, "test async loadPlugin");
10800 });
10801
10802 backend
10804 .plugin_contexts
10805 .borrow()
10806 .get("test")
10807 .unwrap()
10808 .clone()
10809 .with(|ctx| {
10810 let global = ctx.globals();
10811 let result: bool = global.get("_loadResult").unwrap();
10812 assert!(result);
10813 });
10814 }
10815
10816 #[test]
10817 fn test_api_version() {
10818 let (mut backend, _rx) = create_test_backend();
10819
10820 backend
10821 .execute_js(
10822 r#"
10823 const editor = getEditor();
10824 globalThis._apiVersion = editor.apiVersion();
10825 "#,
10826 "test.js",
10827 )
10828 .unwrap();
10829
10830 backend
10831 .plugin_contexts
10832 .borrow()
10833 .get("test")
10834 .unwrap()
10835 .clone()
10836 .with(|ctx| {
10837 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10838 assert_eq!(version, 2);
10839 });
10840 }
10841
10842 #[test]
10843 fn test_api_unload_plugin_rejects_on_error() {
10844 let (mut backend, rx) = create_test_backend();
10845
10846 backend
10848 .execute_js(
10849 r#"
10850 const editor = getEditor();
10851 globalThis._unloadError = null;
10852 editor.unloadPlugin("nonexistent-plugin").catch(err => {
10853 globalThis._unloadError = err.message || String(err);
10854 });
10855 "#,
10856 "test.js",
10857 )
10858 .unwrap();
10859
10860 let callback_id = match rx.try_recv().unwrap() {
10862 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10863 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10864 };
10865
10866 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10868
10869 backend
10871 .plugin_contexts
10872 .borrow()
10873 .get("test")
10874 .unwrap()
10875 .clone()
10876 .with(|ctx| {
10877 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10878 });
10879
10880 backend
10882 .plugin_contexts
10883 .borrow()
10884 .get("test")
10885 .unwrap()
10886 .clone()
10887 .with(|ctx| {
10888 let global = ctx.globals();
10889 let error: String = global.get("_unloadError").unwrap();
10890 assert!(error.contains("nonexistent-plugin"));
10891 });
10892 }
10893
10894 #[test]
10895 fn test_api_set_global_state() {
10896 let (mut backend, rx) = create_test_backend();
10897
10898 backend
10899 .execute_js(
10900 r#"
10901 const editor = getEditor();
10902 editor.setGlobalState("myKey", { enabled: true, count: 42 });
10903 "#,
10904 "test_plugin.js",
10905 )
10906 .unwrap();
10907
10908 let cmd = rx.try_recv().unwrap();
10909 match cmd {
10910 PluginCommand::SetGlobalState {
10911 plugin_name,
10912 key,
10913 value,
10914 } => {
10915 assert_eq!(plugin_name, "test_plugin");
10916 assert_eq!(key, "myKey");
10917 let v = value.unwrap();
10918 assert_eq!(v["enabled"], serde_json::json!(true));
10919 assert_eq!(v["count"], serde_json::json!(42));
10920 }
10921 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10922 }
10923 }
10924
10925 #[test]
10926 fn test_api_set_global_state_delete() {
10927 let (mut backend, rx) = create_test_backend();
10928
10929 backend
10930 .execute_js(
10931 r#"
10932 const editor = getEditor();
10933 editor.setGlobalState("myKey", null);
10934 "#,
10935 "test_plugin.js",
10936 )
10937 .unwrap();
10938
10939 let cmd = rx.try_recv().unwrap();
10940 match cmd {
10941 PluginCommand::SetGlobalState {
10942 plugin_name,
10943 key,
10944 value,
10945 } => {
10946 assert_eq!(plugin_name, "test_plugin");
10947 assert_eq!(key, "myKey");
10948 assert!(value.is_none(), "null should delete the key");
10949 }
10950 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10951 }
10952 }
10953
10954 #[test]
10955 fn test_api_get_global_state_roundtrip() {
10956 let (mut backend, _rx) = create_test_backend();
10957
10958 backend
10960 .execute_js(
10961 r#"
10962 const editor = getEditor();
10963 editor.setGlobalState("flag", true);
10964 globalThis._result = editor.getGlobalState("flag");
10965 "#,
10966 "test_plugin.js",
10967 )
10968 .unwrap();
10969
10970 backend
10971 .plugin_contexts
10972 .borrow()
10973 .get("test_plugin")
10974 .unwrap()
10975 .clone()
10976 .with(|ctx| {
10977 let global = ctx.globals();
10978 let result: bool = global.get("_result").unwrap();
10979 assert!(
10980 result,
10981 "getGlobalState should return the value set by setGlobalState"
10982 );
10983 });
10984 }
10985
10986 #[test]
10991 fn test_api_set_session_state_roundtrip() {
10992 let (mut backend, _rx) = create_test_backend();
10993
10994 backend
10995 .execute_js(
10996 r#"
10997 const editor = getEditor();
10998 editor.setWindowState("draft", { count: 7 });
10999 globalThis._result = editor.getWindowState("draft");
11000 globalThis._missing = editor.getWindowState("absent");
11001 "#,
11002 "test_plugin.js",
11003 )
11004 .unwrap();
11005
11006 backend
11007 .plugin_contexts
11008 .borrow()
11009 .get("test_plugin")
11010 .unwrap()
11011 .clone()
11012 .with(|ctx| {
11013 let global = ctx.globals();
11014 let count: i64 = global
11015 .get::<_, rquickjs::Object>("_result")
11016 .unwrap()
11017 .get("count")
11018 .unwrap();
11019 assert_eq!(
11020 count, 7,
11021 "getWindowState should return the value set by setWindowState"
11022 );
11023 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
11024 assert!(
11025 missing.is_undefined(),
11026 "getWindowState for an unset key must be undefined"
11027 );
11028 });
11029 }
11030
11031 #[test]
11032 fn test_api_get_global_state_missing_key() {
11033 let (mut backend, _rx) = create_test_backend();
11034
11035 backend
11036 .execute_js(
11037 r#"
11038 const editor = getEditor();
11039 globalThis._result = editor.getGlobalState("nonexistent");
11040 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
11041 "#,
11042 "test_plugin.js",
11043 )
11044 .unwrap();
11045
11046 backend
11047 .plugin_contexts
11048 .borrow()
11049 .get("test_plugin")
11050 .unwrap()
11051 .clone()
11052 .with(|ctx| {
11053 let global = ctx.globals();
11054 let is_undefined: bool = global.get("_isUndefined").unwrap();
11055 assert!(
11056 is_undefined,
11057 "getGlobalState for missing key should return undefined"
11058 );
11059 });
11060 }
11061
11062 #[test]
11063 fn test_api_global_state_isolation_between_plugins() {
11064 let (tx, _rx) = mpsc::channel();
11066 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
11067 let services = Arc::new(TestServiceBridge::new());
11068
11069 let mut backend_a =
11071 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
11072 .unwrap();
11073 backend_a
11074 .execute_js(
11075 r#"
11076 const editor = getEditor();
11077 editor.setGlobalState("flag", "from_plugin_a");
11078 "#,
11079 "plugin_a.js",
11080 )
11081 .unwrap();
11082
11083 let mut backend_b =
11085 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
11086 .unwrap();
11087 backend_b
11088 .execute_js(
11089 r#"
11090 const editor = getEditor();
11091 editor.setGlobalState("flag", "from_plugin_b");
11092 "#,
11093 "plugin_b.js",
11094 )
11095 .unwrap();
11096
11097 backend_a
11099 .execute_js(
11100 r#"
11101 const editor = getEditor();
11102 globalThis._aValue = editor.getGlobalState("flag");
11103 "#,
11104 "plugin_a.js",
11105 )
11106 .unwrap();
11107
11108 backend_a
11109 .plugin_contexts
11110 .borrow()
11111 .get("plugin_a")
11112 .unwrap()
11113 .clone()
11114 .with(|ctx| {
11115 let global = ctx.globals();
11116 let a_value: String = global.get("_aValue").unwrap();
11117 assert_eq!(
11118 a_value, "from_plugin_a",
11119 "Plugin A should see its own value, not plugin B's"
11120 );
11121 });
11122
11123 backend_b
11125 .execute_js(
11126 r#"
11127 const editor = getEditor();
11128 globalThis._bValue = editor.getGlobalState("flag");
11129 "#,
11130 "plugin_b.js",
11131 )
11132 .unwrap();
11133
11134 backend_b
11135 .plugin_contexts
11136 .borrow()
11137 .get("plugin_b")
11138 .unwrap()
11139 .clone()
11140 .with(|ctx| {
11141 let global = ctx.globals();
11142 let b_value: String = global.get("_bValue").unwrap();
11143 assert_eq!(
11144 b_value, "from_plugin_b",
11145 "Plugin B should see its own value, not plugin A's"
11146 );
11147 });
11148 }
11149
11150 #[test]
11151 fn test_register_command_collision_different_plugins() {
11152 let (mut backend, _rx) = create_test_backend();
11153
11154 backend
11156 .execute_js(
11157 r#"
11158 const editor = getEditor();
11159 globalThis.handlerA = function() { };
11160 editor.registerCommand("My Command", "From A", "handlerA", null);
11161 "#,
11162 "plugin_a.js",
11163 )
11164 .unwrap();
11165
11166 let result = backend.execute_js(
11168 r#"
11169 const editor = getEditor();
11170 globalThis.handlerB = function() { };
11171 editor.registerCommand("My Command", "From B", "handlerB", null);
11172 "#,
11173 "plugin_b.js",
11174 );
11175
11176 assert!(
11177 result.is_err(),
11178 "Second plugin registering the same command name should fail"
11179 );
11180 let err_msg = result.unwrap_err().to_string();
11181 assert!(
11182 err_msg.contains("already registered"),
11183 "Error should mention collision: {}",
11184 err_msg
11185 );
11186 }
11187
11188 #[test]
11189 fn test_register_command_same_plugin_allowed() {
11190 let (mut backend, _rx) = create_test_backend();
11191
11192 backend
11194 .execute_js(
11195 r#"
11196 const editor = getEditor();
11197 globalThis.handler1 = function() { };
11198 editor.registerCommand("My Command", "Version 1", "handler1", null);
11199 globalThis.handler2 = function() { };
11200 editor.registerCommand("My Command", "Version 2", "handler2", null);
11201 "#,
11202 "plugin_a.js",
11203 )
11204 .unwrap();
11205 }
11206
11207 #[test]
11208 fn test_register_command_after_unregister() {
11209 let (mut backend, _rx) = create_test_backend();
11210
11211 backend
11213 .execute_js(
11214 r#"
11215 const editor = getEditor();
11216 globalThis.handlerA = function() { };
11217 editor.registerCommand("My Command", "From A", "handlerA", null);
11218 editor.unregisterCommand("My Command");
11219 "#,
11220 "plugin_a.js",
11221 )
11222 .unwrap();
11223
11224 backend
11226 .execute_js(
11227 r#"
11228 const editor = getEditor();
11229 globalThis.handlerB = function() { };
11230 editor.registerCommand("My Command", "From B", "handlerB", null);
11231 "#,
11232 "plugin_b.js",
11233 )
11234 .unwrap();
11235 }
11236
11237 #[test]
11238 fn test_register_command_collision_caught_in_try_catch() {
11239 let (mut backend, _rx) = create_test_backend();
11240
11241 backend
11243 .execute_js(
11244 r#"
11245 const editor = getEditor();
11246 globalThis.handlerA = function() { };
11247 editor.registerCommand("My Command", "From A", "handlerA", null);
11248 "#,
11249 "plugin_a.js",
11250 )
11251 .unwrap();
11252
11253 backend
11255 .execute_js(
11256 r#"
11257 const editor = getEditor();
11258 globalThis.handlerB = function() { };
11259 let caught = false;
11260 try {
11261 editor.registerCommand("My Command", "From B", "handlerB", null);
11262 } catch (e) {
11263 caught = true;
11264 }
11265 if (!caught) throw new Error("Expected collision error");
11266 "#,
11267 "plugin_b.js",
11268 )
11269 .unwrap();
11270 }
11271
11272 #[test]
11273 fn test_register_command_i18n_key_no_collision_across_plugins() {
11274 let (mut backend, _rx) = create_test_backend();
11275
11276 backend
11278 .execute_js(
11279 r#"
11280 const editor = getEditor();
11281 globalThis.handlerA = function() { };
11282 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
11283 "#,
11284 "plugin_a.js",
11285 )
11286 .unwrap();
11287
11288 backend
11291 .execute_js(
11292 r#"
11293 const editor = getEditor();
11294 globalThis.handlerB = function() { };
11295 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
11296 "#,
11297 "plugin_b.js",
11298 )
11299 .unwrap();
11300 }
11301
11302 #[test]
11303 fn test_register_command_non_i18n_still_collides() {
11304 let (mut backend, _rx) = create_test_backend();
11305
11306 backend
11308 .execute_js(
11309 r#"
11310 const editor = getEditor();
11311 globalThis.handlerA = function() { };
11312 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
11313 "#,
11314 "plugin_a.js",
11315 )
11316 .unwrap();
11317
11318 let result = backend.execute_js(
11320 r#"
11321 const editor = getEditor();
11322 globalThis.handlerB = function() { };
11323 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
11324 "#,
11325 "plugin_b.js",
11326 );
11327
11328 assert!(
11329 result.is_err(),
11330 "Non-%-prefixed names should still collide across plugins"
11331 );
11332 }
11333}