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