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]
1120 pub fn has_active_search(&self) -> bool {
1121 self.state_snapshot
1122 .read()
1123 .map(|s| s.has_active_search)
1124 .unwrap_or(false)
1125 }
1126
1127 #[plugin_api(ts_return = "BufferInfo[]")]
1129 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1130 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
1131 s.buffers.values().cloned().collect()
1132 } else {
1133 Vec::new()
1134 };
1135 rquickjs_serde::to_value(ctx, &buffers)
1136 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1137 }
1138
1139 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
1141 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1142 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
1143 s.available_grammars.clone()
1144 } else {
1145 Vec::new()
1146 };
1147 rquickjs_serde::to_value(ctx, &grammars)
1148 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1149 }
1150
1151 pub fn debug(&self, msg: String) {
1154 tracing::debug!("Plugin: {}", msg);
1155 }
1156
1157 pub fn info(&self, msg: String) {
1158 tracing::info!("Plugin: {}", msg);
1159 }
1160
1161 pub fn warn(&self, msg: String) {
1162 tracing::warn!("Plugin: {}", msg);
1163 }
1164
1165 pub fn error(&self, msg: String) {
1166 tracing::error!("Plugin: {}", msg);
1167 }
1168
1169 pub fn set_status(&self, msg: String) {
1172 let _ = self
1173 .command_sender
1174 .send(PluginCommand::SetStatus { message: msg });
1175 }
1176
1177 pub fn copy_to_clipboard(&self, text: String) {
1180 let _ = self
1181 .command_sender
1182 .send(PluginCommand::SetClipboard { text });
1183 }
1184
1185 pub fn set_clipboard(&self, text: String) {
1186 let _ = self
1187 .command_sender
1188 .send(PluginCommand::SetClipboard { text });
1189 }
1190
1191 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
1196 if let Some(mode_name) = mode {
1197 let key = format!("{}\0{}", action, mode_name);
1198 if let Ok(snapshot) = self.state_snapshot.read() {
1199 return snapshot.keybinding_labels.get(&key).cloned();
1200 }
1201 }
1202 None
1203 }
1204
1205 pub fn register_command<'js>(
1216 &self,
1217 ctx: rquickjs::Ctx<'js>,
1218 name: String,
1219 description: String,
1220 handler_name: String,
1221 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
1222 rquickjs::Value<'js>,
1223 >,
1224 #[plugin_api(ts_type = "{ terminalBypass?: boolean } | null")]
1225 options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1226 ) -> rquickjs::Result<bool> {
1227 let plugin_name = self.plugin_name.clone();
1229 let context_str: Option<String> = context.0.and_then(|v| {
1231 if v.is_null() || v.is_undefined() {
1232 None
1233 } else {
1234 v.as_string().and_then(|s| s.to_string().ok())
1235 }
1236 });
1237
1238 tracing::debug!(
1239 "registerCommand: plugin='{}', name='{}', handler='{}'",
1240 plugin_name,
1241 name,
1242 handler_name
1243 );
1244
1245 let tracking_key = if name.starts_with('%') {
1249 format!("{}:{}", plugin_name, name)
1250 } else {
1251 name.clone()
1252 };
1253 {
1254 let names = self.registered_command_names.borrow();
1255 if let Some(existing_plugin) = names.get(&tracking_key) {
1256 if existing_plugin != &plugin_name {
1257 let msg = format!(
1258 "Command '{}' already registered by plugin '{}'",
1259 name, existing_plugin
1260 );
1261 tracing::warn!("registerCommand collision: {}", msg);
1262 return Err(
1263 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1264 );
1265 }
1266 }
1268 }
1269
1270 self.registered_command_names
1272 .borrow_mut()
1273 .insert(tracking_key, plugin_name.clone());
1274
1275 self.registered_actions.borrow_mut().insert(
1277 handler_name.clone(),
1278 PluginHandler {
1279 plugin_name: self.plugin_name.clone(),
1280 handler_name: handler_name.clone(),
1281 },
1282 );
1283
1284 let terminal_bypass: bool = options
1288 .0
1289 .and_then(|v| {
1290 if v.is_null() || v.is_undefined() {
1291 None
1292 } else {
1293 v.into_object()
1294 .and_then(|obj| obj.get::<&str, bool>("terminalBypass").ok())
1295 }
1296 })
1297 .unwrap_or(false);
1298
1299 let command = Command {
1301 name: name.clone(),
1302 description,
1303 action_name: handler_name,
1304 plugin_name,
1305 custom_contexts: context_str.into_iter().collect(),
1306 terminal_bypass,
1307 };
1308
1309 Ok(self
1310 .command_sender
1311 .send(PluginCommand::RegisterCommand { command })
1312 .is_ok())
1313 }
1314
1315 pub fn unregister_command(&self, name: String) -> bool {
1317 let tracking_key = if name.starts_with('%') {
1320 format!("{}:{}", self.plugin_name, name)
1321 } else {
1322 name.clone()
1323 };
1324 self.registered_command_names
1325 .borrow_mut()
1326 .remove(&tracking_key);
1327 self.command_sender
1328 .send(PluginCommand::UnregisterCommand { name })
1329 .is_ok()
1330 }
1331
1332 pub fn set_context(&self, name: String, active: bool) -> bool {
1334 if active {
1336 self.plugin_tracked_state
1337 .borrow_mut()
1338 .entry(self.plugin_name.clone())
1339 .or_default()
1340 .contexts_set
1341 .push(name.clone());
1342 }
1343 self.command_sender
1344 .send(PluginCommand::SetContext { name, active })
1345 .is_ok()
1346 }
1347
1348 pub fn execute_action(&self, action_name: String) -> bool {
1350 self.command_sender
1351 .send(PluginCommand::ExecuteAction { action_name })
1352 .is_ok()
1353 }
1354
1355 pub fn cancel_prompt(&self) -> bool {
1360 self.command_sender
1361 .send(PluginCommand::CancelPrompt)
1362 .is_ok()
1363 }
1364
1365 pub fn register_status_bar_element(&self, token_name: String, title: String) -> bool {
1369 let plugin_name = self.plugin_name.clone();
1370 self.command_sender
1371 .send(PluginCommand::RegisterStatusBarElement {
1372 plugin_name,
1373 token_name,
1374 title,
1375 })
1376 .is_ok()
1377 }
1378
1379 pub fn set_status_bar_value(&self, buffer_id: u64, token_name: String, value: String) -> bool {
1382 let key = format!("{}:{}", self.plugin_name, token_name);
1383 self.command_sender
1384 .send(PluginCommand::SetStatusBarValue {
1385 buffer_id,
1386 key,
1387 value,
1388 })
1389 .is_ok()
1390 }
1391
1392 pub fn t<'js>(
1397 &self,
1398 _ctx: rquickjs::Ctx<'js>,
1399 key: String,
1400 args: rquickjs::function::Rest<Value<'js>>,
1401 ) -> String {
1402 let plugin_name = self.plugin_name.clone();
1404 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1406 if let Some(obj) = first_arg.as_object() {
1407 let mut map = HashMap::new();
1408 for k in obj.keys::<String>().flatten() {
1409 if let Ok(v) = obj.get::<_, String>(&k) {
1410 map.insert(k, v);
1411 }
1412 }
1413 map
1414 } else {
1415 HashMap::new()
1416 }
1417 } else {
1418 HashMap::new()
1419 };
1420 let res = self.services.translate(&plugin_name, &key, &args_map);
1421
1422 tracing::info!(
1423 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1424 key,
1425 plugin_name,
1426 args_map,
1427 res
1428 );
1429 res
1430 }
1431
1432 pub fn get_cursor_position(&self) -> u32 {
1436 self.state_snapshot
1437 .read()
1438 .ok()
1439 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1440 .unwrap_or(0)
1441 }
1442
1443 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1445 if let Ok(s) = self.state_snapshot.read() {
1446 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1447 if let Some(p) = &b.path {
1448 return p.to_string_lossy().to_string();
1449 }
1450 }
1451 }
1452 String::new()
1453 }
1454
1455 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
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.length as u32;
1460 }
1461 }
1462 0
1463 }
1464
1465 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1467 if let Ok(s) = self.state_snapshot.read() {
1468 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1469 return b.modified;
1470 }
1471 }
1472 false
1473 }
1474
1475 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1478 self.command_sender
1479 .send(PluginCommand::SaveBufferToPath {
1480 buffer_id: BufferId(buffer_id as usize),
1481 path: std::path::PathBuf::from(path),
1482 })
1483 .is_ok()
1484 }
1485
1486 #[plugin_api(ts_return = "BufferInfo | null")]
1488 pub fn get_buffer_info<'js>(
1489 &self,
1490 ctx: rquickjs::Ctx<'js>,
1491 buffer_id: u32,
1492 ) -> rquickjs::Result<Value<'js>> {
1493 let info = if let Ok(s) = self.state_snapshot.read() {
1494 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1495 } else {
1496 None
1497 };
1498 rquickjs_serde::to_value(ctx, &info)
1499 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1500 }
1501
1502 #[plugin_api(ts_return = "CursorInfo | null")]
1504 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1505 let cursor = if let Ok(s) = self.state_snapshot.read() {
1506 s.primary_cursor.clone()
1507 } else {
1508 None
1509 };
1510 rquickjs_serde::to_value(ctx, &cursor)
1511 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1512 }
1513
1514 #[plugin_api(ts_return = "CursorInfo[]")]
1516 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1517 let cursors = if let Ok(s) = self.state_snapshot.read() {
1518 s.all_cursors.clone()
1519 } else {
1520 Vec::new()
1521 };
1522 rquickjs_serde::to_value(ctx, &cursors)
1523 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1524 }
1525
1526 #[plugin_api(ts_return = "number[]")]
1528 pub fn get_all_cursor_positions<'js>(
1529 &self,
1530 ctx: rquickjs::Ctx<'js>,
1531 ) -> rquickjs::Result<Value<'js>> {
1532 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1533 s.all_cursors.iter().map(|c| c.position as u32).collect()
1534 } else {
1535 Vec::new()
1536 };
1537 rquickjs_serde::to_value(ctx, &positions)
1538 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1539 }
1540
1541 #[plugin_api(ts_return = "ViewportInfo | null")]
1543 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1544 let viewport = if let Ok(s) = self.state_snapshot.read() {
1545 s.viewport.clone()
1546 } else {
1547 None
1548 };
1549 rquickjs_serde::to_value(ctx, &viewport)
1550 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1551 }
1552
1553 #[plugin_api(ts_return = "ScreenSize")]
1558 pub fn get_screen_size<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1559 let size = if let Ok(s) = self.state_snapshot.read() {
1560 fresh_core::api::ScreenSize {
1561 width: s.terminal_width,
1562 height: s.terminal_height,
1563 }
1564 } else {
1565 fresh_core::api::ScreenSize {
1566 width: 0,
1567 height: 0,
1568 }
1569 };
1570 rquickjs_serde::to_value(ctx, &size)
1571 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1572 }
1573
1574 #[plugin_api(ts_return = "SplitSnapshot[]")]
1581 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1582 let splits = if let Ok(s) = self.state_snapshot.read() {
1583 s.splits.clone()
1584 } else {
1585 Vec::new()
1586 };
1587 rquickjs_serde::to_value(ctx, &splits)
1588 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1589 }
1590
1591 pub fn get_cursor_line(&self) -> u32 {
1599 self.state_snapshot
1600 .read()
1601 .ok()
1602 .and_then(|s| s.primary_cursor.as_ref().and_then(|c| c.line))
1603 .unwrap_or(0) as u32
1604 }
1605
1606 #[plugin_api(
1609 async_promise,
1610 js_name = "getLineStartPosition",
1611 ts_return = "number | null"
1612 )]
1613 #[qjs(rename = "_getLineStartPositionStart")]
1614 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1615 let id = self.alloc_request_id();
1616 let _ = self
1618 .command_sender
1619 .send(PluginCommand::GetLineStartPosition {
1620 buffer_id: BufferId(0),
1621 line,
1622 request_id: id,
1623 });
1624 id
1625 }
1626
1627 #[plugin_api(
1631 async_promise,
1632 js_name = "getLineEndPosition",
1633 ts_return = "number | null"
1634 )]
1635 #[qjs(rename = "_getLineEndPositionStart")]
1636 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1637 let id = self.alloc_request_id();
1638 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1640 buffer_id: BufferId(0),
1641 line,
1642 request_id: id,
1643 });
1644 id
1645 }
1646
1647 #[plugin_api(
1650 async_promise,
1651 js_name = "getBufferLineCount",
1652 ts_return = "number | null"
1653 )]
1654 #[qjs(rename = "_getBufferLineCountStart")]
1655 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1656 let id = self.alloc_request_id();
1657 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1659 buffer_id: BufferId(0),
1660 request_id: id,
1661 });
1662 id
1663 }
1664
1665 #[plugin_api(
1673 async_promise,
1674 js_name = "getCompositeCursorInfo",
1675 ts_return = "{ focusedPane: number, paneCount: number, lines: Array<number | null> } | null"
1676 )]
1677 #[qjs(rename = "_getCompositeCursorInfoStart")]
1678 pub fn get_composite_cursor_info_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1679 let id = self.alloc_request_id();
1680 let _ = self
1681 .command_sender
1682 .send(PluginCommand::GetCompositeCursorInfo { request_id: id });
1683 id
1684 }
1685
1686 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1689 self.command_sender
1690 .send(PluginCommand::ScrollToLineCenter {
1691 split_id: SplitId(split_id as usize),
1692 buffer_id: BufferId(buffer_id as usize),
1693 line: line as usize,
1694 })
1695 .is_ok()
1696 }
1697
1698 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1707 self.command_sender
1708 .send(PluginCommand::ScrollBufferToLine {
1709 buffer_id: BufferId(buffer_id as usize),
1710 line: line as usize,
1711 })
1712 .is_ok()
1713 }
1714
1715 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1717 let path_buf = std::path::PathBuf::from(&path);
1718 if let Ok(s) = self.state_snapshot.read() {
1719 for (id, info) in &s.buffers {
1720 if let Some(buf_path) = &info.path {
1721 if buf_path == &path_buf {
1722 return id.0 as u32;
1723 }
1724 }
1725 }
1726 }
1727 0
1728 }
1729
1730 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1732 pub fn get_buffer_saved_diff<'js>(
1733 &self,
1734 ctx: rquickjs::Ctx<'js>,
1735 buffer_id: u32,
1736 ) -> rquickjs::Result<Value<'js>> {
1737 let diff = if let Ok(s) = self.state_snapshot.read() {
1738 s.buffer_saved_diffs
1739 .get(&BufferId(buffer_id as usize))
1740 .cloned()
1741 } else {
1742 None
1743 };
1744 rquickjs_serde::to_value(ctx, &diff)
1745 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1746 }
1747
1748 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1752 self.command_sender
1753 .send(PluginCommand::InsertText {
1754 buffer_id: BufferId(buffer_id as usize),
1755 position: position as usize,
1756 text,
1757 })
1758 .is_ok()
1759 }
1760
1761 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1763 self.command_sender
1764 .send(PluginCommand::DeleteRange {
1765 buffer_id: BufferId(buffer_id as usize),
1766 range: (start as usize)..(end as usize),
1767 })
1768 .is_ok()
1769 }
1770
1771 pub fn insert_at_cursor(&self, text: String) -> bool {
1773 self.command_sender
1774 .send(PluginCommand::InsertAtCursor { text })
1775 .is_ok()
1776 }
1777
1778 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1782 self.command_sender
1783 .send(PluginCommand::OpenFileAtLocation {
1784 path: PathBuf::from(path),
1785 line: line.map(|l| l as usize),
1786 column: column.map(|c| c as usize),
1787 })
1788 .is_ok()
1789 }
1790
1791 pub fn open_file_in_background(
1799 &self,
1800 path: String,
1801 window_id: rquickjs::function::Opt<u64>,
1802 ) -> bool {
1803 self.command_sender
1804 .send(PluginCommand::OpenFileInBackground {
1805 path: PathBuf::from(path),
1806 window_id: window_id.0.map(fresh_core::WindowId),
1807 })
1808 .is_ok()
1809 }
1810
1811 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1813 self.command_sender
1814 .send(PluginCommand::OpenFileInSplit {
1815 split_id: split_id as usize,
1816 path: PathBuf::from(path),
1817 line: Some(line as usize),
1818 column: Some(column as usize),
1819 })
1820 .is_ok()
1821 }
1822
1823 #[plugin_api(
1832 async_promise,
1833 js_name = "openFileStreaming",
1834 ts_return = "number | null"
1835 )]
1836 #[qjs(rename = "_openFileStreamingStart")]
1837 pub fn open_file_streaming_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
1838 let id = self.alloc_request_id();
1839 let _ = self.command_sender.send(PluginCommand::OpenFileStreaming {
1840 path: PathBuf::from(path),
1841 request_id: id,
1842 });
1843 id
1844 }
1845
1846 #[plugin_api(
1854 async_promise,
1855 js_name = "refreshBufferFromDisk",
1856 ts_return = "number | null"
1857 )]
1858 #[qjs(rename = "_refreshBufferFromDiskStart")]
1859 pub fn refresh_buffer_from_disk_start(&self, _ctx: rquickjs::Ctx<'_>, buffer_id: u32) -> u64 {
1860 let id = self.alloc_request_id();
1861 let _ = self
1862 .command_sender
1863 .send(PluginCommand::RefreshBufferFromDisk {
1864 buffer_id: BufferId(buffer_id as usize),
1865 request_id: id,
1866 });
1867 id
1868 }
1869
1870 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1872 self.command_sender
1873 .send(PluginCommand::ShowBuffer {
1874 buffer_id: BufferId(buffer_id as usize),
1875 })
1876 .is_ok()
1877 }
1878
1879 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1881 self.command_sender
1882 .send(PluginCommand::CloseBuffer {
1883 buffer_id: BufferId(buffer_id as usize),
1884 })
1885 .is_ok()
1886 }
1887
1888 pub fn close_other_buffers_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1890 self.command_sender
1891 .send(PluginCommand::CloseOtherBuffersInSplit {
1892 buffer_id: BufferId(buffer_id as usize),
1893 split_id: SplitId(split_id as usize),
1894 })
1895 .is_ok()
1896 }
1897
1898 pub fn close_all_buffers_in_split(&self, split_id: u32) -> bool {
1900 self.command_sender
1901 .send(PluginCommand::CloseAllBuffersInSplit {
1902 split_id: SplitId(split_id as usize),
1903 })
1904 .is_ok()
1905 }
1906
1907 pub fn close_buffers_to_right_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1909 self.command_sender
1910 .send(PluginCommand::CloseBuffersToRightInSplit {
1911 buffer_id: BufferId(buffer_id as usize),
1912 split_id: SplitId(split_id as usize),
1913 })
1914 .is_ok()
1915 }
1916
1917 pub fn close_buffers_to_left_in_split(&self, buffer_id: u32, split_id: u32) -> bool {
1919 self.command_sender
1920 .send(PluginCommand::CloseBuffersToLeftInSplit {
1921 buffer_id: BufferId(buffer_id as usize),
1922 split_id: SplitId(split_id as usize),
1923 })
1924 .is_ok()
1925 }
1926
1927 #[plugin_api(ts_return = "boolean")]
1929 pub fn move_tab_to_left(&self) -> bool {
1930 self.command_sender.send(PluginCommand::MoveTabLeft).is_ok()
1931 }
1932
1933 #[plugin_api(ts_return = "boolean")]
1935 pub fn move_tab_to_right(&self) -> bool {
1936 self.command_sender
1937 .send(PluginCommand::MoveTabRight)
1938 .is_ok()
1939 }
1940
1941 #[plugin_api(skip)]
1947 #[qjs(skip)]
1948 fn alloc_request_id(&self) -> u64 {
1949 let mut id_ref = self.next_request_id.borrow_mut();
1950 let id = *id_ref;
1951 *id_ref += 1;
1952 self.callback_contexts
1953 .borrow_mut()
1954 .insert(id, self.plugin_name.clone());
1955 id
1956 }
1957
1958 #[plugin_api(skip)]
1962 #[qjs(skip)]
1963 fn alloc_animation_id(&self) -> u64 {
1964 let mut id_ref = self.next_request_id.borrow_mut();
1965 let id = *id_ref;
1966 *id_ref += 1;
1967 id
1968 }
1969
1970 pub fn animate_area<'js>(
1973 &self,
1974 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1975 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1976 ) -> rquickjs::Result<u64> {
1977 let rect = parse_animation_rect(&rect)?;
1978 let kind = parse_animation_kind(&kind)?;
1979 let id = self.alloc_animation_id();
1980 let _ = self
1981 .command_sender
1982 .send(PluginCommand::StartAnimationArea { id, rect, kind });
1983 Ok(id)
1984 }
1985
1986 pub fn animate_virtual_buffer<'js>(
1989 &self,
1990 buffer_id: u32,
1991 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1992 ) -> rquickjs::Result<u64> {
1993 let kind = parse_animation_kind(&kind)?;
1994 let id = self.alloc_animation_id();
1995 let _ = self
1996 .command_sender
1997 .send(PluginCommand::StartAnimationVirtualBuffer {
1998 id,
1999 buffer_id: BufferId(buffer_id as usize),
2000 kind,
2001 });
2002 Ok(id)
2003 }
2004
2005 pub fn cancel_animation(&self, id: u64) -> bool {
2008 self.command_sender
2009 .send(PluginCommand::CancelAnimation { id })
2010 .is_ok()
2011 }
2012
2013 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
2017 if event_name == "lines_changed" {
2021 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
2022 }
2023 self.event_handlers
2024 .write()
2025 .expect("event_handlers poisoned")
2026 .entry(event_name)
2027 .or_default()
2028 .push(PluginHandler {
2029 plugin_name: self.plugin_name.clone(),
2030 handler_name,
2031 });
2032 }
2033
2034 pub fn off(&self, event_name: String, handler_name: String) {
2036 if let Some(list) = self
2037 .event_handlers
2038 .write()
2039 .expect("event_handlers poisoned")
2040 .get_mut(&event_name)
2041 {
2042 list.retain(|h| h.handler_name != handler_name);
2043 }
2044 }
2045
2046 pub fn get_env(&self, name: String) -> Option<String> {
2050 std::env::var(&name).ok()
2051 }
2052
2053 pub fn get_cwd(&self) -> String {
2055 self.state_snapshot
2056 .read()
2057 .map(|s| s.working_dir.to_string_lossy().to_string())
2058 .unwrap_or_else(|_| ".".to_string())
2059 }
2060
2061 pub fn get_authority_label(&self) -> String {
2070 self.state_snapshot
2071 .read()
2072 .map(|s| s.authority_label.clone())
2073 .unwrap_or_default()
2074 }
2075
2076 pub fn workspace_trust_level(&self) -> String {
2081 self.state_snapshot
2082 .read()
2083 .map(|s| s.workspace_trust_level.clone())
2084 .unwrap_or_default()
2085 }
2086
2087 pub fn env_active(&self) -> bool {
2092 self.state_snapshot
2093 .read()
2094 .map(|s| s.env_active)
2095 .unwrap_or(false)
2096 }
2097
2098 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
2110 let mut result_parts: Vec<String> = Vec::new();
2111 let mut leading_slashes: u8 = 0;
2113
2114 for part in &parts.0 {
2115 let normalized = part.replace('\\', "/");
2117
2118 let is_absolute = normalized.starts_with('/')
2120 || (normalized.len() >= 2
2121 && normalized
2122 .chars()
2123 .next()
2124 .map(|c| c.is_ascii_alphabetic())
2125 .unwrap_or(false)
2126 && normalized.chars().nth(1) == Some(':'));
2127
2128 if is_absolute {
2129 result_parts.clear();
2131 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
2135 }
2136
2137 for segment in normalized.split('/') {
2139 if !segment.is_empty() && segment != "." {
2140 if segment == ".." {
2141 result_parts.pop();
2142 } else {
2143 result_parts.push(segment.to_string());
2144 }
2145 }
2146 }
2147 }
2148
2149 let joined = result_parts.join("/");
2151 let prefix = match leading_slashes {
2152 0 => "",
2153 1 => "/",
2154 _ => "//",
2155 };
2156
2157 if leading_slashes > 0 {
2158 format!("{}{}", prefix, joined)
2159 } else {
2160 joined
2161 }
2162 }
2163
2164 pub fn path_dirname(&self, path: String) -> String {
2166 Path::new(&path)
2167 .parent()
2168 .map(|p| p.to_string_lossy().to_string())
2169 .unwrap_or_default()
2170 }
2171
2172 pub fn path_basename(&self, path: String) -> String {
2174 Path::new(&path)
2175 .file_name()
2176 .map(|s| s.to_string_lossy().to_string())
2177 .unwrap_or_default()
2178 }
2179
2180 pub fn path_extname(&self, path: String) -> String {
2182 Path::new(&path)
2183 .extension()
2184 .map(|s| format!(".{}", s.to_string_lossy()))
2185 .unwrap_or_default()
2186 }
2187
2188 pub fn path_is_absolute(&self, path: String) -> bool {
2190 Path::new(&path).is_absolute()
2191 }
2192
2193 pub fn file_uri_to_path(&self, uri: String) -> String {
2197 fresh_core::file_uri::file_uri_to_path(&uri)
2198 .map(|p| p.to_string_lossy().to_string())
2199 .unwrap_or_default()
2200 }
2201
2202 pub fn path_to_file_uri(&self, path: String) -> String {
2206 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
2207 }
2208
2209 pub fn utf8_byte_length(&self, text: String) -> u32 {
2217 text.len() as u32
2218 }
2219
2220 pub fn file_exists(&self, path: String) -> bool {
2224 Path::new(&path).exists()
2225 }
2226
2227 pub fn read_file(&self, path: String) -> Option<String> {
2229 std::fs::read_to_string(&path).ok()
2230 }
2231
2232 pub fn write_file(&self, path: String, content: String) -> bool {
2234 let p = Path::new(&path);
2235 if let Some(parent) = p.parent() {
2236 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2237 return false;
2238 }
2239 }
2240 std::fs::write(p, content).is_ok()
2241 }
2242
2243 #[plugin_api(ts_return = "DirEntry[]")]
2245 pub fn read_dir<'js>(
2246 &self,
2247 ctx: rquickjs::Ctx<'js>,
2248 path: String,
2249 ) -> rquickjs::Result<Value<'js>> {
2250 use fresh_core::api::DirEntry;
2251
2252 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
2253 Ok(entries) => entries
2254 .filter_map(|e| e.ok())
2255 .map(|entry| {
2256 let file_type = entry.file_type().ok();
2257 DirEntry {
2258 name: entry.file_name().to_string_lossy().to_string(),
2259 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
2260 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
2261 }
2262 })
2263 .collect(),
2264 Err(e) => {
2265 tracing::warn!("readDir failed for '{}': {}", path, e);
2266 Vec::new()
2267 }
2268 };
2269
2270 rquickjs_serde::to_value(ctx, &entries)
2271 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2272 }
2273
2274 pub fn create_dir(&self, path: String) -> bool {
2277 let p = Path::new(&path);
2278 if p.is_dir() {
2279 return true;
2280 }
2281 std::fs::create_dir_all(p).is_ok()
2282 }
2283
2284 pub fn remove_path(&self, path: String) -> bool {
2288 let target = match Path::new(&path).canonicalize() {
2289 Ok(p) => p,
2290 Err(_) => return false, };
2292
2293 let temp_dir = std::env::temp_dir()
2299 .canonicalize()
2300 .unwrap_or_else(|_| std::env::temp_dir());
2301 let config_dir = self
2302 .services
2303 .config_dir()
2304 .canonicalize()
2305 .unwrap_or_else(|_| self.services.config_dir());
2306
2307 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
2309 if !allowed {
2310 tracing::warn!(
2311 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
2312 target,
2313 temp_dir,
2314 config_dir
2315 );
2316 return false;
2317 }
2318
2319 if target == temp_dir || target == config_dir {
2321 tracing::warn!(
2322 "removePath refused: cannot remove root directory {:?}",
2323 target
2324 );
2325 return false;
2326 }
2327
2328 match trash::delete(&target) {
2329 Ok(()) => true,
2330 Err(e) => {
2331 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
2332 false
2333 }
2334 }
2335 }
2336
2337 pub fn rename_path(&self, from: String, to: String) -> bool {
2340 if std::fs::rename(&from, &to).is_ok() {
2342 return true;
2343 }
2344 let from_path = Path::new(&from);
2346 let copied = if from_path.is_dir() {
2347 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
2348 } else {
2349 std::fs::copy(&from, &to).is_ok()
2350 };
2351 if copied {
2352 return trash::delete(from_path).is_ok();
2353 }
2354 false
2355 }
2356
2357 pub fn copy_path(&self, from: String, to: String) -> bool {
2360 let from_path = Path::new(&from);
2361 let to_path = Path::new(&to);
2362 if from_path.is_dir() {
2363 copy_dir_recursive(from_path, to_path).is_ok()
2364 } else {
2365 if let Some(parent) = to_path.parent() {
2367 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
2368 return false;
2369 }
2370 }
2371 std::fs::copy(from_path, to_path).is_ok()
2372 }
2373 }
2374
2375 pub fn get_temp_dir(&self) -> String {
2377 std::env::temp_dir().to_string_lossy().to_string()
2378 }
2379
2380 #[plugin_api(ts_return = "unknown")]
2391 pub fn parse_jsonc<'js>(
2392 &self,
2393 ctx: rquickjs::Ctx<'js>,
2394 text: String,
2395 ) -> rquickjs::Result<Value<'js>> {
2396 let value: serde_json::Value =
2397 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
2398 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
2399 })?;
2400 rquickjs_serde::to_value(ctx, &value)
2401 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2402 }
2403
2404 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2413 let config = self
2414 .state_snapshot
2415 .read()
2416 .map(|s| std::sync::Arc::clone(&s.config))
2417 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2418
2419 rquickjs_serde::to_value(ctx, &*config)
2420 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2421 }
2422
2423 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2425 let config = self
2426 .state_snapshot
2427 .read()
2428 .map(|s| std::sync::Arc::clone(&s.user_config))
2429 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2430
2431 rquickjs_serde::to_value(ctx, &*config)
2432 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2433 }
2434
2435 #[plugin_api(ts_return = "boolean")]
2443 pub fn define_config_boolean<'js>(
2444 &self,
2445 ctx: rquickjs::Ctx<'js>,
2446 name: String,
2447 #[plugin_api(ts_type = "{ default: boolean; description?: string }")]
2448 options: rquickjs::Object<'js>,
2449 ) -> rquickjs::Result<bool> {
2450 let opts = parse_options(&ctx, "defineConfigBoolean", &name, options)?;
2451 validate_allowed_keys(
2452 &ctx,
2453 "defineConfigBoolean",
2454 &name,
2455 &opts,
2456 &["default", "description"],
2457 )?;
2458 let default = match opts.get("default") {
2459 Some(serde_json::Value::Bool(b)) => *b,
2460 _ => {
2461 return Err(throw_js(
2462 &ctx,
2463 &format!(
2464 "defineConfigBoolean(\"{}\"): `default` (boolean) is required",
2465 name
2466 ),
2467 ));
2468 }
2469 };
2470 let description = string_opt(&opts, "description");
2471 let mut field = serde_json::Map::new();
2472 field.insert("type".into(), serde_json::json!("boolean"));
2473 field.insert("default".into(), serde_json::json!(default));
2474 if let Some(d) = description {
2475 field.insert("description".into(), serde_json::json!(d));
2476 }
2477 self.send_field_registration(&name, serde_json::Value::Object(field));
2478 Ok(self
2479 .current_field_value(&name)
2480 .and_then(|v| v.as_bool())
2481 .unwrap_or(default))
2482 }
2483
2484 #[plugin_api(ts_return = "number")]
2487 pub fn define_config_integer<'js>(
2488 &self,
2489 ctx: rquickjs::Ctx<'js>,
2490 name: String,
2491 #[plugin_api(
2492 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2493 )]
2494 options: rquickjs::Object<'js>,
2495 ) -> rquickjs::Result<i64> {
2496 let opts = parse_options(&ctx, "defineConfigInteger", &name, options)?;
2497 validate_allowed_keys(
2498 &ctx,
2499 "defineConfigInteger",
2500 &name,
2501 &opts,
2502 &["default", "description", "minimum", "maximum"],
2503 )?;
2504 let default = require_integer(&ctx, "defineConfigInteger", &name, &opts, "default")?;
2505 let minimum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "minimum")?;
2506 let maximum = optional_integer(&ctx, "defineConfigInteger", &name, &opts, "maximum")?;
2507 check_range(
2508 &ctx,
2509 "defineConfigInteger",
2510 &name,
2511 default as f64,
2512 minimum.map(|v| v as f64),
2513 maximum.map(|v| v as f64),
2514 )?;
2515 let description = string_opt(&opts, "description");
2516 let mut field = serde_json::Map::new();
2517 field.insert("type".into(), serde_json::json!("integer"));
2518 field.insert("default".into(), serde_json::json!(default));
2519 if let Some(d) = description {
2520 field.insert("description".into(), serde_json::json!(d));
2521 }
2522 if let Some(v) = minimum {
2523 field.insert("minimum".into(), serde_json::json!(v));
2524 }
2525 if let Some(v) = maximum {
2526 field.insert("maximum".into(), serde_json::json!(v));
2527 }
2528 self.send_field_registration(&name, serde_json::Value::Object(field));
2529 Ok(self
2530 .current_field_value(&name)
2531 .and_then(|v| v.as_i64())
2532 .unwrap_or(default))
2533 }
2534
2535 #[plugin_api(ts_return = "number")]
2538 pub fn define_config_number<'js>(
2539 &self,
2540 ctx: rquickjs::Ctx<'js>,
2541 name: String,
2542 #[plugin_api(
2543 ts_type = "{ default: number; description?: string; minimum?: number; maximum?: number }"
2544 )]
2545 options: rquickjs::Object<'js>,
2546 ) -> rquickjs::Result<f64> {
2547 let opts = parse_options(&ctx, "defineConfigNumber", &name, options)?;
2548 validate_allowed_keys(
2549 &ctx,
2550 "defineConfigNumber",
2551 &name,
2552 &opts,
2553 &["default", "description", "minimum", "maximum"],
2554 )?;
2555 let default = require_number(&ctx, "defineConfigNumber", &name, &opts, "default")?;
2556 let minimum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "minimum")?;
2557 let maximum = optional_number(&ctx, "defineConfigNumber", &name, &opts, "maximum")?;
2558 check_range(&ctx, "defineConfigNumber", &name, default, minimum, maximum)?;
2559 let description = string_opt(&opts, "description");
2560 let mut field = serde_json::Map::new();
2561 field.insert("type".into(), serde_json::json!("number"));
2562 field.insert("default".into(), serde_json::json!(default));
2563 if let Some(d) = description {
2564 field.insert("description".into(), serde_json::json!(d));
2565 }
2566 if let Some(v) = minimum {
2567 field.insert("minimum".into(), serde_json::json!(v));
2568 }
2569 if let Some(v) = maximum {
2570 field.insert("maximum".into(), serde_json::json!(v));
2571 }
2572 self.send_field_registration(&name, serde_json::Value::Object(field));
2573 Ok(self
2574 .current_field_value(&name)
2575 .and_then(|v| v.as_f64())
2576 .unwrap_or(default))
2577 }
2578
2579 #[plugin_api(ts_return = "string")]
2581 pub fn define_config_string<'js>(
2582 &self,
2583 ctx: rquickjs::Ctx<'js>,
2584 name: String,
2585 #[plugin_api(ts_type = "{ default: string; description?: string }")]
2586 options: rquickjs::Object<'js>,
2587 ) -> rquickjs::Result<String> {
2588 let opts = parse_options(&ctx, "defineConfigString", &name, options)?;
2589 validate_allowed_keys(
2590 &ctx,
2591 "defineConfigString",
2592 &name,
2593 &opts,
2594 &["default", "description"],
2595 )?;
2596 let default = match opts.get("default") {
2597 Some(serde_json::Value::String(s)) => s.clone(),
2598 _ => {
2599 return Err(throw_js(
2600 &ctx,
2601 &format!(
2602 "defineConfigString(\"{}\"): `default` (string) is required",
2603 name
2604 ),
2605 ));
2606 }
2607 };
2608 let description = string_opt(&opts, "description");
2609 let mut field = serde_json::Map::new();
2610 field.insert("type".into(), serde_json::json!("string"));
2611 field.insert("default".into(), serde_json::json!(default));
2612 if let Some(d) = description {
2613 field.insert("description".into(), serde_json::json!(d));
2614 }
2615 self.send_field_registration(&name, serde_json::Value::Object(field));
2616 Ok(self
2617 .current_field_value(&name)
2618 .and_then(|v| v.as_str().map(|s| s.to_string()))
2619 .unwrap_or(default))
2620 }
2621
2622 #[plugin_api(skip)]
2629 pub fn define_config_enum<'js>(
2630 &self,
2631 ctx: rquickjs::Ctx<'js>,
2632 name: String,
2633 options: rquickjs::Object<'js>,
2634 ) -> rquickjs::Result<String> {
2635 let opts = parse_options(&ctx, "defineConfigEnum", &name, options)?;
2636 validate_allowed_keys(
2637 &ctx,
2638 "defineConfigEnum",
2639 &name,
2640 &opts,
2641 &["default", "description", "values"],
2642 )?;
2643 let values: Vec<String> = match opts.get("values") {
2644 Some(serde_json::Value::Array(arr)) if !arr.is_empty() => {
2645 let mut out = Vec::with_capacity(arr.len());
2646 for v in arr {
2647 match v {
2648 serde_json::Value::String(s) => out.push(s.clone()),
2649 _ => {
2650 return Err(throw_js(
2651 &ctx,
2652 &format!(
2653 "defineConfigEnum(\"{}\"): `values` must be an array of strings",
2654 name
2655 ),
2656 ));
2657 }
2658 }
2659 }
2660 out
2661 }
2662 _ => {
2663 return Err(throw_js(
2664 &ctx,
2665 &format!(
2666 "defineConfigEnum(\"{}\"): `values` (non-empty string[]) is required",
2667 name
2668 ),
2669 ));
2670 }
2671 };
2672 let default = match opts.get("default") {
2673 Some(serde_json::Value::String(s)) => s.clone(),
2674 _ => {
2675 return Err(throw_js(
2676 &ctx,
2677 &format!(
2678 "defineConfigEnum(\"{}\"): `default` (string) is required",
2679 name
2680 ),
2681 ));
2682 }
2683 };
2684 if !values.contains(&default) {
2685 return Err(throw_js(
2686 &ctx,
2687 &format!(
2688 "defineConfigEnum(\"{}\"): `default` must be one of {:?}",
2689 name, values
2690 ),
2691 ));
2692 }
2693 let description = string_opt(&opts, "description");
2694 let mut field = serde_json::Map::new();
2695 field.insert("type".into(), serde_json::json!("string"));
2696 field.insert("enum".into(), serde_json::json!(values));
2697 field.insert("default".into(), serde_json::json!(default));
2698 if let Some(d) = description {
2699 field.insert("description".into(), serde_json::json!(d));
2700 }
2701 self.send_field_registration(&name, serde_json::Value::Object(field));
2702 let current = self
2703 .current_field_value(&name)
2704 .and_then(|v| v.as_str().map(|s| s.to_string()));
2705 Ok(current.filter(|v| values.contains(v)).unwrap_or(default))
2709 }
2710
2711 #[plugin_api(ts_return = "string[]")]
2714 pub fn define_config_string_array<'js>(
2715 &self,
2716 ctx: rquickjs::Ctx<'js>,
2717 name: String,
2718 #[plugin_api(ts_type = "{ default: string[]; description?: string }")]
2719 options: rquickjs::Object<'js>,
2720 ) -> rquickjs::Result<Vec<String>> {
2721 let opts = parse_options(&ctx, "defineConfigStringArray", &name, options)?;
2722 validate_allowed_keys(
2723 &ctx,
2724 "defineConfigStringArray",
2725 &name,
2726 &opts,
2727 &["default", "description"],
2728 )?;
2729 let default: Vec<String> = match opts.get("default") {
2730 Some(serde_json::Value::Array(arr)) => {
2731 let mut out = Vec::with_capacity(arr.len());
2732 for v in arr {
2733 match v {
2734 serde_json::Value::String(s) => out.push(s.clone()),
2735 _ => {
2736 return Err(throw_js(
2737 &ctx,
2738 &format!(
2739 "defineConfigStringArray(\"{}\"): `default` entries must all be strings",
2740 name
2741 ),
2742 ));
2743 }
2744 }
2745 }
2746 out
2747 }
2748 _ => {
2749 return Err(throw_js(
2750 &ctx,
2751 &format!(
2752 "defineConfigStringArray(\"{}\"): `default` (string[]) is required",
2753 name
2754 ),
2755 ));
2756 }
2757 };
2758 let description = string_opt(&opts, "description");
2759 let mut field = serde_json::Map::new();
2760 field.insert("type".into(), serde_json::json!("array"));
2761 field.insert("items".into(), serde_json::json!({"type": "string"}));
2762 field.insert("default".into(), serde_json::json!(default));
2763 if let Some(d) = description {
2764 field.insert("description".into(), serde_json::json!(d));
2765 }
2766 self.send_field_registration(&name, serde_json::Value::Object(field));
2767 Ok(self
2768 .current_field_value(&name)
2769 .and_then(|v| {
2770 v.as_array().map(|arr| {
2771 arr.iter()
2772 .filter_map(|x| x.as_str().map(|s| s.to_string()))
2773 .collect::<Vec<_>>()
2774 })
2775 })
2776 .unwrap_or(default))
2777 }
2778
2779 pub fn get_plugin_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2787 let config = self
2788 .state_snapshot
2789 .read()
2790 .map(|s| std::sync::Arc::clone(&s.config))
2791 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
2792
2793 let settings = config
2794 .pointer(&format!("/plugins/{}/settings", self.plugin_name))
2795 .cloned()
2796 .unwrap_or(serde_json::Value::Null);
2797
2798 rquickjs_serde::to_value(ctx, &settings)
2799 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2800 }
2801
2802 pub fn reload_config(&self) {
2804 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
2805 }
2806
2807 pub fn set_setting<'js>(
2820 &self,
2821 _ctx: rquickjs::Ctx<'js>,
2822 path: String,
2823 value: Value<'js>,
2824 ) -> rquickjs::Result<bool> {
2825 let json: serde_json::Value = rquickjs_serde::from_value(value)
2826 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2827 Ok(self
2828 .command_sender
2829 .send(PluginCommand::SetSetting {
2830 plugin_name: self.plugin_name.clone(),
2831 path,
2832 value: json,
2833 })
2834 .is_ok())
2835 }
2836
2837 pub fn reload_themes(&self) {
2840 let _ = self
2841 .command_sender
2842 .send(PluginCommand::ReloadThemes { apply_theme: None });
2843 }
2844
2845 pub fn reload_and_apply_theme(&self, theme_name: String) {
2847 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2848 apply_theme: Some(theme_name),
2849 });
2850 }
2851
2852 pub fn register_grammar<'js>(
2855 &self,
2856 ctx: rquickjs::Ctx<'js>,
2857 language: String,
2858 grammar_path: String,
2859 extensions: Vec<String>,
2860 ) -> rquickjs::Result<bool> {
2861 {
2863 let langs = self.registered_grammar_languages.borrow();
2864 if let Some(existing_plugin) = langs.get(&language) {
2865 if existing_plugin != &self.plugin_name {
2866 let msg = format!(
2867 "Grammar for language '{}' already registered by plugin '{}'",
2868 language, existing_plugin
2869 );
2870 tracing::warn!("registerGrammar collision: {}", msg);
2871 return Err(
2872 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2873 );
2874 }
2875 }
2876 }
2877 self.registered_grammar_languages
2878 .borrow_mut()
2879 .insert(language.clone(), self.plugin_name.clone());
2880
2881 Ok(self
2882 .command_sender
2883 .send(PluginCommand::RegisterGrammar {
2884 language,
2885 grammar_path,
2886 extensions,
2887 })
2888 .is_ok())
2889 }
2890
2891 pub fn register_language_config<'js>(
2893 &self,
2894 ctx: rquickjs::Ctx<'js>,
2895 language: String,
2896 config: LanguagePackConfig,
2897 ) -> rquickjs::Result<bool> {
2898 {
2900 let langs = self.registered_language_configs.borrow();
2901 if let Some(existing_plugin) = langs.get(&language) {
2902 if existing_plugin != &self.plugin_name {
2903 let msg = format!(
2904 "Language config for '{}' already registered by plugin '{}'",
2905 language, existing_plugin
2906 );
2907 tracing::warn!("registerLanguageConfig collision: {}", msg);
2908 return Err(
2909 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2910 );
2911 }
2912 }
2913 }
2914 self.registered_language_configs
2915 .borrow_mut()
2916 .insert(language.clone(), self.plugin_name.clone());
2917
2918 Ok(self
2919 .command_sender
2920 .send(PluginCommand::RegisterLanguageConfig { language, config })
2921 .is_ok())
2922 }
2923
2924 pub fn register_lsp_server<'js>(
2926 &self,
2927 ctx: rquickjs::Ctx<'js>,
2928 language: String,
2929 config: LspServerPackConfig,
2930 ) -> rquickjs::Result<bool> {
2931 {
2933 let langs = self.registered_lsp_servers.borrow();
2934 if let Some(existing_plugin) = langs.get(&language) {
2935 if existing_plugin != &self.plugin_name {
2936 let msg = format!(
2937 "LSP server for language '{}' already registered by plugin '{}'",
2938 language, existing_plugin
2939 );
2940 tracing::warn!("registerLspServer collision: {}", msg);
2941 return Err(
2942 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2943 );
2944 }
2945 }
2946 }
2947 self.registered_lsp_servers
2948 .borrow_mut()
2949 .insert(language.clone(), self.plugin_name.clone());
2950
2951 Ok(self
2952 .command_sender
2953 .send(PluginCommand::RegisterLspServer { language, config })
2954 .is_ok())
2955 }
2956
2957 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2961 #[qjs(rename = "_reloadGrammarsStart")]
2962 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2963 let id = self.alloc_request_id();
2964 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2965 callback_id: fresh_core::api::JsCallbackId::new(id),
2966 });
2967 id
2968 }
2969
2970 pub fn get_plugin_dir(&self) -> String {
2973 self.services
2974 .plugins_dir()
2975 .join("packages")
2976 .join(&self.plugin_name)
2977 .to_string_lossy()
2978 .to_string()
2979 }
2980
2981 pub fn get_config_dir(&self) -> String {
2983 self.services.config_dir().to_string_lossy().to_string()
2984 }
2985
2986 pub fn get_data_dir(&self) -> String {
2990 self.services.data_dir().to_string_lossy().to_string()
2991 }
2992
2993 pub fn get_terminal_dir(&self) -> String {
2998 let working_dir = self
2999 .state_snapshot
3000 .read()
3001 .map(|s| s.working_dir.clone())
3002 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3003 self.services
3004 .terminal_dir(&working_dir)
3005 .to_string_lossy()
3006 .to_string()
3007 }
3008
3009 pub fn get_working_data_dir(&self) -> String {
3015 let working_dir = self
3016 .state_snapshot
3017 .read()
3018 .map(|s| s.working_dir.clone())
3019 .unwrap_or_else(|_| std::path::PathBuf::from("."));
3020 self.services
3021 .working_data_dir(&working_dir)
3022 .to_string_lossy()
3023 .to_string()
3024 }
3025
3026 pub fn get_themes_dir(&self) -> String {
3028 self.services
3029 .config_dir()
3030 .join("themes")
3031 .to_string_lossy()
3032 .to_string()
3033 }
3034
3035 pub fn apply_theme(&self, theme_name: String) -> bool {
3037 self.command_sender
3038 .send(PluginCommand::ApplyTheme { theme_name })
3039 .is_ok()
3040 }
3041
3042 pub fn override_theme_colors<'js>(
3051 &self,
3052 _ctx: rquickjs::Ctx<'js>,
3053 overrides: Value<'js>,
3054 ) -> rquickjs::Result<bool> {
3055 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
3061 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
3062 let Some(obj) = json.as_object() else {
3063 return Err(rquickjs::Error::new_from_js_message(
3064 "type",
3065 "",
3066 "overrideThemeColors expects an object of \"key\": [r, g, b]",
3067 ));
3068 };
3069 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
3070 n.as_i64()
3071 .or_else(|| n.as_f64().map(|f| f as i64))
3072 .map(|v| v.clamp(0, 255) as u8)
3073 };
3074 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
3075 std::collections::HashMap::with_capacity(obj.len());
3076 for (key, value) in obj {
3077 let Some(arr) = value.as_array() else {
3078 continue;
3079 };
3080 if arr.len() != 3 {
3081 continue;
3082 }
3083 let Some(r) = to_u8(&arr[0]) else { continue };
3084 let Some(g) = to_u8(&arr[1]) else { continue };
3085 let Some(b) = to_u8(&arr[2]) else { continue };
3086 clamped.insert(key.clone(), [r, g, b]);
3087 }
3088 Ok(self
3089 .command_sender
3090 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
3091 .is_ok())
3092 }
3093
3094 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3096 let schema = self.services.get_theme_schema();
3097 rquickjs_serde::to_value(ctx, &schema)
3098 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3099 }
3100
3101 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3103 let themes = self.services.get_builtin_themes();
3104 rquickjs_serde::to_value(ctx, &themes)
3105 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3106 }
3107
3108 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3111 let themes = self.services.get_all_themes();
3112 rquickjs_serde::to_value(ctx, &themes)
3113 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3114 }
3115
3116 #[qjs(rename = "_deleteThemeSync")]
3118 pub fn delete_theme_sync(&self, name: String) -> bool {
3119 let themes_dir = self.services.config_dir().join("themes");
3121 let theme_path = themes_dir.join(format!("{}.json", name));
3122
3123 if let Ok(canonical) = theme_path.canonicalize() {
3125 if let Ok(themes_canonical) = themes_dir.canonicalize() {
3126 if canonical.starts_with(&themes_canonical) {
3127 return std::fs::remove_file(&canonical).is_ok();
3128 }
3129 }
3130 }
3131 false
3132 }
3133
3134 pub fn delete_theme(&self, name: String) -> bool {
3136 self.delete_theme_sync(name)
3137 }
3138
3139 pub fn get_theme_data<'js>(
3141 &self,
3142 ctx: rquickjs::Ctx<'js>,
3143 name: String,
3144 ) -> rquickjs::Result<Value<'js>> {
3145 match self.services.get_theme_data(&name) {
3146 Some(data) => rquickjs_serde::to_value(ctx, &data)
3147 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
3148 None => Ok(Value::new_null(ctx)),
3149 }
3150 }
3151
3152 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
3154 self.services
3155 .save_theme_file(&name, &content)
3156 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
3157 }
3158
3159 pub fn theme_file_exists(&self, name: String) -> bool {
3161 self.services.theme_file_exists(&name)
3162 }
3163
3164 pub fn file_stat<'js>(
3168 &self,
3169 ctx: rquickjs::Ctx<'js>,
3170 path: String,
3171 ) -> rquickjs::Result<Value<'js>> {
3172 let metadata = std::fs::metadata(&path).ok();
3173 let stat = metadata.map(|m| {
3174 serde_json::json!({
3175 "isFile": m.is_file(),
3176 "isDir": m.is_dir(),
3177 "size": m.len(),
3178 "readonly": m.permissions().readonly(),
3179 })
3180 });
3181 rquickjs_serde::to_value(ctx, &stat)
3182 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
3183 }
3184
3185 pub fn is_process_running(&self, _process_id: u64) -> bool {
3189 false
3192 }
3193
3194 pub fn kill_process(&self, process_id: u64) -> bool {
3196 self.command_sender
3197 .send(PluginCommand::KillBackgroundProcess { process_id })
3198 .is_ok()
3199 }
3200
3201 pub fn plugin_translate<'js>(
3205 &self,
3206 _ctx: rquickjs::Ctx<'js>,
3207 plugin_name: String,
3208 key: String,
3209 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
3210 ) -> String {
3211 let args_map: HashMap<String, String> = args
3212 .0
3213 .map(|obj| {
3214 let mut map = HashMap::new();
3215 for (k, v) in obj.props::<String, String>().flatten() {
3216 map.insert(k, v);
3217 }
3218 map
3219 })
3220 .unwrap_or_default();
3221
3222 self.services.translate(&plugin_name, &key, &args_map)
3223 }
3224
3225 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
3232 #[qjs(rename = "_createCompositeBufferStart")]
3233 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
3234 let id = self.alloc_request_id();
3235
3236 if let Ok(mut owners) = self.async_resource_owners.lock() {
3238 owners.insert(id, self.plugin_name.clone());
3239 }
3240 let _ = self
3241 .command_sender
3242 .send(PluginCommand::CreateCompositeBuffer {
3243 name: opts.name,
3244 mode: opts.mode,
3245 layout: opts.layout,
3246 sources: opts.sources,
3247 hunks: opts.hunks,
3248 initial_focus_hunk: opts.initial_focus_hunk,
3249 request_id: Some(id),
3250 });
3251
3252 id
3253 }
3254
3255 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
3259 self.command_sender
3260 .send(PluginCommand::UpdateCompositeAlignment {
3261 buffer_id: BufferId(buffer_id as usize),
3262 hunks,
3263 })
3264 .is_ok()
3265 }
3266
3267 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
3269 self.command_sender
3270 .send(PluginCommand::CloseCompositeBuffer {
3271 buffer_id: BufferId(buffer_id as usize),
3272 })
3273 .is_ok()
3274 }
3275
3276 pub fn flush_layout(&self) -> bool {
3280 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
3281 }
3282
3283 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
3285 self.command_sender
3286 .send(PluginCommand::CompositeNextHunk {
3287 buffer_id: BufferId(buffer_id as usize),
3288 })
3289 .is_ok()
3290 }
3291
3292 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
3294 self.command_sender
3295 .send(PluginCommand::CompositePrevHunk {
3296 buffer_id: BufferId(buffer_id as usize),
3297 })
3298 .is_ok()
3299 }
3300
3301 #[plugin_api(
3305 async_promise,
3306 js_name = "getHighlights",
3307 ts_return = "TsHighlightSpan[]"
3308 )]
3309 #[qjs(rename = "_getHighlightsStart")]
3310 pub fn get_highlights_start<'js>(
3311 &self,
3312 _ctx: rquickjs::Ctx<'js>,
3313 buffer_id: u32,
3314 start: u32,
3315 end: u32,
3316 ) -> rquickjs::Result<u64> {
3317 let id = self.alloc_request_id();
3318
3319 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
3320 buffer_id: BufferId(buffer_id as usize),
3321 range: (start as usize)..(end as usize),
3322 request_id: id,
3323 });
3324
3325 Ok(id)
3326 }
3327
3328 pub fn add_overlay<'js>(
3350 &self,
3351 _ctx: rquickjs::Ctx<'js>,
3352 buffer_id: u32,
3353 namespace: String,
3354 start: u32,
3355 end: u32,
3356 options: rquickjs::Object<'js>,
3357 ) -> rquickjs::Result<bool> {
3358 use fresh_core::api::OverlayColorSpec;
3359
3360 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3362 if let Ok(theme_key) = obj.get::<_, String>(key) {
3364 if !theme_key.is_empty() {
3365 return Some(OverlayColorSpec::ThemeKey(theme_key));
3366 }
3367 }
3368 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3370 if arr.len() >= 3 {
3371 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3372 }
3373 }
3374 None
3375 }
3376
3377 let fg = parse_color_spec("fg", &options);
3378 let bg = parse_color_spec("bg", &options);
3379 let underline: bool = options.get("underline").unwrap_or(false);
3380 let bold: bool = options.get("bold").unwrap_or(false);
3381 let italic: bool = options.get("italic").unwrap_or(false);
3382 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
3383 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
3384 let fg_on_collision_only: bool = options.get("fgOnCollisionOnly").unwrap_or(false);
3385 let url: Option<String> = options.get("url").ok();
3386
3387 let options = OverlayOptions {
3388 fg,
3389 bg,
3390 underline,
3391 bold,
3392 italic,
3393 strikethrough,
3394 extend_to_line_end,
3395 fg_on_collision_only,
3396 url,
3397 };
3398
3399 self.plugin_tracked_state
3401 .borrow_mut()
3402 .entry(self.plugin_name.clone())
3403 .or_default()
3404 .overlay_namespaces
3405 .push((BufferId(buffer_id as usize), namespace.clone()));
3406
3407 let _ = self.command_sender.send(PluginCommand::AddOverlay {
3408 buffer_id: BufferId(buffer_id as usize),
3409 namespace: Some(OverlayNamespace::from_string(namespace)),
3410 range: (start as usize)..(end as usize),
3411 options,
3412 });
3413
3414 Ok(true)
3415 }
3416
3417 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3419 self.command_sender
3420 .send(PluginCommand::ClearNamespace {
3421 buffer_id: BufferId(buffer_id as usize),
3422 namespace: OverlayNamespace::from_string(namespace),
3423 })
3424 .is_ok()
3425 }
3426
3427 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
3429 self.command_sender
3430 .send(PluginCommand::ClearAllOverlays {
3431 buffer_id: BufferId(buffer_id as usize),
3432 })
3433 .is_ok()
3434 }
3435
3436 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3438 self.command_sender
3439 .send(PluginCommand::ClearOverlaysInRange {
3440 buffer_id: BufferId(buffer_id as usize),
3441 start: start as usize,
3442 end: end as usize,
3443 })
3444 .is_ok()
3445 }
3446
3447 pub fn clear_overlays_in_range_for_namespace(
3449 &self,
3450 buffer_id: u32,
3451 namespace: String,
3452 start: u32,
3453 end: u32,
3454 ) -> bool {
3455 self.command_sender
3456 .send(PluginCommand::ClearOverlaysInRangeForNamespace {
3457 buffer_id: BufferId(buffer_id as usize),
3458 namespace: OverlayNamespace::from_string(namespace),
3459 start: start as usize,
3460 end: end as usize,
3461 })
3462 .is_ok()
3463 }
3464
3465 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
3467 use fresh_core::overlay::OverlayHandle;
3468 self.command_sender
3469 .send(PluginCommand::RemoveOverlay {
3470 buffer_id: BufferId(buffer_id as usize),
3471 handle: OverlayHandle(handle),
3472 })
3473 .is_ok()
3474 }
3475
3476 pub fn add_conceal(
3480 &self,
3481 buffer_id: u32,
3482 namespace: String,
3483 start: u32,
3484 end: u32,
3485 replacement: Option<String>,
3486 ) -> bool {
3487 self.plugin_tracked_state
3489 .borrow_mut()
3490 .entry(self.plugin_name.clone())
3491 .or_default()
3492 .overlay_namespaces
3493 .push((BufferId(buffer_id as usize), namespace.clone()));
3494
3495 self.command_sender
3496 .send(PluginCommand::AddConceal {
3497 buffer_id: BufferId(buffer_id as usize),
3498 namespace: OverlayNamespace::from_string(namespace),
3499 start: start as usize,
3500 end: end as usize,
3501 replacement,
3502 })
3503 .is_ok()
3504 }
3505
3506 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3508 self.command_sender
3509 .send(PluginCommand::ClearConcealNamespace {
3510 buffer_id: BufferId(buffer_id as usize),
3511 namespace: OverlayNamespace::from_string(namespace),
3512 })
3513 .is_ok()
3514 }
3515
3516 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3518 self.command_sender
3519 .send(PluginCommand::ClearConcealsInRange {
3520 buffer_id: BufferId(buffer_id as usize),
3521 start: start as usize,
3522 end: end as usize,
3523 })
3524 .is_ok()
3525 }
3526
3527 pub fn add_fold(
3534 &self,
3535 buffer_id: u32,
3536 start: u32,
3537 end: u32,
3538 placeholder: rquickjs::function::Opt<String>,
3539 ) -> bool {
3540 self.command_sender
3541 .send(PluginCommand::AddFold {
3542 buffer_id: BufferId(buffer_id as usize),
3543 start: start as usize,
3544 end: end as usize,
3545 placeholder: placeholder.0,
3546 })
3547 .is_ok()
3548 }
3549
3550 pub fn clear_folds(&self, buffer_id: u32) -> bool {
3552 self.command_sender
3553 .send(PluginCommand::ClearFolds {
3554 buffer_id: BufferId(buffer_id as usize),
3555 })
3556 .is_ok()
3557 }
3558
3559 pub fn set_folding_ranges<'js>(
3572 &self,
3573 _ctx: rquickjs::Ctx<'js>,
3574 buffer_id: u32,
3575 ranges_arr: Vec<rquickjs::Object<'js>>,
3576 ) -> rquickjs::Result<bool> {
3577 let mut ranges: Vec<lsp_types::FoldingRange> = Vec::with_capacity(ranges_arr.len());
3578 for obj in ranges_arr {
3579 let start_line: u32 = obj.get("startLine").unwrap_or(0);
3580 let end_line: u32 = obj.get("endLine").unwrap_or(start_line);
3581 let kind = obj
3582 .get::<_, String>("kind")
3583 .ok()
3584 .and_then(|s| match s.as_str() {
3585 "comment" => Some(lsp_types::FoldingRangeKind::Comment),
3586 "imports" => Some(lsp_types::FoldingRangeKind::Imports),
3587 "region" => Some(lsp_types::FoldingRangeKind::Region),
3588 _ => None,
3589 });
3590 ranges.push(lsp_types::FoldingRange {
3591 start_line,
3592 end_line,
3593 start_character: None,
3594 end_character: None,
3595 kind,
3596 collapsed_text: None,
3597 });
3598 }
3599 Ok(self
3600 .command_sender
3601 .send(PluginCommand::SetFoldingRanges {
3602 buffer_id: BufferId(buffer_id as usize),
3603 ranges,
3604 })
3605 .is_ok())
3606 }
3607
3608 pub fn add_soft_break(
3612 &self,
3613 buffer_id: u32,
3614 namespace: String,
3615 position: u32,
3616 indent: u32,
3617 ) -> bool {
3618 self.plugin_tracked_state
3620 .borrow_mut()
3621 .entry(self.plugin_name.clone())
3622 .or_default()
3623 .overlay_namespaces
3624 .push((BufferId(buffer_id as usize), namespace.clone()));
3625
3626 self.command_sender
3627 .send(PluginCommand::AddSoftBreak {
3628 buffer_id: BufferId(buffer_id as usize),
3629 namespace: OverlayNamespace::from_string(namespace),
3630 position: position as usize,
3631 indent: indent as u16,
3632 })
3633 .is_ok()
3634 }
3635
3636 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3638 self.command_sender
3639 .send(PluginCommand::ClearSoftBreakNamespace {
3640 buffer_id: BufferId(buffer_id as usize),
3641 namespace: OverlayNamespace::from_string(namespace),
3642 })
3643 .is_ok()
3644 }
3645
3646 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
3648 self.command_sender
3649 .send(PluginCommand::ClearSoftBreaksInRange {
3650 buffer_id: BufferId(buffer_id as usize),
3651 start: start as usize,
3652 end: end as usize,
3653 })
3654 .is_ok()
3655 }
3656
3657 #[allow(clippy::too_many_arguments)]
3667 pub fn submit_view_transform<'js>(
3668 &self,
3669 _ctx: rquickjs::Ctx<'js>,
3670 buffer_id: u32,
3671 split_id: Option<u32>,
3672 start: u32,
3673 end: u32,
3674 tokens: Vec<rquickjs::Object<'js>>,
3675 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
3676 ) -> rquickjs::Result<bool> {
3677 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
3678
3679 let tokens: Vec<ViewTokenWire> = tokens
3680 .into_iter()
3681 .enumerate()
3682 .map(|(idx, obj)| {
3683 parse_view_token(&obj, idx)
3685 })
3686 .collect::<rquickjs::Result<Vec<_>>>()?;
3687
3688 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
3690 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
3691 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
3692 Some(LayoutHints {
3693 compose_width,
3694 column_guides,
3695 })
3696 } else {
3697 None
3698 };
3699
3700 let payload = ViewTransformPayload {
3701 range: (start as usize)..(end as usize),
3702 tokens,
3703 layout_hints: parsed_layout_hints,
3704 };
3705
3706 Ok(self
3707 .command_sender
3708 .send(PluginCommand::SubmitViewTransform {
3709 buffer_id: BufferId(buffer_id as usize),
3710 split_id: split_id.map(|id| SplitId(id as usize)),
3711 payload,
3712 })
3713 .is_ok())
3714 }
3715
3716 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
3718 self.command_sender
3719 .send(PluginCommand::ClearViewTransform {
3720 buffer_id: BufferId(buffer_id as usize),
3721 split_id: split_id.map(|id| SplitId(id as usize)),
3722 })
3723 .is_ok()
3724 }
3725
3726 pub fn set_layout_hints<'js>(
3729 &self,
3730 buffer_id: u32,
3731 split_id: Option<u32>,
3732 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
3733 ) -> rquickjs::Result<bool> {
3734 use fresh_core::api::LayoutHints;
3735
3736 let compose_width: Option<u16> = hints.get("composeWidth").ok();
3737 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
3738 let parsed_hints = LayoutHints {
3739 compose_width,
3740 column_guides,
3741 };
3742
3743 Ok(self
3744 .command_sender
3745 .send(PluginCommand::SetLayoutHints {
3746 buffer_id: BufferId(buffer_id as usize),
3747 split_id: split_id.map(|id| SplitId(id as usize)),
3748 range: 0..0,
3749 hints: parsed_hints,
3750 })
3751 .is_ok())
3752 }
3753
3754 pub fn set_file_explorer_decorations<'js>(
3758 &self,
3759 _ctx: rquickjs::Ctx<'js>,
3760 namespace: String,
3761 decorations: Vec<rquickjs::Object<'js>>,
3762 ) -> rquickjs::Result<bool> {
3763 use fresh_core::file_explorer::FileExplorerDecoration;
3764 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3765
3766 let decorations: Vec<FileExplorerDecoration> = decorations
3767 .into_iter()
3768 .map(|obj| {
3769 let path: String = obj.get("path")?;
3770 let symbol: String = obj.get("symbol")?;
3771 let priority: i32 = obj.get("priority").unwrap_or(0);
3772
3773 let color_val: rquickjs::Value = obj.get("color")?;
3775 let color = if color_val.is_string() {
3776 let key: String = color_val.get()?;
3777 fresh_core::api::OverlayColorSpec::ThemeKey(key)
3778 } else if color_val.is_array() {
3779 let arr: Vec<u8> = color_val.get()?;
3780 if arr.len() < 3 {
3781 return Err(rquickjs::Error::FromJs {
3782 from: "array",
3783 to: "color",
3784 message: Some(format!(
3785 "color array must have at least 3 elements, got {}",
3786 arr.len()
3787 )),
3788 });
3789 }
3790 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
3791 } else {
3792 return Err(rquickjs::Error::FromJs {
3793 from: "value",
3794 to: "color",
3795 message: Some("color must be an RGB array or theme key string".to_string()),
3796 });
3797 };
3798
3799 Ok(FileExplorerDecoration {
3800 path: std::path::PathBuf::from(path),
3801 symbol,
3802 color,
3803 priority,
3804 })
3805 })
3806 .collect::<rquickjs::Result<Vec<_>>>()?;
3807
3808 self.plugin_tracked_state
3810 .borrow_mut()
3811 .entry(self.plugin_name.clone())
3812 .or_default()
3813 .file_explorer_namespaces
3814 .push(scoped_namespace.clone());
3815
3816 Ok(self
3817 .command_sender
3818 .send(PluginCommand::SetFileExplorerDecorations {
3819 namespace: scoped_namespace,
3820 decorations,
3821 })
3822 .is_ok())
3823 }
3824
3825 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
3827 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3828 self.command_sender
3829 .send(PluginCommand::ClearFileExplorerDecorations {
3830 namespace: scoped_namespace,
3831 })
3832 .is_ok()
3833 }
3834
3835 pub fn set_file_explorer_slots<'js>(
3837 &self,
3838 ctx: rquickjs::Ctx<'js>,
3839 namespace: String,
3840 slots: Vec<rquickjs::Object<'js>>,
3841 ) -> rquickjs::Result<bool> {
3842 use fresh_core::file_explorer::FileExplorerSlotEntry;
3843 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3844
3845 let slots: Vec<FileExplorerSlotEntry> = slots
3846 .into_iter()
3847 .map(|obj| <FileExplorerSlotEntry as rquickjs::FromJs>::from_js(&ctx, obj.into()))
3848 .collect::<rquickjs::Result<Vec<_>>>()?;
3849
3850 self.plugin_tracked_state
3851 .borrow_mut()
3852 .entry(self.plugin_name.clone())
3853 .or_default()
3854 .file_explorer_namespaces
3855 .push(scoped_namespace.clone());
3856
3857 Ok(self
3858 .command_sender
3859 .send(PluginCommand::SetFileExplorerSlots {
3860 namespace: scoped_namespace,
3861 slots,
3862 })
3863 .is_ok())
3864 }
3865
3866 pub fn clear_file_explorer_slots(&self, namespace: String) -> bool {
3868 let scoped_namespace = format!("{}::{}", self.plugin_name, namespace);
3869 self.command_sender
3870 .send(PluginCommand::ClearFileExplorerSlots {
3871 namespace: scoped_namespace,
3872 })
3873 .is_ok()
3874 }
3875
3876 #[allow(clippy::too_many_arguments)]
3880 pub fn add_virtual_text(
3881 &self,
3882 buffer_id: u32,
3883 virtual_text_id: String,
3884 position: u32,
3885 text: String,
3886 r: u8,
3887 g: u8,
3888 b: u8,
3889 before: bool,
3890 use_bg: bool,
3891 ) -> bool {
3892 self.plugin_tracked_state
3894 .borrow_mut()
3895 .entry(self.plugin_name.clone())
3896 .or_default()
3897 .virtual_text_ids
3898 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3899
3900 self.command_sender
3901 .send(PluginCommand::AddVirtualText {
3902 buffer_id: BufferId(buffer_id as usize),
3903 virtual_text_id,
3904 position: position as usize,
3905 text,
3906 color: (r, g, b),
3907 use_bg,
3908 before,
3909 })
3910 .is_ok()
3911 }
3912
3913 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
3915 self.command_sender
3916 .send(PluginCommand::RemoveVirtualText {
3917 buffer_id: BufferId(buffer_id as usize),
3918 virtual_text_id,
3919 })
3920 .is_ok()
3921 }
3922
3923 #[allow(clippy::too_many_arguments)]
3929 pub fn add_virtual_text_styled<'js>(
3930 &self,
3931 _ctx: rquickjs::Ctx<'js>,
3932 buffer_id: u32,
3933 virtual_text_id: String,
3934 position: u32,
3935 text: String,
3936 options: rquickjs::Object<'js>,
3937 before: bool,
3938 ) -> rquickjs::Result<bool> {
3939 use fresh_core::api::OverlayColorSpec;
3940
3941 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3944 if let Ok(theme_key) = obj.get::<_, String>(key) {
3945 if !theme_key.is_empty() {
3946 return Some(OverlayColorSpec::ThemeKey(theme_key));
3947 }
3948 }
3949 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3950 if arr.len() >= 3 {
3951 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3952 }
3953 }
3954 None
3955 }
3956
3957 let fg = parse_color_spec("fg", &options);
3958 let bg = parse_color_spec("bg", &options);
3959 let bold: bool = options.get("bold").unwrap_or(false);
3960 let italic: bool = options.get("italic").unwrap_or(false);
3961
3962 self.plugin_tracked_state
3964 .borrow_mut()
3965 .entry(self.plugin_name.clone())
3966 .or_default()
3967 .virtual_text_ids
3968 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
3969
3970 let _ = self
3971 .command_sender
3972 .send(PluginCommand::AddVirtualTextStyled {
3973 buffer_id: BufferId(buffer_id as usize),
3974 virtual_text_id,
3975 position: position as usize,
3976 text,
3977 fg,
3978 bg,
3979 bold,
3980 italic,
3981 before,
3982 });
3983 Ok(true)
3984 }
3985
3986 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
3988 self.command_sender
3989 .send(PluginCommand::RemoveVirtualTextsByPrefix {
3990 buffer_id: BufferId(buffer_id as usize),
3991 prefix,
3992 })
3993 .is_ok()
3994 }
3995
3996 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
3998 self.command_sender
3999 .send(PluginCommand::ClearVirtualTexts {
4000 buffer_id: BufferId(buffer_id as usize),
4001 })
4002 .is_ok()
4003 }
4004
4005 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
4007 self.command_sender
4008 .send(PluginCommand::ClearVirtualTextNamespace {
4009 buffer_id: BufferId(buffer_id as usize),
4010 namespace,
4011 })
4012 .is_ok()
4013 }
4014
4015 #[allow(clippy::too_many_arguments)]
4030 pub fn add_virtual_line<'js>(
4031 &self,
4032 _ctx: rquickjs::Ctx<'js>,
4033 buffer_id: u32,
4034 position: u32,
4035 text: String,
4036 options: rquickjs::Object<'js>,
4037 above: bool,
4038 namespace: String,
4039 priority: i32,
4040 ) -> rquickjs::Result<bool> {
4041 use fresh_core::api::OverlayColorSpec;
4042
4043 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
4046 if let Ok(theme_key) = obj.get::<_, String>(key) {
4047 if !theme_key.is_empty() {
4048 return Some(OverlayColorSpec::ThemeKey(theme_key));
4049 }
4050 }
4051 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
4052 if arr.len() >= 3 {
4053 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
4054 }
4055 }
4056 None
4057 }
4058
4059 let fg_color = parse_color_spec("fg", &options);
4060 let bg_color = parse_color_spec("bg", &options);
4061 let gutter_glyph = options
4062 .get::<_, String>("gutterGlyph")
4063 .ok()
4064 .filter(|s| !s.is_empty());
4065 let gutter_color = parse_color_spec("gutterColor", &options);
4066
4067 let text_overlays: Vec<fresh_core::api::VirtualLineTextOverlay> = options
4073 .get::<_, rquickjs::Value<'js>>("textOverlays")
4074 .ok()
4075 .filter(|v| !v.is_undefined() && !v.is_null())
4076 .and_then(|v| rquickjs_serde::from_value(v).ok())
4077 .map(|v: Vec<fresh_core::api::VirtualLineTextOverlay>| {
4078 v.into_iter().filter(|o| o.end > o.start).collect()
4079 })
4080 .unwrap_or_default();
4081
4082 self.plugin_tracked_state
4084 .borrow_mut()
4085 .entry(self.plugin_name.clone())
4086 .or_default()
4087 .virtual_line_namespaces
4088 .push((BufferId(buffer_id as usize), namespace.clone()));
4089
4090 Ok(self
4091 .command_sender
4092 .send(PluginCommand::AddVirtualLine {
4093 buffer_id: BufferId(buffer_id as usize),
4094 position: position as usize,
4095 text,
4096 fg_color,
4097 bg_color,
4098 above,
4099 namespace,
4100 priority,
4101 gutter_glyph,
4102 gutter_color,
4103 text_overlays,
4104 })
4105 .is_ok())
4106 }
4107
4108 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
4113 #[qjs(rename = "_promptStart")]
4114 pub fn prompt_start(
4115 &self,
4116 _ctx: rquickjs::Ctx<'_>,
4117 label: String,
4118 initial_value: String,
4119 ) -> u64 {
4120 let id = self.alloc_request_id();
4121
4122 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
4123 label,
4124 initial_value,
4125 callback_id: JsCallbackId::new(id),
4126 });
4127
4128 id
4129 }
4130
4131 pub fn start_prompt(
4142 &self,
4143 label: String,
4144 prompt_type: String,
4145 floating_overlay: rquickjs::function::Opt<bool>,
4146 ) -> bool {
4147 self.command_sender
4148 .send(PluginCommand::StartPrompt {
4149 label,
4150 prompt_type,
4151 floating_overlay: floating_overlay.0.unwrap_or(false),
4152 })
4153 .is_ok()
4154 }
4155
4156 pub fn begin_key_capture(&self) -> bool {
4166 self.command_sender
4167 .send(PluginCommand::SetKeyCaptureActive { active: true })
4168 .is_ok()
4169 }
4170
4171 pub fn end_key_capture(&self) -> bool {
4175 self.command_sender
4176 .send(PluginCommand::SetKeyCaptureActive { active: false })
4177 .is_ok()
4178 }
4179
4180 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
4192 #[qjs(rename = "_getNextKeyStart")]
4193 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
4194 let id = self.alloc_request_id();
4195 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
4196 callback_id: JsCallbackId::new(id),
4197 });
4198 id
4199 }
4200
4201 pub fn start_prompt_with_initial(
4204 &self,
4205 label: String,
4206 prompt_type: String,
4207 initial_value: String,
4208 floating_overlay: rquickjs::function::Opt<bool>,
4209 ) -> bool {
4210 self.command_sender
4211 .send(PluginCommand::StartPromptWithInitial {
4212 label,
4213 prompt_type,
4214 initial_value,
4215 floating_overlay: floating_overlay.0.unwrap_or(false),
4216 })
4217 .is_ok()
4218 }
4219
4220 pub fn set_prompt_suggestions(
4230 &self,
4231 suggestions: Vec<fresh_core::command::Suggestion>,
4232 selected_index: rquickjs::function::Opt<Option<u32>>,
4233 ) -> bool {
4234 self.command_sender
4235 .send(PluginCommand::SetPromptSuggestions {
4236 suggestions,
4237 selected_index: selected_index.0.flatten(),
4238 })
4239 .is_ok()
4240 }
4241
4242 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
4243 self.command_sender
4244 .send(PluginCommand::SetPromptInputSync { sync })
4245 .is_ok()
4246 }
4247
4248 pub fn set_prompt_title(
4258 &self,
4259 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
4260 ) -> bool {
4261 self.command_sender
4262 .send(PluginCommand::SetPromptTitle { title })
4263 .is_ok()
4264 }
4265
4266 pub fn set_prompt_footer(
4272 &self,
4273 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
4274 ) -> bool {
4275 self.command_sender
4276 .send(PluginCommand::SetPromptFooter { footer })
4277 .is_ok()
4278 }
4279
4280 pub fn set_prompt_status(&self, status: String) -> bool {
4283 self.command_sender
4284 .send(PluginCommand::SetPromptStatus { status })
4285 .is_ok()
4286 }
4287
4288 #[qjs(rename = "setPromptToolbar")]
4292 pub fn set_prompt_toolbar<'js>(
4293 &self,
4294 ctx: rquickjs::Ctx<'js>,
4295 spec_obj: rquickjs::Value<'js>,
4296 ) -> rquickjs::Result<bool> {
4297 let spec = if spec_obj.is_null() || spec_obj.is_undefined() {
4298 None
4299 } else {
4300 let json = js_to_json(&ctx, spec_obj);
4301 match serde_json::from_value::<fresh_core::api::WidgetSpec>(json) {
4302 Ok(s) => Some(s),
4303 Err(e) => {
4304 tracing::error!("setPromptToolbar: invalid spec: {}", e);
4305 return Ok(false);
4306 }
4307 }
4308 };
4309 Ok(self
4310 .command_sender
4311 .send(PluginCommand::SetPromptToolbar { spec })
4312 .is_ok())
4313 }
4314
4315 #[qjs(rename = "toggleOverlayToolbarWidget")]
4320 pub fn toggle_overlay_toolbar_widget(&self, key: String) -> bool {
4321 self.command_sender
4322 .send(PluginCommand::ToggleOverlayToolbarWidget { key })
4323 .is_ok()
4324 }
4325
4326 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
4334 self.command_sender
4335 .send(PluginCommand::SetPromptSelectedIndex { index })
4336 .is_ok()
4337 }
4338
4339 pub fn define_mode(
4343 &self,
4344 name: String,
4345 bindings_arr: Vec<Vec<String>>,
4346 read_only: rquickjs::function::Opt<bool>,
4347 allow_text_input: rquickjs::function::Opt<bool>,
4348 inherit_normal_bindings: rquickjs::function::Opt<bool>,
4349 ) -> bool {
4350 let bindings: Vec<(String, String)> = bindings_arr
4351 .into_iter()
4352 .filter_map(|arr| {
4353 if arr.len() >= 2 {
4354 Some((arr[0].clone(), arr[1].clone()))
4355 } else {
4356 None
4357 }
4358 })
4359 .collect();
4360
4361 {
4364 let mut registered = self.registered_actions.borrow_mut();
4365 for (_, cmd_name) in &bindings {
4366 registered.insert(
4367 cmd_name.clone(),
4368 PluginHandler {
4369 plugin_name: self.plugin_name.clone(),
4370 handler_name: cmd_name.clone(),
4371 },
4372 );
4373 }
4374 }
4375
4376 let allow_text = allow_text_input.0.unwrap_or(false);
4379 if allow_text {
4380 let mut registered = self.registered_actions.borrow_mut();
4381 registered.insert(
4382 "mode_text_input".to_string(),
4383 PluginHandler {
4384 plugin_name: self.plugin_name.clone(),
4385 handler_name: "mode_text_input".to_string(),
4386 },
4387 );
4388 }
4389
4390 self.command_sender
4391 .send(PluginCommand::DefineMode {
4392 name,
4393 bindings,
4394 read_only: read_only.0.unwrap_or(false),
4395 allow_text_input: allow_text,
4396 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
4397 plugin_name: Some(self.plugin_name.clone()),
4398 })
4399 .is_ok()
4400 }
4401
4402 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
4404 self.command_sender
4405 .send(PluginCommand::SetEditorMode { mode })
4406 .is_ok()
4407 }
4408
4409 pub fn get_editor_mode(&self) -> Option<String> {
4411 self.state_snapshot
4412 .read()
4413 .ok()
4414 .and_then(|s| s.editor_mode.clone())
4415 }
4416
4417 pub fn close_split(&self, split_id: u32) -> bool {
4421 self.command_sender
4422 .send(PluginCommand::CloseSplit {
4423 split_id: SplitId(split_id as usize),
4424 })
4425 .is_ok()
4426 }
4427
4428 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
4430 self.command_sender
4431 .send(PluginCommand::SetSplitBuffer {
4432 split_id: SplitId(split_id as usize),
4433 buffer_id: BufferId(buffer_id as usize),
4434 })
4435 .is_ok()
4436 }
4437
4438 pub fn focus_split(&self, split_id: u32) -> bool {
4440 self.command_sender
4441 .send(PluginCommand::FocusSplit {
4442 split_id: SplitId(split_id as usize),
4443 })
4444 .is_ok()
4445 }
4446
4447 pub fn create_window(&self, root: String, label: String) -> bool {
4466 self.command_sender
4467 .send(PluginCommand::CreateWindow {
4468 root: std::path::PathBuf::from(root),
4469 label,
4470 })
4471 .is_ok()
4472 }
4473
4474 pub fn set_active_window(&self, id: u64) -> bool {
4479 self.command_sender
4480 .send(PluginCommand::SetActiveWindow {
4481 id: fresh_core::WindowId(id),
4482 })
4483 .is_ok()
4484 }
4485
4486 #[qjs(rename = "setActiveWindowAnimated")]
4490 pub fn set_active_window_animated(&self, id: u64, from_edge: String) -> bool {
4491 self.command_sender
4492 .send(PluginCommand::SetActiveWindowAnimated {
4493 id: fresh_core::WindowId(id),
4494 from_edge,
4495 })
4496 .is_ok()
4497 }
4498
4499 #[qjs(rename = "setWindowCycleOrder")]
4504 pub fn set_window_cycle_order(&self, ids: Vec<i64>) -> bool {
4505 self.command_sender
4506 .send(PluginCommand::SetWindowCycleOrder {
4507 ids: ids
4508 .into_iter()
4509 .filter(|n| *n > 0)
4510 .map(|n| fresh_core::WindowId(n as u64))
4511 .collect(),
4512 })
4513 .is_ok()
4514 }
4515
4516 pub fn close_window(&self, id: u64) -> bool {
4519 self.command_sender
4520 .send(PluginCommand::CloseWindow {
4521 id: fresh_core::WindowId(id),
4522 })
4523 .is_ok()
4524 }
4525
4526 pub fn prewarm_window(&self, id: u64) -> bool {
4530 self.command_sender
4531 .send(PluginCommand::PrewarmWindow {
4532 id: fresh_core::WindowId(id),
4533 })
4534 .is_ok()
4535 }
4536
4537 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
4549 #[qjs(rename = "_watchPathStart")]
4550 pub fn watch_path_start(
4551 &self,
4552 _ctx: rquickjs::Ctx<'_>,
4553 path: String,
4554 recursive: rquickjs::function::Opt<bool>,
4555 ) -> rquickjs::Result<u64> {
4556 let id = self.alloc_request_id();
4557 if let Ok(mut owners) = self.async_resource_owners.lock() {
4558 owners.insert(id, self.plugin_name.clone());
4559 }
4560 let _ = self.command_sender.send(PluginCommand::WatchPath {
4561 path: std::path::PathBuf::from(path),
4562 recursive: recursive.0.unwrap_or(false),
4563 request_id: id,
4564 });
4565 Ok(id)
4566 }
4567
4568 pub fn unwatch_path(&self, handle: u64) -> bool {
4571 self.command_sender
4572 .send(PluginCommand::UnwatchPath { handle })
4573 .is_ok()
4574 }
4575
4576 pub fn preview_window_in_rect(&self, id: u64) -> bool {
4587 let sid = if id == 0 {
4588 None
4589 } else {
4590 Some(fresh_core::WindowId(id))
4591 };
4592 self.command_sender
4593 .send(PluginCommand::PreviewWindowInRect { id: sid })
4594 .is_ok()
4595 }
4596
4597 pub fn clear_window_preview(&self) -> bool {
4600 self.command_sender
4601 .send(PluginCommand::PreviewWindowInRect { id: None })
4602 .is_ok()
4603 }
4604
4605 #[plugin_api(ts_return = "WindowInfo[]")]
4608 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
4609 let sessions: Vec<fresh_core::api::WindowInfo> = self
4610 .state_snapshot
4611 .read()
4612 .map(|s| s.windows.clone())
4613 .unwrap_or_default();
4614 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
4615 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
4616 })
4617 }
4618
4619 pub fn active_window(&self) -> u64 {
4622 self.state_snapshot
4623 .read()
4624 .map(|s| s.active_window_id.0)
4625 .unwrap_or(1)
4626 }
4627
4628 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
4630 self.command_sender
4631 .send(PluginCommand::SetSplitScroll {
4632 split_id: SplitId(split_id as usize),
4633 top_byte: top_byte as usize,
4634 })
4635 .is_ok()
4636 }
4637
4638 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
4640 self.command_sender
4641 .send(PluginCommand::SetSplitRatio {
4642 split_id: SplitId(split_id as usize),
4643 ratio,
4644 })
4645 .is_ok()
4646 }
4647
4648 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
4650 self.command_sender
4651 .send(PluginCommand::SetSplitLabel {
4652 split_id: SplitId(split_id as usize),
4653 label,
4654 })
4655 .is_ok()
4656 }
4657
4658 pub fn clear_split_label(&self, split_id: u32) -> bool {
4660 self.command_sender
4661 .send(PluginCommand::ClearSplitLabel {
4662 split_id: SplitId(split_id as usize),
4663 })
4664 .is_ok()
4665 }
4666
4667 #[plugin_api(
4669 async_promise,
4670 js_name = "getSplitByLabel",
4671 ts_return = "number | null"
4672 )]
4673 #[qjs(rename = "_getSplitByLabelStart")]
4674 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
4675 let id = self.alloc_request_id();
4676 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
4677 label,
4678 request_id: id,
4679 });
4680 id
4681 }
4682
4683 pub fn distribute_splits_evenly(&self) -> bool {
4685 self.command_sender
4687 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
4688 .is_ok()
4689 }
4690
4691 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
4693 self.command_sender
4694 .send(PluginCommand::SetBufferCursor {
4695 buffer_id: BufferId(buffer_id as usize),
4696 position: position as usize,
4697 })
4698 .is_ok()
4699 }
4700
4701 #[qjs(rename = "setBufferShowCursors")]
4708 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
4709 self.command_sender
4710 .send(PluginCommand::SetBufferShowCursors {
4711 buffer_id: BufferId(buffer_id as usize),
4712 show,
4713 })
4714 .is_ok()
4715 }
4716
4717 #[allow(clippy::too_many_arguments)]
4721 pub fn set_line_indicator(
4722 &self,
4723 buffer_id: u32,
4724 line: u32,
4725 namespace: String,
4726 symbol: String,
4727 r: u8,
4728 g: u8,
4729 b: u8,
4730 priority: i32,
4731 ) -> bool {
4732 self.plugin_tracked_state
4734 .borrow_mut()
4735 .entry(self.plugin_name.clone())
4736 .or_default()
4737 .line_indicator_namespaces
4738 .push((BufferId(buffer_id as usize), namespace.clone()));
4739
4740 self.command_sender
4741 .send(PluginCommand::SetLineIndicator {
4742 buffer_id: BufferId(buffer_id as usize),
4743 line: line as usize,
4744 namespace,
4745 symbol,
4746 color: (r, g, b),
4747 priority,
4748 })
4749 .is_ok()
4750 }
4751
4752 #[allow(clippy::too_many_arguments)]
4754 pub fn set_line_indicators(
4755 &self,
4756 buffer_id: u32,
4757 lines: Vec<u32>,
4758 namespace: String,
4759 symbol: String,
4760 r: u8,
4761 g: u8,
4762 b: u8,
4763 priority: i32,
4764 ) -> bool {
4765 self.plugin_tracked_state
4767 .borrow_mut()
4768 .entry(self.plugin_name.clone())
4769 .or_default()
4770 .line_indicator_namespaces
4771 .push((BufferId(buffer_id as usize), namespace.clone()));
4772
4773 self.command_sender
4774 .send(PluginCommand::SetLineIndicators {
4775 buffer_id: BufferId(buffer_id as usize),
4776 lines: lines.into_iter().map(|l| l as usize).collect(),
4777 namespace,
4778 symbol,
4779 color: (r, g, b),
4780 priority,
4781 })
4782 .is_ok()
4783 }
4784
4785 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
4787 self.command_sender
4788 .send(PluginCommand::ClearLineIndicators {
4789 buffer_id: BufferId(buffer_id as usize),
4790 namespace,
4791 })
4792 .is_ok()
4793 }
4794
4795 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
4797 self.command_sender
4798 .send(PluginCommand::SetLineNumbers {
4799 buffer_id: BufferId(buffer_id as usize),
4800 enabled,
4801 })
4802 .is_ok()
4803 }
4804
4805 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
4807 self.command_sender
4808 .send(PluginCommand::SetViewMode {
4809 buffer_id: BufferId(buffer_id as usize),
4810 mode,
4811 })
4812 .is_ok()
4813 }
4814
4815 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
4817 self.command_sender
4818 .send(PluginCommand::SetLineWrap {
4819 buffer_id: BufferId(buffer_id as usize),
4820 split_id: split_id.map(|s| SplitId(s as usize)),
4821 enabled,
4822 })
4823 .is_ok()
4824 }
4825
4826 pub fn set_view_state<'js>(
4830 &self,
4831 ctx: rquickjs::Ctx<'js>,
4832 buffer_id: u32,
4833 key: String,
4834 value: Value<'js>,
4835 ) -> bool {
4836 let bid = BufferId(buffer_id as usize);
4837
4838 let json_value = if value.is_undefined() || value.is_null() {
4840 None
4841 } else {
4842 Some(js_to_json(&ctx, value))
4843 };
4844
4845 if let Ok(mut snapshot) = self.state_snapshot.write() {
4847 if let Some(ref json_val) = json_value {
4848 snapshot
4849 .plugin_view_states
4850 .entry(bid)
4851 .or_default()
4852 .insert(key.clone(), json_val.clone());
4853 } else {
4854 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
4856 map.remove(&key);
4857 if map.is_empty() {
4858 snapshot.plugin_view_states.remove(&bid);
4859 }
4860 }
4861 }
4862 }
4863
4864 self.command_sender
4866 .send(PluginCommand::SetViewState {
4867 buffer_id: bid,
4868 key,
4869 value: json_value,
4870 })
4871 .is_ok()
4872 }
4873
4874 pub fn get_view_state<'js>(
4876 &self,
4877 ctx: rquickjs::Ctx<'js>,
4878 buffer_id: u32,
4879 key: String,
4880 ) -> rquickjs::Result<Value<'js>> {
4881 let bid = BufferId(buffer_id as usize);
4882 if let Ok(snapshot) = self.state_snapshot.read() {
4883 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
4884 if let Some(json_val) = map.get(&key) {
4885 return json_to_js_value(&ctx, json_val);
4886 }
4887 }
4888 }
4889 Ok(Value::new_undefined(ctx.clone()))
4890 }
4891
4892 pub fn set_global_state<'js>(
4898 &self,
4899 ctx: rquickjs::Ctx<'js>,
4900 key: String,
4901 value: Value<'js>,
4902 ) -> bool {
4903 let json_value = if value.is_undefined() || value.is_null() {
4905 None
4906 } else {
4907 Some(js_to_json(&ctx, value))
4908 };
4909
4910 if let Ok(mut snapshot) = self.state_snapshot.write() {
4912 if let Some(ref json_val) = json_value {
4913 snapshot
4914 .plugin_global_states
4915 .entry(self.plugin_name.clone())
4916 .or_default()
4917 .insert(key.clone(), json_val.clone());
4918 } else {
4919 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
4921 map.remove(&key);
4922 if map.is_empty() {
4923 snapshot.plugin_global_states.remove(&self.plugin_name);
4924 }
4925 }
4926 }
4927 }
4928
4929 self.command_sender
4931 .send(PluginCommand::SetGlobalState {
4932 plugin_name: self.plugin_name.clone(),
4933 key,
4934 value: json_value,
4935 })
4936 .is_ok()
4937 }
4938
4939 pub fn get_global_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.plugin_global_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 set_window_state<'js>(
4966 &self,
4967 ctx: rquickjs::Ctx<'js>,
4968 key: String,
4969 value: Value<'js>,
4970 ) -> bool {
4971 let json_value = if value.is_undefined() || value.is_null() {
4972 None
4973 } else {
4974 Some(js_to_json(&ctx, value))
4975 };
4976 if let Ok(mut snapshot) = self.state_snapshot.write() {
4980 match &json_value {
4981 Some(v) => {
4982 snapshot
4983 .active_session_plugin_states
4984 .entry(self.plugin_name.clone())
4985 .or_default()
4986 .insert(key.clone(), v.clone());
4987 }
4988 None => {
4989 if let Some(map) = snapshot
4990 .active_session_plugin_states
4991 .get_mut(&self.plugin_name)
4992 {
4993 map.remove(&key);
4994 if map.is_empty() {
4995 snapshot
4996 .active_session_plugin_states
4997 .remove(&self.plugin_name);
4998 }
4999 }
5000 }
5001 }
5002 }
5003 self.command_sender
5004 .send(PluginCommand::SetWindowState {
5005 plugin_name: self.plugin_name.clone(),
5006 key,
5007 value: json_value,
5008 })
5009 .is_ok()
5010 }
5011
5012 pub fn get_window_state<'js>(
5015 &self,
5016 ctx: rquickjs::Ctx<'js>,
5017 key: String,
5018 ) -> rquickjs::Result<Value<'js>> {
5019 if let Ok(snapshot) = self.state_snapshot.read() {
5020 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
5021 if let Some(json_val) = map.get(&key) {
5022 return json_to_js_value(&ctx, json_val);
5023 }
5024 }
5025 }
5026 Ok(Value::new_undefined(ctx.clone()))
5027 }
5028
5029 pub fn create_scroll_sync_group(
5033 &self,
5034 group_id: u32,
5035 left_split: u32,
5036 right_split: u32,
5037 ) -> bool {
5038 self.plugin_tracked_state
5040 .borrow_mut()
5041 .entry(self.plugin_name.clone())
5042 .or_default()
5043 .scroll_sync_group_ids
5044 .push(group_id);
5045 self.command_sender
5046 .send(PluginCommand::CreateScrollSyncGroup {
5047 group_id,
5048 left_split: SplitId(left_split as usize),
5049 right_split: SplitId(right_split as usize),
5050 })
5051 .is_ok()
5052 }
5053
5054 pub fn set_scroll_sync_anchors<'js>(
5056 &self,
5057 _ctx: rquickjs::Ctx<'js>,
5058 group_id: u32,
5059 anchors: Vec<Vec<u32>>,
5060 ) -> bool {
5061 let anchors: Vec<(usize, usize)> = anchors
5062 .into_iter()
5063 .filter_map(|pair| {
5064 if pair.len() >= 2 {
5065 Some((pair[0] as usize, pair[1] as usize))
5066 } else {
5067 None
5068 }
5069 })
5070 .collect();
5071 self.command_sender
5072 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
5073 .is_ok()
5074 }
5075
5076 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
5078 self.command_sender
5079 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
5080 .is_ok()
5081 }
5082
5083 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
5089 self.command_sender
5090 .send(PluginCommand::ExecuteActions { actions })
5091 .is_ok()
5092 }
5093
5094 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
5098 self.command_sender
5099 .send(PluginCommand::ShowActionPopup {
5100 popup_id: opts.id,
5101 title: opts.title,
5102 message: opts.message,
5103 actions: opts.actions,
5104 })
5105 .is_ok()
5106 }
5107
5108 pub fn set_lsp_menu_contributions(
5112 &self,
5113 plugin_id: String,
5114 language: String,
5115 items: Vec<fresh_core::api::LspMenuItem>,
5116 ) -> bool {
5117 self.command_sender
5118 .send(PluginCommand::SetLspMenuContributions {
5119 plugin_id,
5120 language,
5121 items,
5122 })
5123 .is_ok()
5124 }
5125
5126 pub fn disable_lsp_for_language(&self, language: String) -> bool {
5128 self.command_sender
5129 .send(PluginCommand::DisableLspForLanguage { language })
5130 .is_ok()
5131 }
5132
5133 pub fn restart_lsp_for_language(&self, language: String) -> bool {
5135 self.command_sender
5136 .send(PluginCommand::RestartLspForLanguage { language })
5137 .is_ok()
5138 }
5139
5140 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
5143 self.command_sender
5144 .send(PluginCommand::SetLspRootUri { language, uri })
5145 .is_ok()
5146 }
5147
5148 #[plugin_api(ts_return = "JsDiagnostic[]")]
5150 pub fn get_all_diagnostics<'js>(
5151 &self,
5152 ctx: rquickjs::Ctx<'js>,
5153 ) -> rquickjs::Result<Value<'js>> {
5154 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
5155
5156 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
5157 let mut result: Vec<JsDiagnostic> = Vec::new();
5159 for (uri, diags) in s.diagnostics.iter() {
5160 for diag in diags {
5161 result.push(JsDiagnostic {
5162 uri: uri.clone(),
5163 message: diag.message.clone(),
5164 severity: diag.severity.map(|s| match s {
5165 lsp_types::DiagnosticSeverity::ERROR => 1,
5166 lsp_types::DiagnosticSeverity::WARNING => 2,
5167 lsp_types::DiagnosticSeverity::INFORMATION => 3,
5168 lsp_types::DiagnosticSeverity::HINT => 4,
5169 _ => 0,
5170 }),
5171 range: JsRange {
5172 start: JsPosition {
5173 line: diag.range.start.line,
5174 character: diag.range.start.character,
5175 },
5176 end: JsPosition {
5177 line: diag.range.end.line,
5178 character: diag.range.end.character,
5179 },
5180 },
5181 source: diag.source.clone(),
5182 });
5183 }
5184 }
5185 result
5186 } else {
5187 Vec::new()
5188 };
5189 rquickjs_serde::to_value(ctx, &diagnostics)
5190 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
5191 }
5192
5193 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
5195 self.event_handlers
5196 .read()
5197 .expect("event_handlers poisoned")
5198 .get(&event_name)
5199 .cloned()
5200 .unwrap_or_default()
5201 .into_iter()
5202 .map(|h| h.handler_name)
5203 .collect()
5204 }
5205
5206 #[plugin_api(
5210 async_promise,
5211 js_name = "createVirtualBuffer",
5212 ts_return = "VirtualBufferResult"
5213 )]
5214 #[qjs(rename = "_createVirtualBufferStart")]
5215 pub fn create_virtual_buffer_start(
5216 &self,
5217 _ctx: rquickjs::Ctx<'_>,
5218 opts: fresh_core::api::CreateVirtualBufferOptions,
5219 ) -> rquickjs::Result<u64> {
5220 let id = self.alloc_request_id();
5221
5222 let entries: Vec<TextPropertyEntry> = opts
5224 .entries
5225 .unwrap_or_default()
5226 .into_iter()
5227 .map(|e| TextPropertyEntry {
5228 text: e.text,
5229 properties: e.properties.unwrap_or_default(),
5230 style: e.style,
5231 inline_overlays: e.inline_overlays.unwrap_or_default(),
5232 segments: e.segments.unwrap_or_default(),
5233 pad_to_chars: e.pad_to_chars,
5234 truncate_to_chars: e.truncate_to_chars,
5235 })
5236 .collect();
5237
5238 tracing::debug!(
5239 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
5240 id
5241 );
5242 if let Ok(mut owners) = self.async_resource_owners.lock() {
5244 owners.insert(id, self.plugin_name.clone());
5245 }
5246 let _ = self
5247 .command_sender
5248 .send(PluginCommand::CreateVirtualBufferWithContent {
5249 name: opts.name,
5250 mode: opts.mode.unwrap_or_default(),
5251 read_only: opts.read_only.unwrap_or(false),
5252 entries,
5253 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
5254 show_cursors: opts.show_cursors.unwrap_or(true),
5255 editing_disabled: opts.editing_disabled.unwrap_or(false),
5256 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
5257 request_id: Some(id),
5258 });
5259 Ok(id)
5260 }
5261
5262 #[plugin_api(
5264 async_promise,
5265 js_name = "createVirtualBufferInSplit",
5266 ts_return = "VirtualBufferResult"
5267 )]
5268 #[qjs(rename = "_createVirtualBufferInSplitStart")]
5269 pub fn create_virtual_buffer_in_split_start(
5270 &self,
5271 _ctx: rquickjs::Ctx<'_>,
5272 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
5273 ) -> rquickjs::Result<u64> {
5274 let id = self.alloc_request_id();
5275
5276 let entries: Vec<TextPropertyEntry> = opts
5278 .entries
5279 .unwrap_or_default()
5280 .into_iter()
5281 .map(|e| TextPropertyEntry {
5282 text: e.text,
5283 properties: e.properties.unwrap_or_default(),
5284 style: e.style,
5285 inline_overlays: e.inline_overlays.unwrap_or_default(),
5286 segments: e.segments.unwrap_or_default(),
5287 pad_to_chars: e.pad_to_chars,
5288 truncate_to_chars: e.truncate_to_chars,
5289 })
5290 .collect();
5291
5292 if let Ok(mut owners) = self.async_resource_owners.lock() {
5294 owners.insert(id, self.plugin_name.clone());
5295 }
5296 let _ = self
5297 .command_sender
5298 .send(PluginCommand::CreateVirtualBufferInSplit {
5299 name: opts.name,
5300 mode: opts.mode.unwrap_or_default(),
5301 read_only: opts.read_only.unwrap_or(false),
5302 entries,
5303 ratio: opts.ratio.unwrap_or(0.5),
5304 direction: opts.direction,
5305 panel_id: opts.panel_id,
5306 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5307 show_cursors: opts.show_cursors.unwrap_or(true),
5308 editing_disabled: opts.editing_disabled.unwrap_or(false),
5309 line_wrap: opts.line_wrap,
5310 before: opts.before.unwrap_or(false),
5311 role: opts.role,
5312 request_id: Some(id),
5313 });
5314 Ok(id)
5315 }
5316
5317 #[plugin_api(
5319 async_promise,
5320 js_name = "createVirtualBufferInExistingSplit",
5321 ts_return = "VirtualBufferResult"
5322 )]
5323 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
5324 pub fn create_virtual_buffer_in_existing_split_start(
5325 &self,
5326 _ctx: rquickjs::Ctx<'_>,
5327 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
5328 ) -> rquickjs::Result<u64> {
5329 let id = self.alloc_request_id();
5330
5331 let entries: Vec<TextPropertyEntry> = opts
5333 .entries
5334 .unwrap_or_default()
5335 .into_iter()
5336 .map(|e| TextPropertyEntry {
5337 text: e.text,
5338 properties: e.properties.unwrap_or_default(),
5339 style: e.style,
5340 inline_overlays: e.inline_overlays.unwrap_or_default(),
5341 segments: e.segments.unwrap_or_default(),
5342 pad_to_chars: e.pad_to_chars,
5343 truncate_to_chars: e.truncate_to_chars,
5344 })
5345 .collect();
5346
5347 if let Ok(mut owners) = self.async_resource_owners.lock() {
5349 owners.insert(id, self.plugin_name.clone());
5350 }
5351 let _ = self
5352 .command_sender
5353 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
5354 name: opts.name,
5355 mode: opts.mode.unwrap_or_default(),
5356 read_only: opts.read_only.unwrap_or(false),
5357 entries,
5358 split_id: SplitId(opts.split_id),
5359 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
5360 show_cursors: opts.show_cursors.unwrap_or(true),
5361 editing_disabled: opts.editing_disabled.unwrap_or(false),
5362 line_wrap: opts.line_wrap,
5363 request_id: Some(id),
5364 });
5365 Ok(id)
5366 }
5367
5368 #[qjs(rename = "_createBufferGroupStart")]
5370 pub fn create_buffer_group_start(
5371 &self,
5372 _ctx: rquickjs::Ctx<'_>,
5373 name: String,
5374 mode: String,
5375 layout_json: String,
5376 ) -> rquickjs::Result<u64> {
5377 let id = self.alloc_request_id();
5378 if let Ok(mut owners) = self.async_resource_owners.lock() {
5379 owners.insert(id, self.plugin_name.clone());
5380 }
5381 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
5382 name,
5383 mode,
5384 layout_json,
5385 request_id: Some(id),
5386 });
5387 Ok(id)
5388 }
5389
5390 #[qjs(rename = "setPanelContent")]
5392 pub fn set_panel_content<'js>(
5393 &self,
5394 ctx: rquickjs::Ctx<'js>,
5395 group_id: u32,
5396 panel_name: String,
5397 entries_arr: Vec<rquickjs::Object<'js>>,
5398 ) -> rquickjs::Result<bool> {
5399 let entries: Vec<TextPropertyEntry> = entries_arr
5400 .iter()
5401 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5402 .collect();
5403 Ok(self
5404 .command_sender
5405 .send(PluginCommand::SetPanelContent {
5406 group_id: group_id as usize,
5407 panel_name,
5408 entries,
5409 })
5410 .is_ok())
5411 }
5412
5413 #[qjs(rename = "closeBufferGroup")]
5415 pub fn close_buffer_group(&self, group_id: u32) -> bool {
5416 self.command_sender
5417 .send(PluginCommand::CloseBufferGroup {
5418 group_id: group_id as usize,
5419 })
5420 .is_ok()
5421 }
5422
5423 #[qjs(rename = "focusBufferGroupPanel")]
5425 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
5426 self.command_sender
5427 .send(PluginCommand::FocusPanel {
5428 group_id: group_id as usize,
5429 panel_name,
5430 })
5431 .is_ok()
5432 }
5433
5434 #[plugin_api(
5441 async_promise,
5442 js_name = "setBufferGroupPanelBuffer",
5443 ts_return = "boolean"
5444 )]
5445 #[qjs(rename = "_setBufferGroupPanelBufferStart")]
5446 pub fn set_buffer_group_panel_buffer_start(
5447 &self,
5448 _ctx: rquickjs::Ctx<'_>,
5449 group_id: u32,
5450 panel_name: String,
5451 buffer_id: u32,
5452 ) -> u64 {
5453 let id = self.alloc_request_id();
5454 let _ = self
5455 .command_sender
5456 .send(PluginCommand::SetBufferGroupPanelBuffer {
5457 group_id: group_id as usize,
5458 panel_name,
5459 buffer_id: BufferId(buffer_id as usize),
5460 request_id: id,
5461 });
5462 id
5463 }
5464
5465 pub fn set_virtual_buffer_content<'js>(
5469 &self,
5470 ctx: rquickjs::Ctx<'js>,
5471 buffer_id: u32,
5472 entries_arr: Vec<rquickjs::Object<'js>>,
5473 ) -> rquickjs::Result<bool> {
5474 let entries: Vec<TextPropertyEntry> = entries_arr
5475 .iter()
5476 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
5477 .collect();
5478 Ok(self
5479 .command_sender
5480 .send(PluginCommand::SetVirtualBufferContent {
5481 buffer_id: BufferId(buffer_id as usize),
5482 entries,
5483 })
5484 .is_ok())
5485 }
5486
5487 pub fn get_text_properties_at_cursor(
5489 &self,
5490 buffer_id: u32,
5491 ) -> fresh_core::api::TextPropertiesAtCursor {
5492 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
5493 }
5494
5495 #[qjs(rename = "mountWidgetPanel")]
5505 pub fn mount_widget_panel<'js>(
5506 &self,
5507 ctx: rquickjs::Ctx<'js>,
5508 panel_id: f64,
5509 buffer_id: u32,
5510 spec_obj: rquickjs::Value<'js>,
5511 ) -> rquickjs::Result<bool> {
5512 let json = js_to_json(&ctx, spec_obj);
5513 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5514 Ok(s) => s,
5515 Err(e) => {
5516 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
5517 return Ok(false);
5518 }
5519 };
5520 Ok(self
5521 .command_sender
5522 .send(PluginCommand::MountWidgetPanel {
5523 panel_id: panel_id as u64,
5524 buffer_id: BufferId(buffer_id as usize),
5525 spec,
5526 })
5527 .is_ok())
5528 }
5529
5530 #[qjs(rename = "updateWidgetPanel")]
5533 pub fn update_widget_panel<'js>(
5534 &self,
5535 ctx: rquickjs::Ctx<'js>,
5536 panel_id: f64,
5537 spec_obj: rquickjs::Value<'js>,
5538 ) -> rquickjs::Result<bool> {
5539 let json = js_to_json(&ctx, spec_obj);
5540 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5541 Ok(s) => s,
5542 Err(e) => {
5543 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
5544 return Ok(false);
5545 }
5546 };
5547 Ok(self
5548 .command_sender
5549 .send(PluginCommand::UpdateWidgetPanel {
5550 panel_id: panel_id as u64,
5551 spec,
5552 })
5553 .is_ok())
5554 }
5555
5556 #[qjs(rename = "unmountWidgetPanel")]
5559 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
5560 self.command_sender
5561 .send(PluginCommand::UnmountWidgetPanel {
5562 panel_id: panel_id as u64,
5563 })
5564 .is_ok()
5565 }
5566
5567 #[qjs(rename = "widgetCommand")]
5576 pub fn widget_command<'js>(
5577 &self,
5578 ctx: rquickjs::Ctx<'js>,
5579 panel_id: f64,
5580 action_obj: rquickjs::Value<'js>,
5581 ) -> rquickjs::Result<bool> {
5582 let json = js_to_json(&ctx, action_obj);
5583 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
5584 Ok(a) => a,
5585 Err(e) => {
5586 tracing::error!("widgetCommand: invalid action: {}", e);
5587 return Ok(false);
5588 }
5589 };
5590 Ok(self
5591 .command_sender
5592 .send(PluginCommand::WidgetCommand {
5593 panel_id: panel_id as u64,
5594 action,
5595 })
5596 .is_ok())
5597 }
5598
5599 #[qjs(rename = "widgetMutate")]
5605 pub fn widget_mutate<'js>(
5606 &self,
5607 ctx: rquickjs::Ctx<'js>,
5608 panel_id: f64,
5609 mutation_obj: rquickjs::Value<'js>,
5610 ) -> rquickjs::Result<bool> {
5611 let json = js_to_json(&ctx, mutation_obj);
5612 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
5613 Ok(m) => m,
5614 Err(e) => {
5615 tracing::error!("widgetMutate: invalid mutation: {}", e);
5616 return Ok(false);
5617 }
5618 };
5619 Ok(self
5620 .command_sender
5621 .send(PluginCommand::WidgetMutate {
5622 panel_id: panel_id as u64,
5623 mutation,
5624 })
5625 .is_ok())
5626 }
5627
5628 #[qjs(rename = "mountFloatingWidget")]
5631 pub fn mount_floating_widget<'js>(
5632 &self,
5633 ctx: rquickjs::Ctx<'js>,
5634 panel_id: f64,
5635 spec_obj: rquickjs::Value<'js>,
5636 width_pct: f64,
5637 height_pct: f64,
5638 as_dock: rquickjs::function::Opt<bool>,
5639 ) -> rquickjs::Result<bool> {
5640 let json = js_to_json(&ctx, spec_obj);
5641 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5642 Ok(s) => s,
5643 Err(e) => {
5644 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
5645 return Ok(false);
5646 }
5647 };
5648 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
5649 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
5650 Ok(self
5651 .command_sender
5652 .send(PluginCommand::MountFloatingWidget {
5653 panel_id: panel_id as u64,
5654 spec,
5655 width_pct,
5656 height_pct,
5657 as_dock: as_dock.0.unwrap_or(false),
5658 })
5659 .is_ok())
5660 }
5661
5662 #[qjs(rename = "updateFloatingWidget")]
5664 pub fn update_floating_widget<'js>(
5665 &self,
5666 ctx: rquickjs::Ctx<'js>,
5667 panel_id: f64,
5668 spec_obj: rquickjs::Value<'js>,
5669 ) -> rquickjs::Result<bool> {
5670 let json = js_to_json(&ctx, spec_obj);
5671 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
5672 Ok(s) => s,
5673 Err(e) => {
5674 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
5675 return Ok(false);
5676 }
5677 };
5678 Ok(self
5679 .command_sender
5680 .send(PluginCommand::UpdateFloatingWidget {
5681 panel_id: panel_id as u64,
5682 spec,
5683 })
5684 .is_ok())
5685 }
5686
5687 #[qjs(rename = "unmountFloatingWidget")]
5689 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
5690 self.command_sender
5691 .send(PluginCommand::UnmountFloatingWidget {
5692 panel_id: panel_id as u64,
5693 })
5694 .is_ok()
5695 }
5696
5697 #[qjs(rename = "floatingPanelControl")]
5703 pub fn floating_panel_control(&self, panel_id: f64, op: String, arg: f64) -> bool {
5704 self.command_sender
5705 .send(PluginCommand::FloatingPanelControl {
5706 panel_id: panel_id as u64,
5707 op,
5708 arg,
5709 })
5710 .is_ok()
5711 }
5712
5713 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
5722 #[qjs(rename = "_spawnProcessStart")]
5723 pub fn spawn_process_start(
5724 &self,
5725 _ctx: rquickjs::Ctx<'_>,
5726 command: String,
5727 args: Vec<String>,
5728 cwd: rquickjs::function::Opt<String>,
5729 stdout_to: rquickjs::function::Opt<String>,
5730 ) -> u64 {
5731 let id = self.alloc_request_id();
5732 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
5738 self.state_snapshot
5739 .read()
5740 .ok()
5741 .map(|s| s.working_dir.to_string_lossy().to_string())
5742 });
5743 let stdout_to_path = stdout_to
5744 .0
5745 .filter(|s| !s.is_empty())
5746 .map(std::path::PathBuf::from);
5747 tracing::info!(
5748 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, stdout_to={:?}, callback_id={}",
5749 self.plugin_name,
5750 command,
5751 args,
5752 effective_cwd,
5753 stdout_to_path,
5754 id
5755 );
5756 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
5757 callback_id: JsCallbackId::new(id),
5758 command,
5759 args,
5760 cwd: effective_cwd,
5761 stdout_to: stdout_to_path,
5762 });
5763 id
5764 }
5765
5766 #[plugin_api(
5773 async_thenable,
5774 js_name = "spawnHostProcess",
5775 ts_return = "SpawnResult"
5776 )]
5777 #[qjs(rename = "_spawnHostProcessStart")]
5778 pub fn spawn_host_process_start(
5779 &self,
5780 _ctx: rquickjs::Ctx<'_>,
5781 command: String,
5782 args: Vec<String>,
5783 cwd: rquickjs::function::Opt<String>,
5784 ) -> u64 {
5785 let id = self.alloc_request_id();
5786 let effective_cwd = cwd.0.or_else(|| {
5787 self.state_snapshot
5788 .read()
5789 .ok()
5790 .map(|s| s.working_dir.to_string_lossy().to_string())
5791 });
5792 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
5793 callback_id: JsCallbackId::new(id),
5794 command,
5795 args,
5796 cwd: effective_cwd,
5797 });
5798 id
5799 }
5800
5801 #[plugin_api(js_name = "_killHostProcess")]
5811 pub fn kill_host_process(&self, process_id: u64) -> bool {
5812 self.command_sender
5813 .send(PluginCommand::KillHostProcess { process_id })
5814 .is_ok()
5815 }
5816
5817 #[plugin_api(js_name = "setAuthority")]
5826 pub fn set_authority(
5827 &self,
5828 ctx: rquickjs::Ctx<'_>,
5829 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
5830 ) -> bool {
5831 let json = js_to_json(&ctx, payload);
5832 let _ = self
5833 .command_sender
5834 .send(PluginCommand::SetAuthority { payload: json });
5835 true
5836 }
5837
5838 #[plugin_api(js_name = "clearAuthority")]
5841 pub fn clear_authority(&self) {
5842 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
5843 }
5844
5845 #[plugin_api(async_promise, js_name = "attachRemoteAgent", ts_return = "void")]
5861 #[qjs(rename = "_attachRemoteAgentStart")]
5862 pub fn attach_remote_agent(
5863 &self,
5864 ctx: rquickjs::Ctx<'_>,
5865 #[plugin_api(ts_type = "RemoteAgentSpec")] payload: rquickjs::Value<'_>,
5866 ) -> u64 {
5867 let json = js_to_json(&ctx, payload);
5868 let id = self.alloc_request_id();
5869 let _ = self.command_sender.send(PluginCommand::AttachRemoteAgent {
5870 payload: json,
5871 request_id: id,
5872 });
5873 id
5874 }
5875
5876 #[plugin_api(js_name = "cancelRemoteAgent")]
5881 pub fn cancel_remote_agent(&self) {
5882 let _ = self.command_sender.send(PluginCommand::CancelRemoteAttach);
5883 }
5884
5885 #[plugin_api(js_name = "setEnv")]
5889 pub fn set_env(&self, snippet: String, dir: Option<String>) {
5890 let _ = self
5891 .command_sender
5892 .send(PluginCommand::SetEnv { snippet, dir });
5893 }
5894
5895 #[plugin_api(js_name = "clearEnv")]
5897 pub fn clear_env(&self) {
5898 let _ = self.command_sender.send(PluginCommand::ClearEnv);
5899 }
5900
5901 #[plugin_api(js_name = "setRemoteIndicatorState")]
5919 pub fn set_remote_indicator_state(
5920 &self,
5921 ctx: rquickjs::Ctx<'_>,
5922 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
5923 ) -> bool {
5924 let json = js_to_json(&ctx, state);
5925 let _ = self
5926 .command_sender
5927 .send(PluginCommand::SetRemoteIndicatorState { state: json });
5928 true
5929 }
5930
5931 #[plugin_api(js_name = "clearRemoteIndicatorState")]
5934 pub fn clear_remote_indicator_state(&self) {
5935 let _ = self
5936 .command_sender
5937 .send(PluginCommand::ClearRemoteIndicatorState);
5938 }
5939
5940 #[plugin_api(async_thenable, js_name = "httpFetch", ts_return = "SpawnResult")]
5951 #[qjs(rename = "_httpFetchStart")]
5952 pub fn http_fetch_start(
5953 &self,
5954 _ctx: rquickjs::Ctx<'_>,
5955 url: String,
5956 target_path: String,
5957 ) -> u64 {
5958 let id = self.alloc_request_id();
5959 tracing::info!(
5960 "http_fetch_start: plugin='{}', url='{}', target='{}', callback_id={}",
5961 self.plugin_name,
5962 url,
5963 target_path,
5964 id
5965 );
5966 let _ = self.command_sender.send(PluginCommand::HttpFetch {
5967 url,
5968 target_path: std::path::PathBuf::from(target_path),
5969 callback_id: JsCallbackId::new(id),
5970 });
5971 id
5972 }
5973
5974 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
5976 #[qjs(rename = "_spawnProcessWaitStart")]
5977 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
5978 let id = self.alloc_request_id();
5979 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
5980 process_id,
5981 callback_id: JsCallbackId::new(id),
5982 });
5983 id
5984 }
5985
5986 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
5988 #[qjs(rename = "_getBufferTextStart")]
5989 pub fn get_buffer_text_start(
5990 &self,
5991 _ctx: rquickjs::Ctx<'_>,
5992 buffer_id: u32,
5993 start: u32,
5994 end: u32,
5995 ) -> u64 {
5996 let id = self.alloc_request_id();
5997 let _ = self.command_sender.send(PluginCommand::GetBufferText {
5998 buffer_id: BufferId(buffer_id as usize),
5999 start: start as usize,
6000 end: end as usize,
6001 request_id: id,
6002 });
6003 id
6004 }
6005
6006 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
6008 #[qjs(rename = "_delayStart")]
6009 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
6010 let id = self.alloc_request_id();
6011 let _ = self.command_sender.send(PluginCommand::Delay {
6012 callback_id: JsCallbackId::new(id),
6013 duration_ms,
6014 });
6015 id
6016 }
6017
6018 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
6022 #[qjs(rename = "_grepProjectStart")]
6023 pub fn grep_project_start(
6024 &self,
6025 _ctx: rquickjs::Ctx<'_>,
6026 pattern: String,
6027 fixed_string: Option<bool>,
6028 case_sensitive: Option<bool>,
6029 max_results: Option<u32>,
6030 whole_words: Option<bool>,
6031 ) -> u64 {
6032 let id = self.alloc_request_id();
6033 let _ = self.command_sender.send(PluginCommand::GrepProject {
6034 pattern,
6035 fixed_string: fixed_string.unwrap_or(true),
6036 case_sensitive: case_sensitive.unwrap_or(true),
6037 max_results: max_results.unwrap_or(200) as usize,
6038 whole_words: whole_words.unwrap_or(false),
6039 callback_id: JsCallbackId::new(id),
6040 });
6041 id
6042 }
6043
6044 #[plugin_api(
6049 js_name = "beginSearch",
6050 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean; sourceBufferId?: number }): SearchHandle"
6051 )]
6052 #[qjs(rename = "_beginSearch")]
6053 pub fn begin_search(
6054 &self,
6055 _ctx: rquickjs::Ctx<'_>,
6056 pattern: String,
6057 fixed_string: bool,
6058 case_sensitive: bool,
6059 max_results: u32,
6060 whole_words: bool,
6061 source_buffer_id: u32,
6062 ) -> u64 {
6063 let id = self.alloc_request_id();
6064 let entry = Arc::new(SearchHandleState::new());
6067 if let Ok(mut map) = self.search_handles.lock() {
6068 map.insert(id, entry);
6069 }
6070 let _ = self.command_sender.send(PluginCommand::BeginSearch {
6071 pattern,
6072 fixed_string,
6073 case_sensitive,
6074 max_results: max_results as usize,
6075 whole_words,
6076 source_buffer_id: source_buffer_id as usize,
6077 handle_id: id,
6078 });
6079 id
6080 }
6081
6082 #[plugin_api(ts_return = "SearchTakeResult")]
6087 #[qjs(rename = "_searchHandleTake")]
6088 pub fn search_handle_take<'js>(
6089 &self,
6090 ctx: rquickjs::Ctx<'js>,
6091 handle_id: u64,
6092 ) -> rquickjs::Result<Value<'js>> {
6093 let entry = self
6094 .search_handles
6095 .lock()
6096 .ok()
6097 .and_then(|m| m.get(&handle_id).cloned());
6098 let result = match entry {
6099 Some(handle) => {
6100 let mut state = match handle.state.lock() {
6102 Ok(s) => s,
6103 Err(poisoned) => poisoned.into_inner(),
6104 };
6105 let matches = std::mem::take(&mut state.pending);
6106 let snapshot = SearchTakeResult {
6107 matches,
6108 done: state.done,
6109 total_seen: state.total_seen,
6110 truncated: state.truncated,
6111 error: state.error.clone(),
6112 };
6113 let done = snapshot.done;
6114 drop(state);
6115 if done {
6116 if let Ok(mut map) = self.search_handles.lock() {
6117 map.remove(&handle_id);
6118 }
6119 }
6120 snapshot
6121 }
6122 None => SearchTakeResult {
6123 matches: Vec::new(),
6124 done: true,
6125 total_seen: 0,
6126 truncated: false,
6127 error: None,
6128 },
6129 };
6130 rquickjs_serde::to_value(ctx, &result)
6131 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
6132 }
6133
6134 #[qjs(rename = "_searchHandleCancel")]
6137 pub fn search_handle_cancel(&self, handle_id: u64) {
6138 if let Ok(map) = self.search_handles.lock() {
6139 if let Some(entry) = map.get(&handle_id) {
6140 entry
6141 .cancel
6142 .store(true, std::sync::atomic::Ordering::Relaxed);
6143 }
6144 }
6145 }
6146
6147 #[plugin_api(
6151 async_promise,
6152 js_name = "replaceInFile",
6153 ts_raw = "replaceInFile(filePath: string, matches: number[][], replacement: string, bufferId?: number): Promise<ReplaceResult>"
6154 )]
6155 #[qjs(rename = "_replaceInFileStart")]
6156 pub fn replace_in_file_start(
6157 &self,
6158 _ctx: rquickjs::Ctx<'_>,
6159 file_path: String,
6160 matches: Vec<Vec<u32>>,
6161 replacement: String,
6162 buffer_id: rquickjs::function::Opt<u32>,
6163 ) -> u64 {
6164 let id = self.alloc_request_id();
6165 let match_pairs: Vec<(usize, usize)> = matches
6167 .iter()
6168 .map(|m| (m[0] as usize, m[1] as usize))
6169 .collect();
6170 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
6171 file_path: PathBuf::from(file_path),
6172 buffer_id: buffer_id.0.unwrap_or(0) as usize,
6173 matches: match_pairs,
6174 replacement,
6175 callback_id: JsCallbackId::new(id),
6176 });
6177 id
6178 }
6179
6180 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
6182 #[qjs(rename = "_sendLspRequestStart")]
6183 pub fn send_lsp_request_start<'js>(
6184 &self,
6185 ctx: rquickjs::Ctx<'js>,
6186 language: String,
6187 method: String,
6188 params: Option<rquickjs::Object<'js>>,
6189 ) -> rquickjs::Result<u64> {
6190 let id = self.alloc_request_id();
6191 let params_json: Option<serde_json::Value> = params.map(|obj| {
6193 let val = obj.into_value();
6194 js_to_json(&ctx, val)
6195 });
6196 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
6197 request_id: id,
6198 language,
6199 method,
6200 params: params_json,
6201 });
6202 Ok(id)
6203 }
6204
6205 #[plugin_api(
6207 async_thenable,
6208 js_name = "spawnBackgroundProcess",
6209 ts_return = "BackgroundProcessResult"
6210 )]
6211 #[qjs(rename = "_spawnBackgroundProcessStart")]
6212 pub fn spawn_background_process_start(
6213 &self,
6214 _ctx: rquickjs::Ctx<'_>,
6215 command: String,
6216 args: Vec<String>,
6217 cwd: rquickjs::function::Opt<String>,
6218 ) -> u64 {
6219 let id = self.alloc_request_id();
6220 let process_id = id;
6222 self.plugin_tracked_state
6224 .borrow_mut()
6225 .entry(self.plugin_name.clone())
6226 .or_default()
6227 .background_process_ids
6228 .push(process_id);
6229 let _ = self
6231 .command_sender
6232 .send(PluginCommand::SpawnBackgroundProcess {
6233 process_id,
6234 command,
6235 args,
6236 cwd: cwd.0.filter(|s| !s.is_empty()),
6237 callback_id: JsCallbackId::new(id),
6238 });
6239 id
6240 }
6241
6242 pub fn kill_background_process(&self, process_id: u64) -> bool {
6244 self.command_sender
6245 .send(PluginCommand::KillBackgroundProcess { process_id })
6246 .is_ok()
6247 }
6248
6249 #[plugin_api(
6253 async_promise,
6254 js_name = "createTerminal",
6255 ts_return = "TerminalResult"
6256 )]
6257 #[qjs(rename = "_createTerminalStart")]
6258 pub fn create_terminal_start(
6259 &self,
6260 _ctx: rquickjs::Ctx<'_>,
6261 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
6262 ) -> rquickjs::Result<u64> {
6263 let id = self.alloc_request_id();
6264
6265 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
6266 cwd: None,
6267 direction: None,
6268 ratio: None,
6269 focus: None,
6270 persistent: None,
6271 window_id: None,
6272 command: None,
6273 title: None,
6274 });
6275
6276 if let Ok(mut owners) = self.async_resource_owners.lock() {
6278 owners.insert(id, self.plugin_name.clone());
6279 }
6280 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
6281 cwd: opts.cwd,
6282 direction: opts.direction,
6283 ratio: opts.ratio,
6284 focus: opts.focus,
6285 window_id: opts.window_id,
6286 persistent: opts.persistent.unwrap_or(false),
6290 command: opts.command,
6291 title: opts.title,
6292 request_id: id,
6293 });
6294 Ok(id)
6295 }
6296
6297 #[plugin_api(
6303 async_promise,
6304 js_name = "createWindowWithTerminal",
6305 ts_return = "SessionWithTerminalResult"
6306 )]
6307 #[qjs(rename = "_createWindowWithTerminalStart")]
6308 pub fn create_window_with_terminal_start(
6309 &self,
6310 _ctx: rquickjs::Ctx<'_>,
6311 opts: fresh_core::api::CreateWindowWithTerminalOptions,
6312 ) -> rquickjs::Result<u64> {
6313 let id = self.alloc_request_id();
6314 if let Ok(mut owners) = self.async_resource_owners.lock() {
6315 owners.insert(id, self.plugin_name.clone());
6316 }
6317 let _ = self
6318 .command_sender
6319 .send(PluginCommand::CreateWindowWithTerminal {
6320 root: std::path::PathBuf::from(opts.root),
6321 label: opts.label,
6322 cwd: opts.cwd,
6323 command: opts.command,
6324 title: opts.title,
6325 resume: opts.resume,
6326 request_id: id,
6327 });
6328 Ok(id)
6329 }
6330
6331 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
6333 self.command_sender
6334 .send(PluginCommand::SendTerminalInput {
6335 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6336 data,
6337 })
6338 .is_ok()
6339 }
6340
6341 pub fn close_terminal(&self, terminal_id: u64) -> bool {
6343 self.command_sender
6344 .send(PluginCommand::CloseTerminal {
6345 terminal_id: fresh_core::TerminalId(terminal_id as usize),
6346 })
6347 .is_ok()
6348 }
6349
6350 pub fn signal_window(&self, id: f64, signal: String) -> bool {
6357 self.command_sender
6358 .send(PluginCommand::SignalWindow {
6359 id: fresh_core::WindowId(id as u64),
6360 signal,
6361 })
6362 .is_ok()
6363 }
6364
6365 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
6369 self.command_sender
6370 .send(PluginCommand::RefreshLines {
6371 buffer_id: BufferId(buffer_id as usize),
6372 })
6373 .is_ok()
6374 }
6375
6376 pub fn get_current_locale(&self) -> String {
6378 self.services.current_locale()
6379 }
6380
6381 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
6385 #[qjs(rename = "_loadPluginStart")]
6386 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
6387 let id = self.alloc_request_id();
6388 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
6389 path: std::path::PathBuf::from(path),
6390 callback_id: JsCallbackId::new(id),
6391 });
6392 id
6393 }
6394
6395 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
6397 #[qjs(rename = "_unloadPluginStart")]
6398 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6399 let id = self.alloc_request_id();
6400 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
6401 name,
6402 callback_id: JsCallbackId::new(id),
6403 });
6404 id
6405 }
6406
6407 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
6409 #[qjs(rename = "_reloadPluginStart")]
6410 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
6411 let id = self.alloc_request_id();
6412 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
6413 name,
6414 callback_id: JsCallbackId::new(id),
6415 });
6416 id
6417 }
6418
6419 #[plugin_api(
6422 async_promise,
6423 js_name = "listPlugins",
6424 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
6425 )]
6426 #[qjs(rename = "_listPluginsStart")]
6427 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
6428 let id = self.alloc_request_id();
6429 let _ = self.command_sender.send(PluginCommand::ListPlugins {
6430 callback_id: JsCallbackId::new(id),
6431 });
6432 id
6433 }
6434}
6435
6436fn parse_view_token(
6443 obj: &rquickjs::Object<'_>,
6444 idx: usize,
6445) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
6446 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
6447
6448 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
6450 from: "object",
6451 to: "ViewTokenWire",
6452 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
6453 })?;
6454
6455 let source_offset: Option<usize> = obj
6457 .get("sourceOffset")
6458 .ok()
6459 .or_else(|| obj.get("source_offset").ok());
6460
6461 let kind = if kind_value.is_string() {
6463 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6466 from: "value",
6467 to: "string",
6468 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
6469 })?;
6470
6471 match kind_str.to_lowercase().as_str() {
6472 "text" => {
6473 let text: String = obj.get("text").unwrap_or_default();
6474 ViewTokenWireKind::Text(text)
6475 }
6476 "newline" => ViewTokenWireKind::Newline,
6477 "space" => ViewTokenWireKind::Space,
6478 "break" => ViewTokenWireKind::Break,
6479 _ => {
6480 tracing::warn!(
6482 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
6483 idx, kind_str
6484 );
6485 return Err(rquickjs::Error::FromJs {
6486 from: "string",
6487 to: "ViewTokenWireKind",
6488 message: Some(format!(
6489 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
6490 idx, kind_str
6491 )),
6492 });
6493 }
6494 }
6495 } else if kind_value.is_object() {
6496 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
6498 from: "value",
6499 to: "object",
6500 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
6501 })?;
6502
6503 if let Ok(text) = kind_obj.get::<_, String>("Text") {
6504 ViewTokenWireKind::Text(text)
6505 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
6506 ViewTokenWireKind::BinaryByte(byte)
6507 } else {
6508 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
6510 tracing::warn!(
6511 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
6512 idx,
6513 keys
6514 );
6515 return Err(rquickjs::Error::FromJs {
6516 from: "object",
6517 to: "ViewTokenWireKind",
6518 message: Some(format!(
6519 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
6520 idx, keys
6521 )),
6522 });
6523 }
6524 } else {
6525 tracing::warn!(
6526 "token[{}]: 'kind' field must be a string or object, got: {:?}",
6527 idx,
6528 kind_value.type_of()
6529 );
6530 return Err(rquickjs::Error::FromJs {
6531 from: "value",
6532 to: "ViewTokenWireKind",
6533 message: Some(format!(
6534 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
6535 idx
6536 )),
6537 });
6538 };
6539
6540 let style = parse_view_token_style(obj, idx)?;
6542
6543 Ok(ViewTokenWire {
6544 source_offset,
6545 kind,
6546 style,
6547 })
6548}
6549
6550fn parse_view_token_style(
6552 obj: &rquickjs::Object<'_>,
6553 idx: usize,
6554) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
6555 use fresh_core::api::{TokenColor, ViewTokenStyle};
6556
6557 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
6558 let Some(s) = style_obj else {
6559 return Ok(None);
6560 };
6561
6562 fn parse_color(
6567 s: &rquickjs::Object<'_>,
6568 field: &str,
6569 idx: usize,
6570 ) -> rquickjs::Result<Option<TokenColor>> {
6571 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
6572 if arr.len() < 3 {
6573 tracing::warn!(
6574 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
6575 idx,
6576 field,
6577 arr.len()
6578 );
6579 return Ok(None);
6580 }
6581 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
6582 }
6583 if let Ok(name) = s.get::<_, String>(field) {
6584 return Ok(Some(TokenColor::Named(name)));
6585 }
6586 Ok(None)
6587 }
6588
6589 Ok(Some(ViewTokenStyle {
6590 fg: parse_color(&s, "fg", idx)?,
6591 bg: parse_color(&s, "bg", idx)?,
6592 bold: s.get("bold").unwrap_or(false),
6593 italic: s.get("italic").unwrap_or(false),
6594 underline: s.get("underline").unwrap_or(false),
6595 }))
6596}
6597
6598pub struct QuickJsBackend {
6600 runtime: Runtime,
6601 main_context: Context,
6603 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
6605 event_handlers: EventHandlerRegistry,
6609 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
6611 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6613 command_sender: mpsc::Sender<PluginCommand>,
6615 #[allow(dead_code)]
6617 pending_responses: PendingResponses,
6618 next_request_id: Rc<RefCell<u64>>,
6620 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
6622 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6624 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
6626 async_resource_owners: AsyncResourceOwners,
6629 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
6631 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
6633 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
6635 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
6637 plugin_api_exports: PluginApiExports,
6641 search_handles: SearchHandleRegistry,
6643}
6644
6645impl Drop for QuickJsBackend {
6646 fn drop(&mut self) {
6647 self.plugin_api_exports.borrow_mut().clear();
6653 }
6654}
6655
6656impl QuickJsBackend {
6657 pub fn new() -> Result<Self> {
6659 let (tx, _rx) = mpsc::channel();
6660 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6661 let services = Arc::new(fresh_core::services::NoopServiceBridge);
6662 Self::with_state(state_snapshot, tx, services)
6663 }
6664
6665 pub fn with_state(
6667 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6668 command_sender: mpsc::Sender<PluginCommand>,
6669 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6670 ) -> Result<Self> {
6671 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
6672 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
6673 }
6674
6675 pub fn with_state_and_responses(
6677 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6678 command_sender: mpsc::Sender<PluginCommand>,
6679 pending_responses: PendingResponses,
6680 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6681 ) -> Result<Self> {
6682 let async_resource_owners: AsyncResourceOwners =
6683 Arc::new(std::sync::Mutex::new(HashMap::new()));
6684 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
6685 let event_handlers: EventHandlerRegistry = Arc::new(RwLock::new(HashMap::new()));
6686 Self::with_state_responses_and_resources(
6687 state_snapshot,
6688 command_sender,
6689 pending_responses,
6690 services,
6691 async_resource_owners,
6692 search_handles,
6693 event_handlers,
6694 )
6695 }
6696
6697 pub fn with_state_responses_and_resources(
6700 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
6701 command_sender: mpsc::Sender<PluginCommand>,
6702 pending_responses: PendingResponses,
6703 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
6704 async_resource_owners: AsyncResourceOwners,
6705 search_handles: SearchHandleRegistry,
6706 event_handlers: EventHandlerRegistry,
6707 ) -> Result<Self> {
6708 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
6709
6710 let runtime =
6711 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
6712
6713 runtime.set_host_promise_rejection_tracker(Some(Box::new(
6715 |_ctx, _promise, reason, is_handled| {
6716 if !is_handled {
6717 let error_msg = if let Some(exc) = reason.as_exception() {
6719 format!(
6720 "{}: {}",
6721 exc.message().unwrap_or_default(),
6722 exc.stack().unwrap_or_default()
6723 )
6724 } else {
6725 format!("{:?}", reason)
6726 };
6727
6728 tracing::error!("Unhandled Promise rejection: {}", error_msg);
6729
6730 if should_panic_on_js_errors() {
6731 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
6734 set_fatal_js_error(full_msg);
6735 }
6736 }
6737 },
6738 )));
6739
6740 let main_context = Context::full(&runtime)
6741 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
6742
6743 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
6744 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
6745 let next_request_id = Rc::new(RefCell::new(1u64));
6746 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
6747 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
6748 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
6749 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
6750 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
6751 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
6752 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
6753
6754 let backend = Self {
6755 runtime,
6756 main_context,
6757 plugin_contexts,
6758 event_handlers,
6759 registered_actions,
6760 state_snapshot,
6761 command_sender,
6762 pending_responses,
6763 next_request_id,
6764 callback_contexts,
6765 services,
6766 plugin_tracked_state,
6767 async_resource_owners,
6768 registered_command_names,
6769 registered_grammar_languages,
6770 registered_language_configs,
6771 registered_lsp_servers,
6772 plugin_api_exports,
6773 search_handles,
6774 };
6775
6776 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
6778
6779 tracing::debug!("QuickJsBackend::new: runtime created successfully");
6780 Ok(backend)
6781 }
6782
6783 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
6785 let state_snapshot = Arc::clone(&self.state_snapshot);
6786 let command_sender = self.command_sender.clone();
6787 let event_handlers = Arc::clone(&self.event_handlers);
6788 let registered_actions = Rc::clone(&self.registered_actions);
6789 let next_request_id = Rc::clone(&self.next_request_id);
6790 let registered_command_names = Rc::clone(&self.registered_command_names);
6791 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
6792 let registered_language_configs = Rc::clone(&self.registered_language_configs);
6793 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
6794 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
6795
6796 context.with(|ctx| {
6797 let globals = ctx.globals();
6798
6799 globals.set("__pluginName__", plugin_name)?;
6801
6802 let js_api = JsEditorApi {
6805 state_snapshot: Arc::clone(&state_snapshot),
6806 command_sender: command_sender.clone(),
6807 registered_actions: Rc::clone(®istered_actions),
6808 event_handlers: Arc::clone(&event_handlers),
6809 next_request_id: Rc::clone(&next_request_id),
6810 callback_contexts: Rc::clone(&self.callback_contexts),
6811 services: self.services.clone(),
6812 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
6813 async_resource_owners: Arc::clone(&self.async_resource_owners),
6814 registered_command_names: Rc::clone(®istered_command_names),
6815 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
6816 registered_language_configs: Rc::clone(®istered_language_configs),
6817 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
6818 plugin_api_exports: Rc::clone(&plugin_api_exports),
6819 search_handles: Arc::clone(&self.search_handles),
6820 plugin_name: plugin_name.to_string(),
6821 };
6822 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
6823
6824 globals.set("editor", editor)?;
6826
6827 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
6829
6830 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
6832
6833ctx.eval::<(), _>(
6840 r#"
6841 (function() {
6842 const originalOn = editor.on.bind(editor);
6843 const originalOff = editor.off.bind(editor);
6844 let counter = 0;
6845 const anonNames = new WeakMap();
6846 editor.on = function(eventName, handlerOrName) {
6847 if (typeof handlerOrName === 'function') {
6848 const existing = anonNames.get(handlerOrName);
6849 const name = existing || `__anon_on_${++counter}`;
6850 if (!existing) {
6851 anonNames.set(handlerOrName, name);
6852 }
6853 globalThis[name] = handlerOrName;
6854 return originalOn(eventName, name);
6855 }
6856 return originalOn(eventName, handlerOrName);
6857 };
6858 editor.off = function(eventName, handlerOrName) {
6859 if (typeof handlerOrName === 'function') {
6860 const name = anonNames.get(handlerOrName);
6861 if (name === undefined) return false;
6862 return originalOff(eventName, name);
6863 }
6864 return originalOff(eventName, handlerOrName);
6865 };
6866 })();
6867 "#,
6868 )?;
6869
6870 let console = Object::new(ctx.clone())?;
6873 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6874 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6875 tracing::info!("console.log: {}", parts.join(" "));
6876 })?)?;
6877 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6878 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6879 tracing::warn!("console.warn: {}", parts.join(" "));
6880 })?)?;
6881 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
6882 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
6883 tracing::error!("console.error: {}", parts.join(" "));
6884 })?)?;
6885 globals.set("console", console)?;
6886
6887 ctx.eval::<(), _>(r#"
6889 // Pending promise callbacks: callbackId -> { resolve, reject }
6890 globalThis._pendingCallbacks = new Map();
6891
6892 // Resolve a pending callback (called from Rust)
6893 globalThis._resolveCallback = function(callbackId, result) {
6894 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
6895 const cb = globalThis._pendingCallbacks.get(callbackId);
6896 if (cb) {
6897 console.log('[JS] _resolveCallback: found callback, calling resolve()');
6898 globalThis._pendingCallbacks.delete(callbackId);
6899 cb.resolve(result);
6900 console.log('[JS] _resolveCallback: resolve() called');
6901 } else {
6902 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
6903 }
6904 };
6905
6906 // Reject a pending callback (called from Rust)
6907 globalThis._rejectCallback = function(callbackId, error) {
6908 const cb = globalThis._pendingCallbacks.get(callbackId);
6909 if (cb) {
6910 globalThis._pendingCallbacks.delete(callbackId);
6911 cb.reject(new Error(error));
6912 }
6913 };
6914
6915 // Generic async wrapper decorator
6916 // Wraps a function that returns a callbackId into a promise-returning function
6917 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
6918 // NOTE: We pass the method name as a string and call via bracket notation
6919 // to preserve rquickjs's automatic Ctx injection for methods
6920 globalThis._wrapAsync = function(methodName, fnName) {
6921 const startFn = editor[methodName];
6922 if (typeof startFn !== 'function') {
6923 // Return a function that always throws - catches missing implementations
6924 return function(...args) {
6925 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6926 editor.debug(`[ASYNC ERROR] ${error.message}`);
6927 throw error;
6928 };
6929 }
6930 return function(...args) {
6931 // Call via bracket notation to preserve method binding and Ctx injection
6932 const callbackId = editor[methodName](...args);
6933 return new Promise((resolve, reject) => {
6934 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6935 // TODO: Implement setTimeout polyfill using editor.delay() or similar
6936 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6937 });
6938 };
6939 };
6940
6941 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
6942 // The returned object has .result promise and is itself thenable
6943 globalThis._wrapAsyncThenable = function(methodName, fnName) {
6944 const startFn = editor[methodName];
6945 if (typeof startFn !== 'function') {
6946 // Return a function that always throws - catches missing implementations
6947 return function(...args) {
6948 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
6949 editor.debug(`[ASYNC ERROR] ${error.message}`);
6950 throw error;
6951 };
6952 }
6953 return function(...args) {
6954 // Call via bracket notation to preserve method binding and Ctx injection
6955 const callbackId = editor[methodName](...args);
6956 const resultPromise = new Promise((resolve, reject) => {
6957 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
6958 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
6959 });
6960 return {
6961 get result() { return resultPromise; },
6962 then(onFulfilled, onRejected) {
6963 return resultPromise.then(onFulfilled, onRejected);
6964 },
6965 catch(onRejected) {
6966 return resultPromise.catch(onRejected);
6967 }
6968 };
6969 };
6970 };
6971
6972 // Apply wrappers to async functions on editor
6973 // spawnProcess accepts either form for the 4th arg:
6974 // editor.spawnProcess(cmd, args, cwd?, stdoutTo?: string)
6975 // editor.spawnProcess(cmd, args, cwd?, { stdoutTo?: string })
6976 // The first matches the auto-generated TS signature
6977 // (flat positional from the Rust binding's `Opt<String>`
6978 // args); the second is the structured options form
6979 // plugin authors often prefer.
6980 editor.spawnProcess = function(command, argsArr, cwdOrOpts, fourth) {
6981 if (typeof editor._spawnProcessStart !== 'function') {
6982 throw new Error('editor.spawnProcess is not implemented (missing _spawnProcessStart)');
6983 }
6984 // The 3rd arg is either cwd (string) or an options
6985 // object when cwd is omitted; the 4th is either a
6986 // stdoutTo string or an options object.
6987 let cwd = "";
6988 let stdoutTo = "";
6989 if (typeof cwdOrOpts === "string") {
6990 cwd = cwdOrOpts;
6991 } else if (cwdOrOpts && typeof cwdOrOpts === "object") {
6992 if (typeof cwdOrOpts.stdoutTo === "string") stdoutTo = cwdOrOpts.stdoutTo;
6993 }
6994 if (typeof fourth === "string") {
6995 stdoutTo = fourth;
6996 } else if (fourth && typeof fourth === "object") {
6997 if (typeof fourth.stdoutTo === "string") stdoutTo = fourth.stdoutTo;
6998 }
6999 const callbackId = editor._spawnProcessStart(
7000 command,
7001 argsArr || [],
7002 cwd,
7003 stdoutTo,
7004 );
7005 const resultPromise = new Promise((resolve, reject) => {
7006 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
7007 });
7008 return {
7009 get result() { return resultPromise; },
7010 // `kill()` cancels a still-running spawn. The
7011 // dispatcher stores a oneshot keyed by callbackId;
7012 // _killHostProcess fires it and the spawner's
7013 // tokio::select! kills the child. No-op if the
7014 // child already exited (id removed from the map).
7015 kill() {
7016 if (typeof editor._killHostProcess === 'function') {
7017 return editor._killHostProcess(callbackId);
7018 }
7019 return false;
7020 },
7021 then(onFulfilled, onRejected) {
7022 return resultPromise.then(onFulfilled, onRejected);
7023 },
7024 catch(onRejected) {
7025 return resultPromise.catch(onRejected);
7026 }
7027 };
7028 };
7029 // spawnHostProcess gets a bespoke wrapper (instead of
7030 // `_wrapAsyncThenable`) because its `ProcessHandle`
7031 // exposes a real `kill()` that forwards to
7032 // `_killHostProcess`. Generic wrap has no hook for
7033 // that.
7034 editor.spawnHostProcess = function(command, args, cwd) {
7035 if (typeof editor._spawnHostProcessStart !== 'function') {
7036 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
7037 }
7038 // Pass real strings only. Earlier revisions forwarded
7039 // `""` for a missing cwd, which landed verbatim as
7040 // `Command::current_dir("")` in the dispatcher —
7041 // every host-spawn then failed with ENOENT. Use two
7042 // arity forms so the Rust `Opt<String>` stays `None`
7043 // instead of `Some("")`.
7044 let callbackId;
7045 if (typeof cwd === "string" && cwd.length > 0) {
7046 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
7047 } else {
7048 callbackId = editor._spawnHostProcessStart(command, args || []);
7049 }
7050 const resultPromise = new Promise(function(resolve, reject) {
7051 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
7052 });
7053 return {
7054 processId: callbackId,
7055 get result() { return resultPromise; },
7056 then: function(f, r) { return resultPromise.then(f, r); },
7057 catch: function(r) { return resultPromise.catch(r); },
7058 kill: function() {
7059 // Returns true when the kill was enqueued
7060 // (the process may have already exited; in
7061 // that case the dispatcher silently
7062 // drops it). Matches the
7063 // `ProcessHandle.kill(): Promise<boolean>`
7064 // type signature by wrapping the sync
7065 // boolean in a Promise.
7066 return Promise.resolve(editor._killHostProcess(callbackId));
7067 }
7068 };
7069 };
7070 editor.delay = _wrapAsync("_delayStart", "delay");
7071 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
7072 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
7073 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
7074 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
7075 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
7076 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
7077 editor.httpFetch = _wrapAsyncThenable("_httpFetchStart", "httpFetch");
7078 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
7079 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
7080 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
7081 editor.getCompositeCursorInfo = _wrapAsync("_getCompositeCursorInfoStart", "getCompositeCursorInfo");
7082 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
7083 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
7084 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
7085 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
7086 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
7087 editor.prompt = _wrapAsync("_promptStart", "prompt");
7088 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
7089 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
7090 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
7091 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
7092 editor.createWindowWithTerminal = _wrapAsync("_createWindowWithTerminalStart", "createWindowWithTerminal");
7093 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
7094 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
7095 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
7096 editor.openFileStreaming = _wrapAsync("_openFileStreamingStart", "openFileStreaming");
7097 editor.refreshBufferFromDisk = _wrapAsync("_refreshBufferFromDiskStart", "refreshBufferFromDisk");
7098 editor.setBufferGroupPanelBuffer = _wrapAsync("_setBufferGroupPanelBufferStart", "setBufferGroupPanelBuffer");
7099 editor.attachRemoteAgent = _wrapAsync("_attachRemoteAgentStart", "attachRemoteAgent");
7100
7101 // Pull-based streaming search. Producers (host searcher tasks)
7102 // write into shared state at full speed; the consumer drains
7103 // it via take() at its own cadence — no per-chunk JS dispatch.
7104 editor.beginSearch = function(pattern, opts) {
7105 opts = opts || {};
7106 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
7107 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
7108 const maxResults = opts.maxResults || 10000;
7109 const wholeWords = opts.wholeWords || false;
7110 const sourceBufferId = opts.sourceBufferId || 0;
7111 const handleId = editor._beginSearch(
7112 pattern, fixedString, caseSensitive, maxResults, wholeWords, sourceBufferId
7113 );
7114 return {
7115 searchId: handleId,
7116 take: function() { return editor._searchHandleTake(handleId); },
7117 cancel: function() { editor._searchHandleCancel(handleId); }
7118 };
7119 };
7120
7121 // Wrapper for deleteTheme - wraps sync function in Promise
7122 editor.deleteTheme = function(name) {
7123 return new Promise(function(resolve, reject) {
7124 const success = editor._deleteThemeSync(name);
7125 if (success) {
7126 resolve();
7127 } else {
7128 reject(new Error("Failed to delete theme: " + name));
7129 }
7130 });
7131 };
7132 "#.as_bytes())?;
7133
7134 Ok::<_, rquickjs::Error>(())
7135 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
7136
7137 Ok(())
7138 }
7139
7140 pub async fn load_module_with_source(
7142 &mut self,
7143 path: &str,
7144 _plugin_source: &str,
7145 ) -> Result<()> {
7146 let path_buf = PathBuf::from(path);
7147 let source = std::fs::read_to_string(&path_buf)
7148 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
7149
7150 let filename = path_buf
7151 .file_name()
7152 .and_then(|s| s.to_str())
7153 .unwrap_or("plugin.ts");
7154
7155 if has_es_imports(&source) {
7157 match bundle_module(&path_buf) {
7159 Ok(bundled) => {
7160 self.execute_js(&bundled, path)?;
7161 }
7162 Err(e) => {
7163 tracing::warn!(
7164 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
7165 path,
7166 e
7167 );
7168 return Ok(()); }
7170 }
7171 } else if has_es_module_syntax(&source) {
7172 let stripped = strip_imports_and_exports(&source);
7174 let js_code = if filename.ends_with(".ts") {
7175 transpile_typescript(&stripped, filename)?
7176 } else {
7177 stripped
7178 };
7179 self.execute_js(&js_code, path)?;
7180 } else {
7181 let js_code = if filename.ends_with(".ts") {
7183 transpile_typescript(&source, filename)?
7184 } else {
7185 source
7186 };
7187 self.execute_js(&js_code, path)?;
7188 }
7189
7190 Ok(())
7191 }
7192
7193 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
7195 let plugin_name = Path::new(source_name)
7197 .file_stem()
7198 .and_then(|s| s.to_str())
7199 .unwrap_or("unknown");
7200
7201 tracing::debug!(
7202 "execute_js: starting for plugin '{}' from '{}'",
7203 plugin_name,
7204 source_name
7205 );
7206
7207 let context = {
7209 let mut contexts = self.plugin_contexts.borrow_mut();
7210 if let Some(ctx) = contexts.get(plugin_name) {
7211 ctx.clone()
7212 } else {
7213 let ctx = Context::full(&self.runtime).map_err(|e| {
7214 anyhow!(
7215 "Failed to create QuickJS context for plugin {}: {}",
7216 plugin_name,
7217 e
7218 )
7219 })?;
7220 self.setup_context_api(&ctx, plugin_name)?;
7221 contexts.insert(plugin_name.to_string(), ctx.clone());
7222 ctx
7223 }
7224 };
7225
7226 let wrapped_code = format!("(function() {{ {} }})();", code);
7230 let wrapped = wrapped_code.as_str();
7231
7232 context.with(|ctx| {
7233 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
7234
7235 let mut eval_options = rquickjs::context::EvalOptions::default();
7237 eval_options.global = true;
7238 eval_options.filename = Some(source_name.to_string());
7239 let result = ctx
7240 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
7241 .map_err(|e| format_js_error(&ctx, e, source_name));
7242
7243 tracing::debug!(
7244 "execute_js: plugin code execution finished for '{}', result: {:?}",
7245 plugin_name,
7246 result.is_ok()
7247 );
7248
7249 result
7250 })
7251 }
7252
7253 pub fn execute_source(
7259 &mut self,
7260 source: &str,
7261 plugin_name: &str,
7262 is_typescript: bool,
7263 ) -> Result<()> {
7264 use fresh_parser_js::{
7265 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
7266 };
7267
7268 if has_es_imports(source) {
7269 tracing::warn!(
7270 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
7271 plugin_name
7272 );
7273 }
7274
7275 let js_code = if has_es_module_syntax(source) {
7276 let stripped = strip_imports_and_exports(source);
7277 if is_typescript {
7278 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
7279 } else {
7280 stripped
7281 }
7282 } else if is_typescript {
7283 transpile_typescript(source, &format!("{}.ts", plugin_name))?
7284 } else {
7285 source.to_string()
7286 };
7287
7288 let source_name = format!(
7290 "{}.{}",
7291 plugin_name,
7292 if is_typescript { "ts" } else { "js" }
7293 );
7294 self.execute_js(&js_code, &source_name)
7295 }
7296
7297 pub fn cleanup_plugin(&self, plugin_name: &str) {
7303 self.plugin_contexts.borrow_mut().remove(plugin_name);
7305
7306 {
7308 let mut handlers_map = self
7309 .event_handlers
7310 .write()
7311 .expect("event_handlers poisoned");
7312 for handlers in handlers_map.values_mut() {
7313 handlers.retain(|h| h.plugin_name != plugin_name);
7314 }
7315 handlers_map.retain(|_, list| !list.is_empty());
7319 }
7320
7321 self.registered_actions
7323 .borrow_mut()
7324 .retain(|_, h| h.plugin_name != plugin_name);
7325
7326 self.callback_contexts
7328 .borrow_mut()
7329 .retain(|_, pname| pname != plugin_name);
7330
7331 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
7333 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
7335 std::collections::HashSet::new();
7336 for (buf_id, ns) in &tracked.overlay_namespaces {
7337 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
7338 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
7340 buffer_id: *buf_id,
7341 namespace: OverlayNamespace::from_string(ns.clone()),
7342 });
7343 let _ = self
7345 .command_sender
7346 .send(PluginCommand::ClearConcealNamespace {
7347 buffer_id: *buf_id,
7348 namespace: OverlayNamespace::from_string(ns.clone()),
7349 });
7350 let _ = self
7351 .command_sender
7352 .send(PluginCommand::ClearSoftBreakNamespace {
7353 buffer_id: *buf_id,
7354 namespace: OverlayNamespace::from_string(ns.clone()),
7355 });
7356 }
7357 }
7358
7359 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
7365 std::collections::HashSet::new();
7366 for (buf_id, ns) in &tracked.line_indicator_namespaces {
7367 if seen_li_ns.insert((buf_id.0, ns.clone())) {
7368 let _ = self
7369 .command_sender
7370 .send(PluginCommand::ClearLineIndicators {
7371 buffer_id: *buf_id,
7372 namespace: ns.clone(),
7373 });
7374 }
7375 }
7376
7377 let mut seen_vt: std::collections::HashSet<(usize, String)> =
7379 std::collections::HashSet::new();
7380 for (buf_id, vt_id) in &tracked.virtual_text_ids {
7381 if seen_vt.insert((buf_id.0, vt_id.clone())) {
7382 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
7383 buffer_id: *buf_id,
7384 virtual_text_id: vt_id.clone(),
7385 });
7386 }
7387 }
7388
7389 let mut seen_fe_ns: std::collections::HashSet<String> =
7391 std::collections::HashSet::new();
7392 for ns in &tracked.file_explorer_namespaces {
7393 if seen_fe_ns.insert(ns.clone()) {
7394 let _ = self
7395 .command_sender
7396 .send(PluginCommand::ClearFileExplorerDecorations {
7397 namespace: ns.clone(),
7398 });
7399 let _ = self
7400 .command_sender
7401 .send(PluginCommand::ClearFileExplorerSlots {
7402 namespace: ns.clone(),
7403 });
7404 }
7405 }
7406
7407 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
7409 for ctx_name in &tracked.contexts_set {
7410 if seen_ctx.insert(ctx_name.clone()) {
7411 let _ = self.command_sender.send(PluginCommand::SetContext {
7412 name: ctx_name.clone(),
7413 active: false,
7414 });
7415 }
7416 }
7417
7418 for process_id in &tracked.background_process_ids {
7422 let _ = self
7423 .command_sender
7424 .send(PluginCommand::KillBackgroundProcess {
7425 process_id: *process_id,
7426 });
7427 }
7428
7429 for group_id in &tracked.scroll_sync_group_ids {
7431 let _ = self
7432 .command_sender
7433 .send(PluginCommand::RemoveScrollSyncGroup {
7434 group_id: *group_id,
7435 });
7436 }
7437
7438 for buffer_id in &tracked.virtual_buffer_ids {
7440 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
7441 buffer_id: *buffer_id,
7442 });
7443 }
7444
7445 for buffer_id in &tracked.composite_buffer_ids {
7447 let _ = self
7448 .command_sender
7449 .send(PluginCommand::CloseCompositeBuffer {
7450 buffer_id: *buffer_id,
7451 });
7452 }
7453
7454 for terminal_id in &tracked.terminal_ids {
7456 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
7457 terminal_id: *terminal_id,
7458 });
7459 }
7460
7461 for handle in &tracked.watch_handles {
7465 let _ = self
7466 .command_sender
7467 .send(PluginCommand::UnwatchPath { handle: *handle });
7468 }
7469 }
7470
7471 if let Ok(mut owners) = self.async_resource_owners.lock() {
7473 owners.retain(|_, name| name != plugin_name);
7474 }
7475
7476 self.plugin_api_exports
7478 .borrow_mut()
7479 .retain(|_, (exporter, _)| exporter != plugin_name);
7480
7481 self.registered_command_names
7483 .borrow_mut()
7484 .retain(|_, pname| pname != plugin_name);
7485 self.registered_grammar_languages
7486 .borrow_mut()
7487 .retain(|_, pname| pname != plugin_name);
7488 self.registered_language_configs
7489 .borrow_mut()
7490 .retain(|_, pname| pname != plugin_name);
7491 self.registered_lsp_servers
7492 .borrow_mut()
7493 .retain(|_, pname| pname != plugin_name);
7494
7495 tracing::debug!(
7496 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
7497 plugin_name
7498 );
7499 }
7500
7501 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
7503 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
7504
7505 self.services
7506 .set_js_execution_state(format!("hook '{}'", event_name));
7507
7508 let handlers = self
7509 .event_handlers
7510 .read()
7511 .expect("event_handlers poisoned")
7512 .get(event_name)
7513 .cloned();
7514 if let Some(handler_pairs) = handlers {
7515 let plugin_contexts = self.plugin_contexts.borrow();
7516 for handler in &handler_pairs {
7517 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
7518 continue;
7519 };
7520 context.with(|ctx| {
7521 call_handler(&ctx, &handler.handler_name, event_data);
7522 });
7523 }
7524 }
7525
7526 self.services.clear_js_execution_state();
7527 Ok(true)
7528 }
7529
7530 pub fn has_handlers(&self, event_name: &str) -> bool {
7532 self.event_handlers
7533 .read()
7534 .expect("event_handlers poisoned")
7535 .get(event_name)
7536 .map(|v| !v.is_empty())
7537 .unwrap_or(false)
7538 }
7539
7540 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
7544 let (lookup_name, text_input_char) =
7547 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
7548 ("mode_text_input", Some(ch.to_string()))
7549 } else {
7550 (action_name, None)
7551 };
7552
7553 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
7554 let (plugin_name, function_name) = match pair {
7555 Some(handler) => (handler.plugin_name, handler.handler_name),
7556 None => ("main".to_string(), lookup_name.to_string()),
7557 };
7558
7559 let plugin_contexts = self.plugin_contexts.borrow();
7560 let context = plugin_contexts
7561 .get(&plugin_name)
7562 .unwrap_or(&self.main_context);
7563
7564 self.services
7566 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
7567
7568 tracing::info!(
7569 "start_action: BEGIN '{}' -> function '{}'",
7570 action_name,
7571 function_name
7572 );
7573
7574 let call_args = if let Some(ref ch) = text_input_char {
7577 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
7578 format!("({{text:\"{}\"}})", escaped)
7579 } else {
7580 "()".to_string()
7581 };
7582
7583 let code = format!(
7584 r#"
7585 (function() {{
7586 console.log('[JS] start_action: calling {fn}');
7587 try {{
7588 if (typeof globalThis.{fn} === 'function') {{
7589 console.log('[JS] start_action: {fn} is a function, invoking...');
7590 globalThis.{fn}{args};
7591 console.log('[JS] start_action: {fn} invoked (may be async)');
7592 }} else {{
7593 console.error('[JS] Action {action} is not defined as a global function');
7594 }}
7595 }} catch (e) {{
7596 console.error('[JS] Action {action} error:', e);
7597 }}
7598 }})();
7599 "#,
7600 fn = function_name,
7601 action = action_name,
7602 args = call_args
7603 );
7604
7605 tracing::info!("start_action: evaluating JS code");
7606 context.with(|ctx| {
7607 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7608 log_js_error(&ctx, e, &format!("action {}", action_name));
7609 }
7610 tracing::info!("start_action: running pending microtasks");
7611 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
7613 tracing::info!("start_action: executed {} pending jobs", count);
7614 });
7615
7616 tracing::info!("start_action: END '{}'", action_name);
7617
7618 self.services.clear_js_execution_state();
7620
7621 Ok(())
7622 }
7623
7624 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
7626 let pair = self.registered_actions.borrow().get(action_name).cloned();
7628 let (plugin_name, function_name) = match pair {
7629 Some(handler) => (handler.plugin_name, handler.handler_name),
7630 None => ("main".to_string(), action_name.to_string()),
7631 };
7632
7633 let plugin_contexts = self.plugin_contexts.borrow();
7634 let context = plugin_contexts
7635 .get(&plugin_name)
7636 .unwrap_or(&self.main_context);
7637
7638 tracing::debug!(
7639 "execute_action: '{}' -> function '{}'",
7640 action_name,
7641 function_name
7642 );
7643
7644 let code = format!(
7647 r#"
7648 (async function() {{
7649 try {{
7650 if (typeof globalThis.{fn} === 'function') {{
7651 const result = globalThis.{fn}();
7652 // If it's a Promise, await it
7653 if (result && typeof result.then === 'function') {{
7654 await result;
7655 }}
7656 }} else {{
7657 console.error('Action {action} is not defined as a global function');
7658 }}
7659 }} catch (e) {{
7660 console.error('Action {action} error:', e);
7661 }}
7662 }})();
7663 "#,
7664 fn = function_name,
7665 action = action_name
7666 );
7667
7668 context.with(|ctx| {
7669 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
7671 Ok(value) => {
7672 if value.is_object() {
7674 if let Some(obj) = value.as_object() {
7675 if obj.get::<_, rquickjs::Function>("then").is_ok() {
7677 run_pending_jobs_checked(
7680 &ctx,
7681 &format!("execute_action {} promise", action_name),
7682 );
7683 }
7684 }
7685 }
7686 }
7687 Err(e) => {
7688 log_js_error(&ctx, e, &format!("action {}", action_name));
7689 }
7690 }
7691 });
7692
7693 Ok(())
7694 }
7695
7696 pub fn poll_event_loop_once(&mut self) -> bool {
7698 let mut had_work = false;
7699
7700 self.main_context.with(|ctx| {
7702 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
7703 if count > 0 {
7704 had_work = true;
7705 }
7706 });
7707
7708 let contexts = self.plugin_contexts.borrow().clone();
7710 for (name, context) in contexts {
7711 context.with(|ctx| {
7712 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
7713 if count > 0 {
7714 had_work = true;
7715 }
7716 });
7717 }
7718 had_work
7719 }
7720
7721 pub fn send_status(&self, message: String) {
7723 let _ = self
7724 .command_sender
7725 .send(PluginCommand::SetStatus { message });
7726 }
7727
7728 pub fn send_hook_completed(&self, hook_name: String) {
7732 let _ = self
7733 .command_sender
7734 .send(PluginCommand::HookCompleted { hook_name });
7735 }
7736
7737 pub fn resolve_callback(
7742 &mut self,
7743 callback_id: fresh_core::api::JsCallbackId,
7744 result_json: &str,
7745 ) {
7746 let id = callback_id.as_u64();
7747 tracing::debug!("resolve_callback: starting for callback_id={}", id);
7748
7749 let plugin_name = {
7751 let mut contexts = self.callback_contexts.borrow_mut();
7752 contexts.remove(&id)
7753 };
7754
7755 let Some(name) = plugin_name else {
7756 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
7757 return;
7758 };
7759
7760 let plugin_contexts = self.plugin_contexts.borrow();
7761 let Some(context) = plugin_contexts.get(&name) else {
7762 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
7763 return;
7764 };
7765
7766 context.with(|ctx| {
7767 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
7769 Ok(v) => v,
7770 Err(e) => {
7771 tracing::error!(
7772 "resolve_callback: failed to parse JSON for callback_id={}: {}",
7773 id,
7774 e
7775 );
7776 return;
7777 }
7778 };
7779
7780 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
7782 Ok(v) => v,
7783 Err(e) => {
7784 tracing::error!(
7785 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
7786 id,
7787 e
7788 );
7789 return;
7790 }
7791 };
7792
7793 let globals = ctx.globals();
7795 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
7796 Ok(f) => f,
7797 Err(e) => {
7798 tracing::error!(
7799 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
7800 id,
7801 e
7802 );
7803 return;
7804 }
7805 };
7806
7807 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
7809 log_js_error(&ctx, e, &format!("resolving callback {}", id));
7810 }
7811
7812 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
7814 tracing::info!(
7815 "resolve_callback: executed {} pending jobs for callback_id={}",
7816 job_count,
7817 id
7818 );
7819 });
7820 }
7821
7822 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
7824 let id = callback_id.as_u64();
7825
7826 let plugin_name = {
7828 let mut contexts = self.callback_contexts.borrow_mut();
7829 contexts.remove(&id)
7830 };
7831
7832 let Some(name) = plugin_name else {
7833 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
7834 return;
7835 };
7836
7837 let plugin_contexts = self.plugin_contexts.borrow();
7838 let Some(context) = plugin_contexts.get(&name) else {
7839 tracing::warn!("reject_callback: Context lost for plugin {}", name);
7840 return;
7841 };
7842
7843 context.with(|ctx| {
7844 let globals = ctx.globals();
7846 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
7847 Ok(f) => f,
7848 Err(e) => {
7849 tracing::error!(
7850 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
7851 id,
7852 e
7853 );
7854 return;
7855 }
7856 };
7857
7858 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
7860 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
7861 }
7862
7863 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
7865 });
7866 }
7867}
7868
7869#[cfg(test)]
7870mod tests {
7871 use super::*;
7872 use fresh_core::api::{BufferInfo, CursorInfo};
7873 use std::sync::mpsc;
7874
7875 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
7877 let (tx, rx) = mpsc::channel();
7878 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7879 let services = Arc::new(TestServiceBridge::new());
7880 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7881 (backend, rx)
7882 }
7883
7884 struct TestServiceBridge {
7885 en_strings: std::sync::Mutex<HashMap<String, String>>,
7886 }
7887
7888 impl TestServiceBridge {
7889 fn new() -> Self {
7890 Self {
7891 en_strings: std::sync::Mutex::new(HashMap::new()),
7892 }
7893 }
7894 }
7895
7896 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
7897 fn as_any(&self) -> &dyn std::any::Any {
7898 self
7899 }
7900 fn translate(
7901 &self,
7902 _plugin_name: &str,
7903 key: &str,
7904 _args: &HashMap<String, String>,
7905 ) -> String {
7906 self.en_strings
7907 .lock()
7908 .unwrap()
7909 .get(key)
7910 .cloned()
7911 .unwrap_or_else(|| key.to_string())
7912 }
7913 fn current_locale(&self) -> String {
7914 "en".to_string()
7915 }
7916 fn set_js_execution_state(&self, _state: String) {}
7917 fn clear_js_execution_state(&self) {}
7918 fn get_theme_schema(&self) -> serde_json::Value {
7919 serde_json::json!({})
7920 }
7921 fn get_builtin_themes(&self) -> serde_json::Value {
7922 serde_json::json!([])
7923 }
7924 fn get_all_themes(&self) -> serde_json::Value {
7925 serde_json::json!({})
7926 }
7927 fn register_command(&self, _command: fresh_core::command::Command) {}
7928 fn unregister_command(&self, _name: &str) {}
7929 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
7930 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
7931 fn plugins_dir(&self) -> std::path::PathBuf {
7932 std::path::PathBuf::from("/tmp/plugins")
7933 }
7934 fn config_dir(&self) -> std::path::PathBuf {
7935 std::path::PathBuf::from("/tmp/config")
7936 }
7937 fn data_dir(&self) -> std::path::PathBuf {
7938 std::path::PathBuf::from("/tmp/data")
7939 }
7940 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
7941 None
7942 }
7943 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
7944 Err("not implemented in test".to_string())
7945 }
7946 fn theme_file_exists(&self, _name: &str) -> bool {
7947 false
7948 }
7949 }
7950
7951 #[test]
7952 fn test_quickjs_backend_creation() {
7953 let backend = QuickJsBackend::new();
7954 assert!(backend.is_ok());
7955 }
7956
7957 #[test]
7958 fn test_execute_simple_js() {
7959 let mut backend = QuickJsBackend::new().unwrap();
7960 let result = backend.execute_js("const x = 1 + 2;", "test.js");
7961 assert!(result.is_ok());
7962 }
7963
7964 #[test]
7965 fn test_event_handler_registration() {
7966 let backend = QuickJsBackend::new().unwrap();
7967
7968 assert!(!backend.has_handlers("test_event"));
7970
7971 backend
7973 .event_handlers
7974 .write()
7975 .unwrap()
7976 .entry("test_event".to_string())
7977 .or_default()
7978 .push(PluginHandler {
7979 plugin_name: "test".to_string(),
7980 handler_name: "testHandler".to_string(),
7981 });
7982
7983 assert!(backend.has_handlers("test_event"));
7985 }
7986
7987 #[test]
7990 fn test_api_set_status() {
7991 let (mut backend, rx) = create_test_backend();
7992
7993 backend
7994 .execute_js(
7995 r#"
7996 const editor = getEditor();
7997 editor.setStatus("Hello from test");
7998 "#,
7999 "test.js",
8000 )
8001 .unwrap();
8002
8003 let cmd = rx.try_recv().unwrap();
8004 match cmd {
8005 PluginCommand::SetStatus { message } => {
8006 assert_eq!(message, "Hello from test");
8007 }
8008 _ => panic!("Expected SetStatus command, got {:?}", cmd),
8009 }
8010 }
8011
8012 #[test]
8013 fn test_api_register_command() {
8014 let (mut backend, rx) = create_test_backend();
8015
8016 backend
8017 .execute_js(
8018 r#"
8019 const editor = getEditor();
8020 globalThis.myTestHandler = function() { };
8021 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
8022 "#,
8023 "test_plugin.js",
8024 )
8025 .unwrap();
8026
8027 let cmd = rx.try_recv().unwrap();
8028 match cmd {
8029 PluginCommand::RegisterCommand { command } => {
8030 assert_eq!(command.name, "Test Command");
8031 assert_eq!(command.description, "A test command");
8032 assert_eq!(command.plugin_name, "test_plugin");
8034 }
8035 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
8036 }
8037 }
8038
8039 #[test]
8040 fn test_api_define_mode() {
8041 let (mut backend, rx) = create_test_backend();
8042
8043 backend
8044 .execute_js(
8045 r#"
8046 const editor = getEditor();
8047 editor.defineMode("test-mode", [
8048 ["a", "action_a"],
8049 ["b", "action_b"]
8050 ]);
8051 "#,
8052 "test.js",
8053 )
8054 .unwrap();
8055
8056 let cmd = rx.try_recv().unwrap();
8057 match cmd {
8058 PluginCommand::DefineMode {
8059 name,
8060 bindings,
8061 read_only,
8062 allow_text_input,
8063 inherit_normal_bindings,
8064 plugin_name,
8065 } => {
8066 assert_eq!(name, "test-mode");
8067 assert_eq!(bindings.len(), 2);
8068 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
8069 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
8070 assert!(!read_only);
8071 assert!(!allow_text_input);
8072 assert!(!inherit_normal_bindings);
8073 assert!(plugin_name.is_some());
8074 }
8075 _ => panic!("Expected DefineMode, got {:?}", cmd),
8076 }
8077 }
8078
8079 #[test]
8080 fn test_api_set_editor_mode() {
8081 let (mut backend, rx) = create_test_backend();
8082
8083 backend
8084 .execute_js(
8085 r#"
8086 const editor = getEditor();
8087 editor.setEditorMode("vi-normal");
8088 "#,
8089 "test.js",
8090 )
8091 .unwrap();
8092
8093 let cmd = rx.try_recv().unwrap();
8094 match cmd {
8095 PluginCommand::SetEditorMode { mode } => {
8096 assert_eq!(mode, Some("vi-normal".to_string()));
8097 }
8098 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
8099 }
8100 }
8101
8102 #[test]
8103 fn test_api_clear_editor_mode() {
8104 let (mut backend, rx) = create_test_backend();
8105
8106 backend
8107 .execute_js(
8108 r#"
8109 const editor = getEditor();
8110 editor.setEditorMode(null);
8111 "#,
8112 "test.js",
8113 )
8114 .unwrap();
8115
8116 let cmd = rx.try_recv().unwrap();
8117 match cmd {
8118 PluginCommand::SetEditorMode { mode } => {
8119 assert!(mode.is_none());
8120 }
8121 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
8122 }
8123 }
8124
8125 #[test]
8126 fn test_api_insert_at_cursor() {
8127 let (mut backend, rx) = create_test_backend();
8128
8129 backend
8130 .execute_js(
8131 r#"
8132 const editor = getEditor();
8133 editor.insertAtCursor("Hello, World!");
8134 "#,
8135 "test.js",
8136 )
8137 .unwrap();
8138
8139 let cmd = rx.try_recv().unwrap();
8140 match cmd {
8141 PluginCommand::InsertAtCursor { text } => {
8142 assert_eq!(text, "Hello, World!");
8143 }
8144 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
8145 }
8146 }
8147
8148 #[test]
8149 fn test_api_set_context() {
8150 let (mut backend, rx) = create_test_backend();
8151
8152 backend
8153 .execute_js(
8154 r#"
8155 const editor = getEditor();
8156 editor.setContext("myContext", true);
8157 "#,
8158 "test.js",
8159 )
8160 .unwrap();
8161
8162 let cmd = rx.try_recv().unwrap();
8163 match cmd {
8164 PluginCommand::SetContext { name, active } => {
8165 assert_eq!(name, "myContext");
8166 assert!(active);
8167 }
8168 _ => panic!("Expected SetContext, got {:?}", cmd),
8169 }
8170 }
8171
8172 #[tokio::test]
8173 async fn test_execute_action_sync_function() {
8174 let (mut backend, rx) = create_test_backend();
8175
8176 backend.registered_actions.borrow_mut().insert(
8178 "my_sync_action".to_string(),
8179 PluginHandler {
8180 plugin_name: "test".to_string(),
8181 handler_name: "my_sync_action".to_string(),
8182 },
8183 );
8184
8185 backend
8187 .execute_js(
8188 r#"
8189 const editor = getEditor();
8190 globalThis.my_sync_action = function() {
8191 editor.setStatus("sync action executed");
8192 };
8193 "#,
8194 "test.js",
8195 )
8196 .unwrap();
8197
8198 while rx.try_recv().is_ok() {}
8200
8201 backend.execute_action("my_sync_action").await.unwrap();
8203
8204 let cmd = rx.try_recv().unwrap();
8206 match cmd {
8207 PluginCommand::SetStatus { message } => {
8208 assert_eq!(message, "sync action executed");
8209 }
8210 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
8211 }
8212 }
8213
8214 #[tokio::test]
8215 async fn test_execute_action_async_function() {
8216 let (mut backend, rx) = create_test_backend();
8217
8218 backend.registered_actions.borrow_mut().insert(
8220 "my_async_action".to_string(),
8221 PluginHandler {
8222 plugin_name: "test".to_string(),
8223 handler_name: "my_async_action".to_string(),
8224 },
8225 );
8226
8227 backend
8229 .execute_js(
8230 r#"
8231 const editor = getEditor();
8232 globalThis.my_async_action = async function() {
8233 await Promise.resolve();
8234 editor.setStatus("async action executed");
8235 };
8236 "#,
8237 "test.js",
8238 )
8239 .unwrap();
8240
8241 while rx.try_recv().is_ok() {}
8243
8244 backend.execute_action("my_async_action").await.unwrap();
8246
8247 let cmd = rx.try_recv().unwrap();
8249 match cmd {
8250 PluginCommand::SetStatus { message } => {
8251 assert_eq!(message, "async action executed");
8252 }
8253 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
8254 }
8255 }
8256
8257 #[tokio::test]
8258 async fn test_execute_action_with_registered_handler() {
8259 let (mut backend, rx) = create_test_backend();
8260
8261 backend.registered_actions.borrow_mut().insert(
8263 "my_action".to_string(),
8264 PluginHandler {
8265 plugin_name: "test".to_string(),
8266 handler_name: "actual_handler_function".to_string(),
8267 },
8268 );
8269
8270 backend
8271 .execute_js(
8272 r#"
8273 const editor = getEditor();
8274 globalThis.actual_handler_function = function() {
8275 editor.setStatus("handler executed");
8276 };
8277 "#,
8278 "test.js",
8279 )
8280 .unwrap();
8281
8282 while rx.try_recv().is_ok() {}
8284
8285 backend.execute_action("my_action").await.unwrap();
8287
8288 let cmd = rx.try_recv().unwrap();
8289 match cmd {
8290 PluginCommand::SetStatus { message } => {
8291 assert_eq!(message, "handler executed");
8292 }
8293 _ => panic!("Expected SetStatus, got {:?}", cmd),
8294 }
8295 }
8296
8297 #[test]
8298 fn test_api_on_event_registration() {
8299 let (mut backend, _rx) = create_test_backend();
8300
8301 backend
8302 .execute_js(
8303 r#"
8304 const editor = getEditor();
8305 globalThis.myEventHandler = function() { };
8306 editor.on("bufferSave", "myEventHandler");
8307 "#,
8308 "test.js",
8309 )
8310 .unwrap();
8311
8312 assert!(backend.has_handlers("bufferSave"));
8313 }
8314
8315 #[test]
8316 fn test_api_off_event_unregistration() {
8317 let (mut backend, _rx) = create_test_backend();
8318
8319 backend
8320 .execute_js(
8321 r#"
8322 const editor = getEditor();
8323 globalThis.myEventHandler = function() { };
8324 editor.on("bufferSave", "myEventHandler");
8325 editor.off("bufferSave", "myEventHandler");
8326 "#,
8327 "test.js",
8328 )
8329 .unwrap();
8330
8331 assert!(!backend.has_handlers("bufferSave"));
8333 }
8334
8335 #[tokio::test]
8336 async fn test_emit_event() {
8337 let (mut backend, rx) = create_test_backend();
8338
8339 backend
8340 .execute_js(
8341 r#"
8342 const editor = getEditor();
8343 globalThis.onSaveHandler = function(data) {
8344 editor.setStatus("saved: " + JSON.stringify(data));
8345 };
8346 editor.on("bufferSave", "onSaveHandler");
8347 "#,
8348 "test.js",
8349 )
8350 .unwrap();
8351
8352 while rx.try_recv().is_ok() {}
8354
8355 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
8357 backend.emit("bufferSave", &event_data).await.unwrap();
8358
8359 let cmd = rx.try_recv().unwrap();
8360 match cmd {
8361 PluginCommand::SetStatus { message } => {
8362 assert!(message.contains("/test.txt"));
8363 }
8364 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
8365 }
8366 }
8367
8368 #[test]
8369 fn test_api_copy_to_clipboard() {
8370 let (mut backend, rx) = create_test_backend();
8371
8372 backend
8373 .execute_js(
8374 r#"
8375 const editor = getEditor();
8376 editor.copyToClipboard("clipboard text");
8377 "#,
8378 "test.js",
8379 )
8380 .unwrap();
8381
8382 let cmd = rx.try_recv().unwrap();
8383 match cmd {
8384 PluginCommand::SetClipboard { text } => {
8385 assert_eq!(text, "clipboard text");
8386 }
8387 _ => panic!("Expected SetClipboard, got {:?}", cmd),
8388 }
8389 }
8390
8391 #[test]
8392 fn test_api_open_file() {
8393 let (mut backend, rx) = create_test_backend();
8394
8395 backend
8397 .execute_js(
8398 r#"
8399 const editor = getEditor();
8400 editor.openFile("/path/to/file.txt", null, null);
8401 "#,
8402 "test.js",
8403 )
8404 .unwrap();
8405
8406 let cmd = rx.try_recv().unwrap();
8407 match cmd {
8408 PluginCommand::OpenFileAtLocation { path, line, column } => {
8409 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
8410 assert!(line.is_none());
8411 assert!(column.is_none());
8412 }
8413 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
8414 }
8415 }
8416
8417 #[test]
8418 fn test_api_delete_range() {
8419 let (mut backend, rx) = create_test_backend();
8420
8421 backend
8423 .execute_js(
8424 r#"
8425 const editor = getEditor();
8426 editor.deleteRange(0, 10, 20);
8427 "#,
8428 "test.js",
8429 )
8430 .unwrap();
8431
8432 let cmd = rx.try_recv().unwrap();
8433 match cmd {
8434 PluginCommand::DeleteRange { range, .. } => {
8435 assert_eq!(range.start, 10);
8436 assert_eq!(range.end, 20);
8437 }
8438 _ => panic!("Expected DeleteRange, got {:?}", cmd),
8439 }
8440 }
8441
8442 #[test]
8443 fn test_api_insert_text() {
8444 let (mut backend, rx) = create_test_backend();
8445
8446 backend
8448 .execute_js(
8449 r#"
8450 const editor = getEditor();
8451 editor.insertText(0, 5, "inserted");
8452 "#,
8453 "test.js",
8454 )
8455 .unwrap();
8456
8457 let cmd = rx.try_recv().unwrap();
8458 match cmd {
8459 PluginCommand::InsertText { position, text, .. } => {
8460 assert_eq!(position, 5);
8461 assert_eq!(text, "inserted");
8462 }
8463 _ => panic!("Expected InsertText, got {:?}", cmd),
8464 }
8465 }
8466
8467 #[test]
8468 fn test_api_set_buffer_cursor() {
8469 let (mut backend, rx) = create_test_backend();
8470
8471 backend
8473 .execute_js(
8474 r#"
8475 const editor = getEditor();
8476 editor.setBufferCursor(0, 100);
8477 "#,
8478 "test.js",
8479 )
8480 .unwrap();
8481
8482 let cmd = rx.try_recv().unwrap();
8483 match cmd {
8484 PluginCommand::SetBufferCursor { position, .. } => {
8485 assert_eq!(position, 100);
8486 }
8487 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
8488 }
8489 }
8490
8491 #[test]
8492 fn test_api_get_cursor_position_from_state() {
8493 let (tx, _rx) = mpsc::channel();
8494 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8495
8496 {
8498 let mut state = state_snapshot.write().unwrap();
8499 state.primary_cursor = Some(CursorInfo {
8500 position: 42,
8501 selection: None,
8502 line: Some(0),
8503 });
8504 }
8505
8506 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8507 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8508
8509 backend
8511 .execute_js(
8512 r#"
8513 const editor = getEditor();
8514 const pos = editor.getCursorPosition();
8515 globalThis._testResult = pos;
8516 "#,
8517 "test.js",
8518 )
8519 .unwrap();
8520
8521 backend
8523 .plugin_contexts
8524 .borrow()
8525 .get("test")
8526 .unwrap()
8527 .clone()
8528 .with(|ctx| {
8529 let global = ctx.globals();
8530 let result: u32 = global.get("_testResult").unwrap();
8531 assert_eq!(result, 42);
8532 });
8533 }
8534
8535 #[test]
8546 fn test_api_get_cursor_line_small_and_large_file() {
8547 let (tx, _rx) = mpsc::channel();
8549 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8550 {
8551 let mut state = state_snapshot.write().unwrap();
8552 state.primary_cursor = Some(CursorInfo {
8553 position: 120,
8554 selection: None,
8555 line: Some(7),
8556 });
8557 state.all_cursors = vec![
8558 CursorInfo {
8559 position: 120,
8560 selection: None,
8561 line: Some(7),
8562 },
8563 CursorInfo {
8564 position: 200,
8565 selection: None,
8566 line: Some(12),
8567 },
8568 ];
8569 }
8570
8571 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8572 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8573
8574 backend
8575 .execute_js(
8576 r#"
8577 const editor = getEditor();
8578 const primary = editor.getPrimaryCursor();
8579 globalThis._primaryLine = primary.line;
8580 globalThis._cursorLine = editor.getCursorLine();
8581 globalThis._allLines = editor.getAllCursors().map(c => c.line);
8582 "#,
8583 "probe_small.js",
8584 )
8585 .unwrap();
8586
8587 backend
8588 .plugin_contexts
8589 .borrow()
8590 .get("probe_small")
8591 .unwrap()
8592 .clone()
8593 .with(|ctx| {
8594 let global = ctx.globals();
8595 let primary_line: i32 = global.get("_primaryLine").unwrap();
8597 assert_eq!(primary_line, 7);
8598 let cursor_line: u32 = global.get("_cursorLine").unwrap();
8600 assert_eq!(cursor_line, 7);
8601 let all_lines: Vec<i32> = global.get("_allLines").unwrap();
8603 assert_eq!(all_lines, vec![7, 12]);
8604 });
8605
8606 let (tx2, _rx2) = mpsc::channel();
8608 let state_snapshot2 = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8609 {
8610 let mut state = state_snapshot2.write().unwrap();
8611 state.primary_cursor = Some(CursorInfo {
8612 position: 5_000_000,
8613 selection: None,
8614 line: None,
8615 });
8616 state.all_cursors = vec![CursorInfo {
8617 position: 5_000_000,
8618 selection: None,
8619 line: None,
8620 }];
8621 }
8622
8623 let services2 = Arc::new(fresh_core::services::NoopServiceBridge);
8624 let mut backend2 = QuickJsBackend::with_state(state_snapshot2, tx2, services2).unwrap();
8625
8626 backend2
8627 .execute_js(
8628 r#"
8629 const editor = getEditor();
8630 const primary = editor.getPrimaryCursor();
8631 // null and undefined both serialize to JS null here; normalize to a
8632 // sentinel so the Rust side can assert "unknown" unambiguously.
8633 globalThis._primaryLineIsNull = (primary.line === null || primary.line === undefined);
8634 globalThis._cursorLineFallback = editor.getCursorLine();
8635 globalThis._allLineIsNull = (editor.getAllCursors()[0].line === null);
8636 "#,
8637 "probe_large.js",
8638 )
8639 .unwrap();
8640
8641 backend2
8642 .plugin_contexts
8643 .borrow()
8644 .get("probe_large")
8645 .unwrap()
8646 .clone()
8647 .with(|ctx| {
8648 let global = ctx.globals();
8649 let primary_null: bool = global.get("_primaryLineIsNull").unwrap();
8651 assert!(
8652 primary_null,
8653 "primary.line should be null in large-file mode"
8654 );
8655 let all_null: bool = global.get("_allLineIsNull").unwrap();
8656 assert!(
8657 all_null,
8658 "getAllCursors()[0].line should be null in large-file mode"
8659 );
8660 let fallback: u32 = global.get("_cursorLineFallback").unwrap();
8662 assert_eq!(fallback, 0);
8663 });
8664 }
8665
8666 #[test]
8667 fn test_api_path_functions() {
8668 let (mut backend, _rx) = create_test_backend();
8669
8670 #[cfg(windows)]
8673 let absolute_path = r#"C:\\foo\\bar"#;
8674 #[cfg(not(windows))]
8675 let absolute_path = "/foo/bar";
8676
8677 let js_code = format!(
8679 r#"
8680 const editor = getEditor();
8681 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
8682 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
8683 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
8684 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
8685 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
8686 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
8687 "#,
8688 absolute_path
8689 );
8690 backend.execute_js(&js_code, "test.js").unwrap();
8691
8692 backend
8693 .plugin_contexts
8694 .borrow()
8695 .get("test")
8696 .unwrap()
8697 .clone()
8698 .with(|ctx| {
8699 let global = ctx.globals();
8700 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
8701 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
8702 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
8703 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
8704 assert!(!global.get::<_, bool>("_isRelative").unwrap());
8705 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
8706 });
8707 }
8708
8709 #[test]
8717 fn test_path_join_preserves_unc_prefix() {
8718 let (mut backend, _rx) = create_test_backend();
8719 backend
8720 .execute_js(
8721 r#"
8722 const editor = getEditor();
8723 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
8724 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
8725 globalThis._posix = editor.pathJoin("/foo", "bar");
8726 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
8727 "#,
8728 "test.js",
8729 )
8730 .unwrap();
8731
8732 backend
8733 .plugin_contexts
8734 .borrow()
8735 .get("test")
8736 .unwrap()
8737 .clone()
8738 .with(|ctx| {
8739 let global = ctx.globals();
8740 assert_eq!(
8741 global.get::<_, String>("_unc").unwrap(),
8742 "//?/C:/workspace/.devcontainer/devcontainer.json",
8743 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
8744 );
8745 assert_eq!(
8746 global.get::<_, String>("_unc_fwd").unwrap(),
8747 "//?/C:/workspace/.devcontainer/devcontainer.json",
8748 "UNC prefix in forward-slash form stays as `//`",
8749 );
8750 assert_eq!(
8751 global.get::<_, String>("_posix").unwrap(),
8752 "/foo/bar",
8753 "POSIX absolute paths keep their single leading slash",
8754 );
8755 assert_eq!(
8756 global.get::<_, String>("_drive").unwrap(),
8757 "C:/foo/bar",
8758 "Windows drive-letter paths have no leading slash",
8759 );
8760 });
8761 }
8762
8763 #[test]
8764 fn test_file_uri_to_path_and_back() {
8765 let (mut backend, _rx) = create_test_backend();
8766
8767 #[cfg(not(windows))]
8769 let js_code = r#"
8770 const editor = getEditor();
8771 // Basic file URI to path
8772 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
8773 // Percent-encoded characters
8774 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
8775 // Invalid URI returns empty string
8776 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8777 // Path to file URI
8778 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
8779 // Round-trip
8780 globalThis._roundtrip = editor.fileUriToPath(
8781 editor.pathToFileUri("/home/user/file.txt")
8782 );
8783 "#;
8784
8785 #[cfg(windows)]
8786 let js_code = r#"
8787 const editor = getEditor();
8788 // Windows URI with encoded colon (the bug from issue #1071)
8789 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
8790 // Windows URI with normal colon
8791 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
8792 // Invalid URI returns empty string
8793 globalThis._path3 = editor.fileUriToPath("not-a-uri");
8794 // Path to file URI
8795 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
8796 // Round-trip
8797 globalThis._roundtrip = editor.fileUriToPath(
8798 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
8799 );
8800 "#;
8801
8802 backend.execute_js(js_code, "test.js").unwrap();
8803
8804 backend
8805 .plugin_contexts
8806 .borrow()
8807 .get("test")
8808 .unwrap()
8809 .clone()
8810 .with(|ctx| {
8811 let global = ctx.globals();
8812
8813 #[cfg(not(windows))]
8814 {
8815 assert_eq!(
8816 global.get::<_, String>("_path1").unwrap(),
8817 "/home/user/file.txt"
8818 );
8819 assert_eq!(
8820 global.get::<_, String>("_path2").unwrap(),
8821 "/home/user/my file.txt"
8822 );
8823 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8824 assert_eq!(
8825 global.get::<_, String>("_uri1").unwrap(),
8826 "file:///home/user/file.txt"
8827 );
8828 assert_eq!(
8829 global.get::<_, String>("_roundtrip").unwrap(),
8830 "/home/user/file.txt"
8831 );
8832 }
8833
8834 #[cfg(windows)]
8835 {
8836 assert_eq!(
8838 global.get::<_, String>("_path1").unwrap(),
8839 "C:\\Users\\admin\\Repos\\file.cs"
8840 );
8841 assert_eq!(
8842 global.get::<_, String>("_path2").unwrap(),
8843 "C:\\Users\\admin\\Repos\\file.cs"
8844 );
8845 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
8846 assert_eq!(
8847 global.get::<_, String>("_uri1").unwrap(),
8848 "file:///C:/Users/admin/Repos/file.cs"
8849 );
8850 assert_eq!(
8851 global.get::<_, String>("_roundtrip").unwrap(),
8852 "C:\\Users\\admin\\Repos\\file.cs"
8853 );
8854 }
8855 });
8856 }
8857
8858 #[test]
8859 fn test_typescript_transpilation() {
8860 use fresh_parser_js::transpile_typescript;
8861
8862 let (mut backend, rx) = create_test_backend();
8863
8864 let ts_code = r#"
8866 const editor = getEditor();
8867 function greet(name: string): string {
8868 return "Hello, " + name;
8869 }
8870 editor.setStatus(greet("TypeScript"));
8871 "#;
8872
8873 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
8875
8876 backend.execute_js(&js_code, "test.js").unwrap();
8878
8879 let cmd = rx.try_recv().unwrap();
8880 match cmd {
8881 PluginCommand::SetStatus { message } => {
8882 assert_eq!(message, "Hello, TypeScript");
8883 }
8884 _ => panic!("Expected SetStatus, got {:?}", cmd),
8885 }
8886 }
8887
8888 #[test]
8889 fn test_api_get_buffer_text_sends_command() {
8890 let (mut backend, rx) = create_test_backend();
8891
8892 backend
8894 .execute_js(
8895 r#"
8896 const editor = getEditor();
8897 // Store the promise for later
8898 globalThis._textPromise = editor.getBufferText(0, 10, 20);
8899 "#,
8900 "test.js",
8901 )
8902 .unwrap();
8903
8904 let cmd = rx.try_recv().unwrap();
8906 match cmd {
8907 PluginCommand::GetBufferText {
8908 buffer_id,
8909 start,
8910 end,
8911 request_id,
8912 } => {
8913 assert_eq!(buffer_id.0, 0);
8914 assert_eq!(start, 10);
8915 assert_eq!(end, 20);
8916 assert!(request_id > 0); }
8918 _ => panic!("Expected GetBufferText, got {:?}", cmd),
8919 }
8920 }
8921
8922 #[test]
8923 fn test_api_get_buffer_text_resolves_callback() {
8924 let (mut backend, rx) = create_test_backend();
8925
8926 backend
8928 .execute_js(
8929 r#"
8930 const editor = getEditor();
8931 globalThis._resolvedText = null;
8932 editor.getBufferText(0, 0, 100).then(text => {
8933 globalThis._resolvedText = text;
8934 });
8935 "#,
8936 "test.js",
8937 )
8938 .unwrap();
8939
8940 let request_id = match rx.try_recv().unwrap() {
8942 PluginCommand::GetBufferText { request_id, .. } => request_id,
8943 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
8944 };
8945
8946 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
8948
8949 backend
8951 .plugin_contexts
8952 .borrow()
8953 .get("test")
8954 .unwrap()
8955 .clone()
8956 .with(|ctx| {
8957 run_pending_jobs_checked(&ctx, "test async getText");
8958 });
8959
8960 backend
8962 .plugin_contexts
8963 .borrow()
8964 .get("test")
8965 .unwrap()
8966 .clone()
8967 .with(|ctx| {
8968 let global = ctx.globals();
8969 let result: String = global.get("_resolvedText").unwrap();
8970 assert_eq!(result, "hello world");
8971 });
8972 }
8973
8974 #[test]
8975 fn test_plugin_translation() {
8976 let (mut backend, _rx) = create_test_backend();
8977
8978 backend
8980 .execute_js(
8981 r#"
8982 const editor = getEditor();
8983 globalThis._translated = editor.t("test.key");
8984 "#,
8985 "test.js",
8986 )
8987 .unwrap();
8988
8989 backend
8990 .plugin_contexts
8991 .borrow()
8992 .get("test")
8993 .unwrap()
8994 .clone()
8995 .with(|ctx| {
8996 let global = ctx.globals();
8997 let result: String = global.get("_translated").unwrap();
8999 assert_eq!(result, "test.key");
9000 });
9001 }
9002
9003 #[test]
9004 fn test_plugin_translation_with_registered_strings() {
9005 let (mut backend, _rx) = create_test_backend();
9006
9007 let mut en_strings = std::collections::HashMap::new();
9009 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
9010 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9011
9012 let mut strings = std::collections::HashMap::new();
9013 strings.insert("en".to_string(), en_strings);
9014
9015 if let Some(bridge) = backend
9017 .services
9018 .as_any()
9019 .downcast_ref::<TestServiceBridge>()
9020 {
9021 let mut en = bridge.en_strings.lock().unwrap();
9022 en.insert("greeting".to_string(), "Hello, World!".to_string());
9023 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
9024 }
9025
9026 backend
9028 .execute_js(
9029 r#"
9030 const editor = getEditor();
9031 globalThis._greeting = editor.t("greeting");
9032 globalThis._prompt = editor.t("prompt.find_file");
9033 globalThis._missing = editor.t("nonexistent.key");
9034 "#,
9035 "test.js",
9036 )
9037 .unwrap();
9038
9039 backend
9040 .plugin_contexts
9041 .borrow()
9042 .get("test")
9043 .unwrap()
9044 .clone()
9045 .with(|ctx| {
9046 let global = ctx.globals();
9047 let greeting: String = global.get("_greeting").unwrap();
9048 assert_eq!(greeting, "Hello, World!");
9049
9050 let prompt: String = global.get("_prompt").unwrap();
9051 assert_eq!(prompt, "Find file: ");
9052
9053 let missing: String = global.get("_missing").unwrap();
9055 assert_eq!(missing, "nonexistent.key");
9056 });
9057 }
9058
9059 #[test]
9062 fn test_api_set_line_indicator() {
9063 let (mut backend, rx) = create_test_backend();
9064
9065 backend
9066 .execute_js(
9067 r#"
9068 const editor = getEditor();
9069 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
9070 "#,
9071 "test.js",
9072 )
9073 .unwrap();
9074
9075 let cmd = rx.try_recv().unwrap();
9076 match cmd {
9077 PluginCommand::SetLineIndicator {
9078 buffer_id,
9079 line,
9080 namespace,
9081 symbol,
9082 color,
9083 priority,
9084 } => {
9085 assert_eq!(buffer_id.0, 1);
9086 assert_eq!(line, 5);
9087 assert_eq!(namespace, "test-ns");
9088 assert_eq!(symbol, "●");
9089 assert_eq!(color, (255, 0, 0));
9090 assert_eq!(priority, 10);
9091 }
9092 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
9093 }
9094 }
9095
9096 #[test]
9097 fn test_api_clear_line_indicators() {
9098 let (mut backend, rx) = create_test_backend();
9099
9100 backend
9101 .execute_js(
9102 r#"
9103 const editor = getEditor();
9104 editor.clearLineIndicators(1, "test-ns");
9105 "#,
9106 "test.js",
9107 )
9108 .unwrap();
9109
9110 let cmd = rx.try_recv().unwrap();
9111 match cmd {
9112 PluginCommand::ClearLineIndicators {
9113 buffer_id,
9114 namespace,
9115 } => {
9116 assert_eq!(buffer_id.0, 1);
9117 assert_eq!(namespace, "test-ns");
9118 }
9119 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
9120 }
9121 }
9122
9123 #[test]
9126 fn test_api_create_virtual_buffer_sends_command() {
9127 let (mut backend, rx) = create_test_backend();
9128
9129 backend
9130 .execute_js(
9131 r#"
9132 const editor = getEditor();
9133 editor.createVirtualBuffer({
9134 name: "*Test Buffer*",
9135 mode: "test-mode",
9136 readOnly: true,
9137 entries: [
9138 { text: "Line 1\n", properties: { type: "header" } },
9139 { text: "Line 2\n", properties: { type: "content" } }
9140 ],
9141 showLineNumbers: false,
9142 showCursors: true,
9143 editingDisabled: true
9144 });
9145 "#,
9146 "test.js",
9147 )
9148 .unwrap();
9149
9150 let cmd = rx.try_recv().unwrap();
9151 match cmd {
9152 PluginCommand::CreateVirtualBufferWithContent {
9153 name,
9154 mode,
9155 read_only,
9156 entries,
9157 show_line_numbers,
9158 show_cursors,
9159 editing_disabled,
9160 ..
9161 } => {
9162 assert_eq!(name, "*Test Buffer*");
9163 assert_eq!(mode, "test-mode");
9164 assert!(read_only);
9165 assert_eq!(entries.len(), 2);
9166 assert_eq!(entries[0].text, "Line 1\n");
9167 assert!(!show_line_numbers);
9168 assert!(show_cursors);
9169 assert!(editing_disabled);
9170 }
9171 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
9172 }
9173 }
9174
9175 #[test]
9176 fn test_api_set_virtual_buffer_content() {
9177 let (mut backend, rx) = create_test_backend();
9178
9179 backend
9180 .execute_js(
9181 r#"
9182 const editor = getEditor();
9183 editor.setVirtualBufferContent(5, [
9184 { text: "New content\n", properties: { type: "updated" } }
9185 ]);
9186 "#,
9187 "test.js",
9188 )
9189 .unwrap();
9190
9191 let cmd = rx.try_recv().unwrap();
9192 match cmd {
9193 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
9194 assert_eq!(buffer_id.0, 5);
9195 assert_eq!(entries.len(), 1);
9196 assert_eq!(entries[0].text, "New content\n");
9197 }
9198 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
9199 }
9200 }
9201
9202 #[test]
9205 fn test_api_add_overlay() {
9206 let (mut backend, rx) = create_test_backend();
9207
9208 backend
9209 .execute_js(
9210 r#"
9211 const editor = getEditor();
9212 editor.addOverlay(1, "highlight", 10, 20, {
9213 fg: [255, 128, 0],
9214 bg: [50, 50, 50],
9215 bold: true,
9216 });
9217 "#,
9218 "test.js",
9219 )
9220 .unwrap();
9221
9222 let cmd = rx.try_recv().unwrap();
9223 match cmd {
9224 PluginCommand::AddOverlay {
9225 buffer_id,
9226 namespace,
9227 range,
9228 options,
9229 } => {
9230 use fresh_core::api::OverlayColorSpec;
9231 assert_eq!(buffer_id.0, 1);
9232 assert!(namespace.is_some());
9233 assert_eq!(namespace.unwrap().as_str(), "highlight");
9234 assert_eq!(range, 10..20);
9235 assert!(matches!(
9236 options.fg,
9237 Some(OverlayColorSpec::Rgb(255, 128, 0))
9238 ));
9239 assert!(matches!(
9240 options.bg,
9241 Some(OverlayColorSpec::Rgb(50, 50, 50))
9242 ));
9243 assert!(!options.underline);
9244 assert!(options.bold);
9245 assert!(!options.italic);
9246 assert!(!options.extend_to_line_end);
9247 }
9248 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9249 }
9250 }
9251
9252 #[test]
9253 fn test_api_add_overlay_with_theme_keys() {
9254 let (mut backend, rx) = create_test_backend();
9255
9256 backend
9257 .execute_js(
9258 r#"
9259 const editor = getEditor();
9260 // Test with theme keys for colors
9261 editor.addOverlay(1, "themed", 0, 10, {
9262 fg: "ui.status_bar_fg",
9263 bg: "editor.selection_bg",
9264 });
9265 "#,
9266 "test.js",
9267 )
9268 .unwrap();
9269
9270 let cmd = rx.try_recv().unwrap();
9271 match cmd {
9272 PluginCommand::AddOverlay {
9273 buffer_id,
9274 namespace,
9275 range,
9276 options,
9277 } => {
9278 use fresh_core::api::OverlayColorSpec;
9279 assert_eq!(buffer_id.0, 1);
9280 assert!(namespace.is_some());
9281 assert_eq!(namespace.unwrap().as_str(), "themed");
9282 assert_eq!(range, 0..10);
9283 assert!(matches!(
9284 &options.fg,
9285 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
9286 ));
9287 assert!(matches!(
9288 &options.bg,
9289 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
9290 ));
9291 assert!(!options.underline);
9292 assert!(!options.bold);
9293 assert!(!options.italic);
9294 assert!(!options.extend_to_line_end);
9295 }
9296 _ => panic!("Expected AddOverlay, got {:?}", cmd),
9297 }
9298 }
9299
9300 #[test]
9301 fn test_api_clear_namespace() {
9302 let (mut backend, rx) = create_test_backend();
9303
9304 backend
9305 .execute_js(
9306 r#"
9307 const editor = getEditor();
9308 editor.clearNamespace(1, "highlight");
9309 "#,
9310 "test.js",
9311 )
9312 .unwrap();
9313
9314 let cmd = rx.try_recv().unwrap();
9315 match cmd {
9316 PluginCommand::ClearNamespace {
9317 buffer_id,
9318 namespace,
9319 } => {
9320 assert_eq!(buffer_id.0, 1);
9321 assert_eq!(namespace.as_str(), "highlight");
9322 }
9323 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
9324 }
9325 }
9326
9327 #[test]
9330 fn test_api_get_theme_schema() {
9331 let (mut backend, _rx) = create_test_backend();
9332
9333 backend
9334 .execute_js(
9335 r#"
9336 const editor = getEditor();
9337 const schema = editor.getThemeSchema();
9338 globalThis._isObject = typeof schema === 'object' && schema !== null;
9339 "#,
9340 "test.js",
9341 )
9342 .unwrap();
9343
9344 backend
9345 .plugin_contexts
9346 .borrow()
9347 .get("test")
9348 .unwrap()
9349 .clone()
9350 .with(|ctx| {
9351 let global = ctx.globals();
9352 let is_object: bool = global.get("_isObject").unwrap();
9353 assert!(is_object);
9355 });
9356 }
9357
9358 #[test]
9359 fn test_api_get_builtin_themes() {
9360 let (mut backend, _rx) = create_test_backend();
9361
9362 backend
9363 .execute_js(
9364 r#"
9365 const editor = getEditor();
9366 const themes = editor.getBuiltinThemes();
9367 globalThis._isObject = typeof themes === 'object' && themes !== null;
9368 "#,
9369 "test.js",
9370 )
9371 .unwrap();
9372
9373 backend
9374 .plugin_contexts
9375 .borrow()
9376 .get("test")
9377 .unwrap()
9378 .clone()
9379 .with(|ctx| {
9380 let global = ctx.globals();
9381 let is_object: bool = global.get("_isObject").unwrap();
9382 assert!(is_object);
9384 });
9385 }
9386
9387 #[test]
9388 fn test_api_apply_theme() {
9389 let (mut backend, rx) = create_test_backend();
9390
9391 backend
9392 .execute_js(
9393 r#"
9394 const editor = getEditor();
9395 editor.applyTheme("dark");
9396 "#,
9397 "test.js",
9398 )
9399 .unwrap();
9400
9401 let cmd = rx.try_recv().unwrap();
9402 match cmd {
9403 PluginCommand::ApplyTheme { theme_name } => {
9404 assert_eq!(theme_name, "dark");
9405 }
9406 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
9407 }
9408 }
9409
9410 #[test]
9411 fn test_api_override_theme_colors_round_trip() {
9412 let (mut backend, rx) = create_test_backend();
9415
9416 backend
9417 .execute_js(
9418 r#"
9419 const editor = getEditor();
9420 editor.overrideThemeColors({
9421 "editor.bg": [10, 20, 30],
9422 "editor.fg": [220, 221, 222],
9423 });
9424 "#,
9425 "test.js",
9426 )
9427 .unwrap();
9428
9429 let cmd = rx.try_recv().unwrap();
9430 match cmd {
9431 PluginCommand::OverrideThemeColors { overrides } => {
9432 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
9433 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
9434 assert_eq!(overrides.len(), 2);
9435 }
9436 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
9437 }
9438 }
9439
9440 #[test]
9441 fn test_api_override_theme_colors_clamps_out_of_range() {
9442 let (mut backend, rx) = create_test_backend();
9443
9444 backend
9445 .execute_js(
9446 r#"
9447 const editor = getEditor();
9448 editor.overrideThemeColors({
9449 "editor.bg": [-5, 300, 128],
9450 });
9451 "#,
9452 "test.js",
9453 )
9454 .unwrap();
9455
9456 match rx.try_recv().unwrap() {
9457 PluginCommand::OverrideThemeColors { overrides } => {
9458 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
9459 }
9460 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9461 }
9462 }
9463
9464 #[test]
9465 fn test_api_override_theme_colors_drops_malformed_entries() {
9466 let (mut backend, rx) = create_test_backend();
9469
9470 backend
9471 .execute_js(
9472 r#"
9473 const editor = getEditor();
9474 editor.overrideThemeColors({
9475 "editor.bg": [1, 2, 3],
9476 "not_an_array": "oops",
9477 "wrong_length": [1, 2],
9478 "floats_are_fine": [10.7, 20.2, 30.9],
9479 });
9480 "#,
9481 "test.js",
9482 )
9483 .unwrap();
9484
9485 match rx.try_recv().unwrap() {
9486 PluginCommand::OverrideThemeColors { overrides } => {
9487 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
9488 assert!(!overrides.contains_key("not_an_array"));
9489 assert!(!overrides.contains_key("wrong_length"));
9490 assert_eq!(
9492 overrides.get("floats_are_fine").copied(),
9493 Some([10, 20, 30])
9494 );
9495 }
9496 other => panic!("Expected OverrideThemeColors, got {other:?}"),
9497 }
9498 }
9499
9500 #[test]
9501 fn test_api_get_theme_data_missing() {
9502 let (mut backend, _rx) = create_test_backend();
9503
9504 backend
9505 .execute_js(
9506 r#"
9507 const editor = getEditor();
9508 const data = editor.getThemeData("nonexistent");
9509 globalThis._isNull = data === null;
9510 "#,
9511 "test.js",
9512 )
9513 .unwrap();
9514
9515 backend
9516 .plugin_contexts
9517 .borrow()
9518 .get("test")
9519 .unwrap()
9520 .clone()
9521 .with(|ctx| {
9522 let global = ctx.globals();
9523 let is_null: bool = global.get("_isNull").unwrap();
9524 assert!(is_null);
9526 });
9527 }
9528
9529 #[test]
9530 fn test_api_get_theme_data_present() {
9531 let (tx, _rx) = mpsc::channel();
9533 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9534 let services = Arc::new(ThemeCacheTestBridge {
9535 inner: TestServiceBridge::new(),
9536 });
9537 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9538
9539 backend
9540 .execute_js(
9541 r#"
9542 const editor = getEditor();
9543 const data = editor.getThemeData("test-theme");
9544 globalThis._hasData = data !== null && typeof data === 'object';
9545 globalThis._name = data ? data.name : null;
9546 "#,
9547 "test.js",
9548 )
9549 .unwrap();
9550
9551 backend
9552 .plugin_contexts
9553 .borrow()
9554 .get("test")
9555 .unwrap()
9556 .clone()
9557 .with(|ctx| {
9558 let global = ctx.globals();
9559 let has_data: bool = global.get("_hasData").unwrap();
9560 assert!(has_data, "getThemeData should return theme object");
9561 let name: String = global.get("_name").unwrap();
9562 assert_eq!(name, "test-theme");
9563 });
9564 }
9565
9566 #[test]
9567 fn test_api_theme_file_exists() {
9568 let (mut backend, _rx) = create_test_backend();
9569
9570 backend
9571 .execute_js(
9572 r#"
9573 const editor = getEditor();
9574 globalThis._exists = editor.themeFileExists("anything");
9575 "#,
9576 "test.js",
9577 )
9578 .unwrap();
9579
9580 backend
9581 .plugin_contexts
9582 .borrow()
9583 .get("test")
9584 .unwrap()
9585 .clone()
9586 .with(|ctx| {
9587 let global = ctx.globals();
9588 let exists: bool = global.get("_exists").unwrap();
9589 assert!(!exists);
9591 });
9592 }
9593
9594 #[test]
9595 fn test_api_save_theme_file_error() {
9596 let (mut backend, _rx) = create_test_backend();
9597
9598 backend
9599 .execute_js(
9600 r#"
9601 const editor = getEditor();
9602 let threw = false;
9603 try {
9604 editor.saveThemeFile("test", "{}");
9605 } catch (e) {
9606 threw = true;
9607 }
9608 globalThis._threw = threw;
9609 "#,
9610 "test.js",
9611 )
9612 .unwrap();
9613
9614 backend
9615 .plugin_contexts
9616 .borrow()
9617 .get("test")
9618 .unwrap()
9619 .clone()
9620 .with(|ctx| {
9621 let global = ctx.globals();
9622 let threw: bool = global.get("_threw").unwrap();
9623 assert!(threw);
9625 });
9626 }
9627
9628 struct ThemeCacheTestBridge {
9630 inner: TestServiceBridge,
9631 }
9632
9633 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
9634 fn as_any(&self) -> &dyn std::any::Any {
9635 self
9636 }
9637 fn translate(
9638 &self,
9639 plugin_name: &str,
9640 key: &str,
9641 args: &HashMap<String, String>,
9642 ) -> String {
9643 self.inner.translate(plugin_name, key, args)
9644 }
9645 fn current_locale(&self) -> String {
9646 self.inner.current_locale()
9647 }
9648 fn set_js_execution_state(&self, state: String) {
9649 self.inner.set_js_execution_state(state);
9650 }
9651 fn clear_js_execution_state(&self) {
9652 self.inner.clear_js_execution_state();
9653 }
9654 fn get_theme_schema(&self) -> serde_json::Value {
9655 self.inner.get_theme_schema()
9656 }
9657 fn get_builtin_themes(&self) -> serde_json::Value {
9658 self.inner.get_builtin_themes()
9659 }
9660 fn get_all_themes(&self) -> serde_json::Value {
9661 self.inner.get_all_themes()
9662 }
9663 fn register_command(&self, command: fresh_core::command::Command) {
9664 self.inner.register_command(command);
9665 }
9666 fn unregister_command(&self, name: &str) {
9667 self.inner.unregister_command(name);
9668 }
9669 fn unregister_commands_by_prefix(&self, prefix: &str) {
9670 self.inner.unregister_commands_by_prefix(prefix);
9671 }
9672 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
9673 self.inner.unregister_commands_by_plugin(plugin_name);
9674 }
9675 fn plugins_dir(&self) -> std::path::PathBuf {
9676 self.inner.plugins_dir()
9677 }
9678 fn config_dir(&self) -> std::path::PathBuf {
9679 self.inner.config_dir()
9680 }
9681 fn data_dir(&self) -> std::path::PathBuf {
9682 self.inner.data_dir()
9683 }
9684 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
9685 if name == "test-theme" {
9686 Some(serde_json::json!({
9687 "name": "test-theme",
9688 "editor": {},
9689 "ui": {},
9690 "syntax": {}
9691 }))
9692 } else {
9693 None
9694 }
9695 }
9696 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
9697 Err("test bridge does not support save".to_string())
9698 }
9699 fn theme_file_exists(&self, name: &str) -> bool {
9700 name == "test-theme"
9701 }
9702 }
9703
9704 #[test]
9707 fn test_api_close_buffer() {
9708 let (mut backend, rx) = create_test_backend();
9709
9710 backend
9711 .execute_js(
9712 r#"
9713 const editor = getEditor();
9714 editor.closeBuffer(3);
9715 "#,
9716 "test.js",
9717 )
9718 .unwrap();
9719
9720 let cmd = rx.try_recv().unwrap();
9721 match cmd {
9722 PluginCommand::CloseBuffer { buffer_id } => {
9723 assert_eq!(buffer_id.0, 3);
9724 }
9725 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
9726 }
9727 }
9728
9729 #[test]
9730 fn test_api_focus_split() {
9731 let (mut backend, rx) = create_test_backend();
9732
9733 backend
9734 .execute_js(
9735 r#"
9736 const editor = getEditor();
9737 editor.focusSplit(2);
9738 "#,
9739 "test.js",
9740 )
9741 .unwrap();
9742
9743 let cmd = rx.try_recv().unwrap();
9744 match cmd {
9745 PluginCommand::FocusSplit { split_id } => {
9746 assert_eq!(split_id.0, 2);
9747 }
9748 _ => panic!("Expected FocusSplit, got {:?}", cmd),
9749 }
9750 }
9751
9752 #[test]
9756 fn test_api_session_lifecycle_dispatches_commands() {
9757 let (mut backend, rx) = create_test_backend();
9758
9759 backend
9760 .execute_js(
9761 r#"
9762 const editor = getEditor();
9763 editor.createWindow("/tmp/wt-feat", "feat");
9764 editor.setActiveWindow(7);
9765 editor.closeWindow(3);
9766 "#,
9767 "test.js",
9768 )
9769 .unwrap();
9770
9771 let create = rx.try_recv().unwrap();
9772 match create {
9773 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
9774 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
9775 assert_eq!(label, "feat");
9776 }
9777 other => panic!("Expected CreateWindow, got {:?}", other),
9778 }
9779
9780 let activate = rx.try_recv().unwrap();
9781 match activate {
9782 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
9783 assert_eq!(id, fresh_core::WindowId(7));
9784 }
9785 other => panic!("Expected SetActiveWindow, got {:?}", other),
9786 }
9787
9788 let close = rx.try_recv().unwrap();
9789 match close {
9790 fresh_core::api::PluginCommand::CloseWindow { id } => {
9791 assert_eq!(id, fresh_core::WindowId(3));
9792 }
9793 other => panic!("Expected CloseWindow, got {:?}", other),
9794 }
9795 }
9796
9797 #[test]
9801 fn test_api_list_sessions_reads_snapshot() {
9802 let (tx, _rx) = mpsc::channel();
9803 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9804
9805 {
9806 let mut state = state_snapshot.write().unwrap();
9807 state.windows = vec![
9808 fresh_core::api::WindowInfo {
9809 id: fresh_core::WindowId(1),
9810 label: "main".into(),
9811 root: std::path::PathBuf::from("/repo"),
9812 project_path: std::path::PathBuf::from("/repo"),
9813 shared_worktree: false,
9814 },
9815 fresh_core::api::WindowInfo {
9816 id: fresh_core::WindowId(2),
9817 label: "feat-auth".into(),
9818 root: std::path::PathBuf::from("/wt/feat-auth"),
9819 project_path: std::path::PathBuf::from("/wt/feat-auth"),
9820 shared_worktree: false,
9821 },
9822 ];
9823 state.active_window_id = fresh_core::WindowId(2);
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 list = editor.listWindows();
9834 globalThis._sessionCount = list.length;
9835 globalThis._secondLabel = list[1].label;
9836 globalThis._secondRoot = list[1].root;
9837 globalThis._activeId = editor.activeWindow();
9838 "#,
9839 "test.js",
9840 )
9841 .unwrap();
9842
9843 backend
9844 .plugin_contexts
9845 .borrow()
9846 .get("test")
9847 .unwrap()
9848 .clone()
9849 .with(|ctx| {
9850 let global = ctx.globals();
9851 let count: u32 = global.get("_sessionCount").unwrap();
9852 let label: String = global.get("_secondLabel").unwrap();
9853 let root: String = global.get("_secondRoot").unwrap();
9854 let active: u32 = global.get("_activeId").unwrap();
9855 assert_eq!(count, 2);
9856 assert_eq!(label, "feat-auth");
9857 assert_eq!(root, "/wt/feat-auth");
9858 assert_eq!(active, 2);
9859 });
9860 }
9861
9862 #[test]
9863 fn test_api_list_buffers() {
9864 let (tx, _rx) = mpsc::channel();
9865 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9866
9867 {
9869 let mut state = state_snapshot.write().unwrap();
9870 state.buffers.insert(
9871 BufferId(0),
9872 BufferInfo {
9873 id: BufferId(0),
9874 path: Some(PathBuf::from("/test1.txt")),
9875 modified: false,
9876 length: 100,
9877 is_virtual: false,
9878 view_mode: "source".to_string(),
9879 is_composing_in_any_split: false,
9880 compose_width: None,
9881 language: "text".to_string(),
9882 is_preview: false,
9883 splits: Vec::new(),
9884 },
9885 );
9886 state.buffers.insert(
9887 BufferId(1),
9888 BufferInfo {
9889 id: BufferId(1),
9890 path: Some(PathBuf::from("/test2.txt")),
9891 modified: true,
9892 length: 200,
9893 is_virtual: false,
9894 view_mode: "source".to_string(),
9895 is_composing_in_any_split: false,
9896 compose_width: None,
9897 language: "text".to_string(),
9898 is_preview: false,
9899 splits: Vec::new(),
9900 },
9901 );
9902 }
9903
9904 let services = Arc::new(fresh_core::services::NoopServiceBridge);
9905 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
9906
9907 backend
9908 .execute_js(
9909 r#"
9910 const editor = getEditor();
9911 const buffers = editor.listBuffers();
9912 globalThis._isArray = Array.isArray(buffers);
9913 globalThis._length = buffers.length;
9914 "#,
9915 "test.js",
9916 )
9917 .unwrap();
9918
9919 backend
9920 .plugin_contexts
9921 .borrow()
9922 .get("test")
9923 .unwrap()
9924 .clone()
9925 .with(|ctx| {
9926 let global = ctx.globals();
9927 let is_array: bool = global.get("_isArray").unwrap();
9928 let length: u32 = global.get("_length").unwrap();
9929 assert!(is_array);
9930 assert_eq!(length, 2);
9931 });
9932 }
9933
9934 #[test]
9937 fn test_api_start_prompt() {
9938 let (mut backend, rx) = create_test_backend();
9939
9940 backend
9941 .execute_js(
9942 r#"
9943 const editor = getEditor();
9944 editor.startPrompt("Enter value:", "test-prompt");
9945 "#,
9946 "test.js",
9947 )
9948 .unwrap();
9949
9950 let cmd = rx.try_recv().unwrap();
9951 match cmd {
9952 PluginCommand::StartPrompt {
9953 label,
9954 prompt_type,
9955 floating_overlay,
9956 } => {
9957 assert_eq!(label, "Enter value:");
9958 assert_eq!(prompt_type, "test-prompt");
9959 assert!(!floating_overlay);
9960 }
9961 _ => panic!("Expected StartPrompt, got {:?}", cmd),
9962 }
9963 }
9964
9965 #[test]
9966 fn test_api_start_prompt_with_initial() {
9967 let (mut backend, rx) = create_test_backend();
9968
9969 backend
9970 .execute_js(
9971 r#"
9972 const editor = getEditor();
9973 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
9974 "#,
9975 "test.js",
9976 )
9977 .unwrap();
9978
9979 let cmd = rx.try_recv().unwrap();
9980 match cmd {
9981 PluginCommand::StartPromptWithInitial {
9982 label,
9983 prompt_type,
9984 initial_value,
9985 floating_overlay,
9986 } => {
9987 assert_eq!(label, "Enter value:");
9988 assert_eq!(prompt_type, "test-prompt");
9989 assert_eq!(initial_value, "default");
9990 assert!(!floating_overlay);
9991 }
9992 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
9993 }
9994 }
9995
9996 #[test]
9997 fn test_api_set_prompt_suggestions() {
9998 let (mut backend, rx) = create_test_backend();
9999
10000 backend
10001 .execute_js(
10002 r#"
10003 const editor = getEditor();
10004 editor.setPromptSuggestions([
10005 { text: "Option 1", value: "opt1" },
10006 { text: "Option 2", value: "opt2" }
10007 ]);
10008 "#,
10009 "test.js",
10010 )
10011 .unwrap();
10012
10013 let cmd = rx.try_recv().unwrap();
10014 match cmd {
10015 PluginCommand::SetPromptSuggestions { suggestions, .. } => {
10016 assert_eq!(suggestions.len(), 2);
10017 assert_eq!(suggestions[0].text, "Option 1");
10018 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
10019 }
10020 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
10021 }
10022 }
10023
10024 #[test]
10027 fn test_api_get_active_buffer_id() {
10028 let (tx, _rx) = mpsc::channel();
10029 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10030
10031 {
10032 let mut state = state_snapshot.write().unwrap();
10033 state.active_buffer_id = BufferId(42);
10034 }
10035
10036 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10037 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10038
10039 backend
10040 .execute_js(
10041 r#"
10042 const editor = getEditor();
10043 globalThis._activeId = editor.getActiveBufferId();
10044 "#,
10045 "test.js",
10046 )
10047 .unwrap();
10048
10049 backend
10050 .plugin_contexts
10051 .borrow()
10052 .get("test")
10053 .unwrap()
10054 .clone()
10055 .with(|ctx| {
10056 let global = ctx.globals();
10057 let result: u32 = global.get("_activeId").unwrap();
10058 assert_eq!(result, 42);
10059 });
10060 }
10061
10062 #[test]
10063 fn test_api_get_active_split_id() {
10064 let (tx, _rx) = mpsc::channel();
10065 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10066
10067 {
10068 let mut state = state_snapshot.write().unwrap();
10069 state.active_split_id = 7;
10070 }
10071
10072 let services = Arc::new(fresh_core::services::NoopServiceBridge);
10073 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
10074
10075 backend
10076 .execute_js(
10077 r#"
10078 const editor = getEditor();
10079 globalThis._splitId = editor.getActiveSplitId();
10080 "#,
10081 "test.js",
10082 )
10083 .unwrap();
10084
10085 backend
10086 .plugin_contexts
10087 .borrow()
10088 .get("test")
10089 .unwrap()
10090 .clone()
10091 .with(|ctx| {
10092 let global = ctx.globals();
10093 let result: u32 = global.get("_splitId").unwrap();
10094 assert_eq!(result, 7);
10095 });
10096 }
10097
10098 #[test]
10101 fn test_api_file_exists() {
10102 let (mut backend, _rx) = create_test_backend();
10103
10104 backend
10105 .execute_js(
10106 r#"
10107 const editor = getEditor();
10108 // Test with a path that definitely exists
10109 globalThis._exists = editor.fileExists("/");
10110 "#,
10111 "test.js",
10112 )
10113 .unwrap();
10114
10115 backend
10116 .plugin_contexts
10117 .borrow()
10118 .get("test")
10119 .unwrap()
10120 .clone()
10121 .with(|ctx| {
10122 let global = ctx.globals();
10123 let result: bool = global.get("_exists").unwrap();
10124 assert!(result);
10125 });
10126 }
10127
10128 #[test]
10129 fn test_api_parse_jsonc() {
10130 let (mut backend, _rx) = create_test_backend();
10131
10132 backend
10133 .execute_js(
10134 r#"
10135 const editor = getEditor();
10136 // Comments, trailing commas, and nested structures should all parse.
10137 const parsed = editor.parseJsonc(`{
10138 // name of the container
10139 "name": "test",
10140 "features": {
10141 "docker-in-docker": {},
10142 },
10143 /* forwarded port list */
10144 "forwardPorts": [3000, 8080,],
10145 }`);
10146 globalThis._name = parsed.name;
10147 globalThis._featureCount = Object.keys(parsed.features).length;
10148 globalThis._portCount = parsed.forwardPorts.length;
10149
10150 // Invalid JSONC should throw.
10151 try {
10152 editor.parseJsonc("{ broken");
10153 globalThis._threw = false;
10154 } catch (_e) {
10155 globalThis._threw = true;
10156 }
10157 "#,
10158 "test.js",
10159 )
10160 .unwrap();
10161
10162 backend
10163 .plugin_contexts
10164 .borrow()
10165 .get("test")
10166 .unwrap()
10167 .clone()
10168 .with(|ctx| {
10169 let global = ctx.globals();
10170 let name: String = global.get("_name").unwrap();
10171 let feature_count: u32 = global.get("_featureCount").unwrap();
10172 let port_count: u32 = global.get("_portCount").unwrap();
10173 let threw: bool = global.get("_threw").unwrap();
10174 assert_eq!(name, "test");
10175 assert_eq!(feature_count, 1);
10176 assert_eq!(port_count, 2);
10177 assert!(threw, "Invalid JSONC should throw");
10178 });
10179 }
10180
10181 #[test]
10182 fn test_api_get_cwd() {
10183 let (mut backend, _rx) = create_test_backend();
10184
10185 backend
10186 .execute_js(
10187 r#"
10188 const editor = getEditor();
10189 globalThis._cwd = editor.getCwd();
10190 "#,
10191 "test.js",
10192 )
10193 .unwrap();
10194
10195 backend
10196 .plugin_contexts
10197 .borrow()
10198 .get("test")
10199 .unwrap()
10200 .clone()
10201 .with(|ctx| {
10202 let global = ctx.globals();
10203 let result: String = global.get("_cwd").unwrap();
10204 assert!(!result.is_empty());
10206 });
10207 }
10208
10209 #[test]
10210 fn test_api_get_env() {
10211 let (mut backend, _rx) = create_test_backend();
10212
10213 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
10215
10216 backend
10217 .execute_js(
10218 r#"
10219 const editor = getEditor();
10220 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
10221 "#,
10222 "test.js",
10223 )
10224 .unwrap();
10225
10226 backend
10227 .plugin_contexts
10228 .borrow()
10229 .get("test")
10230 .unwrap()
10231 .clone()
10232 .with(|ctx| {
10233 let global = ctx.globals();
10234 let result: Option<String> = global.get("_envVal").unwrap();
10235 assert_eq!(result, Some("test_value".to_string()));
10236 });
10237
10238 std::env::remove_var("TEST_PLUGIN_VAR");
10239 }
10240
10241 #[test]
10242 fn test_api_get_config() {
10243 let (mut backend, _rx) = create_test_backend();
10244
10245 backend
10246 .execute_js(
10247 r#"
10248 const editor = getEditor();
10249 const config = editor.getConfig();
10250 globalThis._isObject = typeof config === 'object';
10251 "#,
10252 "test.js",
10253 )
10254 .unwrap();
10255
10256 backend
10257 .plugin_contexts
10258 .borrow()
10259 .get("test")
10260 .unwrap()
10261 .clone()
10262 .with(|ctx| {
10263 let global = ctx.globals();
10264 let is_object: bool = global.get("_isObject").unwrap();
10265 assert!(is_object);
10267 });
10268 }
10269
10270 #[test]
10271 fn test_api_get_themes_dir() {
10272 let (mut backend, _rx) = create_test_backend();
10273
10274 backend
10275 .execute_js(
10276 r#"
10277 const editor = getEditor();
10278 globalThis._themesDir = editor.getThemesDir();
10279 "#,
10280 "test.js",
10281 )
10282 .unwrap();
10283
10284 backend
10285 .plugin_contexts
10286 .borrow()
10287 .get("test")
10288 .unwrap()
10289 .clone()
10290 .with(|ctx| {
10291 let global = ctx.globals();
10292 let result: String = global.get("_themesDir").unwrap();
10293 assert!(!result.is_empty());
10295 });
10296 }
10297
10298 #[test]
10301 fn test_api_read_dir() {
10302 let (mut backend, _rx) = create_test_backend();
10303
10304 backend
10305 .execute_js(
10306 r#"
10307 const editor = getEditor();
10308 const entries = editor.readDir("/tmp");
10309 globalThis._isArray = Array.isArray(entries);
10310 globalThis._length = entries.length;
10311 "#,
10312 "test.js",
10313 )
10314 .unwrap();
10315
10316 backend
10317 .plugin_contexts
10318 .borrow()
10319 .get("test")
10320 .unwrap()
10321 .clone()
10322 .with(|ctx| {
10323 let global = ctx.globals();
10324 let is_array: bool = global.get("_isArray").unwrap();
10325 let length: u32 = global.get("_length").unwrap();
10326 assert!(is_array);
10328 let _ = length;
10330 });
10331 }
10332
10333 #[test]
10336 fn test_api_execute_action() {
10337 let (mut backend, rx) = create_test_backend();
10338
10339 backend
10340 .execute_js(
10341 r#"
10342 const editor = getEditor();
10343 editor.executeAction("move_cursor_up");
10344 "#,
10345 "test.js",
10346 )
10347 .unwrap();
10348
10349 let cmd = rx.try_recv().unwrap();
10350 match cmd {
10351 PluginCommand::ExecuteAction { action_name } => {
10352 assert_eq!(action_name, "move_cursor_up");
10353 }
10354 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
10355 }
10356 }
10357
10358 #[test]
10361 fn test_api_debug() {
10362 let (mut backend, _rx) = create_test_backend();
10363
10364 backend
10366 .execute_js(
10367 r#"
10368 const editor = getEditor();
10369 editor.debug("Test debug message");
10370 editor.debug("Another message with special chars: <>&\"'");
10371 "#,
10372 "test.js",
10373 )
10374 .unwrap();
10375 }
10377
10378 #[test]
10381 fn test_typescript_preamble_generated() {
10382 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
10384 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
10385 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
10386 println!(
10387 "Generated {} bytes of TypeScript preamble",
10388 JSEDITORAPI_TS_PREAMBLE.len()
10389 );
10390 }
10391
10392 #[test]
10393 fn test_typescript_editor_api_generated() {
10394 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
10396 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
10397 println!(
10398 "Generated {} bytes of EditorAPI interface",
10399 JSEDITORAPI_TS_EDITOR_API.len()
10400 );
10401 }
10402
10403 #[test]
10404 fn test_js_methods_list() {
10405 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
10407 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
10408 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
10410 if i < 20 {
10411 println!(" - {}", method);
10412 }
10413 }
10414 if JSEDITORAPI_JS_METHODS.len() > 20 {
10415 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
10416 }
10417 }
10418
10419 #[test]
10422 fn test_api_load_plugin_sends_command() {
10423 let (mut backend, rx) = create_test_backend();
10424
10425 backend
10427 .execute_js(
10428 r#"
10429 const editor = getEditor();
10430 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
10431 "#,
10432 "test.js",
10433 )
10434 .unwrap();
10435
10436 let cmd = rx.try_recv().unwrap();
10438 match cmd {
10439 PluginCommand::LoadPlugin { path, callback_id } => {
10440 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
10441 assert!(callback_id.0 > 0); }
10443 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
10444 }
10445 }
10446
10447 #[test]
10448 fn test_api_unload_plugin_sends_command() {
10449 let (mut backend, rx) = create_test_backend();
10450
10451 backend
10453 .execute_js(
10454 r#"
10455 const editor = getEditor();
10456 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
10457 "#,
10458 "test.js",
10459 )
10460 .unwrap();
10461
10462 let cmd = rx.try_recv().unwrap();
10464 match cmd {
10465 PluginCommand::UnloadPlugin { name, callback_id } => {
10466 assert_eq!(name, "my-plugin");
10467 assert!(callback_id.0 > 0); }
10469 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
10470 }
10471 }
10472
10473 #[test]
10474 fn test_api_reload_plugin_sends_command() {
10475 let (mut backend, rx) = create_test_backend();
10476
10477 backend
10479 .execute_js(
10480 r#"
10481 const editor = getEditor();
10482 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
10483 "#,
10484 "test.js",
10485 )
10486 .unwrap();
10487
10488 let cmd = rx.try_recv().unwrap();
10490 match cmd {
10491 PluginCommand::ReloadPlugin { name, callback_id } => {
10492 assert_eq!(name, "my-plugin");
10493 assert!(callback_id.0 > 0); }
10495 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
10496 }
10497 }
10498
10499 #[test]
10500 fn test_api_load_plugin_resolves_callback() {
10501 let (mut backend, rx) = create_test_backend();
10502
10503 backend
10505 .execute_js(
10506 r#"
10507 const editor = getEditor();
10508 globalThis._loadResult = null;
10509 editor.loadPlugin("/path/to/plugin.ts").then(result => {
10510 globalThis._loadResult = result;
10511 });
10512 "#,
10513 "test.js",
10514 )
10515 .unwrap();
10516
10517 let callback_id = match rx.try_recv().unwrap() {
10519 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
10520 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
10521 };
10522
10523 backend.resolve_callback(callback_id, "true");
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 loadPlugin");
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 result: bool = global.get("_loadResult").unwrap();
10547 assert!(result);
10548 });
10549 }
10550
10551 #[test]
10552 fn test_api_version() {
10553 let (mut backend, _rx) = create_test_backend();
10554
10555 backend
10556 .execute_js(
10557 r#"
10558 const editor = getEditor();
10559 globalThis._apiVersion = editor.apiVersion();
10560 "#,
10561 "test.js",
10562 )
10563 .unwrap();
10564
10565 backend
10566 .plugin_contexts
10567 .borrow()
10568 .get("test")
10569 .unwrap()
10570 .clone()
10571 .with(|ctx| {
10572 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
10573 assert_eq!(version, 2);
10574 });
10575 }
10576
10577 #[test]
10578 fn test_api_unload_plugin_rejects_on_error() {
10579 let (mut backend, rx) = create_test_backend();
10580
10581 backend
10583 .execute_js(
10584 r#"
10585 const editor = getEditor();
10586 globalThis._unloadError = null;
10587 editor.unloadPlugin("nonexistent-plugin").catch(err => {
10588 globalThis._unloadError = err.message || String(err);
10589 });
10590 "#,
10591 "test.js",
10592 )
10593 .unwrap();
10594
10595 let callback_id = match rx.try_recv().unwrap() {
10597 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
10598 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
10599 };
10600
10601 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
10603
10604 backend
10606 .plugin_contexts
10607 .borrow()
10608 .get("test")
10609 .unwrap()
10610 .clone()
10611 .with(|ctx| {
10612 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
10613 });
10614
10615 backend
10617 .plugin_contexts
10618 .borrow()
10619 .get("test")
10620 .unwrap()
10621 .clone()
10622 .with(|ctx| {
10623 let global = ctx.globals();
10624 let error: String = global.get("_unloadError").unwrap();
10625 assert!(error.contains("nonexistent-plugin"));
10626 });
10627 }
10628
10629 #[test]
10630 fn test_api_set_global_state() {
10631 let (mut backend, rx) = create_test_backend();
10632
10633 backend
10634 .execute_js(
10635 r#"
10636 const editor = getEditor();
10637 editor.setGlobalState("myKey", { enabled: true, count: 42 });
10638 "#,
10639 "test_plugin.js",
10640 )
10641 .unwrap();
10642
10643 let cmd = rx.try_recv().unwrap();
10644 match cmd {
10645 PluginCommand::SetGlobalState {
10646 plugin_name,
10647 key,
10648 value,
10649 } => {
10650 assert_eq!(plugin_name, "test_plugin");
10651 assert_eq!(key, "myKey");
10652 let v = value.unwrap();
10653 assert_eq!(v["enabled"], serde_json::json!(true));
10654 assert_eq!(v["count"], serde_json::json!(42));
10655 }
10656 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10657 }
10658 }
10659
10660 #[test]
10661 fn test_api_set_global_state_delete() {
10662 let (mut backend, rx) = create_test_backend();
10663
10664 backend
10665 .execute_js(
10666 r#"
10667 const editor = getEditor();
10668 editor.setGlobalState("myKey", null);
10669 "#,
10670 "test_plugin.js",
10671 )
10672 .unwrap();
10673
10674 let cmd = rx.try_recv().unwrap();
10675 match cmd {
10676 PluginCommand::SetGlobalState {
10677 plugin_name,
10678 key,
10679 value,
10680 } => {
10681 assert_eq!(plugin_name, "test_plugin");
10682 assert_eq!(key, "myKey");
10683 assert!(value.is_none(), "null should delete the key");
10684 }
10685 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
10686 }
10687 }
10688
10689 #[test]
10690 fn test_api_get_global_state_roundtrip() {
10691 let (mut backend, _rx) = create_test_backend();
10692
10693 backend
10695 .execute_js(
10696 r#"
10697 const editor = getEditor();
10698 editor.setGlobalState("flag", true);
10699 globalThis._result = editor.getGlobalState("flag");
10700 "#,
10701 "test_plugin.js",
10702 )
10703 .unwrap();
10704
10705 backend
10706 .plugin_contexts
10707 .borrow()
10708 .get("test_plugin")
10709 .unwrap()
10710 .clone()
10711 .with(|ctx| {
10712 let global = ctx.globals();
10713 let result: bool = global.get("_result").unwrap();
10714 assert!(
10715 result,
10716 "getGlobalState should return the value set by setGlobalState"
10717 );
10718 });
10719 }
10720
10721 #[test]
10726 fn test_api_set_session_state_roundtrip() {
10727 let (mut backend, _rx) = create_test_backend();
10728
10729 backend
10730 .execute_js(
10731 r#"
10732 const editor = getEditor();
10733 editor.setWindowState("draft", { count: 7 });
10734 globalThis._result = editor.getWindowState("draft");
10735 globalThis._missing = editor.getWindowState("absent");
10736 "#,
10737 "test_plugin.js",
10738 )
10739 .unwrap();
10740
10741 backend
10742 .plugin_contexts
10743 .borrow()
10744 .get("test_plugin")
10745 .unwrap()
10746 .clone()
10747 .with(|ctx| {
10748 let global = ctx.globals();
10749 let count: i64 = global
10750 .get::<_, rquickjs::Object>("_result")
10751 .unwrap()
10752 .get("count")
10753 .unwrap();
10754 assert_eq!(
10755 count, 7,
10756 "getWindowState should return the value set by setWindowState"
10757 );
10758 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
10759 assert!(
10760 missing.is_undefined(),
10761 "getWindowState for an unset key must be undefined"
10762 );
10763 });
10764 }
10765
10766 #[test]
10767 fn test_api_get_global_state_missing_key() {
10768 let (mut backend, _rx) = create_test_backend();
10769
10770 backend
10771 .execute_js(
10772 r#"
10773 const editor = getEditor();
10774 globalThis._result = editor.getGlobalState("nonexistent");
10775 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
10776 "#,
10777 "test_plugin.js",
10778 )
10779 .unwrap();
10780
10781 backend
10782 .plugin_contexts
10783 .borrow()
10784 .get("test_plugin")
10785 .unwrap()
10786 .clone()
10787 .with(|ctx| {
10788 let global = ctx.globals();
10789 let is_undefined: bool = global.get("_isUndefined").unwrap();
10790 assert!(
10791 is_undefined,
10792 "getGlobalState for missing key should return undefined"
10793 );
10794 });
10795 }
10796
10797 #[test]
10798 fn test_api_global_state_isolation_between_plugins() {
10799 let (tx, _rx) = mpsc::channel();
10801 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
10802 let services = Arc::new(TestServiceBridge::new());
10803
10804 let mut backend_a =
10806 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10807 .unwrap();
10808 backend_a
10809 .execute_js(
10810 r#"
10811 const editor = getEditor();
10812 editor.setGlobalState("flag", "from_plugin_a");
10813 "#,
10814 "plugin_a.js",
10815 )
10816 .unwrap();
10817
10818 let mut backend_b =
10820 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
10821 .unwrap();
10822 backend_b
10823 .execute_js(
10824 r#"
10825 const editor = getEditor();
10826 editor.setGlobalState("flag", "from_plugin_b");
10827 "#,
10828 "plugin_b.js",
10829 )
10830 .unwrap();
10831
10832 backend_a
10834 .execute_js(
10835 r#"
10836 const editor = getEditor();
10837 globalThis._aValue = editor.getGlobalState("flag");
10838 "#,
10839 "plugin_a.js",
10840 )
10841 .unwrap();
10842
10843 backend_a
10844 .plugin_contexts
10845 .borrow()
10846 .get("plugin_a")
10847 .unwrap()
10848 .clone()
10849 .with(|ctx| {
10850 let global = ctx.globals();
10851 let a_value: String = global.get("_aValue").unwrap();
10852 assert_eq!(
10853 a_value, "from_plugin_a",
10854 "Plugin A should see its own value, not plugin B's"
10855 );
10856 });
10857
10858 backend_b
10860 .execute_js(
10861 r#"
10862 const editor = getEditor();
10863 globalThis._bValue = editor.getGlobalState("flag");
10864 "#,
10865 "plugin_b.js",
10866 )
10867 .unwrap();
10868
10869 backend_b
10870 .plugin_contexts
10871 .borrow()
10872 .get("plugin_b")
10873 .unwrap()
10874 .clone()
10875 .with(|ctx| {
10876 let global = ctx.globals();
10877 let b_value: String = global.get("_bValue").unwrap();
10878 assert_eq!(
10879 b_value, "from_plugin_b",
10880 "Plugin B should see its own value, not plugin A's"
10881 );
10882 });
10883 }
10884
10885 #[test]
10886 fn test_register_command_collision_different_plugins() {
10887 let (mut backend, _rx) = create_test_backend();
10888
10889 backend
10891 .execute_js(
10892 r#"
10893 const editor = getEditor();
10894 globalThis.handlerA = function() { };
10895 editor.registerCommand("My Command", "From A", "handlerA", null);
10896 "#,
10897 "plugin_a.js",
10898 )
10899 .unwrap();
10900
10901 let result = backend.execute_js(
10903 r#"
10904 const editor = getEditor();
10905 globalThis.handlerB = function() { };
10906 editor.registerCommand("My Command", "From B", "handlerB", null);
10907 "#,
10908 "plugin_b.js",
10909 );
10910
10911 assert!(
10912 result.is_err(),
10913 "Second plugin registering the same command name should fail"
10914 );
10915 let err_msg = result.unwrap_err().to_string();
10916 assert!(
10917 err_msg.contains("already registered"),
10918 "Error should mention collision: {}",
10919 err_msg
10920 );
10921 }
10922
10923 #[test]
10924 fn test_register_command_same_plugin_allowed() {
10925 let (mut backend, _rx) = create_test_backend();
10926
10927 backend
10929 .execute_js(
10930 r#"
10931 const editor = getEditor();
10932 globalThis.handler1 = function() { };
10933 editor.registerCommand("My Command", "Version 1", "handler1", null);
10934 globalThis.handler2 = function() { };
10935 editor.registerCommand("My Command", "Version 2", "handler2", null);
10936 "#,
10937 "plugin_a.js",
10938 )
10939 .unwrap();
10940 }
10941
10942 #[test]
10943 fn test_register_command_after_unregister() {
10944 let (mut backend, _rx) = create_test_backend();
10945
10946 backend
10948 .execute_js(
10949 r#"
10950 const editor = getEditor();
10951 globalThis.handlerA = function() { };
10952 editor.registerCommand("My Command", "From A", "handlerA", null);
10953 editor.unregisterCommand("My Command");
10954 "#,
10955 "plugin_a.js",
10956 )
10957 .unwrap();
10958
10959 backend
10961 .execute_js(
10962 r#"
10963 const editor = getEditor();
10964 globalThis.handlerB = function() { };
10965 editor.registerCommand("My Command", "From B", "handlerB", null);
10966 "#,
10967 "plugin_b.js",
10968 )
10969 .unwrap();
10970 }
10971
10972 #[test]
10973 fn test_register_command_collision_caught_in_try_catch() {
10974 let (mut backend, _rx) = create_test_backend();
10975
10976 backend
10978 .execute_js(
10979 r#"
10980 const editor = getEditor();
10981 globalThis.handlerA = function() { };
10982 editor.registerCommand("My Command", "From A", "handlerA", null);
10983 "#,
10984 "plugin_a.js",
10985 )
10986 .unwrap();
10987
10988 backend
10990 .execute_js(
10991 r#"
10992 const editor = getEditor();
10993 globalThis.handlerB = function() { };
10994 let caught = false;
10995 try {
10996 editor.registerCommand("My Command", "From B", "handlerB", null);
10997 } catch (e) {
10998 caught = true;
10999 }
11000 if (!caught) throw new Error("Expected collision error");
11001 "#,
11002 "plugin_b.js",
11003 )
11004 .unwrap();
11005 }
11006
11007 #[test]
11008 fn test_register_command_i18n_key_no_collision_across_plugins() {
11009 let (mut backend, _rx) = create_test_backend();
11010
11011 backend
11013 .execute_js(
11014 r#"
11015 const editor = getEditor();
11016 globalThis.handlerA = function() { };
11017 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
11018 "#,
11019 "plugin_a.js",
11020 )
11021 .unwrap();
11022
11023 backend
11026 .execute_js(
11027 r#"
11028 const editor = getEditor();
11029 globalThis.handlerB = function() { };
11030 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
11031 "#,
11032 "plugin_b.js",
11033 )
11034 .unwrap();
11035 }
11036
11037 #[test]
11038 fn test_register_command_non_i18n_still_collides() {
11039 let (mut backend, _rx) = create_test_backend();
11040
11041 backend
11043 .execute_js(
11044 r#"
11045 const editor = getEditor();
11046 globalThis.handlerA = function() { };
11047 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
11048 "#,
11049 "plugin_a.js",
11050 )
11051 .unwrap();
11052
11053 let result = backend.execute_js(
11055 r#"
11056 const editor = getEditor();
11057 globalThis.handlerB = function() { };
11058 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
11059 "#,
11060 "plugin_b.js",
11061 );
11062
11063 assert!(
11064 result.is_err(),
11065 "Non-%-prefixed names should still collide across plugins"
11066 );
11067 }
11068}