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