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