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
705#[derive(Debug, Clone)]
706pub struct PluginHandler {
707 pub plugin_name: String,
708 pub handler_name: String,
709}
710
711fn parse_animation_rect(
714 obj: &rquickjs::Object<'_>,
715) -> rquickjs::Result<fresh_core::api::AnimationRect> {
716 Ok(fresh_core::api::AnimationRect {
717 x: obj.get::<_, u16>("x").unwrap_or(0),
718 y: obj.get::<_, u16>("y").unwrap_or(0),
719 width: obj.get::<_, u16>("width").unwrap_or(0),
720 height: obj.get::<_, u16>("height").unwrap_or(0),
721 })
722}
723
724fn parse_animation_kind(
728 obj: &rquickjs::Object<'_>,
729) -> rquickjs::Result<fresh_core::api::PluginAnimationKind> {
730 use fresh_core::api::{PluginAnimationEdge, PluginAnimationKind};
731 let kind: String = obj.get::<_, String>("kind").unwrap_or_default();
732 match kind.as_str() {
733 "slideIn" | "" => {
734 let from_str: String = obj.get::<_, String>("from").unwrap_or_default();
735 let from = match from_str.as_str() {
736 "top" => PluginAnimationEdge::Top,
737 "left" => PluginAnimationEdge::Left,
738 "right" => PluginAnimationEdge::Right,
739 _ => PluginAnimationEdge::Bottom,
740 };
741 let duration_ms: u32 = obj.get::<_, u32>("durationMs").unwrap_or(300);
742 let delay_ms: u32 = obj.get::<_, u32>("delayMs").unwrap_or(0);
743 Ok(PluginAnimationKind::SlideIn {
744 from,
745 duration_ms,
746 delay_ms,
747 })
748 }
749 other => Err(rquickjs::Error::new_from_js_message(
750 "string",
751 "PluginAnimationKind",
752 format!("unknown animation kind: {}", other),
753 )),
754 }
755}
756
757#[derive(rquickjs::class::Trace, rquickjs::JsLifetime)]
760#[rquickjs::class]
761pub struct JsEditorApi {
762 #[qjs(skip_trace)]
763 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
764 #[qjs(skip_trace)]
765 command_sender: mpsc::Sender<PluginCommand>,
766 #[qjs(skip_trace)]
767 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
768 #[qjs(skip_trace)]
769 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
770 #[qjs(skip_trace)]
771 next_request_id: Rc<RefCell<u64>>,
772 #[qjs(skip_trace)]
773 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
774 #[qjs(skip_trace)]
775 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
776 #[qjs(skip_trace)]
777 plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
778 #[qjs(skip_trace)]
779 async_resource_owners: AsyncResourceOwners,
780 #[qjs(skip_trace)]
782 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
783 #[qjs(skip_trace)]
785 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
786 #[qjs(skip_trace)]
788 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
789 #[qjs(skip_trace)]
791 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
792 #[qjs(skip_trace)]
796 plugin_api_exports: PluginApiExports,
797 #[qjs(skip_trace)]
801 search_handles: SearchHandleRegistry,
802 pub plugin_name: String,
803}
804
805#[plugin_api_impl]
806#[rquickjs::methods(rename_all = "camelCase")]
807impl JsEditorApi {
808 pub fn api_version(&self) -> u32 {
813 2
814 }
815
816 pub fn plugin_name(&self) -> String {
820 self.plugin_name.clone()
821 }
822
823 #[plugin_api(ts_return = "boolean")]
833 pub fn export_plugin_api<'js>(
834 &self,
835 ctx: rquickjs::Ctx<'js>,
836 name: String,
837 api: rquickjs::Value<'js>,
838 ) -> rquickjs::Result<bool> {
839 if name.is_empty() {
840 let msg =
841 rquickjs::String::from_str(ctx.clone(), "exportPluginApi: name must be non-empty")?;
842 return Err(ctx.throw(msg.into_value()));
843 }
844 let obj = match api.as_object() {
845 Some(o) => o.clone(),
846 None => {
847 let msg = rquickjs::String::from_str(
848 ctx.clone(),
849 "exportPluginApi: api must be an object",
850 )?;
851 return Err(ctx.throw(msg.into_value()));
852 }
853 };
854 let persistent = rquickjs::Persistent::save(&ctx, obj);
855 self.plugin_api_exports
856 .borrow_mut()
857 .insert(name, (self.plugin_name.clone(), persistent));
858 Ok(true)
859 }
860
861 #[plugin_api(ts_return = "unknown | null")]
865 pub fn get_plugin_api<'js>(
866 &self,
867 ctx: rquickjs::Ctx<'js>,
868 name: String,
869 ) -> rquickjs::Result<rquickjs::Value<'js>> {
870 let persistent = self
871 .plugin_api_exports
872 .borrow()
873 .get(&name)
874 .map(|(_exporter, p)| p.clone());
875 match persistent {
876 Some(p) => {
877 let restored = p.restore(&ctx)?;
878 Ok(restored.into_value())
879 }
880 None => Ok(rquickjs::Value::new_null(ctx)),
881 }
882 }
883
884 pub fn get_active_buffer_id(&self) -> u32 {
886 self.state_snapshot
887 .read()
888 .map(|s| s.active_buffer_id.0 as u32)
889 .unwrap_or(0)
890 }
891
892 pub fn get_active_split_id(&self) -> u32 {
894 self.state_snapshot
895 .read()
896 .map(|s| s.active_split_id as u32)
897 .unwrap_or(0)
898 }
899
900 #[plugin_api(ts_return = "BufferInfo[]")]
902 pub fn list_buffers<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
903 let buffers: Vec<BufferInfo> = if let Ok(s) = self.state_snapshot.read() {
904 s.buffers.values().cloned().collect()
905 } else {
906 Vec::new()
907 };
908 rquickjs_serde::to_value(ctx, &buffers)
909 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
910 }
911
912 #[plugin_api(ts_return = "GrammarInfoSnapshot[]")]
914 pub fn list_grammars<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
915 let grammars: Vec<GrammarInfoSnapshot> = if let Ok(s) = self.state_snapshot.read() {
916 s.available_grammars.clone()
917 } else {
918 Vec::new()
919 };
920 rquickjs_serde::to_value(ctx, &grammars)
921 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
922 }
923
924 pub fn debug(&self, msg: String) {
927 tracing::debug!("Plugin: {}", msg);
928 }
929
930 pub fn info(&self, msg: String) {
931 tracing::info!("Plugin: {}", msg);
932 }
933
934 pub fn warn(&self, msg: String) {
935 tracing::warn!("Plugin: {}", msg);
936 }
937
938 pub fn error(&self, msg: String) {
939 tracing::error!("Plugin: {}", msg);
940 }
941
942 pub fn set_status(&self, msg: String) {
945 let _ = self
946 .command_sender
947 .send(PluginCommand::SetStatus { message: msg });
948 }
949
950 pub fn copy_to_clipboard(&self, text: String) {
953 let _ = self
954 .command_sender
955 .send(PluginCommand::SetClipboard { text });
956 }
957
958 pub fn set_clipboard(&self, text: String) {
959 let _ = self
960 .command_sender
961 .send(PluginCommand::SetClipboard { text });
962 }
963
964 pub fn get_keybinding_label(&self, action: String, mode: Option<String>) -> Option<String> {
969 if let Some(mode_name) = mode {
970 let key = format!("{}\0{}", action, mode_name);
971 if let Ok(snapshot) = self.state_snapshot.read() {
972 return snapshot.keybinding_labels.get(&key).cloned();
973 }
974 }
975 None
976 }
977
978 pub fn register_command<'js>(
989 &self,
990 ctx: rquickjs::Ctx<'js>,
991 name: String,
992 description: String,
993 handler_name: String,
994 #[plugin_api(ts_type = "string | null")] context: rquickjs::function::Opt<
995 rquickjs::Value<'js>,
996 >,
997 ) -> rquickjs::Result<bool> {
998 let plugin_name = self.plugin_name.clone();
1000 let context_str: Option<String> = context.0.and_then(|v| {
1002 if v.is_null() || v.is_undefined() {
1003 None
1004 } else {
1005 v.as_string().and_then(|s| s.to_string().ok())
1006 }
1007 });
1008
1009 tracing::debug!(
1010 "registerCommand: plugin='{}', name='{}', handler='{}'",
1011 plugin_name,
1012 name,
1013 handler_name
1014 );
1015
1016 let tracking_key = if name.starts_with('%') {
1020 format!("{}:{}", plugin_name, name)
1021 } else {
1022 name.clone()
1023 };
1024 {
1025 let names = self.registered_command_names.borrow();
1026 if let Some(existing_plugin) = names.get(&tracking_key) {
1027 if existing_plugin != &plugin_name {
1028 let msg = format!(
1029 "Command '{}' already registered by plugin '{}'",
1030 name, existing_plugin
1031 );
1032 tracing::warn!("registerCommand collision: {}", msg);
1033 return Err(
1034 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
1035 );
1036 }
1037 }
1039 }
1040
1041 self.registered_command_names
1043 .borrow_mut()
1044 .insert(tracking_key, plugin_name.clone());
1045
1046 self.registered_actions.borrow_mut().insert(
1048 handler_name.clone(),
1049 PluginHandler {
1050 plugin_name: self.plugin_name.clone(),
1051 handler_name: handler_name.clone(),
1052 },
1053 );
1054
1055 let command = Command {
1057 name: name.clone(),
1058 description,
1059 action_name: handler_name,
1060 plugin_name,
1061 custom_contexts: context_str.into_iter().collect(),
1062 };
1063
1064 Ok(self
1065 .command_sender
1066 .send(PluginCommand::RegisterCommand { command })
1067 .is_ok())
1068 }
1069
1070 pub fn unregister_command(&self, name: String) -> bool {
1072 let tracking_key = if name.starts_with('%') {
1075 format!("{}:{}", self.plugin_name, name)
1076 } else {
1077 name.clone()
1078 };
1079 self.registered_command_names
1080 .borrow_mut()
1081 .remove(&tracking_key);
1082 self.command_sender
1083 .send(PluginCommand::UnregisterCommand { name })
1084 .is_ok()
1085 }
1086
1087 pub fn set_context(&self, name: String, active: bool) -> bool {
1089 if active {
1091 self.plugin_tracked_state
1092 .borrow_mut()
1093 .entry(self.plugin_name.clone())
1094 .or_default()
1095 .contexts_set
1096 .push(name.clone());
1097 }
1098 self.command_sender
1099 .send(PluginCommand::SetContext { name, active })
1100 .is_ok()
1101 }
1102
1103 pub fn execute_action(&self, action_name: String) -> bool {
1105 self.command_sender
1106 .send(PluginCommand::ExecuteAction { action_name })
1107 .is_ok()
1108 }
1109
1110 pub fn t<'js>(
1115 &self,
1116 _ctx: rquickjs::Ctx<'js>,
1117 key: String,
1118 args: rquickjs::function::Rest<Value<'js>>,
1119 ) -> String {
1120 let plugin_name = self.plugin_name.clone();
1122 let args_map: HashMap<String, String> = if let Some(first_arg) = args.0.first() {
1124 if let Some(obj) = first_arg.as_object() {
1125 let mut map = HashMap::new();
1126 for k in obj.keys::<String>().flatten() {
1127 if let Ok(v) = obj.get::<_, String>(&k) {
1128 map.insert(k, v);
1129 }
1130 }
1131 map
1132 } else {
1133 HashMap::new()
1134 }
1135 } else {
1136 HashMap::new()
1137 };
1138 let res = self.services.translate(&plugin_name, &key, &args_map);
1139
1140 tracing::info!(
1141 "Translating: key={}, plugin={}, args={:?} => res='{}'",
1142 key,
1143 plugin_name,
1144 args_map,
1145 res
1146 );
1147 res
1148 }
1149
1150 pub fn get_cursor_position(&self) -> u32 {
1154 self.state_snapshot
1155 .read()
1156 .ok()
1157 .and_then(|s| s.primary_cursor.as_ref().map(|c| c.position as u32))
1158 .unwrap_or(0)
1159 }
1160
1161 pub fn get_buffer_path(&self, buffer_id: u32) -> String {
1163 if let Ok(s) = self.state_snapshot.read() {
1164 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1165 if let Some(p) = &b.path {
1166 return p.to_string_lossy().to_string();
1167 }
1168 }
1169 }
1170 String::new()
1171 }
1172
1173 pub fn get_buffer_length(&self, buffer_id: u32) -> u32 {
1175 if let Ok(s) = self.state_snapshot.read() {
1176 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1177 return b.length as u32;
1178 }
1179 }
1180 0
1181 }
1182
1183 pub fn is_buffer_modified(&self, buffer_id: u32) -> bool {
1185 if let Ok(s) = self.state_snapshot.read() {
1186 if let Some(b) = s.buffers.get(&BufferId(buffer_id as usize)) {
1187 return b.modified;
1188 }
1189 }
1190 false
1191 }
1192
1193 pub fn save_buffer_to_path(&self, buffer_id: u32, path: String) -> bool {
1196 self.command_sender
1197 .send(PluginCommand::SaveBufferToPath {
1198 buffer_id: BufferId(buffer_id as usize),
1199 path: std::path::PathBuf::from(path),
1200 })
1201 .is_ok()
1202 }
1203
1204 #[plugin_api(ts_return = "BufferInfo | null")]
1206 pub fn get_buffer_info<'js>(
1207 &self,
1208 ctx: rquickjs::Ctx<'js>,
1209 buffer_id: u32,
1210 ) -> rquickjs::Result<Value<'js>> {
1211 let info = if let Ok(s) = self.state_snapshot.read() {
1212 s.buffers.get(&BufferId(buffer_id as usize)).cloned()
1213 } else {
1214 None
1215 };
1216 rquickjs_serde::to_value(ctx, &info)
1217 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1218 }
1219
1220 #[plugin_api(ts_return = "CursorInfo | null")]
1222 pub fn get_primary_cursor<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1223 let cursor = if let Ok(s) = self.state_snapshot.read() {
1224 s.primary_cursor.clone()
1225 } else {
1226 None
1227 };
1228 rquickjs_serde::to_value(ctx, &cursor)
1229 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1230 }
1231
1232 #[plugin_api(ts_return = "CursorInfo[]")]
1234 pub fn get_all_cursors<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1235 let cursors = if let Ok(s) = self.state_snapshot.read() {
1236 s.all_cursors.clone()
1237 } else {
1238 Vec::new()
1239 };
1240 rquickjs_serde::to_value(ctx, &cursors)
1241 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1242 }
1243
1244 #[plugin_api(ts_return = "number[]")]
1246 pub fn get_all_cursor_positions<'js>(
1247 &self,
1248 ctx: rquickjs::Ctx<'js>,
1249 ) -> rquickjs::Result<Value<'js>> {
1250 let positions: Vec<u32> = if let Ok(s) = self.state_snapshot.read() {
1251 s.all_cursors.iter().map(|c| c.position as u32).collect()
1252 } else {
1253 Vec::new()
1254 };
1255 rquickjs_serde::to_value(ctx, &positions)
1256 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1257 }
1258
1259 #[plugin_api(ts_return = "ViewportInfo | null")]
1261 pub fn get_viewport<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1262 let viewport = if let Ok(s) = self.state_snapshot.read() {
1263 s.viewport.clone()
1264 } else {
1265 None
1266 };
1267 rquickjs_serde::to_value(ctx, &viewport)
1268 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1269 }
1270
1271 #[plugin_api(ts_return = "SplitSnapshot[]")]
1278 pub fn list_splits<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1279 let splits = if let Ok(s) = self.state_snapshot.read() {
1280 s.splits.clone()
1281 } else {
1282 Vec::new()
1283 };
1284 rquickjs_serde::to_value(ctx, &splits)
1285 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1286 }
1287
1288 pub fn get_cursor_line(&self) -> u32 {
1290 0
1294 }
1295
1296 #[plugin_api(
1299 async_promise,
1300 js_name = "getLineStartPosition",
1301 ts_return = "number | null"
1302 )]
1303 #[qjs(rename = "_getLineStartPositionStart")]
1304 pub fn get_line_start_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1305 let id = self.alloc_request_id();
1306 let _ = self
1308 .command_sender
1309 .send(PluginCommand::GetLineStartPosition {
1310 buffer_id: BufferId(0),
1311 line,
1312 request_id: id,
1313 });
1314 id
1315 }
1316
1317 #[plugin_api(
1321 async_promise,
1322 js_name = "getLineEndPosition",
1323 ts_return = "number | null"
1324 )]
1325 #[qjs(rename = "_getLineEndPositionStart")]
1326 pub fn get_line_end_position_start(&self, _ctx: rquickjs::Ctx<'_>, line: u32) -> u64 {
1327 let id = self.alloc_request_id();
1328 let _ = self.command_sender.send(PluginCommand::GetLineEndPosition {
1330 buffer_id: BufferId(0),
1331 line,
1332 request_id: id,
1333 });
1334 id
1335 }
1336
1337 #[plugin_api(
1340 async_promise,
1341 js_name = "getBufferLineCount",
1342 ts_return = "number | null"
1343 )]
1344 #[qjs(rename = "_getBufferLineCountStart")]
1345 pub fn get_buffer_line_count_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
1346 let id = self.alloc_request_id();
1347 let _ = self.command_sender.send(PluginCommand::GetBufferLineCount {
1349 buffer_id: BufferId(0),
1350 request_id: id,
1351 });
1352 id
1353 }
1354
1355 pub fn scroll_to_line_center(&self, split_id: u32, buffer_id: u32, line: u32) -> bool {
1358 self.command_sender
1359 .send(PluginCommand::ScrollToLineCenter {
1360 split_id: SplitId(split_id as usize),
1361 buffer_id: BufferId(buffer_id as usize),
1362 line: line as usize,
1363 })
1364 .is_ok()
1365 }
1366
1367 pub fn scroll_buffer_to_line(&self, buffer_id: u32, line: u32) -> bool {
1376 self.command_sender
1377 .send(PluginCommand::ScrollBufferToLine {
1378 buffer_id: BufferId(buffer_id as usize),
1379 line: line as usize,
1380 })
1381 .is_ok()
1382 }
1383
1384 pub fn find_buffer_by_path(&self, path: String) -> u32 {
1386 let path_buf = std::path::PathBuf::from(&path);
1387 if let Ok(s) = self.state_snapshot.read() {
1388 for (id, info) in &s.buffers {
1389 if let Some(buf_path) = &info.path {
1390 if buf_path == &path_buf {
1391 return id.0 as u32;
1392 }
1393 }
1394 }
1395 }
1396 0
1397 }
1398
1399 #[plugin_api(ts_return = "BufferSavedDiff | null")]
1401 pub fn get_buffer_saved_diff<'js>(
1402 &self,
1403 ctx: rquickjs::Ctx<'js>,
1404 buffer_id: u32,
1405 ) -> rquickjs::Result<Value<'js>> {
1406 let diff = if let Ok(s) = self.state_snapshot.read() {
1407 s.buffer_saved_diffs
1408 .get(&BufferId(buffer_id as usize))
1409 .cloned()
1410 } else {
1411 None
1412 };
1413 rquickjs_serde::to_value(ctx, &diff)
1414 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1415 }
1416
1417 pub fn insert_text(&self, buffer_id: u32, position: u32, text: String) -> bool {
1421 self.command_sender
1422 .send(PluginCommand::InsertText {
1423 buffer_id: BufferId(buffer_id as usize),
1424 position: position as usize,
1425 text,
1426 })
1427 .is_ok()
1428 }
1429
1430 pub fn delete_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
1432 self.command_sender
1433 .send(PluginCommand::DeleteRange {
1434 buffer_id: BufferId(buffer_id as usize),
1435 range: (start as usize)..(end as usize),
1436 })
1437 .is_ok()
1438 }
1439
1440 pub fn insert_at_cursor(&self, text: String) -> bool {
1442 self.command_sender
1443 .send(PluginCommand::InsertAtCursor { text })
1444 .is_ok()
1445 }
1446
1447 pub fn open_file(&self, path: String, line: Option<u32>, column: Option<u32>) -> bool {
1451 self.command_sender
1452 .send(PluginCommand::OpenFileAtLocation {
1453 path: PathBuf::from(path),
1454 line: line.map(|l| l as usize),
1455 column: column.map(|c| c as usize),
1456 })
1457 .is_ok()
1458 }
1459
1460 pub fn open_file_in_background(
1468 &self,
1469 path: String,
1470 window_id: rquickjs::function::Opt<u64>,
1471 ) -> bool {
1472 self.command_sender
1473 .send(PluginCommand::OpenFileInBackground {
1474 path: PathBuf::from(path),
1475 window_id: window_id.0.map(fresh_core::WindowId),
1476 })
1477 .is_ok()
1478 }
1479
1480 pub fn open_file_in_split(&self, split_id: u32, path: String, line: u32, column: u32) -> bool {
1482 self.command_sender
1483 .send(PluginCommand::OpenFileInSplit {
1484 split_id: split_id as usize,
1485 path: PathBuf::from(path),
1486 line: Some(line as usize),
1487 column: Some(column as usize),
1488 })
1489 .is_ok()
1490 }
1491
1492 pub fn show_buffer(&self, buffer_id: u32) -> bool {
1494 self.command_sender
1495 .send(PluginCommand::ShowBuffer {
1496 buffer_id: BufferId(buffer_id as usize),
1497 })
1498 .is_ok()
1499 }
1500
1501 pub fn close_buffer(&self, buffer_id: u32) -> bool {
1503 self.command_sender
1504 .send(PluginCommand::CloseBuffer {
1505 buffer_id: BufferId(buffer_id as usize),
1506 })
1507 .is_ok()
1508 }
1509
1510 #[plugin_api(skip)]
1516 #[qjs(skip)]
1517 fn alloc_request_id(&self) -> u64 {
1518 let mut id_ref = self.next_request_id.borrow_mut();
1519 let id = *id_ref;
1520 *id_ref += 1;
1521 self.callback_contexts
1522 .borrow_mut()
1523 .insert(id, self.plugin_name.clone());
1524 id
1525 }
1526
1527 #[plugin_api(skip)]
1531 #[qjs(skip)]
1532 fn alloc_animation_id(&self) -> u64 {
1533 let mut id_ref = self.next_request_id.borrow_mut();
1534 let id = *id_ref;
1535 *id_ref += 1;
1536 id
1537 }
1538
1539 pub fn animate_area<'js>(
1542 &self,
1543 #[plugin_api(ts_type = "AnimationRect")] rect: rquickjs::Object<'js>,
1544 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1545 ) -> rquickjs::Result<u64> {
1546 let rect = parse_animation_rect(&rect)?;
1547 let kind = parse_animation_kind(&kind)?;
1548 let id = self.alloc_animation_id();
1549 let _ = self
1550 .command_sender
1551 .send(PluginCommand::StartAnimationArea { id, rect, kind });
1552 Ok(id)
1553 }
1554
1555 pub fn animate_virtual_buffer<'js>(
1558 &self,
1559 buffer_id: u32,
1560 #[plugin_api(ts_type = "PluginAnimationKind")] kind: rquickjs::Object<'js>,
1561 ) -> rquickjs::Result<u64> {
1562 let kind = parse_animation_kind(&kind)?;
1563 let id = self.alloc_animation_id();
1564 let _ = self
1565 .command_sender
1566 .send(PluginCommand::StartAnimationVirtualBuffer {
1567 id,
1568 buffer_id: BufferId(buffer_id as usize),
1569 kind,
1570 });
1571 Ok(id)
1572 }
1573
1574 pub fn cancel_animation(&self, id: u64) -> bool {
1577 self.command_sender
1578 .send(PluginCommand::CancelAnimation { id })
1579 .is_ok()
1580 }
1581
1582 pub fn on<'js>(&self, _ctx: rquickjs::Ctx<'js>, event_name: String, handler_name: String) {
1586 if event_name == "lines_changed" {
1590 let _ = self.command_sender.send(PluginCommand::RefreshAllLines);
1591 }
1592 self.event_handlers
1593 .borrow_mut()
1594 .entry(event_name)
1595 .or_default()
1596 .push(PluginHandler {
1597 plugin_name: self.plugin_name.clone(),
1598 handler_name,
1599 });
1600 }
1601
1602 pub fn off(&self, event_name: String, handler_name: String) {
1604 if let Some(list) = self.event_handlers.borrow_mut().get_mut(&event_name) {
1605 list.retain(|h| h.handler_name != handler_name);
1606 }
1607 }
1608
1609 pub fn get_env(&self, name: String) -> Option<String> {
1613 std::env::var(&name).ok()
1614 }
1615
1616 pub fn get_cwd(&self) -> String {
1618 self.state_snapshot
1619 .read()
1620 .map(|s| s.working_dir.to_string_lossy().to_string())
1621 .unwrap_or_else(|_| ".".to_string())
1622 }
1623
1624 pub fn get_authority_label(&self) -> String {
1633 self.state_snapshot
1634 .read()
1635 .map(|s| s.authority_label.clone())
1636 .unwrap_or_default()
1637 }
1638
1639 pub fn path_join(&self, parts: rquickjs::function::Rest<String>) -> String {
1651 let mut result_parts: Vec<String> = Vec::new();
1652 let mut leading_slashes: u8 = 0;
1654
1655 for part in &parts.0 {
1656 let normalized = part.replace('\\', "/");
1658
1659 let is_absolute = normalized.starts_with('/')
1661 || (normalized.len() >= 2
1662 && normalized
1663 .chars()
1664 .next()
1665 .map(|c| c.is_ascii_alphabetic())
1666 .unwrap_or(false)
1667 && normalized.chars().nth(1) == Some(':'));
1668
1669 if is_absolute {
1670 result_parts.clear();
1672 leading_slashes = normalized.chars().take_while(|&c| c == '/').count().min(2) as u8;
1676 }
1677
1678 for segment in normalized.split('/') {
1680 if !segment.is_empty() && segment != "." {
1681 if segment == ".." {
1682 result_parts.pop();
1683 } else {
1684 result_parts.push(segment.to_string());
1685 }
1686 }
1687 }
1688 }
1689
1690 let joined = result_parts.join("/");
1692 let prefix = match leading_slashes {
1693 0 => "",
1694 1 => "/",
1695 _ => "//",
1696 };
1697
1698 if leading_slashes > 0 {
1699 format!("{}{}", prefix, joined)
1700 } else {
1701 joined
1702 }
1703 }
1704
1705 pub fn path_dirname(&self, path: String) -> String {
1707 Path::new(&path)
1708 .parent()
1709 .map(|p| p.to_string_lossy().to_string())
1710 .unwrap_or_default()
1711 }
1712
1713 pub fn path_basename(&self, path: String) -> String {
1715 Path::new(&path)
1716 .file_name()
1717 .map(|s| s.to_string_lossy().to_string())
1718 .unwrap_or_default()
1719 }
1720
1721 pub fn path_extname(&self, path: String) -> String {
1723 Path::new(&path)
1724 .extension()
1725 .map(|s| format!(".{}", s.to_string_lossy()))
1726 .unwrap_or_default()
1727 }
1728
1729 pub fn path_is_absolute(&self, path: String) -> bool {
1731 Path::new(&path).is_absolute()
1732 }
1733
1734 pub fn file_uri_to_path(&self, uri: String) -> String {
1738 fresh_core::file_uri::file_uri_to_path(&uri)
1739 .map(|p| p.to_string_lossy().to_string())
1740 .unwrap_or_default()
1741 }
1742
1743 pub fn path_to_file_uri(&self, path: String) -> String {
1747 fresh_core::file_uri::path_to_file_uri(std::path::Path::new(&path)).unwrap_or_default()
1748 }
1749
1750 pub fn utf8_byte_length(&self, text: String) -> u32 {
1758 text.len() as u32
1759 }
1760
1761 pub fn file_exists(&self, path: String) -> bool {
1765 Path::new(&path).exists()
1766 }
1767
1768 pub fn read_file(&self, path: String) -> Option<String> {
1770 std::fs::read_to_string(&path).ok()
1771 }
1772
1773 pub fn write_file(&self, path: String, content: String) -> bool {
1775 let p = Path::new(&path);
1776 if let Some(parent) = p.parent() {
1777 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1778 return false;
1779 }
1780 }
1781 std::fs::write(p, content).is_ok()
1782 }
1783
1784 #[plugin_api(ts_return = "DirEntry[]")]
1786 pub fn read_dir<'js>(
1787 &self,
1788 ctx: rquickjs::Ctx<'js>,
1789 path: String,
1790 ) -> rquickjs::Result<Value<'js>> {
1791 use fresh_core::api::DirEntry;
1792
1793 let entries: Vec<DirEntry> = match std::fs::read_dir(&path) {
1794 Ok(entries) => entries
1795 .filter_map(|e| e.ok())
1796 .map(|entry| {
1797 let file_type = entry.file_type().ok();
1798 DirEntry {
1799 name: entry.file_name().to_string_lossy().to_string(),
1800 is_file: file_type.map(|ft| ft.is_file()).unwrap_or(false),
1801 is_dir: file_type.map(|ft| ft.is_dir()).unwrap_or(false),
1802 }
1803 })
1804 .collect(),
1805 Err(e) => {
1806 tracing::warn!("readDir failed for '{}': {}", path, e);
1807 Vec::new()
1808 }
1809 };
1810
1811 rquickjs_serde::to_value(ctx, &entries)
1812 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1813 }
1814
1815 pub fn create_dir(&self, path: String) -> bool {
1818 let p = Path::new(&path);
1819 if p.is_dir() {
1820 return true;
1821 }
1822 std::fs::create_dir_all(p).is_ok()
1823 }
1824
1825 pub fn remove_path(&self, path: String) -> bool {
1829 let target = match Path::new(&path).canonicalize() {
1830 Ok(p) => p,
1831 Err(_) => return false, };
1833
1834 let temp_dir = std::env::temp_dir()
1840 .canonicalize()
1841 .unwrap_or_else(|_| std::env::temp_dir());
1842 let config_dir = self
1843 .services
1844 .config_dir()
1845 .canonicalize()
1846 .unwrap_or_else(|_| self.services.config_dir());
1847
1848 let allowed = target.starts_with(&temp_dir) || target.starts_with(&config_dir);
1850 if !allowed {
1851 tracing::warn!(
1852 "removePath refused: {:?} is not under temp dir ({:?}) or config dir ({:?})",
1853 target,
1854 temp_dir,
1855 config_dir
1856 );
1857 return false;
1858 }
1859
1860 if target == temp_dir || target == config_dir {
1862 tracing::warn!(
1863 "removePath refused: cannot remove root directory {:?}",
1864 target
1865 );
1866 return false;
1867 }
1868
1869 match trash::delete(&target) {
1870 Ok(()) => true,
1871 Err(e) => {
1872 tracing::warn!("removePath trash failed for {:?}: {}", target, e);
1873 false
1874 }
1875 }
1876 }
1877
1878 pub fn rename_path(&self, from: String, to: String) -> bool {
1881 if std::fs::rename(&from, &to).is_ok() {
1883 return true;
1884 }
1885 let from_path = Path::new(&from);
1887 let copied = if from_path.is_dir() {
1888 copy_dir_recursive(from_path, Path::new(&to)).is_ok()
1889 } else {
1890 std::fs::copy(&from, &to).is_ok()
1891 };
1892 if copied {
1893 return trash::delete(from_path).is_ok();
1894 }
1895 false
1896 }
1897
1898 pub fn copy_path(&self, from: String, to: String) -> bool {
1901 let from_path = Path::new(&from);
1902 let to_path = Path::new(&to);
1903 if from_path.is_dir() {
1904 copy_dir_recursive(from_path, to_path).is_ok()
1905 } else {
1906 if let Some(parent) = to_path.parent() {
1908 if !parent.exists() && std::fs::create_dir_all(parent).is_err() {
1909 return false;
1910 }
1911 }
1912 std::fs::copy(from_path, to_path).is_ok()
1913 }
1914 }
1915
1916 pub fn get_temp_dir(&self) -> String {
1918 std::env::temp_dir().to_string_lossy().to_string()
1919 }
1920
1921 #[plugin_api(ts_return = "unknown")]
1932 pub fn parse_jsonc<'js>(
1933 &self,
1934 ctx: rquickjs::Ctx<'js>,
1935 text: String,
1936 ) -> rquickjs::Result<Value<'js>> {
1937 let value: serde_json::Value =
1938 jsonc_parser::parse_to_serde_value(&text, &Default::default()).map_err(|e| {
1939 rquickjs::Error::new_from_js_message("parseJsonc", "", &e.to_string())
1940 })?;
1941 rquickjs_serde::to_value(ctx, &value)
1942 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1943 }
1944
1945 pub fn get_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1954 let config = self
1955 .state_snapshot
1956 .read()
1957 .map(|s| std::sync::Arc::clone(&s.config))
1958 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1959
1960 rquickjs_serde::to_value(ctx, &*config)
1961 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1962 }
1963
1964 pub fn get_user_config<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
1966 let config = self
1967 .state_snapshot
1968 .read()
1969 .map(|s| std::sync::Arc::clone(&s.user_config))
1970 .unwrap_or_else(|_| std::sync::Arc::new(serde_json::json!({})));
1971
1972 rquickjs_serde::to_value(ctx, &*config)
1973 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
1974 }
1975
1976 pub fn reload_config(&self) {
1978 let _ = self.command_sender.send(PluginCommand::ReloadConfig);
1979 }
1980
1981 pub fn set_setting<'js>(
1994 &self,
1995 _ctx: rquickjs::Ctx<'js>,
1996 path: String,
1997 value: Value<'js>,
1998 ) -> rquickjs::Result<bool> {
1999 let json: serde_json::Value = rquickjs_serde::from_value(value)
2000 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))?;
2001 Ok(self
2002 .command_sender
2003 .send(PluginCommand::SetSetting {
2004 plugin_name: self.plugin_name.clone(),
2005 path,
2006 value: json,
2007 })
2008 .is_ok())
2009 }
2010
2011 pub fn reload_themes(&self) {
2014 let _ = self
2015 .command_sender
2016 .send(PluginCommand::ReloadThemes { apply_theme: None });
2017 }
2018
2019 pub fn reload_and_apply_theme(&self, theme_name: String) {
2021 let _ = self.command_sender.send(PluginCommand::ReloadThemes {
2022 apply_theme: Some(theme_name),
2023 });
2024 }
2025
2026 pub fn register_grammar<'js>(
2029 &self,
2030 ctx: rquickjs::Ctx<'js>,
2031 language: String,
2032 grammar_path: String,
2033 extensions: Vec<String>,
2034 ) -> rquickjs::Result<bool> {
2035 {
2037 let langs = self.registered_grammar_languages.borrow();
2038 if let Some(existing_plugin) = langs.get(&language) {
2039 if existing_plugin != &self.plugin_name {
2040 let msg = format!(
2041 "Grammar for language '{}' already registered by plugin '{}'",
2042 language, existing_plugin
2043 );
2044 tracing::warn!("registerGrammar collision: {}", msg);
2045 return Err(
2046 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2047 );
2048 }
2049 }
2050 }
2051 self.registered_grammar_languages
2052 .borrow_mut()
2053 .insert(language.clone(), self.plugin_name.clone());
2054
2055 Ok(self
2056 .command_sender
2057 .send(PluginCommand::RegisterGrammar {
2058 language,
2059 grammar_path,
2060 extensions,
2061 })
2062 .is_ok())
2063 }
2064
2065 pub fn register_language_config<'js>(
2067 &self,
2068 ctx: rquickjs::Ctx<'js>,
2069 language: String,
2070 config: LanguagePackConfig,
2071 ) -> rquickjs::Result<bool> {
2072 {
2074 let langs = self.registered_language_configs.borrow();
2075 if let Some(existing_plugin) = langs.get(&language) {
2076 if existing_plugin != &self.plugin_name {
2077 let msg = format!(
2078 "Language config for '{}' already registered by plugin '{}'",
2079 language, existing_plugin
2080 );
2081 tracing::warn!("registerLanguageConfig collision: {}", msg);
2082 return Err(
2083 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2084 );
2085 }
2086 }
2087 }
2088 self.registered_language_configs
2089 .borrow_mut()
2090 .insert(language.clone(), self.plugin_name.clone());
2091
2092 Ok(self
2093 .command_sender
2094 .send(PluginCommand::RegisterLanguageConfig { language, config })
2095 .is_ok())
2096 }
2097
2098 pub fn register_lsp_server<'js>(
2100 &self,
2101 ctx: rquickjs::Ctx<'js>,
2102 language: String,
2103 config: LspServerPackConfig,
2104 ) -> rquickjs::Result<bool> {
2105 {
2107 let langs = self.registered_lsp_servers.borrow();
2108 if let Some(existing_plugin) = langs.get(&language) {
2109 if existing_plugin != &self.plugin_name {
2110 let msg = format!(
2111 "LSP server for language '{}' already registered by plugin '{}'",
2112 language, existing_plugin
2113 );
2114 tracing::warn!("registerLspServer collision: {}", msg);
2115 return Err(
2116 ctx.throw(rquickjs::String::from_str(ctx.clone(), &msg)?.into_value())
2117 );
2118 }
2119 }
2120 }
2121 self.registered_lsp_servers
2122 .borrow_mut()
2123 .insert(language.clone(), self.plugin_name.clone());
2124
2125 Ok(self
2126 .command_sender
2127 .send(PluginCommand::RegisterLspServer { language, config })
2128 .is_ok())
2129 }
2130
2131 #[plugin_api(async_promise, js_name = "reloadGrammars", ts_return = "void")]
2135 #[qjs(rename = "_reloadGrammarsStart")]
2136 pub fn reload_grammars_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
2137 let id = self.alloc_request_id();
2138 let _ = self.command_sender.send(PluginCommand::ReloadGrammars {
2139 callback_id: fresh_core::api::JsCallbackId::new(id),
2140 });
2141 id
2142 }
2143
2144 pub fn get_plugin_dir(&self) -> String {
2147 self.services
2148 .plugins_dir()
2149 .join("packages")
2150 .join(&self.plugin_name)
2151 .to_string_lossy()
2152 .to_string()
2153 }
2154
2155 pub fn get_config_dir(&self) -> String {
2157 self.services.config_dir().to_string_lossy().to_string()
2158 }
2159
2160 pub fn get_data_dir(&self) -> String {
2164 self.services.data_dir().to_string_lossy().to_string()
2165 }
2166
2167 pub fn get_themes_dir(&self) -> String {
2169 self.services
2170 .config_dir()
2171 .join("themes")
2172 .to_string_lossy()
2173 .to_string()
2174 }
2175
2176 pub fn apply_theme(&self, theme_name: String) -> bool {
2178 self.command_sender
2179 .send(PluginCommand::ApplyTheme { theme_name })
2180 .is_ok()
2181 }
2182
2183 pub fn override_theme_colors<'js>(
2192 &self,
2193 _ctx: rquickjs::Ctx<'js>,
2194 overrides: Value<'js>,
2195 ) -> rquickjs::Result<bool> {
2196 let json: serde_json::Value = rquickjs_serde::from_value(overrides)
2202 .map_err(|e| rquickjs::Error::new_from_js_message("deserialize", "", &e.to_string()))?;
2203 let Some(obj) = json.as_object() else {
2204 return Err(rquickjs::Error::new_from_js_message(
2205 "type",
2206 "",
2207 "overrideThemeColors expects an object of \"key\": [r, g, b]",
2208 ));
2209 };
2210 let to_u8 = |n: &serde_json::Value| -> Option<u8> {
2211 n.as_i64()
2212 .or_else(|| n.as_f64().map(|f| f as i64))
2213 .map(|v| v.clamp(0, 255) as u8)
2214 };
2215 let mut clamped: std::collections::HashMap<String, [u8; 3]> =
2216 std::collections::HashMap::with_capacity(obj.len());
2217 for (key, value) in obj {
2218 let Some(arr) = value.as_array() else {
2219 continue;
2220 };
2221 if arr.len() != 3 {
2222 continue;
2223 }
2224 let Some(r) = to_u8(&arr[0]) else { continue };
2225 let Some(g) = to_u8(&arr[1]) else { continue };
2226 let Some(b) = to_u8(&arr[2]) else { continue };
2227 clamped.insert(key.clone(), [r, g, b]);
2228 }
2229 Ok(self
2230 .command_sender
2231 .send(PluginCommand::OverrideThemeColors { overrides: clamped })
2232 .is_ok())
2233 }
2234
2235 pub fn get_theme_schema<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2237 let schema = self.services.get_theme_schema();
2238 rquickjs_serde::to_value(ctx, &schema)
2239 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2240 }
2241
2242 pub fn get_builtin_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2244 let themes = self.services.get_builtin_themes();
2245 rquickjs_serde::to_value(ctx, &themes)
2246 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2247 }
2248
2249 pub fn get_all_themes<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
2252 let themes = self.services.get_all_themes();
2253 rquickjs_serde::to_value(ctx, &themes)
2254 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2255 }
2256
2257 #[qjs(rename = "_deleteThemeSync")]
2259 pub fn delete_theme_sync(&self, name: String) -> bool {
2260 let themes_dir = self.services.config_dir().join("themes");
2262 let theme_path = themes_dir.join(format!("{}.json", name));
2263
2264 if let Ok(canonical) = theme_path.canonicalize() {
2266 if let Ok(themes_canonical) = themes_dir.canonicalize() {
2267 if canonical.starts_with(&themes_canonical) {
2268 return std::fs::remove_file(&canonical).is_ok();
2269 }
2270 }
2271 }
2272 false
2273 }
2274
2275 pub fn delete_theme(&self, name: String) -> bool {
2277 self.delete_theme_sync(name)
2278 }
2279
2280 pub fn get_theme_data<'js>(
2282 &self,
2283 ctx: rquickjs::Ctx<'js>,
2284 name: String,
2285 ) -> rquickjs::Result<Value<'js>> {
2286 match self.services.get_theme_data(&name) {
2287 Some(data) => rquickjs_serde::to_value(ctx, &data)
2288 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string())),
2289 None => Ok(Value::new_null(ctx)),
2290 }
2291 }
2292
2293 pub fn save_theme_file(&self, name: String, content: String) -> rquickjs::Result<String> {
2295 self.services
2296 .save_theme_file(&name, &content)
2297 .map_err(|e| rquickjs::Error::new_from_js_message("io", "", &e))
2298 }
2299
2300 pub fn theme_file_exists(&self, name: String) -> bool {
2302 self.services.theme_file_exists(&name)
2303 }
2304
2305 pub fn file_stat<'js>(
2309 &self,
2310 ctx: rquickjs::Ctx<'js>,
2311 path: String,
2312 ) -> rquickjs::Result<Value<'js>> {
2313 let metadata = std::fs::metadata(&path).ok();
2314 let stat = metadata.map(|m| {
2315 serde_json::json!({
2316 "isFile": m.is_file(),
2317 "isDir": m.is_dir(),
2318 "size": m.len(),
2319 "readonly": m.permissions().readonly(),
2320 })
2321 });
2322 rquickjs_serde::to_value(ctx, &stat)
2323 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
2324 }
2325
2326 pub fn is_process_running(&self, _process_id: u64) -> bool {
2330 false
2333 }
2334
2335 pub fn kill_process(&self, process_id: u64) -> bool {
2337 self.command_sender
2338 .send(PluginCommand::KillBackgroundProcess { process_id })
2339 .is_ok()
2340 }
2341
2342 pub fn plugin_translate<'js>(
2346 &self,
2347 _ctx: rquickjs::Ctx<'js>,
2348 plugin_name: String,
2349 key: String,
2350 args: rquickjs::function::Opt<rquickjs::Object<'js>>,
2351 ) -> String {
2352 let args_map: HashMap<String, String> = args
2353 .0
2354 .map(|obj| {
2355 let mut map = HashMap::new();
2356 for (k, v) in obj.props::<String, String>().flatten() {
2357 map.insert(k, v);
2358 }
2359 map
2360 })
2361 .unwrap_or_default();
2362
2363 self.services.translate(&plugin_name, &key, &args_map)
2364 }
2365
2366 #[plugin_api(async_promise, js_name = "createCompositeBuffer", ts_return = "number")]
2373 #[qjs(rename = "_createCompositeBufferStart")]
2374 pub fn create_composite_buffer_start(&self, opts: CreateCompositeBufferOptions) -> u64 {
2375 let id = self.alloc_request_id();
2376
2377 if let Ok(mut owners) = self.async_resource_owners.lock() {
2379 owners.insert(id, self.plugin_name.clone());
2380 }
2381 let _ = self
2382 .command_sender
2383 .send(PluginCommand::CreateCompositeBuffer {
2384 name: opts.name,
2385 mode: opts.mode,
2386 layout: opts.layout,
2387 sources: opts.sources,
2388 hunks: opts.hunks,
2389 initial_focus_hunk: opts.initial_focus_hunk,
2390 request_id: Some(id),
2391 });
2392
2393 id
2394 }
2395
2396 pub fn update_composite_alignment(&self, buffer_id: u32, hunks: Vec<CompositeHunk>) -> bool {
2400 self.command_sender
2401 .send(PluginCommand::UpdateCompositeAlignment {
2402 buffer_id: BufferId(buffer_id as usize),
2403 hunks,
2404 })
2405 .is_ok()
2406 }
2407
2408 pub fn close_composite_buffer(&self, buffer_id: u32) -> bool {
2410 self.command_sender
2411 .send(PluginCommand::CloseCompositeBuffer {
2412 buffer_id: BufferId(buffer_id as usize),
2413 })
2414 .is_ok()
2415 }
2416
2417 pub fn flush_layout(&self) -> bool {
2421 self.command_sender.send(PluginCommand::FlushLayout).is_ok()
2422 }
2423
2424 pub fn composite_next_hunk(&self, buffer_id: u32) -> bool {
2426 self.command_sender
2427 .send(PluginCommand::CompositeNextHunk {
2428 buffer_id: BufferId(buffer_id as usize),
2429 })
2430 .is_ok()
2431 }
2432
2433 pub fn composite_prev_hunk(&self, buffer_id: u32) -> bool {
2435 self.command_sender
2436 .send(PluginCommand::CompositePrevHunk {
2437 buffer_id: BufferId(buffer_id as usize),
2438 })
2439 .is_ok()
2440 }
2441
2442 #[plugin_api(
2446 async_promise,
2447 js_name = "getHighlights",
2448 ts_return = "TsHighlightSpan[]"
2449 )]
2450 #[qjs(rename = "_getHighlightsStart")]
2451 pub fn get_highlights_start<'js>(
2452 &self,
2453 _ctx: rquickjs::Ctx<'js>,
2454 buffer_id: u32,
2455 start: u32,
2456 end: u32,
2457 ) -> rquickjs::Result<u64> {
2458 let id = self.alloc_request_id();
2459
2460 let _ = self.command_sender.send(PluginCommand::RequestHighlights {
2461 buffer_id: BufferId(buffer_id as usize),
2462 range: (start as usize)..(end as usize),
2463 request_id: id,
2464 });
2465
2466 Ok(id)
2467 }
2468
2469 pub fn add_overlay<'js>(
2491 &self,
2492 _ctx: rquickjs::Ctx<'js>,
2493 buffer_id: u32,
2494 namespace: String,
2495 start: u32,
2496 end: u32,
2497 options: rquickjs::Object<'js>,
2498 ) -> rquickjs::Result<bool> {
2499 use fresh_core::api::OverlayColorSpec;
2500
2501 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2503 if let Ok(theme_key) = obj.get::<_, String>(key) {
2505 if !theme_key.is_empty() {
2506 return Some(OverlayColorSpec::ThemeKey(theme_key));
2507 }
2508 }
2509 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2511 if arr.len() >= 3 {
2512 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2513 }
2514 }
2515 None
2516 }
2517
2518 let fg = parse_color_spec("fg", &options);
2519 let bg = parse_color_spec("bg", &options);
2520 let underline: bool = options.get("underline").unwrap_or(false);
2521 let bold: bool = options.get("bold").unwrap_or(false);
2522 let italic: bool = options.get("italic").unwrap_or(false);
2523 let strikethrough: bool = options.get("strikethrough").unwrap_or(false);
2524 let extend_to_line_end: bool = options.get("extendToLineEnd").unwrap_or(false);
2525 let url: Option<String> = options.get("url").ok();
2526
2527 let options = OverlayOptions {
2528 fg,
2529 bg,
2530 underline,
2531 bold,
2532 italic,
2533 strikethrough,
2534 extend_to_line_end,
2535 url,
2536 };
2537
2538 self.plugin_tracked_state
2540 .borrow_mut()
2541 .entry(self.plugin_name.clone())
2542 .or_default()
2543 .overlay_namespaces
2544 .push((BufferId(buffer_id as usize), namespace.clone()));
2545
2546 let _ = self.command_sender.send(PluginCommand::AddOverlay {
2547 buffer_id: BufferId(buffer_id as usize),
2548 namespace: Some(OverlayNamespace::from_string(namespace)),
2549 range: (start as usize)..(end as usize),
2550 options,
2551 });
2552
2553 Ok(true)
2554 }
2555
2556 pub fn clear_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2558 self.command_sender
2559 .send(PluginCommand::ClearNamespace {
2560 buffer_id: BufferId(buffer_id as usize),
2561 namespace: OverlayNamespace::from_string(namespace),
2562 })
2563 .is_ok()
2564 }
2565
2566 pub fn clear_all_overlays(&self, buffer_id: u32) -> bool {
2568 self.command_sender
2569 .send(PluginCommand::ClearAllOverlays {
2570 buffer_id: BufferId(buffer_id as usize),
2571 })
2572 .is_ok()
2573 }
2574
2575 pub fn clear_overlays_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2577 self.command_sender
2578 .send(PluginCommand::ClearOverlaysInRange {
2579 buffer_id: BufferId(buffer_id as usize),
2580 start: start as usize,
2581 end: end as usize,
2582 })
2583 .is_ok()
2584 }
2585
2586 pub fn remove_overlay(&self, buffer_id: u32, handle: String) -> bool {
2588 use fresh_core::overlay::OverlayHandle;
2589 self.command_sender
2590 .send(PluginCommand::RemoveOverlay {
2591 buffer_id: BufferId(buffer_id as usize),
2592 handle: OverlayHandle(handle),
2593 })
2594 .is_ok()
2595 }
2596
2597 pub fn add_conceal(
2601 &self,
2602 buffer_id: u32,
2603 namespace: String,
2604 start: u32,
2605 end: u32,
2606 replacement: Option<String>,
2607 ) -> bool {
2608 self.plugin_tracked_state
2610 .borrow_mut()
2611 .entry(self.plugin_name.clone())
2612 .or_default()
2613 .overlay_namespaces
2614 .push((BufferId(buffer_id as usize), namespace.clone()));
2615
2616 self.command_sender
2617 .send(PluginCommand::AddConceal {
2618 buffer_id: BufferId(buffer_id as usize),
2619 namespace: OverlayNamespace::from_string(namespace),
2620 start: start as usize,
2621 end: end as usize,
2622 replacement,
2623 })
2624 .is_ok()
2625 }
2626
2627 pub fn clear_conceal_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2629 self.command_sender
2630 .send(PluginCommand::ClearConcealNamespace {
2631 buffer_id: BufferId(buffer_id as usize),
2632 namespace: OverlayNamespace::from_string(namespace),
2633 })
2634 .is_ok()
2635 }
2636
2637 pub fn clear_conceals_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2639 self.command_sender
2640 .send(PluginCommand::ClearConcealsInRange {
2641 buffer_id: BufferId(buffer_id as usize),
2642 start: start as usize,
2643 end: end as usize,
2644 })
2645 .is_ok()
2646 }
2647
2648 pub fn add_fold(
2655 &self,
2656 buffer_id: u32,
2657 start: u32,
2658 end: u32,
2659 placeholder: rquickjs::function::Opt<String>,
2660 ) -> bool {
2661 self.command_sender
2662 .send(PluginCommand::AddFold {
2663 buffer_id: BufferId(buffer_id as usize),
2664 start: start as usize,
2665 end: end as usize,
2666 placeholder: placeholder.0,
2667 })
2668 .is_ok()
2669 }
2670
2671 pub fn clear_folds(&self, buffer_id: u32) -> bool {
2673 self.command_sender
2674 .send(PluginCommand::ClearFolds {
2675 buffer_id: BufferId(buffer_id as usize),
2676 })
2677 .is_ok()
2678 }
2679
2680 pub fn add_soft_break(
2684 &self,
2685 buffer_id: u32,
2686 namespace: String,
2687 position: u32,
2688 indent: u32,
2689 ) -> bool {
2690 self.plugin_tracked_state
2692 .borrow_mut()
2693 .entry(self.plugin_name.clone())
2694 .or_default()
2695 .overlay_namespaces
2696 .push((BufferId(buffer_id as usize), namespace.clone()));
2697
2698 self.command_sender
2699 .send(PluginCommand::AddSoftBreak {
2700 buffer_id: BufferId(buffer_id as usize),
2701 namespace: OverlayNamespace::from_string(namespace),
2702 position: position as usize,
2703 indent: indent as u16,
2704 })
2705 .is_ok()
2706 }
2707
2708 pub fn clear_soft_break_namespace(&self, buffer_id: u32, namespace: String) -> bool {
2710 self.command_sender
2711 .send(PluginCommand::ClearSoftBreakNamespace {
2712 buffer_id: BufferId(buffer_id as usize),
2713 namespace: OverlayNamespace::from_string(namespace),
2714 })
2715 .is_ok()
2716 }
2717
2718 pub fn clear_soft_breaks_in_range(&self, buffer_id: u32, start: u32, end: u32) -> bool {
2720 self.command_sender
2721 .send(PluginCommand::ClearSoftBreaksInRange {
2722 buffer_id: BufferId(buffer_id as usize),
2723 start: start as usize,
2724 end: end as usize,
2725 })
2726 .is_ok()
2727 }
2728
2729 #[allow(clippy::too_many_arguments)]
2739 pub fn submit_view_transform<'js>(
2740 &self,
2741 _ctx: rquickjs::Ctx<'js>,
2742 buffer_id: u32,
2743 split_id: Option<u32>,
2744 start: u32,
2745 end: u32,
2746 tokens: Vec<rquickjs::Object<'js>>,
2747 layout_hints: rquickjs::function::Opt<rquickjs::Object<'js>>,
2748 ) -> rquickjs::Result<bool> {
2749 use fresh_core::api::{LayoutHints, ViewTokenWire, ViewTransformPayload};
2750
2751 let tokens: Vec<ViewTokenWire> = tokens
2752 .into_iter()
2753 .enumerate()
2754 .map(|(idx, obj)| {
2755 parse_view_token(&obj, idx)
2757 })
2758 .collect::<rquickjs::Result<Vec<_>>>()?;
2759
2760 let parsed_layout_hints = if let Some(hints_obj) = layout_hints.into_inner() {
2762 let compose_width: Option<u16> = hints_obj.get("composeWidth").ok();
2763 let column_guides: Option<Vec<u16>> = hints_obj.get("columnGuides").ok();
2764 Some(LayoutHints {
2765 compose_width,
2766 column_guides,
2767 })
2768 } else {
2769 None
2770 };
2771
2772 let payload = ViewTransformPayload {
2773 range: (start as usize)..(end as usize),
2774 tokens,
2775 layout_hints: parsed_layout_hints,
2776 };
2777
2778 Ok(self
2779 .command_sender
2780 .send(PluginCommand::SubmitViewTransform {
2781 buffer_id: BufferId(buffer_id as usize),
2782 split_id: split_id.map(|id| SplitId(id as usize)),
2783 payload,
2784 })
2785 .is_ok())
2786 }
2787
2788 pub fn clear_view_transform(&self, buffer_id: u32, split_id: Option<u32>) -> bool {
2790 self.command_sender
2791 .send(PluginCommand::ClearViewTransform {
2792 buffer_id: BufferId(buffer_id as usize),
2793 split_id: split_id.map(|id| SplitId(id as usize)),
2794 })
2795 .is_ok()
2796 }
2797
2798 pub fn set_layout_hints<'js>(
2801 &self,
2802 buffer_id: u32,
2803 split_id: Option<u32>,
2804 #[plugin_api(ts_type = "LayoutHints")] hints: rquickjs::Object<'js>,
2805 ) -> rquickjs::Result<bool> {
2806 use fresh_core::api::LayoutHints;
2807
2808 let compose_width: Option<u16> = hints.get("composeWidth").ok();
2809 let column_guides: Option<Vec<u16>> = hints.get("columnGuides").ok();
2810 let parsed_hints = LayoutHints {
2811 compose_width,
2812 column_guides,
2813 };
2814
2815 Ok(self
2816 .command_sender
2817 .send(PluginCommand::SetLayoutHints {
2818 buffer_id: BufferId(buffer_id as usize),
2819 split_id: split_id.map(|id| SplitId(id as usize)),
2820 range: 0..0,
2821 hints: parsed_hints,
2822 })
2823 .is_ok())
2824 }
2825
2826 pub fn set_file_explorer_decorations<'js>(
2830 &self,
2831 _ctx: rquickjs::Ctx<'js>,
2832 namespace: String,
2833 decorations: Vec<rquickjs::Object<'js>>,
2834 ) -> rquickjs::Result<bool> {
2835 use fresh_core::file_explorer::FileExplorerDecoration;
2836
2837 let decorations: Vec<FileExplorerDecoration> = decorations
2838 .into_iter()
2839 .map(|obj| {
2840 let path: String = obj.get("path")?;
2841 let symbol: String = obj.get("symbol")?;
2842 let priority: i32 = obj.get("priority").unwrap_or(0);
2843
2844 let color_val: rquickjs::Value = obj.get("color")?;
2846 let color = if color_val.is_string() {
2847 let key: String = color_val.get()?;
2848 fresh_core::api::OverlayColorSpec::ThemeKey(key)
2849 } else if color_val.is_array() {
2850 let arr: Vec<u8> = color_val.get()?;
2851 if arr.len() < 3 {
2852 return Err(rquickjs::Error::FromJs {
2853 from: "array",
2854 to: "color",
2855 message: Some(format!(
2856 "color array must have at least 3 elements, got {}",
2857 arr.len()
2858 )),
2859 });
2860 }
2861 fresh_core::api::OverlayColorSpec::Rgb(arr[0], arr[1], arr[2])
2862 } else {
2863 return Err(rquickjs::Error::FromJs {
2864 from: "value",
2865 to: "color",
2866 message: Some("color must be an RGB array or theme key string".to_string()),
2867 });
2868 };
2869
2870 Ok(FileExplorerDecoration {
2871 path: std::path::PathBuf::from(path),
2872 symbol,
2873 color,
2874 priority,
2875 })
2876 })
2877 .collect::<rquickjs::Result<Vec<_>>>()?;
2878
2879 self.plugin_tracked_state
2881 .borrow_mut()
2882 .entry(self.plugin_name.clone())
2883 .or_default()
2884 .file_explorer_namespaces
2885 .push(namespace.clone());
2886
2887 Ok(self
2888 .command_sender
2889 .send(PluginCommand::SetFileExplorerDecorations {
2890 namespace,
2891 decorations,
2892 })
2893 .is_ok())
2894 }
2895
2896 pub fn clear_file_explorer_decorations(&self, namespace: String) -> bool {
2898 self.command_sender
2899 .send(PluginCommand::ClearFileExplorerDecorations { namespace })
2900 .is_ok()
2901 }
2902
2903 #[allow(clippy::too_many_arguments)]
2907 pub fn add_virtual_text(
2908 &self,
2909 buffer_id: u32,
2910 virtual_text_id: String,
2911 position: u32,
2912 text: String,
2913 r: u8,
2914 g: u8,
2915 b: u8,
2916 before: bool,
2917 use_bg: bool,
2918 ) -> bool {
2919 self.plugin_tracked_state
2921 .borrow_mut()
2922 .entry(self.plugin_name.clone())
2923 .or_default()
2924 .virtual_text_ids
2925 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2926
2927 self.command_sender
2928 .send(PluginCommand::AddVirtualText {
2929 buffer_id: BufferId(buffer_id as usize),
2930 virtual_text_id,
2931 position: position as usize,
2932 text,
2933 color: (r, g, b),
2934 use_bg,
2935 before,
2936 })
2937 .is_ok()
2938 }
2939
2940 pub fn remove_virtual_text(&self, buffer_id: u32, virtual_text_id: String) -> bool {
2942 self.command_sender
2943 .send(PluginCommand::RemoveVirtualText {
2944 buffer_id: BufferId(buffer_id as usize),
2945 virtual_text_id,
2946 })
2947 .is_ok()
2948 }
2949
2950 #[allow(clippy::too_many_arguments)]
2956 pub fn add_virtual_text_styled<'js>(
2957 &self,
2958 _ctx: rquickjs::Ctx<'js>,
2959 buffer_id: u32,
2960 virtual_text_id: String,
2961 position: u32,
2962 text: String,
2963 options: rquickjs::Object<'js>,
2964 before: bool,
2965 ) -> rquickjs::Result<bool> {
2966 use fresh_core::api::OverlayColorSpec;
2967
2968 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
2971 if let Ok(theme_key) = obj.get::<_, String>(key) {
2972 if !theme_key.is_empty() {
2973 return Some(OverlayColorSpec::ThemeKey(theme_key));
2974 }
2975 }
2976 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
2977 if arr.len() >= 3 {
2978 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
2979 }
2980 }
2981 None
2982 }
2983
2984 let fg = parse_color_spec("fg", &options);
2985 let bg = parse_color_spec("bg", &options);
2986 let bold: bool = options.get("bold").unwrap_or(false);
2987 let italic: bool = options.get("italic").unwrap_or(false);
2988
2989 self.plugin_tracked_state
2991 .borrow_mut()
2992 .entry(self.plugin_name.clone())
2993 .or_default()
2994 .virtual_text_ids
2995 .push((BufferId(buffer_id as usize), virtual_text_id.clone()));
2996
2997 let _ = self
2998 .command_sender
2999 .send(PluginCommand::AddVirtualTextStyled {
3000 buffer_id: BufferId(buffer_id as usize),
3001 virtual_text_id,
3002 position: position as usize,
3003 text,
3004 fg,
3005 bg,
3006 bold,
3007 italic,
3008 before,
3009 });
3010 Ok(true)
3011 }
3012
3013 pub fn remove_virtual_texts_by_prefix(&self, buffer_id: u32, prefix: String) -> bool {
3015 self.command_sender
3016 .send(PluginCommand::RemoveVirtualTextsByPrefix {
3017 buffer_id: BufferId(buffer_id as usize),
3018 prefix,
3019 })
3020 .is_ok()
3021 }
3022
3023 pub fn clear_virtual_texts(&self, buffer_id: u32) -> bool {
3025 self.command_sender
3026 .send(PluginCommand::ClearVirtualTexts {
3027 buffer_id: BufferId(buffer_id as usize),
3028 })
3029 .is_ok()
3030 }
3031
3032 pub fn clear_virtual_text_namespace(&self, buffer_id: u32, namespace: String) -> bool {
3034 self.command_sender
3035 .send(PluginCommand::ClearVirtualTextNamespace {
3036 buffer_id: BufferId(buffer_id as usize),
3037 namespace,
3038 })
3039 .is_ok()
3040 }
3041
3042 #[allow(clippy::too_many_arguments)]
3057 pub fn add_virtual_line<'js>(
3058 &self,
3059 _ctx: rquickjs::Ctx<'js>,
3060 buffer_id: u32,
3061 position: u32,
3062 text: String,
3063 options: rquickjs::Object<'js>,
3064 above: bool,
3065 namespace: String,
3066 priority: i32,
3067 ) -> rquickjs::Result<bool> {
3068 use fresh_core::api::OverlayColorSpec;
3069
3070 fn parse_color_spec(key: &str, obj: &rquickjs::Object<'_>) -> Option<OverlayColorSpec> {
3073 if let Ok(theme_key) = obj.get::<_, String>(key) {
3074 if !theme_key.is_empty() {
3075 return Some(OverlayColorSpec::ThemeKey(theme_key));
3076 }
3077 }
3078 if let Ok(arr) = obj.get::<_, Vec<u8>>(key) {
3079 if arr.len() >= 3 {
3080 return Some(OverlayColorSpec::Rgb(arr[0], arr[1], arr[2]));
3081 }
3082 }
3083 None
3084 }
3085
3086 let fg_color = parse_color_spec("fg", &options);
3087 let bg_color = parse_color_spec("bg", &options);
3088 let gutter_glyph = options
3089 .get::<_, String>("gutterGlyph")
3090 .ok()
3091 .filter(|s| !s.is_empty());
3092 let gutter_color = parse_color_spec("gutterColor", &options);
3093
3094 self.plugin_tracked_state
3096 .borrow_mut()
3097 .entry(self.plugin_name.clone())
3098 .or_default()
3099 .virtual_line_namespaces
3100 .push((BufferId(buffer_id as usize), namespace.clone()));
3101
3102 Ok(self
3103 .command_sender
3104 .send(PluginCommand::AddVirtualLine {
3105 buffer_id: BufferId(buffer_id as usize),
3106 position: position as usize,
3107 text,
3108 fg_color,
3109 bg_color,
3110 above,
3111 namespace,
3112 priority,
3113 gutter_glyph,
3114 gutter_color,
3115 })
3116 .is_ok())
3117 }
3118
3119 #[plugin_api(async_promise, js_name = "prompt", ts_return = "string | null")]
3124 #[qjs(rename = "_promptStart")]
3125 pub fn prompt_start(
3126 &self,
3127 _ctx: rquickjs::Ctx<'_>,
3128 label: String,
3129 initial_value: String,
3130 ) -> u64 {
3131 let id = self.alloc_request_id();
3132
3133 let _ = self.command_sender.send(PluginCommand::StartPromptAsync {
3134 label,
3135 initial_value,
3136 callback_id: JsCallbackId::new(id),
3137 });
3138
3139 id
3140 }
3141
3142 pub fn start_prompt(
3153 &self,
3154 label: String,
3155 prompt_type: String,
3156 floating_overlay: rquickjs::function::Opt<bool>,
3157 ) -> bool {
3158 self.command_sender
3159 .send(PluginCommand::StartPrompt {
3160 label,
3161 prompt_type,
3162 floating_overlay: floating_overlay.0.unwrap_or(false),
3163 })
3164 .is_ok()
3165 }
3166
3167 pub fn begin_key_capture(&self) -> bool {
3177 self.command_sender
3178 .send(PluginCommand::SetKeyCaptureActive { active: true })
3179 .is_ok()
3180 }
3181
3182 pub fn end_key_capture(&self) -> bool {
3186 self.command_sender
3187 .send(PluginCommand::SetKeyCaptureActive { active: false })
3188 .is_ok()
3189 }
3190
3191 #[plugin_api(async_promise, js_name = "getNextKey", ts_return = "KeyEventPayload")]
3203 #[qjs(rename = "_getNextKeyStart")]
3204 pub fn get_next_key_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
3205 let id = self.alloc_request_id();
3206 let _ = self.command_sender.send(PluginCommand::AwaitNextKey {
3207 callback_id: JsCallbackId::new(id),
3208 });
3209 id
3210 }
3211
3212 pub fn start_prompt_with_initial(
3215 &self,
3216 label: String,
3217 prompt_type: String,
3218 initial_value: String,
3219 floating_overlay: rquickjs::function::Opt<bool>,
3220 ) -> bool {
3221 self.command_sender
3222 .send(PluginCommand::StartPromptWithInitial {
3223 label,
3224 prompt_type,
3225 initial_value,
3226 floating_overlay: floating_overlay.0.unwrap_or(false),
3227 })
3228 .is_ok()
3229 }
3230
3231 pub fn set_prompt_suggestions(
3235 &self,
3236 suggestions: Vec<fresh_core::command::Suggestion>,
3237 ) -> bool {
3238 self.command_sender
3239 .send(PluginCommand::SetPromptSuggestions { suggestions })
3240 .is_ok()
3241 }
3242
3243 pub fn set_prompt_input_sync(&self, sync: bool) -> bool {
3244 self.command_sender
3245 .send(PluginCommand::SetPromptInputSync { sync })
3246 .is_ok()
3247 }
3248
3249 pub fn set_prompt_title(
3259 &self,
3260 #[plugin_api(ts_type = "StyledText[]")] title: Vec<fresh_core::api::StyledText>,
3261 ) -> bool {
3262 self.command_sender
3263 .send(PluginCommand::SetPromptTitle { title })
3264 .is_ok()
3265 }
3266
3267 pub fn set_prompt_footer(
3273 &self,
3274 #[plugin_api(ts_type = "StyledText[]")] footer: Vec<fresh_core::api::StyledText>,
3275 ) -> bool {
3276 self.command_sender
3277 .send(PluginCommand::SetPromptFooter { footer })
3278 .is_ok()
3279 }
3280
3281 pub fn set_prompt_selected_index(&self, index: u32) -> bool {
3289 self.command_sender
3290 .send(PluginCommand::SetPromptSelectedIndex { index })
3291 .is_ok()
3292 }
3293
3294 pub fn define_mode(
3298 &self,
3299 name: String,
3300 bindings_arr: Vec<Vec<String>>,
3301 read_only: rquickjs::function::Opt<bool>,
3302 allow_text_input: rquickjs::function::Opt<bool>,
3303 inherit_normal_bindings: rquickjs::function::Opt<bool>,
3304 ) -> bool {
3305 let bindings: Vec<(String, String)> = bindings_arr
3306 .into_iter()
3307 .filter_map(|arr| {
3308 if arr.len() >= 2 {
3309 Some((arr[0].clone(), arr[1].clone()))
3310 } else {
3311 None
3312 }
3313 })
3314 .collect();
3315
3316 {
3319 let mut registered = self.registered_actions.borrow_mut();
3320 for (_, cmd_name) in &bindings {
3321 registered.insert(
3322 cmd_name.clone(),
3323 PluginHandler {
3324 plugin_name: self.plugin_name.clone(),
3325 handler_name: cmd_name.clone(),
3326 },
3327 );
3328 }
3329 }
3330
3331 let allow_text = allow_text_input.0.unwrap_or(false);
3334 if allow_text {
3335 let mut registered = self.registered_actions.borrow_mut();
3336 registered.insert(
3337 "mode_text_input".to_string(),
3338 PluginHandler {
3339 plugin_name: self.plugin_name.clone(),
3340 handler_name: "mode_text_input".to_string(),
3341 },
3342 );
3343 }
3344
3345 self.command_sender
3346 .send(PluginCommand::DefineMode {
3347 name,
3348 bindings,
3349 read_only: read_only.0.unwrap_or(false),
3350 allow_text_input: allow_text,
3351 inherit_normal_bindings: inherit_normal_bindings.0.unwrap_or(false),
3352 plugin_name: Some(self.plugin_name.clone()),
3353 })
3354 .is_ok()
3355 }
3356
3357 pub fn set_editor_mode(&self, mode: Option<String>) -> bool {
3359 self.command_sender
3360 .send(PluginCommand::SetEditorMode { mode })
3361 .is_ok()
3362 }
3363
3364 pub fn get_editor_mode(&self) -> Option<String> {
3366 self.state_snapshot
3367 .read()
3368 .ok()
3369 .and_then(|s| s.editor_mode.clone())
3370 }
3371
3372 pub fn close_split(&self, split_id: u32) -> bool {
3376 self.command_sender
3377 .send(PluginCommand::CloseSplit {
3378 split_id: SplitId(split_id as usize),
3379 })
3380 .is_ok()
3381 }
3382
3383 pub fn set_split_buffer(&self, split_id: u32, buffer_id: u32) -> bool {
3385 self.command_sender
3386 .send(PluginCommand::SetSplitBuffer {
3387 split_id: SplitId(split_id as usize),
3388 buffer_id: BufferId(buffer_id as usize),
3389 })
3390 .is_ok()
3391 }
3392
3393 pub fn focus_split(&self, split_id: u32) -> bool {
3395 self.command_sender
3396 .send(PluginCommand::FocusSplit {
3397 split_id: SplitId(split_id as usize),
3398 })
3399 .is_ok()
3400 }
3401
3402 pub fn create_window(&self, root: String, label: String) -> bool {
3421 self.command_sender
3422 .send(PluginCommand::CreateWindow {
3423 root: std::path::PathBuf::from(root),
3424 label,
3425 })
3426 .is_ok()
3427 }
3428
3429 pub fn set_active_window(&self, id: u64) -> bool {
3434 self.command_sender
3435 .send(PluginCommand::SetActiveWindow {
3436 id: fresh_core::WindowId(id),
3437 })
3438 .is_ok()
3439 }
3440
3441 pub fn close_window(&self, id: u64) -> bool {
3444 self.command_sender
3445 .send(PluginCommand::CloseWindow {
3446 id: fresh_core::WindowId(id),
3447 })
3448 .is_ok()
3449 }
3450
3451 pub fn prewarm_window(&self, id: u64) -> bool {
3455 self.command_sender
3456 .send(PluginCommand::PrewarmWindow {
3457 id: fresh_core::WindowId(id),
3458 })
3459 .is_ok()
3460 }
3461
3462 #[plugin_api(async_promise, js_name = "watchPath", ts_return = "number")]
3474 #[qjs(rename = "_watchPathStart")]
3475 pub fn watch_path_start(
3476 &self,
3477 _ctx: rquickjs::Ctx<'_>,
3478 path: String,
3479 recursive: rquickjs::function::Opt<bool>,
3480 ) -> rquickjs::Result<u64> {
3481 let id = self.alloc_request_id();
3482 if let Ok(mut owners) = self.async_resource_owners.lock() {
3483 owners.insert(id, self.plugin_name.clone());
3484 }
3485 let _ = self.command_sender.send(PluginCommand::WatchPath {
3486 path: std::path::PathBuf::from(path),
3487 recursive: recursive.0.unwrap_or(false),
3488 request_id: id,
3489 });
3490 Ok(id)
3491 }
3492
3493 pub fn unwatch_path(&self, handle: u64) -> bool {
3496 self.command_sender
3497 .send(PluginCommand::UnwatchPath { handle })
3498 .is_ok()
3499 }
3500
3501 pub fn preview_window_in_rect(&self, id: u64) -> bool {
3512 let sid = if id == 0 {
3513 None
3514 } else {
3515 Some(fresh_core::WindowId(id))
3516 };
3517 self.command_sender
3518 .send(PluginCommand::PreviewWindowInRect { id: sid })
3519 .is_ok()
3520 }
3521
3522 pub fn clear_window_preview(&self) -> bool {
3525 self.command_sender
3526 .send(PluginCommand::PreviewWindowInRect { id: None })
3527 .is_ok()
3528 }
3529
3530 #[plugin_api(ts_return = "WindowInfo[]")]
3533 pub fn list_windows<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<Value<'js>> {
3534 let sessions: Vec<fresh_core::api::WindowInfo> = self
3535 .state_snapshot
3536 .read()
3537 .map(|s| s.windows.clone())
3538 .unwrap_or_default();
3539 rquickjs_serde::to_value(ctx, &sessions).map_err(|e| {
3540 rquickjs::Error::new_from_js_message("serialize", "WindowInfo", &e.to_string())
3541 })
3542 }
3543
3544 pub fn active_window(&self) -> u64 {
3547 self.state_snapshot
3548 .read()
3549 .map(|s| s.active_window_id.0)
3550 .unwrap_or(1)
3551 }
3552
3553 pub fn set_split_scroll(&self, split_id: u32, top_byte: u32) -> bool {
3555 self.command_sender
3556 .send(PluginCommand::SetSplitScroll {
3557 split_id: SplitId(split_id as usize),
3558 top_byte: top_byte as usize,
3559 })
3560 .is_ok()
3561 }
3562
3563 pub fn set_split_ratio(&self, split_id: u32, ratio: f32) -> bool {
3565 self.command_sender
3566 .send(PluginCommand::SetSplitRatio {
3567 split_id: SplitId(split_id as usize),
3568 ratio,
3569 })
3570 .is_ok()
3571 }
3572
3573 pub fn set_split_label(&self, split_id: u32, label: String) -> bool {
3575 self.command_sender
3576 .send(PluginCommand::SetSplitLabel {
3577 split_id: SplitId(split_id as usize),
3578 label,
3579 })
3580 .is_ok()
3581 }
3582
3583 pub fn clear_split_label(&self, split_id: u32) -> bool {
3585 self.command_sender
3586 .send(PluginCommand::ClearSplitLabel {
3587 split_id: SplitId(split_id as usize),
3588 })
3589 .is_ok()
3590 }
3591
3592 #[plugin_api(
3594 async_promise,
3595 js_name = "getSplitByLabel",
3596 ts_return = "number | null"
3597 )]
3598 #[qjs(rename = "_getSplitByLabelStart")]
3599 pub fn get_split_by_label_start(&self, _ctx: rquickjs::Ctx<'_>, label: String) -> u64 {
3600 let id = self.alloc_request_id();
3601 let _ = self.command_sender.send(PluginCommand::GetSplitByLabel {
3602 label,
3603 request_id: id,
3604 });
3605 id
3606 }
3607
3608 pub fn distribute_splits_evenly(&self) -> bool {
3610 self.command_sender
3612 .send(PluginCommand::DistributeSplitsEvenly { split_ids: vec![] })
3613 .is_ok()
3614 }
3615
3616 pub fn set_buffer_cursor(&self, buffer_id: u32, position: u32) -> bool {
3618 self.command_sender
3619 .send(PluginCommand::SetBufferCursor {
3620 buffer_id: BufferId(buffer_id as usize),
3621 position: position as usize,
3622 })
3623 .is_ok()
3624 }
3625
3626 #[qjs(rename = "setBufferShowCursors")]
3633 pub fn set_buffer_show_cursors(&self, buffer_id: u32, show: bool) -> bool {
3634 self.command_sender
3635 .send(PluginCommand::SetBufferShowCursors {
3636 buffer_id: BufferId(buffer_id as usize),
3637 show,
3638 })
3639 .is_ok()
3640 }
3641
3642 #[allow(clippy::too_many_arguments)]
3646 pub fn set_line_indicator(
3647 &self,
3648 buffer_id: u32,
3649 line: u32,
3650 namespace: String,
3651 symbol: String,
3652 r: u8,
3653 g: u8,
3654 b: u8,
3655 priority: i32,
3656 ) -> bool {
3657 self.plugin_tracked_state
3659 .borrow_mut()
3660 .entry(self.plugin_name.clone())
3661 .or_default()
3662 .line_indicator_namespaces
3663 .push((BufferId(buffer_id as usize), namespace.clone()));
3664
3665 self.command_sender
3666 .send(PluginCommand::SetLineIndicator {
3667 buffer_id: BufferId(buffer_id as usize),
3668 line: line as usize,
3669 namespace,
3670 symbol,
3671 color: (r, g, b),
3672 priority,
3673 })
3674 .is_ok()
3675 }
3676
3677 #[allow(clippy::too_many_arguments)]
3679 pub fn set_line_indicators(
3680 &self,
3681 buffer_id: u32,
3682 lines: Vec<u32>,
3683 namespace: String,
3684 symbol: String,
3685 r: u8,
3686 g: u8,
3687 b: u8,
3688 priority: i32,
3689 ) -> bool {
3690 self.plugin_tracked_state
3692 .borrow_mut()
3693 .entry(self.plugin_name.clone())
3694 .or_default()
3695 .line_indicator_namespaces
3696 .push((BufferId(buffer_id as usize), namespace.clone()));
3697
3698 self.command_sender
3699 .send(PluginCommand::SetLineIndicators {
3700 buffer_id: BufferId(buffer_id as usize),
3701 lines: lines.into_iter().map(|l| l as usize).collect(),
3702 namespace,
3703 symbol,
3704 color: (r, g, b),
3705 priority,
3706 })
3707 .is_ok()
3708 }
3709
3710 pub fn clear_line_indicators(&self, buffer_id: u32, namespace: String) -> bool {
3712 self.command_sender
3713 .send(PluginCommand::ClearLineIndicators {
3714 buffer_id: BufferId(buffer_id as usize),
3715 namespace,
3716 })
3717 .is_ok()
3718 }
3719
3720 pub fn set_line_numbers(&self, buffer_id: u32, enabled: bool) -> bool {
3722 self.command_sender
3723 .send(PluginCommand::SetLineNumbers {
3724 buffer_id: BufferId(buffer_id as usize),
3725 enabled,
3726 })
3727 .is_ok()
3728 }
3729
3730 pub fn set_view_mode(&self, buffer_id: u32, mode: String) -> bool {
3732 self.command_sender
3733 .send(PluginCommand::SetViewMode {
3734 buffer_id: BufferId(buffer_id as usize),
3735 mode,
3736 })
3737 .is_ok()
3738 }
3739
3740 pub fn set_line_wrap(&self, buffer_id: u32, split_id: Option<u32>, enabled: bool) -> bool {
3742 self.command_sender
3743 .send(PluginCommand::SetLineWrap {
3744 buffer_id: BufferId(buffer_id as usize),
3745 split_id: split_id.map(|s| SplitId(s as usize)),
3746 enabled,
3747 })
3748 .is_ok()
3749 }
3750
3751 pub fn set_view_state<'js>(
3755 &self,
3756 ctx: rquickjs::Ctx<'js>,
3757 buffer_id: u32,
3758 key: String,
3759 value: Value<'js>,
3760 ) -> bool {
3761 let bid = BufferId(buffer_id as usize);
3762
3763 let json_value = if value.is_undefined() || value.is_null() {
3765 None
3766 } else {
3767 Some(js_to_json(&ctx, value))
3768 };
3769
3770 if let Ok(mut snapshot) = self.state_snapshot.write() {
3772 if let Some(ref json_val) = json_value {
3773 snapshot
3774 .plugin_view_states
3775 .entry(bid)
3776 .or_default()
3777 .insert(key.clone(), json_val.clone());
3778 } else {
3779 if let Some(map) = snapshot.plugin_view_states.get_mut(&bid) {
3781 map.remove(&key);
3782 if map.is_empty() {
3783 snapshot.plugin_view_states.remove(&bid);
3784 }
3785 }
3786 }
3787 }
3788
3789 self.command_sender
3791 .send(PluginCommand::SetViewState {
3792 buffer_id: bid,
3793 key,
3794 value: json_value,
3795 })
3796 .is_ok()
3797 }
3798
3799 pub fn get_view_state<'js>(
3801 &self,
3802 ctx: rquickjs::Ctx<'js>,
3803 buffer_id: u32,
3804 key: String,
3805 ) -> rquickjs::Result<Value<'js>> {
3806 let bid = BufferId(buffer_id as usize);
3807 if let Ok(snapshot) = self.state_snapshot.read() {
3808 if let Some(map) = snapshot.plugin_view_states.get(&bid) {
3809 if let Some(json_val) = map.get(&key) {
3810 return json_to_js_value(&ctx, json_val);
3811 }
3812 }
3813 }
3814 Ok(Value::new_undefined(ctx.clone()))
3815 }
3816
3817 pub fn set_global_state<'js>(
3823 &self,
3824 ctx: rquickjs::Ctx<'js>,
3825 key: String,
3826 value: Value<'js>,
3827 ) -> bool {
3828 let json_value = if value.is_undefined() || value.is_null() {
3830 None
3831 } else {
3832 Some(js_to_json(&ctx, value))
3833 };
3834
3835 if let Ok(mut snapshot) = self.state_snapshot.write() {
3837 if let Some(ref json_val) = json_value {
3838 snapshot
3839 .plugin_global_states
3840 .entry(self.plugin_name.clone())
3841 .or_default()
3842 .insert(key.clone(), json_val.clone());
3843 } else {
3844 if let Some(map) = snapshot.plugin_global_states.get_mut(&self.plugin_name) {
3846 map.remove(&key);
3847 if map.is_empty() {
3848 snapshot.plugin_global_states.remove(&self.plugin_name);
3849 }
3850 }
3851 }
3852 }
3853
3854 self.command_sender
3856 .send(PluginCommand::SetGlobalState {
3857 plugin_name: self.plugin_name.clone(),
3858 key,
3859 value: json_value,
3860 })
3861 .is_ok()
3862 }
3863
3864 pub fn get_global_state<'js>(
3868 &self,
3869 ctx: rquickjs::Ctx<'js>,
3870 key: String,
3871 ) -> rquickjs::Result<Value<'js>> {
3872 if let Ok(snapshot) = self.state_snapshot.read() {
3873 if let Some(map) = snapshot.plugin_global_states.get(&self.plugin_name) {
3874 if let Some(json_val) = map.get(&key) {
3875 return json_to_js_value(&ctx, json_val);
3876 }
3877 }
3878 }
3879 Ok(Value::new_undefined(ctx.clone()))
3880 }
3881
3882 pub fn set_window_state<'js>(
3891 &self,
3892 ctx: rquickjs::Ctx<'js>,
3893 key: String,
3894 value: Value<'js>,
3895 ) -> bool {
3896 let json_value = if value.is_undefined() || value.is_null() {
3897 None
3898 } else {
3899 Some(js_to_json(&ctx, value))
3900 };
3901 if let Ok(mut snapshot) = self.state_snapshot.write() {
3905 match &json_value {
3906 Some(v) => {
3907 snapshot
3908 .active_session_plugin_states
3909 .entry(self.plugin_name.clone())
3910 .or_default()
3911 .insert(key.clone(), v.clone());
3912 }
3913 None => {
3914 if let Some(map) = snapshot
3915 .active_session_plugin_states
3916 .get_mut(&self.plugin_name)
3917 {
3918 map.remove(&key);
3919 if map.is_empty() {
3920 snapshot
3921 .active_session_plugin_states
3922 .remove(&self.plugin_name);
3923 }
3924 }
3925 }
3926 }
3927 }
3928 self.command_sender
3929 .send(PluginCommand::SetWindowState {
3930 plugin_name: self.plugin_name.clone(),
3931 key,
3932 value: json_value,
3933 })
3934 .is_ok()
3935 }
3936
3937 pub fn get_window_state<'js>(
3940 &self,
3941 ctx: rquickjs::Ctx<'js>,
3942 key: String,
3943 ) -> rquickjs::Result<Value<'js>> {
3944 if let Ok(snapshot) = self.state_snapshot.read() {
3945 if let Some(map) = snapshot.active_session_plugin_states.get(&self.plugin_name) {
3946 if let Some(json_val) = map.get(&key) {
3947 return json_to_js_value(&ctx, json_val);
3948 }
3949 }
3950 }
3951 Ok(Value::new_undefined(ctx.clone()))
3952 }
3953
3954 pub fn create_scroll_sync_group(
3958 &self,
3959 group_id: u32,
3960 left_split: u32,
3961 right_split: u32,
3962 ) -> bool {
3963 self.plugin_tracked_state
3965 .borrow_mut()
3966 .entry(self.plugin_name.clone())
3967 .or_default()
3968 .scroll_sync_group_ids
3969 .push(group_id);
3970 self.command_sender
3971 .send(PluginCommand::CreateScrollSyncGroup {
3972 group_id,
3973 left_split: SplitId(left_split as usize),
3974 right_split: SplitId(right_split as usize),
3975 })
3976 .is_ok()
3977 }
3978
3979 pub fn set_scroll_sync_anchors<'js>(
3981 &self,
3982 _ctx: rquickjs::Ctx<'js>,
3983 group_id: u32,
3984 anchors: Vec<Vec<u32>>,
3985 ) -> bool {
3986 let anchors: Vec<(usize, usize)> = anchors
3987 .into_iter()
3988 .filter_map(|pair| {
3989 if pair.len() >= 2 {
3990 Some((pair[0] as usize, pair[1] as usize))
3991 } else {
3992 None
3993 }
3994 })
3995 .collect();
3996 self.command_sender
3997 .send(PluginCommand::SetScrollSyncAnchors { group_id, anchors })
3998 .is_ok()
3999 }
4000
4001 pub fn remove_scroll_sync_group(&self, group_id: u32) -> bool {
4003 self.command_sender
4004 .send(PluginCommand::RemoveScrollSyncGroup { group_id })
4005 .is_ok()
4006 }
4007
4008 pub fn execute_actions(&self, actions: Vec<ActionSpec>) -> bool {
4014 self.command_sender
4015 .send(PluginCommand::ExecuteActions { actions })
4016 .is_ok()
4017 }
4018
4019 pub fn show_action_popup(&self, opts: fresh_core::api::ActionPopupOptions) -> bool {
4023 self.command_sender
4024 .send(PluginCommand::ShowActionPopup {
4025 popup_id: opts.id,
4026 title: opts.title,
4027 message: opts.message,
4028 actions: opts.actions,
4029 })
4030 .is_ok()
4031 }
4032
4033 pub fn set_lsp_menu_contributions(
4037 &self,
4038 plugin_id: String,
4039 language: String,
4040 items: Vec<fresh_core::api::LspMenuItem>,
4041 ) -> bool {
4042 self.command_sender
4043 .send(PluginCommand::SetLspMenuContributions {
4044 plugin_id,
4045 language,
4046 items,
4047 })
4048 .is_ok()
4049 }
4050
4051 pub fn disable_lsp_for_language(&self, language: String) -> bool {
4053 self.command_sender
4054 .send(PluginCommand::DisableLspForLanguage { language })
4055 .is_ok()
4056 }
4057
4058 pub fn restart_lsp_for_language(&self, language: String) -> bool {
4060 self.command_sender
4061 .send(PluginCommand::RestartLspForLanguage { language })
4062 .is_ok()
4063 }
4064
4065 pub fn set_lsp_root_uri(&self, language: String, uri: String) -> bool {
4068 self.command_sender
4069 .send(PluginCommand::SetLspRootUri { language, uri })
4070 .is_ok()
4071 }
4072
4073 #[plugin_api(ts_return = "JsDiagnostic[]")]
4075 pub fn get_all_diagnostics<'js>(
4076 &self,
4077 ctx: rquickjs::Ctx<'js>,
4078 ) -> rquickjs::Result<Value<'js>> {
4079 use fresh_core::api::{JsDiagnostic, JsPosition, JsRange};
4080
4081 let diagnostics = if let Ok(s) = self.state_snapshot.read() {
4082 let mut result: Vec<JsDiagnostic> = Vec::new();
4084 for (uri, diags) in s.diagnostics.iter() {
4085 for diag in diags {
4086 result.push(JsDiagnostic {
4087 uri: uri.clone(),
4088 message: diag.message.clone(),
4089 severity: diag.severity.map(|s| match s {
4090 lsp_types::DiagnosticSeverity::ERROR => 1,
4091 lsp_types::DiagnosticSeverity::WARNING => 2,
4092 lsp_types::DiagnosticSeverity::INFORMATION => 3,
4093 lsp_types::DiagnosticSeverity::HINT => 4,
4094 _ => 0,
4095 }),
4096 range: JsRange {
4097 start: JsPosition {
4098 line: diag.range.start.line,
4099 character: diag.range.start.character,
4100 },
4101 end: JsPosition {
4102 line: diag.range.end.line,
4103 character: diag.range.end.character,
4104 },
4105 },
4106 source: diag.source.clone(),
4107 });
4108 }
4109 }
4110 result
4111 } else {
4112 Vec::new()
4113 };
4114 rquickjs_serde::to_value(ctx, &diagnostics)
4115 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4116 }
4117
4118 pub fn get_handlers(&self, event_name: String) -> Vec<String> {
4120 self.event_handlers
4121 .borrow()
4122 .get(&event_name)
4123 .cloned()
4124 .unwrap_or_default()
4125 .into_iter()
4126 .map(|h| h.handler_name)
4127 .collect()
4128 }
4129
4130 #[plugin_api(
4134 async_promise,
4135 js_name = "createVirtualBuffer",
4136 ts_return = "VirtualBufferResult"
4137 )]
4138 #[qjs(rename = "_createVirtualBufferStart")]
4139 pub fn create_virtual_buffer_start(
4140 &self,
4141 _ctx: rquickjs::Ctx<'_>,
4142 opts: fresh_core::api::CreateVirtualBufferOptions,
4143 ) -> rquickjs::Result<u64> {
4144 let id = self.alloc_request_id();
4145
4146 let entries: Vec<TextPropertyEntry> = opts
4148 .entries
4149 .unwrap_or_default()
4150 .into_iter()
4151 .map(|e| TextPropertyEntry {
4152 text: e.text,
4153 properties: e.properties.unwrap_or_default(),
4154 style: e.style,
4155 inline_overlays: e.inline_overlays.unwrap_or_default(),
4156 segments: e.segments.unwrap_or_default(),
4157 pad_to_chars: e.pad_to_chars,
4158 truncate_to_chars: e.truncate_to_chars,
4159 })
4160 .collect();
4161
4162 tracing::debug!(
4163 "_createVirtualBufferStart: sending CreateVirtualBufferWithContent command, request_id={}",
4164 id
4165 );
4166 if let Ok(mut owners) = self.async_resource_owners.lock() {
4168 owners.insert(id, self.plugin_name.clone());
4169 }
4170 let _ = self
4171 .command_sender
4172 .send(PluginCommand::CreateVirtualBufferWithContent {
4173 name: opts.name,
4174 mode: opts.mode.unwrap_or_default(),
4175 read_only: opts.read_only.unwrap_or(false),
4176 entries,
4177 show_line_numbers: opts.show_line_numbers.unwrap_or(false),
4178 show_cursors: opts.show_cursors.unwrap_or(true),
4179 editing_disabled: opts.editing_disabled.unwrap_or(false),
4180 hidden_from_tabs: opts.hidden_from_tabs.unwrap_or(false),
4181 request_id: Some(id),
4182 });
4183 Ok(id)
4184 }
4185
4186 #[plugin_api(
4188 async_promise,
4189 js_name = "createVirtualBufferInSplit",
4190 ts_return = "VirtualBufferResult"
4191 )]
4192 #[qjs(rename = "_createVirtualBufferInSplitStart")]
4193 pub fn create_virtual_buffer_in_split_start(
4194 &self,
4195 _ctx: rquickjs::Ctx<'_>,
4196 opts: fresh_core::api::CreateVirtualBufferInSplitOptions,
4197 ) -> rquickjs::Result<u64> {
4198 let id = self.alloc_request_id();
4199
4200 let entries: Vec<TextPropertyEntry> = opts
4202 .entries
4203 .unwrap_or_default()
4204 .into_iter()
4205 .map(|e| TextPropertyEntry {
4206 text: e.text,
4207 properties: e.properties.unwrap_or_default(),
4208 style: e.style,
4209 inline_overlays: e.inline_overlays.unwrap_or_default(),
4210 segments: e.segments.unwrap_or_default(),
4211 pad_to_chars: e.pad_to_chars,
4212 truncate_to_chars: e.truncate_to_chars,
4213 })
4214 .collect();
4215
4216 if let Ok(mut owners) = self.async_resource_owners.lock() {
4218 owners.insert(id, self.plugin_name.clone());
4219 }
4220 let _ = self
4221 .command_sender
4222 .send(PluginCommand::CreateVirtualBufferInSplit {
4223 name: opts.name,
4224 mode: opts.mode.unwrap_or_default(),
4225 read_only: opts.read_only.unwrap_or(false),
4226 entries,
4227 ratio: opts.ratio.unwrap_or(0.5),
4228 direction: opts.direction,
4229 panel_id: opts.panel_id,
4230 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
4231 show_cursors: opts.show_cursors.unwrap_or(true),
4232 editing_disabled: opts.editing_disabled.unwrap_or(false),
4233 line_wrap: opts.line_wrap,
4234 before: opts.before.unwrap_or(false),
4235 role: opts.role,
4236 request_id: Some(id),
4237 });
4238 Ok(id)
4239 }
4240
4241 #[plugin_api(
4243 async_promise,
4244 js_name = "createVirtualBufferInExistingSplit",
4245 ts_return = "VirtualBufferResult"
4246 )]
4247 #[qjs(rename = "_createVirtualBufferInExistingSplitStart")]
4248 pub fn create_virtual_buffer_in_existing_split_start(
4249 &self,
4250 _ctx: rquickjs::Ctx<'_>,
4251 opts: fresh_core::api::CreateVirtualBufferInExistingSplitOptions,
4252 ) -> rquickjs::Result<u64> {
4253 let id = self.alloc_request_id();
4254
4255 let entries: Vec<TextPropertyEntry> = opts
4257 .entries
4258 .unwrap_or_default()
4259 .into_iter()
4260 .map(|e| TextPropertyEntry {
4261 text: e.text,
4262 properties: e.properties.unwrap_or_default(),
4263 style: e.style,
4264 inline_overlays: e.inline_overlays.unwrap_or_default(),
4265 segments: e.segments.unwrap_or_default(),
4266 pad_to_chars: e.pad_to_chars,
4267 truncate_to_chars: e.truncate_to_chars,
4268 })
4269 .collect();
4270
4271 if let Ok(mut owners) = self.async_resource_owners.lock() {
4273 owners.insert(id, self.plugin_name.clone());
4274 }
4275 let _ = self
4276 .command_sender
4277 .send(PluginCommand::CreateVirtualBufferInExistingSplit {
4278 name: opts.name,
4279 mode: opts.mode.unwrap_or_default(),
4280 read_only: opts.read_only.unwrap_or(false),
4281 entries,
4282 split_id: SplitId(opts.split_id),
4283 show_line_numbers: opts.show_line_numbers.unwrap_or(true),
4284 show_cursors: opts.show_cursors.unwrap_or(true),
4285 editing_disabled: opts.editing_disabled.unwrap_or(false),
4286 line_wrap: opts.line_wrap,
4287 request_id: Some(id),
4288 });
4289 Ok(id)
4290 }
4291
4292 #[qjs(rename = "_createBufferGroupStart")]
4294 pub fn create_buffer_group_start(
4295 &self,
4296 _ctx: rquickjs::Ctx<'_>,
4297 name: String,
4298 mode: String,
4299 layout_json: String,
4300 ) -> rquickjs::Result<u64> {
4301 let id = self.alloc_request_id();
4302 if let Ok(mut owners) = self.async_resource_owners.lock() {
4303 owners.insert(id, self.plugin_name.clone());
4304 }
4305 let _ = self.command_sender.send(PluginCommand::CreateBufferGroup {
4306 name,
4307 mode,
4308 layout_json,
4309 request_id: Some(id),
4310 });
4311 Ok(id)
4312 }
4313
4314 #[qjs(rename = "setPanelContent")]
4316 pub fn set_panel_content<'js>(
4317 &self,
4318 ctx: rquickjs::Ctx<'js>,
4319 group_id: u32,
4320 panel_name: String,
4321 entries_arr: Vec<rquickjs::Object<'js>>,
4322 ) -> rquickjs::Result<bool> {
4323 let entries: Vec<TextPropertyEntry> = entries_arr
4324 .iter()
4325 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
4326 .collect();
4327 Ok(self
4328 .command_sender
4329 .send(PluginCommand::SetPanelContent {
4330 group_id: group_id as usize,
4331 panel_name,
4332 entries,
4333 })
4334 .is_ok())
4335 }
4336
4337 #[qjs(rename = "closeBufferGroup")]
4339 pub fn close_buffer_group(&self, group_id: u32) -> bool {
4340 self.command_sender
4341 .send(PluginCommand::CloseBufferGroup {
4342 group_id: group_id as usize,
4343 })
4344 .is_ok()
4345 }
4346
4347 #[qjs(rename = "focusBufferGroupPanel")]
4349 pub fn focus_buffer_group_panel(&self, group_id: u32, panel_name: String) -> bool {
4350 self.command_sender
4351 .send(PluginCommand::FocusPanel {
4352 group_id: group_id as usize,
4353 panel_name,
4354 })
4355 .is_ok()
4356 }
4357
4358 pub fn set_virtual_buffer_content<'js>(
4362 &self,
4363 ctx: rquickjs::Ctx<'js>,
4364 buffer_id: u32,
4365 entries_arr: Vec<rquickjs::Object<'js>>,
4366 ) -> rquickjs::Result<bool> {
4367 let entries: Vec<TextPropertyEntry> = entries_arr
4368 .iter()
4369 .filter_map(|obj| parse_text_property_entry(&ctx, obj))
4370 .collect();
4371 Ok(self
4372 .command_sender
4373 .send(PluginCommand::SetVirtualBufferContent {
4374 buffer_id: BufferId(buffer_id as usize),
4375 entries,
4376 })
4377 .is_ok())
4378 }
4379
4380 pub fn get_text_properties_at_cursor(
4382 &self,
4383 buffer_id: u32,
4384 ) -> fresh_core::api::TextPropertiesAtCursor {
4385 get_text_properties_at_cursor_typed(&self.state_snapshot, buffer_id)
4386 }
4387
4388 #[qjs(rename = "mountWidgetPanel")]
4398 pub fn mount_widget_panel<'js>(
4399 &self,
4400 ctx: rquickjs::Ctx<'js>,
4401 panel_id: f64,
4402 buffer_id: u32,
4403 spec_obj: rquickjs::Value<'js>,
4404 ) -> rquickjs::Result<bool> {
4405 let json = js_to_json(&ctx, spec_obj);
4406 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
4407 Ok(s) => s,
4408 Err(e) => {
4409 tracing::error!("mountWidgetPanel: invalid spec: {}", e);
4410 return Ok(false);
4411 }
4412 };
4413 Ok(self
4414 .command_sender
4415 .send(PluginCommand::MountWidgetPanel {
4416 panel_id: panel_id as u64,
4417 buffer_id: BufferId(buffer_id as usize),
4418 spec,
4419 })
4420 .is_ok())
4421 }
4422
4423 #[qjs(rename = "updateWidgetPanel")]
4426 pub fn update_widget_panel<'js>(
4427 &self,
4428 ctx: rquickjs::Ctx<'js>,
4429 panel_id: f64,
4430 spec_obj: rquickjs::Value<'js>,
4431 ) -> rquickjs::Result<bool> {
4432 let json = js_to_json(&ctx, spec_obj);
4433 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
4434 Ok(s) => s,
4435 Err(e) => {
4436 tracing::error!("updateWidgetPanel: invalid spec: {}", e);
4437 return Ok(false);
4438 }
4439 };
4440 Ok(self
4441 .command_sender
4442 .send(PluginCommand::UpdateWidgetPanel {
4443 panel_id: panel_id as u64,
4444 spec,
4445 })
4446 .is_ok())
4447 }
4448
4449 #[qjs(rename = "unmountWidgetPanel")]
4452 pub fn unmount_widget_panel(&self, panel_id: f64) -> bool {
4453 self.command_sender
4454 .send(PluginCommand::UnmountWidgetPanel {
4455 panel_id: panel_id as u64,
4456 })
4457 .is_ok()
4458 }
4459
4460 #[qjs(rename = "widgetCommand")]
4469 pub fn widget_command<'js>(
4470 &self,
4471 ctx: rquickjs::Ctx<'js>,
4472 panel_id: f64,
4473 action_obj: rquickjs::Value<'js>,
4474 ) -> rquickjs::Result<bool> {
4475 let json = js_to_json(&ctx, action_obj);
4476 let action: fresh_core::api::WidgetAction = match serde_json::from_value(json) {
4477 Ok(a) => a,
4478 Err(e) => {
4479 tracing::error!("widgetCommand: invalid action: {}", e);
4480 return Ok(false);
4481 }
4482 };
4483 Ok(self
4484 .command_sender
4485 .send(PluginCommand::WidgetCommand {
4486 panel_id: panel_id as u64,
4487 action,
4488 })
4489 .is_ok())
4490 }
4491
4492 #[qjs(rename = "widgetMutate")]
4498 pub fn widget_mutate<'js>(
4499 &self,
4500 ctx: rquickjs::Ctx<'js>,
4501 panel_id: f64,
4502 mutation_obj: rquickjs::Value<'js>,
4503 ) -> rquickjs::Result<bool> {
4504 let json = js_to_json(&ctx, mutation_obj);
4505 let mutation: fresh_core::api::WidgetMutation = match serde_json::from_value(json) {
4506 Ok(m) => m,
4507 Err(e) => {
4508 tracing::error!("widgetMutate: invalid mutation: {}", e);
4509 return Ok(false);
4510 }
4511 };
4512 Ok(self
4513 .command_sender
4514 .send(PluginCommand::WidgetMutate {
4515 panel_id: panel_id as u64,
4516 mutation,
4517 })
4518 .is_ok())
4519 }
4520
4521 #[qjs(rename = "mountFloatingWidget")]
4524 pub fn mount_floating_widget<'js>(
4525 &self,
4526 ctx: rquickjs::Ctx<'js>,
4527 panel_id: f64,
4528 spec_obj: rquickjs::Value<'js>,
4529 width_pct: f64,
4530 height_pct: f64,
4531 ) -> rquickjs::Result<bool> {
4532 let json = js_to_json(&ctx, spec_obj);
4533 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
4534 Ok(s) => s,
4535 Err(e) => {
4536 tracing::error!("mountFloatingWidget: invalid spec: {}", e);
4537 return Ok(false);
4538 }
4539 };
4540 let width_pct = width_pct.clamp(1.0, 100.0) as u8;
4541 let height_pct = height_pct.clamp(1.0, 100.0) as u8;
4542 Ok(self
4543 .command_sender
4544 .send(PluginCommand::MountFloatingWidget {
4545 panel_id: panel_id as u64,
4546 spec,
4547 width_pct,
4548 height_pct,
4549 })
4550 .is_ok())
4551 }
4552
4553 #[qjs(rename = "updateFloatingWidget")]
4555 pub fn update_floating_widget<'js>(
4556 &self,
4557 ctx: rquickjs::Ctx<'js>,
4558 panel_id: f64,
4559 spec_obj: rquickjs::Value<'js>,
4560 ) -> rquickjs::Result<bool> {
4561 let json = js_to_json(&ctx, spec_obj);
4562 let spec: fresh_core::api::WidgetSpec = match serde_json::from_value(json) {
4563 Ok(s) => s,
4564 Err(e) => {
4565 tracing::error!("updateFloatingWidget: invalid spec: {}", e);
4566 return Ok(false);
4567 }
4568 };
4569 Ok(self
4570 .command_sender
4571 .send(PluginCommand::UpdateFloatingWidget {
4572 panel_id: panel_id as u64,
4573 spec,
4574 })
4575 .is_ok())
4576 }
4577
4578 #[qjs(rename = "unmountFloatingWidget")]
4580 pub fn unmount_floating_widget(&self, panel_id: f64) -> bool {
4581 self.command_sender
4582 .send(PluginCommand::UnmountFloatingWidget {
4583 panel_id: panel_id as u64,
4584 })
4585 .is_ok()
4586 }
4587
4588 #[plugin_api(async_thenable, js_name = "spawnProcess", ts_return = "SpawnResult")]
4592 #[qjs(rename = "_spawnProcessStart")]
4593 pub fn spawn_process_start(
4594 &self,
4595 _ctx: rquickjs::Ctx<'_>,
4596 command: String,
4597 args: Vec<String>,
4598 cwd: rquickjs::function::Opt<String>,
4599 ) -> u64 {
4600 let id = self.alloc_request_id();
4601 let effective_cwd = cwd.0.filter(|s| !s.is_empty()).or_else(|| {
4607 self.state_snapshot
4608 .read()
4609 .ok()
4610 .map(|s| s.working_dir.to_string_lossy().to_string())
4611 });
4612 tracing::info!(
4613 "spawn_process_start: plugin='{}', command='{}', args={:?}, cwd={:?}, callback_id={}",
4614 self.plugin_name,
4615 command,
4616 args,
4617 effective_cwd,
4618 id
4619 );
4620 let _ = self.command_sender.send(PluginCommand::SpawnProcess {
4621 callback_id: JsCallbackId::new(id),
4622 command,
4623 args,
4624 cwd: effective_cwd,
4625 });
4626 id
4627 }
4628
4629 #[plugin_api(
4636 async_thenable,
4637 js_name = "spawnHostProcess",
4638 ts_return = "SpawnResult"
4639 )]
4640 #[qjs(rename = "_spawnHostProcessStart")]
4641 pub fn spawn_host_process_start(
4642 &self,
4643 _ctx: rquickjs::Ctx<'_>,
4644 command: String,
4645 args: Vec<String>,
4646 cwd: rquickjs::function::Opt<String>,
4647 ) -> u64 {
4648 let id = self.alloc_request_id();
4649 let effective_cwd = cwd.0.or_else(|| {
4650 self.state_snapshot
4651 .read()
4652 .ok()
4653 .map(|s| s.working_dir.to_string_lossy().to_string())
4654 });
4655 let _ = self.command_sender.send(PluginCommand::SpawnHostProcess {
4656 callback_id: JsCallbackId::new(id),
4657 command,
4658 args,
4659 cwd: effective_cwd,
4660 });
4661 id
4662 }
4663
4664 #[plugin_api(js_name = "_killHostProcess")]
4674 pub fn kill_host_process(&self, process_id: u64) -> bool {
4675 self.command_sender
4676 .send(PluginCommand::KillHostProcess { process_id })
4677 .is_ok()
4678 }
4679
4680 #[plugin_api(js_name = "setAuthority")]
4689 pub fn set_authority(
4690 &self,
4691 ctx: rquickjs::Ctx<'_>,
4692 #[plugin_api(ts_type = "AuthorityPayload")] payload: rquickjs::Value<'_>,
4693 ) -> bool {
4694 let json = js_to_json(&ctx, payload);
4695 let _ = self
4696 .command_sender
4697 .send(PluginCommand::SetAuthority { payload: json });
4698 true
4699 }
4700
4701 #[plugin_api(js_name = "clearAuthority")]
4704 pub fn clear_authority(&self) {
4705 let _ = self.command_sender.send(PluginCommand::ClearAuthority);
4706 }
4707
4708 #[plugin_api(js_name = "setRemoteIndicatorState")]
4726 pub fn set_remote_indicator_state(
4727 &self,
4728 ctx: rquickjs::Ctx<'_>,
4729 #[plugin_api(ts_type = "RemoteIndicatorStatePayload")] state: rquickjs::Value<'_>,
4730 ) -> bool {
4731 let json = js_to_json(&ctx, state);
4732 let _ = self
4733 .command_sender
4734 .send(PluginCommand::SetRemoteIndicatorState { state: json });
4735 true
4736 }
4737
4738 #[plugin_api(js_name = "clearRemoteIndicatorState")]
4741 pub fn clear_remote_indicator_state(&self) {
4742 let _ = self
4743 .command_sender
4744 .send(PluginCommand::ClearRemoteIndicatorState);
4745 }
4746
4747 #[plugin_api(async_promise, js_name = "spawnProcessWait", ts_return = "SpawnResult")]
4749 #[qjs(rename = "_spawnProcessWaitStart")]
4750 pub fn spawn_process_wait_start(&self, _ctx: rquickjs::Ctx<'_>, process_id: u64) -> u64 {
4751 let id = self.alloc_request_id();
4752 let _ = self.command_sender.send(PluginCommand::SpawnProcessWait {
4753 process_id,
4754 callback_id: JsCallbackId::new(id),
4755 });
4756 id
4757 }
4758
4759 #[plugin_api(async_promise, js_name = "getBufferText", ts_return = "string")]
4761 #[qjs(rename = "_getBufferTextStart")]
4762 pub fn get_buffer_text_start(
4763 &self,
4764 _ctx: rquickjs::Ctx<'_>,
4765 buffer_id: u32,
4766 start: u32,
4767 end: u32,
4768 ) -> u64 {
4769 let id = self.alloc_request_id();
4770 let _ = self.command_sender.send(PluginCommand::GetBufferText {
4771 buffer_id: BufferId(buffer_id as usize),
4772 start: start as usize,
4773 end: end as usize,
4774 request_id: id,
4775 });
4776 id
4777 }
4778
4779 #[plugin_api(async_promise, js_name = "delay", ts_return = "void")]
4781 #[qjs(rename = "_delayStart")]
4782 pub fn delay_start(&self, _ctx: rquickjs::Ctx<'_>, duration_ms: u64) -> u64 {
4783 let id = self.alloc_request_id();
4784 let _ = self.command_sender.send(PluginCommand::Delay {
4785 callback_id: JsCallbackId::new(id),
4786 duration_ms,
4787 });
4788 id
4789 }
4790
4791 #[plugin_api(async_promise, js_name = "grepProject", ts_return = "GrepMatch[]")]
4795 #[qjs(rename = "_grepProjectStart")]
4796 pub fn grep_project_start(
4797 &self,
4798 _ctx: rquickjs::Ctx<'_>,
4799 pattern: String,
4800 fixed_string: Option<bool>,
4801 case_sensitive: Option<bool>,
4802 max_results: Option<u32>,
4803 whole_words: Option<bool>,
4804 ) -> u64 {
4805 let id = self.alloc_request_id();
4806 let _ = self.command_sender.send(PluginCommand::GrepProject {
4807 pattern,
4808 fixed_string: fixed_string.unwrap_or(true),
4809 case_sensitive: case_sensitive.unwrap_or(true),
4810 max_results: max_results.unwrap_or(200) as usize,
4811 whole_words: whole_words.unwrap_or(false),
4812 callback_id: JsCallbackId::new(id),
4813 });
4814 id
4815 }
4816
4817 #[plugin_api(
4822 js_name = "beginSearch",
4823 ts_raw = "beginSearch(pattern: string, opts?: { fixedString?: boolean; caseSensitive?: boolean; maxResults?: number; wholeWords?: boolean }): SearchHandle"
4824 )]
4825 #[qjs(rename = "_beginSearch")]
4826 pub fn begin_search(
4827 &self,
4828 _ctx: rquickjs::Ctx<'_>,
4829 pattern: String,
4830 fixed_string: bool,
4831 case_sensitive: bool,
4832 max_results: u32,
4833 whole_words: bool,
4834 ) -> u64 {
4835 let id = self.alloc_request_id();
4836 let entry = Arc::new(SearchHandleState::new());
4839 if let Ok(mut map) = self.search_handles.lock() {
4840 map.insert(id, entry);
4841 }
4842 let _ = self.command_sender.send(PluginCommand::BeginSearch {
4843 pattern,
4844 fixed_string,
4845 case_sensitive,
4846 max_results: max_results as usize,
4847 whole_words,
4848 handle_id: id,
4849 });
4850 id
4851 }
4852
4853 #[plugin_api(ts_return = "SearchTakeResult")]
4858 #[qjs(rename = "_searchHandleTake")]
4859 pub fn search_handle_take<'js>(
4860 &self,
4861 ctx: rquickjs::Ctx<'js>,
4862 handle_id: u64,
4863 ) -> rquickjs::Result<Value<'js>> {
4864 let entry = self
4865 .search_handles
4866 .lock()
4867 .ok()
4868 .and_then(|m| m.get(&handle_id).cloned());
4869 let result = match entry {
4870 Some(handle) => {
4871 let mut state = match handle.state.lock() {
4873 Ok(s) => s,
4874 Err(poisoned) => poisoned.into_inner(),
4875 };
4876 let matches = std::mem::take(&mut state.pending);
4877 let snapshot = SearchTakeResult {
4878 matches,
4879 done: state.done,
4880 total_seen: state.total_seen,
4881 truncated: state.truncated,
4882 error: state.error.clone(),
4883 };
4884 let done = snapshot.done;
4885 drop(state);
4886 if done {
4887 if let Ok(mut map) = self.search_handles.lock() {
4888 map.remove(&handle_id);
4889 }
4890 }
4891 snapshot
4892 }
4893 None => SearchTakeResult {
4894 matches: Vec::new(),
4895 done: true,
4896 total_seen: 0,
4897 truncated: false,
4898 error: None,
4899 },
4900 };
4901 rquickjs_serde::to_value(ctx, &result)
4902 .map_err(|e| rquickjs::Error::new_from_js_message("serialize", "", &e.to_string()))
4903 }
4904
4905 #[qjs(rename = "_searchHandleCancel")]
4908 pub fn search_handle_cancel(&self, handle_id: u64) {
4909 if let Ok(map) = self.search_handles.lock() {
4910 if let Some(entry) = map.get(&handle_id) {
4911 entry
4912 .cancel
4913 .store(true, std::sync::atomic::Ordering::Relaxed);
4914 }
4915 }
4916 }
4917
4918 #[plugin_api(async_promise, js_name = "replaceInFile", ts_return = "ReplaceResult")]
4922 #[qjs(rename = "_replaceInFileStart")]
4923 pub fn replace_in_file_start(
4924 &self,
4925 _ctx: rquickjs::Ctx<'_>,
4926 file_path: String,
4927 matches: Vec<Vec<u32>>,
4928 replacement: String,
4929 ) -> u64 {
4930 let id = self.alloc_request_id();
4931 let match_pairs: Vec<(usize, usize)> = matches
4933 .iter()
4934 .map(|m| (m[0] as usize, m[1] as usize))
4935 .collect();
4936 let _ = self.command_sender.send(PluginCommand::ReplaceInBuffer {
4937 file_path: PathBuf::from(file_path),
4938 matches: match_pairs,
4939 replacement,
4940 callback_id: JsCallbackId::new(id),
4941 });
4942 id
4943 }
4944
4945 #[plugin_api(async_promise, js_name = "sendLspRequest", ts_return = "unknown")]
4947 #[qjs(rename = "_sendLspRequestStart")]
4948 pub fn send_lsp_request_start<'js>(
4949 &self,
4950 ctx: rquickjs::Ctx<'js>,
4951 language: String,
4952 method: String,
4953 params: Option<rquickjs::Object<'js>>,
4954 ) -> rquickjs::Result<u64> {
4955 let id = self.alloc_request_id();
4956 let params_json: Option<serde_json::Value> = params.map(|obj| {
4958 let val = obj.into_value();
4959 js_to_json(&ctx, val)
4960 });
4961 let _ = self.command_sender.send(PluginCommand::SendLspRequest {
4962 request_id: id,
4963 language,
4964 method,
4965 params: params_json,
4966 });
4967 Ok(id)
4968 }
4969
4970 #[plugin_api(
4972 async_thenable,
4973 js_name = "spawnBackgroundProcess",
4974 ts_return = "BackgroundProcessResult"
4975 )]
4976 #[qjs(rename = "_spawnBackgroundProcessStart")]
4977 pub fn spawn_background_process_start(
4978 &self,
4979 _ctx: rquickjs::Ctx<'_>,
4980 command: String,
4981 args: Vec<String>,
4982 cwd: rquickjs::function::Opt<String>,
4983 ) -> u64 {
4984 let id = self.alloc_request_id();
4985 let process_id = id;
4987 self.plugin_tracked_state
4989 .borrow_mut()
4990 .entry(self.plugin_name.clone())
4991 .or_default()
4992 .background_process_ids
4993 .push(process_id);
4994 let _ = self
4996 .command_sender
4997 .send(PluginCommand::SpawnBackgroundProcess {
4998 process_id,
4999 command,
5000 args,
5001 cwd: cwd.0.filter(|s| !s.is_empty()),
5002 callback_id: JsCallbackId::new(id),
5003 });
5004 id
5005 }
5006
5007 pub fn kill_background_process(&self, process_id: u64) -> bool {
5009 self.command_sender
5010 .send(PluginCommand::KillBackgroundProcess { process_id })
5011 .is_ok()
5012 }
5013
5014 #[plugin_api(
5018 async_promise,
5019 js_name = "createTerminal",
5020 ts_return = "TerminalResult"
5021 )]
5022 #[qjs(rename = "_createTerminalStart")]
5023 pub fn create_terminal_start(
5024 &self,
5025 _ctx: rquickjs::Ctx<'_>,
5026 opts: rquickjs::function::Opt<fresh_core::api::CreateTerminalOptions>,
5027 ) -> rquickjs::Result<u64> {
5028 let id = self.alloc_request_id();
5029
5030 let opts = opts.0.unwrap_or(fresh_core::api::CreateTerminalOptions {
5031 cwd: None,
5032 direction: None,
5033 ratio: None,
5034 focus: None,
5035 persistent: None,
5036 window_id: None,
5037 });
5038
5039 if let Ok(mut owners) = self.async_resource_owners.lock() {
5041 owners.insert(id, self.plugin_name.clone());
5042 }
5043 let _ = self.command_sender.send(PluginCommand::CreateTerminal {
5044 cwd: opts.cwd,
5045 direction: opts.direction,
5046 ratio: opts.ratio,
5047 focus: opts.focus,
5048 window_id: opts.window_id,
5049 persistent: opts.persistent.unwrap_or(false),
5053 request_id: id,
5054 });
5055 Ok(id)
5056 }
5057
5058 pub fn send_terminal_input(&self, terminal_id: u64, data: String) -> bool {
5060 self.command_sender
5061 .send(PluginCommand::SendTerminalInput {
5062 terminal_id: fresh_core::TerminalId(terminal_id as usize),
5063 data,
5064 })
5065 .is_ok()
5066 }
5067
5068 pub fn close_terminal(&self, terminal_id: u64) -> bool {
5070 self.command_sender
5071 .send(PluginCommand::CloseTerminal {
5072 terminal_id: fresh_core::TerminalId(terminal_id as usize),
5073 })
5074 .is_ok()
5075 }
5076
5077 pub fn signal_window(&self, id: f64, signal: String) -> bool {
5084 self.command_sender
5085 .send(PluginCommand::SignalWindow {
5086 id: fresh_core::WindowId(id as u64),
5087 signal,
5088 })
5089 .is_ok()
5090 }
5091
5092 pub fn refresh_lines(&self, buffer_id: u32) -> bool {
5096 self.command_sender
5097 .send(PluginCommand::RefreshLines {
5098 buffer_id: BufferId(buffer_id as usize),
5099 })
5100 .is_ok()
5101 }
5102
5103 pub fn get_current_locale(&self) -> String {
5105 self.services.current_locale()
5106 }
5107
5108 #[plugin_api(async_promise, js_name = "loadPlugin", ts_return = "boolean")]
5112 #[qjs(rename = "_loadPluginStart")]
5113 pub fn load_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, path: String) -> u64 {
5114 let id = self.alloc_request_id();
5115 let _ = self.command_sender.send(PluginCommand::LoadPlugin {
5116 path: std::path::PathBuf::from(path),
5117 callback_id: JsCallbackId::new(id),
5118 });
5119 id
5120 }
5121
5122 #[plugin_api(async_promise, js_name = "unloadPlugin", ts_return = "boolean")]
5124 #[qjs(rename = "_unloadPluginStart")]
5125 pub fn unload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
5126 let id = self.alloc_request_id();
5127 let _ = self.command_sender.send(PluginCommand::UnloadPlugin {
5128 name,
5129 callback_id: JsCallbackId::new(id),
5130 });
5131 id
5132 }
5133
5134 #[plugin_api(async_promise, js_name = "reloadPlugin", ts_return = "boolean")]
5136 #[qjs(rename = "_reloadPluginStart")]
5137 pub fn reload_plugin_start(&self, _ctx: rquickjs::Ctx<'_>, name: String) -> u64 {
5138 let id = self.alloc_request_id();
5139 let _ = self.command_sender.send(PluginCommand::ReloadPlugin {
5140 name,
5141 callback_id: JsCallbackId::new(id),
5142 });
5143 id
5144 }
5145
5146 #[plugin_api(
5149 async_promise,
5150 js_name = "listPlugins",
5151 ts_return = "Array<{name: string, path: string, enabled: boolean}>"
5152 )]
5153 #[qjs(rename = "_listPluginsStart")]
5154 pub fn list_plugins_start(&self, _ctx: rquickjs::Ctx<'_>) -> u64 {
5155 let id = self.alloc_request_id();
5156 let _ = self.command_sender.send(PluginCommand::ListPlugins {
5157 callback_id: JsCallbackId::new(id),
5158 });
5159 id
5160 }
5161}
5162
5163fn parse_view_token(
5170 obj: &rquickjs::Object<'_>,
5171 idx: usize,
5172) -> rquickjs::Result<fresh_core::api::ViewTokenWire> {
5173 use fresh_core::api::{ViewTokenWire, ViewTokenWireKind};
5174
5175 let kind_value: rquickjs::Value = obj.get("kind").map_err(|_| rquickjs::Error::FromJs {
5177 from: "object",
5178 to: "ViewTokenWire",
5179 message: Some(format!("token[{}]: missing required field 'kind'", idx)),
5180 })?;
5181
5182 let source_offset: Option<usize> = obj
5184 .get("sourceOffset")
5185 .ok()
5186 .or_else(|| obj.get("source_offset").ok());
5187
5188 let kind = if kind_value.is_string() {
5190 let kind_str: String = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
5193 from: "value",
5194 to: "string",
5195 message: Some(format!("token[{}]: 'kind' is not a valid string", idx)),
5196 })?;
5197
5198 match kind_str.to_lowercase().as_str() {
5199 "text" => {
5200 let text: String = obj.get("text").unwrap_or_default();
5201 ViewTokenWireKind::Text(text)
5202 }
5203 "newline" => ViewTokenWireKind::Newline,
5204 "space" => ViewTokenWireKind::Space,
5205 "break" => ViewTokenWireKind::Break,
5206 _ => {
5207 tracing::warn!(
5209 "token[{}]: unknown kind string '{}', expected one of: text, newline, space, break",
5210 idx, kind_str
5211 );
5212 return Err(rquickjs::Error::FromJs {
5213 from: "string",
5214 to: "ViewTokenWireKind",
5215 message: Some(format!(
5216 "token[{}]: unknown kind '{}', expected: text, newline, space, break, or {{Text: \"...\"}}",
5217 idx, kind_str
5218 )),
5219 });
5220 }
5221 }
5222 } else if kind_value.is_object() {
5223 let kind_obj: rquickjs::Object = kind_value.get().map_err(|_| rquickjs::Error::FromJs {
5225 from: "value",
5226 to: "object",
5227 message: Some(format!("token[{}]: 'kind' is not an object", idx)),
5228 })?;
5229
5230 if let Ok(text) = kind_obj.get::<_, String>("Text") {
5231 ViewTokenWireKind::Text(text)
5232 } else if let Ok(byte) = kind_obj.get::<_, u8>("BinaryByte") {
5233 ViewTokenWireKind::BinaryByte(byte)
5234 } else {
5235 let keys: Vec<String> = kind_obj.keys::<String>().filter_map(|k| k.ok()).collect();
5237 tracing::warn!(
5238 "token[{}]: kind object has unknown keys: {:?}, expected 'Text' or 'BinaryByte'",
5239 idx,
5240 keys
5241 );
5242 return Err(rquickjs::Error::FromJs {
5243 from: "object",
5244 to: "ViewTokenWireKind",
5245 message: Some(format!(
5246 "token[{}]: kind object must have 'Text' or 'BinaryByte' key, found: {:?}",
5247 idx, keys
5248 )),
5249 });
5250 }
5251 } else {
5252 tracing::warn!(
5253 "token[{}]: 'kind' field must be a string or object, got: {:?}",
5254 idx,
5255 kind_value.type_of()
5256 );
5257 return Err(rquickjs::Error::FromJs {
5258 from: "value",
5259 to: "ViewTokenWireKind",
5260 message: Some(format!(
5261 "token[{}]: 'kind' must be a string (e.g., \"text\") or object (e.g., {{Text: \"...\"}})",
5262 idx
5263 )),
5264 });
5265 };
5266
5267 let style = parse_view_token_style(obj, idx)?;
5269
5270 Ok(ViewTokenWire {
5271 source_offset,
5272 kind,
5273 style,
5274 })
5275}
5276
5277fn parse_view_token_style(
5279 obj: &rquickjs::Object<'_>,
5280 idx: usize,
5281) -> rquickjs::Result<Option<fresh_core::api::ViewTokenStyle>> {
5282 use fresh_core::api::{TokenColor, ViewTokenStyle};
5283
5284 let style_obj: Option<rquickjs::Object> = obj.get("style").ok();
5285 let Some(s) = style_obj else {
5286 return Ok(None);
5287 };
5288
5289 fn parse_color(
5294 s: &rquickjs::Object<'_>,
5295 field: &str,
5296 idx: usize,
5297 ) -> rquickjs::Result<Option<TokenColor>> {
5298 if let Ok(arr) = s.get::<_, Vec<u8>>(field) {
5299 if arr.len() < 3 {
5300 tracing::warn!(
5301 "token[{}]: style.{} has {} elements, expected 3 (RGB)",
5302 idx,
5303 field,
5304 arr.len()
5305 );
5306 return Ok(None);
5307 }
5308 return Ok(Some(TokenColor::Rgb(arr[0], arr[1], arr[2])));
5309 }
5310 if let Ok(name) = s.get::<_, String>(field) {
5311 return Ok(Some(TokenColor::Named(name)));
5312 }
5313 Ok(None)
5314 }
5315
5316 Ok(Some(ViewTokenStyle {
5317 fg: parse_color(&s, "fg", idx)?,
5318 bg: parse_color(&s, "bg", idx)?,
5319 bold: s.get("bold").unwrap_or(false),
5320 italic: s.get("italic").unwrap_or(false),
5321 }))
5322}
5323
5324pub struct QuickJsBackend {
5326 runtime: Runtime,
5327 main_context: Context,
5329 plugin_contexts: Rc<RefCell<HashMap<String, Context>>>,
5331 event_handlers: Rc<RefCell<HashMap<String, Vec<PluginHandler>>>>,
5333 registered_actions: Rc<RefCell<HashMap<String, PluginHandler>>>,
5335 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
5337 command_sender: mpsc::Sender<PluginCommand>,
5339 #[allow(dead_code)]
5341 pending_responses: PendingResponses,
5342 next_request_id: Rc<RefCell<u64>>,
5344 callback_contexts: Rc<RefCell<HashMap<u64, String>>>,
5346 pub services: Arc<dyn fresh_core::services::PluginServiceBridge>,
5348 pub(crate) plugin_tracked_state: Rc<RefCell<HashMap<String, PluginTrackedState>>>,
5350 async_resource_owners: AsyncResourceOwners,
5353 registered_command_names: Rc<RefCell<HashMap<String, String>>>,
5355 registered_grammar_languages: Rc<RefCell<HashMap<String, String>>>,
5357 registered_language_configs: Rc<RefCell<HashMap<String, String>>>,
5359 registered_lsp_servers: Rc<RefCell<HashMap<String, String>>>,
5361 plugin_api_exports: PluginApiExports,
5365 search_handles: SearchHandleRegistry,
5367}
5368
5369impl Drop for QuickJsBackend {
5370 fn drop(&mut self) {
5371 self.plugin_api_exports.borrow_mut().clear();
5377 }
5378}
5379
5380impl QuickJsBackend {
5381 pub fn new() -> Result<Self> {
5383 let (tx, _rx) = mpsc::channel();
5384 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
5385 let services = Arc::new(fresh_core::services::NoopServiceBridge);
5386 Self::with_state(state_snapshot, tx, services)
5387 }
5388
5389 pub fn with_state(
5391 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
5392 command_sender: mpsc::Sender<PluginCommand>,
5393 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
5394 ) -> Result<Self> {
5395 let pending_responses: PendingResponses = Arc::new(std::sync::Mutex::new(HashMap::new()));
5396 Self::with_state_and_responses(state_snapshot, command_sender, pending_responses, services)
5397 }
5398
5399 pub fn with_state_and_responses(
5401 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
5402 command_sender: mpsc::Sender<PluginCommand>,
5403 pending_responses: PendingResponses,
5404 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
5405 ) -> Result<Self> {
5406 let async_resource_owners: AsyncResourceOwners =
5407 Arc::new(std::sync::Mutex::new(HashMap::new()));
5408 let search_handles: SearchHandleRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
5409 Self::with_state_responses_and_resources(
5410 state_snapshot,
5411 command_sender,
5412 pending_responses,
5413 services,
5414 async_resource_owners,
5415 search_handles,
5416 )
5417 }
5418
5419 pub fn with_state_responses_and_resources(
5422 state_snapshot: Arc<RwLock<EditorStateSnapshot>>,
5423 command_sender: mpsc::Sender<PluginCommand>,
5424 pending_responses: PendingResponses,
5425 services: Arc<dyn fresh_core::services::PluginServiceBridge>,
5426 async_resource_owners: AsyncResourceOwners,
5427 search_handles: SearchHandleRegistry,
5428 ) -> Result<Self> {
5429 tracing::debug!("QuickJsBackend::new: creating QuickJS runtime");
5430
5431 let runtime =
5432 Runtime::new().map_err(|e| anyhow!("Failed to create QuickJS runtime: {}", e))?;
5433
5434 runtime.set_host_promise_rejection_tracker(Some(Box::new(
5436 |_ctx, _promise, reason, is_handled| {
5437 if !is_handled {
5438 let error_msg = if let Some(exc) = reason.as_exception() {
5440 format!(
5441 "{}: {}",
5442 exc.message().unwrap_or_default(),
5443 exc.stack().unwrap_or_default()
5444 )
5445 } else {
5446 format!("{:?}", reason)
5447 };
5448
5449 tracing::error!("Unhandled Promise rejection: {}", error_msg);
5450
5451 if should_panic_on_js_errors() {
5452 let full_msg = format!("Unhandled Promise rejection: {}", error_msg);
5455 set_fatal_js_error(full_msg);
5456 }
5457 }
5458 },
5459 )));
5460
5461 let main_context = Context::full(&runtime)
5462 .map_err(|e| anyhow!("Failed to create QuickJS context: {}", e))?;
5463
5464 let plugin_contexts = Rc::new(RefCell::new(HashMap::new()));
5465 let event_handlers = Rc::new(RefCell::new(HashMap::new()));
5466 let registered_actions = Rc::new(RefCell::new(HashMap::new()));
5467 let next_request_id = Rc::new(RefCell::new(1u64));
5468 let callback_contexts = Rc::new(RefCell::new(HashMap::new()));
5469 let plugin_tracked_state = Rc::new(RefCell::new(HashMap::new()));
5470 let registered_command_names = Rc::new(RefCell::new(HashMap::new()));
5471 let registered_grammar_languages = Rc::new(RefCell::new(HashMap::new()));
5472 let registered_language_configs = Rc::new(RefCell::new(HashMap::new()));
5473 let registered_lsp_servers = Rc::new(RefCell::new(HashMap::new()));
5474 let plugin_api_exports = Rc::new(RefCell::new(HashMap::new()));
5475
5476 let backend = Self {
5477 runtime,
5478 main_context,
5479 plugin_contexts,
5480 event_handlers,
5481 registered_actions,
5482 state_snapshot,
5483 command_sender,
5484 pending_responses,
5485 next_request_id,
5486 callback_contexts,
5487 services,
5488 plugin_tracked_state,
5489 async_resource_owners,
5490 registered_command_names,
5491 registered_grammar_languages,
5492 registered_language_configs,
5493 registered_lsp_servers,
5494 plugin_api_exports,
5495 search_handles,
5496 };
5497
5498 backend.setup_context_api(&backend.main_context.clone(), "internal")?;
5500
5501 tracing::debug!("QuickJsBackend::new: runtime created successfully");
5502 Ok(backend)
5503 }
5504
5505 fn setup_context_api(&self, context: &Context, plugin_name: &str) -> Result<()> {
5507 let state_snapshot = Arc::clone(&self.state_snapshot);
5508 let command_sender = self.command_sender.clone();
5509 let event_handlers = Rc::clone(&self.event_handlers);
5510 let registered_actions = Rc::clone(&self.registered_actions);
5511 let next_request_id = Rc::clone(&self.next_request_id);
5512 let registered_command_names = Rc::clone(&self.registered_command_names);
5513 let registered_grammar_languages = Rc::clone(&self.registered_grammar_languages);
5514 let registered_language_configs = Rc::clone(&self.registered_language_configs);
5515 let registered_lsp_servers = Rc::clone(&self.registered_lsp_servers);
5516 let plugin_api_exports = Rc::clone(&self.plugin_api_exports);
5517
5518 context.with(|ctx| {
5519 let globals = ctx.globals();
5520
5521 globals.set("__pluginName__", plugin_name)?;
5523
5524 let js_api = JsEditorApi {
5527 state_snapshot: Arc::clone(&state_snapshot),
5528 command_sender: command_sender.clone(),
5529 registered_actions: Rc::clone(®istered_actions),
5530 event_handlers: Rc::clone(&event_handlers),
5531 next_request_id: Rc::clone(&next_request_id),
5532 callback_contexts: Rc::clone(&self.callback_contexts),
5533 services: self.services.clone(),
5534 plugin_tracked_state: Rc::clone(&self.plugin_tracked_state),
5535 async_resource_owners: Arc::clone(&self.async_resource_owners),
5536 registered_command_names: Rc::clone(®istered_command_names),
5537 registered_grammar_languages: Rc::clone(®istered_grammar_languages),
5538 registered_language_configs: Rc::clone(®istered_language_configs),
5539 registered_lsp_servers: Rc::clone(®istered_lsp_servers),
5540 plugin_api_exports: Rc::clone(&plugin_api_exports),
5541 search_handles: Arc::clone(&self.search_handles),
5542 plugin_name: plugin_name.to_string(),
5543 };
5544 let editor = rquickjs::Class::<JsEditorApi>::instance(ctx.clone(), js_api)?;
5545
5546 globals.set("editor", editor)?;
5548
5549 ctx.eval::<(), _>("globalThis.getEditor = function() { return editor; };")?;
5551
5552 ctx.eval::<(), _>("globalThis.registerHandler = function(name, fn) { globalThis[name] = fn; };")?;
5554
5555ctx.eval::<(), _>(
5562 r#"
5563 (function() {
5564 const originalOn = editor.on.bind(editor);
5565 const originalOff = editor.off.bind(editor);
5566 let counter = 0;
5567 const anonNames = new WeakMap();
5568 editor.on = function(eventName, handlerOrName) {
5569 if (typeof handlerOrName === 'function') {
5570 const existing = anonNames.get(handlerOrName);
5571 const name = existing || `__anon_on_${++counter}`;
5572 if (!existing) {
5573 anonNames.set(handlerOrName, name);
5574 }
5575 globalThis[name] = handlerOrName;
5576 return originalOn(eventName, name);
5577 }
5578 return originalOn(eventName, handlerOrName);
5579 };
5580 editor.off = function(eventName, handlerOrName) {
5581 if (typeof handlerOrName === 'function') {
5582 const name = anonNames.get(handlerOrName);
5583 if (name === undefined) return false;
5584 return originalOff(eventName, name);
5585 }
5586 return originalOff(eventName, handlerOrName);
5587 };
5588 })();
5589 "#,
5590 )?;
5591
5592 let console = Object::new(ctx.clone())?;
5595 console.set("log", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
5596 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
5597 tracing::info!("console.log: {}", parts.join(" "));
5598 })?)?;
5599 console.set("warn", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
5600 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
5601 tracing::warn!("console.warn: {}", parts.join(" "));
5602 })?)?;
5603 console.set("error", Function::new(ctx.clone(), |ctx: rquickjs::Ctx, args: rquickjs::function::Rest<rquickjs::Value>| {
5604 let parts: Vec<String> = args.0.iter().map(|v| js_value_to_string(&ctx, v)).collect();
5605 tracing::error!("console.error: {}", parts.join(" "));
5606 })?)?;
5607 globals.set("console", console)?;
5608
5609 ctx.eval::<(), _>(r#"
5611 // Pending promise callbacks: callbackId -> { resolve, reject }
5612 globalThis._pendingCallbacks = new Map();
5613
5614 // Resolve a pending callback (called from Rust)
5615 globalThis._resolveCallback = function(callbackId, result) {
5616 console.log('[JS] _resolveCallback called with callbackId=' + callbackId + ', pendingCallbacks.size=' + globalThis._pendingCallbacks.size);
5617 const cb = globalThis._pendingCallbacks.get(callbackId);
5618 if (cb) {
5619 console.log('[JS] _resolveCallback: found callback, calling resolve()');
5620 globalThis._pendingCallbacks.delete(callbackId);
5621 cb.resolve(result);
5622 console.log('[JS] _resolveCallback: resolve() called');
5623 } else {
5624 console.log('[JS] _resolveCallback: NO callback found for id=' + callbackId);
5625 }
5626 };
5627
5628 // Reject a pending callback (called from Rust)
5629 globalThis._rejectCallback = function(callbackId, error) {
5630 const cb = globalThis._pendingCallbacks.get(callbackId);
5631 if (cb) {
5632 globalThis._pendingCallbacks.delete(callbackId);
5633 cb.reject(new Error(error));
5634 }
5635 };
5636
5637 // Generic async wrapper decorator
5638 // Wraps a function that returns a callbackId into a promise-returning function
5639 // Usage: editor.foo = _wrapAsync("_fooStart", "foo");
5640 // NOTE: We pass the method name as a string and call via bracket notation
5641 // to preserve rquickjs's automatic Ctx injection for methods
5642 globalThis._wrapAsync = function(methodName, fnName) {
5643 const startFn = editor[methodName];
5644 if (typeof startFn !== 'function') {
5645 // Return a function that always throws - catches missing implementations
5646 return function(...args) {
5647 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
5648 editor.debug(`[ASYNC ERROR] ${error.message}`);
5649 throw error;
5650 };
5651 }
5652 return function(...args) {
5653 // Call via bracket notation to preserve method binding and Ctx injection
5654 const callbackId = editor[methodName](...args);
5655 return new Promise((resolve, reject) => {
5656 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
5657 // TODO: Implement setTimeout polyfill using editor.delay() or similar
5658 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
5659 });
5660 };
5661 };
5662
5663 // Async wrapper that returns a thenable object (for APIs like spawnProcess)
5664 // The returned object has .result promise and is itself thenable
5665 globalThis._wrapAsyncThenable = function(methodName, fnName) {
5666 const startFn = editor[methodName];
5667 if (typeof startFn !== 'function') {
5668 // Return a function that always throws - catches missing implementations
5669 return function(...args) {
5670 const error = new Error(`editor.${fnName || 'unknown'} is not implemented (missing ${methodName})`);
5671 editor.debug(`[ASYNC ERROR] ${error.message}`);
5672 throw error;
5673 };
5674 }
5675 return function(...args) {
5676 // Call via bracket notation to preserve method binding and Ctx injection
5677 const callbackId = editor[methodName](...args);
5678 const resultPromise = new Promise((resolve, reject) => {
5679 // NOTE: setTimeout not available in QuickJS - timeout disabled for now
5680 globalThis._pendingCallbacks.set(callbackId, { resolve, reject });
5681 });
5682 return {
5683 get result() { return resultPromise; },
5684 then(onFulfilled, onRejected) {
5685 return resultPromise.then(onFulfilled, onRejected);
5686 },
5687 catch(onRejected) {
5688 return resultPromise.catch(onRejected);
5689 }
5690 };
5691 };
5692 };
5693
5694 // Apply wrappers to async functions on editor
5695 editor.spawnProcess = _wrapAsyncThenable("_spawnProcessStart", "spawnProcess");
5696 // spawnHostProcess gets a bespoke wrapper (instead of
5697 // `_wrapAsyncThenable`) because its `ProcessHandle`
5698 // exposes a real `kill()` that forwards to
5699 // `_killHostProcess`. Generic wrap has no hook for
5700 // that.
5701 editor.spawnHostProcess = function(command, args, cwd) {
5702 if (typeof editor._spawnHostProcessStart !== 'function') {
5703 throw new Error('editor.spawnHostProcess is not implemented (missing _spawnHostProcessStart)');
5704 }
5705 // Pass real strings only. Earlier revisions forwarded
5706 // `""` for a missing cwd, which landed verbatim as
5707 // `Command::current_dir("")` in the dispatcher —
5708 // every host-spawn then failed with ENOENT. Use two
5709 // arity forms so the Rust `Opt<String>` stays `None`
5710 // instead of `Some("")`.
5711 let callbackId;
5712 if (typeof cwd === "string" && cwd.length > 0) {
5713 callbackId = editor._spawnHostProcessStart(command, args || [], cwd);
5714 } else {
5715 callbackId = editor._spawnHostProcessStart(command, args || []);
5716 }
5717 const resultPromise = new Promise(function(resolve, reject) {
5718 globalThis._pendingCallbacks.set(callbackId, { resolve: resolve, reject: reject });
5719 });
5720 return {
5721 processId: callbackId,
5722 get result() { return resultPromise; },
5723 then: function(f, r) { return resultPromise.then(f, r); },
5724 catch: function(r) { return resultPromise.catch(r); },
5725 kill: function() {
5726 // Returns true when the kill was enqueued
5727 // (the process may have already exited; in
5728 // that case the dispatcher silently
5729 // drops it). Matches the
5730 // `ProcessHandle.kill(): Promise<boolean>`
5731 // type signature by wrapping the sync
5732 // boolean in a Promise.
5733 return Promise.resolve(editor._killHostProcess(callbackId));
5734 }
5735 };
5736 };
5737 editor.delay = _wrapAsync("_delayStart", "delay");
5738 editor.createVirtualBuffer = _wrapAsync("_createVirtualBufferStart", "createVirtualBuffer");
5739 editor.createVirtualBufferInSplit = _wrapAsync("_createVirtualBufferInSplitStart", "createVirtualBufferInSplit");
5740 editor.createVirtualBufferInExistingSplit = _wrapAsync("_createVirtualBufferInExistingSplitStart", "createVirtualBufferInExistingSplit");
5741 editor.createBufferGroup = _wrapAsync("_createBufferGroupStart", "createBufferGroup");
5742 editor.sendLspRequest = _wrapAsync("_sendLspRequestStart", "sendLspRequest");
5743 editor.spawnBackgroundProcess = _wrapAsyncThenable("_spawnBackgroundProcessStart", "spawnBackgroundProcess");
5744 editor.spawnProcessWait = _wrapAsync("_spawnProcessWaitStart", "spawnProcessWait");
5745 editor.getBufferText = _wrapAsync("_getBufferTextStart", "getBufferText");
5746 editor.createCompositeBuffer = _wrapAsync("_createCompositeBufferStart", "createCompositeBuffer");
5747 editor.getHighlights = _wrapAsync("_getHighlightsStart", "getHighlights");
5748 editor.loadPlugin = _wrapAsync("_loadPluginStart", "loadPlugin");
5749 editor.unloadPlugin = _wrapAsync("_unloadPluginStart", "unloadPlugin");
5750 editor.reloadPlugin = _wrapAsync("_reloadPluginStart", "reloadPlugin");
5751 editor.listPlugins = _wrapAsync("_listPluginsStart", "listPlugins");
5752 editor.prompt = _wrapAsync("_promptStart", "prompt");
5753 editor.getNextKey = _wrapAsync("_getNextKeyStart", "getNextKey");
5754 editor.getLineStartPosition = _wrapAsync("_getLineStartPositionStart", "getLineStartPosition");
5755 editor.getLineEndPosition = _wrapAsync("_getLineEndPositionStart", "getLineEndPosition");
5756 editor.createTerminal = _wrapAsync("_createTerminalStart", "createTerminal");
5757 editor.reloadGrammars = _wrapAsync("_reloadGrammarsStart", "reloadGrammars");
5758 editor.grepProject = _wrapAsync("_grepProjectStart", "grepProject");
5759 editor.replaceInFile = _wrapAsync("_replaceInFileStart", "replaceInFile");
5760
5761 // Pull-based streaming search. Producers (host searcher tasks)
5762 // write into shared state at full speed; the consumer drains
5763 // it via take() at its own cadence — no per-chunk JS dispatch.
5764 editor.beginSearch = function(pattern, opts) {
5765 opts = opts || {};
5766 const fixedString = opts.fixedString !== undefined ? opts.fixedString : true;
5767 const caseSensitive = opts.caseSensitive !== undefined ? opts.caseSensitive : true;
5768 const maxResults = opts.maxResults || 10000;
5769 const wholeWords = opts.wholeWords || false;
5770 const handleId = editor._beginSearch(
5771 pattern, fixedString, caseSensitive, maxResults, wholeWords
5772 );
5773 return {
5774 searchId: handleId,
5775 take: function() { return editor._searchHandleTake(handleId); },
5776 cancel: function() { editor._searchHandleCancel(handleId); }
5777 };
5778 };
5779
5780 // Wrapper for deleteTheme - wraps sync function in Promise
5781 editor.deleteTheme = function(name) {
5782 return new Promise(function(resolve, reject) {
5783 const success = editor._deleteThemeSync(name);
5784 if (success) {
5785 resolve();
5786 } else {
5787 reject(new Error("Failed to delete theme: " + name));
5788 }
5789 });
5790 };
5791 "#.as_bytes())?;
5792
5793 Ok::<_, rquickjs::Error>(())
5794 }).map_err(|e| anyhow!("Failed to set up global API: {}", e))?;
5795
5796 Ok(())
5797 }
5798
5799 pub async fn load_module_with_source(
5801 &mut self,
5802 path: &str,
5803 _plugin_source: &str,
5804 ) -> Result<()> {
5805 let path_buf = PathBuf::from(path);
5806 let source = std::fs::read_to_string(&path_buf)
5807 .map_err(|e| anyhow!("Failed to read plugin {}: {}", path, e))?;
5808
5809 let filename = path_buf
5810 .file_name()
5811 .and_then(|s| s.to_str())
5812 .unwrap_or("plugin.ts");
5813
5814 if has_es_imports(&source) {
5816 match bundle_module(&path_buf) {
5818 Ok(bundled) => {
5819 self.execute_js(&bundled, path)?;
5820 }
5821 Err(e) => {
5822 tracing::warn!(
5823 "Plugin {} uses ES imports but bundling failed: {}. Skipping.",
5824 path,
5825 e
5826 );
5827 return Ok(()); }
5829 }
5830 } else if has_es_module_syntax(&source) {
5831 let stripped = strip_imports_and_exports(&source);
5833 let js_code = if filename.ends_with(".ts") {
5834 transpile_typescript(&stripped, filename)?
5835 } else {
5836 stripped
5837 };
5838 self.execute_js(&js_code, path)?;
5839 } else {
5840 let js_code = if filename.ends_with(".ts") {
5842 transpile_typescript(&source, filename)?
5843 } else {
5844 source
5845 };
5846 self.execute_js(&js_code, path)?;
5847 }
5848
5849 Ok(())
5850 }
5851
5852 pub(crate) fn execute_js(&mut self, code: &str, source_name: &str) -> Result<()> {
5854 let plugin_name = Path::new(source_name)
5856 .file_stem()
5857 .and_then(|s| s.to_str())
5858 .unwrap_or("unknown");
5859
5860 tracing::debug!(
5861 "execute_js: starting for plugin '{}' from '{}'",
5862 plugin_name,
5863 source_name
5864 );
5865
5866 let context = {
5868 let mut contexts = self.plugin_contexts.borrow_mut();
5869 if let Some(ctx) = contexts.get(plugin_name) {
5870 ctx.clone()
5871 } else {
5872 let ctx = Context::full(&self.runtime).map_err(|e| {
5873 anyhow!(
5874 "Failed to create QuickJS context for plugin {}: {}",
5875 plugin_name,
5876 e
5877 )
5878 })?;
5879 self.setup_context_api(&ctx, plugin_name)?;
5880 contexts.insert(plugin_name.to_string(), ctx.clone());
5881 ctx
5882 }
5883 };
5884
5885 let wrapped_code = format!("(function() {{ {} }})();", code);
5889 let wrapped = wrapped_code.as_str();
5890
5891 context.with(|ctx| {
5892 tracing::debug!("execute_js: executing plugin code for '{}'", plugin_name);
5893
5894 let mut eval_options = rquickjs::context::EvalOptions::default();
5896 eval_options.global = true;
5897 eval_options.filename = Some(source_name.to_string());
5898 let result = ctx
5899 .eval_with_options::<(), _>(wrapped.as_bytes(), eval_options)
5900 .map_err(|e| format_js_error(&ctx, e, source_name));
5901
5902 tracing::debug!(
5903 "execute_js: plugin code execution finished for '{}', result: {:?}",
5904 plugin_name,
5905 result.is_ok()
5906 );
5907
5908 result
5909 })
5910 }
5911
5912 pub fn execute_source(
5918 &mut self,
5919 source: &str,
5920 plugin_name: &str,
5921 is_typescript: bool,
5922 ) -> Result<()> {
5923 use fresh_parser_js::{
5924 has_es_imports, has_es_module_syntax, strip_imports_and_exports, transpile_typescript,
5925 };
5926
5927 if has_es_imports(source) {
5928 tracing::warn!(
5929 "Buffer plugin '{}' has ES imports which cannot be resolved (no filesystem path). Stripping them.",
5930 plugin_name
5931 );
5932 }
5933
5934 let js_code = if has_es_module_syntax(source) {
5935 let stripped = strip_imports_and_exports(source);
5936 if is_typescript {
5937 transpile_typescript(&stripped, &format!("{}.ts", plugin_name))?
5938 } else {
5939 stripped
5940 }
5941 } else if is_typescript {
5942 transpile_typescript(source, &format!("{}.ts", plugin_name))?
5943 } else {
5944 source.to_string()
5945 };
5946
5947 let source_name = format!(
5949 "{}.{}",
5950 plugin_name,
5951 if is_typescript { "ts" } else { "js" }
5952 );
5953 self.execute_js(&js_code, &source_name)
5954 }
5955
5956 pub fn cleanup_plugin(&self, plugin_name: &str) {
5962 self.plugin_contexts.borrow_mut().remove(plugin_name);
5964
5965 for handlers in self.event_handlers.borrow_mut().values_mut() {
5967 handlers.retain(|h| h.plugin_name != plugin_name);
5968 }
5969
5970 self.registered_actions
5972 .borrow_mut()
5973 .retain(|_, h| h.plugin_name != plugin_name);
5974
5975 self.callback_contexts
5977 .borrow_mut()
5978 .retain(|_, pname| pname != plugin_name);
5979
5980 if let Some(tracked) = self.plugin_tracked_state.borrow_mut().remove(plugin_name) {
5982 let mut seen_overlay_ns: std::collections::HashSet<(usize, String)> =
5984 std::collections::HashSet::new();
5985 for (buf_id, ns) in &tracked.overlay_namespaces {
5986 if seen_overlay_ns.insert((buf_id.0, ns.clone())) {
5987 let _ = self.command_sender.send(PluginCommand::ClearNamespace {
5989 buffer_id: *buf_id,
5990 namespace: OverlayNamespace::from_string(ns.clone()),
5991 });
5992 let _ = self
5994 .command_sender
5995 .send(PluginCommand::ClearConcealNamespace {
5996 buffer_id: *buf_id,
5997 namespace: OverlayNamespace::from_string(ns.clone()),
5998 });
5999 let _ = self
6000 .command_sender
6001 .send(PluginCommand::ClearSoftBreakNamespace {
6002 buffer_id: *buf_id,
6003 namespace: OverlayNamespace::from_string(ns.clone()),
6004 });
6005 }
6006 }
6007
6008 let mut seen_li_ns: std::collections::HashSet<(usize, String)> =
6014 std::collections::HashSet::new();
6015 for (buf_id, ns) in &tracked.line_indicator_namespaces {
6016 if seen_li_ns.insert((buf_id.0, ns.clone())) {
6017 let _ = self
6018 .command_sender
6019 .send(PluginCommand::ClearLineIndicators {
6020 buffer_id: *buf_id,
6021 namespace: ns.clone(),
6022 });
6023 }
6024 }
6025
6026 let mut seen_vt: std::collections::HashSet<(usize, String)> =
6028 std::collections::HashSet::new();
6029 for (buf_id, vt_id) in &tracked.virtual_text_ids {
6030 if seen_vt.insert((buf_id.0, vt_id.clone())) {
6031 let _ = self.command_sender.send(PluginCommand::RemoveVirtualText {
6032 buffer_id: *buf_id,
6033 virtual_text_id: vt_id.clone(),
6034 });
6035 }
6036 }
6037
6038 let mut seen_fe_ns: std::collections::HashSet<String> =
6040 std::collections::HashSet::new();
6041 for ns in &tracked.file_explorer_namespaces {
6042 if seen_fe_ns.insert(ns.clone()) {
6043 let _ = self
6044 .command_sender
6045 .send(PluginCommand::ClearFileExplorerDecorations {
6046 namespace: ns.clone(),
6047 });
6048 }
6049 }
6050
6051 let mut seen_ctx: std::collections::HashSet<String> = std::collections::HashSet::new();
6053 for ctx_name in &tracked.contexts_set {
6054 if seen_ctx.insert(ctx_name.clone()) {
6055 let _ = self.command_sender.send(PluginCommand::SetContext {
6056 name: ctx_name.clone(),
6057 active: false,
6058 });
6059 }
6060 }
6061
6062 for process_id in &tracked.background_process_ids {
6066 let _ = self
6067 .command_sender
6068 .send(PluginCommand::KillBackgroundProcess {
6069 process_id: *process_id,
6070 });
6071 }
6072
6073 for group_id in &tracked.scroll_sync_group_ids {
6075 let _ = self
6076 .command_sender
6077 .send(PluginCommand::RemoveScrollSyncGroup {
6078 group_id: *group_id,
6079 });
6080 }
6081
6082 for buffer_id in &tracked.virtual_buffer_ids {
6084 let _ = self.command_sender.send(PluginCommand::CloseBuffer {
6085 buffer_id: *buffer_id,
6086 });
6087 }
6088
6089 for buffer_id in &tracked.composite_buffer_ids {
6091 let _ = self
6092 .command_sender
6093 .send(PluginCommand::CloseCompositeBuffer {
6094 buffer_id: *buffer_id,
6095 });
6096 }
6097
6098 for terminal_id in &tracked.terminal_ids {
6100 let _ = self.command_sender.send(PluginCommand::CloseTerminal {
6101 terminal_id: *terminal_id,
6102 });
6103 }
6104
6105 for handle in &tracked.watch_handles {
6109 let _ = self
6110 .command_sender
6111 .send(PluginCommand::UnwatchPath { handle: *handle });
6112 }
6113 }
6114
6115 if let Ok(mut owners) = self.async_resource_owners.lock() {
6117 owners.retain(|_, name| name != plugin_name);
6118 }
6119
6120 self.plugin_api_exports
6122 .borrow_mut()
6123 .retain(|_, (exporter, _)| exporter != plugin_name);
6124
6125 self.registered_command_names
6127 .borrow_mut()
6128 .retain(|_, pname| pname != plugin_name);
6129 self.registered_grammar_languages
6130 .borrow_mut()
6131 .retain(|_, pname| pname != plugin_name);
6132 self.registered_language_configs
6133 .borrow_mut()
6134 .retain(|_, pname| pname != plugin_name);
6135 self.registered_lsp_servers
6136 .borrow_mut()
6137 .retain(|_, pname| pname != plugin_name);
6138
6139 tracing::debug!(
6140 "cleanup_plugin: cleaned up runtime state for plugin '{}'",
6141 plugin_name
6142 );
6143 }
6144
6145 pub async fn emit(&mut self, event_name: &str, event_data: &serde_json::Value) -> Result<bool> {
6147 tracing::trace!("emit: event '{}' with data: {:?}", event_name, event_data);
6148
6149 self.services
6150 .set_js_execution_state(format!("hook '{}'", event_name));
6151
6152 let handlers = self.event_handlers.borrow().get(event_name).cloned();
6153 if let Some(handler_pairs) = handlers {
6154 let plugin_contexts = self.plugin_contexts.borrow();
6155 for handler in &handler_pairs {
6156 let Some(context) = plugin_contexts.get(&handler.plugin_name) else {
6157 continue;
6158 };
6159 context.with(|ctx| {
6160 call_handler(&ctx, &handler.handler_name, event_data);
6161 });
6162 }
6163 }
6164
6165 self.services.clear_js_execution_state();
6166 Ok(true)
6167 }
6168
6169 pub fn has_handlers(&self, event_name: &str) -> bool {
6171 self.event_handlers
6172 .borrow()
6173 .get(event_name)
6174 .map(|v| !v.is_empty())
6175 .unwrap_or(false)
6176 }
6177
6178 pub fn start_action(&mut self, action_name: &str) -> Result<()> {
6182 let (lookup_name, text_input_char) =
6185 if let Some(ch) = action_name.strip_prefix("mode_text_input:") {
6186 ("mode_text_input", Some(ch.to_string()))
6187 } else {
6188 (action_name, None)
6189 };
6190
6191 let pair = self.registered_actions.borrow().get(lookup_name).cloned();
6192 let (plugin_name, function_name) = match pair {
6193 Some(handler) => (handler.plugin_name, handler.handler_name),
6194 None => ("main".to_string(), lookup_name.to_string()),
6195 };
6196
6197 let plugin_contexts = self.plugin_contexts.borrow();
6198 let context = plugin_contexts
6199 .get(&plugin_name)
6200 .unwrap_or(&self.main_context);
6201
6202 self.services
6204 .set_js_execution_state(format!("action '{}' (fn: {})", action_name, function_name));
6205
6206 tracing::info!(
6207 "start_action: BEGIN '{}' -> function '{}'",
6208 action_name,
6209 function_name
6210 );
6211
6212 let call_args = if let Some(ref ch) = text_input_char {
6215 let escaped = ch.replace('\\', "\\\\").replace('\"', "\\\"");
6216 format!("({{text:\"{}\"}})", escaped)
6217 } else {
6218 "()".to_string()
6219 };
6220
6221 let code = format!(
6222 r#"
6223 (function() {{
6224 console.log('[JS] start_action: calling {fn}');
6225 try {{
6226 if (typeof globalThis.{fn} === 'function') {{
6227 console.log('[JS] start_action: {fn} is a function, invoking...');
6228 globalThis.{fn}{args};
6229 console.log('[JS] start_action: {fn} invoked (may be async)');
6230 }} else {{
6231 console.error('[JS] Action {action} is not defined as a global function');
6232 }}
6233 }} catch (e) {{
6234 console.error('[JS] Action {action} error:', e);
6235 }}
6236 }})();
6237 "#,
6238 fn = function_name,
6239 action = action_name,
6240 args = call_args
6241 );
6242
6243 tracing::info!("start_action: evaluating JS code");
6244 context.with(|ctx| {
6245 if let Err(e) = ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
6246 log_js_error(&ctx, e, &format!("action {}", action_name));
6247 }
6248 tracing::info!("start_action: running pending microtasks");
6249 let count = run_pending_jobs_checked(&ctx, &format!("start_action {}", action_name));
6251 tracing::info!("start_action: executed {} pending jobs", count);
6252 });
6253
6254 tracing::info!("start_action: END '{}'", action_name);
6255
6256 self.services.clear_js_execution_state();
6258
6259 Ok(())
6260 }
6261
6262 pub async fn execute_action(&mut self, action_name: &str) -> Result<()> {
6264 let pair = self.registered_actions.borrow().get(action_name).cloned();
6266 let (plugin_name, function_name) = match pair {
6267 Some(handler) => (handler.plugin_name, handler.handler_name),
6268 None => ("main".to_string(), action_name.to_string()),
6269 };
6270
6271 let plugin_contexts = self.plugin_contexts.borrow();
6272 let context = plugin_contexts
6273 .get(&plugin_name)
6274 .unwrap_or(&self.main_context);
6275
6276 tracing::debug!(
6277 "execute_action: '{}' -> function '{}'",
6278 action_name,
6279 function_name
6280 );
6281
6282 let code = format!(
6285 r#"
6286 (async function() {{
6287 try {{
6288 if (typeof globalThis.{fn} === 'function') {{
6289 const result = globalThis.{fn}();
6290 // If it's a Promise, await it
6291 if (result && typeof result.then === 'function') {{
6292 await result;
6293 }}
6294 }} else {{
6295 console.error('Action {action} is not defined as a global function');
6296 }}
6297 }} catch (e) {{
6298 console.error('Action {action} error:', e);
6299 }}
6300 }})();
6301 "#,
6302 fn = function_name,
6303 action = action_name
6304 );
6305
6306 context.with(|ctx| {
6307 match ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
6309 Ok(value) => {
6310 if value.is_object() {
6312 if let Some(obj) = value.as_object() {
6313 if obj.get::<_, rquickjs::Function>("then").is_ok() {
6315 run_pending_jobs_checked(
6318 &ctx,
6319 &format!("execute_action {} promise", action_name),
6320 );
6321 }
6322 }
6323 }
6324 }
6325 Err(e) => {
6326 log_js_error(&ctx, e, &format!("action {}", action_name));
6327 }
6328 }
6329 });
6330
6331 Ok(())
6332 }
6333
6334 pub fn poll_event_loop_once(&mut self) -> bool {
6336 let mut had_work = false;
6337
6338 self.main_context.with(|ctx| {
6340 let count = run_pending_jobs_checked(&ctx, "poll_event_loop main");
6341 if count > 0 {
6342 had_work = true;
6343 }
6344 });
6345
6346 let contexts = self.plugin_contexts.borrow().clone();
6348 for (name, context) in contexts {
6349 context.with(|ctx| {
6350 let count = run_pending_jobs_checked(&ctx, &format!("poll_event_loop {}", name));
6351 if count > 0 {
6352 had_work = true;
6353 }
6354 });
6355 }
6356 had_work
6357 }
6358
6359 pub fn send_status(&self, message: String) {
6361 let _ = self
6362 .command_sender
6363 .send(PluginCommand::SetStatus { message });
6364 }
6365
6366 pub fn send_hook_completed(&self, hook_name: String) {
6370 let _ = self
6371 .command_sender
6372 .send(PluginCommand::HookCompleted { hook_name });
6373 }
6374
6375 pub fn resolve_callback(
6380 &mut self,
6381 callback_id: fresh_core::api::JsCallbackId,
6382 result_json: &str,
6383 ) {
6384 let id = callback_id.as_u64();
6385 tracing::debug!("resolve_callback: starting for callback_id={}", id);
6386
6387 let plugin_name = {
6389 let mut contexts = self.callback_contexts.borrow_mut();
6390 contexts.remove(&id)
6391 };
6392
6393 let Some(name) = plugin_name else {
6394 tracing::warn!("resolve_callback: No plugin found for callback_id={}", id);
6395 return;
6396 };
6397
6398 let plugin_contexts = self.plugin_contexts.borrow();
6399 let Some(context) = plugin_contexts.get(&name) else {
6400 tracing::warn!("resolve_callback: Context lost for plugin {}", name);
6401 return;
6402 };
6403
6404 context.with(|ctx| {
6405 let json_value: serde_json::Value = match serde_json::from_str(result_json) {
6407 Ok(v) => v,
6408 Err(e) => {
6409 tracing::error!(
6410 "resolve_callback: failed to parse JSON for callback_id={}: {}",
6411 id,
6412 e
6413 );
6414 return;
6415 }
6416 };
6417
6418 let js_value = match rquickjs_serde::to_value(ctx.clone(), &json_value) {
6420 Ok(v) => v,
6421 Err(e) => {
6422 tracing::error!(
6423 "resolve_callback: failed to convert to JS value for callback_id={}: {}",
6424 id,
6425 e
6426 );
6427 return;
6428 }
6429 };
6430
6431 let globals = ctx.globals();
6433 let resolve_fn: rquickjs::Function = match globals.get("_resolveCallback") {
6434 Ok(f) => f,
6435 Err(e) => {
6436 tracing::error!(
6437 "resolve_callback: _resolveCallback not found for callback_id={}: {:?}",
6438 id,
6439 e
6440 );
6441 return;
6442 }
6443 };
6444
6445 if let Err(e) = resolve_fn.call::<_, ()>((id, js_value)) {
6447 log_js_error(&ctx, e, &format!("resolving callback {}", id));
6448 }
6449
6450 let job_count = run_pending_jobs_checked(&ctx, &format!("resolve_callback {}", id));
6452 tracing::info!(
6453 "resolve_callback: executed {} pending jobs for callback_id={}",
6454 job_count,
6455 id
6456 );
6457 });
6458 }
6459
6460 pub fn reject_callback(&mut self, callback_id: fresh_core::api::JsCallbackId, error: &str) {
6462 let id = callback_id.as_u64();
6463
6464 let plugin_name = {
6466 let mut contexts = self.callback_contexts.borrow_mut();
6467 contexts.remove(&id)
6468 };
6469
6470 let Some(name) = plugin_name else {
6471 tracing::warn!("reject_callback: No plugin found for callback_id={}", id);
6472 return;
6473 };
6474
6475 let plugin_contexts = self.plugin_contexts.borrow();
6476 let Some(context) = plugin_contexts.get(&name) else {
6477 tracing::warn!("reject_callback: Context lost for plugin {}", name);
6478 return;
6479 };
6480
6481 context.with(|ctx| {
6482 let globals = ctx.globals();
6484 let reject_fn: rquickjs::Function = match globals.get("_rejectCallback") {
6485 Ok(f) => f,
6486 Err(e) => {
6487 tracing::error!(
6488 "reject_callback: _rejectCallback not found for callback_id={}: {:?}",
6489 id,
6490 e
6491 );
6492 return;
6493 }
6494 };
6495
6496 if let Err(e) = reject_fn.call::<_, ()>((id, error)) {
6498 log_js_error(&ctx, e, &format!("rejecting callback {}", id));
6499 }
6500
6501 run_pending_jobs_checked(&ctx, &format!("reject_callback {}", id));
6503 });
6504 }
6505}
6506
6507#[cfg(test)]
6508mod tests {
6509 use super::*;
6510 use fresh_core::api::{BufferInfo, CursorInfo};
6511 use std::sync::mpsc;
6512
6513 fn create_test_backend() -> (QuickJsBackend, mpsc::Receiver<PluginCommand>) {
6515 let (tx, rx) = mpsc::channel();
6516 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
6517 let services = Arc::new(TestServiceBridge::new());
6518 let backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
6519 (backend, rx)
6520 }
6521
6522 struct TestServiceBridge {
6523 en_strings: std::sync::Mutex<HashMap<String, String>>,
6524 }
6525
6526 impl TestServiceBridge {
6527 fn new() -> Self {
6528 Self {
6529 en_strings: std::sync::Mutex::new(HashMap::new()),
6530 }
6531 }
6532 }
6533
6534 impl fresh_core::services::PluginServiceBridge for TestServiceBridge {
6535 fn as_any(&self) -> &dyn std::any::Any {
6536 self
6537 }
6538 fn translate(
6539 &self,
6540 _plugin_name: &str,
6541 key: &str,
6542 _args: &HashMap<String, String>,
6543 ) -> String {
6544 self.en_strings
6545 .lock()
6546 .unwrap()
6547 .get(key)
6548 .cloned()
6549 .unwrap_or_else(|| key.to_string())
6550 }
6551 fn current_locale(&self) -> String {
6552 "en".to_string()
6553 }
6554 fn set_js_execution_state(&self, _state: String) {}
6555 fn clear_js_execution_state(&self) {}
6556 fn get_theme_schema(&self) -> serde_json::Value {
6557 serde_json::json!({})
6558 }
6559 fn get_builtin_themes(&self) -> serde_json::Value {
6560 serde_json::json!([])
6561 }
6562 fn get_all_themes(&self) -> serde_json::Value {
6563 serde_json::json!({})
6564 }
6565 fn register_command(&self, _command: fresh_core::command::Command) {}
6566 fn unregister_command(&self, _name: &str) {}
6567 fn unregister_commands_by_prefix(&self, _prefix: &str) {}
6568 fn unregister_commands_by_plugin(&self, _plugin_name: &str) {}
6569 fn plugins_dir(&self) -> std::path::PathBuf {
6570 std::path::PathBuf::from("/tmp/plugins")
6571 }
6572 fn config_dir(&self) -> std::path::PathBuf {
6573 std::path::PathBuf::from("/tmp/config")
6574 }
6575 fn data_dir(&self) -> std::path::PathBuf {
6576 std::path::PathBuf::from("/tmp/data")
6577 }
6578 fn get_theme_data(&self, _name: &str) -> Option<serde_json::Value> {
6579 None
6580 }
6581 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
6582 Err("not implemented in test".to_string())
6583 }
6584 fn theme_file_exists(&self, _name: &str) -> bool {
6585 false
6586 }
6587 }
6588
6589 #[test]
6590 fn test_quickjs_backend_creation() {
6591 let backend = QuickJsBackend::new();
6592 assert!(backend.is_ok());
6593 }
6594
6595 #[test]
6596 fn test_execute_simple_js() {
6597 let mut backend = QuickJsBackend::new().unwrap();
6598 let result = backend.execute_js("const x = 1 + 2;", "test.js");
6599 assert!(result.is_ok());
6600 }
6601
6602 #[test]
6603 fn test_event_handler_registration() {
6604 let backend = QuickJsBackend::new().unwrap();
6605
6606 assert!(!backend.has_handlers("test_event"));
6608
6609 backend
6611 .event_handlers
6612 .borrow_mut()
6613 .entry("test_event".to_string())
6614 .or_default()
6615 .push(PluginHandler {
6616 plugin_name: "test".to_string(),
6617 handler_name: "testHandler".to_string(),
6618 });
6619
6620 assert!(backend.has_handlers("test_event"));
6622 }
6623
6624 #[test]
6627 fn test_api_set_status() {
6628 let (mut backend, rx) = create_test_backend();
6629
6630 backend
6631 .execute_js(
6632 r#"
6633 const editor = getEditor();
6634 editor.setStatus("Hello from test");
6635 "#,
6636 "test.js",
6637 )
6638 .unwrap();
6639
6640 let cmd = rx.try_recv().unwrap();
6641 match cmd {
6642 PluginCommand::SetStatus { message } => {
6643 assert_eq!(message, "Hello from test");
6644 }
6645 _ => panic!("Expected SetStatus command, got {:?}", cmd),
6646 }
6647 }
6648
6649 #[test]
6650 fn test_api_register_command() {
6651 let (mut backend, rx) = create_test_backend();
6652
6653 backend
6654 .execute_js(
6655 r#"
6656 const editor = getEditor();
6657 globalThis.myTestHandler = function() { };
6658 editor.registerCommand("Test Command", "A test command", "myTestHandler", null);
6659 "#,
6660 "test_plugin.js",
6661 )
6662 .unwrap();
6663
6664 let cmd = rx.try_recv().unwrap();
6665 match cmd {
6666 PluginCommand::RegisterCommand { command } => {
6667 assert_eq!(command.name, "Test Command");
6668 assert_eq!(command.description, "A test command");
6669 assert_eq!(command.plugin_name, "test_plugin");
6671 }
6672 _ => panic!("Expected RegisterCommand, got {:?}", cmd),
6673 }
6674 }
6675
6676 #[test]
6677 fn test_api_define_mode() {
6678 let (mut backend, rx) = create_test_backend();
6679
6680 backend
6681 .execute_js(
6682 r#"
6683 const editor = getEditor();
6684 editor.defineMode("test-mode", [
6685 ["a", "action_a"],
6686 ["b", "action_b"]
6687 ]);
6688 "#,
6689 "test.js",
6690 )
6691 .unwrap();
6692
6693 let cmd = rx.try_recv().unwrap();
6694 match cmd {
6695 PluginCommand::DefineMode {
6696 name,
6697 bindings,
6698 read_only,
6699 allow_text_input,
6700 inherit_normal_bindings,
6701 plugin_name,
6702 } => {
6703 assert_eq!(name, "test-mode");
6704 assert_eq!(bindings.len(), 2);
6705 assert_eq!(bindings[0], ("a".to_string(), "action_a".to_string()));
6706 assert_eq!(bindings[1], ("b".to_string(), "action_b".to_string()));
6707 assert!(!read_only);
6708 assert!(!allow_text_input);
6709 assert!(!inherit_normal_bindings);
6710 assert!(plugin_name.is_some());
6711 }
6712 _ => panic!("Expected DefineMode, got {:?}", cmd),
6713 }
6714 }
6715
6716 #[test]
6717 fn test_api_set_editor_mode() {
6718 let (mut backend, rx) = create_test_backend();
6719
6720 backend
6721 .execute_js(
6722 r#"
6723 const editor = getEditor();
6724 editor.setEditorMode("vi-normal");
6725 "#,
6726 "test.js",
6727 )
6728 .unwrap();
6729
6730 let cmd = rx.try_recv().unwrap();
6731 match cmd {
6732 PluginCommand::SetEditorMode { mode } => {
6733 assert_eq!(mode, Some("vi-normal".to_string()));
6734 }
6735 _ => panic!("Expected SetEditorMode, got {:?}", cmd),
6736 }
6737 }
6738
6739 #[test]
6740 fn test_api_clear_editor_mode() {
6741 let (mut backend, rx) = create_test_backend();
6742
6743 backend
6744 .execute_js(
6745 r#"
6746 const editor = getEditor();
6747 editor.setEditorMode(null);
6748 "#,
6749 "test.js",
6750 )
6751 .unwrap();
6752
6753 let cmd = rx.try_recv().unwrap();
6754 match cmd {
6755 PluginCommand::SetEditorMode { mode } => {
6756 assert!(mode.is_none());
6757 }
6758 _ => panic!("Expected SetEditorMode with None, got {:?}", cmd),
6759 }
6760 }
6761
6762 #[test]
6763 fn test_api_insert_at_cursor() {
6764 let (mut backend, rx) = create_test_backend();
6765
6766 backend
6767 .execute_js(
6768 r#"
6769 const editor = getEditor();
6770 editor.insertAtCursor("Hello, World!");
6771 "#,
6772 "test.js",
6773 )
6774 .unwrap();
6775
6776 let cmd = rx.try_recv().unwrap();
6777 match cmd {
6778 PluginCommand::InsertAtCursor { text } => {
6779 assert_eq!(text, "Hello, World!");
6780 }
6781 _ => panic!("Expected InsertAtCursor, got {:?}", cmd),
6782 }
6783 }
6784
6785 #[test]
6786 fn test_api_set_context() {
6787 let (mut backend, rx) = create_test_backend();
6788
6789 backend
6790 .execute_js(
6791 r#"
6792 const editor = getEditor();
6793 editor.setContext("myContext", true);
6794 "#,
6795 "test.js",
6796 )
6797 .unwrap();
6798
6799 let cmd = rx.try_recv().unwrap();
6800 match cmd {
6801 PluginCommand::SetContext { name, active } => {
6802 assert_eq!(name, "myContext");
6803 assert!(active);
6804 }
6805 _ => panic!("Expected SetContext, got {:?}", cmd),
6806 }
6807 }
6808
6809 #[tokio::test]
6810 async fn test_execute_action_sync_function() {
6811 let (mut backend, rx) = create_test_backend();
6812
6813 backend.registered_actions.borrow_mut().insert(
6815 "my_sync_action".to_string(),
6816 PluginHandler {
6817 plugin_name: "test".to_string(),
6818 handler_name: "my_sync_action".to_string(),
6819 },
6820 );
6821
6822 backend
6824 .execute_js(
6825 r#"
6826 const editor = getEditor();
6827 globalThis.my_sync_action = function() {
6828 editor.setStatus("sync action executed");
6829 };
6830 "#,
6831 "test.js",
6832 )
6833 .unwrap();
6834
6835 while rx.try_recv().is_ok() {}
6837
6838 backend.execute_action("my_sync_action").await.unwrap();
6840
6841 let cmd = rx.try_recv().unwrap();
6843 match cmd {
6844 PluginCommand::SetStatus { message } => {
6845 assert_eq!(message, "sync action executed");
6846 }
6847 _ => panic!("Expected SetStatus from action, got {:?}", cmd),
6848 }
6849 }
6850
6851 #[tokio::test]
6852 async fn test_execute_action_async_function() {
6853 let (mut backend, rx) = create_test_backend();
6854
6855 backend.registered_actions.borrow_mut().insert(
6857 "my_async_action".to_string(),
6858 PluginHandler {
6859 plugin_name: "test".to_string(),
6860 handler_name: "my_async_action".to_string(),
6861 },
6862 );
6863
6864 backend
6866 .execute_js(
6867 r#"
6868 const editor = getEditor();
6869 globalThis.my_async_action = async function() {
6870 await Promise.resolve();
6871 editor.setStatus("async action executed");
6872 };
6873 "#,
6874 "test.js",
6875 )
6876 .unwrap();
6877
6878 while rx.try_recv().is_ok() {}
6880
6881 backend.execute_action("my_async_action").await.unwrap();
6883
6884 let cmd = rx.try_recv().unwrap();
6886 match cmd {
6887 PluginCommand::SetStatus { message } => {
6888 assert_eq!(message, "async action executed");
6889 }
6890 _ => panic!("Expected SetStatus from async action, got {:?}", cmd),
6891 }
6892 }
6893
6894 #[tokio::test]
6895 async fn test_execute_action_with_registered_handler() {
6896 let (mut backend, rx) = create_test_backend();
6897
6898 backend.registered_actions.borrow_mut().insert(
6900 "my_action".to_string(),
6901 PluginHandler {
6902 plugin_name: "test".to_string(),
6903 handler_name: "actual_handler_function".to_string(),
6904 },
6905 );
6906
6907 backend
6908 .execute_js(
6909 r#"
6910 const editor = getEditor();
6911 globalThis.actual_handler_function = function() {
6912 editor.setStatus("handler executed");
6913 };
6914 "#,
6915 "test.js",
6916 )
6917 .unwrap();
6918
6919 while rx.try_recv().is_ok() {}
6921
6922 backend.execute_action("my_action").await.unwrap();
6924
6925 let cmd = rx.try_recv().unwrap();
6926 match cmd {
6927 PluginCommand::SetStatus { message } => {
6928 assert_eq!(message, "handler executed");
6929 }
6930 _ => panic!("Expected SetStatus, got {:?}", cmd),
6931 }
6932 }
6933
6934 #[test]
6935 fn test_api_on_event_registration() {
6936 let (mut backend, _rx) = create_test_backend();
6937
6938 backend
6939 .execute_js(
6940 r#"
6941 const editor = getEditor();
6942 globalThis.myEventHandler = function() { };
6943 editor.on("bufferSave", "myEventHandler");
6944 "#,
6945 "test.js",
6946 )
6947 .unwrap();
6948
6949 assert!(backend.has_handlers("bufferSave"));
6950 }
6951
6952 #[test]
6953 fn test_api_off_event_unregistration() {
6954 let (mut backend, _rx) = create_test_backend();
6955
6956 backend
6957 .execute_js(
6958 r#"
6959 const editor = getEditor();
6960 globalThis.myEventHandler = function() { };
6961 editor.on("bufferSave", "myEventHandler");
6962 editor.off("bufferSave", "myEventHandler");
6963 "#,
6964 "test.js",
6965 )
6966 .unwrap();
6967
6968 assert!(!backend.has_handlers("bufferSave"));
6970 }
6971
6972 #[tokio::test]
6973 async fn test_emit_event() {
6974 let (mut backend, rx) = create_test_backend();
6975
6976 backend
6977 .execute_js(
6978 r#"
6979 const editor = getEditor();
6980 globalThis.onSaveHandler = function(data) {
6981 editor.setStatus("saved: " + JSON.stringify(data));
6982 };
6983 editor.on("bufferSave", "onSaveHandler");
6984 "#,
6985 "test.js",
6986 )
6987 .unwrap();
6988
6989 while rx.try_recv().is_ok() {}
6991
6992 let event_data: serde_json::Value = serde_json::json!({"path": "/test.txt"});
6994 backend.emit("bufferSave", &event_data).await.unwrap();
6995
6996 let cmd = rx.try_recv().unwrap();
6997 match cmd {
6998 PluginCommand::SetStatus { message } => {
6999 assert!(message.contains("/test.txt"));
7000 }
7001 _ => panic!("Expected SetStatus from event handler, got {:?}", cmd),
7002 }
7003 }
7004
7005 #[test]
7006 fn test_api_copy_to_clipboard() {
7007 let (mut backend, rx) = create_test_backend();
7008
7009 backend
7010 .execute_js(
7011 r#"
7012 const editor = getEditor();
7013 editor.copyToClipboard("clipboard text");
7014 "#,
7015 "test.js",
7016 )
7017 .unwrap();
7018
7019 let cmd = rx.try_recv().unwrap();
7020 match cmd {
7021 PluginCommand::SetClipboard { text } => {
7022 assert_eq!(text, "clipboard text");
7023 }
7024 _ => panic!("Expected SetClipboard, got {:?}", cmd),
7025 }
7026 }
7027
7028 #[test]
7029 fn test_api_open_file() {
7030 let (mut backend, rx) = create_test_backend();
7031
7032 backend
7034 .execute_js(
7035 r#"
7036 const editor = getEditor();
7037 editor.openFile("/path/to/file.txt", null, null);
7038 "#,
7039 "test.js",
7040 )
7041 .unwrap();
7042
7043 let cmd = rx.try_recv().unwrap();
7044 match cmd {
7045 PluginCommand::OpenFileAtLocation { path, line, column } => {
7046 assert_eq!(path.to_str().unwrap(), "/path/to/file.txt");
7047 assert!(line.is_none());
7048 assert!(column.is_none());
7049 }
7050 _ => panic!("Expected OpenFileAtLocation, got {:?}", cmd),
7051 }
7052 }
7053
7054 #[test]
7055 fn test_api_delete_range() {
7056 let (mut backend, rx) = create_test_backend();
7057
7058 backend
7060 .execute_js(
7061 r#"
7062 const editor = getEditor();
7063 editor.deleteRange(0, 10, 20);
7064 "#,
7065 "test.js",
7066 )
7067 .unwrap();
7068
7069 let cmd = rx.try_recv().unwrap();
7070 match cmd {
7071 PluginCommand::DeleteRange { range, .. } => {
7072 assert_eq!(range.start, 10);
7073 assert_eq!(range.end, 20);
7074 }
7075 _ => panic!("Expected DeleteRange, got {:?}", cmd),
7076 }
7077 }
7078
7079 #[test]
7080 fn test_api_insert_text() {
7081 let (mut backend, rx) = create_test_backend();
7082
7083 backend
7085 .execute_js(
7086 r#"
7087 const editor = getEditor();
7088 editor.insertText(0, 5, "inserted");
7089 "#,
7090 "test.js",
7091 )
7092 .unwrap();
7093
7094 let cmd = rx.try_recv().unwrap();
7095 match cmd {
7096 PluginCommand::InsertText { position, text, .. } => {
7097 assert_eq!(position, 5);
7098 assert_eq!(text, "inserted");
7099 }
7100 _ => panic!("Expected InsertText, got {:?}", cmd),
7101 }
7102 }
7103
7104 #[test]
7105 fn test_api_set_buffer_cursor() {
7106 let (mut backend, rx) = create_test_backend();
7107
7108 backend
7110 .execute_js(
7111 r#"
7112 const editor = getEditor();
7113 editor.setBufferCursor(0, 100);
7114 "#,
7115 "test.js",
7116 )
7117 .unwrap();
7118
7119 let cmd = rx.try_recv().unwrap();
7120 match cmd {
7121 PluginCommand::SetBufferCursor { position, .. } => {
7122 assert_eq!(position, 100);
7123 }
7124 _ => panic!("Expected SetBufferCursor, got {:?}", cmd),
7125 }
7126 }
7127
7128 #[test]
7129 fn test_api_get_cursor_position_from_state() {
7130 let (tx, _rx) = mpsc::channel();
7131 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
7132
7133 {
7135 let mut state = state_snapshot.write().unwrap();
7136 state.primary_cursor = Some(CursorInfo {
7137 position: 42,
7138 selection: None,
7139 });
7140 }
7141
7142 let services = Arc::new(fresh_core::services::NoopServiceBridge);
7143 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
7144
7145 backend
7147 .execute_js(
7148 r#"
7149 const editor = getEditor();
7150 const pos = editor.getCursorPosition();
7151 globalThis._testResult = pos;
7152 "#,
7153 "test.js",
7154 )
7155 .unwrap();
7156
7157 backend
7159 .plugin_contexts
7160 .borrow()
7161 .get("test")
7162 .unwrap()
7163 .clone()
7164 .with(|ctx| {
7165 let global = ctx.globals();
7166 let result: u32 = global.get("_testResult").unwrap();
7167 assert_eq!(result, 42);
7168 });
7169 }
7170
7171 #[test]
7172 fn test_api_path_functions() {
7173 let (mut backend, _rx) = create_test_backend();
7174
7175 #[cfg(windows)]
7178 let absolute_path = r#"C:\\foo\\bar"#;
7179 #[cfg(not(windows))]
7180 let absolute_path = "/foo/bar";
7181
7182 let js_code = format!(
7184 r#"
7185 const editor = getEditor();
7186 globalThis._dirname = editor.pathDirname("/foo/bar/baz.txt");
7187 globalThis._basename = editor.pathBasename("/foo/bar/baz.txt");
7188 globalThis._extname = editor.pathExtname("/foo/bar/baz.txt");
7189 globalThis._isAbsolute = editor.pathIsAbsolute("{}");
7190 globalThis._isRelative = editor.pathIsAbsolute("foo/bar");
7191 globalThis._joined = editor.pathJoin("/foo", "bar", "baz");
7192 "#,
7193 absolute_path
7194 );
7195 backend.execute_js(&js_code, "test.js").unwrap();
7196
7197 backend
7198 .plugin_contexts
7199 .borrow()
7200 .get("test")
7201 .unwrap()
7202 .clone()
7203 .with(|ctx| {
7204 let global = ctx.globals();
7205 assert_eq!(global.get::<_, String>("_dirname").unwrap(), "/foo/bar");
7206 assert_eq!(global.get::<_, String>("_basename").unwrap(), "baz.txt");
7207 assert_eq!(global.get::<_, String>("_extname").unwrap(), ".txt");
7208 assert!(global.get::<_, bool>("_isAbsolute").unwrap());
7209 assert!(!global.get::<_, bool>("_isRelative").unwrap());
7210 assert_eq!(global.get::<_, String>("_joined").unwrap(), "/foo/bar/baz");
7211 });
7212 }
7213
7214 #[test]
7222 fn test_path_join_preserves_unc_prefix() {
7223 let (mut backend, _rx) = create_test_backend();
7224 backend
7225 .execute_js(
7226 r#"
7227 const editor = getEditor();
7228 globalThis._unc = editor.pathJoin("\\\\?\\C:\\workspace", ".devcontainer", "devcontainer.json");
7229 globalThis._unc_fwd = editor.pathJoin("//?/C:/workspace", ".devcontainer", "devcontainer.json");
7230 globalThis._posix = editor.pathJoin("/foo", "bar");
7231 globalThis._drive = editor.pathJoin("C:\\foo", "bar");
7232 "#,
7233 "test.js",
7234 )
7235 .unwrap();
7236
7237 backend
7238 .plugin_contexts
7239 .borrow()
7240 .get("test")
7241 .unwrap()
7242 .clone()
7243 .with(|ctx| {
7244 let global = ctx.globals();
7245 assert_eq!(
7246 global.get::<_, String>("_unc").unwrap(),
7247 "//?/C:/workspace/.devcontainer/devcontainer.json",
7248 "UNC prefix `\\\\?\\` must survive pathJoin normalization",
7249 );
7250 assert_eq!(
7251 global.get::<_, String>("_unc_fwd").unwrap(),
7252 "//?/C:/workspace/.devcontainer/devcontainer.json",
7253 "UNC prefix in forward-slash form stays as `//`",
7254 );
7255 assert_eq!(
7256 global.get::<_, String>("_posix").unwrap(),
7257 "/foo/bar",
7258 "POSIX absolute paths keep their single leading slash",
7259 );
7260 assert_eq!(
7261 global.get::<_, String>("_drive").unwrap(),
7262 "C:/foo/bar",
7263 "Windows drive-letter paths have no leading slash",
7264 );
7265 });
7266 }
7267
7268 #[test]
7269 fn test_file_uri_to_path_and_back() {
7270 let (mut backend, _rx) = create_test_backend();
7271
7272 #[cfg(not(windows))]
7274 let js_code = r#"
7275 const editor = getEditor();
7276 // Basic file URI to path
7277 globalThis._path1 = editor.fileUriToPath("file:///home/user/file.txt");
7278 // Percent-encoded characters
7279 globalThis._path2 = editor.fileUriToPath("file:///home/user/my%20file.txt");
7280 // Invalid URI returns empty string
7281 globalThis._path3 = editor.fileUriToPath("not-a-uri");
7282 // Path to file URI
7283 globalThis._uri1 = editor.pathToFileUri("/home/user/file.txt");
7284 // Round-trip
7285 globalThis._roundtrip = editor.fileUriToPath(
7286 editor.pathToFileUri("/home/user/file.txt")
7287 );
7288 "#;
7289
7290 #[cfg(windows)]
7291 let js_code = r#"
7292 const editor = getEditor();
7293 // Windows URI with encoded colon (the bug from issue #1071)
7294 globalThis._path1 = editor.fileUriToPath("file:///C%3A/Users/admin/Repos/file.cs");
7295 // Windows URI with normal colon
7296 globalThis._path2 = editor.fileUriToPath("file:///C:/Users/admin/Repos/file.cs");
7297 // Invalid URI returns empty string
7298 globalThis._path3 = editor.fileUriToPath("not-a-uri");
7299 // Path to file URI
7300 globalThis._uri1 = editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs");
7301 // Round-trip
7302 globalThis._roundtrip = editor.fileUriToPath(
7303 editor.pathToFileUri("C:\\Users\\admin\\Repos\\file.cs")
7304 );
7305 "#;
7306
7307 backend.execute_js(js_code, "test.js").unwrap();
7308
7309 backend
7310 .plugin_contexts
7311 .borrow()
7312 .get("test")
7313 .unwrap()
7314 .clone()
7315 .with(|ctx| {
7316 let global = ctx.globals();
7317
7318 #[cfg(not(windows))]
7319 {
7320 assert_eq!(
7321 global.get::<_, String>("_path1").unwrap(),
7322 "/home/user/file.txt"
7323 );
7324 assert_eq!(
7325 global.get::<_, String>("_path2").unwrap(),
7326 "/home/user/my file.txt"
7327 );
7328 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
7329 assert_eq!(
7330 global.get::<_, String>("_uri1").unwrap(),
7331 "file:///home/user/file.txt"
7332 );
7333 assert_eq!(
7334 global.get::<_, String>("_roundtrip").unwrap(),
7335 "/home/user/file.txt"
7336 );
7337 }
7338
7339 #[cfg(windows)]
7340 {
7341 assert_eq!(
7343 global.get::<_, String>("_path1").unwrap(),
7344 "C:\\Users\\admin\\Repos\\file.cs"
7345 );
7346 assert_eq!(
7347 global.get::<_, String>("_path2").unwrap(),
7348 "C:\\Users\\admin\\Repos\\file.cs"
7349 );
7350 assert_eq!(global.get::<_, String>("_path3").unwrap(), "");
7351 assert_eq!(
7352 global.get::<_, String>("_uri1").unwrap(),
7353 "file:///C:/Users/admin/Repos/file.cs"
7354 );
7355 assert_eq!(
7356 global.get::<_, String>("_roundtrip").unwrap(),
7357 "C:\\Users\\admin\\Repos\\file.cs"
7358 );
7359 }
7360 });
7361 }
7362
7363 #[test]
7364 fn test_typescript_transpilation() {
7365 use fresh_parser_js::transpile_typescript;
7366
7367 let (mut backend, rx) = create_test_backend();
7368
7369 let ts_code = r#"
7371 const editor = getEditor();
7372 function greet(name: string): string {
7373 return "Hello, " + name;
7374 }
7375 editor.setStatus(greet("TypeScript"));
7376 "#;
7377
7378 let js_code = transpile_typescript(ts_code, "test.ts").unwrap();
7380
7381 backend.execute_js(&js_code, "test.js").unwrap();
7383
7384 let cmd = rx.try_recv().unwrap();
7385 match cmd {
7386 PluginCommand::SetStatus { message } => {
7387 assert_eq!(message, "Hello, TypeScript");
7388 }
7389 _ => panic!("Expected SetStatus, got {:?}", cmd),
7390 }
7391 }
7392
7393 #[test]
7394 fn test_api_get_buffer_text_sends_command() {
7395 let (mut backend, rx) = create_test_backend();
7396
7397 backend
7399 .execute_js(
7400 r#"
7401 const editor = getEditor();
7402 // Store the promise for later
7403 globalThis._textPromise = editor.getBufferText(0, 10, 20);
7404 "#,
7405 "test.js",
7406 )
7407 .unwrap();
7408
7409 let cmd = rx.try_recv().unwrap();
7411 match cmd {
7412 PluginCommand::GetBufferText {
7413 buffer_id,
7414 start,
7415 end,
7416 request_id,
7417 } => {
7418 assert_eq!(buffer_id.0, 0);
7419 assert_eq!(start, 10);
7420 assert_eq!(end, 20);
7421 assert!(request_id > 0); }
7423 _ => panic!("Expected GetBufferText, got {:?}", cmd),
7424 }
7425 }
7426
7427 #[test]
7428 fn test_api_get_buffer_text_resolves_callback() {
7429 let (mut backend, rx) = create_test_backend();
7430
7431 backend
7433 .execute_js(
7434 r#"
7435 const editor = getEditor();
7436 globalThis._resolvedText = null;
7437 editor.getBufferText(0, 0, 100).then(text => {
7438 globalThis._resolvedText = text;
7439 });
7440 "#,
7441 "test.js",
7442 )
7443 .unwrap();
7444
7445 let request_id = match rx.try_recv().unwrap() {
7447 PluginCommand::GetBufferText { request_id, .. } => request_id,
7448 cmd => panic!("Expected GetBufferText, got {:?}", cmd),
7449 };
7450
7451 backend.resolve_callback(JsCallbackId::from(request_id), "\"hello world\"");
7453
7454 backend
7456 .plugin_contexts
7457 .borrow()
7458 .get("test")
7459 .unwrap()
7460 .clone()
7461 .with(|ctx| {
7462 run_pending_jobs_checked(&ctx, "test async getText");
7463 });
7464
7465 backend
7467 .plugin_contexts
7468 .borrow()
7469 .get("test")
7470 .unwrap()
7471 .clone()
7472 .with(|ctx| {
7473 let global = ctx.globals();
7474 let result: String = global.get("_resolvedText").unwrap();
7475 assert_eq!(result, "hello world");
7476 });
7477 }
7478
7479 #[test]
7480 fn test_plugin_translation() {
7481 let (mut backend, _rx) = create_test_backend();
7482
7483 backend
7485 .execute_js(
7486 r#"
7487 const editor = getEditor();
7488 globalThis._translated = editor.t("test.key");
7489 "#,
7490 "test.js",
7491 )
7492 .unwrap();
7493
7494 backend
7495 .plugin_contexts
7496 .borrow()
7497 .get("test")
7498 .unwrap()
7499 .clone()
7500 .with(|ctx| {
7501 let global = ctx.globals();
7502 let result: String = global.get("_translated").unwrap();
7504 assert_eq!(result, "test.key");
7505 });
7506 }
7507
7508 #[test]
7509 fn test_plugin_translation_with_registered_strings() {
7510 let (mut backend, _rx) = create_test_backend();
7511
7512 let mut en_strings = std::collections::HashMap::new();
7514 en_strings.insert("greeting".to_string(), "Hello, World!".to_string());
7515 en_strings.insert("prompt.find_file".to_string(), "Find file: ".to_string());
7516
7517 let mut strings = std::collections::HashMap::new();
7518 strings.insert("en".to_string(), en_strings);
7519
7520 if let Some(bridge) = backend
7522 .services
7523 .as_any()
7524 .downcast_ref::<TestServiceBridge>()
7525 {
7526 let mut en = bridge.en_strings.lock().unwrap();
7527 en.insert("greeting".to_string(), "Hello, World!".to_string());
7528 en.insert("prompt.find_file".to_string(), "Find file: ".to_string());
7529 }
7530
7531 backend
7533 .execute_js(
7534 r#"
7535 const editor = getEditor();
7536 globalThis._greeting = editor.t("greeting");
7537 globalThis._prompt = editor.t("prompt.find_file");
7538 globalThis._missing = editor.t("nonexistent.key");
7539 "#,
7540 "test.js",
7541 )
7542 .unwrap();
7543
7544 backend
7545 .plugin_contexts
7546 .borrow()
7547 .get("test")
7548 .unwrap()
7549 .clone()
7550 .with(|ctx| {
7551 let global = ctx.globals();
7552 let greeting: String = global.get("_greeting").unwrap();
7553 assert_eq!(greeting, "Hello, World!");
7554
7555 let prompt: String = global.get("_prompt").unwrap();
7556 assert_eq!(prompt, "Find file: ");
7557
7558 let missing: String = global.get("_missing").unwrap();
7560 assert_eq!(missing, "nonexistent.key");
7561 });
7562 }
7563
7564 #[test]
7567 fn test_api_set_line_indicator() {
7568 let (mut backend, rx) = create_test_backend();
7569
7570 backend
7571 .execute_js(
7572 r#"
7573 const editor = getEditor();
7574 editor.setLineIndicator(1, 5, "test-ns", "●", 255, 0, 0, 10);
7575 "#,
7576 "test.js",
7577 )
7578 .unwrap();
7579
7580 let cmd = rx.try_recv().unwrap();
7581 match cmd {
7582 PluginCommand::SetLineIndicator {
7583 buffer_id,
7584 line,
7585 namespace,
7586 symbol,
7587 color,
7588 priority,
7589 } => {
7590 assert_eq!(buffer_id.0, 1);
7591 assert_eq!(line, 5);
7592 assert_eq!(namespace, "test-ns");
7593 assert_eq!(symbol, "●");
7594 assert_eq!(color, (255, 0, 0));
7595 assert_eq!(priority, 10);
7596 }
7597 _ => panic!("Expected SetLineIndicator, got {:?}", cmd),
7598 }
7599 }
7600
7601 #[test]
7602 fn test_api_clear_line_indicators() {
7603 let (mut backend, rx) = create_test_backend();
7604
7605 backend
7606 .execute_js(
7607 r#"
7608 const editor = getEditor();
7609 editor.clearLineIndicators(1, "test-ns");
7610 "#,
7611 "test.js",
7612 )
7613 .unwrap();
7614
7615 let cmd = rx.try_recv().unwrap();
7616 match cmd {
7617 PluginCommand::ClearLineIndicators {
7618 buffer_id,
7619 namespace,
7620 } => {
7621 assert_eq!(buffer_id.0, 1);
7622 assert_eq!(namespace, "test-ns");
7623 }
7624 _ => panic!("Expected ClearLineIndicators, got {:?}", cmd),
7625 }
7626 }
7627
7628 #[test]
7631 fn test_api_create_virtual_buffer_sends_command() {
7632 let (mut backend, rx) = create_test_backend();
7633
7634 backend
7635 .execute_js(
7636 r#"
7637 const editor = getEditor();
7638 editor.createVirtualBuffer({
7639 name: "*Test Buffer*",
7640 mode: "test-mode",
7641 readOnly: true,
7642 entries: [
7643 { text: "Line 1\n", properties: { type: "header" } },
7644 { text: "Line 2\n", properties: { type: "content" } }
7645 ],
7646 showLineNumbers: false,
7647 showCursors: true,
7648 editingDisabled: true
7649 });
7650 "#,
7651 "test.js",
7652 )
7653 .unwrap();
7654
7655 let cmd = rx.try_recv().unwrap();
7656 match cmd {
7657 PluginCommand::CreateVirtualBufferWithContent {
7658 name,
7659 mode,
7660 read_only,
7661 entries,
7662 show_line_numbers,
7663 show_cursors,
7664 editing_disabled,
7665 ..
7666 } => {
7667 assert_eq!(name, "*Test Buffer*");
7668 assert_eq!(mode, "test-mode");
7669 assert!(read_only);
7670 assert_eq!(entries.len(), 2);
7671 assert_eq!(entries[0].text, "Line 1\n");
7672 assert!(!show_line_numbers);
7673 assert!(show_cursors);
7674 assert!(editing_disabled);
7675 }
7676 _ => panic!("Expected CreateVirtualBufferWithContent, got {:?}", cmd),
7677 }
7678 }
7679
7680 #[test]
7681 fn test_api_set_virtual_buffer_content() {
7682 let (mut backend, rx) = create_test_backend();
7683
7684 backend
7685 .execute_js(
7686 r#"
7687 const editor = getEditor();
7688 editor.setVirtualBufferContent(5, [
7689 { text: "New content\n", properties: { type: "updated" } }
7690 ]);
7691 "#,
7692 "test.js",
7693 )
7694 .unwrap();
7695
7696 let cmd = rx.try_recv().unwrap();
7697 match cmd {
7698 PluginCommand::SetVirtualBufferContent { buffer_id, entries } => {
7699 assert_eq!(buffer_id.0, 5);
7700 assert_eq!(entries.len(), 1);
7701 assert_eq!(entries[0].text, "New content\n");
7702 }
7703 _ => panic!("Expected SetVirtualBufferContent, got {:?}", cmd),
7704 }
7705 }
7706
7707 #[test]
7710 fn test_api_add_overlay() {
7711 let (mut backend, rx) = create_test_backend();
7712
7713 backend
7714 .execute_js(
7715 r#"
7716 const editor = getEditor();
7717 editor.addOverlay(1, "highlight", 10, 20, {
7718 fg: [255, 128, 0],
7719 bg: [50, 50, 50],
7720 bold: true,
7721 });
7722 "#,
7723 "test.js",
7724 )
7725 .unwrap();
7726
7727 let cmd = rx.try_recv().unwrap();
7728 match cmd {
7729 PluginCommand::AddOverlay {
7730 buffer_id,
7731 namespace,
7732 range,
7733 options,
7734 } => {
7735 use fresh_core::api::OverlayColorSpec;
7736 assert_eq!(buffer_id.0, 1);
7737 assert!(namespace.is_some());
7738 assert_eq!(namespace.unwrap().as_str(), "highlight");
7739 assert_eq!(range, 10..20);
7740 assert!(matches!(
7741 options.fg,
7742 Some(OverlayColorSpec::Rgb(255, 128, 0))
7743 ));
7744 assert!(matches!(
7745 options.bg,
7746 Some(OverlayColorSpec::Rgb(50, 50, 50))
7747 ));
7748 assert!(!options.underline);
7749 assert!(options.bold);
7750 assert!(!options.italic);
7751 assert!(!options.extend_to_line_end);
7752 }
7753 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7754 }
7755 }
7756
7757 #[test]
7758 fn test_api_add_overlay_with_theme_keys() {
7759 let (mut backend, rx) = create_test_backend();
7760
7761 backend
7762 .execute_js(
7763 r#"
7764 const editor = getEditor();
7765 // Test with theme keys for colors
7766 editor.addOverlay(1, "themed", 0, 10, {
7767 fg: "ui.status_bar_fg",
7768 bg: "editor.selection_bg",
7769 });
7770 "#,
7771 "test.js",
7772 )
7773 .unwrap();
7774
7775 let cmd = rx.try_recv().unwrap();
7776 match cmd {
7777 PluginCommand::AddOverlay {
7778 buffer_id,
7779 namespace,
7780 range,
7781 options,
7782 } => {
7783 use fresh_core::api::OverlayColorSpec;
7784 assert_eq!(buffer_id.0, 1);
7785 assert!(namespace.is_some());
7786 assert_eq!(namespace.unwrap().as_str(), "themed");
7787 assert_eq!(range, 0..10);
7788 assert!(matches!(
7789 &options.fg,
7790 Some(OverlayColorSpec::ThemeKey(k)) if k == "ui.status_bar_fg"
7791 ));
7792 assert!(matches!(
7793 &options.bg,
7794 Some(OverlayColorSpec::ThemeKey(k)) if k == "editor.selection_bg"
7795 ));
7796 assert!(!options.underline);
7797 assert!(!options.bold);
7798 assert!(!options.italic);
7799 assert!(!options.extend_to_line_end);
7800 }
7801 _ => panic!("Expected AddOverlay, got {:?}", cmd),
7802 }
7803 }
7804
7805 #[test]
7806 fn test_api_clear_namespace() {
7807 let (mut backend, rx) = create_test_backend();
7808
7809 backend
7810 .execute_js(
7811 r#"
7812 const editor = getEditor();
7813 editor.clearNamespace(1, "highlight");
7814 "#,
7815 "test.js",
7816 )
7817 .unwrap();
7818
7819 let cmd = rx.try_recv().unwrap();
7820 match cmd {
7821 PluginCommand::ClearNamespace {
7822 buffer_id,
7823 namespace,
7824 } => {
7825 assert_eq!(buffer_id.0, 1);
7826 assert_eq!(namespace.as_str(), "highlight");
7827 }
7828 _ => panic!("Expected ClearNamespace, got {:?}", cmd),
7829 }
7830 }
7831
7832 #[test]
7835 fn test_api_get_theme_schema() {
7836 let (mut backend, _rx) = create_test_backend();
7837
7838 backend
7839 .execute_js(
7840 r#"
7841 const editor = getEditor();
7842 const schema = editor.getThemeSchema();
7843 globalThis._isObject = typeof schema === 'object' && schema !== null;
7844 "#,
7845 "test.js",
7846 )
7847 .unwrap();
7848
7849 backend
7850 .plugin_contexts
7851 .borrow()
7852 .get("test")
7853 .unwrap()
7854 .clone()
7855 .with(|ctx| {
7856 let global = ctx.globals();
7857 let is_object: bool = global.get("_isObject").unwrap();
7858 assert!(is_object);
7860 });
7861 }
7862
7863 #[test]
7864 fn test_api_get_builtin_themes() {
7865 let (mut backend, _rx) = create_test_backend();
7866
7867 backend
7868 .execute_js(
7869 r#"
7870 const editor = getEditor();
7871 const themes = editor.getBuiltinThemes();
7872 globalThis._isObject = typeof themes === 'object' && themes !== null;
7873 "#,
7874 "test.js",
7875 )
7876 .unwrap();
7877
7878 backend
7879 .plugin_contexts
7880 .borrow()
7881 .get("test")
7882 .unwrap()
7883 .clone()
7884 .with(|ctx| {
7885 let global = ctx.globals();
7886 let is_object: bool = global.get("_isObject").unwrap();
7887 assert!(is_object);
7889 });
7890 }
7891
7892 #[test]
7893 fn test_api_apply_theme() {
7894 let (mut backend, rx) = create_test_backend();
7895
7896 backend
7897 .execute_js(
7898 r#"
7899 const editor = getEditor();
7900 editor.applyTheme("dark");
7901 "#,
7902 "test.js",
7903 )
7904 .unwrap();
7905
7906 let cmd = rx.try_recv().unwrap();
7907 match cmd {
7908 PluginCommand::ApplyTheme { theme_name } => {
7909 assert_eq!(theme_name, "dark");
7910 }
7911 _ => panic!("Expected ApplyTheme, got {:?}", cmd),
7912 }
7913 }
7914
7915 #[test]
7916 fn test_api_override_theme_colors_round_trip() {
7917 let (mut backend, rx) = create_test_backend();
7920
7921 backend
7922 .execute_js(
7923 r#"
7924 const editor = getEditor();
7925 editor.overrideThemeColors({
7926 "editor.bg": [10, 20, 30],
7927 "editor.fg": [220, 221, 222],
7928 });
7929 "#,
7930 "test.js",
7931 )
7932 .unwrap();
7933
7934 let cmd = rx.try_recv().unwrap();
7935 match cmd {
7936 PluginCommand::OverrideThemeColors { overrides } => {
7937 assert_eq!(overrides.get("editor.bg").copied(), Some([10, 20, 30]));
7938 assert_eq!(overrides.get("editor.fg").copied(), Some([220, 221, 222]));
7939 assert_eq!(overrides.len(), 2);
7940 }
7941 _ => panic!("Expected OverrideThemeColors, got {:?}", cmd),
7942 }
7943 }
7944
7945 #[test]
7946 fn test_api_override_theme_colors_clamps_out_of_range() {
7947 let (mut backend, rx) = create_test_backend();
7948
7949 backend
7950 .execute_js(
7951 r#"
7952 const editor = getEditor();
7953 editor.overrideThemeColors({
7954 "editor.bg": [-5, 300, 128],
7955 });
7956 "#,
7957 "test.js",
7958 )
7959 .unwrap();
7960
7961 match rx.try_recv().unwrap() {
7962 PluginCommand::OverrideThemeColors { overrides } => {
7963 assert_eq!(overrides.get("editor.bg").copied(), Some([0, 255, 128]));
7964 }
7965 other => panic!("Expected OverrideThemeColors, got {other:?}"),
7966 }
7967 }
7968
7969 #[test]
7970 fn test_api_override_theme_colors_drops_malformed_entries() {
7971 let (mut backend, rx) = create_test_backend();
7974
7975 backend
7976 .execute_js(
7977 r#"
7978 const editor = getEditor();
7979 editor.overrideThemeColors({
7980 "editor.bg": [1, 2, 3],
7981 "not_an_array": "oops",
7982 "wrong_length": [1, 2],
7983 "floats_are_fine": [10.7, 20.2, 30.9],
7984 });
7985 "#,
7986 "test.js",
7987 )
7988 .unwrap();
7989
7990 match rx.try_recv().unwrap() {
7991 PluginCommand::OverrideThemeColors { overrides } => {
7992 assert_eq!(overrides.get("editor.bg").copied(), Some([1, 2, 3]));
7993 assert!(!overrides.contains_key("not_an_array"));
7994 assert!(!overrides.contains_key("wrong_length"));
7995 assert_eq!(
7997 overrides.get("floats_are_fine").copied(),
7998 Some([10, 20, 30])
7999 );
8000 }
8001 other => panic!("Expected OverrideThemeColors, got {other:?}"),
8002 }
8003 }
8004
8005 #[test]
8006 fn test_api_get_theme_data_missing() {
8007 let (mut backend, _rx) = create_test_backend();
8008
8009 backend
8010 .execute_js(
8011 r#"
8012 const editor = getEditor();
8013 const data = editor.getThemeData("nonexistent");
8014 globalThis._isNull = data === null;
8015 "#,
8016 "test.js",
8017 )
8018 .unwrap();
8019
8020 backend
8021 .plugin_contexts
8022 .borrow()
8023 .get("test")
8024 .unwrap()
8025 .clone()
8026 .with(|ctx| {
8027 let global = ctx.globals();
8028 let is_null: bool = global.get("_isNull").unwrap();
8029 assert!(is_null);
8031 });
8032 }
8033
8034 #[test]
8035 fn test_api_get_theme_data_present() {
8036 let (tx, _rx) = mpsc::channel();
8038 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8039 let services = Arc::new(ThemeCacheTestBridge {
8040 inner: TestServiceBridge::new(),
8041 });
8042 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8043
8044 backend
8045 .execute_js(
8046 r#"
8047 const editor = getEditor();
8048 const data = editor.getThemeData("test-theme");
8049 globalThis._hasData = data !== null && typeof data === 'object';
8050 globalThis._name = data ? data.name : null;
8051 "#,
8052 "test.js",
8053 )
8054 .unwrap();
8055
8056 backend
8057 .plugin_contexts
8058 .borrow()
8059 .get("test")
8060 .unwrap()
8061 .clone()
8062 .with(|ctx| {
8063 let global = ctx.globals();
8064 let has_data: bool = global.get("_hasData").unwrap();
8065 assert!(has_data, "getThemeData should return theme object");
8066 let name: String = global.get("_name").unwrap();
8067 assert_eq!(name, "test-theme");
8068 });
8069 }
8070
8071 #[test]
8072 fn test_api_theme_file_exists() {
8073 let (mut backend, _rx) = create_test_backend();
8074
8075 backend
8076 .execute_js(
8077 r#"
8078 const editor = getEditor();
8079 globalThis._exists = editor.themeFileExists("anything");
8080 "#,
8081 "test.js",
8082 )
8083 .unwrap();
8084
8085 backend
8086 .plugin_contexts
8087 .borrow()
8088 .get("test")
8089 .unwrap()
8090 .clone()
8091 .with(|ctx| {
8092 let global = ctx.globals();
8093 let exists: bool = global.get("_exists").unwrap();
8094 assert!(!exists);
8096 });
8097 }
8098
8099 #[test]
8100 fn test_api_save_theme_file_error() {
8101 let (mut backend, _rx) = create_test_backend();
8102
8103 backend
8104 .execute_js(
8105 r#"
8106 const editor = getEditor();
8107 let threw = false;
8108 try {
8109 editor.saveThemeFile("test", "{}");
8110 } catch (e) {
8111 threw = true;
8112 }
8113 globalThis._threw = threw;
8114 "#,
8115 "test.js",
8116 )
8117 .unwrap();
8118
8119 backend
8120 .plugin_contexts
8121 .borrow()
8122 .get("test")
8123 .unwrap()
8124 .clone()
8125 .with(|ctx| {
8126 let global = ctx.globals();
8127 let threw: bool = global.get("_threw").unwrap();
8128 assert!(threw);
8130 });
8131 }
8132
8133 struct ThemeCacheTestBridge {
8135 inner: TestServiceBridge,
8136 }
8137
8138 impl fresh_core::services::PluginServiceBridge for ThemeCacheTestBridge {
8139 fn as_any(&self) -> &dyn std::any::Any {
8140 self
8141 }
8142 fn translate(
8143 &self,
8144 plugin_name: &str,
8145 key: &str,
8146 args: &HashMap<String, String>,
8147 ) -> String {
8148 self.inner.translate(plugin_name, key, args)
8149 }
8150 fn current_locale(&self) -> String {
8151 self.inner.current_locale()
8152 }
8153 fn set_js_execution_state(&self, state: String) {
8154 self.inner.set_js_execution_state(state);
8155 }
8156 fn clear_js_execution_state(&self) {
8157 self.inner.clear_js_execution_state();
8158 }
8159 fn get_theme_schema(&self) -> serde_json::Value {
8160 self.inner.get_theme_schema()
8161 }
8162 fn get_builtin_themes(&self) -> serde_json::Value {
8163 self.inner.get_builtin_themes()
8164 }
8165 fn get_all_themes(&self) -> serde_json::Value {
8166 self.inner.get_all_themes()
8167 }
8168 fn register_command(&self, command: fresh_core::command::Command) {
8169 self.inner.register_command(command);
8170 }
8171 fn unregister_command(&self, name: &str) {
8172 self.inner.unregister_command(name);
8173 }
8174 fn unregister_commands_by_prefix(&self, prefix: &str) {
8175 self.inner.unregister_commands_by_prefix(prefix);
8176 }
8177 fn unregister_commands_by_plugin(&self, plugin_name: &str) {
8178 self.inner.unregister_commands_by_plugin(plugin_name);
8179 }
8180 fn plugins_dir(&self) -> std::path::PathBuf {
8181 self.inner.plugins_dir()
8182 }
8183 fn config_dir(&self) -> std::path::PathBuf {
8184 self.inner.config_dir()
8185 }
8186 fn data_dir(&self) -> std::path::PathBuf {
8187 self.inner.data_dir()
8188 }
8189 fn get_theme_data(&self, name: &str) -> Option<serde_json::Value> {
8190 if name == "test-theme" {
8191 Some(serde_json::json!({
8192 "name": "test-theme",
8193 "editor": {},
8194 "ui": {},
8195 "syntax": {}
8196 }))
8197 } else {
8198 None
8199 }
8200 }
8201 fn save_theme_file(&self, _name: &str, _content: &str) -> Result<String, String> {
8202 Err("test bridge does not support save".to_string())
8203 }
8204 fn theme_file_exists(&self, name: &str) -> bool {
8205 name == "test-theme"
8206 }
8207 }
8208
8209 #[test]
8212 fn test_api_close_buffer() {
8213 let (mut backend, rx) = create_test_backend();
8214
8215 backend
8216 .execute_js(
8217 r#"
8218 const editor = getEditor();
8219 editor.closeBuffer(3);
8220 "#,
8221 "test.js",
8222 )
8223 .unwrap();
8224
8225 let cmd = rx.try_recv().unwrap();
8226 match cmd {
8227 PluginCommand::CloseBuffer { buffer_id } => {
8228 assert_eq!(buffer_id.0, 3);
8229 }
8230 _ => panic!("Expected CloseBuffer, got {:?}", cmd),
8231 }
8232 }
8233
8234 #[test]
8235 fn test_api_focus_split() {
8236 let (mut backend, rx) = create_test_backend();
8237
8238 backend
8239 .execute_js(
8240 r#"
8241 const editor = getEditor();
8242 editor.focusSplit(2);
8243 "#,
8244 "test.js",
8245 )
8246 .unwrap();
8247
8248 let cmd = rx.try_recv().unwrap();
8249 match cmd {
8250 PluginCommand::FocusSplit { split_id } => {
8251 assert_eq!(split_id.0, 2);
8252 }
8253 _ => panic!("Expected FocusSplit, got {:?}", cmd),
8254 }
8255 }
8256
8257 #[test]
8261 fn test_api_session_lifecycle_dispatches_commands() {
8262 let (mut backend, rx) = create_test_backend();
8263
8264 backend
8265 .execute_js(
8266 r#"
8267 const editor = getEditor();
8268 editor.createWindow("/tmp/wt-feat", "feat");
8269 editor.setActiveWindow(7);
8270 editor.closeWindow(3);
8271 "#,
8272 "test.js",
8273 )
8274 .unwrap();
8275
8276 let create = rx.try_recv().unwrap();
8277 match create {
8278 fresh_core::api::PluginCommand::CreateWindow { root, label } => {
8279 assert_eq!(root, std::path::PathBuf::from("/tmp/wt-feat"));
8280 assert_eq!(label, "feat");
8281 }
8282 other => panic!("Expected CreateWindow, got {:?}", other),
8283 }
8284
8285 let activate = rx.try_recv().unwrap();
8286 match activate {
8287 fresh_core::api::PluginCommand::SetActiveWindow { id } => {
8288 assert_eq!(id, fresh_core::WindowId(7));
8289 }
8290 other => panic!("Expected SetActiveWindow, got {:?}", other),
8291 }
8292
8293 let close = rx.try_recv().unwrap();
8294 match close {
8295 fresh_core::api::PluginCommand::CloseWindow { id } => {
8296 assert_eq!(id, fresh_core::WindowId(3));
8297 }
8298 other => panic!("Expected CloseWindow, got {:?}", other),
8299 }
8300 }
8301
8302 #[test]
8306 fn test_api_list_sessions_reads_snapshot() {
8307 let (tx, _rx) = mpsc::channel();
8308 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8309
8310 {
8311 let mut state = state_snapshot.write().unwrap();
8312 state.windows = vec![
8313 fresh_core::api::WindowInfo {
8314 id: fresh_core::WindowId(1),
8315 label: "main".into(),
8316 root: std::path::PathBuf::from("/repo"),
8317 },
8318 fresh_core::api::WindowInfo {
8319 id: fresh_core::WindowId(2),
8320 label: "feat-auth".into(),
8321 root: std::path::PathBuf::from("/wt/feat-auth"),
8322 },
8323 ];
8324 state.active_window_id = fresh_core::WindowId(2);
8325 }
8326
8327 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8328 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8329
8330 backend
8331 .execute_js(
8332 r#"
8333 const editor = getEditor();
8334 const list = editor.listWindows();
8335 globalThis._sessionCount = list.length;
8336 globalThis._secondLabel = list[1].label;
8337 globalThis._secondRoot = list[1].root;
8338 globalThis._activeId = editor.activeWindow();
8339 "#,
8340 "test.js",
8341 )
8342 .unwrap();
8343
8344 backend
8345 .plugin_contexts
8346 .borrow()
8347 .get("test")
8348 .unwrap()
8349 .clone()
8350 .with(|ctx| {
8351 let global = ctx.globals();
8352 let count: u32 = global.get("_sessionCount").unwrap();
8353 let label: String = global.get("_secondLabel").unwrap();
8354 let root: String = global.get("_secondRoot").unwrap();
8355 let active: u32 = global.get("_activeId").unwrap();
8356 assert_eq!(count, 2);
8357 assert_eq!(label, "feat-auth");
8358 assert_eq!(root, "/wt/feat-auth");
8359 assert_eq!(active, 2);
8360 });
8361 }
8362
8363 #[test]
8364 fn test_api_list_buffers() {
8365 let (tx, _rx) = mpsc::channel();
8366 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8367
8368 {
8370 let mut state = state_snapshot.write().unwrap();
8371 state.buffers.insert(
8372 BufferId(0),
8373 BufferInfo {
8374 id: BufferId(0),
8375 path: Some(PathBuf::from("/test1.txt")),
8376 modified: false,
8377 length: 100,
8378 is_virtual: false,
8379 view_mode: "source".to_string(),
8380 is_composing_in_any_split: false,
8381 compose_width: None,
8382 language: "text".to_string(),
8383 is_preview: false,
8384 splits: Vec::new(),
8385 },
8386 );
8387 state.buffers.insert(
8388 BufferId(1),
8389 BufferInfo {
8390 id: BufferId(1),
8391 path: Some(PathBuf::from("/test2.txt")),
8392 modified: true,
8393 length: 200,
8394 is_virtual: false,
8395 view_mode: "source".to_string(),
8396 is_composing_in_any_split: false,
8397 compose_width: None,
8398 language: "text".to_string(),
8399 is_preview: false,
8400 splits: Vec::new(),
8401 },
8402 );
8403 }
8404
8405 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8406 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8407
8408 backend
8409 .execute_js(
8410 r#"
8411 const editor = getEditor();
8412 const buffers = editor.listBuffers();
8413 globalThis._isArray = Array.isArray(buffers);
8414 globalThis._length = buffers.length;
8415 "#,
8416 "test.js",
8417 )
8418 .unwrap();
8419
8420 backend
8421 .plugin_contexts
8422 .borrow()
8423 .get("test")
8424 .unwrap()
8425 .clone()
8426 .with(|ctx| {
8427 let global = ctx.globals();
8428 let is_array: bool = global.get("_isArray").unwrap();
8429 let length: u32 = global.get("_length").unwrap();
8430 assert!(is_array);
8431 assert_eq!(length, 2);
8432 });
8433 }
8434
8435 #[test]
8438 fn test_api_start_prompt() {
8439 let (mut backend, rx) = create_test_backend();
8440
8441 backend
8442 .execute_js(
8443 r#"
8444 const editor = getEditor();
8445 editor.startPrompt("Enter value:", "test-prompt");
8446 "#,
8447 "test.js",
8448 )
8449 .unwrap();
8450
8451 let cmd = rx.try_recv().unwrap();
8452 match cmd {
8453 PluginCommand::StartPrompt {
8454 label,
8455 prompt_type,
8456 floating_overlay,
8457 } => {
8458 assert_eq!(label, "Enter value:");
8459 assert_eq!(prompt_type, "test-prompt");
8460 assert!(!floating_overlay);
8461 }
8462 _ => panic!("Expected StartPrompt, got {:?}", cmd),
8463 }
8464 }
8465
8466 #[test]
8467 fn test_api_start_prompt_with_initial() {
8468 let (mut backend, rx) = create_test_backend();
8469
8470 backend
8471 .execute_js(
8472 r#"
8473 const editor = getEditor();
8474 editor.startPromptWithInitial("Enter value:", "test-prompt", "default");
8475 "#,
8476 "test.js",
8477 )
8478 .unwrap();
8479
8480 let cmd = rx.try_recv().unwrap();
8481 match cmd {
8482 PluginCommand::StartPromptWithInitial {
8483 label,
8484 prompt_type,
8485 initial_value,
8486 floating_overlay,
8487 } => {
8488 assert_eq!(label, "Enter value:");
8489 assert_eq!(prompt_type, "test-prompt");
8490 assert_eq!(initial_value, "default");
8491 assert!(!floating_overlay);
8492 }
8493 _ => panic!("Expected StartPromptWithInitial, got {:?}", cmd),
8494 }
8495 }
8496
8497 #[test]
8498 fn test_api_set_prompt_suggestions() {
8499 let (mut backend, rx) = create_test_backend();
8500
8501 backend
8502 .execute_js(
8503 r#"
8504 const editor = getEditor();
8505 editor.setPromptSuggestions([
8506 { text: "Option 1", value: "opt1" },
8507 { text: "Option 2", value: "opt2" }
8508 ]);
8509 "#,
8510 "test.js",
8511 )
8512 .unwrap();
8513
8514 let cmd = rx.try_recv().unwrap();
8515 match cmd {
8516 PluginCommand::SetPromptSuggestions { suggestions } => {
8517 assert_eq!(suggestions.len(), 2);
8518 assert_eq!(suggestions[0].text, "Option 1");
8519 assert_eq!(suggestions[0].value, Some("opt1".to_string()));
8520 }
8521 _ => panic!("Expected SetPromptSuggestions, got {:?}", cmd),
8522 }
8523 }
8524
8525 #[test]
8528 fn test_api_get_active_buffer_id() {
8529 let (tx, _rx) = mpsc::channel();
8530 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8531
8532 {
8533 let mut state = state_snapshot.write().unwrap();
8534 state.active_buffer_id = BufferId(42);
8535 }
8536
8537 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8538 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8539
8540 backend
8541 .execute_js(
8542 r#"
8543 const editor = getEditor();
8544 globalThis._activeId = editor.getActiveBufferId();
8545 "#,
8546 "test.js",
8547 )
8548 .unwrap();
8549
8550 backend
8551 .plugin_contexts
8552 .borrow()
8553 .get("test")
8554 .unwrap()
8555 .clone()
8556 .with(|ctx| {
8557 let global = ctx.globals();
8558 let result: u32 = global.get("_activeId").unwrap();
8559 assert_eq!(result, 42);
8560 });
8561 }
8562
8563 #[test]
8564 fn test_api_get_active_split_id() {
8565 let (tx, _rx) = mpsc::channel();
8566 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
8567
8568 {
8569 let mut state = state_snapshot.write().unwrap();
8570 state.active_split_id = 7;
8571 }
8572
8573 let services = Arc::new(fresh_core::services::NoopServiceBridge);
8574 let mut backend = QuickJsBackend::with_state(state_snapshot, tx, services).unwrap();
8575
8576 backend
8577 .execute_js(
8578 r#"
8579 const editor = getEditor();
8580 globalThis._splitId = editor.getActiveSplitId();
8581 "#,
8582 "test.js",
8583 )
8584 .unwrap();
8585
8586 backend
8587 .plugin_contexts
8588 .borrow()
8589 .get("test")
8590 .unwrap()
8591 .clone()
8592 .with(|ctx| {
8593 let global = ctx.globals();
8594 let result: u32 = global.get("_splitId").unwrap();
8595 assert_eq!(result, 7);
8596 });
8597 }
8598
8599 #[test]
8602 fn test_api_file_exists() {
8603 let (mut backend, _rx) = create_test_backend();
8604
8605 backend
8606 .execute_js(
8607 r#"
8608 const editor = getEditor();
8609 // Test with a path that definitely exists
8610 globalThis._exists = editor.fileExists("/");
8611 "#,
8612 "test.js",
8613 )
8614 .unwrap();
8615
8616 backend
8617 .plugin_contexts
8618 .borrow()
8619 .get("test")
8620 .unwrap()
8621 .clone()
8622 .with(|ctx| {
8623 let global = ctx.globals();
8624 let result: bool = global.get("_exists").unwrap();
8625 assert!(result);
8626 });
8627 }
8628
8629 #[test]
8630 fn test_api_parse_jsonc() {
8631 let (mut backend, _rx) = create_test_backend();
8632
8633 backend
8634 .execute_js(
8635 r#"
8636 const editor = getEditor();
8637 // Comments, trailing commas, and nested structures should all parse.
8638 const parsed = editor.parseJsonc(`{
8639 // name of the container
8640 "name": "test",
8641 "features": {
8642 "docker-in-docker": {},
8643 },
8644 /* forwarded port list */
8645 "forwardPorts": [3000, 8080,],
8646 }`);
8647 globalThis._name = parsed.name;
8648 globalThis._featureCount = Object.keys(parsed.features).length;
8649 globalThis._portCount = parsed.forwardPorts.length;
8650
8651 // Invalid JSONC should throw.
8652 try {
8653 editor.parseJsonc("{ broken");
8654 globalThis._threw = false;
8655 } catch (_e) {
8656 globalThis._threw = true;
8657 }
8658 "#,
8659 "test.js",
8660 )
8661 .unwrap();
8662
8663 backend
8664 .plugin_contexts
8665 .borrow()
8666 .get("test")
8667 .unwrap()
8668 .clone()
8669 .with(|ctx| {
8670 let global = ctx.globals();
8671 let name: String = global.get("_name").unwrap();
8672 let feature_count: u32 = global.get("_featureCount").unwrap();
8673 let port_count: u32 = global.get("_portCount").unwrap();
8674 let threw: bool = global.get("_threw").unwrap();
8675 assert_eq!(name, "test");
8676 assert_eq!(feature_count, 1);
8677 assert_eq!(port_count, 2);
8678 assert!(threw, "Invalid JSONC should throw");
8679 });
8680 }
8681
8682 #[test]
8683 fn test_api_get_cwd() {
8684 let (mut backend, _rx) = create_test_backend();
8685
8686 backend
8687 .execute_js(
8688 r#"
8689 const editor = getEditor();
8690 globalThis._cwd = editor.getCwd();
8691 "#,
8692 "test.js",
8693 )
8694 .unwrap();
8695
8696 backend
8697 .plugin_contexts
8698 .borrow()
8699 .get("test")
8700 .unwrap()
8701 .clone()
8702 .with(|ctx| {
8703 let global = ctx.globals();
8704 let result: String = global.get("_cwd").unwrap();
8705 assert!(!result.is_empty());
8707 });
8708 }
8709
8710 #[test]
8711 fn test_api_get_env() {
8712 let (mut backend, _rx) = create_test_backend();
8713
8714 std::env::set_var("TEST_PLUGIN_VAR", "test_value");
8716
8717 backend
8718 .execute_js(
8719 r#"
8720 const editor = getEditor();
8721 globalThis._envVal = editor.getEnv("TEST_PLUGIN_VAR");
8722 "#,
8723 "test.js",
8724 )
8725 .unwrap();
8726
8727 backend
8728 .plugin_contexts
8729 .borrow()
8730 .get("test")
8731 .unwrap()
8732 .clone()
8733 .with(|ctx| {
8734 let global = ctx.globals();
8735 let result: Option<String> = global.get("_envVal").unwrap();
8736 assert_eq!(result, Some("test_value".to_string()));
8737 });
8738
8739 std::env::remove_var("TEST_PLUGIN_VAR");
8740 }
8741
8742 #[test]
8743 fn test_api_get_config() {
8744 let (mut backend, _rx) = create_test_backend();
8745
8746 backend
8747 .execute_js(
8748 r#"
8749 const editor = getEditor();
8750 const config = editor.getConfig();
8751 globalThis._isObject = typeof config === 'object';
8752 "#,
8753 "test.js",
8754 )
8755 .unwrap();
8756
8757 backend
8758 .plugin_contexts
8759 .borrow()
8760 .get("test")
8761 .unwrap()
8762 .clone()
8763 .with(|ctx| {
8764 let global = ctx.globals();
8765 let is_object: bool = global.get("_isObject").unwrap();
8766 assert!(is_object);
8768 });
8769 }
8770
8771 #[test]
8772 fn test_api_get_themes_dir() {
8773 let (mut backend, _rx) = create_test_backend();
8774
8775 backend
8776 .execute_js(
8777 r#"
8778 const editor = getEditor();
8779 globalThis._themesDir = editor.getThemesDir();
8780 "#,
8781 "test.js",
8782 )
8783 .unwrap();
8784
8785 backend
8786 .plugin_contexts
8787 .borrow()
8788 .get("test")
8789 .unwrap()
8790 .clone()
8791 .with(|ctx| {
8792 let global = ctx.globals();
8793 let result: String = global.get("_themesDir").unwrap();
8794 assert!(!result.is_empty());
8796 });
8797 }
8798
8799 #[test]
8802 fn test_api_read_dir() {
8803 let (mut backend, _rx) = create_test_backend();
8804
8805 backend
8806 .execute_js(
8807 r#"
8808 const editor = getEditor();
8809 const entries = editor.readDir("/tmp");
8810 globalThis._isArray = Array.isArray(entries);
8811 globalThis._length = entries.length;
8812 "#,
8813 "test.js",
8814 )
8815 .unwrap();
8816
8817 backend
8818 .plugin_contexts
8819 .borrow()
8820 .get("test")
8821 .unwrap()
8822 .clone()
8823 .with(|ctx| {
8824 let global = ctx.globals();
8825 let is_array: bool = global.get("_isArray").unwrap();
8826 let length: u32 = global.get("_length").unwrap();
8827 assert!(is_array);
8829 let _ = length;
8831 });
8832 }
8833
8834 #[test]
8837 fn test_api_execute_action() {
8838 let (mut backend, rx) = create_test_backend();
8839
8840 backend
8841 .execute_js(
8842 r#"
8843 const editor = getEditor();
8844 editor.executeAction("move_cursor_up");
8845 "#,
8846 "test.js",
8847 )
8848 .unwrap();
8849
8850 let cmd = rx.try_recv().unwrap();
8851 match cmd {
8852 PluginCommand::ExecuteAction { action_name } => {
8853 assert_eq!(action_name, "move_cursor_up");
8854 }
8855 _ => panic!("Expected ExecuteAction, got {:?}", cmd),
8856 }
8857 }
8858
8859 #[test]
8862 fn test_api_debug() {
8863 let (mut backend, _rx) = create_test_backend();
8864
8865 backend
8867 .execute_js(
8868 r#"
8869 const editor = getEditor();
8870 editor.debug("Test debug message");
8871 editor.debug("Another message with special chars: <>&\"'");
8872 "#,
8873 "test.js",
8874 )
8875 .unwrap();
8876 }
8878
8879 #[test]
8882 fn test_typescript_preamble_generated() {
8883 assert!(!JSEDITORAPI_TS_PREAMBLE.is_empty());
8885 assert!(JSEDITORAPI_TS_PREAMBLE.contains("declare function getEditor()"));
8886 assert!(JSEDITORAPI_TS_PREAMBLE.contains("ProcessHandle"));
8887 println!(
8888 "Generated {} bytes of TypeScript preamble",
8889 JSEDITORAPI_TS_PREAMBLE.len()
8890 );
8891 }
8892
8893 #[test]
8894 fn test_typescript_editor_api_generated() {
8895 assert!(!JSEDITORAPI_TS_EDITOR_API.is_empty());
8897 assert!(JSEDITORAPI_TS_EDITOR_API.contains("interface EditorAPI"));
8898 println!(
8899 "Generated {} bytes of EditorAPI interface",
8900 JSEDITORAPI_TS_EDITOR_API.len()
8901 );
8902 }
8903
8904 #[test]
8905 fn test_js_methods_list() {
8906 assert!(!JSEDITORAPI_JS_METHODS.is_empty());
8908 println!("Generated {} API methods", JSEDITORAPI_JS_METHODS.len());
8909 for (i, method) in JSEDITORAPI_JS_METHODS.iter().enumerate() {
8911 if i < 20 {
8912 println!(" - {}", method);
8913 }
8914 }
8915 if JSEDITORAPI_JS_METHODS.len() > 20 {
8916 println!(" ... and {} more", JSEDITORAPI_JS_METHODS.len() - 20);
8917 }
8918 }
8919
8920 #[test]
8923 fn test_api_load_plugin_sends_command() {
8924 let (mut backend, rx) = create_test_backend();
8925
8926 backend
8928 .execute_js(
8929 r#"
8930 const editor = getEditor();
8931 globalThis._loadPromise = editor.loadPlugin("/path/to/plugin.ts");
8932 "#,
8933 "test.js",
8934 )
8935 .unwrap();
8936
8937 let cmd = rx.try_recv().unwrap();
8939 match cmd {
8940 PluginCommand::LoadPlugin { path, callback_id } => {
8941 assert_eq!(path.to_str().unwrap(), "/path/to/plugin.ts");
8942 assert!(callback_id.0 > 0); }
8944 _ => panic!("Expected LoadPlugin, got {:?}", cmd),
8945 }
8946 }
8947
8948 #[test]
8949 fn test_api_unload_plugin_sends_command() {
8950 let (mut backend, rx) = create_test_backend();
8951
8952 backend
8954 .execute_js(
8955 r#"
8956 const editor = getEditor();
8957 globalThis._unloadPromise = editor.unloadPlugin("my-plugin");
8958 "#,
8959 "test.js",
8960 )
8961 .unwrap();
8962
8963 let cmd = rx.try_recv().unwrap();
8965 match cmd {
8966 PluginCommand::UnloadPlugin { name, callback_id } => {
8967 assert_eq!(name, "my-plugin");
8968 assert!(callback_id.0 > 0); }
8970 _ => panic!("Expected UnloadPlugin, got {:?}", cmd),
8971 }
8972 }
8973
8974 #[test]
8975 fn test_api_reload_plugin_sends_command() {
8976 let (mut backend, rx) = create_test_backend();
8977
8978 backend
8980 .execute_js(
8981 r#"
8982 const editor = getEditor();
8983 globalThis._reloadPromise = editor.reloadPlugin("my-plugin");
8984 "#,
8985 "test.js",
8986 )
8987 .unwrap();
8988
8989 let cmd = rx.try_recv().unwrap();
8991 match cmd {
8992 PluginCommand::ReloadPlugin { name, callback_id } => {
8993 assert_eq!(name, "my-plugin");
8994 assert!(callback_id.0 > 0); }
8996 _ => panic!("Expected ReloadPlugin, got {:?}", cmd),
8997 }
8998 }
8999
9000 #[test]
9001 fn test_api_load_plugin_resolves_callback() {
9002 let (mut backend, rx) = create_test_backend();
9003
9004 backend
9006 .execute_js(
9007 r#"
9008 const editor = getEditor();
9009 globalThis._loadResult = null;
9010 editor.loadPlugin("/path/to/plugin.ts").then(result => {
9011 globalThis._loadResult = result;
9012 });
9013 "#,
9014 "test.js",
9015 )
9016 .unwrap();
9017
9018 let callback_id = match rx.try_recv().unwrap() {
9020 PluginCommand::LoadPlugin { callback_id, .. } => callback_id,
9021 cmd => panic!("Expected LoadPlugin, got {:?}", cmd),
9022 };
9023
9024 backend.resolve_callback(callback_id, "true");
9026
9027 backend
9029 .plugin_contexts
9030 .borrow()
9031 .get("test")
9032 .unwrap()
9033 .clone()
9034 .with(|ctx| {
9035 run_pending_jobs_checked(&ctx, "test async loadPlugin");
9036 });
9037
9038 backend
9040 .plugin_contexts
9041 .borrow()
9042 .get("test")
9043 .unwrap()
9044 .clone()
9045 .with(|ctx| {
9046 let global = ctx.globals();
9047 let result: bool = global.get("_loadResult").unwrap();
9048 assert!(result);
9049 });
9050 }
9051
9052 #[test]
9053 fn test_api_version() {
9054 let (mut backend, _rx) = create_test_backend();
9055
9056 backend
9057 .execute_js(
9058 r#"
9059 const editor = getEditor();
9060 globalThis._apiVersion = editor.apiVersion();
9061 "#,
9062 "test.js",
9063 )
9064 .unwrap();
9065
9066 backend
9067 .plugin_contexts
9068 .borrow()
9069 .get("test")
9070 .unwrap()
9071 .clone()
9072 .with(|ctx| {
9073 let version: u32 = ctx.globals().get("_apiVersion").unwrap();
9074 assert_eq!(version, 2);
9075 });
9076 }
9077
9078 #[test]
9079 fn test_api_unload_plugin_rejects_on_error() {
9080 let (mut backend, rx) = create_test_backend();
9081
9082 backend
9084 .execute_js(
9085 r#"
9086 const editor = getEditor();
9087 globalThis._unloadError = null;
9088 editor.unloadPlugin("nonexistent-plugin").catch(err => {
9089 globalThis._unloadError = err.message || String(err);
9090 });
9091 "#,
9092 "test.js",
9093 )
9094 .unwrap();
9095
9096 let callback_id = match rx.try_recv().unwrap() {
9098 PluginCommand::UnloadPlugin { callback_id, .. } => callback_id,
9099 cmd => panic!("Expected UnloadPlugin, got {:?}", cmd),
9100 };
9101
9102 backend.reject_callback(callback_id, "Plugin 'nonexistent-plugin' not found");
9104
9105 backend
9107 .plugin_contexts
9108 .borrow()
9109 .get("test")
9110 .unwrap()
9111 .clone()
9112 .with(|ctx| {
9113 run_pending_jobs_checked(&ctx, "test async unloadPlugin");
9114 });
9115
9116 backend
9118 .plugin_contexts
9119 .borrow()
9120 .get("test")
9121 .unwrap()
9122 .clone()
9123 .with(|ctx| {
9124 let global = ctx.globals();
9125 let error: String = global.get("_unloadError").unwrap();
9126 assert!(error.contains("nonexistent-plugin"));
9127 });
9128 }
9129
9130 #[test]
9131 fn test_api_set_global_state() {
9132 let (mut backend, rx) = create_test_backend();
9133
9134 backend
9135 .execute_js(
9136 r#"
9137 const editor = getEditor();
9138 editor.setGlobalState("myKey", { enabled: true, count: 42 });
9139 "#,
9140 "test_plugin.js",
9141 )
9142 .unwrap();
9143
9144 let cmd = rx.try_recv().unwrap();
9145 match cmd {
9146 PluginCommand::SetGlobalState {
9147 plugin_name,
9148 key,
9149 value,
9150 } => {
9151 assert_eq!(plugin_name, "test_plugin");
9152 assert_eq!(key, "myKey");
9153 let v = value.unwrap();
9154 assert_eq!(v["enabled"], serde_json::json!(true));
9155 assert_eq!(v["count"], serde_json::json!(42));
9156 }
9157 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
9158 }
9159 }
9160
9161 #[test]
9162 fn test_api_set_global_state_delete() {
9163 let (mut backend, rx) = create_test_backend();
9164
9165 backend
9166 .execute_js(
9167 r#"
9168 const editor = getEditor();
9169 editor.setGlobalState("myKey", null);
9170 "#,
9171 "test_plugin.js",
9172 )
9173 .unwrap();
9174
9175 let cmd = rx.try_recv().unwrap();
9176 match cmd {
9177 PluginCommand::SetGlobalState {
9178 plugin_name,
9179 key,
9180 value,
9181 } => {
9182 assert_eq!(plugin_name, "test_plugin");
9183 assert_eq!(key, "myKey");
9184 assert!(value.is_none(), "null should delete the key");
9185 }
9186 _ => panic!("Expected SetGlobalState command, got {:?}", cmd),
9187 }
9188 }
9189
9190 #[test]
9191 fn test_api_get_global_state_roundtrip() {
9192 let (mut backend, _rx) = create_test_backend();
9193
9194 backend
9196 .execute_js(
9197 r#"
9198 const editor = getEditor();
9199 editor.setGlobalState("flag", true);
9200 globalThis._result = editor.getGlobalState("flag");
9201 "#,
9202 "test_plugin.js",
9203 )
9204 .unwrap();
9205
9206 backend
9207 .plugin_contexts
9208 .borrow()
9209 .get("test_plugin")
9210 .unwrap()
9211 .clone()
9212 .with(|ctx| {
9213 let global = ctx.globals();
9214 let result: bool = global.get("_result").unwrap();
9215 assert!(
9216 result,
9217 "getGlobalState should return the value set by setGlobalState"
9218 );
9219 });
9220 }
9221
9222 #[test]
9227 fn test_api_set_session_state_roundtrip() {
9228 let (mut backend, _rx) = create_test_backend();
9229
9230 backend
9231 .execute_js(
9232 r#"
9233 const editor = getEditor();
9234 editor.setWindowState("draft", { count: 7 });
9235 globalThis._result = editor.getWindowState("draft");
9236 globalThis._missing = editor.getWindowState("absent");
9237 "#,
9238 "test_plugin.js",
9239 )
9240 .unwrap();
9241
9242 backend
9243 .plugin_contexts
9244 .borrow()
9245 .get("test_plugin")
9246 .unwrap()
9247 .clone()
9248 .with(|ctx| {
9249 let global = ctx.globals();
9250 let count: i64 = global
9251 .get::<_, rquickjs::Object>("_result")
9252 .unwrap()
9253 .get("count")
9254 .unwrap();
9255 assert_eq!(
9256 count, 7,
9257 "getWindowState should return the value set by setWindowState"
9258 );
9259 let missing = global.get::<_, rquickjs::Value>("_missing").unwrap();
9260 assert!(
9261 missing.is_undefined(),
9262 "getWindowState for an unset key must be undefined"
9263 );
9264 });
9265 }
9266
9267 #[test]
9268 fn test_api_get_global_state_missing_key() {
9269 let (mut backend, _rx) = create_test_backend();
9270
9271 backend
9272 .execute_js(
9273 r#"
9274 const editor = getEditor();
9275 globalThis._result = editor.getGlobalState("nonexistent");
9276 globalThis._isUndefined = (editor.getGlobalState("nonexistent") === undefined);
9277 "#,
9278 "test_plugin.js",
9279 )
9280 .unwrap();
9281
9282 backend
9283 .plugin_contexts
9284 .borrow()
9285 .get("test_plugin")
9286 .unwrap()
9287 .clone()
9288 .with(|ctx| {
9289 let global = ctx.globals();
9290 let is_undefined: bool = global.get("_isUndefined").unwrap();
9291 assert!(
9292 is_undefined,
9293 "getGlobalState for missing key should return undefined"
9294 );
9295 });
9296 }
9297
9298 #[test]
9299 fn test_api_global_state_isolation_between_plugins() {
9300 let (tx, _rx) = mpsc::channel();
9302 let state_snapshot = Arc::new(RwLock::new(EditorStateSnapshot::new()));
9303 let services = Arc::new(TestServiceBridge::new());
9304
9305 let mut backend_a =
9307 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
9308 .unwrap();
9309 backend_a
9310 .execute_js(
9311 r#"
9312 const editor = getEditor();
9313 editor.setGlobalState("flag", "from_plugin_a");
9314 "#,
9315 "plugin_a.js",
9316 )
9317 .unwrap();
9318
9319 let mut backend_b =
9321 QuickJsBackend::with_state(state_snapshot.clone(), tx.clone(), services.clone())
9322 .unwrap();
9323 backend_b
9324 .execute_js(
9325 r#"
9326 const editor = getEditor();
9327 editor.setGlobalState("flag", "from_plugin_b");
9328 "#,
9329 "plugin_b.js",
9330 )
9331 .unwrap();
9332
9333 backend_a
9335 .execute_js(
9336 r#"
9337 const editor = getEditor();
9338 globalThis._aValue = editor.getGlobalState("flag");
9339 "#,
9340 "plugin_a.js",
9341 )
9342 .unwrap();
9343
9344 backend_a
9345 .plugin_contexts
9346 .borrow()
9347 .get("plugin_a")
9348 .unwrap()
9349 .clone()
9350 .with(|ctx| {
9351 let global = ctx.globals();
9352 let a_value: String = global.get("_aValue").unwrap();
9353 assert_eq!(
9354 a_value, "from_plugin_a",
9355 "Plugin A should see its own value, not plugin B's"
9356 );
9357 });
9358
9359 backend_b
9361 .execute_js(
9362 r#"
9363 const editor = getEditor();
9364 globalThis._bValue = editor.getGlobalState("flag");
9365 "#,
9366 "plugin_b.js",
9367 )
9368 .unwrap();
9369
9370 backend_b
9371 .plugin_contexts
9372 .borrow()
9373 .get("plugin_b")
9374 .unwrap()
9375 .clone()
9376 .with(|ctx| {
9377 let global = ctx.globals();
9378 let b_value: String = global.get("_bValue").unwrap();
9379 assert_eq!(
9380 b_value, "from_plugin_b",
9381 "Plugin B should see its own value, not plugin A's"
9382 );
9383 });
9384 }
9385
9386 #[test]
9387 fn test_register_command_collision_different_plugins() {
9388 let (mut backend, _rx) = create_test_backend();
9389
9390 backend
9392 .execute_js(
9393 r#"
9394 const editor = getEditor();
9395 globalThis.handlerA = function() { };
9396 editor.registerCommand("My Command", "From A", "handlerA", null);
9397 "#,
9398 "plugin_a.js",
9399 )
9400 .unwrap();
9401
9402 let result = backend.execute_js(
9404 r#"
9405 const editor = getEditor();
9406 globalThis.handlerB = function() { };
9407 editor.registerCommand("My Command", "From B", "handlerB", null);
9408 "#,
9409 "plugin_b.js",
9410 );
9411
9412 assert!(
9413 result.is_err(),
9414 "Second plugin registering the same command name should fail"
9415 );
9416 let err_msg = result.unwrap_err().to_string();
9417 assert!(
9418 err_msg.contains("already registered"),
9419 "Error should mention collision: {}",
9420 err_msg
9421 );
9422 }
9423
9424 #[test]
9425 fn test_register_command_same_plugin_allowed() {
9426 let (mut backend, _rx) = create_test_backend();
9427
9428 backend
9430 .execute_js(
9431 r#"
9432 const editor = getEditor();
9433 globalThis.handler1 = function() { };
9434 editor.registerCommand("My Command", "Version 1", "handler1", null);
9435 globalThis.handler2 = function() { };
9436 editor.registerCommand("My Command", "Version 2", "handler2", null);
9437 "#,
9438 "plugin_a.js",
9439 )
9440 .unwrap();
9441 }
9442
9443 #[test]
9444 fn test_register_command_after_unregister() {
9445 let (mut backend, _rx) = create_test_backend();
9446
9447 backend
9449 .execute_js(
9450 r#"
9451 const editor = getEditor();
9452 globalThis.handlerA = function() { };
9453 editor.registerCommand("My Command", "From A", "handlerA", null);
9454 editor.unregisterCommand("My Command");
9455 "#,
9456 "plugin_a.js",
9457 )
9458 .unwrap();
9459
9460 backend
9462 .execute_js(
9463 r#"
9464 const editor = getEditor();
9465 globalThis.handlerB = function() { };
9466 editor.registerCommand("My Command", "From B", "handlerB", null);
9467 "#,
9468 "plugin_b.js",
9469 )
9470 .unwrap();
9471 }
9472
9473 #[test]
9474 fn test_register_command_collision_caught_in_try_catch() {
9475 let (mut backend, _rx) = create_test_backend();
9476
9477 backend
9479 .execute_js(
9480 r#"
9481 const editor = getEditor();
9482 globalThis.handlerA = function() { };
9483 editor.registerCommand("My Command", "From A", "handlerA", null);
9484 "#,
9485 "plugin_a.js",
9486 )
9487 .unwrap();
9488
9489 backend
9491 .execute_js(
9492 r#"
9493 const editor = getEditor();
9494 globalThis.handlerB = function() { };
9495 let caught = false;
9496 try {
9497 editor.registerCommand("My Command", "From B", "handlerB", null);
9498 } catch (e) {
9499 caught = true;
9500 }
9501 if (!caught) throw new Error("Expected collision error");
9502 "#,
9503 "plugin_b.js",
9504 )
9505 .unwrap();
9506 }
9507
9508 #[test]
9509 fn test_register_command_i18n_key_no_collision_across_plugins() {
9510 let (mut backend, _rx) = create_test_backend();
9511
9512 backend
9514 .execute_js(
9515 r#"
9516 const editor = getEditor();
9517 globalThis.handlerA = function() { };
9518 editor.registerCommand("%cmd.reload", "Reload A", "handlerA", null);
9519 "#,
9520 "plugin_a.js",
9521 )
9522 .unwrap();
9523
9524 backend
9527 .execute_js(
9528 r#"
9529 const editor = getEditor();
9530 globalThis.handlerB = function() { };
9531 editor.registerCommand("%cmd.reload", "Reload B", "handlerB", null);
9532 "#,
9533 "plugin_b.js",
9534 )
9535 .unwrap();
9536 }
9537
9538 #[test]
9539 fn test_register_command_non_i18n_still_collides() {
9540 let (mut backend, _rx) = create_test_backend();
9541
9542 backend
9544 .execute_js(
9545 r#"
9546 const editor = getEditor();
9547 globalThis.handlerA = function() { };
9548 editor.registerCommand("My Reload", "Reload A", "handlerA", null);
9549 "#,
9550 "plugin_a.js",
9551 )
9552 .unwrap();
9553
9554 let result = backend.execute_js(
9556 r#"
9557 const editor = getEditor();
9558 globalThis.handlerB = function() { };
9559 editor.registerCommand("My Reload", "Reload B", "handlerB", null);
9560 "#,
9561 "plugin_b.js",
9562 );
9563
9564 assert!(
9565 result.is_err(),
9566 "Non-%-prefixed names should still collide across plugins"
9567 );
9568 }
9569}